diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index e53fe38..784af16 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -1,109 +1,141 @@ -name: Build Android APK - -on: - workflow_dispatch: - inputs: - release_tag: - description: GitHub Release tag to upload the APK to - required: false - default: v0.1.0 - push: - tags: - - 'v*' - -permissions: - contents: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - build-apk: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Recreate Android platform project - working-directory: mobile_agent - run: | - rm -rf android - flutter create --platforms=android --project-name mobile_agent --org com.mobilecode . - python3 tooling/prepare_android_project.py - - - name: Resolve Flutter dependencies - working-directory: mobile_agent - run: flutter pub get - +name: Build Android APK + +on: + workflow_dispatch: + inputs: + release_tag: + description: GitHub Release tag to upload the APK to + required: false + default: v0.1.39 + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build-apk: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Recreate Android platform project + working-directory: mobile_agent + run: | + rm -rf android + flutter create --platforms=android --project-name mobile_agent --org com.mobilecode . + python3 tooling/prepare_android_project.py + + - name: Resolve Flutter dependencies + working-directory: mobile_agent + run: flutter pub get + - name: Analyze Flutter sources working-directory: mobile_agent - run: flutter analyze lib/main.dart lib/screens/home_screen.dart --no-fatal-infos --no-fatal-warnings - - - name: Configure stable Android release signing - working-directory: mobile_agent - env: - MOBILECODE_RELEASE_KEYSTORE_BASE64: ${{ secrets.MOBILECODE_RELEASE_KEYSTORE_BASE64 }} - MOBILECODE_RELEASE_STORE_PASSWORD: ${{ secrets.MOBILECODE_RELEASE_STORE_PASSWORD }} - MOBILECODE_RELEASE_KEY_ALIAS: ${{ secrets.MOBILECODE_RELEASE_KEY_ALIAS }} - MOBILECODE_RELEASE_KEY_PASSWORD: ${{ secrets.MOBILECODE_RELEASE_KEY_PASSWORD }} - run: | - if [ -n "$MOBILECODE_RELEASE_KEYSTORE_BASE64" ] && [ -n "$MOBILECODE_RELEASE_STORE_PASSWORD" ] && [ -n "$MOBILECODE_RELEASE_KEY_ALIAS" ] && [ -n "$MOBILECODE_RELEASE_KEY_PASSWORD" ]; then - mkdir -p android/app - KEYSTORE_PATH="$PWD/android/app/mobilecode-release.jks" - printf '%s' "$MOBILECODE_RELEASE_KEYSTORE_BASE64" | base64 --decode > "$KEYSTORE_PATH" - { - echo "storeFile=$KEYSTORE_PATH" - echo "storePassword=$MOBILECODE_RELEASE_STORE_PASSWORD" - echo "keyAlias=$MOBILECODE_RELEASE_KEY_ALIAS" - echo "keyPassword=$MOBILECODE_RELEASE_KEY_PASSWORD" - } > android/key.properties - echo "Stable release signing configured." - else - echo "Release signing secrets are not fully configured; falling back to Flutter debug signing." - fi - - - name: Build release APK - working-directory: mobile_agent - env: - MOBILECODE_MANAGED_API_KEY: ${{ secrets.MOBILECODE_MANAGED_API_KEY }} - run: | - if [ -n "$MOBILECODE_MANAGED_API_KEY" ]; then - flutter build apk --release --target lib/main.dart \ - --dart-define=MOBILECODE_MANAGED_PROVIDER=true \ - --dart-define=MOBILECODE_MANAGED_BASE_URL=https://token-plan-cn.xiaomimimo.com/anthropic \ - --dart-define=MOBILECODE_MANAGED_MODEL=mimo-v2.5-pro \ - --dart-define=MOBILECODE_MANAGED_API_KEY="$MOBILECODE_MANAGED_API_KEY" - else - flutter build apk --release --target lib/main.dart - fi - - - name: Stage APK - run: | - mkdir -p artifacts - cp mobile_agent/build/app/outputs/flutter-apk/app-release.apk artifacts/mobilecode-v0.1.0.apk - - - name: Upload workflow artifact - uses: actions/upload-artifact@v4 - with: - name: mobilecode-apk - path: artifacts/mobilecode-v0.1.0.apk - - - name: Upload APK to GitHub Release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.inputs.release_tag || github.ref_name }} - run: | - gh release upload "$RELEASE_TAG" artifacts/mobilecode-v0.1.0.apk --clobber --repo "$GITHUB_REPOSITORY" + run: flutter analyze lib/main.dart lib/screens/home_screen.dart lib/screens/github_screen.dart lib/screens/github_repo_hub_screen.dart lib/screens/role_manager_screen.dart lib/screens/api_usage_screen.dart lib/screens/device_telemetry_screen.dart lib/services/github_deep_service.dart lib/services/github_oauth_flow.dart lib/services/role_library_service.dart lib/services/token_usage_service.dart lib/services/token_pricing_service.dart lib/services/device_telemetry_service.dart --no-fatal-infos --no-fatal-warnings + + - name: Configure stable Android release signing + working-directory: mobile_agent + env: + MOBILECODE_RELEASE_KEYSTORE_BASE64: ${{ secrets.MOBILECODE_RELEASE_KEYSTORE_BASE64 }} + MOBILECODE_RELEASE_STORE_PASSWORD: ${{ secrets.MOBILECODE_RELEASE_STORE_PASSWORD }} + MOBILECODE_RELEASE_KEY_ALIAS: ${{ secrets.MOBILECODE_RELEASE_KEY_ALIAS }} + MOBILECODE_RELEASE_KEY_PASSWORD: ${{ secrets.MOBILECODE_RELEASE_KEY_PASSWORD }} + run: | + if [ -n "$MOBILECODE_RELEASE_KEYSTORE_BASE64" ] && [ -n "$MOBILECODE_RELEASE_STORE_PASSWORD" ] && [ -n "$MOBILECODE_RELEASE_KEY_ALIAS" ] && [ -n "$MOBILECODE_RELEASE_KEY_PASSWORD" ]; then + mkdir -p android/app + KEYSTORE_PATH="$PWD/android/app/mobilecode-release.jks" + printf '%s' "$MOBILECODE_RELEASE_KEYSTORE_BASE64" | base64 --decode > "$KEYSTORE_PATH" + { + echo "storeFile=$KEYSTORE_PATH" + echo "storePassword=$MOBILECODE_RELEASE_STORE_PASSWORD" + echo "keyAlias=$MOBILECODE_RELEASE_KEY_ALIAS" + echo "keyPassword=$MOBILECODE_RELEASE_KEY_PASSWORD" + } > android/key.properties + echo "Stable release signing configured." + else + echo "Release signing secrets are not fully configured; falling back to Flutter debug signing." + fi + + - name: Build release APK + working-directory: mobile_agent + env: + MOBILECODE_MANAGED_API_KEY: ${{ secrets.MOBILECODE_MANAGED_API_KEY }} + MOBILECODE_GITHUB_OAUTH_CLIENT_ID: ${{ secrets.MOBILECODE_GITHUB_OAUTH_CLIENT_ID }} + MOBILECODE_GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.MOBILECODE_GITHUB_OAUTH_CLIENT_SECRET }} + MOBILECODE_GITHUB_OAUTH_REDIRECT_URI: ${{ vars.MOBILECODE_GITHUB_OAUTH_REDIRECT_URI }} + run: | + DART_DEFINES=() + if [ -n "$MOBILECODE_GITHUB_OAUTH_CLIENT_ID" ]; then + DART_DEFINES+=(--dart-define=MOBILECODE_GITHUB_OAUTH_CLIENT_ID="$MOBILECODE_GITHUB_OAUTH_CLIENT_ID") + fi + if [ -n "$MOBILECODE_GITHUB_OAUTH_CLIENT_SECRET" ]; then + DART_DEFINES+=(--dart-define=MOBILECODE_GITHUB_OAUTH_CLIENT_SECRET="$MOBILECODE_GITHUB_OAUTH_CLIENT_SECRET") + fi + if [ -n "$MOBILECODE_GITHUB_OAUTH_REDIRECT_URI" ]; then + DART_DEFINES+=(--dart-define=MOBILECODE_GITHUB_OAUTH_REDIRECT_URI="$MOBILECODE_GITHUB_OAUTH_REDIRECT_URI") + fi + if [ -n "$MOBILECODE_MANAGED_API_KEY" ]; then + flutter build apk --release --target lib/main.dart \ + --dart-define=MOBILECODE_MANAGED_PROVIDER=true \ + --dart-define=MOBILECODE_MANAGED_BASE_URL=https://token-plan-cn.xiaomimimo.com/anthropic \ + --dart-define=MOBILECODE_MANAGED_MODEL=mimo-v2.5-pro \ + --dart-define=MOBILECODE_MANAGED_API_KEY="$MOBILECODE_MANAGED_API_KEY" \ + "${DART_DEFINES[@]}" + else + flutter build apk --release --target lib/main.dart "${DART_DEFINES[@]}" + fi + + - name: Stage APK + id: stage_apk + env: + RELEASE_TAG: ${{ github.event.inputs.release_tag || github.ref_name || 'v0.1.39' }} + run: | + mkdir -p artifacts + APK_PATH="artifacts/mobilecode-${RELEASE_TAG}.apk" + cp mobile_agent/build/app/outputs/flutter-apk/app-release.apk "$APK_PATH" + echo "apk_path=$APK_PATH" >> "$GITHUB_OUTPUT" + + - name: Upload workflow artifact + uses: actions/upload-artifact@v7 + with: + name: mobilecode-apk + path: ${{ steps.stage_apk.outputs.apk_path }} + + - name: Ensure GitHub Release exists + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.inputs.release_tag || github.ref_name || 'v0.1.39' }} + run: | + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists." + else + gh release create "$RELEASE_TAG" \ + --target "$GITHUB_SHA" \ + --title "MobileCode $RELEASE_TAG" \ + --notes "Automated MobileCode Android APK build." \ + --prerelease \ + --repo "$GITHUB_REPOSITORY" + fi + + - name: Upload APK to GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.inputs.release_tag || github.ref_name || 'v0.1.39' }} + run: | + gh release upload "$RELEASE_TAG" "${{ steps.stage_apk.outputs.apk_path }}" --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/android-app-test.yml b/.github/workflows/android-app-test.yml index 461378c..4615626 100644 --- a/.github/workflows/android-app-test.yml +++ b/.github/workflows/android-app-test.yml @@ -1,101 +1,89 @@ -name: Android App Smoke Test - -on: - workflow_dispatch: - push: - branches: - - main - paths: - - 'mobile_agent/**' - - '.github/workflows/android-app-test.yml' - - '.github/workflows/android-apk.yml' - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - emulator-smoke: - runs-on: ubuntu-latest - timeout-minutes: 35 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Recreate Android platform project - working-directory: mobile_agent - run: | - rm -rf android - flutter create --platforms=android --project-name mobile_agent --org com.mobilecode . - python3 tooling/prepare_android_project.py - - - name: Resolve Flutter dependencies - working-directory: mobile_agent - run: flutter pub get - +name: Android App Smoke Test + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'mobile_agent/**' + - '.github/workflows/android-app-test.yml' + - '.github/workflows/android-apk.yml' + +permissions: + contents: read + +jobs: + emulator-smoke: + runs-on: ubuntu-latest + timeout-minutes: 35 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Recreate Android platform project + working-directory: mobile_agent + run: | + rm -rf android + flutter create --platforms=android --project-name mobile_agent --org com.mobilecode . + python3 tooling/prepare_android_project.py + + - name: Resolve Flutter dependencies + working-directory: mobile_agent + run: flutter pub get + - name: Analyze Flutter entry surfaces working-directory: mobile_agent - run: flutter analyze lib/main.dart lib/screens/home_screen.dart --no-fatal-infos --no-fatal-warnings - - - name: Build debug APK for emulator - working-directory: mobile_agent - run: flutter build apk --debug --target lib/main.dart - - - name: Run APK on Android emulator - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - arch: x86_64 - profile: pixel_2 - target: default - disable-animations: true - script: | - set -eux - mkdir -p artifacts - adb wait-for-device - installed=0; for attempt in 1 2 3; do if adb install -r mobile_agent/build/app/outputs/flutter-apk/app-debug.apk; then installed=1; break; fi; adb kill-server; adb start-server; adb wait-for-device; sleep 20; done; test "$installed" = 1 - adb shell am start -n com.mobilecode.mobile_agent/.MobileCodeHelperLauncherActivity - adb forward tcp:18765 tcp:8765 + run: flutter analyze lib/main.dart lib/screens/home_screen.dart lib/screens/github_screen.dart lib/screens/github_repo_hub_screen.dart lib/screens/role_manager_screen.dart lib/screens/api_usage_screen.dart lib/screens/device_telemetry_screen.dart lib/services/github_deep_service.dart lib/services/github_oauth_flow.dart lib/services/role_library_service.dart lib/services/token_usage_service.dart lib/services/token_pricing_service.dart lib/services/device_telemetry_service.dart --no-fatal-infos --no-fatal-warnings + + - name: Build debug APK for emulator + working-directory: mobile_agent + run: flutter build apk --debug --target lib/main.dart + + - name: Run APK on Android emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + arch: x86_64 + profile: pixel_2 + target: default + disable-animations: true + script: | + set -eux + mkdir -p artifacts + adb wait-for-device + installed=0; for attempt in 1 2 3; do if adb install -r mobile_agent/build/app/outputs/flutter-apk/app-debug.apk; then installed=1; break; fi; adb kill-server; adb start-server; adb wait-for-device; sleep 20; done; test "$installed" = 1 + adb shell am start -n com.mobilecode.mobile_agent/.MobileCodeHelperLauncherActivity + adb forward tcp:18765 tcp:8765 helper_ready=0; for attempt in $(seq 1 20); do if curl -fsS http://127.0.0.1:18765/v1/health > artifacts/android-helper-health.json; then helper_ready=1; break; fi; sleep 1; done; if [ "$helper_ready" != 1 ]; then adb shell dumpsys activity services com.mobilecode.mobile_agent > artifacts/helper-services.txt || true; adb logcat -d -t 1500 > artifacts/android-logcat.txt || true; exit 1; fi curl -fsS http://127.0.0.1:18765/v1/health | tee artifacts/android-helper-health.json - curl -fsS \ - -H 'Content-Type: application/json' \ - -X POST http://127.0.0.1:18765/v1/execute \ - -d '{"command":"pwd","timeoutMs":10000}' \ - | tee artifacts/android-helper-execute.json + curl -fsS -H 'Content-Type: application/json' -X POST http://127.0.0.1:18765/v1/execute -d '{"command":"pwd","timeoutMs":10000}' | tee artifacts/android-helper-execute.json curl -fsS http://127.0.0.1:18765/v1/tasks/current | tee artifacts/android-helper-task.json - grep '"name":"MobileCode Helper Service"' artifacts/android-helper-health.json - grep '"ready":true' artifacts/android-helper-health.json - grep '"backgroundService":true' artifacts/android-helper-health.json - grep '"exitCode":0' artifacts/android-helper-execute.json - grep '"failureKind":"none"' artifacts/android-helper-execute.json - adb shell monkey -p com.mobilecode.mobile_agent -c android.intent.category.LAUNCHER 1 - for attempt in $(seq 1 18); do adb shell pidof com.mobilecode.mobile_agent; adb shell dumpsys window windows > artifacts/window-focus.txt || true; if grep -q "com.mobilecode.mobile_agent" artifacts/window-focus.txt && ! grep -q "Splash Screen com.mobilecode.mobile_agent" artifacts/window-focus.txt; then break; fi; sleep 5; done - adb shell pidof com.mobilecode.mobile_agent - grep -q "com.mobilecode.mobile_agent" artifacts/window-focus.txt - ! grep -q "Splash Screen com.mobilecode.mobile_agent" artifacts/window-focus.txt - adb exec-out screencap -p > artifacts/mobilecode-android-smoke.png - adb logcat -d -t 1200 > artifacts/android-logcat.txt - grep -E "FATAL EXCEPTION|E AndroidRuntime|NoSuchMethodError|MissingPluginException|ANR in com.mobilecode.mobile_agent" artifacts/android-logcat.txt && exit 1 || true - - - name: Upload Android smoke artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: mobilecode-android-smoke - path: artifacts/ + grep '"name":"MobileCode Helper Service"' artifacts/android-helper-health.json + grep '"ready":true' artifacts/android-helper-health.json + grep '"backgroundService":true' artifacts/android-helper-health.json + grep '"exitCode":0' artifacts/android-helper-execute.json + grep '"failureKind":"none"' artifacts/android-helper-execute.json + adb logcat -c || true + adb shell am start -W -n com.mobilecode.mobile_agent/.MainActivity | tee artifacts/main-start.txt + app_drawn=0; for attempt in $(seq 1 24); do adb shell pidof com.mobilecode.mobile_agent; adb shell dumpsys window windows > artifacts/window-focus.txt || true; if awk '/Window #[0-9]+/ { in_app = ($0 ~ /com\.mobilecode\.mobile_agent\/com\.mobilecode\.mobile_agent\.MainActivity/) } in_app && /Surface: shown=true/ { ok=1 } END { exit ok ? 0 : 1 }' artifacts/window-focus.txt; then app_drawn=1; break; fi; sleep 5; done; echo "$app_drawn" > artifacts/app-drawn.txt; adb shell pidof com.mobilecode.mobile_agent; adb exec-out screencap -p > artifacts/mobilecode-android-smoke.png || true; adb logcat -d -t 2000 > artifacts/android-logcat.txt || true; grep -q "com.mobilecode.mobile_agent" artifacts/window-focus.txt; grep -E "FATAL EXCEPTION|E AndroidRuntime|NoSuchMethodError|MissingPluginException|ANR in com.mobilecode.mobile_agent" artifacts/android-logcat.txt && exit 1 || true; test "$app_drawn" = 1 + + - name: Upload Android smoke artifacts + uses: actions/upload-artifact@v7 + if: always() + with: + name: mobilecode-android-smoke + path: artifacts/ diff --git a/.github/workflows/ios-archive.yml b/.github/workflows/ios-archive.yml new file mode 100644 index 0000000..e3d0e55 --- /dev/null +++ b/.github/workflows/ios-archive.yml @@ -0,0 +1,182 @@ +name: Build iOS Archive + +on: + workflow_dispatch: + inputs: + release_tag: + description: GitHub Release tag to upload the unsigned iOS archive to + required: false + default: v0.1.39 + upload_to_release: + description: Upload archive zip to the GitHub Release + required: false + default: true + type: boolean + +permissions: + contents: write + +concurrency: + group: ios-archive-${{ github.ref }}-${{ github.event.inputs.release_tag || 'v0.1.39' }} + cancel-in-progress: false + +jobs: + build-ios-archive: + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Show Apple toolchain + run: | + xcodebuild -version + xcrun --find xcodebuild + + - name: Recreate iOS platform project + working-directory: mobile_agent + run: | + rm -rf ios + flutter create --platforms=ios --project-name mobile_agent --org com.mobilecode . + python3 tooling/prepare_ios_project.py + + - name: Resolve Flutter dependencies + working-directory: mobile_agent + run: flutter pub get + + - name: Analyze Flutter entry surfaces + working-directory: mobile_agent + run: flutter analyze lib/main.dart lib/screens/home_screen.dart lib/screens/role_manager_screen.dart lib/screens/api_usage_screen.dart lib/screens/device_telemetry_screen.dart lib/services/role_library_service.dart lib/services/token_usage_service.dart lib/services/token_pricing_service.dart lib/services/device_telemetry_service.dart --no-fatal-infos --no-fatal-warnings + + - name: Build unsigned iOS device app + working-directory: mobile_agent + env: + MOBILECODE_MANAGED_API_KEY: ${{ secrets.MOBILECODE_MANAGED_API_KEY }} + run: | + if [ -n "$MOBILECODE_MANAGED_API_KEY" ]; then + flutter build ios --release --no-codesign --target lib/main.dart \ + --dart-define=MOBILECODE_MANAGED_PROVIDER=true \ + --dart-define=MOBILECODE_MANAGED_BASE_URL=https://token-plan-cn.xiaomimimo.com/anthropic \ + --dart-define=MOBILECODE_MANAGED_MODEL=mimo-v2.5-pro \ + --dart-define=MOBILECODE_MANAGED_API_KEY="$MOBILECODE_MANAGED_API_KEY" + else + flutter build ios --release --no-codesign --target lib/main.dart + fi + + - name: Package unsigned xcarchive + working-directory: mobile_agent + env: + RELEASE_TAG: ${{ github.event.inputs.release_tag || 'v0.1.39' }} + run: | + set -euxo pipefail + mkdir -p ../artifacts + APP_PATH="build/ios/iphoneos/Runner.app" + ARCHIVE_PATH="../artifacts/MobileCode-${RELEASE_TAG}.xcarchive" + ZIP_PATH="../artifacts/mobilecode-ios-archive-${RELEASE_TAG}.xcarchive.zip" + + if [ ! -d "$APP_PATH" ]; then + echo "Expected iOS app bundle was not found at $APP_PATH" + exit 1 + fi + + rm -rf "$ARCHIVE_PATH" "$ZIP_PATH" + mkdir -p "$ARCHIVE_PATH/Products/Applications" + ditto "$APP_PATH" "$ARCHIVE_PATH/Products/Applications/Runner.app" + + DSYM_ROOT="build/ios/Release-iphoneos" + mkdir -p "$ARCHIVE_PATH/dSYMs" + copied_dsym=0 + for dsym in "$DSYM_ROOT"/*.dSYM; do + if [ -e "$dsym" ]; then + cp -R "$dsym" "$ARCHIVE_PATH/dSYMs/" + copied_dsym=1 + fi + done + if [ "$copied_dsym" -eq 0 ]; then + rmdir "$ARCHIVE_PATH/dSYMs" + fi + + BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_PATH/Info.plist")" + SHORT_VERSION="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$APP_PATH/Info.plist")" + BUILD_VERSION="$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$APP_PATH/Info.plist")" + CREATION_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + cat > "$ARCHIVE_PATH/Info.plist" < + + + + ApplicationProperties + + ApplicationPath + Applications/Runner.app + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleShortVersionString + ${SHORT_VERSION} + CFBundleVersion + ${BUILD_VERSION} + SigningIdentity + Unsigned CI archive + + ArchiveVersion + 2 + CreationDate + ${CREATION_DATE} + Name + MobileCode + SchemeName + Runner + + + PLIST + + plutil -lint "$ARCHIVE_PATH/Info.plist" + ditto -c -k --sequesterRsrc --keepParent "$ARCHIVE_PATH" "$ZIP_PATH" + { + echo "MobileCode iOS archive" + echo "Release tag: $RELEASE_TAG" + echo "Bundle ID: $BUNDLE_ID" + echo "Version: $SHORT_VERSION+$BUILD_VERSION" + echo "Signed: no" + echo "Installable on physical iPhone: no; export/sign this archive first." + } > ../artifacts/ios-archive-summary.txt + + - name: Upload iOS archive artifact + uses: actions/upload-artifact@v7 + if: always() + with: + name: mobilecode-ios-archive + path: artifacts/ + + - name: Ensure GitHub Release exists + if: ${{ github.event.inputs.upload_to_release != 'false' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.inputs.release_tag || 'v0.1.39' }} + run: | + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists." + else + gh release create "$RELEASE_TAG" \ + --target "$GITHUB_SHA" \ + --title "MobileCode $RELEASE_TAG" \ + --notes "Automated MobileCode unsigned iOS archive build." \ + --prerelease \ + --repo "$GITHUB_REPOSITORY" + fi + + - name: Upload iOS archive to GitHub Release + if: ${{ github.event.inputs.upload_to_release != 'false' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.inputs.release_tag || 'v0.1.39' }} + run: | + gh release upload "$RELEASE_TAG" artifacts/mobilecode-ios-archive-"$RELEASE_TAG".xcarchive.zip artifacts/ios-archive-summary.txt --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/ios-simulator.yml b/.github/workflows/ios-simulator.yml index 7d8f7a3..583821a 100644 --- a/.github/workflows/ios-simulator.yml +++ b/.github/workflows/ios-simulator.yml @@ -1,100 +1,277 @@ -name: Build iOS Simulator App - -on: - workflow_dispatch: - inputs: - release_tag: - description: GitHub Release tag to upload the iOS simulator build to - required: false - default: v0.1.0 - -permissions: - contents: write - -jobs: - build-ios-simulator: - runs-on: macos-latest - timeout-minutes: 45 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Show Apple toolchain - run: | - xcodebuild -version - xcrun simctl list runtimes - - - name: Recreate iOS platform project - working-directory: mobile_agent - run: | - rm -rf ios - flutter create --platforms=ios --project-name mobile_agent --org com.mobilecode . - python3 tooling/prepare_ios_project.py - - - name: Resolve Flutter dependencies - working-directory: mobile_agent - run: flutter pub get - - - name: Analyze Flutter entry surfaces - working-directory: mobile_agent - run: flutter analyze lib/main.dart lib/screens/home_screen.dart --no-fatal-infos --no-fatal-warnings - - - name: Build iOS simulator app - working-directory: mobile_agent - env: - MOBILECODE_MANAGED_API_KEY: ${{ secrets.MOBILECODE_MANAGED_API_KEY }} - run: | - if [ -n "$MOBILECODE_MANAGED_API_KEY" ]; then - flutter build ios --simulator --debug --target lib/main.dart \ - --dart-define=MOBILECODE_MANAGED_PROVIDER=true \ - --dart-define=MOBILECODE_MANAGED_BASE_URL=https://token-plan-cn.xiaomimimo.com/anthropic \ - --dart-define=MOBILECODE_MANAGED_MODEL=mimo-v2.5-pro \ - --dart-define=MOBILECODE_MANAGED_API_KEY="$MOBILECODE_MANAGED_API_KEY" - else - flutter build ios --simulator --debug --target lib/main.dart - fi - - - name: Install and launch on iOS simulator - working-directory: mobile_agent - run: | - set -euxo pipefail - mkdir -p ../artifacts - SIM_ID="$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ && /Shutdown/ {print $2; exit}')" - if [ -z "$SIM_ID" ]; then - xcrun simctl list devices - exit 1 - fi - xcrun simctl boot "$SIM_ID" || true - xcrun simctl bootstatus "$SIM_ID" -b - APP_PATH="build/ios/iphonesimulator/Runner.app" - BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_PATH/Info.plist")" - xcrun simctl install "$SIM_ID" "$APP_PATH" - xcrun simctl launch "$SIM_ID" "$BUNDLE_ID" - sleep 15 - xcrun simctl io "$SIM_ID" screenshot ../artifacts/mobilecode-ios-smoke.png - xcrun simctl spawn "$SIM_ID" log show --style compact --last 3m --predicate 'process == "Runner"' > ../artifacts/ios-runner.log || true - if grep -E "Terminating app due to uncaught exception|Fatal error|EXC_CRASH|SIGABRT" ../artifacts/ios-runner.log; then - exit 1 - fi - ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" ../artifacts/mobilecode-ios-simulator-v0.1.0.zip - - - name: Upload iOS simulator artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: mobilecode-ios-simulator - path: artifacts/ - - - name: Upload iOS simulator app to GitHub Release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.inputs.release_tag || 'v0.1.0' }} - run: | - gh release upload "$RELEASE_TAG" artifacts/mobilecode-ios-simulator-v0.1.0.zip --clobber --repo "$GITHUB_REPOSITORY" +name: Build iOS Simulator App + +on: + workflow_dispatch: + inputs: + release_tag: + description: GitHub Release tag to upload the iOS simulator build to + required: false + default: v0.1.39 + upload_to_release: + description: Upload simulator zip to the GitHub Release + required: false + default: true + type: boolean + build_archive: + description: Also build and upload an unsigned iOS xcarchive zip + required: false + default: true + type: boolean + +permissions: + contents: write + +concurrency: + group: ios-simulator-${{ github.ref }}-${{ github.event.inputs.release_tag || 'v0.1.39' }} + cancel-in-progress: false + +jobs: + build-ios-simulator: + runs-on: macos-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Show Apple toolchain + run: | + xcodebuild -version + xcrun simctl list runtimes + + - name: Recreate iOS platform project + working-directory: mobile_agent + run: | + rm -rf ios + flutter create --platforms=ios --project-name mobile_agent --org com.mobilecode . + python3 tooling/prepare_ios_project.py + + - name: Resolve Flutter dependencies + working-directory: mobile_agent + run: flutter pub get + + - name: Analyze Flutter entry surfaces + working-directory: mobile_agent + run: flutter analyze lib/main.dart lib/screens/home_screen.dart lib/screens/role_manager_screen.dart lib/screens/api_usage_screen.dart lib/screens/device_telemetry_screen.dart lib/services/role_library_service.dart lib/services/token_usage_service.dart lib/services/token_pricing_service.dart lib/services/device_telemetry_service.dart --no-fatal-infos --no-fatal-warnings + + - name: Build iOS simulator app + working-directory: mobile_agent + env: + MOBILECODE_MANAGED_API_KEY: ${{ secrets.MOBILECODE_MANAGED_API_KEY }} + run: | + if [ -n "$MOBILECODE_MANAGED_API_KEY" ]; then + flutter build ios --simulator --debug --target lib/main.dart \ + --dart-define=MOBILECODE_MANAGED_PROVIDER=true \ + --dart-define=MOBILECODE_MANAGED_BASE_URL=https://token-plan-cn.xiaomimimo.com/anthropic \ + --dart-define=MOBILECODE_MANAGED_MODEL=mimo-v2.5-pro \ + --dart-define=MOBILECODE_MANAGED_API_KEY="$MOBILECODE_MANAGED_API_KEY" + else + flutter build ios --simulator --debug --target lib/main.dart + fi + + - name: Install and launch on iOS simulator + working-directory: mobile_agent + env: + RELEASE_TAG: ${{ github.event.inputs.release_tag || 'v0.1.39' }} + run: | + set -euxo pipefail + mkdir -p ../artifacts + SIM_ID="$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/ && /Shutdown/ {print $2; exit}')" + if [ -z "$SIM_ID" ]; then + xcrun simctl list devices + exit 1 + fi + xcrun simctl boot "$SIM_ID" || true + xcrun simctl bootstatus "$SIM_ID" -b + APP_PATH="build/ios/iphonesimulator/Runner.app" + BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_PATH/Info.plist")" + xcrun simctl install "$SIM_ID" "$APP_PATH" + xcrun simctl launch "$SIM_ID" "$BUNDLE_ID" + sleep 15 + xcrun simctl io "$SIM_ID" screenshot ../artifacts/mobilecode-ios-smoke.png + xcrun simctl spawn "$SIM_ID" log show --style compact --last 3m --predicate 'process == "Runner"' > ../artifacts/ios-runner.log || true + if grep -E "Terminating app due to uncaught exception|Fatal error|EXC_CRASH|SIGABRT" ../artifacts/ios-runner.log; then + exit 1 + fi + ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "../artifacts/mobilecode-ios-simulator-${RELEASE_TAG}.zip" + + - name: Upload iOS simulator artifacts + uses: actions/upload-artifact@v7 + if: always() + with: + name: mobilecode-ios-simulator + path: artifacts/ + + - name: Upload iOS simulator app to GitHub Release + if: ${{ github.event.inputs.upload_to_release != 'false' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.inputs.release_tag || 'v0.1.39' }} + run: | + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists." + else + gh release create "$RELEASE_TAG" \ + --target "$GITHUB_SHA" \ + --title "MobileCode $RELEASE_TAG" \ + --notes "Automated MobileCode iOS simulator build." \ + --prerelease \ + --repo "$GITHUB_REPOSITORY" + fi + gh release upload "$RELEASE_TAG" artifacts/mobilecode-ios-simulator-"$RELEASE_TAG".zip --clobber --repo "$GITHUB_REPOSITORY" + + build-ios-archive: + if: ${{ github.event.inputs.build_archive != 'false' }} + runs-on: macos-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Show Apple toolchain + run: | + xcodebuild -version + xcrun --find xcodebuild + + - name: Recreate iOS platform project + working-directory: mobile_agent + run: | + rm -rf ios + flutter create --platforms=ios --project-name mobile_agent --org com.mobilecode . + python3 tooling/prepare_ios_project.py + + - name: Resolve Flutter dependencies + working-directory: mobile_agent + run: flutter pub get + + - name: Build unsigned iOS device app + working-directory: mobile_agent + env: + MOBILECODE_MANAGED_API_KEY: ${{ secrets.MOBILECODE_MANAGED_API_KEY }} + run: | + if [ -n "$MOBILECODE_MANAGED_API_KEY" ]; then + flutter build ios --release --no-codesign --target lib/main.dart \ + --dart-define=MOBILECODE_MANAGED_PROVIDER=true \ + --dart-define=MOBILECODE_MANAGED_BASE_URL=https://token-plan-cn.xiaomimimo.com/anthropic \ + --dart-define=MOBILECODE_MANAGED_MODEL=mimo-v2.5-pro \ + --dart-define=MOBILECODE_MANAGED_API_KEY="$MOBILECODE_MANAGED_API_KEY" + else + flutter build ios --release --no-codesign --target lib/main.dart + fi + + - name: Package unsigned xcarchive + working-directory: mobile_agent + env: + RELEASE_TAG: ${{ github.event.inputs.release_tag || 'v0.1.39' }} + run: | + set -euxo pipefail + mkdir -p ../artifacts + APP_PATH="build/ios/iphoneos/Runner.app" + ARCHIVE_PATH="../artifacts/MobileCode-${RELEASE_TAG}.xcarchive" + ZIP_PATH="../artifacts/mobilecode-ios-archive-${RELEASE_TAG}.xcarchive.zip" + + if [ ! -d "$APP_PATH" ]; then + echo "Expected iOS app bundle was not found at $APP_PATH" + exit 1 + fi + + rm -rf "$ARCHIVE_PATH" "$ZIP_PATH" + mkdir -p "$ARCHIVE_PATH/Products/Applications" + ditto "$APP_PATH" "$ARCHIVE_PATH/Products/Applications/Runner.app" + + DSYM_ROOT="build/ios/Release-iphoneos" + mkdir -p "$ARCHIVE_PATH/dSYMs" + copied_dsym=0 + for dsym in "$DSYM_ROOT"/*.dSYM; do + if [ -e "$dsym" ]; then + cp -R "$dsym" "$ARCHIVE_PATH/dSYMs/" + copied_dsym=1 + fi + done + if [ "$copied_dsym" -eq 0 ]; then + rmdir "$ARCHIVE_PATH/dSYMs" + fi + + BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_PATH/Info.plist")" + SHORT_VERSION="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$APP_PATH/Info.plist")" + BUILD_VERSION="$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$APP_PATH/Info.plist")" + CREATION_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + cat > "$ARCHIVE_PATH/Info.plist" < + + + + ApplicationProperties + + ApplicationPath + Applications/Runner.app + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleShortVersionString + ${SHORT_VERSION} + CFBundleVersion + ${BUILD_VERSION} + SigningIdentity + Unsigned CI archive + + ArchiveVersion + 2 + CreationDate + ${CREATION_DATE} + Name + MobileCode + SchemeName + Runner + + + PLIST + + plutil -lint "$ARCHIVE_PATH/Info.plist" + ditto -c -k --sequesterRsrc --keepParent "$ARCHIVE_PATH" "$ZIP_PATH" + { + echo "MobileCode iOS archive" + echo "Release tag: $RELEASE_TAG" + echo "Bundle ID: $BUNDLE_ID" + echo "Version: $SHORT_VERSION+$BUILD_VERSION" + echo "Signed: no" + echo "Installable on physical iPhone: no; export/sign this archive first." + } > ../artifacts/ios-archive-summary.txt + + - name: Upload iOS archive artifact + uses: actions/upload-artifact@v7 + if: always() + with: + name: mobilecode-ios-archive + path: artifacts/ + + - name: Upload iOS archive to GitHub Release + if: ${{ github.event.inputs.upload_to_release != 'false' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.inputs.release_tag || 'v0.1.39' }} + run: | + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $RELEASE_TAG already exists." + else + gh release create "$RELEASE_TAG" \ + --target "$GITHUB_SHA" \ + --title "MobileCode $RELEASE_TAG" \ + --notes "Automated MobileCode unsigned iOS archive build." \ + --prerelease \ + --repo "$GITHUB_REPOSITORY" + fi + gh release upload "$RELEASE_TAG" artifacts/mobilecode-ios-archive-"$RELEASE_TAG".xcarchive.zip artifacts/ios-archive-summary.txt --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/mobile-runtime-ci.yml b/.github/workflows/mobile-runtime-ci.yml index 66db403..3f9b196 100644 --- a/.github/workflows/mobile-runtime-ci.yml +++ b/.github/workflows/mobile-runtime-ci.yml @@ -1,219 +1,241 @@ -name: Mobile Runtime CI - -on: - workflow_dispatch: - pull_request: - paths: - - 'mobile_agent/**' - - 'docs/mobilecode-helper-runtime-protocol.md' - - 'docs/mobilecode-release-qa.md' - - '.github/workflows/mobile-runtime-ci.yml' - push: - paths: - - 'mobile_agent/**' - - 'docs/mobilecode-helper-runtime-protocol.md' - - 'docs/mobilecode-release-qa.md' - - '.github/workflows/mobile-runtime-ci.yml' - -permissions: - contents: read - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -concurrency: - group: mobile-runtime-ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - flutter-runtime-tests: - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Resolve Flutter dependencies - working-directory: mobile_agent - run: flutter pub get - - - name: Analyze runtime and app sources - working-directory: mobile_agent - run: | - flutter analyze \ +name: Mobile Runtime CI + +on: + workflow_dispatch: + pull_request: + paths: + - 'mobile_agent/**' + - 'docs/mobilecode-helper-runtime-protocol.md' + - 'docs/mobilecode-release-qa.md' + - '.github/workflows/mobile-runtime-ci.yml' + push: + paths: + - 'mobile_agent/**' + - 'docs/mobilecode-helper-runtime-protocol.md' + - 'docs/mobilecode-release-qa.md' + - '.github/workflows/mobile-runtime-ci.yml' + +permissions: + contents: read + +concurrency: + group: mobile-runtime-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + flutter-runtime-tests: + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Resolve Flutter dependencies + working-directory: mobile_agent + run: flutter pub get + + - name: Analyze runtime and app sources + working-directory: mobile_agent + run: | + flutter analyze \ lib/main.dart \ lib/screens/home_screen.dart \ lib/screens/build_preview_screen.dart \ + lib/screens/github_screen.dart \ + lib/screens/github_repo_hub_screen.dart \ + lib/screens/role_manager_screen.dart \ + lib/screens/api_usage_screen.dart \ + lib/screens/device_telemetry_screen.dart \ + lib/services/github_deep_service.dart \ + lib/services/github_oauth_flow.dart \ + lib/services/role_library_service.dart \ + lib/services/token_usage_service.dart \ + lib/services/token_pricing_service.dart \ + lib/services/device_telemetry_service.dart \ lib/services/runtime_provider.dart \ lib/services/runtime_manager.dart \ - lib/services/runtime_actions.dart \ - lib/services/runtime_placeholder_providers.dart \ - lib/services/mobile_code_helper_provider.dart \ - lib/services/external_termux_provider.dart \ - lib/services/build_orchestrator.dart \ - lib/widgets/flutter_web_preview.dart \ - test/services/runtime_manager_test.dart \ - test/services/mobile_code_helper_provider_test.dart \ + lib/services/runtime_actions.dart \ + lib/services/runtime_placeholder_providers.dart \ + lib/services/mobile_code_helper_provider.dart \ + lib/services/external_termux_provider.dart \ + lib/services/build_orchestrator.dart \ + lib/services/github_pages_service.dart \ + lib/services/html_publish_readiness_service.dart \ + lib/widgets/flutter_web_preview.dart \ + test/services/runtime_manager_test.dart \ + test/services/mobile_code_helper_provider_test.dart \ + test/services/github_pages_service_test.dart \ + test/services/html_publish_readiness_service_test.dart \ + test/services/role_library_service_test.dart \ + test/services/token_usage_service_test.dart \ + test/services/token_pricing_service_test.dart \ + test/services/device_telemetry_service_test.dart \ --no-fatal-infos \ --no-fatal-warnings - - - name: Run RuntimeProvider tests - working-directory: mobile_agent - run: | - flutter test \ - test/services/runtime_manager_test.dart \ - test/services/mobile_code_helper_provider_test.dart - - helper-daemon-smoke: - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Compile helper daemon - run: | - python3 -m py_compile mobile_agent/tooling/mobilecode_helper_daemon.py - python3 -m py_compile mobile_agent/tooling/prepare_android_project.py - - - name: Smoke test helper protocol - run: | - set -eux - mkdir -p artifacts /tmp/mobilecode-workspace - printf '{"scripts":{"test":"echo ok","build":"echo build"}}\n' > /tmp/mobilecode-workspace/package.json - python3 mobile_agent/tooling/mobilecode_helper_daemon.py \ - --port 18765 \ - --workspace-root /tmp/mobilecode-workspace \ - --auth-token ci-token \ - > artifacts/mobilecode-helper.log 2>&1 & - helper_pid=$! - auth_header='X-MobileCode-Token: ci-token' - trap 'kill "$helper_pid" || true' EXIT - - for attempt in $(seq 1 20); do - if curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/health > artifacts/helper-health.json; then - break - fi - sleep 0.5 - done - - curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/health | tee artifacts/helper-health.json - curl -fsS \ - -H "$auth_header" \ - -H 'Content-Type: application/json' \ - -X POST http://127.0.0.1:18765/v1/execute \ - -d '{"command":"python3 -c \"print(42)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":10000}' \ - | tee artifacts/helper-execute.json - curl -fsS -N \ - -H "$auth_header" \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/x-ndjson' \ - -X POST http://127.0.0.1:18765/v1/execute/stream \ - -d '{"command":"python3 -c \"print(43)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":10000}' \ - | tee artifacts/helper-stream.ndjson - curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/tasks/current | tee artifacts/helper-task.json - curl -fsS -H "$auth_header" 'http://127.0.0.1:18765/v1/tasks?limit=5' | tee artifacts/helper-tasks.json - task_id=$(python3 -c "import json; print(json.load(open('artifacts/helper-task.json'))['task']['id'])") - curl -fsS -H "$auth_header" "http://127.0.0.1:18765/v1/tasks/${task_id}/logs?limit=20" | tee artifacts/helper-task-logs.json - curl -fsS \ - -H "$auth_header" \ - -H 'Content-Type: application/json' \ - -X POST http://127.0.0.1:18765/v1/project/preflight \ - -d '{"cwd":"/tmp/mobilecode-workspace"}' \ - | tee artifacts/helper-project-preflight.json - curl -fsS \ - -H "$auth_header" \ - -H 'Content-Type: application/json' \ - -X POST http://127.0.0.1:18765/v1/execute \ - -d '{"command":"python3 -c \"import time; time.sleep(20)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":30000}' \ - > artifacts/helper-cancelled-execute.json 2>&1 & - long_pid=$! - sleep 1 - curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/tasks/current | tee artifacts/helper-running-task.json - running_task_id=$(python3 -c "import json; print(json.load(open('artifacts/helper-running-task.json'))['task']['id'])") - curl -fsS \ - -H "$auth_header" \ - -H 'Content-Type: application/json' \ - -X POST "http://127.0.0.1:18765/v1/tasks/${running_task_id}/stop" \ - -d '{}' \ - | tee artifacts/helper-stop.json - wait "$long_pid" || true - curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/tasks/current | tee artifacts/helper-cancelled-task.json - curl -fsS \ - -H "$auth_header" \ - -H 'Content-Type: application/json' \ - -X POST http://127.0.0.1:18765/v1/execute \ - -d '{"command":"python3 -c \"import time; time.sleep(20)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":30000}' \ - > artifacts/helper-concurrent-a.json 2>&1 & - concurrent_a_pid=$! - curl -fsS \ - -H "$auth_header" \ - -H 'Content-Type: application/json' \ - -X POST http://127.0.0.1:18765/v1/execute \ - -d '{"command":"python3 -c \"import time; time.sleep(20)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":30000}' \ - > artifacts/helper-concurrent-b.json 2>&1 & - concurrent_b_pid=$! - sleep 1 - curl -fsS -H "$auth_header" 'http://127.0.0.1:18765/v1/tasks?limit=10' | tee artifacts/helper-concurrent-running.json - first_concurrent_id=$(python3 -c "import json; tasks=json.load(open('artifacts/helper-concurrent-running.json'))['tasks']; running=[t['id'] for t in tasks if t.get('status')=='running']; assert len(running) >= 2, running; print(running[0])") - second_concurrent_id=$(python3 -c "import json; tasks=json.load(open('artifacts/helper-concurrent-running.json'))['tasks']; running=[t['id'] for t in tasks if t.get('status')=='running']; assert len(running) >= 2, running; print(running[1])") - export first_concurrent_id second_concurrent_id - curl -fsS \ - -H "$auth_header" \ - -H 'Content-Type: application/json' \ - -X POST "http://127.0.0.1:18765/v1/tasks/${first_concurrent_id}/stop" \ - -d '{}' \ - | tee artifacts/helper-concurrent-stop-first.json - curl -fsS -H "$auth_header" 'http://127.0.0.1:18765/v1/tasks?limit=10' | tee artifacts/helper-concurrent-after-first-stop.json - python3 -c "import json, os; tasks=json.load(open('artifacts/helper-concurrent-after-first-stop.json'))['tasks']; by_id={t['id']:t for t in tasks}; first=os.environ['first_concurrent_id']; second=os.environ['second_concurrent_id']; assert by_id[first]['status']=='cancelled', by_id[first]; assert by_id[second]['status']=='running', by_id[second]" - curl -fsS \ - -H "$auth_header" \ - -H 'Content-Type: application/json' \ - -X POST "http://127.0.0.1:18765/v1/tasks/${second_concurrent_id}/stop" \ - -d '{}' \ - | tee artifacts/helper-concurrent-stop-second.json - wait "$concurrent_a_pid" || true - wait "$concurrent_b_pid" || true - curl -fsS -H "$auth_header" 'http://127.0.0.1:18765/v1/tasks?limit=10' | tee artifacts/helper-concurrent-after-second-stop.json - - grep '"ready":true' artifacts/helper-health.json - grep '"authRequired":true' artifacts/helper-health.json - grep '"exitCode":0' artifacts/helper-execute.json - grep '"failureKind":"none"' artifacts/helper-execute.json - grep '"stdout":"42' artifacts/helper-execute.json - grep '"type":"stdout"' artifacts/helper-stream.ndjson - grep '"type":"exit"' artifacts/helper-stream.ndjson - grep '"status":"succeeded"' artifacts/helper-task.json - grep '"failureKind":"none"' artifacts/helper-task.json - grep '"logs":' artifacts/helper-task.json - grep '"count":' artifacts/helper-tasks.json - grep '"logs":' artifacts/helper-task-logs.json - grep 'package.json' artifacts/helper-project-preflight.json - grep '"status":"running"' artifacts/helper-running-task.json - grep '"stopped":true' artifacts/helper-stop.json - grep "\"taskId\":\"${running_task_id}\"" artifacts/helper-stop.json - grep '"status":"cancelled"' artifacts/helper-cancelled-task.json - grep '"failureKind":"cancelled"' artifacts/helper-cancelled-task.json - grep '"stopped":true' artifacts/helper-concurrent-stop-first.json - grep '"stopped":true' artifacts/helper-concurrent-stop-second.json - - - name: Upload helper smoke artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: mobilecode-helper-smoke - path: artifacts/ + + - name: Run RuntimeProvider tests + working-directory: mobile_agent + run: | + flutter test \ + test/services/runtime_manager_test.dart \ + test/services/mobile_code_helper_provider_test.dart \ + test/services/github_pages_service_test.dart \ + test/services/html_publish_readiness_service_test.dart \ + test/services/role_library_service_test.dart \ + test/services/token_usage_service_test.dart \ + test/services/token_pricing_service_test.dart \ + test/services/device_telemetry_service_test.dart + + helper-daemon-smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Compile helper daemon + run: | + python3 -m py_compile mobile_agent/tooling/mobilecode_helper_daemon.py + python3 -m py_compile mobile_agent/tooling/prepare_android_project.py + + - name: Smoke test helper protocol + run: | + set -eux + mkdir -p artifacts /tmp/mobilecode-workspace + printf '{"scripts":{"test":"echo ok","build":"echo build"}}\n' > /tmp/mobilecode-workspace/package.json + python3 mobile_agent/tooling/mobilecode_helper_daemon.py \ + --port 18765 \ + --workspace-root /tmp/mobilecode-workspace \ + --auth-token ci-token \ + > artifacts/mobilecode-helper.log 2>&1 & + helper_pid=$! + auth_header='X-MobileCode-Token: ci-token' + trap 'kill "$helper_pid" || true' EXIT + + for attempt in $(seq 1 20); do + if curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/health > artifacts/helper-health.json; then + break + fi + sleep 0.5 + done + + curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/health | tee artifacts/helper-health.json + curl -fsS \ + -H "$auth_header" \ + -H 'Content-Type: application/json' \ + -X POST http://127.0.0.1:18765/v1/execute \ + -d '{"command":"python3 -c \"print(42)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":10000}' \ + | tee artifacts/helper-execute.json + curl -fsS -N \ + -H "$auth_header" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/x-ndjson' \ + -X POST http://127.0.0.1:18765/v1/execute/stream \ + -d '{"command":"python3 -c \"print(43)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":10000}' \ + | tee artifacts/helper-stream.ndjson + curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/tasks/current | tee artifacts/helper-task.json + curl -fsS -H "$auth_header" 'http://127.0.0.1:18765/v1/tasks?limit=5' | tee artifacts/helper-tasks.json + task_id=$(python3 -c "import json; print(json.load(open('artifacts/helper-task.json'))['task']['id'])") + curl -fsS -H "$auth_header" "http://127.0.0.1:18765/v1/tasks/${task_id}/logs?limit=20" | tee artifacts/helper-task-logs.json + curl -fsS \ + -H "$auth_header" \ + -H 'Content-Type: application/json' \ + -X POST http://127.0.0.1:18765/v1/project/preflight \ + -d '{"cwd":"/tmp/mobilecode-workspace"}' \ + | tee artifacts/helper-project-preflight.json + curl -fsS \ + -H "$auth_header" \ + -H 'Content-Type: application/json' \ + -X POST http://127.0.0.1:18765/v1/execute \ + -d '{"command":"python3 -c \"import time; time.sleep(20)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":30000}' \ + > artifacts/helper-cancelled-execute.json 2>&1 & + long_pid=$! + sleep 1 + curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/tasks/current | tee artifacts/helper-running-task.json + running_task_id=$(python3 -c "import json; print(json.load(open('artifacts/helper-running-task.json'))['task']['id'])") + curl -fsS \ + -H "$auth_header" \ + -H 'Content-Type: application/json' \ + -X POST "http://127.0.0.1:18765/v1/tasks/${running_task_id}/stop" \ + -d '{}' \ + | tee artifacts/helper-stop.json + wait "$long_pid" || true + curl -fsS -H "$auth_header" http://127.0.0.1:18765/v1/tasks/current | tee artifacts/helper-cancelled-task.json + curl -fsS \ + -H "$auth_header" \ + -H 'Content-Type: application/json' \ + -X POST http://127.0.0.1:18765/v1/execute \ + -d '{"command":"python3 -c \"import time; time.sleep(20)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":30000}' \ + > artifacts/helper-concurrent-a.json 2>&1 & + concurrent_a_pid=$! + curl -fsS \ + -H "$auth_header" \ + -H 'Content-Type: application/json' \ + -X POST http://127.0.0.1:18765/v1/execute \ + -d '{"command":"python3 -c \"import time; time.sleep(20)\"","cwd":"/tmp/mobilecode-workspace","timeoutMs":30000}' \ + > artifacts/helper-concurrent-b.json 2>&1 & + concurrent_b_pid=$! + sleep 1 + curl -fsS -H "$auth_header" 'http://127.0.0.1:18765/v1/tasks?limit=10' | tee artifacts/helper-concurrent-running.json + first_concurrent_id=$(python3 -c "import json; tasks=json.load(open('artifacts/helper-concurrent-running.json'))['tasks']; running=[t['id'] for t in tasks if t.get('status')=='running']; assert len(running) >= 2, running; print(running[0])") + second_concurrent_id=$(python3 -c "import json; tasks=json.load(open('artifacts/helper-concurrent-running.json'))['tasks']; running=[t['id'] for t in tasks if t.get('status')=='running']; assert len(running) >= 2, running; print(running[1])") + export first_concurrent_id second_concurrent_id + curl -fsS \ + -H "$auth_header" \ + -H 'Content-Type: application/json' \ + -X POST "http://127.0.0.1:18765/v1/tasks/${first_concurrent_id}/stop" \ + -d '{}' \ + | tee artifacts/helper-concurrent-stop-first.json + curl -fsS -H "$auth_header" 'http://127.0.0.1:18765/v1/tasks?limit=10' | tee artifacts/helper-concurrent-after-first-stop.json + python3 -c "import json, os; tasks=json.load(open('artifacts/helper-concurrent-after-first-stop.json'))['tasks']; by_id={t['id']:t for t in tasks}; first=os.environ['first_concurrent_id']; second=os.environ['second_concurrent_id']; assert by_id[first]['status']=='cancelled', by_id[first]; assert by_id[second]['status']=='running', by_id[second]" + curl -fsS \ + -H "$auth_header" \ + -H 'Content-Type: application/json' \ + -X POST "http://127.0.0.1:18765/v1/tasks/${second_concurrent_id}/stop" \ + -d '{}' \ + | tee artifacts/helper-concurrent-stop-second.json + wait "$concurrent_a_pid" || true + wait "$concurrent_b_pid" || true + curl -fsS -H "$auth_header" 'http://127.0.0.1:18765/v1/tasks?limit=10' | tee artifacts/helper-concurrent-after-second-stop.json + + grep '"ready":true' artifacts/helper-health.json + grep '"authRequired":true' artifacts/helper-health.json + grep '"exitCode":0' artifacts/helper-execute.json + grep '"failureKind":"none"' artifacts/helper-execute.json + grep '"stdout":"42' artifacts/helper-execute.json + grep '"type":"stdout"' artifacts/helper-stream.ndjson + grep '"type":"exit"' artifacts/helper-stream.ndjson + grep '"status":"succeeded"' artifacts/helper-task.json + grep '"failureKind":"none"' artifacts/helper-task.json + grep '"logs":' artifacts/helper-task.json + grep '"count":' artifacts/helper-tasks.json + grep '"logs":' artifacts/helper-task-logs.json + grep 'package.json' artifacts/helper-project-preflight.json + grep '"status":"running"' artifacts/helper-running-task.json + grep '"stopped":true' artifacts/helper-stop.json + grep "\"taskId\":\"${running_task_id}\"" artifacts/helper-stop.json + grep '"status":"cancelled"' artifacts/helper-cancelled-task.json + grep '"failureKind":"cancelled"' artifacts/helper-cancelled-task.json + grep '"stopped":true' artifacts/helper-concurrent-stop-first.json + grep '"stopped":true' artifacts/helper-concurrent-stop-second.json + + - name: Upload helper smoke artifacts + uses: actions/upload-artifact@v7 + if: always() + with: + name: mobilecode-helper-smoke + path: artifacts/ diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ecbeb84..a5de9a1 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,55 +1,59 @@ -name: Deploy MobileCode Demo Pages - -on: - workflow_dispatch: - push: - branches: - - main - paths: - - 'app/**' - - 'docs/**' - - '.github/workflows/pages.yml' - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - cache-dependency-path: app/package-lock.json - - name: Build promotional site - working-directory: app - run: | - npm ci - npm run build +name: Deploy MobileCode Demo Pages + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'app/**' + - 'docs/**' + - '.github/workflows/pages.yml' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: app/package-lock.json + - name: Build promotional site + working-directory: app + run: | + npm ci + npm run build - name: Attach demo pages run: | - mkdir -p app/dist/demo app/dist/github-test + mkdir -p app/dist/demo app/dist/github-test app/dist/assets cp -R docs/demo/2048 app/dist/demo/ cp -R docs/github-test/* app/dist/github-test/ - - name: Configure Pages - uses: actions/configure-pages@v5 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: app/dist - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + cp docs/mobilecode-principle-video.html app/dist/ + cp docs/mobilecode-principle-video-script.md app/dist/ + cp docs/assets/mobilecode-principle-* app/dist/assets/ + cp docs/assets/mobilecode-short-* app/dist/assets/ + - name: Configure Pages + uses: actions/configure-pages@v6 + - name: Upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: app/dist + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 60893d7..8f24dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,22 @@ -node_modules/ -dist/ -build/ -release/*.zip -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.packages -pubspec.lock -*.log -.env -.env.* +node_modules/ +dist/ +build/ +release/*.zip +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +pubspec.lock +*.log +.env +.env.* android/key.properties *.jks *.keystore + +# Raw local screen recordings are usually large/private; keep the manifest and rendered promo outputs instead. +promo/mobilecode-remotion/public/recordings/*.mp4 +promo/mobilecode-remotion/public/recordings/*.mov +promo/mobilecode-remotion/public/recordings/*.webm +!promo/mobilecode-remotion/public/recordings/.gitkeep +!promo/mobilecode-remotion/public/recordings/README.md diff --git a/MOBILECODE_RULES.md b/MOBILECODE_RULES.md new file mode 100644 index 0000000..ef5be78 --- /dev/null +++ b/MOBILECODE_RULES.md @@ -0,0 +1,22 @@ +# MobileCode Rules + +This file is the product-level rules surface for MobileCode. + +Rules are not the same as Memory: + +- Rules are explicit, stable, user-approved operating instructions. +- Memory is evidence, preferences, repo insights, and proposal history. +- Memory can suggest a Rule, but it should not silently become a Rule. + +## Active Product Rules + +- Keep MobileCode as a phone-native coding harness. The product should make it clear that the harness runs on the mobile device, while GitHub, Termux, Helper, or cloud are optional execution backends. +- Prefer lightweight mobile loops: generate, preview, inspect, publish, and recover on the phone. +- Use GitHub Pages for simple HTML publishing and GitHub Actions for heavy APK or release builds when local runtime is not ready. +- Do not execute Hook or MCP scripts without explicit user review and confirmation. +- Treat Skill and MCP installs as reviewed imports: show provenance, manifest, permissions, and risk before installation. +- Keep Role Recruit as a single execution lane with role personalities unless true multi-agent execution is explicitly implemented. + +## Prompt Injection Boundary + +Future prompt builders may inject approved Rules as high-priority context. They should keep Memory as lower-priority supporting evidence and must keep user secrets, tokens, and private source code out of model prompts unless the user explicitly approves. diff --git a/README.md b/README.md index 051710d..0998aa5 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,182 @@ # MobileCode -MobileCode is an AI coding workspace designed for mobile devices. It combines voice input, screenshot-to-code, agentic development actions, cloud execution, previews, and GitHub workflows into a lightweight mobile-first product. +

+ MobileCode logo +
+ Phone-native AI Coding Harness +
+ The agent harness runs on the phone. Models can be remote; the coding loop, files, previews, runtime routing, and shipping controls stay in MobileCode. +
+ 不是远程 IDE 的手机壳,而是真正把 agent loop、工具状态、文件、预览和发布控制面放到手机本机的 MobileCode。 +

-## Repository Structure +

+ Mobile Runtime CI + Android APK + Android Smoke + Version + Platform +

+ +

+ Watch 15s Short + · + Watch 9:16 Promo Video + · + README Motion Cover + · + HTML Principle Video + · + Download v0.1.30 APK + · + GitHub Pages Demo +

+ +

+ Demo Lab + · + 2048 Demo + · + GitHub Test + · + Download APK + · + Release QA +

+ +

+ + MobileCode phone-native AI coding harness workflow + +

+ +

+ + MobileCode 15-second Remotion teaser cover + +
+ 15-second Remotion teaser with voiceover. Full explainer covers demand, pain, RuntimeProvider, and GitHub-first shipping. +

+ +| Runs on the phone | Remote by choice | GitHub-first shipping | +| --- | --- | --- | +| Agent trace, tool selection, runtime routing, local files, WebView preview, result cards | Model provider, optional Cloud Runtime, external Termux/Helper backends | Repo discovery, Contents API commits, Pages publish, Actions builds, release artifacts | + +## Why MobileCode + +MobileCode 的第一性原理很简单:手机端不适合塞一个完整桌面编译环境,但非常适合成为 AI coding 的本机 harness。 + +它不是 Codex Remote、Claude Remote 或云端 IDE 的移动端外壳。模型可以来自云端 provider,但对话、工具编排、运行时选择、文件落盘、WebView 预览、GitHub 发布和恢复提示都在手机 App 内闭环。 + +它把最重的部分交给外部平台,把最贴近用户的部分留在手机上: + +| Layer | MobileCode does | External layer does | +| --- | --- | --- | +| Phone-native harness | Chat, tool trace, role cards, file cards, preview, runtime diagnostics, settings | None | +| Local runtime | Helper / Termux / WebViewOnly through `RuntimeProvider` | Shell, logs, small local tasks | +| GitHub-first workspace | Repo Hub, watchlist, remote-linked folders, Pages publish cards | Repos, Contents API commits, Actions builds, artifacts | +| Web artifacts | Generate HTML, run publish readiness checks, open browser/WebView | GitHub Pages hosting | +| Heavy builds | Show workflow status, jobs, artifacts | GitHub Actions APK/Web/release builds | + +## Effect Showcase + +These thumbnails are generated from the live GitHub Pages demos with `just-thumbnail`, so the README shows rendered pages rather than mock claims. + + + + + + + +
+ + MobileCode Demo Lab responsive preview + +
+ Demo Lab +
+ Product landing and demo index published on GitHub Pages. +
+ + MobileCode 2048 responsive preview + +
+ 2048 Web +
+ Touch-first generated HTML game for mobile WebView checks. +
+ + MobileCode GitHub Test responsive preview + +
+ GitHub Test +
+ Browser-side token and repo access verification page. +
+ +| Scene | What to try | Link | +| --- | --- | --- | +| Demo Lab | A static landing page for published mobile demos | [Open demo lab](https://harzva.github.io/mobilecode/) | +| 2048 Web | Touch-first generated HTML game, useful for WebView and mobile layout checks | [Play 2048](https://harzva.github.io/mobilecode/demo/2048/) | +| GitHub Test | Verify token identity, repo access, and Pages readiness from a browser | [Open GitHub test](https://harzva.github.io/mobilecode/github-test/) | +| Repo Hub | Watch repos, map them to `mobilecode_projects/github///`, inspect Actions, edit files through GitHub API | `mobile_agent/lib/screens/github_repo_hub_screen.dart` | +| Published Work Card | After Pages publish, show Pages URL, repo URL, local file path, browser open, copy/share, and redeploy actions | `mobile_agent/lib/screens/home_screen.dart` | + +## Product Loop + +```mermaid +flowchart LR + A["User prompt on phone"] --> B["AI generates HTML / code artifact"] + B --> C["Local WebView preview"] + C --> D["HTML publish readiness check"] + D --> E["GitHub Pages publish"] + E --> F["Shareable work card"] + B --> G["GitHub Repo Hub"] + G --> H["Contents API edit + commit"] + G --> I["GitHub Actions workflow_dispatch"] + I --> J["Jobs, logs, artifacts"] +``` + +## Current Capabilities + +- Runtime abstraction: `RuntimeProvider`, `RuntimeManager`, Helper, External Termux, planned Embedded Lite, Cloud, and WebViewOnly fallback. +- MobileCode Helper prototype: health, execute, streaming logs, task stop, task state, preflight checks. +- Chat and agent process UI: model call progress, stop control, trace cards, generated artifact cards. +- HTML-first generation: built-in HTML/UI skill context, publish readiness checks, WebView preview, browser open, GitHub Pages publish. +- GitHub-first workspace: repo list, watchlist, language/Pages/local filters, local existence status, Remote-linked folder marker. +- GitHub Actions surface: workflows, latest run status, jobs/steps, workflow dispatch, artifact zip download record. +- API-backed file flow: browse remote tree, read text files, edit, commit via GitHub Contents API, reload on SHA conflict. +- Extension management: Roles, Skill, MCP, Memory, Agent, Hook Registry surfaces for role-based workflows. +- Observability: RR AgentView, pending role approvals, Token Usage/cache-hit statistics, searchable/sortable LiteLLM-style pricing with manual snapshot checks, and Device Telemetry htop-style phone health. +- Lark CLI connector: opt-in diagnostics and structured dry-run action model. + +## Architecture -- `app/` - React/Vite promotional site for the product. -- `mobile_agent/` - Flutter source modules for the mobile app preview. -- `*.md` - planning, security, roadmap, and product analysis notes. +```mermaid +flowchart TB + UI["Flutter App\nChat · Files · Preview · Settings"] --> RM["RuntimeManager"] + RM --> H["MobileCode Helper\nAndroid foreground service / daemon"] + RM --> T["External Termux\nfallback shell"] + RM --> W["WebViewOnly\npreview-only fallback"] + RM --> C["Cloud Runtime\nheavy tasks later"] + UI --> GH["GitHub Deep Service"] + GH --> Repo["Repos / Contents API"] + GH --> Pages["GitHub Pages"] + GH --> Actions["GitHub Actions"] + Actions --> Artifacts["APK / Web / release artifacts"] +``` + +## Quick Start + +### Try the published demos -## Current Completeness +Open: -The promotional site is ready to build and publish. The Flutter app has a broad set of Dart screens, services, providers, and tests, but it is not yet a complete packaged mobile product in this checkout because the local machine has no Flutter SDK, the `ios/` project is missing, and the Android folder does not include the full Gradle wrapper/root project files. +- [Demo Lab](https://harzva.github.io/mobilecode/) +- [2048 Web Demo](https://harzva.github.io/mobilecode/demo/2048/) +- [GitHub Test](https://harzva.github.io/mobilecode/github-test/) -## Build +### Build the product site ```bash cd app @@ -20,21 +184,77 @@ npm install npm run build ``` -Android/iOS packages should be generated after restoring the Flutter platform scaffolding: +### Build the Flutter app + +Local Flutter SDK is required: ```bash cd mobile_agent flutter pub get flutter create --platforms=android,ios . flutter build apk --release -flutter build appbundle --release -flutter build ipa --release ``` -The iOS build requires macOS, Xcode, and valid signing credentials. +For release QA, prefer GitHub Actions so the build is reproducible: + +- [Mobile Runtime CI](https://github.com/Harzva/mobilecode/actions/workflows/mobile-runtime-ci.yml) +- [Build Android APK](https://github.com/Harzva/mobilecode/actions/workflows/android-apk.yml) +- [Android App Smoke Test](https://github.com/Harzva/mobilecode/actions/workflows/android-app-test.yml) + +## Runtime Strategy + +MobileCode does not try to become a full Termux clone. The long-term model is: + +```text +Flutter App + -> RuntimeProvider abstraction + -> MobileCode Helper + -> External Termux fallback + -> Embedded Lite runtime later + -> Cloud runtime for heavy builds + -> GitHub Pages + GitHub Actions for shipping +``` + +That keeps the phone lightweight while still letting users produce shareable web pages, inspect repos, commit small changes, and build APKs through GitHub Actions. + +## Repository Structure + +```text +. +├─ app/ React/Vite product site +├─ docs/ GitHub Pages demos, QA docs, runtime docs +├─ mobile_agent/ Flutter app source +│ ├─ lib/screens/ Home, GitHub Repo Hub, Skill/MCP/Agent/Memory UI +│ ├─ lib/services/ Runtime, GitHub, Pages, Helper, skill services +│ └─ assets/ Role avatars and icons +├─ mobile-coding-*.md Product and architecture analysis +└─ README.md Project homepage +``` + +## Release Line + +Current candidate: `0.1.30+49`. + +See: + +- [Version Policy](docs/mobilecode-version-policy.md) +- [Release QA Checklist](docs/mobilecode-release-qa.md) +- [Helper Runtime Protocol](docs/mobilecode-helper-runtime-protocol.md) +- [Production Hardening Notes](docs/mobilecode-production-hardening.md) + +## Roadmap + +| Priority | Next focus | Stop condition | +| --- | --- | --- | +| P0 | Pass Mobile Runtime CI, Android APK build, Android smoke test for the pushed commit | APK artifact is downloadable and app launches | +| P1 | Smooth Repo Hub file edit conflict handling and artifact download UX | User can recover from SHA conflicts and find downloaded artifacts | +| P2 | Expand API-backed workspace into selected repo file import/export | Phone can edit selected repo files without true clone | +| Later | Helper APK maturity, queue recovery, PTY, cloud heavy builds | Runtime remains replaceable behind `RuntimeProvider` | + +## Status -## Release Notes +This repository is actively moving toward a deployable mobile coding workspace. The Android build path is GitHub Actions-first; local machines without Flutter/Android SDK should use CI artifacts instead of local builds. -Version `v0.1.0` publishes the product site, source preview, and a real Android APK built from `mobile_agent/lib/main.dart` through GitHub Actions. +## License -The iOS build still requires macOS, Xcode, and signing credentials. +No license file is included yet. Add a `LICENSE` before treating this as a reusable open-source distribution. diff --git a/app/src/App.tsx b/app/src/App.tsx index beb5d3b..e6b1ae0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,25 +1,24 @@ -import { Routes, Route } from 'react-router-dom'; +import { Navigate, Routes, Route } from 'react-router-dom'; import Layout from './components/Layout'; import Home from './pages/Home'; import Features from './pages/Features'; -import Pricing from './pages/Pricing'; import Docs from './pages/Docs'; import Changelog from './pages/Changelog'; import About from './pages/About'; -import Contact from './pages/Contact'; - -export default function App() { - return ( - - +import Contact from './pages/Contact'; + +export default function App() { + return ( + + } /> } /> - } /> + } /> } /> - } /> - } /> - } /> - - - ); -} + } /> + } /> + } /> + + + ); +} diff --git a/app/src/components/Navbar.tsx b/app/src/components/Navbar.tsx index 5697af9..8f5c3ad 100644 --- a/app/src/components/Navbar.tsx +++ b/app/src/components/Navbar.tsx @@ -1,58 +1,57 @@ -import { useState } from 'react'; -import { Link, useLocation } from 'react-router-dom'; -import { Code2, Menu, X } from 'lucide-react'; - -const apkUrl = 'https://github.com/Harzva/mobilecode/releases/download/v0.1.0/mobilecode-v0.1.0.apk'; - +import { useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { Code2, Menu, X } from 'lucide-react'; + +const apkUrl = 'https://github.com/Harzva/mobilecode/releases/download/v0.1.0/mobilecode-v0.1.0.apk'; + const navLinks = [ { label: '产品特性', path: '/features' }, - { label: '定位方案', path: '/pricing' }, { label: '文档中心', path: '/docs' }, { label: '更新日志', path: '/changelog' }, { label: '关于我们', path: '/about' }, -]; - -export default function Navbar() { - const [open, setOpen] = useState(false); - const location = useLocation(); - - return ( -
-
- - - MobileCode - - - - - - 下载应用 - - - -
- - {open && ( - - )} -
- ); -} +]; + +export default function Navbar() { + const [open, setOpen] = useState(false); + const location = useLocation(); + + return ( +
+
+ + + MobileCode + + + + + + 下载应用 + + + +
+ + {open && ( + + )} +
+ ); +} diff --git a/docs/assets/mobilecode-principle-captions.zh.vtt b/docs/assets/mobilecode-principle-captions.zh.vtt new file mode 100644 index 0000000..6487b0f --- /dev/null +++ b/docs/assets/mobilecode-principle-captions.zh.vtt @@ -0,0 +1,19 @@ +WEBVTT + +00:00.000 --> 00:10.000 +MobileCode 不是云端 IDE 外壳,而是把 AI coding harness 真正放到手机上。 + +00:10.000 --> 00:20.000 +手机写代码的核心痛点不是屏幕小,而是执行层不清楚、失败不可恢复。 + +00:20.000 --> 00:30.000 +手机保留对话、文件、预览、诊断和发布控制,把重构建交给外部平台。 + +00:30.000 --> 00:40.000 +RuntimeProvider 让 Helper、Termux、WebViewOnly、Cloud 都成为可替换后端。 + +00:40.000 --> 00:50.000 +GitHub 负责仓库、Pages、Actions 和产物,MobileCode 负责手机端闭环体验。 + +00:50.000 --> 01:00.000 +最终目标:在手机上生成、预览、解释、发布,而不是伪装成桌面环境。 diff --git a/docs/assets/mobilecode-principle-poster.png b/docs/assets/mobilecode-principle-poster.png new file mode 100644 index 0000000..b93887e Binary files /dev/null and b/docs/assets/mobilecode-principle-poster.png differ diff --git a/docs/assets/mobilecode-principle-remotion.mp4 b/docs/assets/mobilecode-principle-remotion.mp4 new file mode 100644 index 0000000..702e2ee Binary files /dev/null and b/docs/assets/mobilecode-principle-remotion.mp4 differ diff --git a/docs/assets/mobilecode-promo-vertical.mp4 b/docs/assets/mobilecode-promo-vertical.mp4 new file mode 100644 index 0000000..9891c7e Binary files /dev/null and b/docs/assets/mobilecode-promo-vertical.mp4 differ diff --git a/docs/assets/mobilecode-readme-cover.mp4 b/docs/assets/mobilecode-readme-cover.mp4 new file mode 100644 index 0000000..906ed14 Binary files /dev/null and b/docs/assets/mobilecode-readme-cover.mp4 differ diff --git a/docs/assets/mobilecode-readme-showcase.svg b/docs/assets/mobilecode-readme-showcase.svg new file mode 100644 index 0000000..c6748f7 --- /dev/null +++ b/docs/assets/mobilecode-readme-showcase.svg @@ -0,0 +1,65 @@ + + + + + + + + Harness on phone + Agent trace · files · WebView · runtime + MobileCode + Phone-native AI coding harness + + Local agent result + mobilecode_projects/agent_snake/index.html + + Preview + + Publish + + GitHub Repo Hub + Public search · files · Actions + + Remote-linked + + Pages + + APK + The harness runs on the phone + Remote model optional. Local control loop, files, previews, and recovery. + + + + Generate + Prompt, role context, tool trace, + and artifact save happen in-app. + + + + + + + Publish + Pages URL, repo URL, local path, + work card and share actions. + + + + + Repo Hub + Anonymous public search plus + token-gated private repo actions. + + + + + + + Actions build + Offload heavy builds to CI, + download artifacts to phone. + + + v0.1.24+43 + Phone harness · RuntimeProvider · GitHub Pages · Actions artifacts + diff --git a/docs/assets/mobilecode-short-captions.zh.vtt b/docs/assets/mobilecode-short-captions.zh.vtt new file mode 100644 index 0000000..e3c688e --- /dev/null +++ b/docs/assets/mobilecode-short-captions.zh.vtt @@ -0,0 +1,10 @@ +WEBVTT + +00:00.000 --> 00:05.000 +MobileCode 不是远程 IDE 外壳。它是真正运行在手机上的 AI coding harness。 + +00:05.000 --> 00:10.000 +手机负责生成、预览和解释。Helper、Termux、GitHub Actions 负责执行和构建。 + +00:10.000 --> 00:15.000 +从一句话到 HTML,从 WebView 到 GitHub Pages。让手机成为 AI coding 控制室。 diff --git a/docs/assets/mobilecode-short-poster.png b/docs/assets/mobilecode-short-poster.png new file mode 100644 index 0000000..7a6cab3 Binary files /dev/null and b/docs/assets/mobilecode-short-poster.png differ diff --git a/docs/assets/mobilecode-short-teaser.mp4 b/docs/assets/mobilecode-short-teaser.mp4 new file mode 100644 index 0000000..ef7c3f3 Binary files /dev/null and b/docs/assets/mobilecode-short-teaser.mp4 differ diff --git a/docs/assets/thumb-2048/responsive.png b/docs/assets/thumb-2048/responsive.png new file mode 100644 index 0000000..4186244 Binary files /dev/null and b/docs/assets/thumb-2048/responsive.png differ diff --git a/docs/assets/thumb-demo-lab/responsive.png b/docs/assets/thumb-demo-lab/responsive.png new file mode 100644 index 0000000..0cc3320 Binary files /dev/null and b/docs/assets/thumb-demo-lab/responsive.png differ diff --git a/docs/assets/thumb-github-test/responsive.png b/docs/assets/thumb-github-test/responsive.png new file mode 100644 index 0000000..baa8b24 Binary files /dev/null and b/docs/assets/thumb-github-test/responsive.png differ diff --git a/docs/index.html b/docs/index.html index 9c07767..6ba7fbe 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,92 +1,98 @@ - - - - - - MobileCode Demo Lab - - - -
-
- -
- MobileCode -

Demo Lab for mobile development workflows

-
-
-

Build, test, and ship from a phone.

-

- These demos are static, fast, and phone-first. They are published from the - MobileCode GitHub repository so the APK can prove the loop from mobile idea - to GitHub Pages preview. -

+ + + + + + MobileCode Demo Lab + + + +
+
+ +
+ MobileCode +

Demo Lab for mobile development workflows

+
+
+

Build, test, and ship from a phone.

+

+ These demos are static, fast, and phone-first. They are published from the + MobileCode GitHub repository so the APK can prove the loop from mobile idea + to GitHub Pages preview. +

+ +
HTML video
+

Principle Video

+

An animated explainer for MobileCode demand, pain points, architecture, and GitHub-first workflow.

+ Play explainer +
Game demo

2048 Web

-

A tiny game that tests touch input, layout, local storage, and browser performance.

- Open 2048 -
- -
Integration demo
-

GitHub Test

-

Paste a GitHub token and verify API identity, repo access, and Pages readiness.

- Test GitHub -
-
-
- - +

A tiny game that tests touch input, layout, local storage, and browser performance.

+ Open 2048 + + +
Integration demo
+

GitHub Test

+

Paste a GitHub token and verify API identity, repo access, and Pages readiness.

+ Test GitHub +
+ +
+ + diff --git a/docs/mobile-platform-testing.md b/docs/mobile-platform-testing.md index b2222eb..b2fb617 100644 --- a/docs/mobile-platform-testing.md +++ b/docs/mobile-platform-testing.md @@ -1,54 +1,62 @@ -# MobileCode Mobile Platform Testing - -This project is tested through GitHub Actions because the local Windows workstation does not have Flutter, Android SDK tools, or Xcode simulators installed. - -## Android - -Runtime CI workflow: `.github/workflows/mobile-runtime-ci.yml` - -- Runs Flutter analyze/test coverage for runtime provider code and the Home/Build runtime surfaces. -- Compiles the MobileCode Helper daemon prototype. -- Compiles the Android project preparation script that injects `MobileCodeHelperService.kt`. -- Smoke tests `/v1/health`, `/v1/execute`, and `/v1/execute/stream`. -- Uploads helper smoke logs and protocol responses as artifacts. - -Workflow: `.github/workflows/android-app-test.yml` - -Checks: - -- Recreates the Android Flutter platform project from source. -- Applies MobileCode Android bridge customizations from `mobile_agent/tooling/prepare_android_project.py`. -- Runs `flutter analyze` on the app entry surfaces. -- Builds a debug APK. -- Installs the APK on an Android emulator. -- Launches `com.mobilecode.mobile_agent`. -- Captures a smoke-test screenshot and logcat. -- Fails on common runtime crash signatures such as `FATAL EXCEPTION`, `AndroidRuntime`, `NoSuchMethodError`, or `MissingPluginException`. - +# MobileCode Mobile Platform Testing + +This project is tested through GitHub Actions because the local Windows workstation does not have Flutter, Android SDK tools, or Xcode simulators installed. + +## Android + +Runtime CI workflow: `.github/workflows/mobile-runtime-ci.yml` + +- Runs Flutter analyze/test coverage for runtime provider code and the Home/Build runtime surfaces. +- Compiles the MobileCode Helper daemon prototype. +- Compiles the Android project preparation script that injects `MobileCodeHelperService.kt`. +- Smoke tests `/v1/health`, `/v1/execute`, and `/v1/execute/stream`. +- Uploads helper smoke logs and protocol responses as artifacts. + +Workflow: `.github/workflows/android-app-test.yml` + +Checks: + +- Recreates the Android Flutter platform project from source. +- Applies MobileCode Android bridge customizations from `mobile_agent/tooling/prepare_android_project.py`. +- Runs `flutter analyze` on the app entry surfaces. +- Builds a debug APK. +- Installs the APK on an Android emulator. +- Launches `com.mobilecode.mobile_agent`. +- Captures a smoke-test screenshot and logcat. +- Fails on common runtime crash signatures such as `FATAL EXCEPTION`, `AndroidRuntime`, `NoSuchMethodError`, or `MissingPluginException`. + Release APK workflow: `.github/workflows/android-apk.yml` - Builds the release APK. - Injects the managed debug provider only from GitHub Secret `MOBILECODE_MANAGED_API_KEY`. -- Uploads `mobilecode-v0.1.0.apk` to the GitHub Release. - -## iOS - +- Uploads `mobilecode-v0.1.24.apk` to the GitHub Release. + +## iOS + Workflow: `.github/workflows/ios-simulator.yml` - -Checks: - -- Runs on a macOS GitHub runner with Xcode. -- Recreates the iOS Flutter platform project from source. -- Runs `flutter analyze` on the app entry surfaces. + +Checks: + +- Runs on a macOS GitHub runner with Xcode. +- Recreates the iOS Flutter platform project from source. +- Runs `flutter analyze` on the app entry surfaces. - Builds a debug iOS Simulator `.app`. - Installs and launches the app on an available iPhone simulator. - Captures a simulator screenshot and Runner logs. -- Uploads `mobilecode-ios-simulator-v0.1.0.zip` to the GitHub Release. +- Uploads `mobilecode-ios-simulator-v0.1.24.zip` to the GitHub Release. -This is an iOS Simulator build, not a signed App Store IPA. A physical-device IPA requires an Apple Developer team, signing certificate, provisioning profile, and an export options plist. +Archive workflow: `.github/workflows/ios-archive.yml` -## Local Plugin Findings +- Builds an unsigned release iOS device app with `flutter build ios --release --no-codesign`. +- Packages the device app as `mobilecode-ios-archive-v0.1.24.xcarchive.zip`. +- Uploads the unsigned archive and `ios-archive-summary.txt` to the GitHub Release. +- Does not create an installable `.ipa`. +The simulator zip and unsigned archive do not require Apple Developer signing secrets. A physical-device IPA still requires an Apple Developer team, signing certificate, provisioning profile, and export options plist. + +## Local Plugin Findings + - Build iOS Apps / XcodeBuildMCP was available, but the local machine had no Xcode defaults configured. - `xcrun` was missing locally, so iOS simulator testing must run on macOS CI. +- Unsigned iOS archive packaging must also run on macOS CI because it needs Xcode and the iOS toolchain. - Android SDK tools such as `adb` were missing locally, so Android device testing must run on emulator CI. diff --git a/docs/mobilecode-helper-runtime-protocol.md b/docs/mobilecode-helper-runtime-protocol.md index 95181e1..4ca05df 100644 --- a/docs/mobilecode-helper-runtime-protocol.md +++ b/docs/mobilecode-helper-runtime-protocol.md @@ -1,331 +1,340 @@ -# MobileCode Helper Runtime Protocol - -MobileCode Helper is a local Android companion daemon that can expose shell-like execution without tying the Flutter app directly to Termux. The Flutter app talks to the helper through localhost HTTP. - -## Endpoint Base - -Default base URL: - -```text -http://127.0.0.1:8765 -``` - -## Prototype Daemon - -The current deployable prototype has two forms: - -- `MobileCodeHelperService.kt`: Android foreground-service prototype injected by `mobile_agent/tooling/prepare_android_project.py`. -- `mobilecode_helper_daemon.py`: small Python daemon that can run inside Termux while the standalone Helper APK is still being built. - -The Flutter app can start the Android service through the `mobilecode/system_tools` method channel: - -```text -startHelperService -stopHelperService -helperServiceStatus -``` - +# MobileCode Helper Runtime Protocol + +MobileCode Helper is a local Android companion daemon that can expose shell-like execution without tying the Flutter app directly to Termux. The Flutter app talks to the helper through localhost HTTP. + +## Endpoint Base + +Default base URL: + +```text +http://127.0.0.1:8765 +``` + +## Prototype Daemon + +The current deployable prototype has two forms: + +- `MobileCodeHelperService.kt`: Android foreground-service prototype injected by `mobile_agent/tooling/prepare_android_project.py`. +- `mobilecode_helper_daemon.py`: small Python daemon that can run inside Termux while the standalone Helper APK is still being built. + +The Flutter app can start the Android service through the `mobilecode/system_tools` method channel: + +```text +startHelperService +stopHelperService +helperServiceStatus +``` + The Termux/Python fallback can be started with: ```bash cd mobile_agent +pkg install -y python git ./tooling/run_mobilecode_helper_daemon.sh ``` - -Equivalent direct command: - -```bash -python3 tooling/mobilecode_helper_daemon.py \ - --host 127.0.0.1 \ - --port 8765 \ - --workspace-root "$HOME/mobilecode_projects" \ - --auth-token "$MOBILECODE_HELPER_TOKEN" -``` - -Both prototypes implement `/v1/health`, `/v1/execute`, `/v1/execute/stream`, `/v1/project/preflight`, `/v1/tasks/current`, `/v1/tasks`, `/v1/tasks/:id/logs`, `/v1/task/stop`, and `/v1/tasks/:id/stop`. Both prototypes intentionally run allowlisted commands without shell expansion and reject working directories outside the configured workspace boundary. - -## Localhost Auth - -The Python daemon supports an optional shared token: - -```http -X-MobileCode-Token: -Authorization: Bearer -``` - -If `--auth-token` or `MOBILECODE_HELPER_TOKEN` is set, every endpoint requires one of those headers and returns HTTP 401 with `failureKind: authFailed` when the token is missing or invalid. The Android foreground-service prototype currently reports `authRequired: false`; the next APK iteration should pass an app-generated token to the Flutter provider before enabling enforcement. - -## Health - -```http -GET /v1/health -``` - -Response: - -```json -{ - "name": "MobileCode Helper", - "available": true, - "ready": true, + +Equivalent direct command: + +```bash +python3 tooling/mobilecode_helper_daemon.py \ + --host 127.0.0.1 \ + --port 8765 \ + --workspace-root "$HOME/mobilecode_projects" \ + --auth-token "$MOBILECODE_HELPER_TOKEN" +``` + +Both prototypes implement `/v1/health`, `/v1/execute`, `/v1/execute/stream`, `/v1/project/preflight`, `/v1/tasks/current`, `/v1/tasks`, `/v1/tasks/:id/logs`, `/v1/task/stop`, and `/v1/tasks/:id/stop`. Both prototypes intentionally run allowlisted commands without shell expansion and reject working directories outside the configured workspace boundary. + +## Localhost Auth + +The Python daemon supports an optional shared token: + +```http +X-MobileCode-Token: +Authorization: Bearer +``` + +If `--auth-token` or `MOBILECODE_HELPER_TOKEN` is set, every endpoint requires one of those headers and returns HTTP 401 with `failureKind: authFailed` when the token is missing or invalid. The Android foreground-service prototype currently reports `authRequired: false`; the next APK iteration should pass an app-generated token to the Flutter provider before enabling enforcement. + +## Health + +```http +GET /v1/health +``` + +Response: + +```json +{ + "name": "MobileCode Helper", + "available": true, + "ready": true, "status": "Helper foreground service is running.", "protocolVersion": 1, + "runtimeKind": "helperApk", + "termux": false, "authRequired": true, - "capabilities": { - "shell": true, - "git": true, - "node": false, - "python": false, - "flutter": false, - "androidBuild": false, - "pty": true, - "backgroundService": true, - "webViewPreview": true, - "cloudBuild": false - }, - "taskRegistry": { - "runningCount": 0, - "maxTasks": 50 - }, - "missingDependencies": [], - "recoveryActions": [] -} -``` - -## Command Execution - -```http -POST /v1/execute -Content-Type: application/json -``` - -Request: - -```json -{ - "command": "git status", - "cwd": "/storage/emulated/0/Documents/MobileCode/project", - "env": {}, - "timeoutMs": 120000 -} -``` - -Response: - -```json -{ - "command": "git status", - "stdout": "", - "stderr": "", - "exitCode": 0, - "durationMs": 42, - "taskId": "task-1715780000000", - "failureKind": "none" -} -``` - -## Project Preflight - -Project preflight is a structured file inspection endpoint. The app should prefer it over arbitrary shell probes when the active runtime is MobileCode Helper. - -```http -POST /v1/project/preflight -Content-Type: application/json -``` - -Request: - -```json -{ - "cwd": "/helper/workspace/project" -} -``` - -Response: - -```json -{ - "success": true, - "cwd": "/helper/workspace/project", - "detectedFiles": ["./package.json", "./.git"] -} -``` - -The helper currently detects `package.json`, `pubspec.yaml`, `requirements.txt`, `pyproject.toml`, and `.git` within depth 2. MobileCode maps those markers to Node/npm, Flutter, Python, and Git-aware action flows before running Install/Test/Preview. - -## Streaming Execution - -```http -POST /v1/execute/stream -Content-Type: application/json -Accept: application/x-ndjson -``` - -Each response line is a JSON object: - -```json -{"type":"stdout","data":"build output"} -{"type":"stderr","data":"warning output"} -{"type":"exit","exitCode":0,"durationMs":1200,"taskId":"task-1715780000000"} -``` - -## Task Recovery - -```http -GET /v1/tasks/current -``` - -Response: - -```json -{ - "running": false, - "taskId": "task-1715780000000", - "command": "npm test", - "logs": ["stdout: test ok"], - "task": { - "id": "task-1715780000000", - "taskId": "task-1715780000000", - "command": "npm test", - "cwd": "/helper/workspace/project", - "status": "succeeded", - "startedAtMs": 1715780000000, - "finishedAtMs": 1715780001200, - "exitCode": 0, - "durationMs": 1200, - "logs": ["stdout: test ok"], - "failureKind": "none" - } -} -``` - -Task status values are: - -```text -queued, running, succeeded, failed, cancelled, timedOut, lost, unknown -``` - -Task failure kinds are: - -```text -none, timeout, cancelled, dependencyMissing, commandBlocked, cwdOutsideWorkspace, authFailed, processFailed, runtimeLost, unknown -``` - -Task history: - -```http -GET /v1/tasks?limit=20 -``` - -Response: - -```json -{ - "tasks": [ - { - "id": "task-1715780000000", - "status": "succeeded", - "command": "npm test", - "failureKind": "none", - "logs": ["stdout: test ok"] - } - ], - "count": 1 -} -``` - -Task logs: - -```http -GET /v1/tasks/task-1715780000000/logs?limit=200 -``` - -Response: - -```json -{ - "taskId": "task-1715780000000", - "logs": ["stdout: test ok"] -} -``` - -Task cancellation: - -```http -POST /v1/task/stop -``` - -Compatibility endpoint for the currently running task: - -Response: - -```json -{ - "success": true, - "stopped": true -} -``` - -Task ID endpoint for queue-ready clients: - -```http -POST /v1/tasks/task-1715780000000/stop -``` - -Helper implementations must treat tasks as a registry keyed by task ID, not as a single global process. A task record owns its command, status, timing, exit code, failure kind, and recent logs. Running process handles are stored in an in-memory `taskId -> process` map, while task snapshots are persisted for recovery. - -If a matching task is running, the helper should terminate only that task's process, mark the task `cancelled`, set `failureKind` to `cancelled`, append a stop log line, persist the task snapshot, and make the updated state visible from `/v1/tasks/current`, `/v1/tasks`, and `/v1/tasks/:id/logs`. Other running tasks must remain running. If the matching task exists but is not running, the endpoint should return `success: true` with `stopped: false` and the persisted `task`. If the task ID is unknown, return HTTP 404 with `success: false`. - -The Android service persists task history under its app-private runtime directory. The Termux/Python prototype persists the latest task as `.mobilecode-helper-task.json` and the recoverable task database as `.mobilecode-helper-tasks.json` in the configured workspace root. If the helper restarts while a task is marked `running`, it must return `lost`/`runtimeLost` with a recovery error instead of pretending the process is still alive. - -## Workspace Sync - -```http -POST /v1/sync -``` - -Request: - -```json -{ - "sourcePath": "/app/workspace/project", - "targetPath": "/helper/workspace/project" -} -``` - -Response: - -```json -{ - "success": true, - "sourcePath": "/app/workspace/project", - "targetPath": "/helper/workspace/project" -} -``` - -## Build And App Lifecycle - -Required endpoints: - -```text -POST /v1/build/web -POST /v1/build/apk -POST /v1/apk/install -POST /v1/app/launch -POST /v1/app/uninstall -POST /v1/task/stop -POST /v1/tasks/:id/stop -``` - -Build response: - -```json -{ - "success": true, - "outputPath": "/helper/workspace/project/build/web", - "buildTimeMs": 12000, - "fileSize": 123456 + "capabilities": { + "shell": true, + "git": true, + "node": false, + "python": false, + "flutter": false, + "androidBuild": false, + "pty": true, + "backgroundService": true, + "webViewPreview": true, + "cloudBuild": false + }, + "taskRegistry": { + "runningCount": 0, + "maxTasks": 50 + }, + "missingDependencies": [], + "recoveryActions": [] } ``` -The helper should reject unsafe paths, enforce command policy, stream logs promptly, and keep long-running work in a foreground service. +`runtimeKind` distinguishes execution surfaces: + +- `helperApk`: Android foreground-service helper. +- `termuxDaemon`: Python daemon running inside Termux; Repo Hub can use this for real `git clone` when `capabilities.git` is true. +- `helperPrototype`: Python daemon outside Termux, useful for desktop protocol tests. + +## Command Execution + +```http +POST /v1/execute +Content-Type: application/json +``` + +Request: + +```json +{ + "command": "git status", + "cwd": "/storage/emulated/0/Documents/MobileCode/project", + "env": {}, + "timeoutMs": 120000 +} +``` + +Response: + +```json +{ + "command": "git status", + "stdout": "", + "stderr": "", + "exitCode": 0, + "durationMs": 42, + "taskId": "task-1715780000000", + "failureKind": "none" +} +``` + +## Project Preflight + +Project preflight is a structured file inspection endpoint. The app should prefer it over arbitrary shell probes when the active runtime is MobileCode Helper. + +```http +POST /v1/project/preflight +Content-Type: application/json +``` + +Request: + +```json +{ + "cwd": "/helper/workspace/project" +} +``` + +Response: + +```json +{ + "success": true, + "cwd": "/helper/workspace/project", + "detectedFiles": ["./package.json", "./.git"] +} +``` + +The helper currently detects `package.json`, `pubspec.yaml`, `requirements.txt`, `pyproject.toml`, and `.git` within depth 2. MobileCode maps those markers to Node/npm, Flutter, Python, and Git-aware action flows before running Install/Test/Preview. + +## Streaming Execution + +```http +POST /v1/execute/stream +Content-Type: application/json +Accept: application/x-ndjson +``` + +Each response line is a JSON object: + +```json +{"type":"stdout","data":"build output"} +{"type":"stderr","data":"warning output"} +{"type":"exit","exitCode":0,"durationMs":1200,"taskId":"task-1715780000000"} +``` + +## Task Recovery + +```http +GET /v1/tasks/current +``` + +Response: + +```json +{ + "running": false, + "taskId": "task-1715780000000", + "command": "npm test", + "logs": ["stdout: test ok"], + "task": { + "id": "task-1715780000000", + "taskId": "task-1715780000000", + "command": "npm test", + "cwd": "/helper/workspace/project", + "status": "succeeded", + "startedAtMs": 1715780000000, + "finishedAtMs": 1715780001200, + "exitCode": 0, + "durationMs": 1200, + "logs": ["stdout: test ok"], + "failureKind": "none" + } +} +``` + +Task status values are: + +```text +queued, running, succeeded, failed, cancelled, timedOut, lost, unknown +``` + +Task failure kinds are: + +```text +none, timeout, cancelled, dependencyMissing, commandBlocked, cwdOutsideWorkspace, authFailed, processFailed, runtimeLost, unknown +``` + +Task history: + +```http +GET /v1/tasks?limit=20 +``` + +Response: + +```json +{ + "tasks": [ + { + "id": "task-1715780000000", + "status": "succeeded", + "command": "npm test", + "failureKind": "none", + "logs": ["stdout: test ok"] + } + ], + "count": 1 +} +``` + +Task logs: + +```http +GET /v1/tasks/task-1715780000000/logs?limit=200 +``` + +Response: + +```json +{ + "taskId": "task-1715780000000", + "logs": ["stdout: test ok"] +} +``` + +Task cancellation: + +```http +POST /v1/task/stop +``` + +Compatibility endpoint for the currently running task: + +Response: + +```json +{ + "success": true, + "stopped": true +} +``` + +Task ID endpoint for queue-ready clients: + +```http +POST /v1/tasks/task-1715780000000/stop +``` + +Helper implementations must treat tasks as a registry keyed by task ID, not as a single global process. A task record owns its command, status, timing, exit code, failure kind, and recent logs. Running process handles are stored in an in-memory `taskId -> process` map, while task snapshots are persisted for recovery. + +If a matching task is running, the helper should terminate only that task's process, mark the task `cancelled`, set `failureKind` to `cancelled`, append a stop log line, persist the task snapshot, and make the updated state visible from `/v1/tasks/current`, `/v1/tasks`, and `/v1/tasks/:id/logs`. Other running tasks must remain running. If the matching task exists but is not running, the endpoint should return `success: true` with `stopped: false` and the persisted `task`. If the task ID is unknown, return HTTP 404 with `success: false`. + +The Android service persists task history under its app-private runtime directory. The Termux/Python prototype persists the latest task as `.mobilecode-helper-task.json` and the recoverable task database as `.mobilecode-helper-tasks.json` in the configured workspace root. If the helper restarts while a task is marked `running`, it must return `lost`/`runtimeLost` with a recovery error instead of pretending the process is still alive. + +## Workspace Sync + +```http +POST /v1/sync +``` + +Request: + +```json +{ + "sourcePath": "/app/workspace/project", + "targetPath": "/helper/workspace/project" +} +``` + +Response: + +```json +{ + "success": true, + "sourcePath": "/app/workspace/project", + "targetPath": "/helper/workspace/project" +} +``` + +## Build And App Lifecycle + +Required endpoints: + +```text +POST /v1/build/web +POST /v1/build/apk +POST /v1/apk/install +POST /v1/app/launch +POST /v1/app/uninstall +POST /v1/task/stop +POST /v1/tasks/:id/stop +``` + +Build response: + +```json +{ + "success": true, + "outputPath": "/helper/workspace/project/build/web", + "buildTimeMs": 12000, + "fileSize": 123456 +} +``` + +The helper should reject unsafe paths, enforce command policy, stream logs promptly, and keep long-running work in a foreground service. diff --git a/docs/mobilecode-mobile-roles-and-extension-management.md b/docs/mobilecode-mobile-roles-and-extension-management.md new file mode 100644 index 0000000..027d136 --- /dev/null +++ b/docs/mobilecode-mobile-roles-and-extension-management.md @@ -0,0 +1,99 @@ +# MobileCode Mobile Roles And Extension Management + +## Purpose + +MobileCode should use roles only when they change execution behavior, evidence requirements, or safety boundaries. Roles are not decorative personas; they route mobile work to the right checks. + +## V1 Role Set + +| Role | Trigger | Output Contract | Completion Gate | +| --- | --- | --- | --- | +| `mobile-ui-designer` | Chat/UI/navigation changes, generated app screens, screenshot-to-code | Mobile layout, responsive states, touch targets, empty/loading/error states | Screenshot or emulator evidence shows the UI is usable on phone width | +| `web-preview-engineer` | HTML/CSS/JS artifact generation and WebView/browser preview | Self-contained HTML, local WebView path, external browser path, failure fallback | User can open code, WebView preview, browser preview, and copy the phone file path | +| `android-runtime-engineer` | Helper, Termux fallback, permissions, APK install/build | RuntimeProvider-safe action, health state, recovery suggestion | Runtime status explains the active backend and does not expose arbitrary shell by default | +| `release-qa-reviewer` | Version bump, APK artifact, CI, smoke test, release docs | Version line, build number, CI links, artifact hash, manual QA checklist | Exact release commit has Mobile Runtime CI, Android APK build, and Android smoke evidence | +| `extension-manager` | Skills, MCP, agents, hooks, memory, marketplace/import | Management entry point, enable/disable state, provenance, permission summary | User can find and inspect extension state without leaving the mobile app | + +## Future Role Progress Cards + +This is a TODO, not a v1 implementation requirement. The current app still runs a single visible agent trace; do not add multi-agent orchestration just for decoration. + +When MobileCode later supports role-routed or multi-agent execution, show progress as compact role cards under the task trace: + +- Each role card shows avatar, role name, assigned step, status, and a short progress meter. +- Cards are evidence-backed: a role appears only when that role owns a real action or review gate. +- The first visible polish already reuses copied local avatar assets under `mobile_agent/assets/role_avatars/`, sourced from `D:\study\code\0ai\产品\14-personal_knowledgebase\svg`. +- SVG/animated avatars are visual identity only; execution state must still come from task IDs, step status, logs, and review results. +- Keep this deferred until the single-agent trace, result card, GitHub Pages publish flow, and release QA are stable. + +## Routing Rules + +- Use `mobile-ui-designer` before changing Home, chat, drawer, settings, generated app cards, or preview layout. +- Use `web-preview-engineer` before accepting generated web artifacts as complete. +- Use `android-runtime-engineer` before exposing anything that runs commands, reads app-private files, opens Termux, or depends on Helper. +- Use `release-qa-reviewer` every time `pubspec.yaml` version changes. +- Use `extension-manager` for Skill, MCP, Agent, Hook, and Memory surfaces. + +## Extension Management Stop Line + +V1 should expose management surfaces, not build a full plugin marketplace. + +Required for V1: + +- Skill Manager entry. +- MCP Manager entry. +- Agent Manager entry. +- Memory Manager entry. +- Hook Registry read-only entry with hook point, owner, enabled state, and safety level. +- GitHub repository import for skill packages, with manifest preview before installation. +- MCP server registration from reviewed configuration only; registry discovery must not auto-run commands. +- Default installed HTML/UI skills can be disabled or uninstalled by the user; their built-in state must persist across restarts. + +## Built-In HTML Skill Line + +MobileCode's current primary artifact is HTML, so the most useful public skill ideas should become product-native defaults instead of only optional external packages. + +Default installed built-ins: + +| Skill | Internalized Advantage | Provenance | +| --- | --- | --- | +| `frontend_design` | Strong visual direction, typography, color, layout, and non-generic UI review | `https://github.com/anthropics/skills` | +| `ui_ux_pro_max` | Mobile UX flow, information hierarchy, and complete UI states | `https://github.com/nextlevelbuilder/ui-ux-pro-max-skill` | +| `shadcn_ui` | Owned component patterns, variants, dialogs, forms, and registry thinking | `https://github.com/giuseppe-trisciuoglio/developer-kit` | +| `stitch_html_design` | Prompt-to-interface structure and high-fidelity HTML screen generation | `https://github.com/google-labs-code/stitch-skills` | +| `web_accessibility` | Semantic HTML, focus order, contrast, labels, and reduced-motion defaults | `https://github.com/supercent-io/skills-template` | +| `web_design_guidelines` | Responsive composition, deployable web quality, and performance-aware UI | `https://github.com/vercel-labs/agent-skills` | +| `ui_animation` | CSS-first motion, micro-interactions, and reduced-motion fallback | `https://github.com/mblode/agent-skills` | +| `figma_implement_design` | Design-context extraction, token translation, visual parity discipline | `https://github.com/figma/mcp-server-guide` | +| `tailwind_design_system` | Tokenized spacing, typography, color, and reusable design-system rules | `https://github.com/wshobson/agents` | + +## External Registry Direction + +SkillHub and MCPHub can be useful discovery sources, but MobileCode should treat them as source adapters, not trusted execution backends. + +V1 integration contract: + +- SkillHub adapter: search/discover skills, resolve the selected item to a GitHub repository URL, then reuse the existing GitHub import and preview flow. +- MCPHub adapter: discover MCP server metadata, show command/transport/env requirements, then register the server disabled by default until the user explicitly enables it. +- GitHub repository import remains the common install path because it gives MobileCode a stable provenance URL, reviewable manifest, and update source. +- No marketplace result may install dependencies, start a server, write hooks, or run scripts without a separate runtime permission gate. + +The Tools control center should expose the real Agent, Skill, MCP, Memory, and Hook surfaces directly. Placeholder sheets are allowed only when the target surface cannot compile or is intentionally deferred. + +## Hook Registry V1 + +The Hook Registry is intentionally read-only in v1. It shows lifecycle hook points such as `chat.before_model_call`, `runtime.before_execute`, `files.before_write`, and `release.before_publish` with enabled state and safety level. + +Not included in v1: + +- Arbitrary script execution. +- Remote hook packages. +- Background hook automation. +- User-editable hook chains. + +Deferred beyond V1: + +- Remote marketplace ranking. +- Third-party code execution without review. +- Full hook scripting runtime. +- Multi-agent background marketplace automation. diff --git a/docs/mobilecode-principle-video-script.md b/docs/mobilecode-principle-video-script.md new file mode 100644 index 0000000..c9fa05e --- /dev/null +++ b/docs/mobilecode-principle-video-script.md @@ -0,0 +1,60 @@ +# MobileCode Principle Video Script + +This script matches `docs/assets/mobilecode-principle-remotion.mp4`. The video is rendered with Remotion and hosted by the HTML player at `docs/mobilecode-principle-video.html`. + +Audio source: `promo/mobilecode-remotion/public/audio/mobilecode-principle-voiceover.wav`. + +Generate audio locally: + +```powershell +cd promo/mobilecode-remotion +npm run voiceover +``` + +The full explainer uses a slower voice pace and renders at about 75 seconds. + +## Voiceover + +### 1. Why MobileCode Exists + +AI coding is moving to the phone, but a phone should not pretend to be a desktop workstation. MobileCode is built as a phone-native coding harness: the model can be remote, but the loop, files, preview, runtime state, and publishing controls stay close to the user. + +Subtitle: MobileCode 不是云端 IDE 外壳,而是把 AI coding harness 真正放到手机上。 + +### 2. The Pain + +Mobile coding breaks when execution is unclear. Users should not need to guess whether an action belongs to the app, Termux, a cloud shell, GitHub, or a hidden preview. The real problem is not just screen size; it is an undefined execution layer. + +Subtitle: 手机写代码的核心痛点不是屏幕小,而是执行层不清楚、失败不可恢复。 + +### 3. The Answer + +MobileCode keeps the harness on the phone and moves heavy work outward. The app owns chat state, tool trace, local files, WebView preview, runtime diagnostics, repo context, and final result cards. Heavy builds can run through external runtimes or GitHub. + +Subtitle: 手机保留对话、文件、预览、诊断和发布控制,把重构建交给外部平台。 + +### 4. Runtime Principle + +RuntimeProvider turns execution into a replaceable contract. The UI should not care whether work runs through MobileCode Helper, external Termux, WebViewOnly, Embedded Lite, or Cloud Runtime. Interface first, backend second. + +Subtitle: RuntimeProvider 让 Helper、Termux、WebViewOnly、Cloud 都成为可替换后端。 + +### 5. GitHub-First Loop + +The phone edits and explains. GitHub stores, builds, and ships. Repo Hub, Contents API commits, Pages publishing, Actions runs, and release artifacts keep MobileCode lightweight but real. + +Subtitle: GitHub 负责仓库、Pages、Actions 和产物,MobileCode 负责手机端闭环体验。 + +### 6. Outcome + +A phone can become the AI coding control room. Not because it compiles everything locally, but because it keeps the user-facing harness, state, explanations, previews, and shipping decisions close to the user. + +Subtitle: 最终目标:在手机上生成、预览、解释、发布,而不是伪装成桌面环境。 + +## 15-Second Social Cut + +Audio source: `promo/mobilecode-remotion/public/audio/mobilecode-short-voiceover.wav`. + +Voiceover: + +MobileCode 不是远程 IDE 外壳。它是真正运行在手机上的 AI coding harness。手机负责生成、预览和解释。Helper、Termux、GitHub Actions 负责执行和构建。从一句话到 HTML,从 WebView 到 GitHub Pages。MobileCode,让手机成为 AI coding 控制室。 diff --git a/docs/mobilecode-principle-video.html b/docs/mobilecode-principle-video.html new file mode 100644 index 0000000..f0d2c4f --- /dev/null +++ b/docs/mobilecode-principle-video.html @@ -0,0 +1,223 @@ + + + + + + MobileCode Principle Video + + + +
+
+
+
M
+
+

MobileCode Principle Video

+

Remotion-rendered HTML video page: demand, pain, solution, architecture, and GitHub-first loop.

+
+
+ +
+ +
+ +
+ +
+

15-second README and social preview cut

+ +
+ +
+
+ Demand +

AI coding is moving to the phone, but the phone needs a clear harness instead of a desktop clone.

+
+
+ Principle +

RuntimeProvider keeps execution replaceable while the app owns state, preview, files, and recovery.

+
+
+ Shipping +

GitHub Pages and Actions handle publishing and heavy builds while MobileCode stays lightweight.

+
+
+
+ + diff --git a/docs/mobilecode-release-qa.md b/docs/mobilecode-release-qa.md index a5e1d08..7aa4c75 100644 --- a/docs/mobilecode-release-qa.md +++ b/docs/mobilecode-release-qa.md @@ -21,7 +21,176 @@ Required GitHub Actions before publishing: - `.github/workflows/android-apk.yml` - Builds the release APK. - Uses stable signing when release keystore secrets are configured. - - Uploads `mobilecode-v0.1.0.apk` as an artifact and GitHub Release asset. + - Uploads `mobilecode-v0.1.10.apk` as an artifact and GitHub Release asset. + +## v0.1.10 Release Candidate + +Release candidate: + +- Branch: `v011-streaming-fix` +- App/build content commit: `39316ab0f1f466d1a9973bda5556ada06d9f2cf2` +- CI smoke workflow commit: `c0dc62fe0329b912a8337be353c3819f4fb1096f` +- Release: `https://github.com/Harzva/mobilecode/releases/tag/v0.1.10` +- APK asset: `https://github.com/Harzva/mobilecode/releases/download/v0.1.10/mobilecode-v0.1.10.apk` +- APK SHA256: `2603fc0b1ad4f5b5e4bb9b0a3c9f961b078e545174f6792972b87f81c5c8166c` + +Required CI evidence: + +| Gate | Run | Result | +| --- | --- | --- | +| Mobile Runtime CI | `https://github.com/Harzva/mobilecode/actions/runs/26015207199` | Passed | +| Build Android APK | `https://github.com/Harzva/mobilecode/actions/runs/26015207331` | Passed | +| Android App Smoke Test | `https://github.com/Harzva/mobilecode/actions/runs/26015653307` | Passed | + +Validated coverage: + +- Flutter scoped analyzer passed for runtime and Home entry surfaces. +- RuntimeProvider tests passed. +- Helper daemon protocol smoke passed. +- Android release APK built and uploaded as the `v0.1.10` release asset. +- Android emulator smoke verified Helper health/execute, launched the main app, captured screenshot/logcat artifacts, and checked common crash signatures. +- Runtime UX polish is included: folded long code viewing, bottom agent trace progress, respectful chat scrolling, and visual Role Recruit / RR mode without real multi-agent parallelism. + +Manual device coverage: + +- Verify GitHub Repo Hub with a signed-in account: current-user repos load, search/language/Pages/local filters work, watchlist survives app restart, and a repo can be added to the phone workspace. +- Verify repo cards show public/private, stars, language, Pages, default branch, recent push time, and local status without overflowing on a 360dp-wide screen. +- Verify an artifact stored under a remote-linked repo folder defaults the GitHub Pages deploy target to that bound owner/repo and explains token visibility errors. +- Verify Actions sheet refreshes workflow jobs, can dispatch a workflow, downloads artifact zip to the app-owned workspace, records it in Recent downloads, opens the zip/folder when Android allows it, and copies the local zip path. +- Verify Files sheet can browse repository folders, open a text file, edit it, commit through the GitHub Contents API with an explicit commit message, and recover from SHA conflicts by reloading the remote file. + +## v0.1.6 Release Evidence + +Release candidate: + +- Branch: `v011-streaming-fix` +- App/build content commit: `f1a6381abbc9912c35d8ff712ef7ac0e9d0edd89` +- Release: `https://github.com/Harzva/mobilecode/releases/tag/v0.1.6` +- APK asset: `https://github.com/Harzva/mobilecode/releases/download/v0.1.6/mobilecode-v0.1.6.apk` +- APK SHA256: `4dd3a7e6fd266874b54d4ed060b27172e915716061ce16ff5ef6e2bb03641622` + +Required CI evidence: + +| Gate | Run | Result | +| --- | --- | --- | +| Mobile Runtime CI | `https://github.com/Harzva/mobilecode/actions/runs/25986990236` | Passed | +| Build Android APK | `https://github.com/Harzva/mobilecode/actions/runs/25986990949` | Passed | +| Android App Smoke Test | `https://github.com/Harzva/mobilecode/actions/runs/25986991684` | Passed | + +Validated coverage: + +- Flutter scoped analyzer passed for runtime and Home entry surfaces. +- RuntimeProvider tests passed. +- Helper daemon protocol smoke passed. +- Android release APK built and uploaded as the v0.1.6 release asset. +- Android emulator smoke installed and launched the debug APK, captured screenshot/logcat artifacts, and checked common crash signatures. +- GitHub Pages deploy passed for the demo Pages site. + +Manual device coverage: + +- Physical-device validation remains required before promoting `v0.1.6` beyond prerelease. +- Verify generated HTML can pass/fail the pre-publish check, GitHub Pages errors explain token/permission recovery, the published work card opens Pages/repo and shows a live thumbnail, Lark CLI structured actions remain dry-run unless explicitly reviewed, and the new theme/avatar polish does not create small-screen overflow. + +## v0.1.5 Release Evidence + +Release candidate: + +- Branch: `v011-streaming-fix` +- App/build content commit: pending CI +- Release: pending +- APK asset: pending +- APK SHA256: pending + +Required CI evidence: + +| Gate | Run | Result | +| --- | --- | --- | +| Mobile Runtime CI | pending | Pending | +| Build Android APK | pending | Pending | +| Android App Smoke Test | pending | Pending | + +Validated coverage: + +- Pending after GitHub Pages pre-publish checks, published work cards with live Pages thumbnail, and Lark CLI structured dry-run actions pass CI. + +## v0.1.4 Release Evidence + +Release candidate: + +- Branch: `v011-streaming-fix` +- App/build content commit: pending CI +- Release: pending +- APK asset: pending +- APK SHA256: pending + +Required CI evidence: + +| Gate | Run | Result | +| --- | --- | --- | +| Mobile Runtime CI | pending | Pending | +| Build Android APK | pending | Pending | +| Android App Smoke Test | pending | Pending | + +Validated coverage: + +- Pending after HTML/UI skill prompt injection, account-free curated GitHub skill/MCP source adapter, and Node 24 Actions updates pass CI. + +## v0.1.3 Release Evidence + +Release candidate: + +- Branch: `v011-streaming-fix` +- App/build content commit: `1f266ca3c85810efdc0e609f8db6a99947898acf` +- Release: `https://github.com/Harzva/mobilecode/releases/tag/v0.1.3` +- APK asset: `https://github.com/Harzva/mobilecode/releases/download/v0.1.3/mobilecode-v0.1.3.apk` +- APK SHA256: `50e53bd2fb820aa3658bc6e6ff0fc3afab1a6c1ec69722013f4247a027561390` + +Required CI evidence: + +| Gate | Run | Result | +| --- | --- | --- | +| Mobile Runtime CI | `https://github.com/Harzva/mobilecode/actions/runs/25982172509` | Passed | +| Build Android APK | `https://github.com/Harzva/mobilecode/actions/runs/25982172502` | Passed | +| Android App Smoke Test | `https://github.com/Harzva/mobilecode/actions/runs/25982172557` | Passed | + +Validated coverage: + +- Flutter scoped analyzer passed for runtime and Home entry surfaces. +- RuntimeProvider tests passed. +- Helper daemon protocol smoke passed for health, execute, stream, task history, task logs, cancel, and project preflight. +- Android release APK build passed and uploaded the v0.1.3 release asset. +- Android emulator smoke built the debug APK, installed and launched the app, captured screenshot/logcat artifacts, and checked common crash signatures. + +## v0.1.2 Release Evidence + +Release candidate: + +- Branch: `v011-streaming-fix` +- App/build content commit: `1e53204` +- Release: `https://github.com/Harzva/mobilecode/releases/tag/v0.1.2` +- APK asset: `https://github.com/Harzva/mobilecode/releases/download/v0.1.2/mobilecode-v0.1.2.apk` +- APK SHA256: `69295185daa8f07af5d3d9145e85d961993a4ee80432acbff104961ef19c9f4f` + +Required CI evidence: + +| Gate | Run | Result | +| --- | --- | --- | +| Mobile Runtime CI | `https://github.com/Harzva/mobilecode/actions/runs/25980342388` | Passed | +| Build Android APK | `https://github.com/Harzva/mobilecode/actions/runs/25980342638` | Passed | +| Android App Smoke Test | `https://github.com/Harzva/mobilecode/actions/runs/25980342398` | Passed | + +Validated coverage: + +- Flutter scoped analyzer passed for runtime and Home entry surfaces. +- RuntimeProvider tests passed. +- Helper daemon protocol smoke passed for health, execute, stream, task history, task logs, cancel, and project preflight. +- Android release APK build passed and uploaded the release asset. +- Android emulator smoke installed the debug APK, started the Helper launcher, verified localhost Helper health and execute, launched the main app, captured screenshot/logcat artifacts, and checked common crash signatures. + +Manual device coverage: + +- Local `adb devices` showed no online device on 2026-05-17, so physical-device validation remains a manual release step. +- Before promoting `v0.1.2` beyond prerelease, verify provider/base URL settings, normal chat streaming, agent pause, new chat creation, recent chat turn counts, generated artifact browser preview, trace-step detail sheets, and Runtime Diagnostics on a real Android device. ## Android Release Signing @@ -96,7 +265,7 @@ Expected result: After downloading the release APK: ```bash -adb install -r mobilecode-v0.1.0.apk +adb install -r mobilecode-v0.1.10.apk adb shell monkey -p com.mobilecode.mobile_agent -c android.intent.category.LAUNCHER 1 adb shell pidof com.mobilecode.mobile_agent adb logcat -d -t 1200 > android-logcat.txt diff --git a/docs/mobilecode-rules-and-memory.md b/docs/mobilecode-rules-and-memory.md new file mode 100644 index 0000000..5e0d09b --- /dev/null +++ b/docs/mobilecode-rules-and-memory.md @@ -0,0 +1,49 @@ +# MobileCode Rules and Memory + +## Purpose + +MobileCode should keep two durable knowledge layers: + +- `Rules`: explicit, user-approved operating instructions, similar to `CLAUDE.md` or `AGENTS.md`. +- `Memory`: accumulated preferences, repo insights, habits, and reusable observations that can propose future rules. + +They are related, but not the same thing. + +## Rules + +Rules are the source of truth for how MobileCode should behave. + +Good examples: + +- Always prefer GitHub Pages for simple HTML publishing. +- Use GitHub Actions for heavy APK builds when local runtime has no Android SDK. +- Never run arbitrary MCP or Hook scripts without review. +- Keep generated web pages mobile-first and touch-friendly. + +Rules should be short, explicit, stable, and user-approved. + +In the app, accepted Rules can be exported as `MOBILECODE_RULES.md`. This is the MobileCode equivalent of a lightweight `CLAUDE.md` / `AGENTS.md`: a portable, user-approved instruction file that can be copied into a project or injected into future planning prompts. + +## Memory + +Memory is evidence and preference history. It can be learned from chats, repo READMEs, accepted role proposals, failed builds, and repeated user corrections. + +Good examples: + +- The user often publishes demos under `Harzva/*.github.io`. +- The user's Flutter projects usually use GitHub Actions for release builds. +- The user prefers Role Recruit mode as single-lane role personality orchestration, not parallel agents. + +Memory can suggest new Rules, but should not silently become a Rule. + +## Approval Flow + +1. MobileCode analyzes repos, chat, or task outcomes. +2. It creates `MemoryRuleProposal` or `RuleProposal`. +3. The user can save, edit then save, or ignore. +4. Accepted Rules are injected into future planning prompts. +5. Accepted Memory remains searchable context and can later produce more precise Rule proposals. + +## V1 Boundary + +For v1, Rules are a product model and release checklist item. They should not execute code. Hooks and MCP servers must remain reviewed and disabled by default unless the user explicitly enables them. diff --git a/docs/mobilecode-version-policy.md b/docs/mobilecode-version-policy.md new file mode 100644 index 0000000..6b8d3d4 --- /dev/null +++ b/docs/mobilecode-version-policy.md @@ -0,0 +1,84 @@ +# MobileCode Version Policy + +## Summary + +MobileCode uses semantic versioning, but the project is still pre-1.0. The version number should communicate release intent clearly, not simply increase because work happened. + +Current next release line: `0.1.30+49`. + +## Version Lines + +| Version line | Meaning | Examples | +| --- | --- | --- | +| `0.1.x` | v1 Runtime closure, bug fixes, QA hardening, release polish | provider settings, chat stop button, runtime diagnostics wording, APK smoke fixes | +| `0.2.0` | first larger runtime capability expansion after v1 closure | Helper APK foreground service maturity, task persistence, streaming logs recovery | +| `0.3.0` | new product workflow surface built on runtime abstraction | project import/clone flow, structured runtime actions in real UI | +| `1.0.0` | stable v1 product release | documented install path, repeatable APK release, passing CI, runtime state understandable to normal users | + +## Build Number Rule + +Flutter uses `version: MAJOR.MINOR.PATCH+BUILD`. + +- Increment `PATCH` for user-visible fixes inside the same release scope. +- Increment `MINOR` for a new capability line or workflow that changes the product boundary. +- Increment `BUILD` for every APK artifact built for QA or release. + +Examples: + +- `0.1.0+19`: first v1 runtime closure baseline APK. +- `0.1.1+20`: provider settings, chat session, pause/streaming, and diagnostics fixes. +- `0.1.2+21`: follow-up QA fix for chat persistence, generated artifact actions, browser preview, and tool-call detail UX. +- `0.1.3+22`: real Skill/MCP/Memory/Agent management routes, read-only Hook Registry, default HTML/UI skills, and extension source hardening. +- `0.1.4+23`: HTML/UI skill prompt injection, account-free curated GitHub skill/MCP source adapters, and Node 24 GitHub Actions updates. +- `0.1.5+24`: GitHub Pages pre-publish checks, published work cards with live Pages thumbnail, and Lark CLI structured dry-run actions. +- `0.1.6+25`: agent process role avatar polish, Claude Yellow / Codex Blue theme options, and release artifact version alignment. +- `0.1.7+26`: browser open preference, MobileCode Projects workspace browser, project-folder actions, Git folder badge, and official GitHub icon polish. +- `0.1.8+27`: GitHub Repo Hub, repo watchlist, phone workspace mapping, and lightweight GitHub Actions status/dispatch entrypoint. +- `0.1.9+28`: GitHub Repo Hub Actions polling/artifact download and API-backed file tree/read/edit/commit workspace flow. +- `0.1.10+29`: Runtime UX polish for folded code viewing, bottom trace progress, respectful chat scrolling, and visual Role Recruit / RR mode roles. +- `0.1.11+30`: Role library management, per-role SVG avatars, RR prompt role context, and AI-polished custom role cards. +- `0.1.12+31`: RR pending role approvals, AgentView role details, Token Usage/cache-hit statistics, and Device Telemetry. +- `0.1.13+32`: Configurable Token Usage pricing with LiteLLM-compatible built-in snapshot, user overrides, and source/update labels. +- `0.1.14+33`: Manual LiteLLM pricing snapshot check/apply flow with local cache and user-confirmed updates. +- `0.1.15+34`: Searchable Pricing table browser for all LiteLLM snapshot models and user overrides. +- `0.1.16+35`: Provider filter chips for the full Token Usage pricing table. +- `0.1.17+36`: Lightweight model-name and price sorting in the full Token Usage pricing table. +- `0.1.18+37`: CI analyzer fix for role polish timeout handling. +- `0.1.19+38`: Repo Hub workspace wording, Pages quick-open, repo chat handoff, and intent-polished repository creation. +- `0.1.20+39`: Visible Repo chat binding plus provider-backed repository intent polish with local fallback. +- `0.1.21+40`: GitHub connectivity status dots, Repo Hub search scopes/copy URL, and Skill GitHub discovery init fix. +- `0.1.22+41`: Repo Hub ownership/discovery boundaries, compact mobile chat composer, and clearer external repo management expectations. +- `0.1.23+42`: Release search asset preview, GitHub account operation boundary panel, Skill/MCP manifest risk review, and compact chat top bar. +- `0.1.24+43`: Repo Hub anonymous public search, local token login sheet, clearer account-gated operations, and logout confirmation. +- `0.1.25+44`: Role/Memory proposal edit polish, MobileCode logo asset, and splash branding refresh. +- `0.1.26+45`: Repo Hub Git clone fallback to Remote-linked workspace and friendlier repository intelligence fallback copy. +- `0.1.27+46`: Termux daemon runtime selection for real Termux git clone, clearer Hooks/MCP management affordances. +- `0.1.28+47`: Runtime workspace browse/sync entry for Termux git clones, including shared-folder copy actions. +- `0.1.29+48`: Recent shared runtime workspace sync history with quick open/copy actions. +- `0.1.30+49`: Global Downloads / Shared folders surface for Actions artifacts and runtime shared copies. +- `0.2.0+38`: Helper APK/runtime capability expansion starts. + +## Stop Rules + +Stay on `0.1.x` until these are true: + +- User can configure managed or custom provider with Base URL. +- Chat can create/select conversations reliably. +- Agent provider calls show progress and can be stopped. +- Runtime Diagnostics explains Helper, External Termux, planned Embedded Lite, Cloud, and WebViewOnly without false red failures. +- Android APK build and install smoke evidence exists for the exact release commit. + +Move to `0.2.0` only when the release adds a real runtime capability, not just UI polish. The recommended `0.2.0` trigger is a usable MobileCode Helper APK foreground service with task recovery evidence. + +Do not tag `1.0.0` until the app is installable, testable, documented, and understandable without developer guidance. + +## Release Naming + +Release tags should match the product version: + +- Tag: `v0.1.30` +- APK asset: `mobilecode-v0.1.30.apk` +- iOS simulator asset: `mobilecode-ios-simulator-v0.1.30.zip` +- Unsigned iOS archive asset: `mobilecode-ios-archive-v0.1.30.xcarchive.zip` + +If a release tag is supplied manually in GitHub Actions, artifact names should follow that tag. diff --git a/mobile_agent/android/app/src/main/AndroidManifest.xml b/mobile_agent/android/app/src/main/AndroidManifest.xml index 01f87dd..ed4d603 100644 --- a/mobile_agent/android/app/src/main/AndroidManifest.xml +++ b/mobile_agent/android/app/src/main/AndroidManifest.xml @@ -46,28 +46,17 @@ + android:debuggable="false" - - android:usesCleartextTraffic="false" - - android:networkSecurityConfig="@xml/network_security_config" - - android:allowBackup="false" android:fullBackupContent="false" - - android:extractNativeLibs="true" - - android:largeHeap="true"> + + + + + + diff --git a/mobile_agent/assets/icons/github-mark-24.svg b/mobile_agent/assets/icons/github-mark-24.svg new file mode 100644 index 0000000..81949e7 --- /dev/null +++ b/mobile_agent/assets/icons/github-mark-24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile_agent/assets/icons/mobilecode-logo.svg b/mobile_agent/assets/icons/mobilecode-logo.svg new file mode 100644 index 0000000..0be37e8 --- /dev/null +++ b/mobile_agent/assets/icons/mobilecode-logo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/agent-coder.svg b/mobile_agent/assets/role_avatars/agent-coder.svg new file mode 100644 index 0000000..455b1c0 --- /dev/null +++ b/mobile_agent/assets/role_avatars/agent-coder.svg @@ -0,0 +1,16 @@ + + Coder role avatar + A tiny orange square pet with blinking code lines on a laptop. + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/agent-codex-blue.svg b/mobile_agent/assets/role_avatars/agent-codex-blue.svg new file mode 100644 index 0000000..e1219df --- /dev/null +++ b/mobile_agent/assets/role_avatars/agent-codex-blue.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/agent-magic.svg b/mobile_agent/assets/role_avatars/agent-magic.svg new file mode 100644 index 0000000..610e3cb --- /dev/null +++ b/mobile_agent/assets/role_avatars/agent-magic.svg @@ -0,0 +1,17 @@ + + Instruction role avatar + A tiny orange square pet casting sparkling magic. + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/agent-rocket.svg b/mobile_agent/assets/role_avatars/agent-rocket.svg new file mode 100644 index 0000000..4385e41 --- /dev/null +++ b/mobile_agent/assets/role_avatars/agent-rocket.svg @@ -0,0 +1,17 @@ + + Artifact role avatar + A tiny orange square pet flying with pulsing rocket flames. + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/agent-wave.svg b/mobile_agent/assets/role_avatars/agent-wave.svg new file mode 100644 index 0000000..d807c42 --- /dev/null +++ b/mobile_agent/assets/role_avatars/agent-wave.svg @@ -0,0 +1,19 @@ + + Report role avatar + A tiny orange square pet waving one hand. + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-01-mist-studio.svg b/mobile_agent/assets/role_avatars/avatar-batch2-01-mist-studio.svg new file mode 100644 index 0000000..b9d9c70 --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-01-mist-studio.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-02-office-glasses.svg b/mobile_agent/assets/role_avatars/avatar-batch2-02-office-glasses.svg new file mode 100644 index 0000000..01d96ce --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-02-office-glasses.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-09-blue-cap.svg b/mobile_agent/assets/role_avatars/avatar-batch2-09-blue-cap.svg new file mode 100644 index 0000000..5e7f405 --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-09-blue-cap.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-14-sticker.svg b/mobile_agent/assets/role_avatars/avatar-batch2-14-sticker.svg new file mode 100644 index 0000000..6445984 --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-14-sticker.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-15-tech.svg b/mobile_agent/assets/role_avatars/avatar-batch2-15-tech.svg new file mode 100644 index 0000000..0572c9f --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-15-tech.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-18-pencil-wash.svg b/mobile_agent/assets/role_avatars/avatar-batch2-18-pencil-wash.svg new file mode 100644 index 0000000..87487dc --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-18-pencil-wash.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-21-navy.svg b/mobile_agent/assets/role_avatars/avatar-batch2-21-navy.svg new file mode 100644 index 0000000..8058de9 --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-21-navy.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-23-mono.svg b/mobile_agent/assets/role_avatars/avatar-batch2-23-mono.svg new file mode 100644 index 0000000..12978a7 --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-23-mono.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-24-rounded-icon.svg b/mobile_agent/assets/role_avatars/avatar-batch2-24-rounded-icon.svg new file mode 100644 index 0000000..c9269d6 --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-24-rounded-icon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/avatar-batch2-35-yellow-bucket.svg b/mobile_agent/assets/role_avatars/avatar-batch2-35-yellow-bucket.svg new file mode 100644 index 0000000..6b2d874 --- /dev/null +++ b/mobile_agent/assets/role_avatars/avatar-batch2-35-yellow-bucket.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/claude-girl-dancer.svg b/mobile_agent/assets/role_avatars/claude-girl-dancer.svg new file mode 100644 index 0000000..ddbe690 --- /dev/null +++ b/mobile_agent/assets/role_avatars/claude-girl-dancer.svg @@ -0,0 +1 @@ +Pink girl Claude dancer petA tiny pink square pet in a tutu. diff --git a/mobile_agent/assets/role_avatars/claude-pet-animated-coder.svg b/mobile_agent/assets/role_avatars/claude-pet-animated-coder.svg new file mode 100644 index 0000000..fa1c0fa --- /dev/null +++ b/mobile_agent/assets/role_avatars/claude-pet-animated-coder.svg @@ -0,0 +1,16 @@ + + Animated coder Claude pet + A tiny orange square pet with blinking code lines on a laptop. + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/claude-pet-animated-magic.svg b/mobile_agent/assets/role_avatars/claude-pet-animated-magic.svg new file mode 100644 index 0000000..fa9a49a --- /dev/null +++ b/mobile_agent/assets/role_avatars/claude-pet-animated-magic.svg @@ -0,0 +1,17 @@ + + Animated magic Claude pet + A tiny orange square pet casting sparkling magic. + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/claude-pet-animated-rocket.svg b/mobile_agent/assets/role_avatars/claude-pet-animated-rocket.svg new file mode 100644 index 0000000..c15196a --- /dev/null +++ b/mobile_agent/assets/role_avatars/claude-pet-animated-rocket.svg @@ -0,0 +1,17 @@ + + Animated rocket Claude pet + A tiny orange square pet flying with pulsing rocket flames. + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/role_avatars/claude-pet-animated-wave.svg b/mobile_agent/assets/role_avatars/claude-pet-animated-wave.svg new file mode 100644 index 0000000..3984d16 --- /dev/null +++ b/mobile_agent/assets/role_avatars/claude-pet-animated-wave.svg @@ -0,0 +1,19 @@ + + Animated waving Claude pet + A tiny orange square pet waving one hand. + + + + + + + + + + + + + + + + diff --git a/mobile_agent/assets/token_pricing/litellm_price_snapshot.json b/mobile_agent/assets/token_pricing/litellm_price_snapshot.json new file mode 100644 index 0000000..7bc5743 --- /dev/null +++ b/mobile_agent/assets/token_pricing/litellm_price_snapshot.json @@ -0,0 +1,92 @@ +{ + "snapshotId": "litellm-compatible-2026-05-18", + "sourceName": "LiteLLM model_prices_and_context_window.json", + "sourceUrl": "https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json", + "updatedAt": "2026-05-18", + "currency": "USD", + "unit": "per_token", + "notes": "Small built-in snapshot for offline MobileCode estimates. Users can override any provider/model price inside Token Usage.", + "prices": [ + { + "provider": "openai", + "model": "gpt-4o-mini", + "input_cost_per_token": 0.00000015, + "output_cost_per_token": 0.0000006, + "cache_read_input_token_cost": 0.000000075 + }, + { + "provider": "openai", + "model": "gpt-4o", + "input_cost_per_token": 0.0000025, + "output_cost_per_token": 0.00001, + "cache_read_input_token_cost": 0.00000125 + }, + { + "provider": "openai", + "model": "gpt-4.1-mini", + "input_cost_per_token": 0.0000004, + "output_cost_per_token": 0.0000016, + "cache_read_input_token_cost": 0.0000001 + }, + { + "provider": "openai", + "model": "gpt-4.1", + "input_cost_per_token": 0.000002, + "output_cost_per_token": 0.000008, + "cache_read_input_token_cost": 0.0000005 + }, + { + "provider": "anthropic", + "model": "claude-3-5-haiku-latest", + "input_cost_per_token": 0.0000008, + "output_cost_per_token": 0.000004, + "cache_read_input_token_cost": 0.00000008, + "cache_creation_input_token_cost": 0.000001 + }, + { + "provider": "anthropic", + "model": "claude-3-5-sonnet-latest", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 0.0000003, + "cache_creation_input_token_cost": 0.00000375 + }, + { + "provider": "anthropic", + "model": "claude-3-7-sonnet-latest", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 0.0000003, + "cache_creation_input_token_cost": 0.00000375 + }, + { + "provider": "anthropic", + "model": "claude-sonnet-4-0", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 0.0000003, + "cache_creation_input_token_cost": 0.00000375 + }, + { + "provider": "anthropic", + "model": "mimo-v2.5-pro", + "input_cost_per_token": 0.000003, + "output_cost_per_token": 0.000015, + "cache_read_input_token_cost": 0.0000003, + "cache_creation_input_token_cost": 0.00000375, + "notes": "Managed MobileCode default. Override this if your gateway contract uses a different price." + }, + { + "provider": "google", + "model": "gemini-1.5-flash", + "input_cost_per_token": 0.000000075, + "output_cost_per_token": 0.0000003 + }, + { + "provider": "google", + "model": "gemini-1.5-pro", + "input_cost_per_token": 0.00000125, + "output_cost_per_token": 0.000005 + } + ] +} diff --git a/mobile_agent/lib/core/constants.dart b/mobile_agent/lib/core/constants.dart index e1bf5e5..4c69a89 100644 --- a/mobile_agent/lib/core/constants.dart +++ b/mobile_agent/lib/core/constants.dart @@ -1,388 +1,388 @@ -/// Application-wide constants for MobileCode. -/// -/// Organized into namespaced classes for clean access: -/// ```dart -/// AppStrings.appName -/// ApiEndpoints.openAiBase -/// StorageKeys.projects -/// ``` -library; - -// ═══════════════════════════════════════════════════════════════════════════ -// String Constants -// ═══════════════════════════════════════════════════════════════════════════ - -/// UI text and display strings -class AppStrings { - AppStrings._(); - - static const String appName = 'MobileCode'; - static const String appTagline = '随时随地,Vibing Coding'; - static const String appSlogan = '用安卓开发安卓,用安卓开发世界'; - static const String appSloganAlt = '让手机发烫的除了游戏,还有你写的每一行代码'; - static const String deepDiveName = '深潜模式'; - static const String deepDiveSlogan = 'AI深潜,代码浮现'; - static const String appVersion = '0.1.0'; - - // Navigation labels - static const String navHome = 'Home'; - static const String navEditor = 'Editor'; - static const String navProjects = 'Projects'; - static const String navSnippets = 'Snippets'; - static const String navGitHub = 'GitHub'; - static const String navSettings = 'Settings'; - - // Editor - static const String editorTitle = 'Code Editor'; - static const String editorNewFile = 'New File'; - static const String editorSave = 'Save'; - static const String editorRun = 'Run'; - static const String editorUndo = 'Undo'; - static const String editorRedo = 'Redo'; - static const String editorSearch = 'Search'; - static const String editorReplace = 'Replace'; - static const String editorGoToLine = 'Go to Line'; - static const String editorFontSize = 'Font Size'; - static const String editorWordWrap = 'Word Wrap'; - static const String editorLineNumbers = 'Line Numbers'; - - // AI Chat - static const String aiTitle = 'AI Assistant'; - static const String aiSend = 'Send'; - static const String aiHint = 'Ask me anything about your code...'; - static const String aiThinking = 'Thinking...'; - static const String aiError = 'Something went wrong. Please try again.'; - static const String aiCopy = 'Copy'; - static const String aiInsert = 'Insert into Editor'; - static const String aiExplain = 'Explain this code'; - static const String aiRefactor = 'Refactor'; - static const String aiGenerate = 'Generate'; - static const String aiReview = 'Review Code'; - - // Projects - static const String projectNew = 'New Project'; - static const String projectName = 'Project Name'; - static const String projectDescription = 'Description'; - static const String projectLanguage = 'Language'; - static const String projectDelete = 'Delete Project'; - static const String projectRename = 'Rename'; - static const String projectDuplicate = 'Duplicate'; - static const String projectFavorite = 'Favorite'; - static const String projectEmpty = 'No projects yet. Create one!'; - - // Snippets - static const String snippetNew = 'New Snippet'; - static const String snippetTitle = 'Title'; - static const String snippetContent = 'Code'; - static const String snippetTags = 'Tags'; - static const String snippetFromVoice = 'Voice Note'; - static const String snippetFromText = 'Quick Note'; - static const String snippetFromScreenshot = 'Screenshot'; - static const String snippetEmpty = 'No snippets yet. Capture an idea!'; - static const String snippetDeleteConfirm = 'Delete this snippet?'; - - // GitHub - static const String githubConnect = 'Connect GitHub'; - static const String githubDisconnect = 'Disconnect'; - static const String githubRepos = 'Repositories'; - static const String githubSync = 'Sync'; - static const String githubClone = 'Clone'; - static const String githubPush = 'Push'; - static const String githubPull = 'Pull'; - static const String githubBranch = 'Branch'; - static const String githubCommit = 'Commit'; - static const String githubEmpty = 'No repositories connected'; - - // API Configuration - static const String apiConfigTitle = 'AI API Configuration'; - static const String apiProvider = 'Provider'; - static const String apiKey = 'API Key'; - static const String apiBaseUrl = 'Base URL'; - static const String apiModel = 'Model'; - static const String apiAdd = 'Add Configuration'; - static const String apiDelete = 'Remove Configuration'; - static const String apiTest = 'Test Connection'; - static const String apiEmpty = 'No API configurations. Add one to use AI features.'; - - // Settings - static const String settingsTitle = 'Settings'; - static const String settingsTheme = 'Theme'; - static const String settingsEditor = 'Editor Settings'; - static const String settingsAI = 'AI Settings'; - static const String settingsGitHub = 'GitHub Account'; - static const String settingsAbout = 'About'; - static const String settingsPrivacy = 'Privacy Policy'; - static const String settingsTerms = 'Terms of Service'; - static const String settingsRate = 'Rate App'; - static const String settingsShare = 'Share App'; - static const String settingsFeedback = 'Send Feedback'; - - // Common - static const String cancel = 'Cancel'; - static const String confirm = 'Confirm'; - static const String delete = 'Delete'; - static const String save = 'Save'; - static const String edit = 'Edit'; - static const String create = 'Create'; - static const String done = 'Done'; - static const String close = 'Close'; - static const String search = 'Search'; - static const String loading = 'Loading...'; - static const String error = 'Error'; - static const String retry = 'Retry'; - static const String empty = 'Nothing here yet'; +/// Application-wide constants for MobileCode. +/// +/// Organized into namespaced classes for clean access: +/// ```dart +/// AppStrings.appName +/// ApiEndpoints.openAiBase +/// StorageKeys.projects +/// ``` +library; + +// ═══════════════════════════════════════════════════════════════════════════ +// String Constants +// ═══════════════════════════════════════════════════════════════════════════ + +/// UI text and display strings +class AppStrings { + AppStrings._(); + + static const String appName = 'MobileCode'; + static const String appTagline = '随时随地,Vibing Coding'; + static const String appSlogan = '用安卓开发安卓,用安卓开发世界'; + static const String appSloganAlt = '让手机发烫的除了游戏,还有你写的每一行代码'; + static const String deepDiveName = '深潜模式'; + static const String deepDiveSlogan = 'AI深潜,代码浮现'; + static const String appVersion = '0.1.4'; + + // Navigation labels + static const String navHome = 'Home'; + static const String navEditor = 'Editor'; + static const String navProjects = 'Projects'; + static const String navSnippets = 'Snippets'; + static const String navGitHub = 'GitHub'; + static const String navSettings = 'Settings'; + + // Editor + static const String editorTitle = 'Code Editor'; + static const String editorNewFile = 'New File'; + static const String editorSave = 'Save'; + static const String editorRun = 'Run'; + static const String editorUndo = 'Undo'; + static const String editorRedo = 'Redo'; + static const String editorSearch = 'Search'; + static const String editorReplace = 'Replace'; + static const String editorGoToLine = 'Go to Line'; + static const String editorFontSize = 'Font Size'; + static const String editorWordWrap = 'Word Wrap'; + static const String editorLineNumbers = 'Line Numbers'; + + // AI Chat + static const String aiTitle = 'AI Assistant'; + static const String aiSend = 'Send'; + static const String aiHint = 'Ask me anything about your code...'; + static const String aiThinking = 'Thinking...'; + static const String aiError = 'Something went wrong. Please try again.'; + static const String aiCopy = 'Copy'; + static const String aiInsert = 'Insert into Editor'; + static const String aiExplain = 'Explain this code'; + static const String aiRefactor = 'Refactor'; + static const String aiGenerate = 'Generate'; + static const String aiReview = 'Review Code'; + + // Projects + static const String projectNew = 'New Project'; + static const String projectName = 'Project Name'; + static const String projectDescription = 'Description'; + static const String projectLanguage = 'Language'; + static const String projectDelete = 'Delete Project'; + static const String projectRename = 'Rename'; + static const String projectDuplicate = 'Duplicate'; + static const String projectFavorite = 'Favorite'; + static const String projectEmpty = 'No projects yet. Create one!'; + + // Snippets + static const String snippetNew = 'New Snippet'; + static const String snippetTitle = 'Title'; + static const String snippetContent = 'Code'; + static const String snippetTags = 'Tags'; + static const String snippetFromVoice = 'Voice Note'; + static const String snippetFromText = 'Quick Note'; + static const String snippetFromScreenshot = 'Screenshot'; + static const String snippetEmpty = 'No snippets yet. Capture an idea!'; + static const String snippetDeleteConfirm = 'Delete this snippet?'; + + // GitHub + static const String githubConnect = 'Connect GitHub'; + static const String githubDisconnect = 'Disconnect'; + static const String githubRepos = 'Repositories'; + static const String githubSync = 'Sync'; + static const String githubClone = 'Clone'; + static const String githubPush = 'Push'; + static const String githubPull = 'Pull'; + static const String githubBranch = 'Branch'; + static const String githubCommit = 'Commit'; + static const String githubEmpty = 'No repositories connected'; + + // API Configuration + static const String apiConfigTitle = 'AI API Configuration'; + static const String apiProvider = 'Provider'; + static const String apiKey = 'API Key'; + static const String apiBaseUrl = 'Base URL'; + static const String apiModel = 'Model'; + static const String apiAdd = 'Add Configuration'; + static const String apiDelete = 'Remove Configuration'; + static const String apiTest = 'Test Connection'; + static const String apiEmpty = 'No API configurations. Add one to use AI features.'; + + // Settings + static const String settingsTitle = 'Settings'; + static const String settingsTheme = 'Theme'; + static const String settingsEditor = 'Editor Settings'; + static const String settingsAI = 'AI Settings'; + static const String settingsGitHub = 'GitHub Account'; + static const String settingsAbout = 'About'; + static const String settingsPrivacy = 'Privacy Policy'; + static const String settingsTerms = 'Terms of Service'; + static const String settingsRate = 'Rate App'; + static const String settingsShare = 'Share App'; + static const String settingsFeedback = 'Send Feedback'; + + // Common + static const String cancel = 'Cancel'; + static const String confirm = 'Confirm'; + static const String delete = 'Delete'; + static const String save = 'Save'; + static const String edit = 'Edit'; + static const String create = 'Create'; + static const String done = 'Done'; + static const String close = 'Close'; + static const String search = 'Search'; + static const String loading = 'Loading...'; + static const String error = 'Error'; + static const String retry = 'Retry'; + static const String empty = 'Nothing here yet'; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// API Endpoints +// ═══════════════════════════════════════════════════════════════════════════ + +/// External API endpoint URLs +class ApiEndpoints { + ApiEndpoints._(); + + // OpenAI + static const String openAiBase = 'https://api.openai.com/v1'; + static const String openAiModels = '/models'; + static const String openAiChat = '/chat/completions'; + static const String openAiAudioTranscription = '/audio/transcriptions'; + + // Anthropic (Claude) + static const String claudeBase = 'https://api.anthropic.com'; + static const String claudeMessages = '/v1/messages'; + + // Google (Gemini) + static const String geminiBase = 'https://generativelanguage.googleapis.com'; + + // GitHub API + static const String githubBase = 'https://api.github.com'; + static const String githubUser = '/user'; + static const String githubUserRepos = '/user/repos'; + static const String githubRepos = '/repos'; + static const String githubSearchRepos = '/search/repositories'; + static const String githubContents = '/contents'; + static const String githubGitTrees = '/git/trees'; + + // GitHub OAuth + static const String githubAuthorize = 'https://github.com/login/oauth/authorize'; + static const String githubAccessToken = 'https://github.com/login/oauth/access_token'; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Storage Keys (for Hive & SharedPreferences) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Hive box names +class StorageBoxes { + StorageBoxes._(); + + static const String projects = 'projects'; + static const String snippets = 'snippets'; + static const String apiConfigs = 'api_configs'; + static const String githubRepos = 'github_repos'; + static const String chatHistory = 'chat_history'; +} + +/// SharedPreferences keys +class PreferenceKeys { + PreferenceKeys._(); + + // App State + static const String onboardingComplete = 'onboarding_complete'; + static const String lastVisitedProject = 'last_visited_project'; + + // Theme + static const String themeMode = 'theme_mode'; + + // Editor + static const String editorFontSize = 'editor_font_size'; + static const String editorFontFamily = 'editor_font_family'; + static const String editorWordWrap = 'editor_word_wrap'; + static const String editorShowLineNumbers = 'editor_show_line_numbers'; + static const String editorTabSize = 'editor_tab_size'; + static const String editorUseSpaces = 'editor_use_spaces'; + + // AI + static const String defaultProvider = 'default_ai_provider'; + static const String defaultModel = 'default_ai_model'; + static const String aiTemperature = 'ai_temperature'; + static const String aiMaxTokens = 'ai_max_tokens'; + static const String aiStreamResponse = 'ai_stream_response'; + + // GitHub + static const String githubToken = 'github_token'; + static const String githubUsername = 'github_username'; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Feature Flags +// ═══════════════════════════════════════════════════════════════════════════ + +/// Feature flags for gradual rollout and A/B testing +class FeatureFlags { + FeatureFlags._(); + + /// Enable AI chat streaming responses + static const bool enableStreamingChat = true; + + /// Enable voice-to-code snippet creation + static const bool enableVoiceSnippets = false; // Coming soon + + /// Enable screenshot-to-code (OCR) + static const bool enableScreenshotOcr = false; // Coming soon + + /// Enable real-time collaboration + static const bool enableCollaboration = false; // Coming soon + + /// Enable cloud sync for projects + static const bool enableCloudSync = false; // Coming soon + + /// Enable GitHub Actions integration + static const bool enableGitHubActions = false; // Coming soon + + /// Show debug info in UI + static const bool showDebugInfo = false; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Default Values +// ═══════════════════════════════════════════════════════════════════════════ + +/// Default configuration values +class Defaults { + Defaults._(); + + // Editor + static const double editorFontSize = 14.0; + static const String editorFontFamily = 'JetBrainsMono'; + static const bool editorWordWrap = true; + static const bool editorShowLineNumbers = true; + static const int editorTabSize = 2; + static const bool editorUseSpaces = true; + + // AI + static const String aiProvider = 'openai'; + static const String aiModel = 'gpt-4o-mini'; + static const double aiTemperature = 0.7; + static const int aiMaxTokens = 4096; + static const bool aiStreamResponse = true; + + // Pagination + static const int pageSize = 20; + static const int maxRecentFiles = 10; + + // Timeouts + static const int apiTimeoutSeconds = 60; + static const int connectionTimeoutSeconds = 10; + + // Limits + static const int maxSnippetTags = 10; + static const int maxProjectFiles = 1000; + static const int maxChatHistory = 100; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Supported Languages +// ═══════════════════════════════════════════════════════════════════════════ + +/// Programming languages supported by the editor +class SupportedLanguages { + SupportedLanguages._(); + + static const Map languages = { + 'dart': 'Dart', + 'javascript': 'JavaScript', + 'typescript': 'TypeScript', + 'python': 'Python', + 'go': 'Go', + 'rust': 'Rust', + 'java': 'Java', + 'kotlin': 'Kotlin', + 'swift': 'Swift', + 'php': 'PHP', + 'ruby': 'Ruby', + 'c': 'C', + 'cpp': 'C++', + 'csharp': 'C#', + 'html': 'HTML', + 'css': 'CSS', + 'json': 'JSON', + 'yaml': 'YAML', + 'markdown': 'Markdown', + 'sql': 'SQL', + 'shell': 'Shell', + 'dockerfile': 'Dockerfile', + }; + + /// File extension to language mapping + static const Map extensionMap = { + '.dart': 'dart', + '.js': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.jsx': 'javascript', + '.py': 'python', + '.go': 'go', + '.rs': 'rust', + '.java': 'java', + '.kt': 'kotlin', + '.swift': 'swift', + '.php': 'php', + '.rb': 'ruby', + '.c': 'c', + '.cpp': 'cpp', + '.h': 'c', + '.cs': 'csharp', + '.html': 'html', + '.htm': 'html', + '.css': 'css', + '.scss': 'css', + '.json': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', + '.md': 'markdown', + '.sql': 'sql', + '.sh': 'shell', + '.bash': 'shell', + '.zsh': 'shell', + '.dockerfile': 'dockerfile', + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// AI Provider Models +// ═══════════════════════════════════════════════════════════════════════════ + +/// Available models per AI provider +class AiModels { + AiModels._(); + + static const Map> modelsByProvider = { + 'openai': [ + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-4-turbo', + 'gpt-3.5-turbo', + ], + 'claude': [ + 'claude-3-5-sonnet-latest', + 'claude-3-5-haiku-latest', + 'claude-3-opus-latest', + ], + 'gemini': [ + 'gemini-2.0-flash', + 'gemini-2.0-pro', + 'gemini-1.5-flash', + 'gemini-1.5-pro', + ], + 'custom': [], // User-defined + }; + + /// Default base URLs for each provider + static const Map defaultBaseUrls = { + 'openai': 'https://api.openai.com/v1', + 'claude': 'https://api.anthropic.com', + 'gemini': 'https://generativelanguage.googleapis.com/v1beta', + 'custom': '', + }; } - -// ═══════════════════════════════════════════════════════════════════════════ -// API Endpoints -// ═══════════════════════════════════════════════════════════════════════════ - -/// External API endpoint URLs -class ApiEndpoints { - ApiEndpoints._(); - - // OpenAI - static const String openAiBase = 'https://api.openai.com/v1'; - static const String openAiModels = '/models'; - static const String openAiChat = '/chat/completions'; - static const String openAiAudioTranscription = '/audio/transcriptions'; - - // Anthropic (Claude) - static const String claudeBase = 'https://api.anthropic.com'; - static const String claudeMessages = '/v1/messages'; - - // Google (Gemini) - static const String geminiBase = 'https://generativelanguage.googleapis.com'; - - // GitHub API - static const String githubBase = 'https://api.github.com'; - static const String githubUser = '/user'; - static const String githubUserRepos = '/user/repos'; - static const String githubRepos = '/repos'; - static const String githubSearchRepos = '/search/repositories'; - static const String githubContents = '/contents'; - static const String githubGitTrees = '/git/trees'; - - // GitHub OAuth - static const String githubAuthorize = 'https://github.com/login/oauth/authorize'; - static const String githubAccessToken = 'https://github.com/login/oauth/access_token'; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Storage Keys (for Hive & SharedPreferences) -// ═══════════════════════════════════════════════════════════════════════════ - -/// Hive box names -class StorageBoxes { - StorageBoxes._(); - - static const String projects = 'projects'; - static const String snippets = 'snippets'; - static const String apiConfigs = 'api_configs'; - static const String githubRepos = 'github_repos'; - static const String chatHistory = 'chat_history'; -} - -/// SharedPreferences keys -class PreferenceKeys { - PreferenceKeys._(); - - // App State - static const String onboardingComplete = 'onboarding_complete'; - static const String lastVisitedProject = 'last_visited_project'; - - // Theme - static const String themeMode = 'theme_mode'; - - // Editor - static const String editorFontSize = 'editor_font_size'; - static const String editorFontFamily = 'editor_font_family'; - static const String editorWordWrap = 'editor_word_wrap'; - static const String editorShowLineNumbers = 'editor_show_line_numbers'; - static const String editorTabSize = 'editor_tab_size'; - static const String editorUseSpaces = 'editor_use_spaces'; - - // AI - static const String defaultProvider = 'default_ai_provider'; - static const String defaultModel = 'default_ai_model'; - static const String aiTemperature = 'ai_temperature'; - static const String aiMaxTokens = 'ai_max_tokens'; - static const String aiStreamResponse = 'ai_stream_response'; - - // GitHub - static const String githubToken = 'github_token'; - static const String githubUsername = 'github_username'; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Feature Flags -// ═══════════════════════════════════════════════════════════════════════════ - -/// Feature flags for gradual rollout and A/B testing -class FeatureFlags { - FeatureFlags._(); - - /// Enable AI chat streaming responses - static const bool enableStreamingChat = true; - - /// Enable voice-to-code snippet creation - static const bool enableVoiceSnippets = false; // Coming soon - - /// Enable screenshot-to-code (OCR) - static const bool enableScreenshotOcr = false; // Coming soon - - /// Enable real-time collaboration - static const bool enableCollaboration = false; // Coming soon - - /// Enable cloud sync for projects - static const bool enableCloudSync = false; // Coming soon - - /// Enable GitHub Actions integration - static const bool enableGitHubActions = false; // Coming soon - - /// Show debug info in UI - static const bool showDebugInfo = false; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Default Values -// ═══════════════════════════════════════════════════════════════════════════ - -/// Default configuration values -class Defaults { - Defaults._(); - - // Editor - static const double editorFontSize = 14.0; - static const String editorFontFamily = 'JetBrainsMono'; - static const bool editorWordWrap = true; - static const bool editorShowLineNumbers = true; - static const int editorTabSize = 2; - static const bool editorUseSpaces = true; - - // AI - static const String aiProvider = 'openai'; - static const String aiModel = 'gpt-4o-mini'; - static const double aiTemperature = 0.7; - static const int aiMaxTokens = 4096; - static const bool aiStreamResponse = true; - - // Pagination - static const int pageSize = 20; - static const int maxRecentFiles = 10; - - // Timeouts - static const int apiTimeoutSeconds = 60; - static const int connectionTimeoutSeconds = 10; - - // Limits - static const int maxSnippetTags = 10; - static const int maxProjectFiles = 1000; - static const int maxChatHistory = 100; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Supported Languages -// ═══════════════════════════════════════════════════════════════════════════ - -/// Programming languages supported by the editor -class SupportedLanguages { - SupportedLanguages._(); - - static const Map languages = { - 'dart': 'Dart', - 'javascript': 'JavaScript', - 'typescript': 'TypeScript', - 'python': 'Python', - 'go': 'Go', - 'rust': 'Rust', - 'java': 'Java', - 'kotlin': 'Kotlin', - 'swift': 'Swift', - 'php': 'PHP', - 'ruby': 'Ruby', - 'c': 'C', - 'cpp': 'C++', - 'csharp': 'C#', - 'html': 'HTML', - 'css': 'CSS', - 'json': 'JSON', - 'yaml': 'YAML', - 'markdown': 'Markdown', - 'sql': 'SQL', - 'shell': 'Shell', - 'dockerfile': 'Dockerfile', - }; - - /// File extension to language mapping - static const Map extensionMap = { - '.dart': 'dart', - '.js': 'javascript', - '.ts': 'typescript', - '.tsx': 'typescript', - '.jsx': 'javascript', - '.py': 'python', - '.go': 'go', - '.rs': 'rust', - '.java': 'java', - '.kt': 'kotlin', - '.swift': 'swift', - '.php': 'php', - '.rb': 'ruby', - '.c': 'c', - '.cpp': 'cpp', - '.h': 'c', - '.cs': 'csharp', - '.html': 'html', - '.htm': 'html', - '.css': 'css', - '.scss': 'css', - '.json': 'json', - '.yaml': 'yaml', - '.yml': 'yaml', - '.md': 'markdown', - '.sql': 'sql', - '.sh': 'shell', - '.bash': 'shell', - '.zsh': 'shell', - '.dockerfile': 'dockerfile', - }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// AI Provider Models -// ═══════════════════════════════════════════════════════════════════════════ - -/// Available models per AI provider -class AiModels { - AiModels._(); - - static const Map> modelsByProvider = { - 'openai': [ - 'gpt-4o', - 'gpt-4o-mini', - 'gpt-4-turbo', - 'gpt-3.5-turbo', - ], - 'claude': [ - 'claude-3-5-sonnet-latest', - 'claude-3-5-haiku-latest', - 'claude-3-opus-latest', - ], - 'gemini': [ - 'gemini-2.0-flash', - 'gemini-2.0-pro', - 'gemini-1.5-flash', - 'gemini-1.5-pro', - ], - 'custom': [], // User-defined - }; - - /// Default base URLs for each provider - static const Map defaultBaseUrls = { - 'openai': 'https://api.openai.com/v1', - 'claude': 'https://api.anthropic.com', - 'gemini': 'https://generativelanguage.googleapis.com/v1beta', - 'custom': '', - }; -} \ No newline at end of file diff --git a/mobile_agent/lib/core/theme.dart b/mobile_agent/lib/core/theme.dart index 6253bc4..a23b9a2 100644 --- a/mobile_agent/lib/core/theme.dart +++ b/mobile_agent/lib/core/theme.dart @@ -1,651 +1,651 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -/// Design system for Mobile Agent. -/// -/// Defines the complete visual identity including colors, typography, -/// component themes, and gradients. All themes are dark-mode oriented -/// for optimal code editing experience. -/// -/// ## Color Palette -/// - Background: Deep space dark (#030508) -/// - Primary: Violet (#7B2FF7) -/// - Accent: Cyan (#00D4AA) -/// - Surfaces: Layered dark grays -/// - Text: White to gray scale -/// - Status: Success green, Error red, Warning amber -class AppTheme { - AppTheme._(); - - // ── Background Colors ─────────────────────────────────────────────── - - /// Deepest background - used for scaffold and base layers - static const Color background = Color(0xFF030508); - - /// Slightly elevated background for contrast - static const Color backgroundElevated = Color(0xFF0A0E14); - - /// Card and container backgrounds - static const Color surface = Color(0xFF111827); - - /// Hover/pressed state for interactive surfaces - static const Color surfaceHover = Color(0xFF1A2236); - - /// Input field backgrounds - static const Color surfaceInput = Color(0xFF151B27); - - // ── Primary & Accent Colors ───────────────────────────────────────── - - /// Primary brand color - Violet - static const Color primary = Color(0xFF7B2FF7); - - /// Primary hover/active state - static const Color primaryHover = Color(0xFF9460FF); - - /// Primary with reduced opacity - static const Color primaryMuted = Color(0x407B2FF7); - - /// Accent color - Cyan (used for highlights, active states) - static const Color accent = Color(0xFF00D4AA); - - /// Accent hover state - static const Color accentHover = Color(0xFF33E0BF); - - /// Accent with reduced opacity - static const Color accentMuted = Color(0x4000D4AA); - - // ── Text Colors ───────────────────────────────────────────────────── - - /// Primary text - white - static const Color textPrimary = Color(0xFFF0F0F5); - - /// Secondary text - light gray - static const Color textSecondary = Color(0xFF9CA3AF); - - /// Tertiary/muted text - static const Color textTertiary = Color(0xFF6B7280); - - /// Text on primary colored backgrounds - static const Color textOnPrimary = Color(0xFFFFFFFF); - - /// Disabled text - static const Color textDisabled = Color(0xFF4B5563); - - // ── Status Colors ─────────────────────────────────────────────────── - - /// Success - Green - static const Color success = Color(0xFF10B981); - - /// Error - Red - static const Color error = Color(0xFFEF4444); - - /// Warning - Amber - static const Color warning = Color(0xFFF59E0B); - - /// Info - Blue - static const Color info = Color(0xFF3B82F6); - - // ── Border & Divider Colors ───────────────────────────────────────── - - /// Default border color - static const Color border = Color(0xFF1F2937); - - /// Border for focused/active states - static const Color borderActive = Color(0xFF7B2FF7); - - /// Divider color - static const Color divider = Color(0xFF1A2236); - - // ── Code Editor Theme Colors ──────────────────────────────────────── - - /// Editor background - static const Color editorBackground = Color(0xFF0D1117); - - /// Editor gutter (line numbers area) - static const Color editorGutter = Color(0xFF0A0E14); - - /// Editor active line highlight - static const Color editorActiveLine = Color(0xFF151B27); - - /// Editor selection color - static const Color editorSelection = Color(0x407B2FF7); - - /// Editor cursor color - static const Color editorCursor = Color(0xFF7B2FF7); - - /// Editor line number color - static const Color editorLineNumber = Color(0xFF4B5563); - - /// Editor comment color - static const Color codeComment = Color(0xFF6B7280); - - /// Editor keyword color - static const Color codeKeyword = Color(0xFFC084FC); - - /// Editor string color - static const Color codeString = Color(0xFF34D399); - - /// Editor function color - static const Color codeFunction = Color(0xFF60A5FA); - - /// Editor number/color literal - static const Color codeLiteral = Color(0xFFFBBF24); - - /// Editor type/class color - static const Color codeType = Color(0xFF00D4AA); - - /// Editor variable color - static const Color codeVariable = Color(0xFFF0F0F5); - - /// Editor operator color - static const Color codeOperator = Color(0xFFF87171); - - // ── Gradients ─────────────────────────────────────────────────────── - - /// Primary gradient for hero sections and prominent buttons - static const LinearGradient primaryGradient = LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF7B2FF7), Color(0xFF4C1D95)], - ); - - /// Accent gradient for highlights - static const LinearGradient accentGradient = LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF00D4AA), Color(0xFF0891B2)], - ); - - /// Subtle surface gradient for cards - static const LinearGradient surfaceGradient = LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF111827), Color(0xFF0A0E14)], - ); - - /// Glassmorphism gradient overlay - static const LinearGradient glassGradient = LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0x20FFFFFF), Color(0x05FFFFFF)], - ); - - // ── Typography ────────────────────────────────────────────────────── - - static const String fontCode = 'JetBrainsMono'; - static const String fontBody = 'Inter'; - - /// Get text theme for the app - static TextTheme get textTheme { - return const TextTheme( - displayLarge: TextStyle( - fontFamily: fontBody, - fontSize: 32, - fontWeight: FontWeight.bold, - color: textPrimary, - letterSpacing: -0.5, - ), - displayMedium: TextStyle( - fontFamily: fontBody, - fontSize: 28, - fontWeight: FontWeight.bold, - color: textPrimary, - letterSpacing: -0.5, - ), - displaySmall: TextStyle( - fontFamily: fontBody, - fontSize: 24, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - headlineLarge: TextStyle( - fontFamily: fontBody, - fontSize: 22, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - headlineMedium: TextStyle( - fontFamily: fontBody, - fontSize: 18, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - headlineSmall: TextStyle( - fontFamily: fontBody, - fontSize: 16, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - titleLarge: TextStyle( - fontFamily: fontBody, - fontSize: 18, - fontWeight: FontWeight.w500, - color: textPrimary, - ), - titleMedium: TextStyle( - fontFamily: fontBody, - fontSize: 16, - fontWeight: FontWeight.w500, - color: textPrimary, - ), - titleSmall: TextStyle( - fontFamily: fontBody, - fontSize: 14, - fontWeight: FontWeight.w500, - color: textSecondary, - ), - bodyLarge: TextStyle( - fontFamily: fontBody, - fontSize: 16, - fontWeight: FontWeight.normal, - color: textPrimary, - ), - bodyMedium: TextStyle( - fontFamily: fontBody, - fontSize: 14, - fontWeight: FontWeight.normal, - color: textSecondary, - ), - bodySmall: TextStyle( - fontFamily: fontBody, - fontSize: 12, - fontWeight: FontWeight.normal, - color: textTertiary, - ), - labelLarge: TextStyle( - fontFamily: fontBody, - fontSize: 14, - fontWeight: FontWeight.w500, - color: textPrimary, - ), - labelMedium: TextStyle( - fontFamily: fontBody, - fontSize: 12, - fontWeight: FontWeight.w500, - color: textSecondary, - ), - labelSmall: TextStyle( - fontFamily: fontBody, - fontSize: 11, - fontWeight: FontWeight.w500, - color: textTertiary, - letterSpacing: 0.5, - ), - ); - } - - /// Code-specific text theme - static TextTheme get codeTextTheme { - return const TextTheme( - bodyLarge: TextStyle( - fontFamily: fontCode, - fontSize: 16, - fontWeight: FontWeight.normal, - color: textPrimary, - height: 1.5, - ), - bodyMedium: TextStyle( - fontFamily: fontCode, - fontSize: 14, - fontWeight: FontWeight.normal, - color: textSecondary, - height: 1.5, - ), - bodySmall: TextStyle( - fontFamily: fontCode, - fontSize: 12, - fontWeight: FontWeight.normal, - color: textTertiary, - height: 1.5, - ), - ); - } - - // ── Component Themes ──────────────────────────────────────────────── - - /// Card theme - static CardTheme get cardTheme { - return CardTheme( - color: surface, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: const BorderSide(color: border, width: 1), - ), - margin: EdgeInsets.zero, - ); - } - - /// Input decoration theme - static InputDecorationTheme get inputDecorationTheme { - return InputDecorationTheme( - filled: true, - fillColor: surfaceInput, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: border, width: 1), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: border, width: 1), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: primary, width: 1.5), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: error, width: 1), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: error, width: 1.5), - ), - hintStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 14, - color: textTertiary, - ), - labelStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 14, - color: textSecondary, - ), - ); - } - - /// Button themes - static ElevatedButtonThemeData get elevatedButtonTheme { - return ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: primary, - foregroundColor: textOnPrimary, - elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - textStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ); - } - - static OutlinedButtonThemeData get outlinedButtonTheme { - return OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - foregroundColor: textPrimary, - side: const BorderSide(color: border, width: 1), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - textStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ); - } - - static TextButtonThemeData get textButtonTheme { - return TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: primary, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - textStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ); - } - - /// Icon theme - static IconThemeData get iconTheme { - return const IconThemeData( - color: textSecondary, - size: 24, - ); - } - - /// AppBar theme - static AppBarTheme get appBarTheme { - return AppBarTheme( - backgroundColor: background.withOpacity(0.8), - foregroundColor: textPrimary, - elevation: 0, - centerTitle: true, - scrolledUnderElevation: 0, - systemOverlayStyle: SystemUiOverlayStyle.light, - titleTextStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 18, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - shape: Border( - bottom: BorderSide(color: divider, width: 1), - ), - ); - } - - /// Bottom navigation bar theme - static BottomNavigationBarThemeData get bottomNavTheme { - return BottomNavigationBarThemeData( - backgroundColor: backgroundElevated, - selectedItemColor: primary, - unselectedItemColor: textTertiary, - type: BottomNavigationBarType.fixed, - elevation: 0, - selectedLabelStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - unselectedLabelStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ); - } - - /// Chip theme - static ChipThemeData get chipTheme { - return ChipThemeData( - backgroundColor: surface, - selectedColor: primaryMuted, - labelStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 12, - color: textSecondary, - ), - secondaryLabelStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 12, - color: textPrimary, - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: const BorderSide(color: border), - ), - ); - } - - /// Divider theme - static DividerThemeData get dividerTheme { - return const DividerThemeData( - color: divider, - thickness: 1, - space: 1, - ); - } - - /// Tab bar theme - static TabBarTheme get tabBarTheme { - return TabBarTheme( - labelColor: primary, - unselectedLabelColor: textTertiary, - indicatorColor: primary, - labelStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - unselectedLabelStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - indicator: const UnderlineTabIndicator( - borderSide: BorderSide(color: primary, width: 2), - ), - ); - } - - /// Dialog theme - static DialogTheme get dialogTheme { - return DialogTheme( - backgroundColor: surface, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: border, width: 1), - ), - titleTextStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 20, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - ); - } - - /// Bottom sheet theme - static BottomSheetThemeData get bottomSheetTheme { - return BottomSheetThemeData( - backgroundColor: surface, - elevation: 0, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - modalBarrierColor: Colors.black.withOpacity(0.5), - ); - } - - // ── Full Theme Data ───────────────────────────────────────────────── - - /// Dark theme (primary app theme) - static ThemeData get darkTheme { - return ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - colorScheme: const ColorScheme.dark( - primary: primary, - secondary: accent, - surface: surface, - background: background, - error: error, - onPrimary: textOnPrimary, - onSecondary: background, - onSurface: textPrimary, - onBackground: textPrimary, - onError: textOnPrimary, - surfaceTint: Colors.transparent, - ), - scaffoldBackgroundColor: background, - canvasColor: backgroundElevated, - textTheme: textTheme, - cardTheme: cardTheme, - inputDecorationTheme: inputDecorationTheme, - elevatedButtonTheme: elevatedButtonTheme, - outlinedButtonTheme: outlinedButtonTheme, - textButtonTheme: textButtonTheme, - iconTheme: iconTheme, - appBarTheme: appBarTheme, - bottomNavigationBarTheme: bottomNavTheme, - chipTheme: chipTheme, - dividerTheme: dividerTheme, - tabBarTheme: tabBarTheme, - dialogTheme: dialogTheme, - bottomSheetTheme: bottomSheetTheme, - snackBarTheme: SnackBarThemeData( - backgroundColor: surfaceHover, - contentTextStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 14, - color: textPrimary, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - behavior: SnackBarBehavior.floating, - ), - tooltipTheme: TooltipThemeData( - decoration: BoxDecoration( - color: surfaceHover, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: border), - ), - textStyle: const TextStyle( - fontFamily: fontBody, - fontSize: 12, - color: textSecondary, - ), - ), - scrollbarTheme: ScrollbarThemeData( - thumbColor: WidgetStateProperty.all(textTertiary.withOpacity(0.5)), - trackColor: WidgetStateProperty.all(Colors.transparent), - radius: const Radius.circular(4), - thickness: WidgetStateProperty.all(4), - ), - ); - } - - /// Light theme (fallback, not primary) - static ThemeData get lightTheme { - // Mobile Agent is dark-mode first, light theme is minimal - return ThemeData( - useMaterial3: true, - brightness: Brightness.light, - colorScheme: const ColorScheme.light( - primary: primary, - secondary: accent, - ), - ); - } +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Design system for Mobile Agent. +/// +/// Defines the complete visual identity including colors, typography, +/// component themes, and gradients. All themes are dark-mode oriented +/// for optimal code editing experience. +/// +/// ## Color Palette +/// - Background: Deep space dark (#030508) +/// - Primary: Violet (#7B2FF7) +/// - Accent: Cyan (#00D4AA) +/// - Surfaces: Layered dark grays +/// - Text: White to gray scale +/// - Status: Success green, Error red, Warning amber +class AppTheme { + AppTheme._(); + + // ── Background Colors ─────────────────────────────────────────────── + + /// Deepest background - used for scaffold and base layers + static const Color background = Color(0xFF030508); + + /// Slightly elevated background for contrast + static const Color backgroundElevated = Color(0xFF0A0E14); + + /// Card and container backgrounds + static const Color surface = Color(0xFF111827); + + /// Hover/pressed state for interactive surfaces + static const Color surfaceHover = Color(0xFF1A2236); + + /// Input field backgrounds + static const Color surfaceInput = Color(0xFF151B27); + + // ── Primary & Accent Colors ───────────────────────────────────────── + + /// Primary brand color - Violet + static const Color primary = Color(0xFF7B2FF7); + + /// Primary hover/active state + static const Color primaryHover = Color(0xFF9460FF); + + /// Primary with reduced opacity + static const Color primaryMuted = Color(0x407B2FF7); + + /// Accent color - Cyan (used for highlights, active states) + static const Color accent = Color(0xFF00D4AA); + + /// Accent hover state + static const Color accentHover = Color(0xFF33E0BF); + + /// Accent with reduced opacity + static const Color accentMuted = Color(0x4000D4AA); + + // ── Text Colors ───────────────────────────────────────────────────── + + /// Primary text - white + static const Color textPrimary = Color(0xFFF0F0F5); + + /// Secondary text - light gray + static const Color textSecondary = Color(0xFF9CA3AF); + + /// Tertiary/muted text + static const Color textTertiary = Color(0xFF6B7280); + + /// Text on primary colored backgrounds + static const Color textOnPrimary = Color(0xFFFFFFFF); + + /// Disabled text + static const Color textDisabled = Color(0xFF4B5563); + + // ── Status Colors ─────────────────────────────────────────────────── + + /// Success - Green + static const Color success = Color(0xFF10B981); + + /// Error - Red + static const Color error = Color(0xFFEF4444); + + /// Warning - Amber + static const Color warning = Color(0xFFF59E0B); + + /// Info - Blue + static const Color info = Color(0xFF3B82F6); + + // ── Border & Divider Colors ───────────────────────────────────────── + + /// Default border color + static const Color border = Color(0xFF1F2937); + + /// Border for focused/active states + static const Color borderActive = Color(0xFF7B2FF7); + + /// Divider color + static const Color divider = Color(0xFF1A2236); + + // ── Code Editor Theme Colors ──────────────────────────────────────── + + /// Editor background + static const Color editorBackground = Color(0xFF0D1117); + + /// Editor gutter (line numbers area) + static const Color editorGutter = Color(0xFF0A0E14); + + /// Editor active line highlight + static const Color editorActiveLine = Color(0xFF151B27); + + /// Editor selection color + static const Color editorSelection = Color(0x407B2FF7); + + /// Editor cursor color + static const Color editorCursor = Color(0xFF7B2FF7); + + /// Editor line number color + static const Color editorLineNumber = Color(0xFF4B5563); + + /// Editor comment color + static const Color codeComment = Color(0xFF6B7280); + + /// Editor keyword color + static const Color codeKeyword = Color(0xFFC084FC); + + /// Editor string color + static const Color codeString = Color(0xFF34D399); + + /// Editor function color + static const Color codeFunction = Color(0xFF60A5FA); + + /// Editor number/color literal + static const Color codeLiteral = Color(0xFFFBBF24); + + /// Editor type/class color + static const Color codeType = Color(0xFF00D4AA); + + /// Editor variable color + static const Color codeVariable = Color(0xFFF0F0F5); + + /// Editor operator color + static const Color codeOperator = Color(0xFFF87171); + + // ── Gradients ─────────────────────────────────────────────────────── + + /// Primary gradient for hero sections and prominent buttons + static const LinearGradient primaryGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF7B2FF7), Color(0xFF4C1D95)], + ); + + /// Accent gradient for highlights + static const LinearGradient accentGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF00D4AA), Color(0xFF0891B2)], + ); + + /// Subtle surface gradient for cards + static const LinearGradient surfaceGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF111827), Color(0xFF0A0E14)], + ); + + /// Glassmorphism gradient overlay + static const LinearGradient glassGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x20FFFFFF), Color(0x05FFFFFF)], + ); + + // ── Typography ────────────────────────────────────────────────────── + + static const String fontCode = 'JetBrainsMono'; + static const String fontBody = 'Inter'; + + /// Get text theme for the app + static TextTheme get textTheme { + return const TextTheme( + displayLarge: TextStyle( + fontFamily: fontBody, + fontSize: 32, + fontWeight: FontWeight.bold, + color: textPrimary, + letterSpacing: -0.5, + ), + displayMedium: TextStyle( + fontFamily: fontBody, + fontSize: 28, + fontWeight: FontWeight.bold, + color: textPrimary, + letterSpacing: -0.5, + ), + displaySmall: TextStyle( + fontFamily: fontBody, + fontSize: 24, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + headlineLarge: TextStyle( + fontFamily: fontBody, + fontSize: 22, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + headlineMedium: TextStyle( + fontFamily: fontBody, + fontSize: 18, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + headlineSmall: TextStyle( + fontFamily: fontBody, + fontSize: 16, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + titleLarge: TextStyle( + fontFamily: fontBody, + fontSize: 18, + fontWeight: FontWeight.w500, + color: textPrimary, + ), + titleMedium: TextStyle( + fontFamily: fontBody, + fontSize: 16, + fontWeight: FontWeight.w500, + color: textPrimary, + ), + titleSmall: TextStyle( + fontFamily: fontBody, + fontSize: 14, + fontWeight: FontWeight.w500, + color: textSecondary, + ), + bodyLarge: TextStyle( + fontFamily: fontBody, + fontSize: 16, + fontWeight: FontWeight.normal, + color: textPrimary, + ), + bodyMedium: TextStyle( + fontFamily: fontBody, + fontSize: 14, + fontWeight: FontWeight.normal, + color: textSecondary, + ), + bodySmall: TextStyle( + fontFamily: fontBody, + fontSize: 12, + fontWeight: FontWeight.normal, + color: textTertiary, + ), + labelLarge: TextStyle( + fontFamily: fontBody, + fontSize: 14, + fontWeight: FontWeight.w500, + color: textPrimary, + ), + labelMedium: TextStyle( + fontFamily: fontBody, + fontSize: 12, + fontWeight: FontWeight.w500, + color: textSecondary, + ), + labelSmall: TextStyle( + fontFamily: fontBody, + fontSize: 11, + fontWeight: FontWeight.w500, + color: textTertiary, + letterSpacing: 0.5, + ), + ); + } + + /// Code-specific text theme + static TextTheme get codeTextTheme { + return const TextTheme( + bodyLarge: TextStyle( + fontFamily: fontCode, + fontSize: 16, + fontWeight: FontWeight.normal, + color: textPrimary, + height: 1.5, + ), + bodyMedium: TextStyle( + fontFamily: fontCode, + fontSize: 14, + fontWeight: FontWeight.normal, + color: textSecondary, + height: 1.5, + ), + bodySmall: TextStyle( + fontFamily: fontCode, + fontSize: 12, + fontWeight: FontWeight.normal, + color: textTertiary, + height: 1.5, + ), + ); + } + + // ── Component Themes ──────────────────────────────────────────────── + + /// Card theme + static CardThemeData get cardTheme { + return CardThemeData( + color: surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: border, width: 1), + ), + margin: EdgeInsets.zero, + ); + } + + /// Input decoration theme + static InputDecorationTheme get inputDecorationTheme { + return InputDecorationTheme( + filled: true, + fillColor: surfaceInput, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: border, width: 1), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: border, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: primary, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: error, width: 1.5), + ), + hintStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 14, + color: textTertiary, + ), + labelStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 14, + color: textSecondary, + ), + ); + } + + /// Button themes + static ElevatedButtonThemeData get elevatedButtonTheme { + return ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primary, + foregroundColor: textOnPrimary, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + textStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + static OutlinedButtonThemeData get outlinedButtonTheme { + return OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: textPrimary, + side: const BorderSide(color: border, width: 1), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + textStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + static TextButtonThemeData get textButtonTheme { + return TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primary, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + textStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + /// Icon theme + static IconThemeData get iconTheme { + return const IconThemeData( + color: textSecondary, + size: 24, + ); + } + + /// AppBar theme + static AppBarTheme get appBarTheme { + return AppBarTheme( + backgroundColor: background.withOpacity(0.8), + foregroundColor: textPrimary, + elevation: 0, + centerTitle: true, + scrolledUnderElevation: 0, + systemOverlayStyle: SystemUiOverlayStyle.light, + titleTextStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 18, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + shape: Border( + bottom: BorderSide(color: divider, width: 1), + ), + ); + } + + /// Bottom navigation bar theme + static BottomNavigationBarThemeData get bottomNavTheme { + return BottomNavigationBarThemeData( + backgroundColor: backgroundElevated, + selectedItemColor: primary, + unselectedItemColor: textTertiary, + type: BottomNavigationBarType.fixed, + elevation: 0, + selectedLabelStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + unselectedLabelStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ); + } + + /// Chip theme + static ChipThemeData get chipTheme { + return ChipThemeData( + backgroundColor: surface, + selectedColor: primaryMuted, + labelStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 12, + color: textSecondary, + ), + secondaryLabelStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 12, + color: textPrimary, + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: border), + ), + ); + } + + /// Divider theme + static DividerThemeData get dividerTheme { + return const DividerThemeData( + color: divider, + thickness: 1, + space: 1, + ); + } + + /// Tab bar theme + static TabBarThemeData get tabBarTheme { + return TabBarThemeData( + labelColor: primary, + unselectedLabelColor: textTertiary, + indicatorColor: primary, + labelStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + indicator: const UnderlineTabIndicator( + borderSide: BorderSide(color: primary, width: 2), + ), + ); + } + + /// Dialog theme + static DialogThemeData get dialogTheme { + return DialogThemeData( + backgroundColor: surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: border, width: 1), + ), + titleTextStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 20, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + ); + } + + /// Bottom sheet theme + static BottomSheetThemeData get bottomSheetTheme { + return BottomSheetThemeData( + backgroundColor: surface, + elevation: 0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + modalBarrierColor: Colors.black.withOpacity(0.5), + ); + } + + // ── Full Theme Data ───────────────────────────────────────────────── + + /// Dark theme (primary app theme) + static ThemeData get darkTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: primary, + secondary: accent, + surface: surface, + background: background, + error: error, + onPrimary: textOnPrimary, + onSecondary: background, + onSurface: textPrimary, + onBackground: textPrimary, + onError: textOnPrimary, + surfaceTint: Colors.transparent, + ), + scaffoldBackgroundColor: background, + canvasColor: backgroundElevated, + textTheme: textTheme, + cardTheme: cardTheme, + inputDecorationTheme: inputDecorationTheme, + elevatedButtonTheme: elevatedButtonTheme, + outlinedButtonTheme: outlinedButtonTheme, + textButtonTheme: textButtonTheme, + iconTheme: iconTheme, + appBarTheme: appBarTheme, + bottomNavigationBarTheme: bottomNavTheme, + chipTheme: chipTheme, + dividerTheme: dividerTheme, + tabBarTheme: tabBarTheme, + dialogTheme: dialogTheme, + bottomSheetTheme: bottomSheetTheme, + snackBarTheme: SnackBarThemeData( + backgroundColor: surfaceHover, + contentTextStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 14, + color: textPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + behavior: SnackBarBehavior.floating, + ), + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: surfaceHover, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: border), + ), + textStyle: const TextStyle( + fontFamily: fontBody, + fontSize: 12, + color: textSecondary, + ), + ), + scrollbarTheme: ScrollbarThemeData( + thumbColor: WidgetStateProperty.all(textTertiary.withOpacity(0.5)), + trackColor: WidgetStateProperty.all(Colors.transparent), + radius: const Radius.circular(4), + thickness: WidgetStateProperty.all(4), + ), + ); + } + + /// Light theme (fallback, not primary) + static ThemeData get lightTheme { + // Mobile Agent is dark-mode first, light theme is minimal + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: const ColorScheme.light( + primary: primary, + secondary: accent, + ), + ); + } +} + +/// Extension for convenient access to AppTheme colors in BuildContext +extension ThemeExtension on BuildContext { + /// Access AppTheme colors directly + AppColors get appColors => AppColors(); +} + +/// Container for organized color access +class AppColors { + /// Code syntax highlighting colors organized by token type + CodeColors get code => const CodeColors(); +} + +/// Code syntax highlighting color palette +class CodeColors { + const CodeColors(); + + final Color comment = AppTheme.codeComment; + final Color keyword = AppTheme.codeKeyword; + final Color string = AppTheme.codeString; + final Color function = AppTheme.codeFunction; + final Color literal = AppTheme.codeLiteral; + final Color type = AppTheme.codeType; + final Color variable = AppTheme.codeVariable; + final Color operator = AppTheme.codeOperator; } - -/// Extension for convenient access to AppTheme colors in BuildContext -extension ThemeExtension on BuildContext { - /// Access AppTheme colors directly - AppColors get appColors => AppColors(); -} - -/// Container for organized color access -class AppColors { - /// Code syntax highlighting colors organized by token type - CodeColors get code => const CodeColors(); -} - -/// Code syntax highlighting color palette -class CodeColors { - const CodeColors(); - - final Color comment = AppTheme.codeComment; - final Color keyword = AppTheme.codeKeyword; - final Color string = AppTheme.codeString; - final Color function = AppTheme.codeFunction; - final Color literal = AppTheme.codeLiteral; - final Color type = AppTheme.codeType; - final Color variable = AppTheme.codeVariable; - final Color operator = AppTheme.codeOperator; -} \ No newline at end of file diff --git a/mobile_agent/lib/core/theme_manager.dart b/mobile_agent/lib/core/theme_manager.dart index 6ed586c..8f40a65 100644 --- a/mobile_agent/lib/core/theme_manager.dart +++ b/mobile_agent/lib/core/theme_manager.dart @@ -1,1215 +1,1359 @@ -// ============================================================ -// theme_manager.dart — MobileCode Complete Theme System -// ============================================================ -// Defines 5 distinct themes: DeepSpace, Aurora, MidnightForest, -// CyberSunset, MonochromeGeek with full Material theming support, -// persistence, dynamic detection, and component-specific styles. -// ============================================================ - -import 'dart:async'; -import 'dart:convert'; -import 'dart:math' show Point; -import 'dart:ui' show lerpDouble; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -// ============================================================ -// SECTION 1: Theme Enum & Metadata -// ============================================================ - -/// Unique identifier for each of the five available themes. +// ============================================================ +// theme_manager.dart — MobileCode Complete Theme System +// ============================================================ +// Defines 5 distinct themes: DeepSpace, Aurora, MidnightForest, +// CyberSunset, MonochromeGeek with full Material theming support, +// persistence, dynamic detection, and component-specific styles. +// ============================================================ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' show Point; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// ============================================================ +// SECTION 1: Theme Enum & Metadata +// ============================================================ + +/// Unique identifier for each of the five available themes. enum AppTheme { deepSpace, aurora, midnightForest, cyberSunset, monochromeGeek, + claudeYellow, + codexBlue, } - -/// Per-theme personality: human-readable labels, descriptions, -/// and accent color previews used by the settings UI. -extension AppThemeMetadata on AppTheme { - String get label { - switch (this) { - case AppTheme.deepSpace: - return 'DeepSpace'; - case AppTheme.aurora: - return 'Aurora'; - case AppTheme.midnightForest: - return 'Midnight Forest'; - case AppTheme.cyberSunset: - return 'Cyber Sunset'; + +/// Per-theme personality: human-readable labels, descriptions, +/// and accent color previews used by the settings UI. +extension AppThemeMetadata on AppTheme { + String get label { + switch (this) { + case AppTheme.deepSpace: + return 'DeepSpace'; + case AppTheme.aurora: + return 'Aurora'; + case AppTheme.midnightForest: + return 'Midnight Forest'; + case AppTheme.cyberSunset: + return 'Cyber Sunset'; case AppTheme.monochromeGeek: return 'Monochrome Geek'; - } - } - - String get description { - switch (this) { - case AppTheme.deepSpace: - return 'Deep space exploration with violet nebulae and cyan starlight.'; - case AppTheme.aurora: - return 'Northern lights dancing across a dark teal sky.'; - case AppTheme.midnightForest: - return 'An emerald canopy glowing with amber fireflies at midnight.'; - case AppTheme.cyberSunset: - return 'Neon-soaked sunset on a distant cyberpunk horizon.'; + case AppTheme.claudeYellow: + return 'Claude Yellow'; + case AppTheme.codexBlue: + return 'Codex Blue'; + } + } + + String get description { + switch (this) { + case AppTheme.deepSpace: + return 'Deep space exploration with violet nebulae and cyan starlight.'; + case AppTheme.aurora: + return 'Northern lights dancing across a dark teal sky.'; + case AppTheme.midnightForest: + return 'An emerald canopy glowing with amber fireflies at midnight.'; + case AppTheme.cyberSunset: + return 'Neon-soaked sunset on a distant cyberpunk horizon.'; case AppTheme.monochromeGeek: return 'Distraction-free monochrome for the focused developer.'; - } - } - - String get emoji { - switch (this) { - case AppTheme.deepSpace: - return '🌌'; - case AppTheme.aurora: - return '🌈'; - case AppTheme.midnightForest: - return '🌲'; - case AppTheme.cyberSunset: - return '🌇'; + case AppTheme.claudeYellow: + return 'Warm Claude-style amber for calm reading, planning, and review.'; + case AppTheme.codexBlue: + return 'Clean Codex-style blue for focused builds, previews, and release work.'; + } + } + + String get emoji { + switch (this) { + case AppTheme.deepSpace: + return '🌌'; + case AppTheme.aurora: + return '🌈'; + case AppTheme.midnightForest: + return '🌲'; + case AppTheme.cyberSunset: + return '🌇'; case AppTheme.monochromeGeek: return '🖥️'; - } - } - - /// Primary accent color — used for quick preview swatches. - Color get previewPrimary { - switch (this) { - case AppTheme.deepSpace: - return const Color(0xFF7B2FF7); - case AppTheme.aurora: - return const Color(0xFF00FF88); - case AppTheme.midnightForest: - return const Color(0xFF2ECC71); - case AppTheme.cyberSunset: - return const Color(0xFFFF7B54); + case AppTheme.claudeYellow: + return '☀️'; + case AppTheme.codexBlue: + return '🔷'; + } + } + + /// Primary accent color — used for quick preview swatches. + Color get previewPrimary { + switch (this) { + case AppTheme.deepSpace: + return const Color(0xFF7B2FF7); + case AppTheme.aurora: + return const Color(0xFF00FF88); + case AppTheme.midnightForest: + return const Color(0xFF2ECC71); + case AppTheme.cyberSunset: + return const Color(0xFFFF7B54); case AppTheme.monochromeGeek: return const Color(0xFFFFFFFF); - } - } - - /// Secondary accent color — used for preview swatches. - Color get previewSecondary { - switch (this) { - case AppTheme.deepSpace: - return const Color(0xFF00D4AA); - case AppTheme.aurora: - return const Color(0xFFFF6B9D); - case AppTheme.midnightForest: - return const Color(0xFFF39C12); - case AppTheme.cyberSunset: - return const Color(0xFF9B59B6); + case AppTheme.claudeYellow: + return const Color(0xFFD97706); + case AppTheme.codexBlue: + return const Color(0xFF2555FF); + } + } + + /// Secondary accent color — used for preview swatches. + Color get previewSecondary { + switch (this) { + case AppTheme.deepSpace: + return const Color(0xFF00D4AA); + case AppTheme.aurora: + return const Color(0xFFFF6B9D); + case AppTheme.midnightForest: + return const Color(0xFFF39C12); + case AppTheme.cyberSunset: + return const Color(0xFF9B59B6); case AppTheme.monochromeGeek: return const Color(0xFF888888); - } - } -} - -// ============================================================ -// SECTION 2: Theme Constants — Raw Color Palettes -// ============================================================ - -/// Immutable color constants for the **DeepSpace** theme. -class _DeepSpaceColors { - static const Color bgPrimary = Color(0xFF030508); - static const Color bgSecondary = Color(0xFF0A0E17); - static const Color bgTertiary = Color(0xFF111827); - static const Color primary = Color(0xFF7B2FF7); - static const Color primaryLight = Color(0xFF9D5CFF); - static const Color primaryDark = Color(0xFF5A1DBF); - static const Color accent = Color(0xFF00D4AA); - static const Color accentLight = Color(0xFF33E5C0); - static const Color accentDark = Color(0xFF00A884); - static const Color textPrimary = Color(0xFFF0F0F5); - static const Color textSecondary = Color(0xFF9CA3AF); - static const Color textMuted = Color(0xFF6B7280); - static const Color surface = Color(0xFF0D1117); - static const Color surfaceLight = Color(0xFF161B22); - static const Color error = Color(0xFFEF4444); - static const Color warning = Color(0xFFF59E0B); - static const Color success = Color(0xFF10B981); - static const Color glass = Color(0x0DFFFFFF); -} - -/// Immutable color constants for the **Aurora** theme. -class _AuroraColors { - static const Color bgPrimary = Color(0xFF0A1628); - static const Color bgSecondary = Color(0xFF0E2236); - static const Color bgTertiary = Color(0xFF142D44); - static const Color primary = Color(0xFF00FF88); - static const Color primaryLight = Color(0xFF33FFA0); - static const Color primaryDark = Color(0xFF00CC6A); - static const Color accent = Color(0xFFFF6B9D); - static const Color accentLight = Color(0xFFFF8FB0); - static const Color accentDark = Color(0xFFE0558A); - static const Color textPrimary = Color(0xFFE8F4F0); - static const Color textSecondary = Color(0xFF9DB8B0); - static const Color textMuted = Color(0xFF6B8A80); - static const Color surface = Color(0xFF0E1D2E); - static const Color surfaceLight = Color(0xFF162D3F); - static const Color error = Color(0xFFFF5555); - static const Color warning = Color(0xFFFFBB33); - static const Color success = Color(0xFF00FF88); - static const Color glass = Color(0x1200FF88); -} - -/// Immutable color constants for the **MidnightForest** theme. -class _MidnightForestColors { - static const Color bgPrimary = Color(0xFF0A1A0A); - static const Color bgSecondary = Color(0xFF0F240F); - static const Color bgTertiary = Color(0xFF162E16); - static const Color primary = Color(0xFF2ECC71); - static const Color primaryLight = Color(0xFF52D687); - static const Color primaryDark = Color(0xFF25A55A); - static const Color accent = Color(0xFFF39C12); - static const Color accentLight = Color(0xFFF5B041); - static const Color accentDark = Color(0xFFD68910); - static const Color textPrimary = Color(0xFFF2F8F0); - static const Color textSecondary = Color(0xFFA8C4A0); - static const Color textMuted = Color(0xFF708C68); - static const Color surface = Color(0xFF0D1F0D); - static const Color surfaceLight = Color(0xFF142B14); - static const Color error = Color(0xFFE74C3C); - static const Color warning = Color(0xFFF39C12); - static const Color success = Color(0xFF2ECC71); - static const Color glass = Color(0x102ECC71); -} - -/// Immutable color constants for the **CyberSunset** theme. -class _CyberSunsetColors { - static const Color bgPrimary = Color(0xFF0D0B2B); - static const Color bgSecondary = Color(0xFF12103A); - static const Color bgTertiary = Color(0xFF1A1648); - static const Color primary = Color(0xFFFF7B54); - static const Color primaryLight = Color(0xFFFF9D80); - static const Color primaryDark = Color(0xFFE06040); - static const Color accent = Color(0xFF9B59B6); - static const Color accentLight = Color(0xFFB07DC9); - static const Color accentDark = Color(0xFF7D3C98); - static const Color textPrimary = Color(0xFFFFF0EB); - static const Color textSecondary = Color(0xFFD4AFA8); - static const Color textMuted = Color(0xFF9A7880); - static const Color surface = Color(0xFF110E34); - static const Color surfaceLight = Color(0xFF1A1645); - static const Color error = Color(0xFFFF4757); - static const Color warning = Color(0xFFFFA502); - static const Color success = Color(0xFF2ED573); - static const Color glass = Color(0x15FF7B54); -} - -/// Immutable color constants for the **MonochromeGeek** theme. + case AppTheme.claudeYellow: + return const Color(0xFFFFB86B); + case AppTheme.codexBlue: + return const Color(0xFF16B9C7); + } + } +} + +// ============================================================ +// SECTION 2: Theme Constants — Raw Color Palettes +// ============================================================ + +/// Immutable color constants for the **DeepSpace** theme. +class _DeepSpaceColors { + static const Color bgPrimary = Color(0xFF030508); + static const Color bgSecondary = Color(0xFF0A0E17); + static const Color bgTertiary = Color(0xFF111827); + static const Color primary = Color(0xFF7B2FF7); + static const Color primaryLight = Color(0xFF9D5CFF); + static const Color primaryDark = Color(0xFF5A1DBF); + static const Color accent = Color(0xFF00D4AA); + static const Color accentLight = Color(0xFF33E5C0); + static const Color accentDark = Color(0xFF00A884); + static const Color textPrimary = Color(0xFFF0F0F5); + static const Color textSecondary = Color(0xFF9CA3AF); + static const Color textMuted = Color(0xFF6B7280); + static const Color surface = Color(0xFF0D1117); + static const Color surfaceLight = Color(0xFF161B22); + static const Color error = Color(0xFFEF4444); + static const Color warning = Color(0xFFF59E0B); + static const Color success = Color(0xFF10B981); + static const Color glass = Color(0x0DFFFFFF); +} + +/// Immutable color constants for the **Aurora** theme. +class _AuroraColors { + static const Color bgPrimary = Color(0xFF0A1628); + static const Color bgSecondary = Color(0xFF0E2236); + static const Color bgTertiary = Color(0xFF142D44); + static const Color primary = Color(0xFF00FF88); + static const Color primaryLight = Color(0xFF33FFA0); + static const Color primaryDark = Color(0xFF00CC6A); + static const Color accent = Color(0xFFFF6B9D); + static const Color accentLight = Color(0xFFFF8FB0); + static const Color accentDark = Color(0xFFE0558A); + static const Color textPrimary = Color(0xFFE8F4F0); + static const Color textSecondary = Color(0xFF9DB8B0); + static const Color textMuted = Color(0xFF6B8A80); + static const Color surface = Color(0xFF0E1D2E); + static const Color surfaceLight = Color(0xFF162D3F); + static const Color error = Color(0xFFFF5555); + static const Color warning = Color(0xFFFFBB33); + static const Color success = Color(0xFF00FF88); + static const Color glass = Color(0x1200FF88); +} + +/// Immutable color constants for the **MidnightForest** theme. +class _MidnightForestColors { + static const Color bgPrimary = Color(0xFF0A1A0A); + static const Color bgSecondary = Color(0xFF0F240F); + static const Color bgTertiary = Color(0xFF162E16); + static const Color primary = Color(0xFF2ECC71); + static const Color primaryLight = Color(0xFF52D687); + static const Color primaryDark = Color(0xFF25A55A); + static const Color accent = Color(0xFFF39C12); + static const Color accentLight = Color(0xFFF5B041); + static const Color accentDark = Color(0xFFD68910); + static const Color textPrimary = Color(0xFFF2F8F0); + static const Color textSecondary = Color(0xFFA8C4A0); + static const Color textMuted = Color(0xFF708C68); + static const Color surface = Color(0xFF0D1F0D); + static const Color surfaceLight = Color(0xFF142B14); + static const Color error = Color(0xFFE74C3C); + static const Color warning = Color(0xFFF39C12); + static const Color success = Color(0xFF2ECC71); + static const Color glass = Color(0x102ECC71); +} + +/// Immutable color constants for the **CyberSunset** theme. +class _CyberSunsetColors { + static const Color bgPrimary = Color(0xFF0D0B2B); + static const Color bgSecondary = Color(0xFF12103A); + static const Color bgTertiary = Color(0xFF1A1648); + static const Color primary = Color(0xFFFF7B54); + static const Color primaryLight = Color(0xFFFF9D80); + static const Color primaryDark = Color(0xFFE06040); + static const Color accent = Color(0xFF9B59B6); + static const Color accentLight = Color(0xFFB07DC9); + static const Color accentDark = Color(0xFF7D3C98); + static const Color textPrimary = Color(0xFFFFF0EB); + static const Color textSecondary = Color(0xFFD4AFA8); + static const Color textMuted = Color(0xFF9A7880); + static const Color surface = Color(0xFF110E34); + static const Color surfaceLight = Color(0xFF1A1645); + static const Color error = Color(0xFFFF4757); + static const Color warning = Color(0xFFFFA502); + static const Color success = Color(0xFF2ED573); + static const Color glass = Color(0x15FF7B54); +} + +/// Immutable color constants for the **MonochromeGeek** theme. class _MonochromeGeekColors { static const Color bgPrimary = Color(0xFF000000); - static const Color bgSecondary = Color(0xFF0A0A0A); - static const Color bgTertiary = Color(0xFF111111); - static const Color primary = Color(0xFFFFFFFF); - static const Color primaryLight = Color(0xFFF5F5F5); - static const Color primaryDark = Color(0xFFCCCCCC); - static const Color accent = Color(0xFF888888); - static const Color accentLight = Color(0xFFAAAAAA); - static const Color accentDark = Color(0xFF666666); - static const Color textPrimary = Color(0xFFFFFFFF); - static const Color textSecondary = Color(0xFFAAAAAA); - static const Color textMuted = Color(0xFF666666); - static const Color surface = Color(0xFF080808); - static const Color surfaceLight = Color(0xFF141414); - static const Color error = Color(0xFFFF4444); - static const Color warning = Color(0xFFFFBB33); - static const Color success = Color(0xFF33CC33); + static const Color bgSecondary = Color(0xFF0A0A0A); + static const Color bgTertiary = Color(0xFF111111); + static const Color primary = Color(0xFFFFFFFF); + static const Color primaryLight = Color(0xFFF5F5F5); + static const Color primaryDark = Color(0xFFCCCCCC); + static const Color accent = Color(0xFF888888); + static const Color accentLight = Color(0xFFAAAAAA); + static const Color accentDark = Color(0xFF666666); + static const Color textPrimary = Color(0xFFFFFFFF); + static const Color textSecondary = Color(0xFFAAAAAA); + static const Color textMuted = Color(0xFF666666); + static const Color surface = Color(0xFF080808); + static const Color surfaceLight = Color(0xFF141414); + static const Color error = Color(0xFFFF4444); + static const Color warning = Color(0xFFFFBB33); + static const Color success = Color(0xFF33CC33); static const Color glass = Color(0x10FFFFFF); } -// ============================================================ -// SECTION 3: MobileTheme — Aggregated Theme Data Object -// ============================================================ - -/// A richer theme container that bundles: -/// - Material [ColorScheme] & [ThemeData] -/// - Custom component colors (editor, terminal, cards) -/// - Animation configuration per theme -/// - Glassmorphism parameters -@immutable -class MobileTheme { - final AppTheme id; - final String name; - final ColorScheme colorScheme; - final ThemeData themeData; - - // -- Custom component colors -- - final Color editorBackground; - final Color editorLineHighlight; - final Color terminalBackground; - final Color terminalText; - final Color cardGlassBackground; - final Color cardGlassBorder; - final Color sidebarBackground; - final Color statusBarBackground; - final Color toolbarBackground; - final Color dividerColor; - - // -- Glassmorphism -- - final double glassOpacity; - final double glassBlur; - final Color glassOverlayColor; - final Color glassBorderColor; - - // -- Animation personality -- - final Duration transitionDuration; - final Curve transitionCurve; - final Duration microAnimationDuration; - final Curve microAnimationCurve; - final Curve bounceCurve; - final Duration staggerDelay; - - const MobileTheme({ - required this.id, - required this.name, - required this.colorScheme, - required this.themeData, - required this.editorBackground, - required this.editorLineHighlight, - required this.terminalBackground, - required this.terminalText, - required this.cardGlassBackground, - required this.cardGlassBorder, - required this.sidebarBackground, - required this.statusBarBackground, - required this.toolbarBackground, - required this.dividerColor, - required this.glassOpacity, - required this.glassBlur, - required this.glassOverlayColor, - required this.glassBorderColor, - required this.transitionDuration, - required this.transitionCurve, - required this.microAnimationDuration, - required this.microAnimationCurve, - required this.bounceCurve, - required this.staggerDelay, - }); -} - -// ============================================================ -// SECTION 4: Theme Builder — Material ThemeData Factory -// ============================================================ - -class _ThemeBuilder { - /// Creates a complete [MobileTheme] for a given raw palette and metadata. - static MobileTheme build({ - required AppTheme id, - required String name, - required Color bgPrimary, - required Color bgSecondary, - required Color bgTertiary, - required Color primary, - required Color primaryLight, - required Color primaryDark, - required Color accent, - required Color accentLight, - required Color accentDark, - required Color textPrimary, - required Color textSecondary, - required Color textMuted, - required Color surface, - required Color surfaceLight, - required Color error, - required Color warning, - required Color success, - required Color glassBase, - required Duration transitionDuration, - required Curve transitionCurve, - required Duration microDuration, - required Curve microCurve, - }) { - final brightness = Brightness.dark; - - // -- Material ColorScheme -- - final colorScheme = ColorScheme( - brightness: brightness, - primary: primary, - onPrimary: _contrastColor(primary), - primaryContainer: primaryDark.withOpacity(0.3), - onPrimaryContainer: primaryLight, - secondary: accent, - onSecondary: _contrastColor(accent), - secondaryContainer: accentDark.withOpacity(0.25), - onSecondaryContainer: accentLight, - tertiary: _lerpColor(primary, accent, 0.5), - onTertiary: textPrimary, - tertiaryContainer: bgTertiary, - onTertiaryContainer: textSecondary, - error: error, - onError: Colors.white, - errorContainer: error.withOpacity(0.15), - onErrorContainer: error.withLightness(+0.2), - surface: surface, - onSurface: textPrimary, - surfaceContainerHighest: surfaceLight, - onSurfaceVariant: textSecondary, - outline: textMuted.withOpacity(0.3), - outlineVariant: textMuted.withOpacity(0.15), - shadow: Colors.black.withOpacity(0.5), - scrim: Colors.black.withOpacity(0.7), - inverseSurface: textPrimary, - onInverseSurface: bgPrimary, - inversePrimary: primaryLight, - ); - - // -- TextTheme — uses Inter-style metrics (assumed font) -- - final textTheme = TextTheme( - displayLarge: TextStyle( - fontSize: 48, - fontWeight: FontWeight.w800, - color: textPrimary, - letterSpacing: -1.5, - ), - displayMedium: TextStyle( - fontSize: 36, - fontWeight: FontWeight.w700, - color: textPrimary, - letterSpacing: -1.0, - ), - displaySmall: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w700, - color: textPrimary, - letterSpacing: -0.5, - ), - headlineLarge: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - color: textPrimary, - letterSpacing: -0.5, - ), - headlineMedium: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: textPrimary, - letterSpacing: -0.3, - ), - headlineSmall: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - titleLarge: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - titleMedium: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - titleSmall: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: textSecondary, - letterSpacing: 0.5, - ), - bodyLarge: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: textPrimary, - height: 1.5, - ), - bodyMedium: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w400, - color: textPrimary, - height: 1.4, - ), - bodySmall: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: textSecondary, - height: 1.3, - ), - labelLarge: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: textPrimary, - ), - labelMedium: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: textSecondary, - ), - labelSmall: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: textMuted, - letterSpacing: 0.5, - ), - ); - - // -- Component Themes -- - final inputBorder = OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: textMuted.withOpacity(0.2)), - ); - - final themeData = ThemeData( - useMaterial3: true, - brightness: brightness, - colorScheme: colorScheme, - scaffoldBackgroundColor: bgPrimary, - canvasColor: bgSecondary, - cardColor: surface, - dividerColor: textMuted.withOpacity(0.15), - shadowColor: Colors.black.withOpacity(0.4), - textTheme: textTheme, - fontFamily: 'Inter', - // -- AppBar -- - appBarTheme: AppBarTheme( - elevation: 0, - scrolledUnderElevation: 0, - backgroundColor: bgSecondary.withOpacity(0.85), - foregroundColor: textPrimary, - centerTitle: true, - titleTextStyle: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), - systemOverlayStyle: SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.dark, - ), - ), - // -- Card -- - cardTheme: CardThemeData( - elevation: 0, - margin: EdgeInsets.zero, - color: surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - clipBehavior: Clip.antiAlias, - ), - // -- ElevatedButton -- - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - elevation: 0, - backgroundColor: primary, - foregroundColor: _contrastColor(primary), - minimumSize: const Size(64, 48), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), - ), - ), - // -- OutlinedButton -- - outlinedButtonTheme: OutlinedButtonThemeData( - style: OutlinedButton.styleFrom( - foregroundColor: primary, - minimumSize: const Size(64, 48), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - side: BorderSide(color: primary.withOpacity(0.5), width: 1.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), - ), - ), - // -- TextButton -- - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: primary, - minimumSize: const Size(48, 40), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - textStyle: textTheme.labelLarge, - ), - ), - // -- InputDecoration -- - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: surfaceLight, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - border: inputBorder, - enabledBorder: inputBorder, - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: primary, width: 2), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: error, width: 1.5), - ), - hintStyle: textTheme.bodyMedium?.copyWith(color: textMuted), - labelStyle: textTheme.bodyMedium?.copyWith(color: textSecondary), - ), - // -- Bottom Navigation -- - bottomNavigationBarTheme: BottomNavigationBarThemeData( - backgroundColor: bgSecondary.withOpacity(0.9), - selectedItemColor: primary, - unselectedItemColor: textMuted, - selectedLabelStyle: textTheme.labelSmall, - unselectedLabelStyle: textTheme.labelSmall, - type: BottomNavigationBarType.fixed, - elevation: 0, - showSelectedLabels: true, - showUnselectedLabels: true, - ), - // -- BottomSheet -- - bottomSheetTheme: BottomSheetThemeData( - backgroundColor: bgSecondary, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - clipBehavior: Clip.antiAlias, - elevation: 8, - ), - // -- Dialog -- - dialogTheme: DialogTheme( - backgroundColor: surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - titleTextStyle: textTheme.headlineSmall, - contentTextStyle: textTheme.bodyMedium, - elevation: 4, - ), - // -- Chip -- - chipTheme: ChipThemeData( - backgroundColor: surfaceLight, - selectedColor: primary.withOpacity(0.2), - labelStyle: textTheme.labelMedium, - secondaryLabelStyle: textTheme.labelMedium?.copyWith(color: primary), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - side: BorderSide.none, - ), - // -- TabBar -- - tabBarTheme: TabBarTheme( - labelColor: primary, - unselectedLabelColor: textMuted, - indicatorColor: primary, - indicatorSize: TabBarIndicatorSize.tab, - labelStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), - unselectedLabelStyle: textTheme.labelLarge, - dividerColor: Colors.transparent, - ), - // -- Switch -- - switchTheme: SwitchThemeData( - thumbColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) return primary; - return textMuted; - }), - trackColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return primary.withOpacity(0.35); - } - return textMuted.withOpacity(0.2); - }), - trackOutlineColor: WidgetStateProperty.all(Colors.transparent), - ), - // -- Slider -- - sliderTheme: SliderThemeData( - activeTrackColor: primary, - inactiveTrackColor: textMuted.withOpacity(0.2), - thumbColor: primaryLight, - overlayColor: primary.withOpacity(0.1), - trackHeight: 4, - ), - // -- ProgressIndicator -- - progressIndicatorTheme: ProgressIndicatorThemeData( - color: primary, - linearTrackColor: textMuted.withOpacity(0.15), - circularTrackColor: textMuted.withOpacity(0.15), - ), - // -- Snackbar -- - snackBarTheme: SnackBarThemeData( - backgroundColor: surfaceLight, - contentTextStyle: textTheme.bodyMedium, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - behavior: SnackBarBehavior.floating, - elevation: 2, - ), - // -- Tooltip -- - tooltipTheme: TooltipThemeData( - decoration: BoxDecoration( - color: surfaceLight, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: textMuted.withOpacity(0.15)), - ), - textStyle: textTheme.labelMedium, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - preferBelow: true, - ), - // -- Divider -- - dividerTheme: DividerThemeData( - color: textMuted.withOpacity(0.12), - thickness: 1, - space: 1, - ), - // -- ListTile -- - listTileTheme: ListTileThemeData( - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), - minLeadingWidth: 36, - iconColor: textSecondary, - textColor: textPrimary, - selectedColor: primary, - selectedTileColor: primary.withOpacity(0.08), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - // -- FloatingActionButton -- - floatingActionButtonTheme: FloatingActionButtonThemeData( - backgroundColor: primary, - foregroundColor: _contrastColor(primary), - elevation: 4, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - // -- PopupMenu -- - popupMenuTheme: PopupMenuThemeData( - color: surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - elevation: 4, - textStyle: textTheme.bodyMedium, - ), - ); - - return MobileTheme( - id: id, - name: name, - colorScheme: colorScheme, - themeData: themeData, - // Editor - editorBackground: bgSecondary, - editorLineHighlight: primary.withOpacity(0.06), - // Terminal - terminalBackground: Color(0xFF0C0C0C) == bgPrimary ? bgSecondary : bgPrimary, - terminalText: textPrimary, - // Glassmorphism card - cardGlassBackground: glassBase.withOpacity(0.08), - cardGlassBorder: glassBase.withOpacity(0.15), - // Sidebar - sidebarBackground: bgSecondary.withOpacity(0.9), - // Status bar - statusBarBackground: bgSecondary, - // Toolbar - toolbarBackground: surfaceLight.withOpacity(0.8), - // Divider - dividerColor: textMuted.withOpacity(0.12), - // Glassmorphism params - glassOpacity: 0.08, - glassBlur: 20, - glassOverlayColor: glassBase.withOpacity(0.06), - glassBorderColor: glassBase.withOpacity(0.2), - // Animation personality - transitionDuration: transitionDuration, - transitionCurve: transitionCurve, - microAnimationDuration: microDuration, - microAnimationCurve: microCurve, - bounceCurve: Curves.elasticOut, - staggerDelay: const Duration(milliseconds: 40), - ); - } - - // -- Helpers -- - static Color _contrastColor(Color color) { - final luminance = color.computeLuminance(); - return luminance > 0.5 ? Colors.black : Colors.white; - } - - static Color _lerpColor(Color a, Color b, double t) { - return Color.lerp(a, b, t) ?? a; - } +/// Warm Claude-inspired palette for reading, planning, and review surfaces. +class _ClaudeYellowColors { + static const Color bgPrimary = Color(0xFF19110A); + static const Color bgSecondary = Color(0xFF24170C); + static const Color bgTertiary = Color(0xFF33200F); + static const Color primary = Color(0xFFD97706); + static const Color primaryLight = Color(0xFFFFB86B); + static const Color primaryDark = Color(0xFF9A4D00); + static const Color accent = Color(0xFFEF925B); + static const Color accentLight = Color(0xFFFFC18C); + static const Color accentDark = Color(0xFFB85F2D); + static const Color textPrimary = Color(0xFFFFF7ED); + static const Color textSecondary = Color(0xFFE9C9A7); + static const Color textMuted = Color(0xFFA9825B); + static const Color surface = Color(0xFF24170C); + static const Color surfaceLight = Color(0xFF3A250F); + static const Color error = Color(0xFFFF5C6C); + static const Color warning = Color(0xFFFFB020); + static const Color success = Color(0xFF48C774); + static const Color glass = Color(0x18FFB86B); } -// -- Extension for lightness adjustment -- -extension _ColorExt on Color { - Color withLightness(double delta) { - final hsl = HSLColor.fromColor(this); - return hsl - .withLightness((hsl.lightness + delta).clamp(0.0, 1.0)) - .toColor(); - } +/// Crisp Codex-inspired blue palette for build, preview, and release work. +class _CodexBlueColors { + static const Color bgPrimary = Color(0xFF071326); + static const Color bgSecondary = Color(0xFF0B1B33); + static const Color bgTertiary = Color(0xFF102544); + static const Color primary = Color(0xFF2555FF); + static const Color primaryLight = Color(0xFF6EA8FF); + static const Color primaryDark = Color(0xFF1536B8); + static const Color accent = Color(0xFF16B9C7); + static const Color accentLight = Color(0xFF6BE4EE); + static const Color accentDark = Color(0xFF0E7F8A); + static const Color textPrimary = Color(0xFFF4F8FF); + static const Color textSecondary = Color(0xFFB4C8E8); + static const Color textMuted = Color(0xFF7187A8); + static const Color surface = Color(0xFF0B1B33); + static const Color surfaceLight = Color(0xFF122A4D); + static const Color error = Color(0xFFFF5C7A); + static const Color warning = Color(0xFFFFC857); + static const Color success = Color(0xFF22C55E); + static const Color glass = Color(0x182555FF); } // ============================================================ -// SECTION 5: Theme Factory — Instantiate All 5 Themes +// SECTION 3: MobileTheme — Aggregated Theme Data Object // ============================================================ - -class MobileThemeFactory { - static final Map _cache = {}; - - static MobileTheme get(AppTheme id) { - if (_cache.containsKey(id)) return _cache[id]!; - final theme = _build(id); - _cache[id] = theme; - return theme; - } - - static MobileTheme _build(AppTheme id) { - switch (id) { - case AppTheme.deepSpace: - return _ThemeBuilder.build( - id: id, - name: 'DeepSpace', - bgPrimary: _DeepSpaceColors.bgPrimary, - bgSecondary: _DeepSpaceColors.bgSecondary, - bgTertiary: _DeepSpaceColors.bgTertiary, - primary: _DeepSpaceColors.primary, - primaryLight: _DeepSpaceColors.primaryLight, - primaryDark: _DeepSpaceColors.primaryDark, - accent: _DeepSpaceColors.accent, - accentLight: _DeepSpaceColors.accentLight, - accentDark: _DeepSpaceColors.accentDark, - textPrimary: _DeepSpaceColors.textPrimary, - textSecondary: _DeepSpaceColors.textSecondary, - textMuted: _DeepSpaceColors.textMuted, - surface: _DeepSpaceColors.surface, - surfaceLight: _DeepSpaceColors.surfaceLight, - error: _DeepSpaceColors.error, - warning: _DeepSpaceColors.warning, - success: _DeepSpaceColors.success, - glassBase: _DeepSpaceColors.glass, - transitionDuration: const Duration(milliseconds: 500), - transitionCurve: Curves.easeInOutCubic, - microDuration: const Duration(milliseconds: 200), - microCurve: Curves.easeOutQuart, - ); - - case AppTheme.aurora: + +/// A richer theme container that bundles: +/// - Material [ColorScheme] & [ThemeData] +/// - Custom component colors (editor, terminal, cards) +/// - Animation configuration per theme +/// - Glassmorphism parameters +@immutable +class MobileTheme { + final AppTheme id; + final String name; + final ColorScheme colorScheme; + final ThemeData themeData; + + // -- Custom component colors -- + final Color editorBackground; + final Color editorLineHighlight; + final Color terminalBackground; + final Color terminalText; + final Color cardGlassBackground; + final Color cardGlassBorder; + final Color sidebarBackground; + final Color statusBarBackground; + final Color toolbarBackground; + final Color dividerColor; + + // -- Glassmorphism -- + final double glassOpacity; + final double glassBlur; + final Color glassOverlayColor; + final Color glassBorderColor; + + // -- Animation personality -- + final Duration transitionDuration; + final Curve transitionCurve; + final Duration microAnimationDuration; + final Curve microAnimationCurve; + final Curve bounceCurve; + final Duration staggerDelay; + + const MobileTheme({ + required this.id, + required this.name, + required this.colorScheme, + required this.themeData, + required this.editorBackground, + required this.editorLineHighlight, + required this.terminalBackground, + required this.terminalText, + required this.cardGlassBackground, + required this.cardGlassBorder, + required this.sidebarBackground, + required this.statusBarBackground, + required this.toolbarBackground, + required this.dividerColor, + required this.glassOpacity, + required this.glassBlur, + required this.glassOverlayColor, + required this.glassBorderColor, + required this.transitionDuration, + required this.transitionCurve, + required this.microAnimationDuration, + required this.microAnimationCurve, + required this.bounceCurve, + required this.staggerDelay, + }); +} + +// ============================================================ +// SECTION 4: Theme Builder — Material ThemeData Factory +// ============================================================ + +class _ThemeBuilder { + /// Creates a complete [MobileTheme] for a given raw palette and metadata. + static MobileTheme build({ + required AppTheme id, + required String name, + required Color bgPrimary, + required Color bgSecondary, + required Color bgTertiary, + required Color primary, + required Color primaryLight, + required Color primaryDark, + required Color accent, + required Color accentLight, + required Color accentDark, + required Color textPrimary, + required Color textSecondary, + required Color textMuted, + required Color surface, + required Color surfaceLight, + required Color error, + required Color warning, + required Color success, + required Color glassBase, + required Duration transitionDuration, + required Curve transitionCurve, + required Duration microDuration, + required Curve microCurve, + }) { + final brightness = Brightness.dark; + + // -- Material ColorScheme -- + final colorScheme = ColorScheme( + brightness: brightness, + primary: primary, + onPrimary: _contrastColor(primary), + primaryContainer: primaryDark.withOpacity(0.3), + onPrimaryContainer: primaryLight, + secondary: accent, + onSecondary: _contrastColor(accent), + secondaryContainer: accentDark.withOpacity(0.25), + onSecondaryContainer: accentLight, + tertiary: _lerpColor(primary, accent, 0.5), + onTertiary: textPrimary, + tertiaryContainer: bgTertiary, + onTertiaryContainer: textSecondary, + error: error, + onError: Colors.white, + errorContainer: error.withOpacity(0.15), + onErrorContainer: error.withLightness(+0.2), + surface: surface, + onSurface: textPrimary, + surfaceContainerHighest: surfaceLight, + onSurfaceVariant: textSecondary, + outline: textMuted.withOpacity(0.3), + outlineVariant: textMuted.withOpacity(0.15), + shadow: Colors.black.withOpacity(0.5), + scrim: Colors.black.withOpacity(0.7), + inverseSurface: textPrimary, + onInverseSurface: bgPrimary, + inversePrimary: primaryLight, + ); + + // -- TextTheme — uses Inter-style metrics (assumed font) -- + final textTheme = TextTheme( + displayLarge: TextStyle( + fontSize: 48, + fontWeight: FontWeight.w800, + color: textPrimary, + letterSpacing: -1.5, + ), + displayMedium: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w700, + color: textPrimary, + letterSpacing: -1.0, + ), + displaySmall: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: textPrimary, + letterSpacing: -0.5, + ), + headlineLarge: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: textPrimary, + letterSpacing: -0.5, + ), + headlineMedium: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: textPrimary, + letterSpacing: -0.3, + ), + headlineSmall: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + titleLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + titleMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + titleSmall: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: textSecondary, + letterSpacing: 0.5, + ), + bodyLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: textPrimary, + height: 1.5, + ), + bodyMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: textPrimary, + height: 1.4, + ), + bodySmall: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: textSecondary, + height: 1.3, + ), + labelLarge: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: textPrimary, + ), + labelMedium: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: textSecondary, + ), + labelSmall: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: textMuted, + letterSpacing: 0.5, + ), + ); + + // -- Component Themes -- + final inputBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: textMuted.withOpacity(0.2)), + ); + + final themeData = ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: colorScheme, + scaffoldBackgroundColor: bgPrimary, + canvasColor: bgSecondary, + cardColor: surface, + dividerColor: textMuted.withOpacity(0.15), + shadowColor: Colors.black.withOpacity(0.4), + textTheme: textTheme, + fontFamily: 'Inter', + // -- AppBar -- + appBarTheme: AppBarTheme( + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: bgSecondary.withOpacity(0.85), + foregroundColor: textPrimary, + centerTitle: true, + titleTextStyle: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + ), + // -- Card -- + cardTheme: CardThemeData( + elevation: 0, + margin: EdgeInsets.zero, + color: surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + clipBehavior: Clip.antiAlias, + ), + // -- ElevatedButton -- + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: primary, + foregroundColor: _contrastColor(primary), + minimumSize: const Size(64, 48), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + ), + ), + // -- OutlinedButton -- + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: primary, + minimumSize: const Size(64, 48), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + side: BorderSide(color: primary.withOpacity(0.5), width: 1.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + ), + ), + // -- TextButton -- + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primary, + minimumSize: const Size(48, 40), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + textStyle: textTheme.labelLarge, + ), + ), + // -- InputDecoration -- + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surfaceLight, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: inputBorder, + enabledBorder: inputBorder, + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: error, width: 1.5), + ), + hintStyle: textTheme.bodyMedium?.copyWith(color: textMuted), + labelStyle: textTheme.bodyMedium?.copyWith(color: textSecondary), + ), + // -- Bottom Navigation -- + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: bgSecondary.withOpacity(0.9), + selectedItemColor: primary, + unselectedItemColor: textMuted, + selectedLabelStyle: textTheme.labelSmall, + unselectedLabelStyle: textTheme.labelSmall, + type: BottomNavigationBarType.fixed, + elevation: 0, + showSelectedLabels: true, + showUnselectedLabels: true, + ), + // -- BottomSheet -- + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: bgSecondary, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + clipBehavior: Clip.antiAlias, + elevation: 8, + ), + // -- Dialog -- + dialogTheme: DialogTheme( + backgroundColor: surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + titleTextStyle: textTheme.headlineSmall, + contentTextStyle: textTheme.bodyMedium, + elevation: 4, + ), + // -- Chip -- + chipTheme: ChipThemeData( + backgroundColor: surfaceLight, + selectedColor: primary.withOpacity(0.2), + labelStyle: textTheme.labelMedium, + secondaryLabelStyle: textTheme.labelMedium?.copyWith(color: primary), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + side: BorderSide.none, + ), + // -- TabBar -- + tabBarTheme: TabBarTheme( + labelColor: primary, + unselectedLabelColor: textMuted, + indicatorColor: primary, + indicatorSize: TabBarIndicatorSize.tab, + labelStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + unselectedLabelStyle: textTheme.labelLarge, + dividerColor: Colors.transparent, + ), + // -- Switch -- + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return primary; + return textMuted; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return primary.withOpacity(0.35); + } + return textMuted.withOpacity(0.2); + }), + trackOutlineColor: WidgetStateProperty.all(Colors.transparent), + ), + // -- Slider -- + sliderTheme: SliderThemeData( + activeTrackColor: primary, + inactiveTrackColor: textMuted.withOpacity(0.2), + thumbColor: primaryLight, + overlayColor: primary.withOpacity(0.1), + trackHeight: 4, + ), + // -- ProgressIndicator -- + progressIndicatorTheme: ProgressIndicatorThemeData( + color: primary, + linearTrackColor: textMuted.withOpacity(0.15), + circularTrackColor: textMuted.withOpacity(0.15), + ), + // -- Snackbar -- + snackBarTheme: SnackBarThemeData( + backgroundColor: surfaceLight, + contentTextStyle: textTheme.bodyMedium, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + behavior: SnackBarBehavior.floating, + elevation: 2, + ), + // -- Tooltip -- + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: surfaceLight, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: textMuted.withOpacity(0.15)), + ), + textStyle: textTheme.labelMedium, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + preferBelow: true, + ), + // -- Divider -- + dividerTheme: DividerThemeData( + color: textMuted.withOpacity(0.12), + thickness: 1, + space: 1, + ), + // -- ListTile -- + listTileTheme: ListTileThemeData( + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + minLeadingWidth: 36, + iconColor: textSecondary, + textColor: textPrimary, + selectedColor: primary, + selectedTileColor: primary.withOpacity(0.08), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + // -- FloatingActionButton -- + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: primary, + foregroundColor: _contrastColor(primary), + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + // -- PopupMenu -- + popupMenuTheme: PopupMenuThemeData( + color: surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 4, + textStyle: textTheme.bodyMedium, + ), + ); + + return MobileTheme( + id: id, + name: name, + colorScheme: colorScheme, + themeData: themeData, + // Editor + editorBackground: bgSecondary, + editorLineHighlight: primary.withOpacity(0.06), + // Terminal + terminalBackground: Color(0xFF0C0C0C) == bgPrimary ? bgSecondary : bgPrimary, + terminalText: textPrimary, + // Glassmorphism card + cardGlassBackground: glassBase.withOpacity(0.08), + cardGlassBorder: glassBase.withOpacity(0.15), + // Sidebar + sidebarBackground: bgSecondary.withOpacity(0.9), + // Status bar + statusBarBackground: bgSecondary, + // Toolbar + toolbarBackground: surfaceLight.withOpacity(0.8), + // Divider + dividerColor: textMuted.withOpacity(0.12), + // Glassmorphism params + glassOpacity: 0.08, + glassBlur: 20, + glassOverlayColor: glassBase.withOpacity(0.06), + glassBorderColor: glassBase.withOpacity(0.2), + // Animation personality + transitionDuration: transitionDuration, + transitionCurve: transitionCurve, + microAnimationDuration: microDuration, + microAnimationCurve: microCurve, + bounceCurve: Curves.elasticOut, + staggerDelay: const Duration(milliseconds: 40), + ); + } + + // -- Helpers -- + static Color _contrastColor(Color color) { + final luminance = color.computeLuminance(); + return luminance > 0.5 ? Colors.black : Colors.white; + } + + static Color _lerpColor(Color a, Color b, double t) { + return Color.lerp(a, b, t) ?? a; + } +} + +// -- Extension for lightness adjustment -- +extension _ColorExt on Color { + Color withLightness(double delta) { + final hsl = HSLColor.fromColor(this); + return hsl + .withLightness((hsl.lightness + delta).clamp(0.0, 1.0)) + .toColor(); + } +} + +// ============================================================ +// SECTION 5: Theme Factory — Instantiate All 5 Themes +// ============================================================ + +class MobileThemeFactory { + static final Map _cache = {}; + + static MobileTheme get(AppTheme id) { + if (_cache.containsKey(id)) return _cache[id]!; + final theme = _build(id); + _cache[id] = theme; + return theme; + } + + static MobileTheme _build(AppTheme id) { + switch (id) { + case AppTheme.deepSpace: + return _ThemeBuilder.build( + id: id, + name: 'DeepSpace', + bgPrimary: _DeepSpaceColors.bgPrimary, + bgSecondary: _DeepSpaceColors.bgSecondary, + bgTertiary: _DeepSpaceColors.bgTertiary, + primary: _DeepSpaceColors.primary, + primaryLight: _DeepSpaceColors.primaryLight, + primaryDark: _DeepSpaceColors.primaryDark, + accent: _DeepSpaceColors.accent, + accentLight: _DeepSpaceColors.accentLight, + accentDark: _DeepSpaceColors.accentDark, + textPrimary: _DeepSpaceColors.textPrimary, + textSecondary: _DeepSpaceColors.textSecondary, + textMuted: _DeepSpaceColors.textMuted, + surface: _DeepSpaceColors.surface, + surfaceLight: _DeepSpaceColors.surfaceLight, + error: _DeepSpaceColors.error, + warning: _DeepSpaceColors.warning, + success: _DeepSpaceColors.success, + glassBase: _DeepSpaceColors.glass, + transitionDuration: const Duration(milliseconds: 500), + transitionCurve: Curves.easeInOutCubic, + microDuration: const Duration(milliseconds: 200), + microCurve: Curves.easeOutQuart, + ); + + case AppTheme.aurora: + return _ThemeBuilder.build( + id: id, + name: 'Aurora', + bgPrimary: _AuroraColors.bgPrimary, + bgSecondary: _AuroraColors.bgSecondary, + bgTertiary: _AuroraColors.bgTertiary, + primary: _AuroraColors.primary, + primaryLight: _AuroraColors.primaryLight, + primaryDark: _AuroraColors.primaryDark, + accent: _AuroraColors.accent, + accentLight: _AuroraColors.accentLight, + accentDark: _AuroraColors.accentDark, + textPrimary: _AuroraColors.textPrimary, + textSecondary: _AuroraColors.textSecondary, + textMuted: _AuroraColors.textMuted, + surface: _AuroraColors.surface, + surfaceLight: _AuroraColors.surfaceLight, + error: _AuroraColors.error, + warning: _AuroraColors.warning, + success: _AuroraColors.success, + glassBase: _AuroraColors.glass, + transitionDuration: const Duration(milliseconds: 600), + transitionCurve: Curves.easeInOutSine, + microDuration: const Duration(milliseconds: 250), + microCurve: Curves.easeOutCubic, + ); + + case AppTheme.midnightForest: + return _ThemeBuilder.build( + id: id, + name: 'Midnight Forest', + bgPrimary: _MidnightForestColors.bgPrimary, + bgSecondary: _MidnightForestColors.bgSecondary, + bgTertiary: _MidnightForestColors.bgTertiary, + primary: _MidnightForestColors.primary, + primaryLight: _MidnightForestColors.primaryLight, + primaryDark: _MidnightForestColors.primaryDark, + accent: _MidnightForestColors.accent, + accentLight: _MidnightForestColors.accentLight, + accentDark: _MidnightForestColors.accentDark, + textPrimary: _MidnightForestColors.textPrimary, + textSecondary: _MidnightForestColors.textSecondary, + textMuted: _MidnightForestColors.textMuted, + surface: _MidnightForestColors.surface, + surfaceLight: _MidnightForestColors.surfaceLight, + error: _MidnightForestColors.error, + warning: _MidnightForestColors.warning, + success: _MidnightForestColors.success, + glassBase: _MidnightForestColors.glass, + transitionDuration: const Duration(milliseconds: 450), + transitionCurve: Curves.easeInOutQuad, + microDuration: const Duration(milliseconds: 180), + microCurve: Curves.easeOutQuart, + ); + + case AppTheme.cyberSunset: + return _ThemeBuilder.build( + id: id, + name: 'Cyber Sunset', + bgPrimary: _CyberSunsetColors.bgPrimary, + bgSecondary: _CyberSunsetColors.bgSecondary, + bgTertiary: _CyberSunsetColors.bgTertiary, + primary: _CyberSunsetColors.primary, + primaryLight: _CyberSunsetColors.primaryLight, + primaryDark: _CyberSunsetColors.primaryDark, + accent: _CyberSunsetColors.accent, + accentLight: _CyberSunsetColors.accentLight, + accentDark: _CyberSunsetColors.accentDark, + textPrimary: _CyberSunsetColors.textPrimary, + textSecondary: _CyberSunsetColors.textSecondary, + textMuted: _CyberSunsetColors.textMuted, + surface: _CyberSunsetColors.surface, + surfaceLight: _CyberSunsetColors.surfaceLight, + error: _CyberSunsetColors.error, + warning: _CyberSunsetColors.warning, + success: _CyberSunsetColors.success, + glassBase: _CyberSunsetColors.glass, + transitionDuration: const Duration(milliseconds: 550), + transitionCurve: Curves.easeInOutBack, + microDuration: const Duration(milliseconds: 220), + microCurve: Curves.easeOutBack, + ); + + case AppTheme.monochromeGeek: return _ThemeBuilder.build( id: id, - name: 'Aurora', - bgPrimary: _AuroraColors.bgPrimary, - bgSecondary: _AuroraColors.bgSecondary, - bgTertiary: _AuroraColors.bgTertiary, - primary: _AuroraColors.primary, - primaryLight: _AuroraColors.primaryLight, - primaryDark: _AuroraColors.primaryDark, - accent: _AuroraColors.accent, - accentLight: _AuroraColors.accentLight, - accentDark: _AuroraColors.accentDark, - textPrimary: _AuroraColors.textPrimary, - textSecondary: _AuroraColors.textSecondary, - textMuted: _AuroraColors.textMuted, - surface: _AuroraColors.surface, - surfaceLight: _AuroraColors.surfaceLight, - error: _AuroraColors.error, - warning: _AuroraColors.warning, - success: _AuroraColors.success, - glassBase: _AuroraColors.glass, - transitionDuration: const Duration(milliseconds: 600), - transitionCurve: Curves.easeInOutSine, - microDuration: const Duration(milliseconds: 250), - microCurve: Curves.easeOutCubic, + name: 'Monochrome Geek', + bgPrimary: _MonochromeGeekColors.bgPrimary, + bgSecondary: _MonochromeGeekColors.bgSecondary, + bgTertiary: _MonochromeGeekColors.bgTertiary, + primary: _MonochromeGeekColors.primary, + primaryLight: _MonochromeGeekColors.primaryLight, + primaryDark: _MonochromeGeekColors.primaryDark, + accent: _MonochromeGeekColors.accent, + accentLight: _MonochromeGeekColors.accentLight, + accentDark: _MonochromeGeekColors.accentDark, + textPrimary: _MonochromeGeekColors.textPrimary, + textSecondary: _MonochromeGeekColors.textSecondary, + textMuted: _MonochromeGeekColors.textMuted, + surface: _MonochromeGeekColors.surface, + surfaceLight: _MonochromeGeekColors.surfaceLight, + error: _MonochromeGeekColors.error, + warning: _MonochromeGeekColors.warning, + success: _MonochromeGeekColors.success, + glassBase: _MonochromeGeekColors.glass, + transitionDuration: const Duration(milliseconds: 350), + transitionCurve: Curves.easeInOut, + microDuration: const Duration(milliseconds: 150), + microCurve: Curves.easeOut, ); - case AppTheme.midnightForest: + case AppTheme.claudeYellow: return _ThemeBuilder.build( id: id, - name: 'Midnight Forest', - bgPrimary: _MidnightForestColors.bgPrimary, - bgSecondary: _MidnightForestColors.bgSecondary, - bgTertiary: _MidnightForestColors.bgTertiary, - primary: _MidnightForestColors.primary, - primaryLight: _MidnightForestColors.primaryLight, - primaryDark: _MidnightForestColors.primaryDark, - accent: _MidnightForestColors.accent, - accentLight: _MidnightForestColors.accentLight, - accentDark: _MidnightForestColors.accentDark, - textPrimary: _MidnightForestColors.textPrimary, - textSecondary: _MidnightForestColors.textSecondary, - textMuted: _MidnightForestColors.textMuted, - surface: _MidnightForestColors.surface, - surfaceLight: _MidnightForestColors.surfaceLight, - error: _MidnightForestColors.error, - warning: _MidnightForestColors.warning, - success: _MidnightForestColors.success, - glassBase: _MidnightForestColors.glass, - transitionDuration: const Duration(milliseconds: 450), - transitionCurve: Curves.easeInOutQuad, + name: 'Claude Yellow', + bgPrimary: _ClaudeYellowColors.bgPrimary, + bgSecondary: _ClaudeYellowColors.bgSecondary, + bgTertiary: _ClaudeYellowColors.bgTertiary, + primary: _ClaudeYellowColors.primary, + primaryLight: _ClaudeYellowColors.primaryLight, + primaryDark: _ClaudeYellowColors.primaryDark, + accent: _ClaudeYellowColors.accent, + accentLight: _ClaudeYellowColors.accentLight, + accentDark: _ClaudeYellowColors.accentDark, + textPrimary: _ClaudeYellowColors.textPrimary, + textSecondary: _ClaudeYellowColors.textSecondary, + textMuted: _ClaudeYellowColors.textMuted, + surface: _ClaudeYellowColors.surface, + surfaceLight: _ClaudeYellowColors.surfaceLight, + error: _ClaudeYellowColors.error, + warning: _ClaudeYellowColors.warning, + success: _ClaudeYellowColors.success, + glassBase: _ClaudeYellowColors.glass, + transitionDuration: const Duration(milliseconds: 420), + transitionCurve: Curves.easeInOutCubic, microDuration: const Duration(milliseconds: 180), microCurve: Curves.easeOutQuart, ); - case AppTheme.cyberSunset: - return _ThemeBuilder.build( - id: id, - name: 'Cyber Sunset', - bgPrimary: _CyberSunsetColors.bgPrimary, - bgSecondary: _CyberSunsetColors.bgSecondary, - bgTertiary: _CyberSunsetColors.bgTertiary, - primary: _CyberSunsetColors.primary, - primaryLight: _CyberSunsetColors.primaryLight, - primaryDark: _CyberSunsetColors.primaryDark, - accent: _CyberSunsetColors.accent, - accentLight: _CyberSunsetColors.accentLight, - accentDark: _CyberSunsetColors.accentDark, - textPrimary: _CyberSunsetColors.textPrimary, - textSecondary: _CyberSunsetColors.textSecondary, - textMuted: _CyberSunsetColors.textMuted, - surface: _CyberSunsetColors.surface, - surfaceLight: _CyberSunsetColors.surfaceLight, - error: _CyberSunsetColors.error, - warning: _CyberSunsetColors.warning, - success: _CyberSunsetColors.success, - glassBase: _CyberSunsetColors.glass, - transitionDuration: const Duration(milliseconds: 550), - transitionCurve: Curves.easeInOutBack, - microDuration: const Duration(milliseconds: 220), - microCurve: Curves.easeOutBack, - ); - - case AppTheme.monochromeGeek: + case AppTheme.codexBlue: return _ThemeBuilder.build( id: id, - name: 'Monochrome Geek', - bgPrimary: _MonochromeGeekColors.bgPrimary, - bgSecondary: _MonochromeGeekColors.bgSecondary, - bgTertiary: _MonochromeGeekColors.bgTertiary, - primary: _MonochromeGeekColors.primary, - primaryLight: _MonochromeGeekColors.primaryLight, - primaryDark: _MonochromeGeekColors.primaryDark, - accent: _MonochromeGeekColors.accent, - accentLight: _MonochromeGeekColors.accentLight, - accentDark: _MonochromeGeekColors.accentDark, - textPrimary: _MonochromeGeekColors.textPrimary, - textSecondary: _MonochromeGeekColors.textSecondary, - textMuted: _MonochromeGeekColors.textMuted, - surface: _MonochromeGeekColors.surface, - surfaceLight: _MonochromeGeekColors.surfaceLight, - error: _MonochromeGeekColors.error, - warning: _MonochromeGeekColors.warning, - success: _MonochromeGeekColors.success, - glassBase: _MonochromeGeekColors.glass, - transitionDuration: const Duration(milliseconds: 350), - transitionCurve: Curves.easeInOut, - microDuration: const Duration(milliseconds: 150), - microCurve: Curves.easeOut, + name: 'Codex Blue', + bgPrimary: _CodexBlueColors.bgPrimary, + bgSecondary: _CodexBlueColors.bgSecondary, + bgTertiary: _CodexBlueColors.bgTertiary, + primary: _CodexBlueColors.primary, + primaryLight: _CodexBlueColors.primaryLight, + primaryDark: _CodexBlueColors.primaryDark, + accent: _CodexBlueColors.accent, + accentLight: _CodexBlueColors.accentLight, + accentDark: _CodexBlueColors.accentDark, + textPrimary: _CodexBlueColors.textPrimary, + textSecondary: _CodexBlueColors.textSecondary, + textMuted: _CodexBlueColors.textMuted, + surface: _CodexBlueColors.surface, + surfaceLight: _CodexBlueColors.surfaceLight, + error: _CodexBlueColors.error, + warning: _CodexBlueColors.warning, + success: _CodexBlueColors.success, + glassBase: _CodexBlueColors.glass, + transitionDuration: const Duration(milliseconds: 380), + transitionCurve: Curves.easeInOutCubic, + microDuration: const Duration(milliseconds: 160), + microCurve: Curves.easeOutQuart, ); } } } - -// ============================================================ -// SECTION 6: Theme Manager — State Management & Persistence -// ============================================================ - -/// Central theme manager that handles: -/// - Active theme state -/// - Persistence via SharedPreferences -/// - System dark-mode detection -/// - Animated theme transition broadcasting -class ThemeManager extends ChangeNotifier { - static const String _prefsKey = 'mobilecode_active_theme'; - static const String _modeKey = 'mobilecode_theme_mode'; - - late AppTheme _activeTheme; - ThemeMode _themeMode; - bool _followSystem; - - // -- Subscriptions for animated transitions -- - final List _transitionListeners = []; - bool _isTransitioning = false; - - // -- Singleton access -- - static ThemeManager? _instance; - static Future getInstance() async { - if (_instance != null) return _instance!; - _instance = ThemeManager._internal(); - await _instance!._loadPersisted(); - return _instance!; - } - - ThemeManager._internal() - : _activeTheme = AppTheme.deepSpace, - _themeMode = ThemeMode.dark, - _followSystem = false; - - // -- Public getters -- - AppTheme get activeThemeId => _activeTheme; - MobileTheme get activeTheme => MobileThemeFactory.get(_activeTheme); - ThemeData get activeThemeData => activeTheme.themeData; - ThemeMode get themeMode => _themeMode; - bool get followSystem => _followSystem; - bool get isTransitioning => _isTransitioning; - - // -- Setters with persistence -- - Future setTheme(AppTheme theme, {bool animate = true}) async { - if (_activeTheme == theme) return; - if (animate) { - _isTransitioning = true; - notifyListeners(); - await Future.delayed(const Duration(milliseconds: 50)); - } - _activeTheme = theme; - _isTransitioning = false; - notifyListeners(); - await _persist(); - } - - Future setThemeMode(ThemeMode mode) async { - _themeMode = mode; - notifyListeners(); - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_modeKey, mode.index); - } - - Future setFollowSystem(bool value) async { - _followSystem = value; - notifyListeners(); - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('${_modeKey}_follow_system', value); - } - - /// Quick toggle between two favourite themes. - void quickToggle() { - final themes = AppTheme.values; - final nextIndex = (themes.indexOf(_activeTheme) + 1) % themes.length; - setTheme(themes[nextIndex]); - } - - /// Register a callback that fires when a theme transition begins. - void addTransitionListener(VoidCallback listener) { - _transitionListeners.add(listener); - } - - void removeTransitionListener(VoidCallback listener) { - _transitionListeners.remove(listener); - } - - // -- Persistence -- - Future _persist() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_prefsKey, _activeTheme.name); - } - - Future _loadPersisted() async { - final prefs = await SharedPreferences.getInstance(); - final saved = prefs.getString(_prefsKey); - if (saved != null) { - _activeTheme = AppTheme.values.firstWhere( - (t) => t.name == saved, - orElse: () => AppTheme.deepSpace, - ); - } - final modeIndex = prefs.getInt(_modeKey); - if (modeIndex != null) { - _themeMode = ThemeMode.values[modeIndex]; - } - _followSystem = prefs.getBool('${_modeKey}_follow_system') ?? false; - } - - /// Clean up singleton (mainly for testing). - static void reset() { - _instance = null; - } -} - -// ============================================================ -// SECTION 7: Inherited Notifier — Widget Tree Access -// ============================================================ - -/// Provides [ThemeManager] access to the widget tree via -/// `ThemeProvider.of(context)`. -class ThemeProvider extends InheritedNotifier { - const ThemeProvider({ - Key? key, - required ThemeManager notifier, - required Widget child, - }) : super(key: key, notifier: notifier, child: child); - - static ThemeManager of(BuildContext context) { - final provider = - context.dependOnInheritedWidgetOfExactType(); - assert(provider != null, 'ThemeProvider not found in widget tree'); - return provider!.notifier!; - } -} - -// ============================================================ -// SECTION 8: Glassmorphism Presets — Reusable Decoration -// ============================================================ - -/// Static factory for glassmorphism card decorations that adapt -/// automatically to the active theme. -class Glassmorphism { - /// Builds a glassmorphism card decoration for the given [theme]. - static BoxDecoration card(MobileTheme theme) { - return BoxDecoration( - color: theme.cardGlassBackground, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: theme.cardGlassBorder, width: 1), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 24, - offset: const Offset(0, 8), - ), - BoxShadow( - color: theme.colorScheme.primary.withOpacity(0.05), - blurRadius: 40, - offset: const Offset(0, 0), - ), - ], - ); - } - - /// A subtle glass chip/pill decoration. - static BoxDecoration chip(MobileTheme theme, {bool isActive = false}) { - return BoxDecoration( - color: isActive - ? theme.colorScheme.primary.withOpacity(0.15) - : theme.glassOverlayColor, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isActive - ? theme.colorScheme.primary.withOpacity(0.4) - : theme.glassBorderColor, - width: 1, - ), - ); - } - - /// A floating panel (e.g., bottom toolbar). - static BoxDecoration panel(MobileTheme theme) { - return BoxDecoration( - color: theme.toolbarBackground, - borderRadius: const BorderRadius.all(Radius.circular(20)), - border: Border.all( - color: theme.glassBorderColor.withOpacity(0.3), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 20, - offset: const Offset(0, 4), - ), - ], - ); - } - - /// Editor container decoration. - static BoxDecoration editor(MobileTheme theme) { - return BoxDecoration( - color: theme.editorBackground, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: theme.dividerColor, width: 1), - ); - } - - /// Terminal container decoration. - static BoxDecoration terminal(MobileTheme theme) { - return BoxDecoration( - color: theme.terminalBackground, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: theme.dividerColor.withOpacity(0.5), width: 1), - ); - } -} - -// ============================================================ -// SECTION 9: Theme Animation Mixin -// ============================================================ - -/// Mixin for widgets that want to react to theme transitions -/// with built-in animation controllers. -mixin ThemeAnimationMixin on State - implements TickerProvider { - late AnimationController _themeAnimController; - late Animation themeFadeAnimation; - late Animation themeSlideAnimation; - - void initThemeAnimation({ - Duration duration = const Duration(milliseconds: 500), - Curve curve = Curves.easeInOutCubic, - }) { - _themeAnimController = AnimationController( - vsync: this, - duration: duration, - ); - themeFadeAnimation = CurvedAnimation( - parent: _themeAnimController, - curve: curve, - ); - themeSlideAnimation = Tween( - begin: const Offset(0, 0.05), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _themeAnimController, - curve: curve, - )); - _themeAnimController.forward(); - } - - void triggerThemeTransition() { - _themeAnimController.reset(); - _themeAnimController.forward(); - } - - @override - void dispose() { - _themeAnimController.dispose(); - super.dispose(); - } -} - -// ============================================================ -// SECTION 10: Syntax Highlighting Colors (Editor Themes) -// ============================================================ - -/// Per-theme syntax highlighting map for the code editor. -extension SyntaxColors on MobileTheme { - Map get syntaxHighlight { - switch (id) { - case AppTheme.deepSpace: - return { - 'keyword': const Color(0xFF7B2FF7), - 'string': const Color(0xFF00D4AA), - 'comment': const Color(0xFF6B7280), - 'number': const Color(0xFFF59E0B), - 'function': const Color(0xFF9D5CFF), - 'type': const Color(0xFF00D4AA), - 'variable': const Color(0xFFF0F0F5), - 'operator': const Color(0xFF7B2FF7), - }; - case AppTheme.aurora: - return { - 'keyword': const Color(0xFF00FF88), - 'string': const Color(0xFFFF6B9D), - 'comment': const Color(0xFF6B8A80), - 'number': const Color(0xFFFFBB33), - 'function': const Color(0xFF33FFA0), - 'type': const Color(0xFFFF8FB0), - 'variable': const Color(0xFFE8F4F0), - 'operator': const Color(0xFF00CC6A), - }; - case AppTheme.midnightForest: - return { - 'keyword': const Color(0xFF2ECC71), - 'string': const Color(0xFFF39C12), - 'comment': const Color(0xFF708C68), - 'number': const Color(0xFFF5B041), - 'function': const Color(0xFF52D687), - 'type': const Color(0xFFF5B041), - 'variable': const Color(0xFFF2F8F0), - 'operator': const Color(0xFF25A55A), - }; - case AppTheme.cyberSunset: - return { - 'keyword': const Color(0xFFFF7B54), - 'string': const Color(0xFF9B59B6), - 'comment': const Color(0xFF9A7880), - 'number': const Color(0xFFFFA502), - 'function': const Color(0xFFFF9D80), - 'type': const Color(0xFFB07DC9), - 'variable': const Color(0xFFFFF0EB), - 'operator': const Color(0xFFE06040), - }; + +// ============================================================ +// SECTION 6: Theme Manager — State Management & Persistence +// ============================================================ + +/// Central theme manager that handles: +/// - Active theme state +/// - Persistence via SharedPreferences +/// - System dark-mode detection +/// - Animated theme transition broadcasting +class ThemeManager extends ChangeNotifier { + static const String _prefsKey = 'mobilecode_active_theme'; + static const String _modeKey = 'mobilecode_theme_mode'; + + late AppTheme _activeTheme; + ThemeMode _themeMode; + bool _followSystem; + + // -- Subscriptions for animated transitions -- + final List _transitionListeners = []; + bool _isTransitioning = false; + + // -- Singleton access -- + static ThemeManager? _instance; + static Future getInstance() async { + if (_instance != null) return _instance!; + _instance = ThemeManager._internal(); + await _instance!._loadPersisted(); + return _instance!; + } + + ThemeManager._internal() + : _activeTheme = AppTheme.deepSpace, + _themeMode = ThemeMode.dark, + _followSystem = false; + + // -- Public getters -- + AppTheme get activeThemeId => _activeTheme; + MobileTheme get activeTheme => MobileThemeFactory.get(_activeTheme); + ThemeData get activeThemeData => activeTheme.themeData; + ThemeMode get themeMode => _themeMode; + bool get followSystem => _followSystem; + bool get isTransitioning => _isTransitioning; + + // -- Setters with persistence -- + Future setTheme(AppTheme theme, {bool animate = true}) async { + if (_activeTheme == theme) return; + if (animate) { + _isTransitioning = true; + notifyListeners(); + await Future.delayed(const Duration(milliseconds: 50)); + } + _activeTheme = theme; + _isTransitioning = false; + notifyListeners(); + await _persist(); + } + + Future setThemeMode(ThemeMode mode) async { + _themeMode = mode; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_modeKey, mode.index); + } + + Future setFollowSystem(bool value) async { + _followSystem = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('${_modeKey}_follow_system', value); + } + + /// Quick toggle between two favourite themes. + void quickToggle() { + final themes = AppTheme.values; + final nextIndex = (themes.indexOf(_activeTheme) + 1) % themes.length; + setTheme(themes[nextIndex]); + } + + /// Register a callback that fires when a theme transition begins. + void addTransitionListener(VoidCallback listener) { + _transitionListeners.add(listener); + } + + void removeTransitionListener(VoidCallback listener) { + _transitionListeners.remove(listener); + } + + // -- Persistence -- + Future _persist() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, _activeTheme.name); + } + + Future _loadPersisted() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_prefsKey); + if (saved != null) { + _activeTheme = AppTheme.values.firstWhere( + (t) => t.name == saved, + orElse: () => AppTheme.deepSpace, + ); + } + final modeIndex = prefs.getInt(_modeKey); + if (modeIndex != null) { + _themeMode = ThemeMode.values[modeIndex]; + } + _followSystem = prefs.getBool('${_modeKey}_follow_system') ?? false; + } + + /// Clean up singleton (mainly for testing). + static void reset() { + _instance = null; + } +} + +// ============================================================ +// SECTION 7: Inherited Notifier — Widget Tree Access +// ============================================================ + +/// Provides [ThemeManager] access to the widget tree via +/// `ThemeProvider.of(context)`. +class ThemeProvider extends InheritedNotifier { + const ThemeProvider({ + Key? key, + required ThemeManager notifier, + required Widget child, + }) : super(key: key, notifier: notifier, child: child); + + static ThemeManager of(BuildContext context) { + final provider = + context.dependOnInheritedWidgetOfExactType(); + assert(provider != null, 'ThemeProvider not found in widget tree'); + return provider!.notifier!; + } +} + +// ============================================================ +// SECTION 8: Glassmorphism Presets — Reusable Decoration +// ============================================================ + +/// Static factory for glassmorphism card decorations that adapt +/// automatically to the active theme. +class Glassmorphism { + /// Builds a glassmorphism card decoration for the given [theme]. + static BoxDecoration card(MobileTheme theme) { + return BoxDecoration( + color: theme.cardGlassBackground, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: theme.cardGlassBorder, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 24, + offset: const Offset(0, 8), + ), + BoxShadow( + color: theme.colorScheme.primary.withOpacity(0.05), + blurRadius: 40, + offset: const Offset(0, 0), + ), + ], + ); + } + + /// A subtle glass chip/pill decoration. + static BoxDecoration chip(MobileTheme theme, {bool isActive = false}) { + return BoxDecoration( + color: isActive + ? theme.colorScheme.primary.withOpacity(0.15) + : theme.glassOverlayColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isActive + ? theme.colorScheme.primary.withOpacity(0.4) + : theme.glassBorderColor, + width: 1, + ), + ); + } + + /// A floating panel (e.g., bottom toolbar). + static BoxDecoration panel(MobileTheme theme) { + return BoxDecoration( + color: theme.toolbarBackground, + borderRadius: const BorderRadius.all(Radius.circular(20)), + border: Border.all( + color: theme.glassBorderColor.withOpacity(0.3), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ); + } + + /// Editor container decoration. + static BoxDecoration editor(MobileTheme theme) { + return BoxDecoration( + color: theme.editorBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor, width: 1), + ); + } + + /// Terminal container decoration. + static BoxDecoration terminal(MobileTheme theme) { + return BoxDecoration( + color: theme.terminalBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor.withOpacity(0.5), width: 1), + ); + } +} + +// ============================================================ +// SECTION 9: Theme Animation Mixin +// ============================================================ + +/// Mixin for widgets that want to react to theme transitions +/// with built-in animation controllers. +mixin ThemeAnimationMixin on State + implements TickerProvider { + late AnimationController _themeAnimController; + late Animation themeFadeAnimation; + late Animation themeSlideAnimation; + + void initThemeAnimation({ + Duration duration = const Duration(milliseconds: 500), + Curve curve = Curves.easeInOutCubic, + }) { + _themeAnimController = AnimationController( + vsync: this, + duration: duration, + ); + themeFadeAnimation = CurvedAnimation( + parent: _themeAnimController, + curve: curve, + ); + themeSlideAnimation = Tween( + begin: const Offset(0, 0.05), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _themeAnimController, + curve: curve, + )); + _themeAnimController.forward(); + } + + void triggerThemeTransition() { + _themeAnimController.reset(); + _themeAnimController.forward(); + } + + @override + void dispose() { + _themeAnimController.dispose(); + super.dispose(); + } +} + +// ============================================================ +// SECTION 10: Syntax Highlighting Colors (Editor Themes) +// ============================================================ + +/// Per-theme syntax highlighting map for the code editor. +extension SyntaxColors on MobileTheme { + Map get syntaxHighlight { + switch (id) { + case AppTheme.deepSpace: + return { + 'keyword': const Color(0xFF7B2FF7), + 'string': const Color(0xFF00D4AA), + 'comment': const Color(0xFF6B7280), + 'number': const Color(0xFFF59E0B), + 'function': const Color(0xFF9D5CFF), + 'type': const Color(0xFF00D4AA), + 'variable': const Color(0xFFF0F0F5), + 'operator': const Color(0xFF7B2FF7), + }; + case AppTheme.aurora: + return { + 'keyword': const Color(0xFF00FF88), + 'string': const Color(0xFFFF6B9D), + 'comment': const Color(0xFF6B8A80), + 'number': const Color(0xFFFFBB33), + 'function': const Color(0xFF33FFA0), + 'type': const Color(0xFFFF8FB0), + 'variable': const Color(0xFFE8F4F0), + 'operator': const Color(0xFF00CC6A), + }; + case AppTheme.midnightForest: + return { + 'keyword': const Color(0xFF2ECC71), + 'string': const Color(0xFFF39C12), + 'comment': const Color(0xFF708C68), + 'number': const Color(0xFFF5B041), + 'function': const Color(0xFF52D687), + 'type': const Color(0xFFF5B041), + 'variable': const Color(0xFFF2F8F0), + 'operator': const Color(0xFF25A55A), + }; + case AppTheme.cyberSunset: + return { + 'keyword': const Color(0xFFFF7B54), + 'string': const Color(0xFF9B59B6), + 'comment': const Color(0xFF9A7880), + 'number': const Color(0xFFFFA502), + 'function': const Color(0xFFFF9D80), + 'type': const Color(0xFFB07DC9), + 'variable': const Color(0xFFFFF0EB), + 'operator': const Color(0xFFE06040), + }; case AppTheme.monochromeGeek: return { 'keyword': const Color(0xFFFFFFFF), 'string': const Color(0xFFAAAAAA), 'comment': const Color(0xFF555555), - 'number': const Color(0xFF888888), - 'function': const Color(0xFFDDDDDD), - 'type': const Color(0xFFCCCCCC), + 'number': const Color(0xFF888888), + 'function': const Color(0xFFDDDDDD), + 'type': const Color(0xFFCCCCCC), 'variable': const Color(0xFFFFFFFF), 'operator': const Color(0xFF999999), }; + case AppTheme.claudeYellow: + return { + 'keyword': const Color(0xFFFFB86B), + 'string': const Color(0xFFEF925B), + 'comment': const Color(0xFFA9825B), + 'number': const Color(0xFFFFC857), + 'function': const Color(0xFFD97706), + 'type': const Color(0xFFFFC18C), + 'variable': const Color(0xFFFFF7ED), + 'operator': const Color(0xFFFFB86B), + }; + case AppTheme.codexBlue: + return { + 'keyword': const Color(0xFF6EA8FF), + 'string': const Color(0xFF16B9C7), + 'comment': const Color(0xFF7187A8), + 'number': const Color(0xFFFFC857), + 'function': const Color(0xFF2555FF), + 'type': const Color(0xFF6BE4EE), + 'variable': const Color(0xFFF4F8FF), + 'operator': const Color(0xFF6EA8FF), + }; } } } diff --git a/mobile_agent/lib/main.dart b/mobile_agent/lib/main.dart index e6cc83b..31458a9 100644 --- a/mobile_agent/lib/main.dart +++ b/mobile_agent/lib/main.dart @@ -1,47 +1,107 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'themes/app_theme.dart'; import 'screens/home_screen.dart'; +const _brandThemePrefsKey = 'mobilecode.brandTheme'; +const _brandThemeCodexBlue = 'codexBlue'; +const _brandThemeClaudeYellow = 'claudeYellow'; + void main() { WidgetsFlutterBinding.ensureInitialized(); - - // Set preferred orientations - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - - // Set system UI overlay style - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - statusBarBrightness: Brightness.light, - systemNavigationBarColor: AppTheme.auroraSurface, - systemNavigationBarIconBrightness: Brightness.dark, - ), - ); - + + // Set preferred orientations + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + + // Set system UI overlay style + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: AppTheme.auroraSurface, + systemNavigationBarIconBrightness: Brightness.dark, + ), + ); + runApp(const ProviderScope(child: MobileAgentApp())); } /// Root app widget -class MobileAgentApp extends StatelessWidget { +class MobileAgentApp extends StatefulWidget { const MobileAgentApp({super.key}); + @override + State createState() => _MobileAgentAppState(); +} + +class _MobileAgentAppState extends State { + String _brandTheme = _brandThemeCodexBlue; + + @override + void initState() { + super.initState(); + _loadBrandTheme(); + } + + Future _loadBrandTheme() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_brandThemePrefsKey); + if (!mounted || saved == null) return; + setState(() => _brandTheme = _normalizeBrandTheme(saved)); + } + + Future _setBrandTheme(String theme) async { + final normalized = _normalizeBrandTheme(theme); + if (_brandTheme != normalized) { + setState(() => _brandTheme = normalized); + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_brandThemePrefsKey, normalized); + } + + String _normalizeBrandTheme(String theme) { + return theme == _brandThemeClaudeYellow + ? _brandThemeClaudeYellow + : _brandThemeCodexBlue; + } + + ThemeData get _activeLightTheme { + return _brandTheme == _brandThemeClaudeYellow + ? AppTheme.claudeYellowLightTheme + : AppTheme.codexBlueLightTheme; + } + @override Widget build(BuildContext context) { + final theme = _activeLightTheme; + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: theme.colorScheme.surface, + systemNavigationBarIconBrightness: Brightness.dark, + ), + ); + return MaterialApp( title: 'Mobile Agent', debugShowCheckedModeBanner: false, - theme: AppTheme.auroraLightTheme, + theme: theme, darkTheme: AppTheme.darkTheme, themeMode: ThemeMode.light, - home: const HomeScreen(), + home: HomeScreen( + brandTheme: _brandTheme, + onBrandThemeChanged: _setBrandTheme, + ), ); } } diff --git a/mobile_agent/lib/models/github_repo.dart b/mobile_agent/lib/models/github_repo.dart index 865e343..d3c1137 100644 --- a/mobile_agent/lib/models/github_repo.dart +++ b/mobile_agent/lib/models/github_repo.dart @@ -217,7 +217,10 @@ class GitHubRepo { } // Extract license name if available - final licenseName = (json['license'] as Map?)?.['name'] as String?; + final licenseData = json['license']; + final licenseName = licenseData is Map + ? licenseData['name'] as String? + : null; // Get the language from API final primaryLanguage = json['language'] as String?; @@ -295,9 +298,7 @@ class GitHubRepo { language: json['language'] as String?, languageColor: json['languageColor'] as String? ?? GitHubLanguage.getColor(json['language'] as String?), - topics: (json['topics'] as List?)? - .whereType() - .toList() ?? + topics: ((json['topics'] as List?)?.whereType().toList()) ?? const [], license: json['license'] as String?, isTemplate: json['isTemplate'] as bool? ?? false, diff --git a/mobile_agent/lib/models/hook_registry_model.dart b/mobile_agent/lib/models/hook_registry_model.dart new file mode 100644 index 0000000..a66ac29 --- /dev/null +++ b/mobile_agent/lib/models/hook_registry_model.dart @@ -0,0 +1,156 @@ +// lib/models/hook_registry_model.dart +// Read-only hook registry model for MobileCode extension management. + +enum HookPhase { + chat, + runtime, + files, + preview, + release, + memory, +} + +extension HookPhaseLabel on HookPhase { + String get label { + switch (this) { + case HookPhase.chat: + return 'Chat'; + case HookPhase.runtime: + return 'Runtime'; + case HookPhase.files: + return 'Files'; + case HookPhase.preview: + return 'Preview'; + case HookPhase.release: + return 'Release'; + case HookPhase.memory: + return 'Memory'; + } + } +} + +enum HookSafetyLevel { + readOnly, + gated, + deferred, +} + +extension HookSafetyLevelLabel on HookSafetyLevel { + String get label { + switch (this) { + case HookSafetyLevel.readOnly: + return 'Read-only'; + case HookSafetyLevel.gated: + return 'Runtime-gated'; + case HookSafetyLevel.deferred: + return 'Deferred'; + } + } +} + +class HookRegistryEntry { + const HookRegistryEntry({ + required this.id, + required this.name, + required this.phase, + required this.enabled, + required this.owner, + required this.description, + required this.safetyLevel, + }); + + final String id; + final String name; + final HookPhase phase; + final bool enabled; + final String owner; + final String description; + final HookSafetyLevel safetyLevel; +} + +class HookRegistrySnapshot { + const HookRegistrySnapshot({required this.entries}); + + final List entries; + + int get enabledCount => entries.where((entry) => entry.enabled).length; + int get deferredCount => + entries.where((entry) => entry.safetyLevel == HookSafetyLevel.deferred).length; + + static const HookRegistrySnapshot v1 = HookRegistrySnapshot( + entries: [ + HookRegistryEntry( + id: 'chat.before_model_call', + name: 'Before model call', + phase: HookPhase.chat, + enabled: true, + owner: 'Model provider guard', + description: 'Validate provider, base URL, model, timeout, and cancellation state before a request starts.', + safetyLevel: HookSafetyLevel.readOnly, + ), + HookRegistryEntry( + id: 'chat.after_model_response', + name: 'After model response', + phase: HookPhase.chat, + enabled: true, + owner: 'Agent process trace', + description: 'Record response status, generated artifact path, and user-visible recovery details.', + safetyLevel: HookSafetyLevel.readOnly, + ), + HookRegistryEntry( + id: 'runtime.before_execute', + name: 'Before runtime execute', + phase: HookPhase.runtime, + enabled: true, + owner: 'RuntimeProvider policy', + description: 'Check workspace bounds, timeout, command policy, and selected runtime capabilities.', + safetyLevel: HookSafetyLevel.gated, + ), + HookRegistryEntry( + id: 'runtime.after_execute', + name: 'After runtime execute', + phase: HookPhase.runtime, + enabled: true, + owner: 'Runtime task reporter', + description: 'Capture exit code, failure kind, recent logs, duration, and retry suggestion.', + safetyLevel: HookSafetyLevel.readOnly, + ), + HookRegistryEntry( + id: 'files.before_write', + name: 'Before file write', + phase: HookPhase.files, + enabled: true, + owner: 'Artifact writer', + description: 'Normalize artifact paths and keep writes inside the MobileCode workspace.', + safetyLevel: HookSafetyLevel.gated, + ), + HookRegistryEntry( + id: 'preview.after_open', + name: 'After preview open', + phase: HookPhase.preview, + enabled: true, + owner: 'Preview surface', + description: 'Expose code file, WebView preview, external browser link, and phone file location.', + safetyLevel: HookSafetyLevel.readOnly, + ), + HookRegistryEntry( + id: 'release.before_publish', + name: 'Before release publish', + phase: HookPhase.release, + enabled: false, + owner: 'Release QA', + description: 'Reserved for CI evidence, version rule, artifact hash, and manual install gate checks.', + safetyLevel: HookSafetyLevel.deferred, + ), + HookRegistryEntry( + id: 'memory.before_write', + name: 'Before memory write', + phase: HookPhase.memory, + enabled: false, + owner: 'Memory manager', + description: 'Reserved for user approval and redaction before durable memory is stored.', + safetyLevel: HookSafetyLevel.deferred, + ), + ], + ); +} diff --git a/mobile_agent/lib/providers/api_manager_provider.dart b/mobile_agent/lib/providers/api_manager_provider.dart index 4bfa69f..fef3b83 100644 --- a/mobile_agent/lib/providers/api_manager_provider.dart +++ b/mobile_agent/lib/providers/api_manager_provider.dart @@ -1,560 +1,565 @@ -// lib/providers/api_manager_provider.dart -// Riverpod Providers for API Management and Feature Flags -// API管理和功能开关的 Riverpod 状态管理 - -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../services/api_manager_service.dart'; -import '../services/api_service.dart'; -import '../services/feature_flags_service.dart'; -import '../services/secure_storage_service.dart'; - -// ═══════════════════════════════════════════════════════════════════════════ -// Core Service Providers -// ═══════════════════════════════════════════════════════════════════════════ - -/// Provider for the [ApiService] instance. -/// -/// This is typically overridden in the ProviderScope at app startup: -/// ```dart -/// ProviderScope( -/// overrides: [ -/// apiServiceProvider.overrideWithValue(myApiService), -/// ], -/// child: MyApp(), -/// ) -/// ``` -final apiServiceProvider = Provider((ref) { - final api = ApiService.create(); - ref.onDispose(() => api.dispose()); - return api; -}); - -/// Provider for the [SecureStorageService] instance. -final secureStorageProvider = Provider((ref) { - final storage = SecureStorageService(); - ref.onDispose(() => storage.dispose()); - return storage; -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// API Manager Provider -// ═══════════════════════════════════════════════════════════════════════════ - -/// State class for the API Manager. -/// -/// Holds the current state of all API connections for reactive UI updates. -@immutable -class ApiManagerState { - final bool isInitialized; - final bool isLoading; - final bool chatGPTConnected; - final bool geminiConnected; - final List customApis; - final List availableProviders; - final List providerPriority; - final bool autoFailoverEnabled; - final Map healthStatuses; - final Map rateLimits; - final String? error; - - const ApiManagerState({ - this.isInitialized = false, - this.isLoading = false, - this.chatGPTConnected = false, - this.geminiConnected = false, - this.customApis = const [], - this.availableProviders = const [], - this.providerPriority = const [], - this.autoFailoverEnabled = true, - this.healthStatuses = const {}, - this.rateLimits = const {}, - this.error, - }); - - ApiManagerState copyWith({ - bool? isInitialized, - bool? isLoading, - bool? chatGPTConnected, - bool? geminiConnected, - List? customApis, - List? availableProviders, - List? providerPriority, - bool? autoFailoverEnabled, - Map? healthStatuses, - Map? rateLimits, - String? error, - }) { - return ApiManagerState( - isInitialized: isInitialized ?? this.isInitialized, - isLoading: isLoading ?? this.isLoading, - chatGPTConnected: chatGPTConnected ?? this.chatGPTConnected, - geminiConnected: geminiConnected ?? this.geminiConnected, - customApis: customApis ?? this.customApis, - availableProviders: availableProviders ?? this.availableProviders, - providerPriority: providerPriority ?? this.providerPriority, - autoFailoverEnabled: autoFailoverEnabled ?? this.autoFailoverEnabled, - healthStatuses: healthStatuses ?? this.healthStatuses, - rateLimits: rateLimits ?? this.rateLimits, - error: error ?? this.error, - ); - } - - /// Whether any provider is available for use. - bool get hasAnyProvider => - chatGPTConnected || geminiConnected || customApis.isNotEmpty; - - @override - String toString() => - 'ApiManagerState(chatGPT: $chatGPTConnected, gemini: $geminiConnected, ' - 'custom: ${customApis.length}, providers: ${availableProviders.length})'; -} - -/// Notifier for managing API manager state. -/// -/// Wraps [ApiManagerService] and exposes reactive state for Riverpod consumers. -class ApiManagerNotifier extends StateNotifier { - final ApiManagerService _service; - - ApiManagerNotifier(this._service) : super(const ApiManagerState()) { - // Listen to the service's internal ChangeNotifier - _service.addListener(_syncState); - } - - void _syncState() { - state = state.copyWith( - chatGPTConnected: _service.isChatGPTOfficialConnected, - geminiConnected: _service.isGeminiOfficialConnected, - customApis: _service.getCustomApis(), - availableProviders: _service.availableProviders, - providerPriority: _service.providerPriority.toList(), - autoFailoverEnabled: _service.autoFailoverEnabled, - ); - } - - /// Initialize the service. - Future initialize() async { - state = state.copyWith(isLoading: true, error: null); - try { - await _service.initialize(); - _syncState(); - state = state.copyWith(isInitialized: true, isLoading: false); - } catch (e) { - state = state.copyWith(error: e.toString(), isLoading: false); - } - } - - /// Connect ChatGPT Official. - Future connectChatGPT({String? sessionToken}) async { - state = state.copyWith(isLoading: true, error: null); - try { - final result = await _service.connectChatGPTOfficial(sessionToken: sessionToken); - state = state.copyWith(isLoading: false); - return result; - } catch (e) { - state = state.copyWith(error: e.toString(), isLoading: false); - return false; - } - } - - /// Disconnect ChatGPT Official. - Future disconnectChatGPT() async { - state = state.copyWith(isLoading: true); - await _service.disconnectChatGPTOfficial(); - state = state.copyWith(isLoading: false); - } - - /// Connect Gemini Official. - Future connectGemini({String? apiKey}) async { - state = state.copyWith(isLoading: true, error: null); - try { - final result = await _service.connectGeminiOfficial(apiKey: apiKey); - state = state.copyWith(isLoading: false); - return result; - } catch (e) { - state = state.copyWith(error: e.toString(), isLoading: false); - return false; - } - } - - /// Disconnect Gemini Official. - Future disconnectGemini() async { - state = state.copyWith(isLoading: true); - await _service.disconnectGeminiOfficial(); - state = state.copyWith(isLoading: false); - } - - /// Add a custom API. - Future addCustomApi({ - required String name, - required String baseUrl, - required String apiKey, - required String model, - String? organization, - }) async { - state = state.copyWith(isLoading: true, error: null); - try { - await _service.addCustomApi( - name: name, - baseUrl: baseUrl, - apiKey: apiKey, - model: model, - organization: organization, - ); - state = state.copyWith(isLoading: false); - } catch (e) { - state = state.copyWith(error: e.toString(), isLoading: false); - } - } - - /// Update a custom API. - Future updateCustomApi( - String id, { - String? name, - String? baseUrl, - String? apiKey, - String? model, - String? organization, - }) async { - state = state.copyWith(isLoading: true, error: null); - try { - await _service.updateCustomApi( - id, - name: name, - baseUrl: baseUrl, - apiKey: apiKey, - model: model, - organization: organization, - ); - state = state.copyWith(isLoading: false); - } catch (e) { - state = state.copyWith(error: e.toString(), isLoading: false); - } - } - - /// Delete a custom API. - Future deleteCustomApi(String id) async { - state = state.copyWith(isLoading: true); - await _service.deleteCustomApi(id); - state = state.copyWith(isLoading: false); - } - - /// Toggle custom API active state. - Future toggleCustomApi(String id) async { - await _service.toggleCustomApiActive(id); - } - - /// Test a connection. - Future testConnection(String apiId) async { - state = state.copyWith(isLoading: true); - final status = await _service.testConnection(apiId); - final newStatuses = Map.from(state.healthStatuses) - ..[apiId] = status; - state = state.copyWith(healthStatuses: newStatuses, isLoading: false); - return status; - } - - /// Set provider priority. - void setProviderPriority(List priority) { - _service.setProviderPriority(priority); - } - - /// Set auto failover. - Future setAutoFailover(bool enabled) async { - await _service.setAutoFailover(enabled); - } - - /// Execute with failover. - Future withFailover(TaskType task, Future Function(ApiProvider) operation) async { - return _service.withFailover(task, operation); - } - - /// Get provider for a task. - ApiProvider? getProviderForTask(TaskType task) { - return _service.getProviderForTask(task); - } - - /// Disconnect all providers. - Future disconnectAll() async { - state = state.copyWith(isLoading: true); - await _service.disconnectAll(); - state = state.copyWith(isLoading: false); - } - - @override - void dispose() { - _service.removeListener(_syncState); - super.dispose(); - } -} - -/// Provider for the [ApiManagerService] instance. -final apiManagerServiceProvider = Provider((ref) { - final api = ref.watch(apiServiceProvider); - final storage = ref.watch(secureStorageProvider); - final service = ApiManagerService( - apiService: api, - secureStorage: storage, - ); - ref.onDispose(() => service.dispose()); - return service; -}); - -/// StateNotifierProvider for reactive API manager state. -final apiManagerNotifierProvider = - StateNotifierProvider((ref) { - final service = ref.watch(apiManagerServiceProvider); - return ApiManagerNotifier(service); -}); - -/// Computed provider: whether any API provider is available. -final hasAnyProviderProvider = Provider((ref) { - return ref.watch(apiManagerNotifierProvider).hasAnyProvider; -}); - -/// Computed provider: list of available providers. -final availableProvidersProvider = Provider>((ref) { - return ref.watch(apiManagerNotifierProvider).availableProviders; -}); - -/// Computed provider: current provider priority list. -final providerPriorityProvider = Provider>((ref) { - return ref.watch(apiManagerNotifierProvider).providerPriority; -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Feature Flags Provider -// ═══════════════════════════════════════════════════════════════════════════ - -/// State class for Feature Flags. -@immutable -class FeatureFlagsState { - final bool isInitialized; - final bool isLoading; - final Map featureValues; - final String? error; - - const FeatureFlagsState({ - this.isInitialized = false, - this.isLoading = false, - this.featureValues = const {}, - this.error, - }); - - FeatureFlagsState copyWith({ - bool? isInitialized, - bool? isLoading, - Map? featureValues, - String? error, - }) { - return FeatureFlagsState( - isInitialized: isInitialized ?? this.isInitialized, - isLoading: isLoading ?? this.isLoading, - featureValues: featureValues ?? this.featureValues, - error: error ?? this.error, - ); - } - - /// Check if a feature is enabled. - bool isEnabled(String featureId) { - final feature = FeatureFlagsService.allFeatures[featureId]; - if (feature == null) return false; - if (feature.isCore) return true; - return featureValues[featureId] ?? feature.defaultValue; - } - - /// Get features organized by category. - Map> get featuresByCategory { - final result = >{}; - for (final entry in FeatureFlagsService.allFeatures.entries) { - final currentValue = isEnabled(entry.key); - result.putIfAbsent(entry.value.category, () => []).add( - entry.value.copyWith(currentValue: currentValue), - ); - } - // Sort by category order - final sortedEntries = result.entries.toList() - ..sort((a, b) => a.key.sortOrder.compareTo(b.key.sortOrder)); - return Map.fromEntries(sortedEntries); - } - - @override - String toString() => 'FeatureFlagsState(features: ${featureValues.length})'; -} - -/// Notifier for managing feature flags state. -class FeatureFlagsNotifier extends StateNotifier { - final FeatureFlagsService _service; - - FeatureFlagsNotifier(this._service) : super(const FeatureFlagsState()) { - _service.addListener(_syncState); - } - - void _syncState() { - final values = {}; - for (final entry in FeatureFlagsService.allFeatures.entries) { - values[entry.key] = _service.isEnabledSync(entry.key); - } - state = state.copyWith(featureValues: values); - } - - /// Initialize the service. - Future initialize() async { - state = state.copyWith(isLoading: true); - try { - await _service.initialize(); - _syncState(); - state = state.copyWith(isInitialized: true, isLoading: false); - } catch (e) { - state = state.copyWith(error: e.toString(), isLoading: false); - } - } - - /// Toggle a feature. - Future toggle(String featureId) async { - try { - await _service.toggle(featureId); - _syncState(); - } catch (e) { - state = state.copyWith(error: e.toString()); - } - } - - /// Set a feature's enabled state. - Future setEnabled(String featureId, bool enabled) async { - try { - await _service.setEnabled(featureId, enabled); - _syncState(); - } catch (e) { - state = state.copyWith(error: e.toString()); - } - } - - /// Check if a feature is enabled (async). - Future isEnabled(String featureId) async { - return _service.isEnabled(featureId); - } - - /// Check if a feature is enabled (sync). - bool isEnabledSync(String featureId) { - return state.isEnabled(featureId); - } - - /// Reset all features to defaults. - Future resetToDefaults() async { - state = state.copyWith(isLoading: true); - await _service.resetToDefaults(); - _syncState(); - state = state.copyWith(isLoading: false); - } - - @override - void dispose() { - _service.removeListener(_syncState); - super.dispose(); - } -} - -/// Provider for the [FeatureFlagsService] instance. -final featureFlagsServiceProvider = Provider((ref) { - final service = FeatureFlagsService(); - ref.onDispose(() {}); - return service; -}); - -/// StateNotifierProvider for reactive feature flags state. -final featureFlagsNotifierProvider = - StateNotifierProvider((ref) { - final service = ref.watch(featureFlagsServiceProvider); - return FeatureFlagsNotifier(service); -}); - -/// Family provider: check if a specific feature is enabled. -final featureEnabledProvider = Provider.family((ref, featureId) { - return ref.watch(featureFlagsNotifierProvider).isEnabled(featureId); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Convenience Combined Providers -// ═══════════════════════════════════════════════════════════════════════════ - -/// Provider that tracks the best available provider for code generation tasks. -final codeGenerationProviderProvider = Provider((ref) { - final apiManager = ref.watch(apiManagerNotifierProvider); - if (!apiManager.isInitialized) return null; - - final service = ref.read(apiManagerServiceProvider); - return service.getProviderForTask(TaskType.codeGeneration); -}); - -/// Provider that tracks the best available provider for chat tasks. -final chatProviderProvider = Provider((ref) { - final apiManager = ref.watch(apiManagerNotifierProvider); - if (!apiManager.isInitialized) return null; - - final service = ref.read(apiManagerServiceProvider); - return service.getProviderForTask(TaskType.chat); -}); - -/// Provider that checks if streaming chat is enabled. -final isStreamingChatEnabledProvider = Provider((ref) { - return ref.watch(featureEnabledProvider('streaming_chat')); -}); - -/// Provider that checks if voice-to-code is enabled. -final isVoiceToCodeEnabledProvider = Provider((ref) { - return ref.watch(featureEnabledProvider('voice_to_code')); -}); - -/// Provider that checks if screenshot-to-code is enabled. -final isScreenshotToCodeEnabledProvider = Provider((ref) { - return ref.watch(featureEnabledProvider('screenshot_to_code')); -}); - -/// Provider that checks if terminal is enabled. -final isTerminalEnabledProvider = Provider((ref) { - return ref.watch(featureEnabledProvider('terminal')); -}); - -/// Provider that checks if GitHub Pages deploy is enabled. -final isGitHubPagesDeployEnabledProvider = Provider((ref) { - return ref.watch(featureEnabledProvider('github_pages_deploy')); -}); - -/// Provider that checks if multi-step agent is enabled. -final isMultiStepAgentEnabledProvider = Provider((ref) { - return ref.watch(featureEnabledProvider('agent_multi_step')); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Initialization -// ═══════════════════════════════════════════════════════════════════════════ - -/// Initializes both API manager and feature flags services. -/// Call this during app startup before rendering UI. -/// -/// ```dart -/// void main() async { -/// WidgetsFlutterBinding.ensureInitialized(); -/// await initializeAppServices(container); -/// runApp(ProviderScope(parent: container, child: MyApp())); -/// } -/// ``` -Future initializeAppServices(ProviderContainer container) async { - debugPrint('[AppServices] Initializing...'); - - // Initialize secure storage first - final storage = container.read(secureStorageProvider); - await storage.initialize(); - - // Initialize API manager - final apiManagerNotifier = container.read(apiManagerNotifierProvider.notifier); - await apiManagerNotifier.initialize(); - - // Initialize feature flags - final featureFlagsNotifier = container.read(featureFlagsNotifierProvider.notifier); - await featureFlagsNotifier.initialize(); - - debugPrint('[AppServices] All services initialized'); -} +// lib/providers/api_manager_provider.dart +// Riverpod Providers for API Management and Feature Flags +// API管理和功能开关的 Riverpod 状态管理 + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../services/api_manager_service.dart'; +import '../services/api_service.dart'; +import '../services/feature_flags_service.dart'; +import '../services/secure_storage_service.dart'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Core Service Providers +// ═══════════════════════════════════════════════════════════════════════════ + +/// Provider for the [ApiService] instance. +/// +/// This is typically overridden in the ProviderScope at app startup: +/// ```dart +/// ProviderScope( +/// overrides: [ +/// apiServiceProvider.overrideWithValue(myApiService), +/// ], +/// child: MyApp(), +/// ) +/// ``` +final apiServiceProvider = Provider((ref) { + final api = ApiService.create(); + ref.onDispose(() => api.dispose()); + return api; +}); + +/// Provider for the [SecureStorageService] instance. +final secureStorageProvider = Provider((ref) { + final storage = SecureStorageService(); + ref.onDispose(() => storage.dispose()); + return storage; +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// API Manager Provider +// ═══════════════════════════════════════════════════════════════════════════ + +/// State class for the API Manager. +/// +/// Holds the current state of all API connections for reactive UI updates. +@immutable +class ApiManagerState { + final bool isInitialized; + final bool isLoading; + final bool chatGPTConnected; + final bool geminiConnected; + final List customApis; + final List availableProviders; + final List providerPriority; + final bool autoFailoverEnabled; + final Map healthStatuses; + final Map rateLimits; + final String? error; + + const ApiManagerState({ + this.isInitialized = false, + this.isLoading = false, + this.chatGPTConnected = false, + this.geminiConnected = false, + this.customApis = const [], + this.availableProviders = const [], + this.providerPriority = const [], + this.autoFailoverEnabled = true, + this.healthStatuses = const {}, + this.rateLimits = const {}, + this.error, + }); + + ApiManagerState copyWith({ + bool? isInitialized, + bool? isLoading, + bool? chatGPTConnected, + bool? geminiConnected, + List? customApis, + List? availableProviders, + List? providerPriority, + bool? autoFailoverEnabled, + Map? healthStatuses, + Map? rateLimits, + String? error, + }) { + return ApiManagerState( + isInitialized: isInitialized ?? this.isInitialized, + isLoading: isLoading ?? this.isLoading, + chatGPTConnected: chatGPTConnected ?? this.chatGPTConnected, + geminiConnected: geminiConnected ?? this.geminiConnected, + customApis: customApis ?? this.customApis, + availableProviders: availableProviders ?? this.availableProviders, + providerPriority: providerPriority ?? this.providerPriority, + autoFailoverEnabled: autoFailoverEnabled ?? this.autoFailoverEnabled, + healthStatuses: healthStatuses ?? this.healthStatuses, + rateLimits: rateLimits ?? this.rateLimits, + error: error ?? this.error, + ); + } + + /// Whether any provider is available for use. + bool get hasAnyProvider => + chatGPTConnected || geminiConnected || customApis.isNotEmpty; + + @override + String toString() => + 'ApiManagerState(chatGPT: $chatGPTConnected, gemini: $geminiConnected, ' + 'custom: ${customApis.length}, providers: ${availableProviders.length})'; +} + +/// Notifier for managing API manager state. +/// +/// Wraps [ApiManagerService] and exposes reactive state for Riverpod consumers. +class ApiManagerNotifier extends StateNotifier { + final ApiManagerService _service; + + ApiManagerNotifier(this._service) : super(const ApiManagerState()) { + // Listen to the service's internal ChangeNotifier + _service.addListener(_syncState); + } + + void _syncState() { + state = state.copyWith( + chatGPTConnected: _service.isChatGPTOfficialConnected, + geminiConnected: _service.isGeminiOfficialConnected, + customApis: _service.getCustomApis(), + availableProviders: _service.availableProviders, + providerPriority: _service.providerPriority.toList(), + autoFailoverEnabled: _service.autoFailoverEnabled, + ); + } + + /// Initialize the service. + Future initialize() async { + state = state.copyWith(isLoading: true, error: null); + try { + await _service.initialize(); + _syncState(); + state = state.copyWith(isInitialized: true, isLoading: false); + } catch (e) { + state = state.copyWith(error: e.toString(), isLoading: false); + } + } + + /// Connect ChatGPT Official. + Future connectChatGPT({String? sessionToken}) async { + state = state.copyWith(isLoading: true, error: null); + try { + final result = await _service.connectChatGPTOfficial(sessionToken: sessionToken); + state = state.copyWith(isLoading: false); + return result; + } catch (e) { + state = state.copyWith(error: e.toString(), isLoading: false); + return false; + } + } + + /// Disconnect ChatGPT Official. + Future disconnectChatGPT() async { + state = state.copyWith(isLoading: true); + await _service.disconnectChatGPTOfficial(); + state = state.copyWith(isLoading: false); + } + + /// Connect Gemini Official. + Future connectGemini({String? apiKey}) async { + state = state.copyWith(isLoading: true, error: null); + try { + final result = await _service.connectGeminiOfficial(apiKey: apiKey); + state = state.copyWith(isLoading: false); + return result; + } catch (e) { + state = state.copyWith(error: e.toString(), isLoading: false); + return false; + } + } + + /// Disconnect Gemini Official. + Future disconnectGemini() async { + state = state.copyWith(isLoading: true); + await _service.disconnectGeminiOfficial(); + state = state.copyWith(isLoading: false); + } + + /// Add a custom API. + Future addCustomApi({ + required String name, + required String baseUrl, + required String apiKey, + required String model, + String? organization, + }) async { + state = state.copyWith(isLoading: true, error: null); + try { + await _service.addCustomApi( + name: name, + baseUrl: baseUrl, + apiKey: apiKey, + model: model, + organization: organization, + ); + state = state.copyWith(isLoading: false); + } catch (e) { + state = state.copyWith(error: e.toString(), isLoading: false); + } + } + + /// Update a custom API. + Future updateCustomApi( + String id, { + String? name, + String? baseUrl, + String? apiKey, + String? model, + String? organization, + }) async { + state = state.copyWith(isLoading: true, error: null); + try { + await _service.updateCustomApi( + id, + name: name, + baseUrl: baseUrl, + apiKey: apiKey, + model: model, + organization: organization, + ); + state = state.copyWith(isLoading: false); + } catch (e) { + state = state.copyWith(error: e.toString(), isLoading: false); + } + } + + /// Delete a custom API. + Future deleteCustomApi(String id) async { + state = state.copyWith(isLoading: true); + await _service.deleteCustomApi(id); + state = state.copyWith(isLoading: false); + } + + /// Toggle custom API active state. + Future toggleCustomApi(String id) async { + await _service.toggleCustomApiActive(id); + } + + /// Test a connection. + Future testConnection(String apiId) async { + state = state.copyWith(isLoading: true); + final status = await _service.testConnection(apiId); + final newStatuses = Map.from(state.healthStatuses) + ..[apiId] = status; + state = state.copyWith(healthStatuses: newStatuses, isLoading: false); + return status; + } + + /// Set provider priority. + void setProviderPriority(List priority) { + _service.setProviderPriority(priority); + } + + /// Set auto failover. + Future setAutoFailover(bool enabled) async { + await _service.setAutoFailover(enabled); + } + + /// Execute with failover. + Future withFailover(TaskType task, Future Function(ApiProvider) operation) async { + return _service.withFailover(task, operation); + } + + /// Get provider for a task. + ApiProvider? getProviderForTask(TaskType task) { + return _service.getProviderForTask(task); + } + + /// Disconnect all providers. + Future disconnectAll() async { + state = state.copyWith(isLoading: true); + await _service.disconnectAll(); + state = state.copyWith(isLoading: false); + } + + @override + void dispose() { + _service.removeListener(_syncState); + super.dispose(); + } +} + +/// Provider for the [ApiManagerService] instance. +final apiManagerServiceProvider = Provider((ref) { + final api = ref.watch(apiServiceProvider); + final storage = ref.watch(secureStorageProvider); + final service = ApiManagerService( + apiService: api, + secureStorage: storage, + ); + ref.onDispose(() => service.dispose()); + return service; +}); + +/// StateNotifierProvider for reactive API manager state. +final apiManagerNotifierProvider = + StateNotifierProvider((ref) { + final service = ref.watch(apiManagerServiceProvider); + return ApiManagerNotifier(service); +}); + +/// Computed provider: whether any API provider is available. +final hasAnyProviderProvider = Provider((ref) { + return ref.watch(apiManagerNotifierProvider).hasAnyProvider; +}); + +/// Computed provider: list of available providers. +final availableProvidersProvider = Provider>((ref) { + return ref.watch(apiManagerNotifierProvider).availableProviders; +}); + +/// Computed provider: current provider priority list. +final providerPriorityProvider = Provider>((ref) { + return ref.watch(apiManagerNotifierProvider).providerPriority; +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Feature Flags Provider +// ═══════════════════════════════════════════════════════════════════════════ + +/// State class for Feature Flags. +@immutable +class FeatureFlagsState { + final bool isInitialized; + final bool isLoading; + final Map featureValues; + final String? error; + + const FeatureFlagsState({ + this.isInitialized = false, + this.isLoading = false, + this.featureValues = const {}, + this.error, + }); + + FeatureFlagsState copyWith({ + bool? isInitialized, + bool? isLoading, + Map? featureValues, + String? error, + }) { + return FeatureFlagsState( + isInitialized: isInitialized ?? this.isInitialized, + isLoading: isLoading ?? this.isLoading, + featureValues: featureValues ?? this.featureValues, + error: error ?? this.error, + ); + } + + /// Check if a feature is enabled. + bool isEnabled(String featureId) { + final feature = FeatureFlagsService.allFeatures[featureId]; + if (feature == null) return false; + if (feature.isCore) return true; + return featureValues[featureId] ?? feature.defaultValue; + } + + /// Get features organized by category. + Map> get featuresByCategory { + final result = >{}; + for (final entry in FeatureFlagsService.allFeatures.entries) { + final currentValue = isEnabled(entry.key); + result.putIfAbsent(entry.value.category, () => []).add( + entry.value.copyWith(currentValue: currentValue), + ); + } + // Sort by category order + final sortedEntries = result.entries.toList() + ..sort((a, b) => a.key.sortOrder.compareTo(b.key.sortOrder)); + return Map.fromEntries(sortedEntries); + } + + @override + String toString() => 'FeatureFlagsState(features: ${featureValues.length})'; +} + +/// Notifier for managing feature flags state. +class FeatureFlagsNotifier extends StateNotifier { + final FeatureFlagsService _service; + + FeatureFlagsNotifier(this._service) : super(const FeatureFlagsState()) { + _service.addListener(_syncState); + } + + void _syncState() { + final values = {}; + for (final entry in FeatureFlagsService.allFeatures.entries) { + values[entry.key] = _service.isEnabledSync(entry.key); + } + state = state.copyWith(featureValues: values); + } + + /// Initialize the service. + Future initialize() async { + state = state.copyWith(isLoading: true); + try { + await _service.initialize(); + _syncState(); + state = state.copyWith(isInitialized: true, isLoading: false); + } catch (e) { + state = state.copyWith(error: e.toString(), isLoading: false); + } + } + + /// Toggle a feature. + Future toggle(String featureId) async { + try { + await _service.toggle(featureId); + _syncState(); + } catch (e) { + state = state.copyWith(error: e.toString()); + } + } + + /// Set a feature's enabled state. + Future setEnabled(String featureId, bool enabled) async { + try { + await _service.setEnabled(featureId, enabled); + _syncState(); + } catch (e) { + state = state.copyWith(error: e.toString()); + } + } + + /// Check if a feature is enabled (async). + Future isEnabled(String featureId) async { + return _service.isEnabled(featureId); + } + + /// Check if a feature is enabled (sync). + bool isEnabledSync(String featureId) { + return state.isEnabled(featureId); + } + + /// Reset all features to defaults. + Future resetToDefaults() async { + state = state.copyWith(isLoading: true); + await _service.resetToDefaults(); + _syncState(); + state = state.copyWith(isLoading: false); + } + + @override + void dispose() { + _service.removeListener(_syncState); + super.dispose(); + } +} + +/// Provider for the [FeatureFlagsService] instance. +final featureFlagsServiceProvider = Provider((ref) { + final service = FeatureFlagsService(); + ref.onDispose(() {}); + return service; +}); + +/// StateNotifierProvider for reactive feature flags state. +final featureFlagsNotifierProvider = + StateNotifierProvider((ref) { + final service = ref.watch(featureFlagsServiceProvider); + return FeatureFlagsNotifier(service); +}); + +/// Family provider: check if a specific feature is enabled. +final featureEnabledProvider = Provider.family((ref, featureId) { + return ref.watch(featureFlagsNotifierProvider).isEnabled(featureId); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Convenience Combined Providers +// ═══════════════════════════════════════════════════════════════════════════ + +/// Provider that tracks the best available provider for code generation tasks. +final codeGenerationProviderProvider = Provider((ref) { + final apiManager = ref.watch(apiManagerNotifierProvider); + if (!apiManager.isInitialized) return null; + + final service = ref.read(apiManagerServiceProvider); + return service.getProviderForTask(TaskType.codeGeneration); +}); + +/// Provider that tracks the best available provider for chat tasks. +final chatProviderProvider = Provider((ref) { + final apiManager = ref.watch(apiManagerNotifierProvider); + if (!apiManager.isInitialized) return null; + + final service = ref.read(apiManagerServiceProvider); + return service.getProviderForTask(TaskType.chat); +}); + +/// Provider that checks if streaming chat is enabled. +final isStreamingChatEnabledProvider = Provider((ref) { + return ref.watch(featureEnabledProvider('streaming_chat')); +}); + +/// Provider that checks if voice-to-code is enabled. +final isVoiceToCodeEnabledProvider = Provider((ref) { + return ref.watch(featureEnabledProvider('voice_to_code')); +}); + +/// Provider that checks if screenshot-to-code is enabled. +final isScreenshotToCodeEnabledProvider = Provider((ref) { + return ref.watch(featureEnabledProvider('screenshot_to_code')); +}); + +/// Provider that checks if terminal is enabled. +final isTerminalEnabledProvider = Provider((ref) { + return ref.watch(featureEnabledProvider('terminal')); +}); + +/// Provider that checks if GitHub Pages deploy is enabled. +final isGitHubPagesDeployEnabledProvider = Provider((ref) { + return ref.watch(featureEnabledProvider('github_pages_deploy')); +}); + +/// Provider that checks if Lark CLI connector is enabled. +final isLarkCliEnabledProvider = Provider((ref) { + return ref.watch(featureEnabledProvider('lark_cli')); +}); + +/// Provider that checks if multi-step agent is enabled. +final isMultiStepAgentEnabledProvider = Provider((ref) { + return ref.watch(featureEnabledProvider('agent_multi_step')); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// Initialization +// ═══════════════════════════════════════════════════════════════════════════ + +/// Initializes both API manager and feature flags services. +/// Call this during app startup before rendering UI. +/// +/// ```dart +/// void main() async { +/// WidgetsFlutterBinding.ensureInitialized(); +/// await initializeAppServices(container); +/// runApp(ProviderScope(parent: container, child: MyApp())); +/// } +/// ``` +Future initializeAppServices(ProviderContainer container) async { + debugPrint('[AppServices] Initializing...'); + + // Initialize secure storage first + final storage = container.read(secureStorageProvider); + await storage.initialize(); + + // Initialize API manager + final apiManagerNotifier = container.read(apiManagerNotifierProvider.notifier); + await apiManagerNotifier.initialize(); + + // Initialize feature flags + final featureFlagsNotifier = container.read(featureFlagsNotifierProvider.notifier); + await featureFlagsNotifier.initialize(); + + debugPrint('[AppServices] All services initialized'); +} diff --git a/mobile_agent/lib/providers/skill_provider.dart b/mobile_agent/lib/providers/skill_provider.dart index 76f119b..4c50bc7 100644 --- a/mobile_agent/lib/providers/skill_provider.dart +++ b/mobile_agent/lib/providers/skill_provider.dart @@ -2,7 +2,8 @@ // Skill Provider - Riverpod providers for skill state management // 技能管理 Riverpod 状态管理 -import 'package:flutter/foundation.dart'; +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/skill_model.dart'; @@ -19,7 +20,8 @@ import '../services/skill_manager_service.dart'; /// final service = ref.read(skillManagerServiceProvider); /// final skills = ref.watch(allSkillsProvider); /// ``` -final skillManagerServiceProvider = Provider((ref) { +final Provider skillManagerServiceProvider = + Provider((ref) { final service = SkillManagerService.instance; // Listen to ChangeNotifier and invalidate downstream providers @@ -178,6 +180,20 @@ final trendingSkillsProvider = FutureProvider>((ref) async { return service.getTrendingSkills(); }); +/// Async provider for account-free curated GitHub discovery. +final curatedSkillSearchProvider = + FutureProvider.family, ({String? query, int limit})>((ref, params) async { + final service = ref.read(skillManagerServiceProvider); + return service.searchCuratedSkillSources(query: params.query, limit: params.limit); +}); + +/// Async provider for account-free MCP registry discovery. +final mcpRegistrySearchProvider = + FutureProvider.family, ({String? query, int limit})>((ref, params) async { + final service = ref.read(skillManagerServiceProvider); + return service.searchMcpRegistryServers(query: params.query, limit: params.limit); +}); + /// Async provider for checking skill updates. final skillUpdateCheckProvider = FutureProvider.family((ref, skillId) async { final service = ref.read(skillManagerServiceProvider); @@ -251,9 +267,8 @@ class SkillStats { // ═══════════════════════════════════════════════════════════════════════════ /// Currently selected skill tab index. -/// 0 = 已安装 (Installed) +/// 0 = 已装载 (Loaded) /// 1 = 发现 (Discover) -/// 2 = MCP管理 (MCP) final skillTabIndexProvider = StateProvider((ref) => 0); /// Search query for skill discovery. @@ -318,7 +333,7 @@ class SkillLifecycleNotifier extends AsyncNotifier { } /// Update a skill. - Future update(String skillId) async { + Future updateSkill(String skillId) async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() => _service.update(skillId)); } diff --git a/mobile_agent/lib/screens/api_config_screen.dart b/mobile_agent/lib/screens/api_config_screen.dart index fa80419..20f2f6d 100644 --- a/mobile_agent/lib/screens/api_config_screen.dart +++ b/mobile_agent/lib/screens/api_config_screen.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + import '../themes/app_theme.dart'; -import '../widgets/glass_card_widget.dart'; -import '../widgets/gradient_button_widget.dart'; -import '../models/api_config_model.dart'; -/// LLM API Configuration screen -/// Manage API keys for OpenAI, Claude, Gemini, Custom providers +/// LLM provider configuration shared with HomeScreen. +/// +/// This screen intentionally writes the same SharedPreferences keys that the +/// chat surface reads, so Settings, drawer shortcuts, and the chat composer do +/// not drift into separate provider profiles. class ApiConfigScreen extends StatefulWidget { const ApiConfigScreen({super.key}); @@ -13,574 +15,258 @@ class ApiConfigScreen extends StatefulWidget { State createState() => _ApiConfigScreenState(); } -class _ApiConfigScreenState extends State { - final List> _configs = [ - { - 'id': '1', - 'name': 'OpenAI GPT-4', - 'provider': LLMProvider.openAI, - 'apiKey': '', // SECURE: Load from SecureStorage or env var, never hardcode - 'baseUrl': 'https://api.openai.com/v1', - 'model': 'gpt-4o', - 'isActive': true, - 'temperature': 0.7, - }, - { - 'id': '2', - 'name': 'Claude Sonnet', - 'provider': LLMProvider.claude, - 'apiKey': '', // SECURE: Load from SecureStorage or env var, never hardcode - 'baseUrl': 'https://api.anthropic.com/v1', - 'model': 'claude-3-5-sonnet', - 'isActive': false, - 'temperature': 0.8, - }, - { - 'id': '3', - 'name': 'Gemini Pro', - 'provider': LLMProvider.gemini, - 'apiKey': '', // SECURE: Load from SecureStorage or env var, never hardcode - 'baseUrl': 'https://generativelanguage.googleapis.com/v1', - 'model': 'gemini-1.5-pro', - 'isActive': false, - 'temperature': 0.7, - }, - ]; +enum _ProviderPreset { mimo, anthropic, openAi, custom } - void _toggleActive(String id) { - setState(() { - for (final config in _configs) { - if (config['id'] == id) { - config['isActive'] = !config['isActive']; - } else { - config['isActive'] = false; // Only one active at a time - } - } - }); - } +class _ProviderDefinition { + const _ProviderDefinition({ + required this.preset, + required this.label, + required this.baseUrl, + required this.model, + required this.icon, + }); - void _deleteConfig(String id) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.surfaceElevated, - title: const Text('删除配置', style: TextStyle(color: AppTheme.textPrimary)), - content: const Text( - '确定要删除这个 API 配置吗?', - style: TextStyle(color: AppTheme.textSecondary), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消', style: TextStyle(color: AppTheme.textSecondary)), - ), - TextButton( - onPressed: () { - setState(() => _configs.removeWhere((c) => c['id'] == id)); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('配置已删除')), - ); - }, - child: const Text('删除', style: TextStyle(color: AppTheme.error)), - ), - ], - ), - ); - } + final _ProviderPreset preset; + final String label; + final String baseUrl; + final String model; + final IconData icon; +} - void _showAddConfigSheet({Map? existingConfig}) { - final isEditing = existingConfig != null; - final nameController = TextEditingController(text: existingConfig?['name'] ?? ''); - final keyController = TextEditingController(text: existingConfig?['apiKey'] ?? ''); - final urlController = TextEditingController(text: existingConfig?['baseUrl'] ?? ''); - LLMProvider selectedProvider = existingConfig?['provider'] ?? LLMProvider.openAI; - String selectedModel = existingConfig?['model'] ?? 'gpt-4o'; - double temperature = (existingConfig?['temperature'] ?? 0.7).toDouble(); - bool isTesting = false; - bool? testResult; +class _ApiConfigScreenState extends State { + static const _baseUrlKey = 'mobilecode.baseUrl'; + static const _apiKeyKey = 'mobilecode.apiKey'; + static const _modelKey = 'mobilecode.model'; + static const _providerModeKey = 'mobilecode.providerMode'; + static const _defaultBaseUrl = 'https://token-plan-cn.xiaomimimo.com/anthropic'; + static const _defaultModel = 'mimo-v2.5-pro'; - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.surfaceElevated, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (context) => StatefulBuilder( - builder: (context, setModalState) => Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Container( - padding: const EdgeInsets.all(24), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.border, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 20), - Text( - isEditing ? '编辑 API 配置' : '添加 API 配置', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 20), + static const _providers = [ + _ProviderDefinition( + preset: _ProviderPreset.mimo, + label: 'Mimo Anthropic', + baseUrl: _defaultBaseUrl, + model: _defaultModel, + icon: Icons.auto_awesome_outlined, + ), + _ProviderDefinition( + preset: _ProviderPreset.anthropic, + label: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + model: 'claude-3-5-sonnet-latest', + icon: Icons.hub_outlined, + ), + _ProviderDefinition( + preset: _ProviderPreset.openAi, + label: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o-mini', + icon: Icons.api_outlined, + ), + _ProviderDefinition( + preset: _ProviderPreset.custom, + label: 'Custom Provider', + baseUrl: '', + model: '', + icon: Icons.tune_outlined, + ), + ]; - // Name - TextField( - controller: nameController, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: const InputDecoration( - labelText: '配置名称', - labelStyle: TextStyle(color: AppTheme.textSecondary), - hintText: '例如: OpenAI GPT-4', - hintStyle: TextStyle(color: AppTheme.textTertiary), - ), - ), - const SizedBox(height: 16), + final _baseUrlController = TextEditingController(text: _defaultBaseUrl); + final _apiKeyController = TextEditingController(); + final _modelController = TextEditingController(text: _defaultModel); + _ProviderPreset _selectedPreset = _ProviderPreset.mimo; + bool _loading = true; + bool _saving = false; - // Provider selector - const Text( - '提供商', - style: TextStyle( - fontSize: 14, - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: LLMProvider.values.map((provider) { - final isSelected = provider == selectedProvider; - return ChoiceChip( - label: Text(provider.displayName), - selected: isSelected, - onSelected: (_) { - setModalState(() { - selectedProvider = provider; - selectedModel = - ApiConfig.defaultModels(provider).first; - if (urlController.text.isEmpty) { - urlController.text = - ApiConfig.defaultBaseUrl(provider); - } - }); - }, - selectedColor: AppTheme.violet.withOpacity(0.3), - labelStyle: TextStyle( - color: isSelected - ? AppTheme.violetLight - : AppTheme.textSecondary, - fontSize: 13, - ), - ); - }).toList(), - ), - const SizedBox(height: 16), + @override + void initState() { + super.initState(); + _loadConfig(); + } - // API Key - TextField( - controller: keyController, - obscureText: true, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: const InputDecoration( - labelText: 'API Key', - labelStyle: TextStyle(color: AppTheme.textSecondary), - hintText: 'sk-...', - hintStyle: TextStyle(color: AppTheme.textTertiary), - prefixIcon: Icon(Icons.vpn_key, color: AppTheme.textTertiary), - ), - ), - const SizedBox(height: 16), + @override + void dispose() { + _baseUrlController.dispose(); + _apiKeyController.dispose(); + _modelController.dispose(); + super.dispose(); + } - // Base URL - TextField( - controller: urlController, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: const InputDecoration( - labelText: 'Base URL', - labelStyle: TextStyle(color: AppTheme.textSecondary), - hintText: 'https://api.example.com/v1', - hintStyle: TextStyle(color: AppTheme.textTertiary), - prefixIcon: Icon(Icons.link, color: AppTheme.textTertiary), - ), - ), - const SizedBox(height: 16), + Future _loadConfig() async { + final prefs = await SharedPreferences.getInstance(); + final baseUrl = prefs.getString(_baseUrlKey)?.trim(); + final model = prefs.getString(_modelKey)?.trim(); + if (!mounted) return; + setState(() { + _baseUrlController.text = baseUrl == null || baseUrl.isEmpty ? _defaultBaseUrl : baseUrl; + _apiKeyController.text = prefs.getString(_apiKeyKey) ?? ''; + _modelController.text = model == null || model.isEmpty ? _defaultModel : model; + _selectedPreset = _detectPreset(_baseUrlController.text, _modelController.text); + _loading = false; + }); + } - // Model selector - Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: AppTheme.surfaceDark, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: selectedModel, - isExpanded: true, - dropdownColor: AppTheme.surfaceDark, - icon: const Icon(Icons.arrow_drop_down, - color: AppTheme.textSecondary), - style: const TextStyle( - color: AppTheme.textPrimary, - fontSize: 14, - ), - onChanged: (v) => setModalState(() => selectedModel = v!), - items: ApiConfig.defaultModels(selectedProvider) - .map((model) => DropdownMenuItem( - value: model, - child: Text(model), - )) - .toList(), - ), - ), - ), - const SizedBox(height: 16), + _ProviderPreset _detectPreset(String baseUrl, String model) { + final probe = '$baseUrl $model'.toLowerCase(); + if (probe.contains('xiaomimimo') || probe.contains('mimo-')) { + return _ProviderPreset.mimo; + } + if (probe.contains('anthropic') || probe.contains('claude')) { + return _ProviderPreset.anthropic; + } + if (probe.contains('openai') || probe.contains('gpt-')) { + return _ProviderPreset.openAi; + } + return _ProviderPreset.custom; + } - // Temperature slider - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Temperature', - style: TextStyle( - fontSize: 14, - color: AppTheme.textSecondary, - ), - ), - Text( - temperature.toStringAsFixed(1), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.violetLight, - ), - ), - ], - ), - Slider( - value: temperature, - min: 0.0, - max: 2.0, - divisions: 20, - activeColor: AppTheme.violet, - inactiveColor: AppTheme.border, - onChanged: (v) => setModalState(() => temperature = v), - ), - const SizedBox(height: 16), + void _selectPreset(_ProviderDefinition provider) { + setState(() { + _selectedPreset = provider.preset; + if (provider.baseUrl.isNotEmpty) { + _baseUrlController.text = provider.baseUrl; + } + if (provider.model.isNotEmpty) { + _modelController.text = provider.model; + } + }); + } - // Test connection button - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: isTesting - ? null - : () async { - setModalState(() { - isTesting = true; - testResult = null; - }); - await Future.delayed( - const Duration(seconds: 1)); - setModalState(() { - isTesting = false; - testResult = true; - }); - }, - icon: isTesting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - AppTheme.violetLight), - ), - ) - : Icon( - testResult == true - ? Icons.check_circle - : Icons.network_check, - size: 18, - color: testResult == true - ? AppTheme.success - : AppTheme.textSecondary, - ), - label: Text( - isTesting - ? '测试中...' - : testResult == true - ? '连接成功' - : '测试连接', - style: TextStyle( - color: testResult == true - ? AppTheme.success - : AppTheme.textSecondary, - ), - ), - style: OutlinedButton.styleFrom( - side: BorderSide( - color: testResult == true - ? AppTheme.success - : AppTheme.border, - ), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - ), - ), - const SizedBox(height: 16), + Future _save() async { + final baseUrl = _baseUrlController.text.trim(); + final model = _modelController.text.trim(); + if (baseUrl.isEmpty || model.isEmpty) { + _showMessage('请填写 Base URL 和 Model'); + return; + } + final uri = Uri.tryParse(baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + _showMessage('Base URL 格式不正确'); + return; + } - // Save button - SizedBox( - width: double.infinity, - child: GradientButtonWidget( - label: isEditing ? '保存更改' : '添加配置', - icon: isEditing ? Icons.save : Icons.add, - onPressed: () { - if (nameController.text.isEmpty || - keyController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请填写必填项')), - ); - return; - } + setState(() => _saving = true); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_providerModeKey, 'custom'); + await prefs.setString(_baseUrlKey, baseUrl); + await prefs.setString(_apiKeyKey, _apiKeyController.text.trim()); + await prefs.setString(_modelKey, model); + if (!mounted) return; + _showMessage('模型配置已保存'); + Navigator.of(context).maybePop(); + } finally { + if (mounted) setState(() => _saving = false); + } + } - setState(() { - if (isEditing) { - final idx = _configs.indexWhere( - (c) => c['id'] == existingConfig['id']); - if (idx != -1) { - _configs[idx] = { - ...existingConfig, - 'name': nameController.text, - 'provider': selectedProvider, - 'apiKey': keyController.text, - 'baseUrl': urlController.text, - 'model': selectedModel, - 'temperature': temperature, - }; - } - } else { - _configs.add({ - 'id': DateTime.now() - .millisecondsSinceEpoch - .toString(), - 'name': nameController.text, - 'provider': selectedProvider, - 'apiKey': keyController.text, - 'baseUrl': urlController.text, - 'model': selectedModel, - 'isActive': false, - 'temperature': temperature, - }); - } - }); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - isEditing ? '配置已更新' : '配置已添加'), - ), - ); - }, - ), - ), - ], - ), - ), - ), - ), - ), - ); + void _showMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: AppTheme.deepSpace, + backgroundColor: AppTheme.auroraBackground, appBar: AppBar( - backgroundColor: AppTheme.surfaceDark, - elevation: 0, - title: const Text( - 'API 配置', - style: TextStyle(color: AppTheme.textPrimary), - ), + title: const Text('模型与 Provider'), leading: IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back, color: AppTheme.textSecondary), - ), - ), - body: SafeArea( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 12), - itemCount: _configs.length, - itemBuilder: (context, index) { - final config = _configs[index]; - return _buildConfigCard(config); - }, - ), - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _showAddConfigSheet(), - icon: const Icon(Icons.add), - label: const Text('添加 API'), - ), - ); - } - - Widget _buildConfigCard(Map config) { - final providerColors = { - LLMProvider.openAI: const Color(0xFF10A37F), - LLMProvider.claude: const Color(0xFFD4A574), - LLMProvider.gemini: const Color(0xFF4285F4), - LLMProvider.custom: AppTheme.textSecondary, - }; - - return Dismissible( - key: ValueKey(config['id']), - direction: DismissDirection.endToStart, - background: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.error.withOpacity(0.2), - borderRadius: BorderRadius.circular(16), + tooltip: 'Back', + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back), ), - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - child: const Icon(Icons.delete, color: AppTheme.error), ), - onDismissed: (_) => _deleteConfig(config['id']), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: GlassCardWidget( - onTap: () => _showAddConfigSheet(existingConfig: config), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ - Row( - children: [ - // Provider indicator - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: providerColors[config['provider']] ?? - AppTheme.textSecondary, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - config['name'], - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - ), - // Active toggle - Switch( - value: config['isActive'] ?? false, - onChanged: (_) => _toggleActive(config['id']), - activeColor: AppTheme.violet, - activeTrackColor: AppTheme.violet.withOpacity(0.3), - inactiveTrackColor: AppTheme.border, - ), - ], - ), - const SizedBox(height: 8), - Text( - (config['provider'] as LLMProvider).displayName, - style: const TextStyle( - fontSize: 13, - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 4), - Text( - config['model'], - style: const TextStyle( - fontSize: 13, - color: AppTheme.violetLight, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Icon( - Icons.link, - size: 12, - color: AppTheme.textTertiary.withOpacity(0.7), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - config['baseUrl'], - style: TextStyle( - fontSize: 11, - color: AppTheme.textTertiary.withOpacity(0.7), - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (config['isActive'] == true) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppTheme.success.withOpacity(0.15), - borderRadius: BorderRadius.circular(6), + Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Provider', + style: TextStyle( + color: AppTheme.auroraText, + fontSize: 16, + fontWeight: FontWeight.w800, + ), ), - child: const Row( - mainAxisSize: MainAxisSize.min, + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, children: [ - Icon( - Icons.check_circle, - size: 12, - color: AppTheme.success, - ), - SizedBox(width: 4), - Text( - '默认', - style: TextStyle( - fontSize: 11, - color: AppTheme.success, - fontWeight: FontWeight.w500, + for (final provider in _providers) + ChoiceChip( + avatar: Icon(provider.icon, size: 16), + label: Text(provider.label), + selected: _selectedPreset == provider.preset, + onSelected: (_) => _selectPreset(provider), ), - ), ], ), - ), - ], + const SizedBox(height: 14), + TextField( + controller: _baseUrlController, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Base URL', + hintText: 'https://api.example.com/v1', + prefixIcon: Icon(Icons.link_outlined), + ), + onChanged: (_) => setState(() { + _selectedPreset = _detectPreset(_baseUrlController.text, _modelController.text); + }), + ), + const SizedBox(height: 12), + TextField( + controller: _modelController, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Model', + hintText: 'mimo-v2.5-pro / gpt-4o-mini / claude-3-5-sonnet-latest', + prefixIcon: Icon(Icons.memory_outlined), + ), + onChanged: (_) => setState(() { + _selectedPreset = _detectPreset(_baseUrlController.text, _modelController.text); + }), + ), + const SizedBox(height: 12), + TextField( + controller: _apiKeyController, + obscureText: true, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'API Key', + hintText: 'sk-... or provider token', + prefixIcon: Icon(Icons.key_outlined), + ), + ), + const SizedBox(height: 10), + const Text( + 'Custom Provider 会按 Base URL 自动识别 Anthropic 或 OpenAI-compatible 调用路径。保存后 Home/Chat 会立即读取同一份配置。', + style: TextStyle(color: AppTheme.auroraTextMuted, fontSize: 12, height: 1.35), + ), + ], + ), + ), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.save_outlined), + label: Text(_saving ? 'Saving' : 'Save provider'), ), ], ), - ), - ), - ), ); } } diff --git a/mobile_agent/lib/screens/api_usage_screen.dart b/mobile_agent/lib/screens/api_usage_screen.dart index dfdde32..b1e592d 100644 --- a/mobile_agent/lib/screens/api_usage_screen.dart +++ b/mobile_agent/lib/screens/api_usage_screen.dart @@ -1,10 +1,24 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -import '../themes/app_theme.dart'; -import '../widgets/glass_card_widget.dart'; -/// API Usage Screen - Tracks LLM API usage with monthly quota of 5000 calls -/// Features: quota tracking, daily bar chart, provider breakdown, -/// endpoint usage, optimization tips, and configurable alerts +import '../services/token_pricing_service.dart'; +import '../services/token_usage_service.dart'; + +const _usageBg = Color(0xFFF7FAFF); +const _usagePanel = Color(0xFFFFFFFF); +const _usageLine = Color(0xFFDDE7F7); +const _usageText = Color(0xFF0B1020); +const _usageMuted = Color(0xFF536079); +const _usageFaint = Color(0xFF8B97AD); +const _usageMint = Color(0xFF0B9B7E); +const _usageCyan = Color(0xFF16B9C7); +const _usageAmber = Color(0xFFB7791F); +const _usageRose = Color(0xFFE0526E); +const _usageViolet = Color(0xFF7557E8); + +enum _PricingSortMode { modelName, priceLowHigh, priceHighLow } + class ApiUsageScreen extends StatefulWidget { const ApiUsageScreen({super.key}); @@ -13,1083 +27,1225 @@ class ApiUsageScreen extends StatefulWidget { } class _ApiUsageScreenState extends State { - DateTime _currentMonth = DateTime.now(); - int _usedCalls = 3247; - final int _quota = 5000; - int? _selectedDay; - - // Mock daily usage data (tokens per day) - final List> _dailyUsage = List.generate(30, (index) { - final providers = ['OpenAI', 'Claude', 'Gemini']; - final provider = providers[index % 3]; - return { - 'day': index + 1, - 'tokens': (80 + (index * 7) % 150) * 1000, - 'calls': 50 + (index * 13) % 120, - 'provider': provider, - }; - }); + final _usageService = TokenUsageService.instance; + final _pricingService = TokenPricingService.instance; + StreamSubscription? _summarySub; + TokenUsageSummary _summary = TokenUsageSummary.empty; + var _checkingPricingUpdate = false; + + @override + void initState() { + super.initState(); + _pricingService.addListener(_handlePricingChanged); + unawaited(_pricingService.initialize()); + _summarySub = _usageService.watchSummary().listen((summary) { + if (mounted) setState(() => _summary = summary); + }); + } - final List> _providers = [ - {'name': 'OpenAI', 'icon': Icons.auto_awesome, 'tokens': 1850000, 'calls': 1450, 'color': Color(0xFF10B981)}, - {'name': 'Claude', 'icon': Icons.psychology, 'tokens': 1200000, 'calls': 980, 'color': Color(0xFF8B5CF6)}, - {'name': 'Gemini', 'icon': Icons.smart_toy, 'tokens': 890000, 'calls': 817, 'color': Color(0xFF3B82F6)}, - ]; - - final List> _endpoints = [ - {'name': 'Chat', 'icon': Icons.chat_bubble, 'requests': 1850, 'avgTokens': 1450}, - {'name': 'Completion', 'icon': Icons.text_fields, 'requests': 980, 'avgTokens': 890}, - {'name': 'Embedding', 'icon': Icons.data_array, 'requests': 417, 'avgTokens': 512}, - ]; - - final List> _tips = [ - { - 'icon': Icons.compress, - 'title': '启用响应压缩', - 'desc': '使用 gzip 压缩可减少 40% 的传输数据量', - 'savings': '约 500 次/月', - }, - { - 'icon': Icons.cached, - 'title': '启用缓存机制', - 'desc': '缓存相似查询结果,避免重复调用', - 'savings': '约 800 次/月', - }, - { - 'icon': Icons.tune, - 'title': '优化 Token 长度', - 'desc': '限制上下文长度,精简输入内容', - 'savings': '约 300 次/月', - }, - ]; - - final List> _alerts = [ - {'threshold': 80, 'enabled': true, 'triggered': true}, - {'threshold': 95, 'enabled': true, 'triggered': false}, - {'threshold': 100, 'enabled': false, 'triggered': false}, - ]; - - double get _usagePercent => _usedCalls / _quota; - int get _remaining => _quota - _usedCalls; - Color get _statusColor { - final pct = _usagePercent; - if (pct < 0.5) return AppTheme.success; - if (pct < 0.8) return AppTheme.warning; - return AppTheme.error; + @override + void dispose() { + _pricingService.removeListener(_handlePricingChanged); + _summarySub?.cancel(); + super.dispose(); } - String get _resetDate { - final nextMonth = DateTime(_currentMonth.year, _currentMonth.month + 1, 1); - return '${nextMonth.year}-${nextMonth.month.toString().padLeft(2, '0')}-01'; + void _handlePricingChanged() { + if (mounted) setState(() {}); } - void _prevMonth() { - setState(() { - _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1); - _usedCalls = 1500 + (_currentMonth.month * 300) % 4800; - _selectedDay = null; - }); + Future _openPricingOverrideSheet([TokenPrice? price]) async { + final override = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _usagePanel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _PricingOverrideSheet(initialPrice: price), + ); + if (override != null) { + await _pricingService.upsertOverride(override); + } } - void _nextMonth() { - if (_currentMonth.year == DateTime.now().year && - _currentMonth.month == DateTime.now().month) return; - setState(() { - _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1); - _usedCalls = 1500 + (_currentMonth.month * 300) % 4800; - _selectedDay = null; - }); + Future _checkLiteLlmUpdate() async { + if (_checkingPricingUpdate) return; + setState(() => _checkingPricingUpdate = true); + try { + final update = await _pricingService.checkLiteLlmUpdate(); + if (!mounted) return; + final apply = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _usagePanel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _PricingUpdateSheet(update: update), + ); + if (apply == true) { + await _pricingService.applySnapshotUpdate(update); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Pricing snapshot updated: ${update.modelCount} models from LiteLLM.')), + ); + } + } on Object catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('LiteLLM update check failed: $error')), + ); + } finally { + if (mounted) setState(() => _checkingPricingUpdate = false); + } } @override Widget build(BuildContext context) { + final events = _usageService.recentEvents.take(40).toList(growable: false); + final breakdown = _breakdownByProviderModel(_usageService.recentEvents); + final catalog = _pricingService.catalog; return Scaffold( - backgroundColor: AppTheme.deepSpace, - body: CustomScrollView( - slivers: [ - // Header with month selector - SliverToBoxAdapter( - child: _buildHeader(), + backgroundColor: _usageBg, + appBar: AppBar( + title: const Text('Token Usage'), + backgroundColor: _usageBg, + foregroundColor: _usageText, + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _UsageHero(summary: _summary), + const SizedBox(height: 12), + _UsageStatGrid(summary: _summary), + const SizedBox(height: 12), + _UsagePanel( + title: 'Provider / model breakdown', + icon: Icons.account_tree_outlined, + color: _usageViolet, + child: breakdown.isEmpty + ? const _EmptyUsageText(text: 'No provider usage has been recorded yet.') + : Column( + children: [ + for (final entry in breakdown.entries) + _BreakdownRow( + label: entry.key, + value: entry.value, + total: _summary.totalTokens, + ), + ], + ), ), - - // Quota card with circular progress - SliverToBoxAdapter( - child: _buildQuotaCard(), + const SizedBox(height: 12), + _PricingPanel( + catalog: catalog, + prices: _pricingService.snapshotPrices, + overrides: _pricingService.overrides, + checkingUpdate: _checkingPricingUpdate, + onCheckUpdate: () => unawaited(_checkLiteLlmUpdate()), + onAdd: () => unawaited(_openPricingOverrideSheet()), + onEdit: (price) => unawaited(_openPricingOverrideSheet(price)), + onRemove: (price) => unawaited(_pricingService.removeOverride(price.key)), + ), + const SizedBox(height: 12), + _UsagePanel( + title: 'Recent runs', + icon: Icons.timeline_outlined, + color: _usageCyan, + child: events.isEmpty + ? const _EmptyUsageText(text: 'Run chat or RR mode once; usage metadata will appear here.') + : Column( + children: [ + for (final event in events) _UsageEventTile(event: event), + ], + ), ), + ], + ), + ); + } +} - // Warning banner if approaching limit - if (_usagePercent >= 0.8) - SliverToBoxAdapter( - child: _buildWarningBanner(), - ), +Map _breakdownByProviderModel(List events) { + final breakdown = {}; + for (final event in events) { + final key = '${event.provider} / ${event.model}'; + breakdown[key] = (breakdown[key] ?? 0) + event.usage.totalTokens; + } + final entries = breakdown.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + return Map.fromEntries(entries.take(12)); +} - // Statistics row (4 glass cards) - SliverToBoxAdapter( - child: _buildStatisticsRow(), - ), +List _combinedPrices({ + required List prices, + required List overrides, +}) { + final combined = {}; + for (final price in prices) { + combined[price.key] = price; + } + for (final price in overrides) { + combined[price.key] = price; + } + final values = combined.values.toList(growable: false) + ..sort((a, b) { + final provider = a.provider.compareTo(b.provider); + if (provider != 0) return provider; + return a.model.compareTo(b.model); + }); + return values; +} - // Daily usage bar chart - SliverToBoxAdapter( - child: _buildUsageChart(), - ), +String _providerBucket(TokenPrice price) { + final text = '${price.provider} ${price.model} ${price.sourceName}'.toLowerCase(); + if (price.custom || text.contains('custom') || text.contains('override')) return 'custom'; + if (text.contains('openai') || text.contains('gpt')) return 'openai'; + if (text.contains('anthropic') || text.contains('claude') || text.contains('mimo')) return 'anthropic'; + if (text.contains('google') || text.contains('gemini')) return 'google'; + return 'other'; +} - // Provider breakdown - SliverToBoxAdapter( - child: _buildProviderBreakdown(), - ), +class _UsageHero extends StatelessWidget { + const _UsageHero({required this.summary}); - // Endpoint usage grid - SliverToBoxAdapter( - child: _buildEndpointUsage(), - ), + final TokenUsageSummary summary; - // Optimization tips - SliverToBoxAdapter( - child: _buildOptimizationTips(), + @override + Widget build(BuildContext context) { + return _UsagePanel( + title: 'AI cost observability', + icon: Icons.token_outlined, + color: _usageMint, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _formatInt(summary.totalTokens), + style: const TextStyle(color: _usageText, fontSize: 36, fontWeight: FontWeight.w900, height: 1), ), - - // Alerts section - SliverToBoxAdapter( - child: _buildAlertsSection(), + const SizedBox(height: 6), + Text( + 'total tokens across ${summary.requestCount} recorded runs', + style: const TextStyle(color: _usageMuted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _UsageBadge( + label: summary.cacheReadTokens + summary.cacheWriteTokens + summary.cacheMissTokens == 0 + ? 'cache unknown' + : 'cache hit ${(summary.cacheHitRate * 100).toStringAsFixed(1)}%', + color: _usageMint, + ), + _UsageBadge(label: '${summary.estimatedCount} estimated', color: _usageAmber), + _UsageBadge(label: '\$${summary.costEstimate.toStringAsFixed(4)} est.', color: _usageViolet), + ], ), + const SizedBox(height: 10), + const Text( + 'MobileCode stores usage metadata only. Prompt and response text are not saved in this statistics store.', + style: TextStyle(color: _usageFaint, fontSize: 11, height: 1.35), + ), + ], + ), + ); + } +} + +class _UsageStatGrid extends StatelessWidget { + const _UsageStatGrid({required this.summary}); + + final TokenUsageSummary summary; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth > 560 ? 4 : 2; + return GridView.count( + crossAxisCount: columns, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: columns == 4 ? 1.95 : 1.45, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + _UsageStatTile(label: 'Input', value: _formatInt(summary.inputTokens), icon: Icons.input_outlined, color: _usageCyan), + _UsageStatTile(label: 'Output', value: _formatInt(summary.outputTokens), icon: Icons.output_outlined, color: _usageViolet), + _UsageStatTile(label: 'Cache read', value: _formatInt(summary.cacheReadTokens), icon: Icons.cached_outlined, color: _usageMint), + _UsageStatTile(label: 'Success', value: '${summary.successCount}/${summary.requestCount}', icon: Icons.verified_outlined, color: _usageAmber), + ], + ); + }, + ); + } +} + +class _UsageStatTile extends StatelessWidget { + const _UsageStatTile({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + final String label; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _usagePanel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _usageLine), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon(icon, color: color, size: 20), + Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _usageText, fontSize: 20, fontWeight: FontWeight.w900)), + Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _usageMuted, fontSize: 11, fontWeight: FontWeight.w700)), + ], + ), + ); + } +} - const SliverToBoxAdapter(child: SizedBox(height: 40)), +class _UsagePanel extends StatelessWidget { + const _UsagePanel({ + required this.title, + required this.icon, + required this.color, + required this.child, + }); + + final String title; + final IconData icon; + final Color color; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _usagePanel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _usageLine), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Expanded(child: Text(title, style: const TextStyle(color: _usageText, fontSize: 14, fontWeight: FontWeight.w900))), + ], + ), + const SizedBox(height: 12), + child, ], ), ); } +} - Widget _buildHeader() { - final monthStr = '${_currentMonth.year}年${_currentMonth.month}月'; - final isCurrentMonth = _currentMonth.year == DateTime.now().year && - _currentMonth.month == DateTime.now().month; +class _BreakdownRow extends StatelessWidget { + const _BreakdownRow({ + required this.label, + required this.value, + required this.total, + }); + + final String label; + final int value; + final int total; + @override + Widget build(BuildContext context) { + final progress = total <= 0 ? 0.0 : value / total; return Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), - child: Row( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'API 用量', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - IconButton( - onPressed: _prevMonth, - icon: const Icon(Icons.chevron_left, color: AppTheme.textSecondary), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - ), - Text( - monthStr, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppTheme.textSecondary, - ), - ), - IconButton( - onPressed: isCurrentMonth ? null : _nextMonth, - icon: Icon( - Icons.chevron_right, - color: isCurrentMonth ? AppTheme.textTertiary : AppTheme.textSecondary, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - ), - ], - ), - ], - ), + Row( + children: [ + Expanded(child: Text(label, style: const TextStyle(color: _usageText, fontSize: 12, fontWeight: FontWeight.w900))), + Text(_formatInt(value), style: const TextStyle(color: _usageMuted, fontSize: 11, fontWeight: FontWeight.w700)), + ], ), - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - gradient: AppTheme.violetGradient, - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: AppTheme.violet.withOpacity(0.3), - blurRadius: 12, - spreadRadius: 1, - ), - ], + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: progress.clamp(0, 1), + minHeight: 8, + backgroundColor: _usageViolet.withOpacity(0.12), + valueColor: const AlwaysStoppedAnimation(_usageViolet), ), - child: const Icon(Icons.show_chart, color: Colors.white, size: 24), ), ], ), ); } +} - Widget _buildQuotaCard() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: GlassCardWidget( - glowEffect: true, - glowColors: [_statusColor.withOpacity(0.4), AppTheme.violet.withOpacity(0.2)], - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( +class _PricingPanel extends StatelessWidget { + const _PricingPanel({ + required this.catalog, + required this.prices, + required this.overrides, + required this.checkingUpdate, + required this.onCheckUpdate, + required this.onAdd, + required this.onEdit, + required this.onRemove, + }); + + final TokenPricingCatalog catalog; + final List prices; + final List overrides; + final bool checkingUpdate; + final VoidCallback onCheckUpdate; + final VoidCallback onAdd; + final ValueChanged onEdit; + final ValueChanged onRemove; + + @override + Widget build(BuildContext context) { + final previewPrices = prices.take(8).toList(growable: false); + final totalVisible = _combinedPrices(prices: prices, overrides: overrides).length; + return _UsagePanel( + title: 'Pricing table', + icon: Icons.price_change_outlined, + color: _usageAmber, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${catalog.sourceName} · ${catalog.snapshotCount} models · updated ${_dateLabel(catalog.updatedAt)}', + style: const TextStyle(color: _usageMuted, fontSize: 11, height: 1.35), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, children: [ - // Circular progress - SizedBox( - width: 160, - height: 160, - child: Stack( - alignment: Alignment.center, - children: [ - // Background circle - SizedBox( - width: 160, - height: 160, - child: CircularProgressIndicator( - value: 1, - strokeWidth: 12, - backgroundColor: AppTheme.surfaceElevated, - valueColor: const AlwaysStoppedAnimation(Colors.transparent), - ), - ), - // Progress arc - SizedBox( - width: 160, - height: 160, - child: TweenAnimationBuilder( - tween: Tween(begin: 0, end: _usagePercent), - duration: const Duration(milliseconds: 1200), - curve: Curves.easeOutCubic, - builder: (context, value, child) { - return CircularProgressIndicator( - value: value, - strokeWidth: 12, - backgroundColor: Colors.transparent, - valueColor: AlwaysStoppedAnimation(_statusColor), - strokeCap: StrokeCap.round, - ); - }, - ), - ), - // Center text - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${(_usagePercent * 100).toInt()}%', - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: _statusColor, - ), - ), - const Text( - '已使用', - style: TextStyle( - fontSize: 12, - color: AppTheme.textTertiary, - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 20), - // Usage text - Text( - '已使用 $_usedCalls / $_quota 次', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), + OutlinedButton.icon( + onPressed: checkingUpdate ? null : onCheckUpdate, + icon: checkingUpdate + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.cloud_sync_outlined, size: 16), + label: Text(checkingUpdate ? 'Checking...' : 'Check LiteLLM update'), ), - const SizedBox(height: 8), - // Remaining with color - Text( - '剩余 $_remaining 次', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: _statusColor, - ), + TextButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add_outlined, size: 16), + label: const Text('Override'), ), - const SizedBox(height: 12), - // Reset date - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.surfaceCard.withOpacity(0.5), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.refresh, size: 14, color: AppTheme.textTertiary), - const SizedBox(width: 6), - Text( - '额度重置于 $_resetDate', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textTertiary, - ), - ), - ], - ), + TextButton.icon( + onPressed: totalVisible == 0 ? null : () => _openPriceBrowser(context), + icon: const Icon(Icons.search_outlined, size: 16), + label: const Text('Search all'), ), ], ), - ), + const SizedBox(height: 8), + if (overrides.isNotEmpty) ...[ + const Text('User overrides', style: TextStyle(color: _usageText, fontSize: 12, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + for (final price in overrides) + _PriceRow( + price: price, + onEdit: () => onEdit(price), + onRemove: () => onRemove(price), + ), + const SizedBox(height: 8), + ], + const Text('Current snapshot', style: TextStyle(color: _usageText, fontSize: 12, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + Text( + prices.length <= previewPrices.length + ? 'Showing all ${prices.length} snapshot models.' + : 'Showing ${previewPrices.length} of ${prices.length} snapshot models. Use Search all for the full table.', + style: const TextStyle(color: _usageFaint, fontSize: 10.5, height: 1.3), + ), + const SizedBox(height: 8), + if (previewPrices.isEmpty) + const _EmptyUsageText(text: 'No snapshot prices loaded yet. Check LiteLLM update or add an override.') + else + for (final price in previewPrices) + _PriceRow( + price: price, + onEdit: () => onEdit(price), + onRemove: price.custom ? () => onRemove(price) : null, + ), + ], ), ); } - Widget _buildWarningBanner() { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppTheme.error.withOpacity(0.3), - AppTheme.warning.withOpacity(0.2), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.error.withOpacity(0.4)), - ), - child: Row( - children: [ - Icon(Icons.warning_amber_rounded, color: AppTheme.warning, size: 24), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '用量警报', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.warning, - ), - ), - const SizedBox(height: 2), - Text( - '您已使用超过 80% 的月度额度,请注意控制用量或升级套餐', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - ], + void _openPriceBrowser(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _usagePanel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => FractionallySizedBox( + heightFactor: 0.92, + child: _PricingCatalogSearchSheet( + catalog: catalog, + prices: prices, + overrides: overrides, + onAdd: onAdd, + onEdit: onEdit, + onRemove: onRemove, ), ), ); } +} - Widget _buildStatisticsRow() { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), +class _PriceRow extends StatelessWidget { + const _PriceRow({ + required this.price, + required this.onEdit, + this.onRemove, + }); + + final TokenPrice price; + final VoidCallback onEdit; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: price.custom ? _usageAmber.withOpacity(0.08) : _usageCyan.withOpacity(0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: (price.custom ? _usageAmber : _usageLine).withOpacity(0.55)), + ), child: Row( children: [ Expanded( - child: _buildStatCard('总请求数', '3,247', Icons.request_page, AppTheme.cyanLight), - ), - const SizedBox(width: 10), - Expanded( - child: _buildStatCard('总Token数', '3.94M', Icons.data_usage, AppTheme.violet), - ), - const SizedBox(width: 10), - Expanded( - child: _buildStatCard('成功率', '99.2%', Icons.check_circle, AppTheme.success), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${price.provider} / ${price.model}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _usageText, fontSize: 12, fontWeight: FontWeight.w900), + ), + const SizedBox(height: 4), + Text( + 'in \$${price.inputPerMillion.toStringAsFixed(3)} / out \$${price.outputPerMillion.toStringAsFixed(3)} per 1M tokens', + style: const TextStyle(color: _usageMuted, fontSize: 11, height: 1.3), + ), + Text( + '${price.custom ? 'User override' : price.sourceName} · ${_dateLabel(price.updatedAt)}', + style: const TextStyle(color: _usageFaint, fontSize: 10.5, height: 1.3), + ), + ], + ), ), - const SizedBox(width: 10), - Expanded( - child: _buildStatCard('预估费用', '\$48.5', Icons.attach_money, AppTheme.cyan), + IconButton( + tooltip: price.custom ? 'Edit override' : 'Create override from this price', + onPressed: onEdit, + icon: const Icon(Icons.tune_outlined, size: 18), ), + if (onRemove != null) + IconButton( + tooltip: 'Remove override', + onPressed: onRemove, + icon: const Icon(Icons.delete_outline, size: 18), + ), ], ), ); } +} + +class _PricingCatalogSearchSheet extends StatefulWidget { + const _PricingCatalogSearchSheet({ + required this.catalog, + required this.prices, + required this.overrides, + required this.onAdd, + required this.onEdit, + required this.onRemove, + }); + + final TokenPricingCatalog catalog; + final List prices; + final List overrides; + final VoidCallback onAdd; + final ValueChanged onEdit; + final ValueChanged onRemove; + + @override + State<_PricingCatalogSearchSheet> createState() => _PricingCatalogSearchSheetState(); +} + +class _PricingCatalogSearchSheetState extends State<_PricingCatalogSearchSheet> { + static const _providerFilters = ['all', 'openai', 'anthropic', 'google', 'custom', 'other']; + + final _query = TextEditingController(); + var _providerFilter = 'all'; + var _sortMode = _PricingSortMode.modelName; + + @override + void dispose() { + _query.dispose(); + super.dispose(); + } - Widget _buildStatCard(String title, String value, IconData icon, Color color) { - return GlassCardWidget( + @override + Widget build(BuildContext context) { + final prices = _filteredPrices; + return SafeArea( child: Padding( - padding: const EdgeInsets.all(12), + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 20, color: color), - const SizedBox(height: 8), - Text( - value, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), + Row( + children: [ + const Icon(Icons.manage_search_outlined, color: _usageAmber, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Pricing table · ${_formatInt(prices.length)} / ${_formatInt(_allPrices.length)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _usageText, fontSize: 16, fontWeight: FontWeight.w900), + ), + ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_outlined), + ), + ], ), - const SizedBox(height: 2), + const SizedBox(height: 6), Text( - title, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textTertiary, + '${widget.catalog.sourceName} · updated ${_dateLabel(widget.catalog.updatedAt)}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _usageMuted, fontSize: 11, height: 1.35), + ), + const SizedBox(height: 12), + TextField( + controller: _query, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search_outlined), + suffixIcon: _query.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + onPressed: () => setState(_query.clear), + icon: const Icon(Icons.close_outlined), + ), + hintText: 'Search provider, model, source...', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + isDense: true, ), - textAlign: TextAlign.center, ), - ], - ), - ), - ); - } - - Widget _buildUsageChart() { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: GlassCardWidget( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '每日用量', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final filter in _providerFilters) + FilterChip( + showCheckmark: false, + selected: _providerFilter == filter, + onSelected: (_) => setState(() => _providerFilter = filter), + label: Text('${_providerFilterLabel(filter)} ${_formatInt(_providerCount(filter))}'), + selectedColor: _usageAmber.withOpacity(0.16), + backgroundColor: _usageBg, + side: BorderSide( + color: _providerFilter == filter ? _usageAmber.withOpacity(0.55) : _usageLine, + ), + labelStyle: TextStyle( + color: _providerFilter == filter ? _usageAmber : _usageMuted, + fontSize: 11, + fontWeight: FontWeight.w900, ), ), - // Legend - Row( - children: [ - _buildLegendDot('OpenAI', const Color(0xFF10B981)), - const SizedBox(width: 12), - _buildLegendDot('Claude', const Color(0xFF8B5CF6)), - const SizedBox(width: 12), - _buildLegendDot('Gemini', const Color(0xFF3B82F6)), - ], + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _UsageBadge(label: '${_formatInt(widget.prices.length)} snapshot', color: _usageCyan), + _UsageBadge(label: '${_formatInt(widget.overrides.length)} overrides', color: _usageAmber), + PopupMenuButton<_PricingSortMode>( + tooltip: 'Sort pricing rows', + initialValue: _sortMode, + onSelected: (mode) => setState(() => _sortMode = mode), + itemBuilder: (context) => const [ + PopupMenuItem( + value: _PricingSortMode.modelName, + child: Text('Model A-Z'), + ), + PopupMenuItem( + value: _PricingSortMode.priceLowHigh, + child: Text('Price low-high'), + ), + PopupMenuItem( + value: _PricingSortMode.priceHighLow, + child: Text('Price high-low'), + ), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: _usageViolet.withOpacity(0.08), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: _usageViolet.withOpacity(0.28)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.sort_outlined, color: _usageViolet, size: 16), + const SizedBox(width: 6), + Text( + _sortModeLabel(_sortMode), + style: const TextStyle(color: _usageViolet, fontSize: 11, fontWeight: FontWeight.w900), + ), + const SizedBox(width: 4), + const Icon(Icons.expand_more_outlined, color: _usageViolet, size: 16), + ], + ), ), - ], - ), - const SizedBox(height: 20), - // Bar chart - SizedBox( - height: 160, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: _dailyUsage.map((day) { - return Expanded( - child: GestureDetector( - onTap: () { - setState(() { - _selectedDay = _selectedDay == day['day'] ? null : day['day']; - }); - }, - child: _buildBar(day), - ), - ); - }).toList(), ), - ), - const SizedBox(height: 12), - // X axis labels (every 5 days) - Row( - children: List.generate(6, (i) { - return Expanded( - flex: i < 5 ? 5 : 5, - child: Text( - '${i * 5 + 1}日', - style: const TextStyle( - fontSize: 10, - color: AppTheme.textTertiary, - ), - textAlign: TextAlign.left, - ), - ); - }), - ), - // Tooltip for selected day - if (_selectedDay != null) ...[ - const SizedBox(height: 12), - _buildDayTooltip(), + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(); + widget.onAdd(); + }, + icon: const Icon(Icons.add_outlined, size: 16), + label: const Text('Add override'), + ), ], - ], - ), + ), + const SizedBox(height: 10), + Expanded( + child: prices.isEmpty + ? const Center(child: _EmptyUsageText(text: 'No pricing rows match this search.')) + : ListView.builder( + itemCount: prices.length, + itemBuilder: (context, index) { + final price = prices[index]; + return _PriceRow( + price: price, + onEdit: () { + Navigator.of(context).pop(); + widget.onEdit(price); + }, + onRemove: price.custom + ? () { + Navigator.of(context).pop(); + widget.onRemove(price); + } + : null, + ); + }, + ), + ), + ], ), ), ); } - Widget _buildLegendDot(String label, Color color) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 4), - Text( - label, - style: const TextStyle(fontSize: 11, color: AppTheme.textTertiary), - ), - ], - ); + List get _allPrices => _combinedPrices( + prices: widget.prices, + overrides: widget.overrides, + ); + + List get _filteredPrices { + final needle = _query.text.trim().toLowerCase(); + final providerFiltered = _allPrices.where((price) { + if (_providerFilter == 'all') return true; + return _providerBucket(price) == _providerFilter; + }); + final filtered = needle.isEmpty + ? providerFiltered + : providerFiltered.where((price) { + final haystack = '${price.provider} ${price.model} ${price.sourceName} ${price.notes}'.toLowerCase(); + return haystack.contains(needle); + }); + return filtered.toList(growable: false) + ..sort(_compareBySortMode); + } + + int _compareBySortMode(TokenPrice a, TokenPrice b) { + switch (_sortMode) { + case _PricingSortMode.priceLowHigh: + return _comparePrice(a, b); + case _PricingSortMode.priceHighLow: + return _comparePrice(b, a); + case _PricingSortMode.modelName: + final model = a.model.compareTo(b.model); + if (model != 0) return model; + return a.provider.compareTo(b.provider); + } + } + + int _providerCount(String filter) { + if (filter == 'all') return _allPrices.length; + return _allPrices.where((price) => _providerBucket(price) == filter).length; } - Widget _buildBar(Map day) { - final maxTokens = 250000; - final barHeight = (day['tokens'] as int) / maxTokens * 140; - final isSelected = _selectedDay == day['day']; - final Color barColor; - switch (day['provider']) { - case 'Claude': - barColor = const Color(0xFF8B5CF6); - break; - case 'Gemini': - barColor = const Color(0xFF3B82F6); - break; + String _providerFilterLabel(String filter) { + switch (filter) { + case 'openai': + return 'OpenAI'; + case 'anthropic': + return 'Anthropic'; + case 'google': + return 'Google'; + case 'custom': + return 'Custom'; + case 'other': + return 'Other'; default: - barColor = const Color(0xFF10B981); + return 'All'; } + } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 1.5), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 300), - width: double.infinity, - height: barHeight.clamp(4, 140), - decoration: BoxDecoration( - color: barColor.withOpacity(isSelected ? 1.0 : 0.7), - borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), - boxShadow: isSelected - ? [ - BoxShadow( - color: barColor.withOpacity(0.4), - blurRadius: 6, - spreadRadius: 1, - ), - ] - : null, - ), - ), - ], - ), - ); + int _comparePrice(TokenPrice a, TokenPrice b) { + final price = _combinedPricePerMillion(a).compareTo(_combinedPricePerMillion(b)); + if (price != 0) return price; + final model = a.model.compareTo(b.model); + if (model != 0) return model; + return a.provider.compareTo(b.provider); } - Widget _buildDayTooltip() { - final day = _dailyUsage.firstWhere((d) => d['day'] == _selectedDay); - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.surfaceElevated, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.border), - ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppTheme.violet.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.calendar_today, size: 16, color: AppTheme.violet), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${_currentMonth.month}月${day['day']}日', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - '请求: ${day['calls']} 次 | Tokens: ${(day['tokens'] as int) ~/ 1000}K | 提供商: ${day['provider']}', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ); + double _combinedPricePerMillion(TokenPrice price) { + return price.inputPerMillion + price.outputPerMillion; + } + + String _sortModeLabel(_PricingSortMode mode) { + switch (mode) { + case _PricingSortMode.priceLowHigh: + return 'Price low-high'; + case _PricingSortMode.priceHighLow: + return 'Price high-low'; + case _PricingSortMode.modelName: + return 'Model A-Z'; + } } +} - Widget _buildProviderBreakdown() { - final totalTokens = _providers.fold(0, (sum, p) => sum + (p['tokens'] as int)); +class _PricingUpdateSheet extends StatelessWidget { + const _PricingUpdateSheet({required this.update}); - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: GlassCardWidget( - child: Padding( - padding: const EdgeInsets.all(20), + final TokenPricingSnapshotUpdate update; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ + const Row( + children: [ + Icon(Icons.cloud_sync_outlined, color: _usageAmber, size: 20), + SizedBox(width: 8), + Expanded( + child: Text('LiteLLM pricing update', style: TextStyle(color: _usageText, fontSize: 16, fontWeight: FontWeight.w900)), + ), + ], + ), + const SizedBox(height: 8), const Text( - '提供商分布', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), + 'This is a manual snapshot update. MobileCode will not update prices in the background, and user overrides still take priority.', + style: TextStyle(color: _usageMuted, fontSize: 12, height: 1.35), ), - const SizedBox(height: 16), - ..._providers.map((provider) { - final pct = (provider['tokens'] as int) / totalTokens; - return _buildProviderRow(provider, pct); - }).toList(), - ], - ), - ), - ), - ); - } - - Widget _buildProviderRow(Map provider, double pct) { - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: Column( - children: [ - Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: (provider['color'] as Color).withOpacity(0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - provider['icon'] as IconData, - size: 18, - color: provider['color'] as Color, - ), + const SizedBox(height: 12), + _PricingUpdateMetric(label: 'Models', value: _formatInt(update.modelCount), color: _usageViolet), + _PricingUpdateMetric(label: 'New', value: _formatInt(update.newCount), color: _usageMint), + _PricingUpdateMetric(label: 'Price changes', value: _formatInt(update.changedCount), color: _usageAmber), + _PricingUpdateMetric(label: 'Unchanged', value: _formatInt(update.unchangedCount), color: _usageCyan), + const SizedBox(height: 10), + Text( + '${update.sourceName} · updated ${_dateLabel(update.updatedAt)}', + style: const TextStyle(color: _usageText, fontSize: 12, fontWeight: FontWeight.w900, height: 1.35), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - provider['name'] as String, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppTheme.textPrimary, - ), - ), - Text( - '${(pct * 100).toInt()}%', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: provider['color'] as Color, - ), - ), - ], + const SizedBox(height: 4), + SelectableText( + update.sourceUrl, + style: const TextStyle(color: _usageMuted, fontSize: 11, height: 1.35), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), ), - const SizedBox(height: 2), - Text( - '${(provider['tokens'] as int) ~/ 1000}K tokens · ${provider['calls']} 次调用', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textTertiary, - ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: update.modelCount == 0 ? null : () => Navigator.of(context).pop(true), + icon: const Icon(Icons.download_done_outlined), + label: const Text('Apply snapshot'), ), - ], - ), + ), + ], ), ], ), - const SizedBox(height: 8), - // Progress bar - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: pct, - minHeight: 6, - backgroundColor: AppTheme.surfaceElevated, - valueColor: AlwaysStoppedAnimation(provider['color'] as Color), - ), - ), - ], + ), ), ); } +} - Widget _buildEndpointUsage() { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, +class _PricingUpdateMetric extends StatelessWidget { + const _PricingUpdateMetric({ + required this.label, + required this.value, + required this.color, + }); + + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.28)), + ), + child: Row( children: [ - const Padding( - padding: EdgeInsets.only(left: 4, bottom: 12), - child: Text( - '端点用量', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - ), - Row( - children: _endpoints.map((endpoint) { - return Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 10), - child: _buildEndpointCard(endpoint), - ), - ); - }).toList(), - ), + Expanded(child: Text(label, style: const TextStyle(color: _usageMuted, fontSize: 12, fontWeight: FontWeight.w700))), + Text(value, style: TextStyle(color: color, fontSize: 13, fontWeight: FontWeight.w900)), ], ), ); } +} - Widget _buildEndpointCard(Map endpoint) { - final colors = [AppTheme.violet, AppTheme.cyan, AppTheme.cyanLight]; - final idx = _endpoints.indexOf(endpoint); - final color = colors[idx % colors.length]; +class _PricingOverrideSheet extends StatefulWidget { + const _PricingOverrideSheet({this.initialPrice}); + + final TokenPrice? initialPrice; + + @override + State<_PricingOverrideSheet> createState() => _PricingOverrideSheetState(); +} + +class _PricingOverrideSheetState extends State<_PricingOverrideSheet> { + late final TextEditingController _provider; + late final TextEditingController _model; + late final TextEditingController _inputPerMillion; + late final TextEditingController _outputPerMillion; + late final TextEditingController _cacheReadPerMillion; + late final TextEditingController _cacheWritePerMillion; + + @override + void initState() { + super.initState(); + final price = widget.initialPrice; + _provider = TextEditingController(text: price?.provider ?? ''); + _model = TextEditingController(text: price?.model ?? ''); + _inputPerMillion = TextEditingController(text: price == null ? '' : price.inputPerMillion.toStringAsFixed(6)); + _outputPerMillion = TextEditingController(text: price == null ? '' : price.outputPerMillion.toStringAsFixed(6)); + _cacheReadPerMillion = TextEditingController(text: price == null || price.cacheReadPerMillion == 0 ? '' : price.cacheReadPerMillion.toStringAsFixed(6)); + _cacheWritePerMillion = TextEditingController(text: price == null || price.cacheWritePerMillion == 0 ? '' : price.cacheWritePerMillion.toStringAsFixed(6)); + } - return GlassCardWidget( + @override + void dispose() { + _provider.dispose(); + _model.dispose(); + _inputPerMillion.dispose(); + _outputPerMillion.dispose(); + _cacheReadPerMillion.dispose(); + _cacheWritePerMillion.dispose(); + super.dispose(); + } + + void _save() { + final provider = _provider.text.trim(); + final model = _model.text.trim(); + final input = _moneyPerMillion(_inputPerMillion.text); + final output = _moneyPerMillion(_outputPerMillion.text); + final cacheRead = _moneyPerMillion(_cacheReadPerMillion.text); + final cacheWrite = _moneyPerMillion(_cacheWritePerMillion.text); + if (provider.isEmpty || model.isEmpty || input == null || output == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Provider, model, input and output prices are required.'))); + return; + } + Navigator.of(context).pop(TokenPrice( + provider: provider, + model: model, + inputCostPerToken: input, + outputCostPerToken: output, + cacheReadCostPerToken: cacheRead ?? 0, + cacheWriteCostPerToken: cacheWrite ?? 0, + sourceName: 'User override', + sourceUrl: '', + updatedAt: DateTime.now(), + custom: true, + notes: 'User-configured MobileCode price override.', + )); + } + + @override + Widget build(BuildContext context) { + return SafeArea( child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + children: [ + Icon(Icons.price_change_outlined, color: _usageAmber, size: 20), + SizedBox(width: 8), + Expanded( + child: Text('Pricing override', style: TextStyle(color: _usageText, fontSize: 16, fontWeight: FontWeight.w900)), + ), + ], ), - child: Icon( - endpoint['icon'] as IconData, - size: 20, - color: color, + const SizedBox(height: 8), + const Text( + 'Enter USD prices per 1M tokens. MobileCode stores only the price table, not prompts or responses.', + style: TextStyle(color: _usageMuted, fontSize: 12, height: 1.35), ), - ), - const SizedBox(height: 12), - Text( - endpoint['name'] as String, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, + const SizedBox(height: 12), + TextField(controller: _provider, decoration: const InputDecoration(labelText: 'Provider, e.g. openai / anthropic / custom')), + const SizedBox(height: 8), + TextField(controller: _model, decoration: const InputDecoration(labelText: 'Model, e.g. gpt-4o-mini')), + const SizedBox(height: 8), + TextField( + controller: _inputPerMillion, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Input USD / 1M tokens'), ), - ), - const SizedBox(height: 8), - Text( - '${endpoint['requests']} 次请求', - style: const TextStyle( - fontSize: 13, - color: AppTheme.textSecondary, + const SizedBox(height: 8), + TextField( + controller: _outputPerMillion, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Output USD / 1M tokens'), ), - ), - const SizedBox(height: 2), - Text( - '平均 ${endpoint['avgTokens']} tokens', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textTertiary, + const SizedBox(height: 8), + TextField( + controller: _cacheReadPerMillion, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Cache read USD / 1M tokens (optional)'), ), - ), - ], + const SizedBox(height: 8), + TextField( + controller: _cacheWritePerMillion, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Cache write USD / 1M tokens (optional)'), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: _save, + icon: const Icon(Icons.save_outlined), + label: const Text('Save'), + ), + ), + ], + ), + ], + ), ), ), ); } +} - Widget _buildOptimizationTips() { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), +class _UsageEventTile extends StatelessWidget { + const _UsageEventTile({required this.event}); + + final TokenUsageEvent event; + + @override + Widget build(BuildContext context) { + final color = event.cancelled + ? _usageAmber + : event.success + ? _usageMint + : _usageRose; + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.07), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.28)), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.only(left: 4, bottom: 12), - child: Text( - '优化建议', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - ), - ..._tips.map((tip) => _buildTipCard(tip)), - ], - ), - ); - } - - Widget _buildTipCard(Map tip) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: GlassCardWidget( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( + Row( children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: AppTheme.auroraGradient, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - tip['icon'] as IconData, - size: 22, - color: Colors.white, - ), - ), - const SizedBox(width: 14), + Icon(event.cancelled ? Icons.pause_circle_outline : event.success ? Icons.check_circle_outline : Icons.error_outline, color: color, size: 18), + const SizedBox(width: 8), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tip['title'] as String, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - tip['desc'] as String, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: AppTheme.success.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), child: Text( - tip['savings'] as String, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppTheme.success, - ), + event.endpoint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _usageText, fontSize: 13, fontWeight: FontWeight.w900), ), ), + if (event.usage.estimated) const _UsageBadge(label: 'estimated', color: _usageAmber), ], ), - ), - ), - ); - } - - Widget _buildAlertsSection() { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: GlassCardWidget( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 8), + Text( + '${event.provider} · ${event.model} · ${event.durationMs}ms · ${_timeLabel(event.createdAt)}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _usageMuted, fontSize: 11, height: 1.35), + ), + const SizedBox(height: 4), + Text( + 'price: ${event.pricingSource} · ${_dateLabel(event.pricingUpdatedAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _usageFaint, fontSize: 10.5, height: 1.3), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '用量警报', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - TextButton.icon( - onPressed: _showAddAlertDialog, - icon: const Icon(Icons.add, size: 16, color: AppTheme.violet), - label: const Text( - '添加', - style: TextStyle(fontSize: 12, color: AppTheme.violet), - ), - ), - ], - ), - const SizedBox(height: 12), - ..._alerts.asMap().entries.map((entry) { - final idx = entry.key; - final alert = entry.value; - return _buildAlertRow(alert, idx); - }).toList(), + _UsageBadge(label: 'total ${_formatInt(event.usage.totalTokens)}', color: _usageViolet), + _UsageBadge(label: 'in ${_formatInt(event.usage.inputTokens)}', color: _usageCyan), + _UsageBadge(label: 'out ${_formatInt(event.usage.outputTokens)}', color: _usageMint), + if (event.usage.cacheReadTokens > 0) _UsageBadge(label: 'cache read ${_formatInt(event.usage.cacheReadTokens)}', color: _usageMint), + if (event.usage.cacheWriteTokens > 0) _UsageBadge(label: 'cache write ${_formatInt(event.usage.cacheWriteTokens)}', color: _usageAmber), ], ), - ), + ], ), ); } +} - Widget _buildAlertRow(Map alert, int index) { - final threshold = alert['threshold'] as int; - final enabled = alert['enabled'] as bool; - final triggered = alert['triggered'] as bool; +class _UsageBadge extends StatelessWidget { + const _UsageBadge({required this.label, required this.color}); - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: triggered ? AppTheme.error.withOpacity(0.1) : AppTheme.surfaceCard.withOpacity(0.5), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: triggered ? AppTheme.error.withOpacity(0.3) : AppTheme.border, - ), - ), - child: Row( - children: [ - Icon( - triggered ? Icons.notifications_active : Icons.notifications_none, - size: 20, - color: triggered ? AppTheme.error : AppTheme.textTertiary, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '用量达到 $threshold% 时提醒', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: triggered ? AppTheme.error : AppTheme.textPrimary, - ), - ), - if (triggered) - const Text( - '已触发 - 当前用量超过此阈值', - style: TextStyle( - fontSize: 12, - color: AppTheme.error, - ), - ), - ], - ), - ), - Switch( - value: enabled, - onChanged: (v) => setState(() => _alerts[index]['enabled'] = v), - activeColor: AppTheme.violet, - activeTrackColor: AppTheme.violetGlow, - inactiveTrackColor: AppTheme.border, - ), - if (triggered) - IconButton( - onPressed: () => setState(() => _alerts[index]['triggered'] = false), - icon: const Icon(Icons.close, size: 18, color: AppTheme.textTertiary), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - ), - ], - ), + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withOpacity(0.35)), ), + child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w900)), ); } +} - void _showAddAlertDialog() { - final TextEditingController controller = TextEditingController(); - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.surfaceCard, - title: const Text( - '添加警报阈值', - style: TextStyle(color: AppTheme.textPrimary), - ), - content: TextField( - controller: controller, - keyboardType: TextInputType.number, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: const InputDecoration( - hintText: '输入百分比 (1-100)', - hintStyle: TextStyle(color: AppTheme.textTertiary), - suffixText: '%', - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消', style: TextStyle(color: AppTheme.textSecondary)), - ), - TextButton( - onPressed: () { - final val = int.tryParse(controller.text); - if (val != null && val > 0 && val <= 100) { - setState(() { - _alerts.add({ - 'threshold': val, - 'enabled': true, - 'triggered': false, - }); - }); - } - Navigator.pop(context); - }, - child: const Text('添加', style: TextStyle(color: AppTheme.violet)), - ), - ], - ), - ); +class _EmptyUsageText extends StatelessWidget { + const _EmptyUsageText({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Text(text, style: const TextStyle(color: _usageMuted, fontSize: 12, height: 1.35)); } } + +String _formatInt(int value) { + final raw = value.toString(); + final buffer = StringBuffer(); + for (var i = 0; i < raw.length; i++) { + final reverseIndex = raw.length - i; + buffer.write(raw[i]); + if (reverseIndex > 1 && reverseIndex % 3 == 1) buffer.write(','); + } + return buffer.toString(); +} + +double? _moneyPerMillion(String value) { + final parsed = double.tryParse(value.trim()); + if (parsed == null || parsed < 0) return null; + return parsed / 1000000; +} + +String _dateLabel(DateTime value) { + final y = value.year.toString().padLeft(4, '0'); + final m = value.month.toString().padLeft(2, '0'); + final d = value.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; +} + +String _timeLabel(DateTime value) { + final h = value.hour.toString().padLeft(2, '0'); + final m = value.minute.toString().padLeft(2, '0'); + return '$h:$m'; +} diff --git a/mobile_agent/lib/screens/device_telemetry_screen.dart b/mobile_agent/lib/screens/device_telemetry_screen.dart new file mode 100644 index 0000000..6f256cf --- /dev/null +++ b/mobile_agent/lib/screens/device_telemetry_screen.dart @@ -0,0 +1,390 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import '../services/device_telemetry_service.dart'; + +const _deviceBg = Color(0xFFF7FAFF); +const _devicePanel = Color(0xFFFFFFFF); +const _deviceLine = Color(0xFFDDE7F7); +const _deviceText = Color(0xFF0B1020); +const _deviceMuted = Color(0xFF536079); +const _deviceFaint = Color(0xFF8B97AD); +const _deviceMint = Color(0xFF0B9B7E); +const _deviceCyan = Color(0xFF16B9C7); +const _deviceAmber = Color(0xFFB7791F); +const _deviceRose = Color(0xFFE0526E); +const _deviceViolet = Color(0xFF7557E8); + +class DeviceTelemetryScreen extends StatefulWidget { + const DeviceTelemetryScreen({super.key}); + + @override + State createState() => _DeviceTelemetryScreenState(); +} + +class _DeviceTelemetryScreenState extends State { + late final Stream _stream; + var _frameWindowStart = DateTime.now(); + var _frameCount = 0; + var _jankCount = 0; + var _fps = 0.0; + var _jankPercent = 0.0; + + @override + void initState() { + super.initState(); + _stream = DeviceTelemetryService.instance.watchTelemetry(); + SchedulerBinding.instance.addTimingsCallback(_handleFrameTimings); + } + + @override + void dispose() { + SchedulerBinding.instance.removeTimingsCallback(_handleFrameTimings); + super.dispose(); + } + + void _handleFrameTimings(List timings) { + _frameCount += timings.length; + _jankCount += timings.where((timing) => timing.totalSpan.inMilliseconds > 16).length; + final elapsed = DateTime.now().difference(_frameWindowStart); + if (elapsed.inMilliseconds < 900 || !mounted) return; + setState(() { + _fps = _frameCount * 1000 / elapsed.inMilliseconds; + _jankPercent = _frameCount == 0 ? 0 : _jankCount * 100 / _frameCount; + _frameCount = 0; + _jankCount = 0; + _frameWindowStart = DateTime.now(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _deviceBg, + appBar: AppBar( + title: const Text('Device Telemetry'), + backgroundColor: _deviceBg, + foregroundColor: _deviceText, + elevation: 0, + ), + body: StreamBuilder( + stream: _stream, + builder: (context, snapshot) { + final data = snapshot.data; + if (data == null) { + return const Center(child: CircularProgressIndicator()); + } + return ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + _DeviceHeader(snapshot: data), + const SizedBox(height: 12), + _MetricGrid(snapshot: data), + const SizedBox(height: 12), + _DevicePanel( + title: 'htop snapshot', + icon: Icons.monitor_heart_outlined, + color: _deviceMint, + children: [ + _TelemetryBar( + label: 'CPU', + valueLabel: '${data.cpuUsagePercent.toStringAsFixed(1)}%', + value: (data.cpuUsagePercent / 100).clamp(0, 1), + color: _deviceMint, + ), + _TelemetryBar( + label: 'RAM', + valueLabel: data.totalMemoryMb > 0 + ? '${data.availableMemoryMb} MB free / ${data.totalMemoryMb} MB' + : 'Unknown', + value: data.memoryUsedPercent.clamp(0, 1), + color: data.lowMemory ? _deviceRose : _deviceCyan, + ), + _TelemetryBar( + label: 'Storage', + valueLabel: data.storageTotalMb > 0 + ? '${data.storageFreeMb} MB free / ${data.storageTotalMb} MB' + : 'Unknown', + value: data.storageUsedPercent.clamp(0, 1), + color: _deviceAmber, + ), + _TelemetryBar( + label: 'Battery', + valueLabel: data.batteryLevel >= 0 + ? '${data.batteryLevel}%${data.batteryCharging ? ' charging' : ''}' + : 'Unknown', + value: data.batteryPercent.clamp(0, 1), + color: data.batteryCharging ? _deviceMint : _deviceViolet, + ), + ], + ), + const SizedBox(height: 12), + _DevicePanel( + title: 'Runtime pressure', + icon: Icons.memory_outlined, + color: _deviceViolet, + children: [ + _InfoRow(label: 'App RSS', value: '${data.appRssMb} MB'), + _InfoRow(label: 'App heap/native', value: '${data.appHeapMb} MB'), + _InfoRow(label: 'FPS / jank', value: '${_fps.toStringAsFixed(0)} fps / ${_jankPercent.toStringAsFixed(1)}%'), + _InfoRow(label: 'CPU cores', value: '${data.cpuCores}'), + _InfoRow(label: 'Thermal status', value: _thermalLabel(data.thermalStatus)), + _InfoRow( + label: 'Battery temperature', + value: data.batteryTemperatureC > 0 ? '${data.batteryTemperatureC.toStringAsFixed(1)} C' : 'Unknown', + ), + _InfoRow(label: 'Last sample', value: _timeLabel(data.timestamp)), + ], + ), + ], + ); + }, + ), + ); + } +} + +class _DeviceHeader extends StatelessWidget { + const _DeviceHeader({required this.snapshot}); + + final DeviceTelemetrySnapshot snapshot; + + @override + Widget build(BuildContext context) { + return _DevicePanel( + title: 'Phone profile', + icon: Icons.phone_android_outlined, + color: _deviceCyan, + trailing: snapshot.fallback + ? const _Badge(label: 'fallback', color: _deviceAmber) + : const _Badge(label: 'native', color: _deviceMint), + children: [ + Text( + [snapshot.manufacturer, snapshot.model].where((item) => item.trim().isNotEmpty).join(' ').trim().isEmpty + ? snapshot.model + : [snapshot.manufacturer, snapshot.model].where((item) => item.trim().isNotEmpty).join(' '), + style: const TextStyle(color: _deviceText, fontSize: 18, fontWeight: FontWeight.w900), + ), + const SizedBox(height: 6), + Text( + [ + snapshot.platform, + if (snapshot.androidVersion.isNotEmpty) 'Android ${snapshot.androidVersion}', + if (snapshot.sdkInt > 0) 'SDK ${snapshot.sdkInt}', + if (snapshot.abis.isNotEmpty) snapshot.abis.take(2).join(', '), + ].join(' · '), + style: const TextStyle(color: _deviceMuted, fontSize: 12, height: 1.35), + ), + ], + ); + } +} + +class _MetricGrid extends StatelessWidget { + const _MetricGrid({required this.snapshot}); + + final DeviceTelemetrySnapshot snapshot; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth > 560 ? 4 : 2; + return GridView.count( + crossAxisCount: columns, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: columns == 4 ? 1.9 : 1.45, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + _MetricTile(label: 'CPU', value: '${snapshot.cpuUsagePercent.toStringAsFixed(0)}%', icon: Icons.speed_outlined, color: _deviceMint), + _MetricTile(label: 'RAM free', value: snapshot.availableMemoryMb > 0 ? '${snapshot.availableMemoryMb} MB' : 'Unknown', icon: Icons.memory_outlined, color: _deviceCyan), + _MetricTile(label: 'Battery', value: snapshot.batteryLevel >= 0 ? '${snapshot.batteryLevel}%' : 'Unknown', icon: Icons.battery_charging_full_outlined, color: _deviceAmber), + _MetricTile(label: 'App RSS', value: '${snapshot.appRssMb} MB', icon: Icons.data_usage_outlined, color: _deviceViolet), + ], + ); + }, + ); + } +} + +class _MetricTile extends StatelessWidget { + const _MetricTile({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + final String label; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _devicePanel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _deviceLine), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon(icon, color: color, size: 20), + Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _deviceText, fontSize: 20, fontWeight: FontWeight.w900)), + Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _deviceMuted, fontSize: 11, fontWeight: FontWeight.w700)), + ], + ), + ); + } +} + +class _DevicePanel extends StatelessWidget { + const _DevicePanel({ + required this.title, + required this.icon, + required this.color, + required this.children, + this.trailing, + }); + + final String title; + final IconData icon; + final Color color; + final List children; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _devicePanel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _deviceLine), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Expanded(child: Text(title, style: const TextStyle(color: _deviceText, fontSize: 14, fontWeight: FontWeight.w900))), + if (trailing != null) trailing!, + ], + ), + const SizedBox(height: 12), + ...children, + ], + ), + ); + } +} + +class _TelemetryBar extends StatelessWidget { + const _TelemetryBar({ + required this.label, + required this.valueLabel, + required this.value, + required this.color, + }); + + final String label; + final String valueLabel; + final double value; + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(label, style: const TextStyle(color: _deviceText, fontSize: 12, fontWeight: FontWeight.w900))), + Text(valueLabel, style: const TextStyle(color: _deviceMuted, fontSize: 11, fontWeight: FontWeight.w700)), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: value, + minHeight: 8, + backgroundColor: color.withOpacity(0.12), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded(child: Text(label, style: const TextStyle(color: _deviceMuted, fontSize: 12, fontWeight: FontWeight.w700))), + Flexible(child: Text(value, textAlign: TextAlign.right, style: const TextStyle(color: _deviceText, fontSize: 12, fontWeight: FontWeight.w900))), + ], + ), + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.label, required this.color}); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withOpacity(0.35)), + ), + child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w900)), + ); + } +} + +String _thermalLabel(int status) { + return switch (status) { + 0 => 'None', + 1 => 'Light', + 2 => 'Moderate', + 3 => 'Severe', + 4 => 'Critical', + 5 => 'Emergency', + 6 => 'Shutdown', + _ => 'Unknown', + }; +} + +String _timeLabel(DateTime value) { + final h = value.hour.toString().padLeft(2, '0'); + final m = value.minute.toString().padLeft(2, '0'); + final s = value.second.toString().padLeft(2, '0'); + return '$h:$m:$s'; +} diff --git a/mobile_agent/lib/screens/downloads_shared_folders_screen.dart b/mobile_agent/lib/screens/downloads_shared_folders_screen.dart new file mode 100644 index 0000000..0a038e6 --- /dev/null +++ b/mobile_agent/lib/screens/downloads_shared_folders_screen.dart @@ -0,0 +1,483 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:url_launcher/url_launcher.dart'; + +import '../services/github_deep_service.dart'; +import '../services/github_repo_hub_service.dart'; + +const _bg = Color(0xFFF7FAFF); +const _panel = Color(0xFFFFFFFF); +const _line = Color(0xFFDDE7F7); +const _text = Color(0xFF0B1020); +const _muted = Color(0xFF536079); +const _faint = Color(0xFF8B97AD); +const _blue = Color(0xFF2555FF); +const _mint = Color(0xFF0B9B7E); +const _violet = Color(0xFF7557E8); +const _rose = Color(0xFFE0526E); + +class DownloadsSharedFoldersScreen extends StatefulWidget { + const DownloadsSharedFoldersScreen({super.key}); + + @override + State createState() => _DownloadsSharedFoldersScreenState(); +} + +class _DownloadsSharedFoldersScreenState extends State { + late final GitHubRepoHubService _hub; + late Future<_DownloadsSharedFoldersData> _data; + + @override + void initState() { + super.initState(); + _hub = GitHubRepoHubService(GitHubDeepService()); + _data = _load(); + } + + Future<_DownloadsSharedFoldersData> _load() async { + final downloads = await _hub.loadArtifactDownloads(); + final sharedFolders = await _hub.loadRuntimeWorkspaceSyncs(); + return _DownloadsSharedFoldersData( + downloads: downloads, + sharedFolders: sharedFolders, + ); + } + + void _refresh() { + setState(() { + _data = _load(); + }); + } + + Future _copy(String value, String label) async { + await Clipboard.setData(ClipboardData(text: value)); + if (!mounted) return; + _toast('$label copied.'); + } + + Future _openPath(String path, String label) async { + final opened = await launchUrl(Uri.file(path), mode: LaunchMode.externalApplication); + if (!mounted) return; + if (opened) { + _toast('Opened $label.'); + } else { + await Clipboard.setData(ClipboardData(text: path)); + _toast('Could not open $label directly. Path copied.', isError: true); + } + } + + void _toast(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? _rose : null, + ), + ); + } + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 2, + child: Scaffold( + backgroundColor: _bg, + appBar: AppBar( + title: const Text('Downloads / Shared folders'), + bottom: const TabBar( + tabs: [ + Tab(icon: Icon(Icons.archive_outlined), text: 'Downloads'), + Tab(icon: Icon(Icons.folder_shared_outlined), text: 'Shared folders'), + ], + ), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: _refresh, + icon: const Icon(Icons.refresh_outlined), + ), + ], + ), + body: FutureBuilder<_DownloadsSharedFoldersData>( + future: _data, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.all(16), + child: _LibraryPanel( + borderColor: _rose, + child: Text(_compact(snapshot.error.toString(), 180), style: const TextStyle(color: _rose, height: 1.35)), + ), + ); + } + final data = snapshot.requireData; + return TabBarView( + children: [ + _DownloadsTab( + records: data.downloads, + onOpen: (record) => unawaited(_openPath(record.path, 'artifact')), + onOpenFolder: (record) => unawaited(_openPath(p.dirname(record.path), 'artifact folder')), + onCopy: (record) => unawaited(_copy(record.path, 'Artifact path')), + ), + _SharedFoldersTab( + records: data.sharedFolders, + onOpen: (record) => unawaited(_openPath(record.sharedPath, 'shared folder')), + onCopyShared: (record) => unawaited(_copy(record.sharedPath, 'Shared folder path')), + onCopyRuntime: (record) => unawaited(_copy(record.runtimePath, 'Runtime path')), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _DownloadsSharedFoldersData { + const _DownloadsSharedFoldersData({ + required this.downloads, + required this.sharedFolders, + }); + + final List downloads; + final List sharedFolders; +} + +class _DownloadsTab extends StatelessWidget { + const _DownloadsTab({ + required this.records, + required this.onOpen, + required this.onOpenFolder, + required this.onCopy, + }); + + final List records; + final ValueChanged onOpen; + final ValueChanged onOpenFolder; + final ValueChanged onCopy; + + @override + Widget build(BuildContext context) { + if (records.isEmpty) { + return const _EmptyLibraryState( + icon: Icons.archive_outlined, + title: 'No downloads yet', + detail: 'GitHub Actions artifacts downloaded from Repo Hub will appear here.', + ); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 22), + itemCount: records.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final record = records[index]; + return _LibraryPanel( + child: _DownloadRecordTile( + record: record, + onOpen: () => onOpen(record), + onOpenFolder: () => onOpenFolder(record), + onCopy: () => onCopy(record), + ), + ); + }, + ); + } +} + +class _SharedFoldersTab extends StatelessWidget { + const _SharedFoldersTab({ + required this.records, + required this.onOpen, + required this.onCopyShared, + required this.onCopyRuntime, + }); + + final List records; + final ValueChanged onOpen; + final ValueChanged onCopyShared; + final ValueChanged onCopyRuntime; + + @override + Widget build(BuildContext context) { + if (records.isEmpty) { + return const _EmptyLibraryState( + icon: Icons.folder_shared_outlined, + title: 'No shared folders yet', + detail: 'Use Repo Hub -> Runtime 文件 -> 同步到共享目录 to create phone-file-manager friendly copies.', + ); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 22), + itemCount: records.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final record = records[index]; + return _LibraryPanel( + child: _SharedFolderRecordTile( + record: record, + onOpen: () => onOpen(record), + onCopyShared: () => onCopyShared(record), + onCopyRuntime: () => onCopyRuntime(record), + ), + ); + }, + ); + } +} + +class _DownloadRecordTile extends StatelessWidget { + const _DownloadRecordTile({ + required this.record, + required this.onOpen, + required this.onOpenFolder, + required this.onCopy, + }); + + final GitHubArtifactDownloadRecord record; + final VoidCallback onOpen; + final VoidCallback onOpenFolder; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const _RecordIcon(icon: Icons.archive_outlined, color: _violet), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(record.artifactName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 4), + Text(record.repoFullName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _muted, fontSize: 12, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text( + '${_timeAgo(record.downloadedAt)} · ${record.sizeBytes == null ? _compact(record.path, 80) : '${_bytesLabel(record.sizeBytes!)} · ${_compact(record.path, 70)}'}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _faint, fontSize: 11, height: 1.25), + ), + ], + ), + ), + _PathActions(onOpen: onOpen, onOpenFolder: onOpenFolder, onCopy: onCopy), + ], + ); + } +} + +class _SharedFolderRecordTile extends StatelessWidget { + const _SharedFolderRecordTile({ + required this.record, + required this.onOpen, + required this.onCopyShared, + required this.onCopyRuntime, + }); + + final GitHubRuntimeWorkspaceSyncRecord record; + final VoidCallback onOpen; + final VoidCallback onCopyShared; + final VoidCallback onCopyRuntime; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const _RecordIcon(icon: Icons.folder_shared_outlined, color: _mint), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(record.repoFullName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 4), + Text('${_timeAgo(record.syncedAt)} · shared copy', style: const TextStyle(color: _muted, fontSize: 12, fontWeight: FontWeight.w700)), + ], + ), + ), + _PathActions(onOpen: onOpen, onOpenFolder: onOpen, onCopy: onCopyShared), + ], + ), + const SizedBox(height: 10), + _PathLine(label: 'Shared', value: record.sharedPath, color: _mint), + const SizedBox(height: 6), + _PathLine(label: 'Runtime', value: record.runtimePath, color: _blue, onCopy: onCopyRuntime), + ], + ); + } +} + +class _PathActions extends StatelessWidget { + const _PathActions({ + required this.onOpen, + required this.onOpenFolder, + required this.onCopy, + }); + + final VoidCallback onOpen; + final VoidCallback onOpenFolder; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 2, + children: [ + IconButton(tooltip: 'Open', visualDensity: VisualDensity.compact, onPressed: onOpen, icon: const Icon(Icons.open_in_new_outlined, color: _blue, size: 19)), + IconButton(tooltip: 'Open folder', visualDensity: VisualDensity.compact, onPressed: onOpenFolder, icon: const Icon(Icons.folder_open_outlined, color: _mint, size: 19)), + IconButton(tooltip: 'Copy path', visualDensity: VisualDensity.compact, onPressed: onCopy, icon: const Icon(Icons.copy_outlined, color: _faint, size: 19)), + ], + ); + } +} + +class _PathLine extends StatelessWidget { + const _PathLine({ + required this.label, + required this.value, + required this.color, + this.onCopy, + }); + + final String label; + final String value; + final Color color; + final VoidCallback? onCopy; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.16)), + ), + child: Row( + children: [ + Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w900)), + const SizedBox(width: 8), + Expanded(child: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _muted, fontSize: 11))), + if (onCopy != null) + IconButton(tooltip: 'Copy runtime path', visualDensity: VisualDensity.compact, onPressed: onCopy, icon: const Icon(Icons.copy_outlined, size: 16)), + ], + ), + ); + } +} + +class _RecordIcon extends StatelessWidget { + const _RecordIcon({required this.icon, required this.color}); + + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.22)), + ), + child: Icon(icon, color: color, size: 21), + ); + } +} + +class _EmptyLibraryState extends StatelessWidget { + const _EmptyLibraryState({ + required this.icon, + required this.title, + required this.detail, + }); + + final IconData icon; + final String title; + final String detail; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(22), + child: _LibraryPanel( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: _faint, size: 38), + const SizedBox(height: 12), + Text(title, textAlign: TextAlign.center, style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 16)), + const SizedBox(height: 6), + Text(detail, textAlign: TextAlign.center, style: const TextStyle(color: _muted, height: 1.35)), + ], + ), + ), + ), + ); + } +} + +class _LibraryPanel extends StatelessWidget { + const _LibraryPanel({ + required this.child, + this.borderColor = _line, + }); + + final Widget child; + final Color borderColor; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _panel, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor), + boxShadow: const [ + BoxShadow( + color: Color(0x0A2555FF), + blurRadius: 16, + offset: Offset(0, 8), + ), + ], + ), + child: child, + ); + } +} + +String _timeAgo(DateTime value) { + final diff = DateTime.now().difference(value); + if (diff.inDays >= 1) return '${diff.inDays}d ago'; + if (diff.inHours >= 1) return '${diff.inHours}h ago'; + if (diff.inMinutes >= 1) return '${diff.inMinutes}m ago'; + return 'just now'; +} + +String _bytesLabel(int bytes) { + if (bytes >= 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + if (bytes >= 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '$bytes B'; +} + +String _compact(String value, int limit) { + final singleLine = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (singleLine.length <= limit) return singleLine; + return '${singleLine.substring(0, limit - 1)}…'; +} diff --git a/mobile_agent/lib/screens/editor_screen.dart b/mobile_agent/lib/screens/editor_screen.dart index 4f22cf9..5b76ca9 100644 --- a/mobile_agent/lib/screens/editor_screen.dart +++ b/mobile_agent/lib/screens/editor_screen.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; import '../core/theme.dart'; import '../core/constants.dart'; import '../widgets/ai_chat_panel.dart'; @@ -193,7 +195,7 @@ class SyntaxHighlighter { pos += tagM.group(0)!.length; continue; } - final attrM = RegExp(r'\b([a-zA-Z-]+)=\s*["\']').matchAsPrefix(line, pos); + final attrM = RegExp(r'''\b([a-zA-Z-]+)=\s*["']''').matchAsPrefix(line, pos); if (attrM != null) { spans.add(_span(attrM.group(0)!, AppTheme.codeFunction)); pos += attrM.group(0)!.length; @@ -316,11 +318,37 @@ class _EditorScreenState extends State { _editorScroll.addListener(() { if (_lineScroll.hasClients) _lineScroll.jumpTo(_editorScroll.offset); }); - if (widget.initialFilePath != null || widget.fileName != null) { - _open(widget.initialFilePath ?? 'untitled', widget.fileName ?? 'untitled', - widget.initialContent ?? '', widget.language, widget.readOnly); - } else { - _open('untitled', 'untitled', ''); + unawaited(_openInitialFile()); + } + + Future _openInitialFile() async { + final filePath = widget.initialFilePath; + final fileName = widget.fileName ?? (filePath == null ? 'untitled' : p.basename(filePath)); + var content = widget.initialContent; + Object? readError; + if (content == null && filePath != null && filePath != 'untitled') { + try { + final file = File(filePath); + final exists = await file.exists(); + content = exists ? await file.readAsString() : ''; + } on Object catch (error) { + readError = error; + content = ''; + } + } + if (!mounted) return; + _open(filePath ?? 'untitled', fileName, content ?? '', widget.language, widget.readOnly); + if (readError != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Could not read "$fileName": $readError'), + behavior: SnackBarBehavior.floating, + backgroundColor: AppTheme.error, + ), + ); + }); } } @@ -404,7 +432,7 @@ class _EditorScreenState extends State { context: context, position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx + 100, pos.dy), color: AppTheme.surface, - items: [ + items: >[ _menuItem('Close', () => _close(i)), _menuItem('Close Others', () => _closeOthers(i)), _menuItem('Close All', _closeAll), @@ -413,7 +441,7 @@ class _EditorScreenState extends State { ], ); - PopupMenuItem _menuItem(String label, VoidCallback onTap) => PopupMenuItem( + PopupMenuItem _menuItem(String label, VoidCallback onTap) => PopupMenuItem( onTap: () => Future.delayed(const Duration(milliseconds: 50), onTap), child: Text(label, style: const TextStyle(color: AppTheme.textPrimary)), ); @@ -613,14 +641,40 @@ class _EditorScreenState extends State { // ── Save ──────────────────────────────────────────────────────────── void _save() { + unawaited(_saveActiveFile()); + } + + Future _saveActiveFile() async { final t = _active; if (t == null) return; - setState(() { t.originalContent = t.controller.text; t.isModified = false; }); - HapticFeedback.mediumImpact(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('"${t.fileName}" saved'), duration: const Duration(seconds: 1), - behavior: SnackBarBehavior.floating, backgroundColor: AppTheme.surfaceHover, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - ); + if (t.readOnly) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('"${t.fileName}" is read-only'), behavior: SnackBarBehavior.floating), + ); + return; + } + try { + if (t.filePath.trim().isEmpty || t.filePath == 'untitled') { + throw StateError('This tab has no phone file path yet.'); + } + await File(t.filePath).writeAsString(t.controller.text); + if (!mounted) return; + setState(() { t.originalContent = t.controller.text; t.isModified = false; }); + HapticFeedback.mediumImpact(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('"${t.fileName}" saved to phone'), duration: const Duration(seconds: 1), + behavior: SnackBarBehavior.floating, backgroundColor: AppTheme.surfaceHover, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + ); + } on Object catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Save failed: $error'), + behavior: SnackBarBehavior.floating, + backgroundColor: AppTheme.error, + ), + ); + } } // ── AI Panel ──────────────────────────────────────────────────────── @@ -663,8 +717,17 @@ class _EditorScreenState extends State { decoration: BoxDecoration(color: AppTheme.backgroundElevated, border: Border(bottom: BorderSide(color: AppTheme.divider.withOpacity(0.8)))), child: Row(children: [ - const SizedBox(width: 40, child: Center( - child: Icon(Icons.folder_open_outlined, size: 18, color: AppTheme.textTertiary))), + SizedBox(width: 42, child: Center( + child: Tooltip( + message: '返回聊天', + child: IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back_rounded, size: 19, color: AppTheme.textSecondary), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 36, height: 36), + ), + ), + )), Expanded(child: ListView.builder(controller: _tabScroll, scrollDirection: Axis.horizontal, itemCount: _tabs.length, itemBuilder: (c, i) => _tabItem(i))), _td(), @@ -714,21 +777,21 @@ class _EditorScreenState extends State { context: context, position: RelativeRect.fromLTRB(MediaQuery.of(context).size.width - 40, 100, 0, 0), color: AppTheme.surface, - items: [ + items: >[ _mi(Icons.format_indent_increase, 'Format Code', _format), _mi(Icons.visibility, _showLineNumbers ? 'Hide Line Numbers' : 'Show Line Numbers', () => setState(() => _showLineNumbers = !_showLineNumbers)), _mi(Icons.format_size, 'Font Size', _showFontDlg), const PopupMenuDivider(), _mi(Icons.save, 'Save', _save), - PopupMenuItem( + PopupMenuItem( onTap: () => Future.delayed(const Duration(milliseconds: 100), _closeAll), child: const Row(children: [Icon(Icons.close, size: 18, color: AppTheme.error), SizedBox(width: 10), Text('Close All Tabs', style: TextStyle(color: AppTheme.error))])), ], ); - PopupMenuItem _mi(IconData icon, String label, VoidCallback fn) => PopupMenuItem( + PopupMenuItem _mi(IconData icon, String label, VoidCallback fn) => PopupMenuItem( onTap: () => Future.delayed(const Duration(milliseconds: 100), fn), child: Row(children: [Icon(icon, size: 18, color: AppTheme.textSecondary), const SizedBox(width: 10), Text(label, style: const TextStyle(color: AppTheme.textPrimary))]), diff --git a/mobile_agent/lib/screens/feature_flags_screen.dart b/mobile_agent/lib/screens/feature_flags_screen.dart index 19ae8c2..7b8f222 100644 --- a/mobile_agent/lib/screens/feature_flags_screen.dart +++ b/mobile_agent/lib/screens/feature_flags_screen.dart @@ -1,721 +1,725 @@ -// lib/screens/feature_flags_screen.dart -// Feature Flags Screen - Organized by category with toggle switches -// 功能开关设置界面 - -import 'package:flutter/material.dart'; - -import '../core/theme.dart'; -import '../services/feature_flags_service.dart'; - -// ═══════════════════════════════════════════════════════════════════════════ -// Feature Flags Screen -// ═══════════════════════════════════════════════════════════════════════════ - -/// Feature Flags Settings Screen -/// -/// Organized by category: -/// - 🧪 实验功能 (Experimental) -/// - ⚙️ 高级功能 (Advanced) -/// - 👥 团队功能 (Team) -/// - 🎨 显示设置 (Display) -/// - 🔒 核心功能 (Core - not toggleable) -/// -/// Each feature: icon + name + description + toggle switch -/// Experimental features show ⚠️ badge -class FeatureFlagsScreen extends StatefulWidget { - final FeatureFlagsService featureFlags; - - const FeatureFlagsScreen({super.key, required this.featureFlags}); - - @override - State createState() => _FeatureFlagsScreenState(); -} - -class _FeatureFlagsScreenState extends State { - bool _isLoading = false; - - void _setLoading(bool v) => setState(() => _isLoading = v); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.background, - appBar: AppBar( - backgroundColor: AppTheme.background.withOpacity(0.8), - elevation: 0, - centerTitle: true, - title: const Text( - '功能开关', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 18, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, size: 20, color: AppTheme.textSecondary), - onPressed: () => Navigator.of(context).pop(), - ), - actions: [ - // Reset button - TextButton( - onPressed: _showResetDialog, - child: const Text( - '重置', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 13, - color: AppTheme.textSecondary, - ), - ), - ), - ], - ), - body: AnimatedBuilder( - animation: widget.featureFlags, - builder: (context, _) { - final featuresByCategory = widget.featureFlags.featuresByCategory; - - if (featuresByCategory.isEmpty) { - return _buildEmptyState(); - } - - return Stack( - children: [ - ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - itemCount: featuresByCategory.length, - itemBuilder: (context, categoryIndex) { - final entry = featuresByCategory.entries.elementAt(categoryIndex); - return _buildCategorySection( - category: entry.key, - features: entry.value, - isLast: categoryIndex == featuresByCategory.length - 1, - ); - }, - ), - if (_isLoading) - Container( - color: Colors.black.withOpacity(0.3), - child: const Center( - child: CircularProgressIndicator(color: AppTheme.primary), - ), - ), - ], - ); - }, - ), - ); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Category Section - // ═══════════════════════════════════════════════════════════════════════ - - Widget _buildCategorySection({ - required FeatureCategory category, - required List features, - required bool isLast, - }) { - final categoryColor = _getCategoryColor(category); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Category header - Padding( - padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), - child: Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: categoryColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - category.emoji, - style: const TextStyle(fontSize: 16), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category.displayName, - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 1), - Text( - category.displayNameEn, - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 11, - color: AppTheme.textTertiary, - ), - ), - ], - ), - ), - // Feature count badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: categoryColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${features.length}', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 12, - fontWeight: FontWeight.w600, - color: categoryColor, - ), - ), - ), - ], - ), - ), - - // Features list - Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.border), - ), - child: Column( - children: [ - for (var i = 0; i < features.length; i++) ...[ - _buildFeatureTile( - feature: features[i], - categoryColor: categoryColor, - ), - if (i < features.length - 1) - const Divider( - height: 1, - indent: 56, - endIndent: 16, - color: AppTheme.divider, - ), - ], - ], - ), - ), - - if (!isLast) const SizedBox(height: 8), - ], - ); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Feature Tile - // ═══════════════════════════════════════════════════════════════════════ - - Widget _buildFeatureTile({ - required FeatureFlag feature, - required Color categoryColor, - }) { - final isExperimental = feature.isExperimental; - final isCore = feature.isCore; - final requiresPermission = feature.requiresPermission; - final permissionType = feature.permissionType; - - return InkWell( - onTap: isCore ? null : () => _toggleFeature(feature), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Icon - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: isCore - ? categoryColor.withOpacity(0.1) - : (feature.value ? categoryColor.withOpacity(0.2) : AppTheme.surfaceHover), - borderRadius: BorderRadius.circular(9), - ), - child: Center( - child: Icon( - _getFeatureIcon(feature.id), - size: 18, - color: isCore - ? categoryColor.withOpacity(0.6) - : (feature.value ? categoryColor : AppTheme.textDisabled), - ), - ), - ), - const SizedBox(width: 12), - - // Content - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Name row with badges - Row( - children: [ - Expanded( - child: Text( - feature.name, - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - color: isCore ? AppTheme.textSecondary : AppTheme.textPrimary, - ), - ), - ), - if (isExperimental && !isCore) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.15), - borderRadius: BorderRadius.circular(5), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.warning_amber, - size: 10, - color: AppTheme.warning, - ), - const SizedBox(width: 2), - Text( - '实验', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 9, - fontWeight: FontWeight.w600, - color: AppTheme.warning, - ), - ), - ], - ), - ), - ], - if (isCore) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.textDisabled.withOpacity(0.15), - borderRadius: BorderRadius.circular(5), - ), - child: Text( - '核心', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 9, - fontWeight: FontWeight.w600, - color: AppTheme.textDisabled, - ), - ), - ), - ], - ], - ), - const SizedBox(height: 3), - - // Description - Text( - feature.description, - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 12, - color: isCore - ? AppTheme.textTertiary.withOpacity(0.6) - : AppTheme.textTertiary, - ), - ), - - // Permission hint - if (requiresPermission && !isCore) ...[ - const SizedBox(height: 5), - Row( - children: [ - Icon( - Icons.info_outline, - size: 11, - color: AppTheme.info.withOpacity(0.7), - ), - const SizedBox(width: 4), - Text( - '需要${_getPermissionLabel(permissionType)}权限', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 10, - color: AppTheme.info.withOpacity(0.7), - ), - ), - ], - ), - ], - ], - ), - ), - - const SizedBox(width: 8), - - // Toggle switch (or lock icon for core features) - if (isCore) - Container( - padding: const EdgeInsets.all(4), - child: Icon( - Icons.lock_outline, - size: 16, - color: AppTheme.textDisabled.withOpacity(0.5), - ), - ) - else - SizedBox( - height: 32, - child: FittedBox( - fit: BoxFit.contain, - child: Switch.adaptive( - value: feature.value, - onChanged: (_) => _toggleFeature(feature), - activeColor: categoryColor, - activeTrackColor: categoryColor.withOpacity(0.3), - inactiveThumbColor: AppTheme.textDisabled, - inactiveTrackColor: AppTheme.textDisabled.withOpacity(0.2), - ), - ), - ), - ], - ), - ), - ); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Actions - // ═══════════════════════════════════════════════════════════════════════ - - Future _toggleFeature(FeatureFlag feature) async { - if (feature.isCore) return; - - _setLoading(true); - try { - await widget.featureFlags.toggle(feature.id); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('切换失败: $e'), - backgroundColor: AppTheme.error, - behavior: SnackBarBehavior.floating, - ), - ); - } - } finally { - _setLoading(false); - } - } - - void _showResetDialog() { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: const BorderSide(color: AppTheme.border), - ), - title: const Text( - '重置功能开关', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 18, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary, - ), - ), - content: Text( - '确定要将所有功能开关恢复为默认值吗?', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 14, - color: AppTheme.textSecondary, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text( - '取消', - style: TextStyle( - fontFamily: AppTheme.fontBody, - color: AppTheme.textSecondary, - ), - ), - ), - ElevatedButton( - onPressed: () async { - Navigator.of(context).pop(); - _setLoading(true); - await widget.featureFlags.resetToDefaults(); - _setLoading(false); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('已恢复默认值'), - backgroundColor: AppTheme.success, - behavior: SnackBarBehavior.floating, - ), - ); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.error, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: const Text( - '重置', - style: TextStyle(fontFamily: AppTheme.fontBody), - ), - ), - ], - ), - ); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Helpers - // ═══════════════════════════════════════════════════════════════════════ - - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.toggle_off_outlined, - size: 64, - color: AppTheme.textDisabled.withOpacity(0.5), - ), - const SizedBox(height: 16), - Text( - '暂无功能开关', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textTertiary, - ), - ), - const SizedBox(height: 6), - Text( - '功能开关将在后续版本中添加', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 13, - color: AppTheme.textTertiary.withOpacity(0.7), - ), - ), - ], - ), - ); - } - - Color _getCategoryColor(FeatureCategory category) { - switch (category) { - case FeatureCategory.experimental: - return AppTheme.warning; - case FeatureCategory.advanced: - return AppTheme.accent; - case FeatureCategory.team: - return AppTheme.info; - case FeatureCategory.display: - return AppTheme.primary; - case FeatureCategory.core: - return AppTheme.textSecondary; - } - } - - IconData _getFeatureIcon(String featureId) { - switch (featureId) { - case 'voice_to_code': - return Icons.mic; - case 'screenshot_to_code': - return Icons.camera_alt; - case 'terminal': - return Icons.terminal; - case 'github_pages_deploy': - return Icons.rocket_launch; - case 'team_collaboration': - return Icons.groups; - case 'offline_ai': - return Icons.cloud_off; - case 'agent_multi_step': - return Icons.psychology; - case 'code_minimap': - return Icons.map; - case 'live_collaboration': - return Icons.group; - case 'wechat_publish': - return Icons.wechat; - case 'advanced_ai_settings': - return Icons.tune; - case 'breadcrumbs': - return Icons.account_tree; - case 'zen_mode': - return Icons.spa; - case 'ai_chat': - return Icons.chat_bubble; - case 'code_editor': - return Icons.code; - case 'file_manager': - return Icons.folder; - case 'github_integration': - return Icons.code; - default: - return Icons.toggle_on; - } - } - - String _getPermissionLabel(String? permissionType) { - switch (permissionType) { - case 'microphone': - return '麦克风'; - case 'camera': - return '相机'; - case 'storage': - return '存储'; - default: - return ''; - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Feature Flag Quick Toggle Widget (for embedding in other screens) -// ═══════════════════════════════════════════════════════════════════════════ - -/// A compact widget that shows a single feature flag toggle. -/// Can be embedded in settings or tool panels. -class FeatureQuickToggle extends StatelessWidget { - final FeatureFlag feature; - final ValueChanged? onChanged; - - const FeatureQuickToggle({ - super.key, - required this.feature, - this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - dense: true, - leading: Icon( - _getIcon(feature.id), - size: 20, - color: feature.value ? AppTheme.primary : AppTheme.textTertiary, - ), - title: Text( - feature.name, - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 14, - color: AppTheme.textPrimary, - ), - ), - trailing: Switch.adaptive( - value: feature.value, - onChanged: feature.isCore ? null : onChanged, - activeColor: AppTheme.primary, - ), - ); - } - - IconData _getIcon(String featureId) { - switch (featureId) { - case 'voice_to_code': - return Icons.mic; - case 'screenshot_to_code': - return Icons.camera_alt; - case 'terminal': - return Icons.terminal; - case 'github_pages_deploy': - return Icons.rocket_launch; - case 'team_collaboration': - return Icons.groups; - case 'offline_ai': - return Icons.cloud_off; - case 'agent_multi_step': - return Icons.psychology; - case 'code_minimap': - return Icons.map; - case 'live_collaboration': - return Icons.group; - case 'wechat_publish': - return Icons.wechat; - case 'advanced_ai_settings': - return Icons.tune; - case 'breadcrumbs': - return Icons.account_tree; - case 'zen_mode': - return Icons.spa; - default: - return Icons.toggle_on; - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Feature Badge Widget (for showing feature status indicators) -// ═══════════════════════════════════════════════════════════════════════════ - -/// A small badge that indicates a feature's experimental status. -class ExperimentalBadge extends StatelessWidget { - final double fontSize; - - const ExperimentalBadge({super.key, this.fontSize = 9}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.warning.withOpacity(0.15), - borderRadius: BorderRadius.circular(5), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.warning_amber, size: fontSize + 1, color: AppTheme.warning), - const SizedBox(width: 2), - Text( - '实验功能', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: fontSize, - fontWeight: FontWeight.w600, - color: AppTheme.warning, - ), - ), - ], - ), - ); - } -} +// lib/screens/feature_flags_screen.dart +// Feature Flags Screen - Organized by category with toggle switches +// 功能开关设置界面 + +import 'package:flutter/material.dart'; + +import '../core/theme.dart'; +import '../services/feature_flags_service.dart'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Feature Flags Screen +// ═══════════════════════════════════════════════════════════════════════════ + +/// Feature Flags Settings Screen +/// +/// Organized by category: +/// - 🧪 实验功能 (Experimental) +/// - ⚙️ 高级功能 (Advanced) +/// - 👥 团队功能 (Team) +/// - 🎨 显示设置 (Display) +/// - 🔒 核心功能 (Core - not toggleable) +/// +/// Each feature: icon + name + description + toggle switch +/// Experimental features show ⚠️ badge +class FeatureFlagsScreen extends StatefulWidget { + final FeatureFlagsService featureFlags; + + const FeatureFlagsScreen({super.key, required this.featureFlags}); + + @override + State createState() => _FeatureFlagsScreenState(); +} + +class _FeatureFlagsScreenState extends State { + bool _isLoading = false; + + void _setLoading(bool v) => setState(() => _isLoading = v); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: AppTheme.background.withOpacity(0.8), + elevation: 0, + centerTitle: true, + title: const Text( + '功能开关', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 20, color: AppTheme.textSecondary), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + // Reset button + TextButton( + onPressed: _showResetDialog, + child: const Text( + '重置', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + body: AnimatedBuilder( + animation: widget.featureFlags, + builder: (context, _) { + final featuresByCategory = widget.featureFlags.featuresByCategory; + + if (featuresByCategory.isEmpty) { + return _buildEmptyState(); + } + + return Stack( + children: [ + ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: featuresByCategory.length, + itemBuilder: (context, categoryIndex) { + final entry = featuresByCategory.entries.elementAt(categoryIndex); + return _buildCategorySection( + category: entry.key, + features: entry.value, + isLast: categoryIndex == featuresByCategory.length - 1, + ); + }, + ), + if (_isLoading) + Container( + color: Colors.black.withOpacity(0.3), + child: const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ), + ), + ], + ); + }, + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Category Section + // ═══════════════════════════════════════════════════════════════════════ + + Widget _buildCategorySection({ + required FeatureCategory category, + required List features, + required bool isLast, + }) { + final categoryColor = _getCategoryColor(category); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category header + Padding( + padding: const EdgeInsets.fromLTRB(4, 16, 4, 8), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: categoryColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + category.emoji, + style: const TextStyle(fontSize: 16), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.displayName, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 1), + Text( + category.displayNameEn, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 11, + color: AppTheme.textTertiary, + ), + ), + ], + ), + ), + // Feature count badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: categoryColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${features.length}', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + fontWeight: FontWeight.w600, + color: categoryColor, + ), + ), + ), + ], + ), + ), + + // Features list + Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.border), + ), + child: Column( + children: [ + for (var i = 0; i < features.length; i++) ...[ + _buildFeatureTile( + feature: features[i], + categoryColor: categoryColor, + ), + if (i < features.length - 1) + const Divider( + height: 1, + indent: 56, + endIndent: 16, + color: AppTheme.divider, + ), + ], + ], + ), + ), + + if (!isLast) const SizedBox(height: 8), + ], + ); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Feature Tile + // ═══════════════════════════════════════════════════════════════════════ + + Widget _buildFeatureTile({ + required FeatureFlag feature, + required Color categoryColor, + }) { + final isExperimental = feature.isExperimental; + final isCore = feature.isCore; + final requiresPermission = feature.requiresPermission; + final permissionType = feature.permissionType; + + return InkWell( + onTap: isCore ? null : () => _toggleFeature(feature), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Icon + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isCore + ? categoryColor.withOpacity(0.1) + : (feature.value ? categoryColor.withOpacity(0.2) : AppTheme.surfaceHover), + borderRadius: BorderRadius.circular(9), + ), + child: Center( + child: Icon( + _getFeatureIcon(feature.id), + size: 18, + color: isCore + ? categoryColor.withOpacity(0.6) + : (feature.value ? categoryColor : AppTheme.textDisabled), + ), + ), + ), + const SizedBox(width: 12), + + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name row with badges + Row( + children: [ + Expanded( + child: Text( + feature.name, + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + color: isCore ? AppTheme.textSecondary : AppTheme.textPrimary, + ), + ), + ), + if (isExperimental && !isCore) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.warning.withOpacity(0.15), + borderRadius: BorderRadius.circular(5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning_amber, + size: 10, + color: AppTheme.warning, + ), + const SizedBox(width: 2), + Text( + '实验', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppTheme.warning, + ), + ), + ], + ), + ), + ], + if (isCore) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.textDisabled.withOpacity(0.15), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + '核心', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 9, + fontWeight: FontWeight.w600, + color: AppTheme.textDisabled, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 3), + + // Description + Text( + feature.description, + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + color: isCore + ? AppTheme.textTertiary.withOpacity(0.6) + : AppTheme.textTertiary, + ), + ), + + // Permission hint + if (requiresPermission && !isCore) ...[ + const SizedBox(height: 5), + Row( + children: [ + Icon( + Icons.info_outline, + size: 11, + color: AppTheme.info.withOpacity(0.7), + ), + const SizedBox(width: 4), + Text( + '需要${_getPermissionLabel(permissionType)}权限', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 10, + color: AppTheme.info.withOpacity(0.7), + ), + ), + ], + ), + ], + ], + ), + ), + + const SizedBox(width: 8), + + // Toggle switch (or lock icon for core features) + if (isCore) + Container( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.lock_outline, + size: 16, + color: AppTheme.textDisabled.withOpacity(0.5), + ), + ) + else + SizedBox( + height: 32, + child: FittedBox( + fit: BoxFit.contain, + child: Switch.adaptive( + value: feature.value, + onChanged: (_) => _toggleFeature(feature), + activeColor: categoryColor, + activeTrackColor: categoryColor.withOpacity(0.3), + inactiveThumbColor: AppTheme.textDisabled, + inactiveTrackColor: AppTheme.textDisabled.withOpacity(0.2), + ), + ), + ), + ], + ), + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Actions + // ═══════════════════════════════════════════════════════════════════════ + + Future _toggleFeature(FeatureFlag feature) async { + if (feature.isCore) return; + + _setLoading(true); + try { + await widget.featureFlags.toggle(feature.id); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('切换失败: $e'), + backgroundColor: AppTheme.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } finally { + _setLoading(false); + } + } + + void _showResetDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: AppTheme.border), + ), + title: const Text( + '重置功能开关', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + content: Text( + '确定要将所有功能开关恢复为默认值吗?', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + color: AppTheme.textSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + '取消', + style: TextStyle( + fontFamily: AppTheme.fontBody, + color: AppTheme.textSecondary, + ), + ), + ), + ElevatedButton( + onPressed: () async { + Navigator.of(context).pop(); + _setLoading(true); + await widget.featureFlags.resetToDefaults(); + _setLoading(false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已恢复默认值'), + backgroundColor: AppTheme.success, + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.error, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text( + '重置', + style: TextStyle(fontFamily: AppTheme.fontBody), + ), + ), + ], + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Helpers + // ═══════════════════════════════════════════════════════════════════════ + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.toggle_off_outlined, + size: 64, + color: AppTheme.textDisabled.withOpacity(0.5), + ), + const SizedBox(height: 16), + Text( + '暂无功能开关', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textTertiary, + ), + ), + const SizedBox(height: 6), + Text( + '功能开关将在后续版本中添加', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + color: AppTheme.textTertiary.withOpacity(0.7), + ), + ), + ], + ), + ); + } + + Color _getCategoryColor(FeatureCategory category) { + switch (category) { + case FeatureCategory.experimental: + return AppTheme.warning; + case FeatureCategory.advanced: + return AppTheme.accent; + case FeatureCategory.team: + return AppTheme.info; + case FeatureCategory.display: + return AppTheme.primary; + case FeatureCategory.core: + return AppTheme.textSecondary; + } + } + + IconData _getFeatureIcon(String featureId) { + switch (featureId) { + case 'voice_to_code': + return Icons.mic; + case 'screenshot_to_code': + return Icons.camera_alt; + case 'terminal': + return Icons.terminal; + case 'github_pages_deploy': + return Icons.rocket_launch; + case 'lark_cli': + return Icons.business_center; + case 'team_collaboration': + return Icons.groups; + case 'offline_ai': + return Icons.cloud_off; + case 'agent_multi_step': + return Icons.psychology; + case 'code_minimap': + return Icons.map; + case 'live_collaboration': + return Icons.group; + case 'wechat_publish': + return Icons.wechat; + case 'advanced_ai_settings': + return Icons.tune; + case 'breadcrumbs': + return Icons.account_tree; + case 'zen_mode': + return Icons.spa; + case 'ai_chat': + return Icons.chat_bubble; + case 'code_editor': + return Icons.code; + case 'file_manager': + return Icons.folder; + case 'github_integration': + return Icons.code; + default: + return Icons.toggle_on; + } + } + + String _getPermissionLabel(String? permissionType) { + switch (permissionType) { + case 'microphone': + return '麦克风'; + case 'camera': + return '相机'; + case 'storage': + return '存储'; + default: + return ''; + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Feature Flag Quick Toggle Widget (for embedding in other screens) +// ═══════════════════════════════════════════════════════════════════════════ + +/// A compact widget that shows a single feature flag toggle. +/// Can be embedded in settings or tool panels. +class FeatureQuickToggle extends StatelessWidget { + final FeatureFlag feature; + final ValueChanged? onChanged; + + const FeatureQuickToggle({ + super.key, + required this.feature, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + dense: true, + leading: Icon( + _getIcon(feature.id), + size: 20, + color: feature.value ? AppTheme.primary : AppTheme.textTertiary, + ), + title: Text( + feature.name, + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + color: AppTheme.textPrimary, + ), + ), + trailing: Switch.adaptive( + value: feature.value, + onChanged: feature.isCore ? null : onChanged, + activeColor: AppTheme.primary, + ), + ); + } + + IconData _getIcon(String featureId) { + switch (featureId) { + case 'voice_to_code': + return Icons.mic; + case 'screenshot_to_code': + return Icons.camera_alt; + case 'terminal': + return Icons.terminal; + case 'github_pages_deploy': + return Icons.rocket_launch; + case 'lark_cli': + return Icons.business_center; + case 'team_collaboration': + return Icons.groups; + case 'offline_ai': + return Icons.cloud_off; + case 'agent_multi_step': + return Icons.psychology; + case 'code_minimap': + return Icons.map; + case 'live_collaboration': + return Icons.group; + case 'wechat_publish': + return Icons.wechat; + case 'advanced_ai_settings': + return Icons.tune; + case 'breadcrumbs': + return Icons.account_tree; + case 'zen_mode': + return Icons.spa; + default: + return Icons.toggle_on; + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Feature Badge Widget (for showing feature status indicators) +// ═══════════════════════════════════════════════════════════════════════════ + +/// A small badge that indicates a feature's experimental status. +class ExperimentalBadge extends StatelessWidget { + final double fontSize; + + const ExperimentalBadge({super.key, this.fontSize = 9}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.warning.withOpacity(0.15), + borderRadius: BorderRadius.circular(5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warning_amber, size: fontSize + 1, color: AppTheme.warning), + const SizedBox(width: 2), + Text( + '实验功能', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: fontSize, + fontWeight: FontWeight.w600, + color: AppTheme.warning, + ), + ), + ], + ), + ); + } +} diff --git a/mobile_agent/lib/screens/github_repo_hub_screen.dart b/mobile_agent/lib/screens/github_repo_hub_screen.dart new file mode 100644 index 0000000..97cddd7 --- /dev/null +++ b/mobile_agent/lib/screens/github_repo_hub_screen.dart @@ -0,0 +1,4003 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:path/path.dart' as p; +import 'package:url_launcher/url_launcher.dart'; + +import 'downloads_shared_folders_screen.dart'; +import '../models/github_repo.dart'; +import '../models/skill_model.dart'; +import '../services/github_deep_service.dart'; +import '../services/github_oauth_flow.dart'; +import '../services/github_repo_hub_service.dart'; +import '../services/memory_service.dart'; +import '../services/mobile_code_helper_provider.dart'; +import '../services/repo_knowledge_digest_service.dart'; +import '../services/role_library_service.dart'; +import '../services/runtime_manager.dart'; +import '../services/runtime_provider.dart'; +import '../services/skill_manager_service.dart'; +import '../services/termux_service.dart'; + +const _bg = Color(0xFFF7FAFF); +const _panel = Color(0xFFFFFFFF); +const _line = Color(0xFFDDE7F7); +const _text = Color(0xFF0B1020); +const _muted = Color(0xFF536079); +const _faint = Color(0xFF8B97AD); +const _blue = Color(0xFF2555FF); +const _mint = Color(0xFF0B9B7E); +const _amber = Color(0xFFB7791F); +const _rose = Color(0xFFE0526E); +const _violet = Color(0xFF7557E8); + +class GitHubRepoChatRequest { + const GitHubRepoChatRequest({ + required this.repoFullName, + required this.repoUrl, + required this.workspaceMode, + required this.prompt, + this.pagesUrl, + this.actionsUrl, + this.workspacePath, + }); + + final String repoFullName; + final String repoUrl; + final String workspaceMode; + final String prompt; + final String? pagesUrl; + final String? actionsUrl; + final String? workspacePath; +} + +class GitHubRepoHubScreen extends StatefulWidget { + const GitHubRepoHubScreen({super.key}); + + @override + State createState() => _GitHubRepoHubScreenState(); +} + +class _GitHubRepoHubScreenState extends State with WidgetsBindingObserver { + late final GitHubDeepService _github; + late final GitHubRepoHubService _hub; + late final RuntimeManager _runtimeManager; + late final SkillManagerService _skillManager; + late final RepoKnowledgeDigestService _repoKnowledge; + final MemoryService _memory = MemoryService(); + final RoleLibraryService _roles = RoleLibraryService.instance; + final _ownerController = TextEditingController(); + final _searchController = TextEditingController(); + + bool _loading = true; + bool _authenticated = false; + bool _hasLoaded = false; + String? _error; + String? _notice; + String _filter = 'all'; + String _languageFilter = 'all'; + String _sort = 'pushed'; + String _source = 'repo'; + List _items = const []; + Set _cloningKeys = const {}; + Set _installingKeys = const {}; + bool _analyzingKnowledge = false; + bool _oauthBusy = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _github = GitHubDeepService(); + _hub = GitHubRepoHubService(_github); + _runtimeManager = RuntimeManager.withExternalTermux(TermuxService()); + _skillManager = SkillManagerService.instance; + _repoKnowledge = RepoKnowledgeDigestService(); + _searchController.addListener(() => setState(() {})); + unawaited(_skillManager.initialize().then((_) { + if (mounted) setState(() {}); + })); + unawaited(_memory.init()); + unawaited(_roles.initialize()); + unawaited(_refresh()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _ownerController.dispose(); + _searchController.dispose(); + _github.dispose(); + unawaited(_runtimeManager.dispose()); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_handlePendingOAuthCallback()); + } + } + + List get _visibleItems { + final query = _searchController.text.trim().toLowerCase(); + return _items.where((item) { + final repo = item.repo; + final matchesQuery = query.isEmpty || + repo.fullName.toLowerCase().contains(query) || + repo.description.toLowerCase().contains(query) || + (repo.language ?? '').toLowerCase().contains(query); + if (!matchesQuery) return false; + if (_languageFilter != 'all' && (repo.language ?? '').toLowerCase() != _languageFilter) { + return false; + } + return switch (_filter) { + 'watched' => item.watched, + 'local' => item.localState.exists, + 'git' => item.localState.hasGit, + 'pages' => repo.hasPages, + _ => true, + }; + }).toList(); + } + + Future _refresh() async { + setState(() { + _loading = true; + _error = null; + _notice = null; + }); + try { + await _hub.initialize(); + final oauthHandled = await _handlePendingOAuthCallback(refreshAfterSuccess: false); + if (oauthHandled) { + await _hub.initialize(); + } + final authenticated = _hub.isAuthenticated; + final owner = _ownerController.text.trim(); + if (_source == 'owner' && owner.isEmpty && !authenticated) { + if (!mounted) return; + setState(() { + _authenticated = false; + _loading = false; + _items = const []; + _hasLoaded = false; + _notice = '输入 GitHub user/org 可以匿名浏览公开仓库;登录后空白输入会加载自己的仓库。'; + }); + return; + } + final items = _source == 'owner' + ? await _hub.loadHubItems( + owner: owner, + sort: _sort, + ) + : await _hub.searchHubItems( + query: owner, + source: _source, + sort: _sort, + ); + if (!mounted) return; + final keepLanguageFilter = _languageFilter == 'all' || + items.any((item) => (item.repo.language ?? '').toLowerCase() == _languageFilter); + setState(() { + _authenticated = authenticated; + _items = items; + _hasLoaded = true; + if (!keepLanguageFilter) _languageFilter = 'all'; + _loading = false; + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _loading = false; + _hasLoaded = true; + _authenticated = _hub.isAuthenticated; + _error = _friendlyGitHubError(error); + }); + } + } + + Future _openLogin() async { + final tokenController = TextEditingController(); + final ok = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))), + builder: (sheetContext) { + var connecting = false; + String? error; + return StatefulBuilder( + builder: (context, setSheetState) { + Future connect() async { + final token = tokenController.text.trim(); + if (token.isEmpty || connecting) return; + setSheetState(() { + connecting = true; + error = null; + }); + final success = await _github.authenticate(token); + if (!sheetContext.mounted) return; + if (success) { + Navigator.of(sheetContext).pop(true); + } else { + setSheetState(() { + connecting = false; + error = 'Token 无法访问 GitHub /user。请确认 token 有效,且没有多余空格。'; + }); + } + } + + return Padding( + padding: EdgeInsets.fromLTRB(20, 14, 20, MediaQuery.of(context).viewInsets.bottom + 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 42, + height: 4, + decoration: BoxDecoration(color: _line, borderRadius: BorderRadius.circular(99)), + ), + ), + const SizedBox(height: 16), + const Text('Add GitHub access', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 18)), + const SizedBox(height: 6), + const Text( + '公开搜索不需要登录;token 只用于加载私有仓库、发布 Pages、触发 Actions、提交文件等账号操作。', + style: TextStyle(color: _muted, height: 1.35), + ), + const SizedBox(height: 14), + TextField( + controller: tokenController, + obscureText: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) => unawaited(connect()), + decoration: const InputDecoration( + labelText: 'GitHub access token', + hintText: 'ghp_... / github_pat_...', + prefixIcon: Icon(Icons.key_outlined), + ), + ), + if (error != null) ...[ + const SizedBox(height: 10), + Text(error!, style: const TextStyle(color: _rose, height: 1.35)), + ], + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: connecting ? null : () => Navigator.of(sheetContext).pop(false), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: connecting ? null : () => unawaited(connect()), + icon: connecting + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.login_outlined), + label: Text(connecting ? 'Connecting' : 'Save access'), + ), + ), + ], + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: connecting || _oauthBusy + ? null + : () { + Navigator.of(sheetContext).pop(false); + unawaited(_launchOAuthFromHub()); + }, + icon: const Icon(Icons.open_in_browser_outlined), + label: Text(GitHubOAuthFlow.actionLabel), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(46), + ), + ), + const SizedBox(height: 8), + Text( + GitHubOAuthFlow.authModeDescription, + style: const TextStyle(color: _faint, fontSize: 12, height: 1.35), + ), + ], + ), + ); + }, + ); + }, + ); + tokenController.dispose(); + if (!mounted) return; + if (ok == true) { + _toast('GitHub access saved.'); + setState(() { + _source = 'owner'; + _ownerController.clear(); + _filter = 'all'; + _languageFilter = 'all'; + }); + await _refresh(); + } + } + + Future _launchOAuthFromHub() async { + if (_oauthBusy) return; + setState(() { + _oauthBusy = true; + _error = null; + _notice = null; + }); + try { + final result = await GitHubOAuthFlow.launchAuthorization(); + if (!mounted) return; + setState(() { + _oauthBusy = false; + if (result.startedOAuth) { + _notice = result.message; + } else { + _error = result.message; + } + }); + } catch (error) { + if (!mounted) return; + setState(() { + _oauthBusy = false; + _error = 'Could not open GitHub: $error'; + }); + } + } + + Future _handlePendingOAuthCallback({bool refreshAfterSuccess = true}) async { + if (_oauthBusy) return false; + final uri = await GitHubOAuthFlow.consumePendingCallbackUri(); + if (uri == null) return false; + if (!mounted) return true; + setState(() { + _oauthBusy = true; + _loading = true; + _error = null; + _notice = 'Completing GitHub OAuth login...'; + }); + final result = await GitHubOAuthFlow.completeCallbackUri(uri, _github); + if (!mounted) return true; + if (result.success) { + setState(() { + _oauthBusy = false; + _authenticated = true; + _source = 'owner'; + _filter = 'all'; + _languageFilter = 'all'; + _ownerController.clear(); + _notice = result.message ?? 'GitHub OAuth login connected.'; + }); + if (refreshAfterSuccess) { + await _refresh(); + } + return true; + } + setState(() { + _oauthBusy = false; + _loading = false; + _error = result.message ?? 'GitHub OAuth login failed.'; + _notice = null; + }); + return true; + } + + Future _setWatched(GitHubRepoHubItem item, bool watched) async { + await _hub.setWatched(item.repo, watched); + final next = []; + for (final current in _items) { + next.add(current.key == item.key + ? GitHubRepoHubItem(repo: current.repo, localState: current.localState, watched: watched) + : current); + } + if (!mounted) return; + setState(() => _items = next); + } + + Future _linkWorkspace(GitHubRepoHubItem item) async { + try { + final local = await _hub.ensureRemoteLinkedWorkspace(item.repo); + final next = []; + for (final current in _items) { + next.add(current.key == item.key + ? GitHubRepoHubItem(repo: current.repo, localState: local, watched: current.watched) + : current); + } + if (!mounted) return; + setState(() => _items = next); + _toast('Repo linked to MobileCode workspace.'); + } on Object catch (error) { + _toast(_compact(error.toString(), 140), isError: true); + } + } + + Future _cloneWorkspace(GitHubRepoHubItem item) async { + final key = item.key; + if (_cloningKeys.contains(key)) return; + + setState(() => _cloningKeys = {..._cloningKeys, key}); + GitHubRepoCloneTarget? cloneTarget; + try { + await _runtimeManager.initialize(); + final capabilities = await _runtimeManager.capabilities(); + if (!capabilities.git) { + final local = await _hub.ensureRemoteLinkedWorkspace(item.repo); + final next = []; + for (final current in _items) { + next.add(current.key == key + ? GitHubRepoHubItem( + repo: current.repo, + localState: local, + watched: current.watched, + ) + : current); + } + if (!mounted) return; + setState(() => _items = next); + _toast( + '当前 runtime 没有 git,已改为 Remote-linked 工作区;可先用 Files/API 提交,安装 Helper/Termux git 后再做完整克隆。', + ); + return; + } + + final provider = _runtimeManager.activeProvider; + if (provider is TermuxDaemonProvider) { + var workspaceRoot = provider.workspaceRoot?.trim(); + if (workspaceRoot == null || workspaceRoot.isEmpty) { + final pwdResult = await _runtimeManager.execute( + 'pwd', + timeout: const Duration(seconds: 10), + ); + if (pwdResult.success) { + workspaceRoot = pwdResult.stdout.trim(); + } + } + if (workspaceRoot == null || workspaceRoot.isEmpty) { + throw StateError('Termux daemon did not report a workspaceRoot.'); + } + final runtimePath = _hub.runtimeClonePathFor(item.repo, workspaceRoot); + final runtimeParent = p.posix.dirname(runtimePath); + final mkdirResult = await _runtimeManager.execute( + 'mkdir -p ${_shellArg(runtimeParent)}', + timeout: const Duration(seconds: 20), + ); + if (!mkdirResult.success) { + throw StateError(_cloneFailureMessage(mkdirResult)); + } + final existing = await _runtimeManager.execute( + 'git -C ${_shellArg(runtimePath)} status --short', + timeout: const Duration(seconds: 20), + ); + if (!existing.success) { + final cloneUrl = item.repo.cloneUrl ?? '${item.repo.webUrl}.git'; + final branch = item.repo.defaultBranch.trim(); + final command = branch.isEmpty + ? 'git clone ${_shellArg(cloneUrl)} ${_shellArg(runtimePath)}' + : 'git clone --branch ${_shellArg(branch)} --single-branch ' + '${_shellArg(cloneUrl)} ${_shellArg(runtimePath)}'; + final result = await _runtimeManager.execute( + command, + timeout: const Duration(minutes: 5), + ); + if (!result.success) { + throw StateError(_cloneFailureMessage(result)); + } + } + + final local = await _hub.ensureRuntimeGitWorkspace(item.repo, runtimePath: runtimePath); + final next = []; + for (final current in _items) { + next.add(current.key == key + ? GitHubRepoHubItem( + repo: current.repo, + localState: local, + watched: current.watched, + ) + : current); + } + if (!mounted) return; + setState(() => _items = next); + _toast('Repo cloned through Termux git: $runtimePath'); + return; + } + + final target = await _hub.prepareCloneTarget(item.repo); + cloneTarget = target; + final cloneUrl = item.repo.cloneUrl ?? '${item.repo.webUrl}.git'; + final branch = item.repo.defaultBranch.trim(); + final command = branch.isEmpty + ? 'git clone ${_shellArg(cloneUrl)} ${_shellArg(target.clonePath)}' + : 'git clone --branch ${_shellArg(branch)} --single-branch ' + '${_shellArg(cloneUrl)} ${_shellArg(target.clonePath)}'; + final result = await _runtimeManager.execute( + command, + timeout: const Duration(minutes: 5), + ); + if (!result.success) { + throw StateError(_cloneFailureMessage(result)); + } + + final local = await _hub.completeCloneTarget(item.repo, target); + final next = []; + for (final current in _items) { + next.add(current.key == key + ? GitHubRepoHubItem( + repo: current.repo, + localState: local, + watched: current.watched, + ) + : current); + } + if (!mounted) return; + setState(() => _items = next); + _toast('Repo cloned to phone workspace.'); + } on Object catch (error) { + final target = cloneTarget; + if (target != null) { + await _hub.cleanupCloneTarget(target); + } + if (!mounted) return; + _toast(_friendlyCloneError(error), isError: true); + } finally { + if (mounted) { + setState(() => _cloningKeys = {..._cloningKeys}..remove(key)); + } + } + } + + Future _openUrl(String? url, String label) async { + if (url == null || url.isEmpty) return; + final opened = await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + if (!mounted) return; + _toast(opened ? 'Opened $label.' : 'Could not open $label.', isError: !opened); + } + + Future _openPages(GitHubRepo repo) async { + await _openUrl(_pagesUrlFor(repo), 'GitHub Pages'); + } + + void _openRepoChat(GitHubRepoHubItem item) { + Navigator.of(context).pop( + GitHubRepoChatRequest( + repoFullName: item.repo.fullName, + repoUrl: item.repo.webUrl, + pagesUrl: item.repo.hasPages ? _pagesUrlFor(item.repo) : null, + actionsUrl: '${item.repo.webUrl}/actions', + workspaceMode: _repoWorkspaceModeLabel(item.localState), + workspacePath: item.localState.exists ? item.localState.path : null, + prompt: _repoChatPrompt(item), + ), + ); + } + + Future _copy(String value, String label) async { + await Clipboard.setData(ClipboardData(text: value)); + if (!mounted) return; + _toast('$label copied.'); + } + + Future _installDiscoveryRepo(GitHubRepoHubItem item) async { + if (_source != 'skill' && _source != 'mcp') return; + final key = '${_source}:${item.key}'; + if (_installingKeys.contains(key)) return; + setState(() => _installingKeys = {..._installingKeys, key}); + try { + await _skillManager.initialize(); + if (_source == 'skill') { + final skill = await _skillManager.previewSkillInstallFromRepo(item.repo.webUrl); + if (!mounted) return; + final ok = await _showSkillInstallReview(skill); + if (ok == true) { + await _skillManager.install(skill); + if (!mounted) return; + _toast('Skill 已装载: ${skill.name}'); + setState(() {}); + } + return; + } + + final candidate = await _skillManager.previewMcpInstallFromRepo( + fullName: item.repo.fullName, + repoUrl: item.repo.webUrl, + name: item.repo.name, + description: item.repo.description, + ); + if (!mounted) return; + final ok = await _showMcpInstallReview(candidate); + if (ok == true) { + await _skillManager.registerReviewedMcpCandidate(candidate); + if (!mounted) return; + _toast('MCP 候选已登记,默认未启用: ${candidate.name}'); + setState(() {}); + } + } on Object catch (error) { + if (!mounted) return; + _toast(_compact(error.toString(), 180), isError: true); + } finally { + if (mounted) { + setState(() => _installingKeys = {..._installingKeys}..remove(key)); + } + } + } + + Future _showSkillInstallReview(Skill skill) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) => Padding( + padding: EdgeInsets.fromLTRB(18, 14, 18, MediaQuery.of(context).viewInsets.bottom + 18), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 42, + height: 4, + decoration: BoxDecoration(color: _line, borderRadius: BorderRadius.circular(99)), + ), + ), + const SizedBox(height: 14), + const Text('审核并装载 Skill', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 18)), + const SizedBox(height: 8), + Text(skill.name, style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15)), + const SizedBox(height: 4), + Text(skill.description, style: const TextStyle(color: _muted, height: 1.35)), + const SizedBox(height: 10), + Wrap( + spacing: 7, + runSpacing: 7, + children: [ + _HubPill(label: skill.version, icon: Icons.sell_outlined, color: _blue), + _HubPill(label: skill.author, icon: Icons.person_outline, color: _violet), + _HubPill(label: skill.source.displayName, icon: Icons.source_outlined, color: _mint), + if (skill.actions.isEmpty && skill.prompts.isEmpty) + const _HubPill(label: 'metadata-only', icon: Icons.warning_amber_outlined, color: _amber), + ], + ), + if (skill.githubUrl != null) ...[ + const SizedBox(height: 10), + SelectableText(skill.githubUrl!, style: const TextStyle(color: _faint, fontSize: 12)), + ], + const SizedBox(height: 12), + const _InlineInfoBox( + icon: Icons.verified_user_outlined, + color: _amber, + title: '先审核,再装载', + detail: 'MobileCode 先导入 manifest 或 metadata。启用和运行仍由用户控制。', + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: () => Navigator.of(context).pop(true), + icon: const Icon(Icons.download_done_outlined), + label: const Text('装载已审核 Skill'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Future _showMcpInstallReview(McpServer server) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) => Padding( + padding: EdgeInsets.fromLTRB(18, 14, 18, MediaQuery.of(context).viewInsets.bottom + 18), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 42, + height: 4, + decoration: BoxDecoration(color: _line, borderRadius: BorderRadius.circular(99)), + ), + ), + const SizedBox(height: 14), + const Text('Review MCP candidate', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 18)), + const SizedBox(height: 8), + Text(server.name, style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15)), + const SizedBox(height: 4), + Text(server.description ?? 'No description', style: const TextStyle(color: _muted, height: 1.35)), + const SizedBox(height: 10), + _CodeLine(label: 'type', value: server.type), + _CodeLine(label: 'command', value: server.command.isEmpty ? '(not inferred)' : server.command), + const SizedBox(height: 12), + const _InlineInfoBox( + icon: Icons.power_settings_new_outlined, + color: _amber, + title: 'Registered disabled', + detail: 'This only stores a disabled MCP candidate. MobileCode will not start a process from GitHub discovery.', + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: () => Navigator.of(context).pop(true), + icon: const Icon(Icons.playlist_add_check_outlined), + label: const Text('登记为未启用 MCP'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Future _analyzeGitHubKnowledge() async { + if (!_authenticated || _analyzingKnowledge) { + _toast('GitHub access is required before analysis.', isError: true); + return; + } + setState(() => _analyzingKnowledge = true); + try { + await _memory.init(); + await _roles.initialize(); + final digest = await _repoKnowledge.analyzeWatchedAndOwnerRepos(_hub); + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => _RepoKnowledgeDigestSheet( + digest: digest, + roleLibrary: _roles, + memory: _memory, + onMessage: _toast, + ), + ); + } on Object catch (error) { + if (!mounted) return; + _toast(_compact(error.toString(), 180), isError: true); + } finally { + if (mounted) setState(() => _analyzingKnowledge = false); + } + } + + void _showActions(GitHubRepo repo) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(14))), + builder: (_) => _RepoActionsSheet( + repo: repo, + hub: _hub, + onOpenUrl: _openUrl, + onMessage: _toast, + ), + ); + } + + void _showWorkspace(GitHubRepo repo) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(14))), + builder: (_) => _RepoWorkspaceSheet( + repo: repo, + hub: _hub, + runtimeManager: _runtimeManager, + onMessage: _toast, + ), + ); + } + + void _toast(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? _rose : null, + ), + ); + } + + @override + Widget build(BuildContext context) { + final visible = _visibleItems; + final languageOptions = {}; + for (final item in _items) { + final language = item.repo.language?.trim(); + if (language == null || language.isEmpty) continue; + languageOptions.putIfAbsent(language.toLowerCase(), () => language); + } + final languages = languageOptions.entries.toList()..sort((a, b) => a.value.compareTo(b.value)); + return Scaffold( + backgroundColor: _bg, + appBar: AppBar( + title: const Text('GitHub Repo Hub'), + actions: [ + IconButton( + tooltip: 'Downloads / Shared folders', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const DownloadsSharedFoldersScreen()), + ), + icon: const Icon(Icons.folder_shared_outlined), + ), + IconButton( + tooltip: 'Refresh', + onPressed: _loading ? null : _refresh, + icon: _loading + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.refresh_outlined), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + const _HubHeader(), + const SizedBox(height: 12), + if (_authenticated) ...[ + _HubAccountPanel( + hub: _hub, + currentUser: _hub.currentUser, + accounts: _hub.accountList, + source: _source, + analyzing: _analyzingKnowledge, + onSwitch: (username) => unawaited(_switchAccount(username)), + onAnalyze: () => unawaited(_analyzeGitHubKnowledge()), + ), + const SizedBox(height: 12), + ] else ...[ + _AuthPanel(onLogin: _openLogin), + const SizedBox(height: 12), + ], + _HubPanel( + child: Column( + children: [ + TextField( + controller: _ownerController, + textInputAction: TextInputAction.search, + onSubmitted: (_) => unawaited(_refresh()), + decoration: InputDecoration( + labelText: _source == 'owner' ? 'GitHub user / org' : _sourceSearchLabel(_source), + hintText: _source == 'owner' + ? (_hub.currentUser == null ? 'Type Harzva, flutter, vercel...' : 'Blank = ${_hub.currentUser}') + : _sourceSearchHint(_source), + prefixIcon: Icon(_source == 'owner' ? Icons.account_circle_outlined : Icons.public_outlined), + suffixIcon: IconButton( + tooltip: _source == 'owner' ? 'Load repos' : 'Search GitHub', + onPressed: _loading ? null : _refresh, + icon: const Icon(Icons.travel_explore_outlined), + ), + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _FilterChip(label: 'Any repo', value: 'repo', selected: _source == 'repo', onSelected: _setSource), + _FilterChip(label: 'Owner repos', value: 'owner', selected: _source == 'owner', onSelected: _setSource), + _FilterChip(label: 'Skill', value: 'skill', selected: _source == 'skill', onSelected: _setSource), + _FilterChip(label: 'MCP', value: 'mcp', selected: _source == 'mcp', onSelected: _setSource), + _FilterChip(label: 'Release', value: 'release', selected: _source == 'release', onSelected: _setSource), + ], + ), + const SizedBox(height: 6), + Text( + _sourceScopeCopy(_source), + style: const TextStyle(color: _muted, fontSize: 11.5, height: 1.35), + ), + const SizedBox(height: 10), + TextField( + controller: _searchController, + decoration: const InputDecoration( + labelText: 'Filter loaded cards', + hintText: 'name, description, language', + prefixIcon: Icon(Icons.search_outlined), + ), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + value: _sort, + decoration: const InputDecoration(labelText: 'Sort'), + items: const [ + DropdownMenuItem(value: 'pushed', child: Text('Recently pushed')), + DropdownMenuItem(value: 'updated', child: Text('Recently updated')), + DropdownMenuItem(value: 'created', child: Text('Recently created')), + DropdownMenuItem(value: 'full_name', child: Text('Name')), + ], + onChanged: (value) { + if (value == null) return; + setState(() => _sort = value); + unawaited(_refresh()); + }, + ), + const SizedBox(height: 10), + DropdownButtonFormField( + value: languageOptions.containsKey(_languageFilter) ? _languageFilter : 'all', + decoration: const InputDecoration(labelText: 'Language'), + items: [ + const DropdownMenuItem(value: 'all', child: Text('All languages')), + for (final language in languages) DropdownMenuItem(value: language.key, child: Text(language.value)), + ], + onChanged: (value) { + if (value == null) return; + setState(() => _languageFilter = value); + }, + ), + ], + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _FilterChip(label: 'All', value: 'all', selected: _filter == 'all', onSelected: _setFilter), + _FilterChip(label: '关注', value: 'watched', selected: _filter == 'watched', onSelected: _setFilter), + _FilterChip(label: 'On phone', value: 'local', selected: _filter == 'local', onSelected: _setFilter), + _FilterChip(label: 'Git', value: 'git', selected: _filter == 'git', onSelected: _setFilter), + _FilterChip(label: 'Pages', value: 'pages', selected: _filter == 'pages', onSelected: _setFilter), + ], + ), + const SizedBox(height: 10), + _HubStats(total: _items.length, visible: visible.length), + const SizedBox(height: 12), + if (_notice != null) + _HubPanel( + borderColor: _amber, + child: Text(_notice!, style: const TextStyle(color: _amber, height: 1.35)), + ), + if (_error != null) + _HubPanel( + borderColor: _rose, + child: Text(_error!, style: const TextStyle(color: _rose, height: 1.35)), + ), + if (_loading) + const Padding( + padding: EdgeInsets.all(28), + child: Center(child: CircularProgressIndicator()), + ) + else if (visible.isEmpty) + _HubPanel( + child: Text(_emptyStateMessage(), style: const TextStyle(color: _muted)), + ) + else + for (final item in visible) ...[ + _RepoHubCard( + item: item, + hub: _hub, + currentUser: _hub.currentUser, + source: _source, + cloning: _cloningKeys.contains(item.key), + installing: _installingKeys.contains('${_source}:${item.key}'), + installLabel: _installLabel(item), + onWatched: (value) => unawaited(_setWatched(item, value)), + onLinkWorkspace: () => unawaited(_linkWorkspace(item)), + onCloneWorkspace: () => unawaited(_cloneWorkspace(item)), + onInstall: () => unawaited(_installDiscoveryRepo(item)), + onOpenRepo: () => unawaited(_openUrl(item.repo.webUrl, 'repository')), + onOpenPages: item.repo.hasPages ? () => unawaited(_openPages(item.repo)) : null, + onOpenChat: () => _openRepoChat(item), + onOpenUrl: (url, label) => unawaited(_openUrl(url, label)), + onCopyRepoUrl: () => unawaited(_copy(item.repo.webUrl, 'GitHub URL')), + onCopyPath: () => unawaited(_copy(item.localState.path, 'Workspace path')), + onActions: () => _showActions(item.repo), + onWorkspace: () => _showWorkspace(item.repo), + ), + const SizedBox(height: 10), + ], + ], + ), + ); + } + + String _emptyStateMessage() { + if (_notice != null && !_hasLoaded) return _notice!; + if (_items.isNotEmpty && _visibleItems.isEmpty) { + return 'Loaded repositories are hidden by the current filter. Clear search text, language, or status chips.'; + } + if (_source == 'owner') { + final owner = _ownerController.text.trim(); + if (owner.isEmpty && !_authenticated) { + return 'Type a GitHub user/org to browse public repos, or add GitHub access to load your own repos.'; + } + if (owner.isEmpty) return 'No repositories were returned for the active GitHub account.'; + return 'No public repositories were returned for $owner.'; + } + if (!_hasLoaded) return 'Type a query or tap search to discover public GitHub repositories.'; + return 'No public repositories matched this GitHub search.'; + } + + String? _installLabel(GitHubRepoHubItem item) { + if (_source != 'skill' && _source != 'mcp') return null; + if (!_skillManager.isInitialized) return _source == 'skill' ? '装载' : '登记'; + try { + if (_source == 'skill') { + return _skillManager.isGitHubSkillInstalled(item.repo.webUrl) ? '已装载' : '装载'; + } + return _skillManager.isMcpRepoRegistered(item.repo.fullName) ? '已登记' : '登记'; + } on Object { + return _source == 'skill' ? '装载' : '登记'; + } + } + + void _setFilter(String value) { + setState(() => _filter = value); + } + + String _friendlyGitHubError(Object error) { + if (error is GitHubDeepException) { + final status = error.statusCode; + if (status == 401) { + return 'GitHub token 失效或无效。公开搜索仍可用;账号仓库、Pages、Actions 操作需要重新登录。'; + } + if (status == 403) { + return 'GitHub 返回 403。可能是匿名 rate limit、token scope 不足,或该仓库不允许当前账号访问。'; + } + if (status == 404) { + return _source == 'owner' + ? '没有找到这个 owner/org 的公开仓库,或该账号不可见。' + : '没有找到可公开访问的仓库资源。'; + } + return _compact('GitHub ${status ?? ''}: ${error.message}', 180); + } + return _compact(error.toString(), 180); + } + + void _setSource(String value) { + setState(() { + _source = value; + _filter = 'all'; + _languageFilter = 'all'; + }); + unawaited(_refresh()); + } + + Future _switchAccount(String username) async { + if (username == _hub.currentUser) return; + final ok = await _hub.switchAccount(username); + if (!mounted) return; + _toast(ok ? 'Switched GitHub account to $username.' : 'Could not switch GitHub account.', isError: !ok); + if (ok) await _refresh(); + } +} + +class _HubHeader extends StatelessWidget { + const _HubHeader(); + + @override + Widget build(BuildContext context) { + return _HubPanel( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: _blue.withOpacity(0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _line), + ), + child: Center( + child: SvgPicture.asset( + 'assets/icons/github-mark-24.svg', + width: 24, + height: 24, + colorFilter: const ColorFilter.mode(_blue, BlendMode.srcIn), + ), + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('GitHub-first mobile workspace', style: TextStyle(color: _text, fontSize: 16, fontWeight: FontWeight.w900)), + SizedBox(height: 4), + Text( + 'Use GitHub as the remote project index and build layer. The phone keeps light files, previews, watchlists, Pages, and Actions status.', + style: TextStyle(color: _muted, height: 1.35), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _AuthPanel extends StatelessWidget { + const _AuthPanel({required this.onLogin}); + + final VoidCallback onLogin; + + @override + Widget build(BuildContext context) { + return _HubPanel( + borderColor: _amber, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('GitHub access optional', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 16)), + const SizedBox(height: 6), + const Text( + 'Public search works without login. Add access when you want private repos, Pages publishing, Actions dispatch, or file commits.', + style: TextStyle(color: _muted, height: 1.35), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onLogin, + icon: const Icon(Icons.login_outlined), + label: const Text('Add GitHub access'), + ), + ], + ), + ); + } +} + +class _HubAccountPanel extends StatelessWidget { + const _HubAccountPanel({ + required this.hub, + required this.currentUser, + required this.accounts, + required this.source, + required this.analyzing, + required this.onSwitch, + required this.onAnalyze, + }); + + final GitHubRepoHubService hub; + final String? currentUser; + final List accounts; + final String source; + final bool analyzing; + final ValueChanged onSwitch; + final VoidCallback onAnalyze; + + @override + Widget build(BuildContext context) { + final active = currentUser; + return _HubPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.switch_account_outlined, color: _blue, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + active == null ? 'GitHub account not selected' : 'Operations use @$active', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 14, fontWeight: FontWeight.w900), + ), + ), + _HubPill( + label: source == 'owner' ? 'Managed lane' : 'Discovery lane', + icon: source == 'owner' ? Icons.verified_user_outlined : Icons.travel_explore_outlined, + color: source == 'owner' ? _mint : _amber, + ), + ], + ), + const SizedBox(height: 6), + const Text( + 'Repo writes, Pages, and Actions always run through the active account. External search results stay read-only until the token has write access or you fork the repo.', + style: TextStyle(color: _muted, fontSize: 11.5, height: 1.35), + ), + if (active != null) ...[ + const SizedBox(height: 8), + FutureBuilder>( + future: hub.loadTokenScopes(username: active), + builder: (context, snapshot) { + final scopes = snapshot.data ?? const []; + final label = snapshot.connectionState == ConnectionState.waiting + ? 'checking scopes' + : scopes.isEmpty + ? 'scopes hidden or fine-grained' + : scopes.take(4).join(', '); + return _HubPill(label: label, icon: Icons.key_outlined, color: scopes.isEmpty ? _faint : _violet); + }, + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: analyzing ? null : onAnalyze, + icon: analyzing + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.auto_awesome_outlined, size: 18), + label: Text(analyzing ? 'Analyzing repositories...' : 'Analyze my GitHub'), + ), + ), + ], + if (accounts.length > 1) ...[ + const SizedBox(height: 9), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final account in accounts) + ChoiceChip( + selected: account == active, + label: Text('@$account'), + avatar: Icon(account == active ? Icons.check_circle_outline : Icons.account_circle_outlined, size: 16), + onSelected: account == active ? null : (_) => onSwitch(account), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _HubStats extends StatelessWidget { + const _HubStats({required this.total, required this.visible}); + + final int total; + final int visible; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _HubPill(label: '$visible shown', icon: Icons.view_list_outlined, color: _blue), + const SizedBox(width: 8), + _HubPill(label: '$total loaded', icon: Icons.cloud_done_outlined, color: _mint), + ], + ); + } +} + +class _RepoRelation { + const _RepoRelation({ + required this.label, + required this.detail, + required this.icon, + required this.color, + }); + + final String label; + final String detail; + final IconData icon; + final Color color; +} + +_RepoRelation _repoRelation(GitHubRepo repo, String? currentUser, String source) { + final activeOwner = currentUser?.trim().toLowerCase(); + final repoOwner = repo.owner.trim().toLowerCase(); + if (activeOwner != null && activeOwner.isNotEmpty && activeOwner == repoOwner) { + return const _RepoRelation( + label: 'Your account', + detail: 'Managed with the active GitHub account; commit, Pages, and Actions are expected to work when scopes are valid.', + icon: Icons.verified_user_outlined, + color: _mint, + ); + } + if (source == 'owner') { + return const _RepoRelation( + label: 'Owner/org', + detail: 'Listed from the chosen owner or organization. Write actions require collaborator or organization permission.', + icon: Icons.groups_2_outlined, + color: _blue, + ); + } + return const _RepoRelation( + label: 'External', + detail: 'Discovered from GitHub search. Treat as read-only unless you fork it or the active token has write access.', + icon: Icons.travel_explore_outlined, + color: _amber, + ); +} + +class _RepoHubCard extends StatelessWidget { + const _RepoHubCard({ + required this.item, + required this.hub, + required this.currentUser, + required this.source, + required this.cloning, + required this.installing, + required this.installLabel, + required this.onWatched, + required this.onLinkWorkspace, + required this.onCloneWorkspace, + required this.onInstall, + required this.onOpenRepo, + required this.onOpenChat, + required this.onOpenUrl, + required this.onCopyRepoUrl, + required this.onCopyPath, + required this.onActions, + required this.onWorkspace, + this.onOpenPages, + }); + + final GitHubRepoHubItem item; + final GitHubRepoHubService hub; + final String? currentUser; + final String source; + final bool cloning; + final bool installing; + final String? installLabel; + final ValueChanged onWatched; + final VoidCallback onLinkWorkspace; + final VoidCallback onCloneWorkspace; + final VoidCallback onInstall; + final VoidCallback onOpenRepo; + final VoidCallback onOpenChat; + final void Function(String url, String label) onOpenUrl; + final VoidCallback onCopyRepoUrl; + final VoidCallback onCopyPath; + final VoidCallback onActions; + final VoidCallback onWorkspace; + final VoidCallback? onOpenPages; + + @override + Widget build(BuildContext context) { + final repo = item.repo; + final local = item.localState; + final relation = _repoRelation(repo, currentUser, source); + final hasPhoneWorkspace = local.exists || local.remoteLinked || local.hasGit; + final localDetail = hasPhoneWorkspace + ? 'Pushed ${_timeAgo(repo.pushedAt)} · ${local.statusLabel} · ${local.path}' + : 'Pushed ${_timeAgo(repo.pushedAt)} · No phone workspace yet'; + final localColor = local.hasGit + ? _mint + : local.remoteLinked + ? _blue + : local.exists + ? _amber + : _faint; + return _HubPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: item.watched, + onChanged: (value) => onWatched(value ?? false), + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(repo.fullName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15)), + if (repo.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(repo.description, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _muted, height: 1.3)), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 7, + runSpacing: 7, + children: [ + _HubPill(label: repo.isPrivate ? 'Private' : 'Public', icon: repo.isPrivate ? Icons.lock_outline : Icons.public_outlined, color: repo.isPrivate ? _amber : _mint), + if (repo.language != null) _HubPill(label: repo.language!, icon: Icons.code_outlined, color: _violet), + _HubPill(label: '${repo.stars} stars', icon: Icons.star_border_outlined, color: _amber), + _HubPill(label: repo.defaultBranch, icon: Icons.account_tree_outlined, color: _blue), + _HubPill(label: relation.label, icon: relation.icon, color: relation.color), + if (repo.hasPages) _HubPill(label: 'Pages', icon: Icons.web_outlined, color: _mint, onTap: onOpenPages), + _HubPill(label: local.statusLabel, icon: local.hasGit ? Icons.call_split_outlined : Icons.folder_open_outlined, color: localColor), + ], + ), + const SizedBox(height: 9), + Text( + localDetail, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _faint, fontSize: 11, height: 1.3), + ), + if (!local.hasGit) ...[ + const SizedBox(height: 5), + Text( + '${relation.detail} ${local.modeDescription}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 11, height: 1.3), + ), + ], + if (source == 'release') ...[ + const SizedBox(height: 10), + _ReleaseAssetsPreview( + hub: hub, + repo: repo, + onOpenUrl: onOpenUrl, + ), + ], + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (installLabel != null) + FilledButton.icon( + onPressed: installing || installLabel == '已装载' || installLabel == '已登记' + ? null + : onInstall, + icon: installing + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : Icon( + installLabel == '已登记' || installLabel == '已装载' + ? Icons.check_circle_outline + : Icons.download_outlined, + size: 16, + ), + label: Text(installing ? (installLabel == '登记' ? '登记中...' : '装载中...') : installLabel!), + ), + OutlinedButton.icon( + onPressed: onLinkWorkspace, + icon: const Icon(Icons.add_to_drive_outlined, size: 16), + label: Text(local.exists ? '刷新本机链接' : '创建手机工作区'), + ), + if (!local.hasGit) + OutlinedButton.icon( + onPressed: cloning ? null : onCloneWorkspace, + icon: const Icon(Icons.download_for_offline_outlined, size: 16), + label: Text(cloning ? 'Cloning...' : 'Git 克隆'), + ), + OutlinedButton.icon( + onPressed: onOpenChat, + icon: const Icon(Icons.chat_bubble_outline, size: 16), + label: const Text('Open chat'), + ), + OutlinedButton.icon( + onPressed: onActions, + icon: const Icon(Icons.play_circle_outline, size: 16), + label: const Text('Actions'), + ), + OutlinedButton.icon( + onPressed: onWorkspace, + icon: Icon(local.runtimeGit ? Icons.sync_alt_outlined : Icons.folder_copy_outlined, size: 16), + label: Text(local.runtimeGit ? 'Runtime 文件' : 'Files'), + ), + OutlinedButton.icon( + onPressed: onOpenRepo, + icon: const Icon(Icons.open_in_new_outlined, size: 16), + label: const Text('仓库'), + ), + OutlinedButton.icon( + onPressed: onCopyRepoUrl, + icon: const Icon(Icons.link_outlined, size: 16), + label: const Text('复制地址'), + ), + OutlinedButton.icon( + onPressed: hasPhoneWorkspace ? onCopyPath : null, + icon: const Icon(Icons.copy_outlined, size: 16), + label: Text(hasPhoneWorkspace ? '路径' : '未创建路径'), + ), + ], + ), + ], + ), + ); + } +} + +class _ReleaseAssetsPreview extends StatelessWidget { + const _ReleaseAssetsPreview({ + required this.hub, + required this.repo, + required this.onOpenUrl, + }); + + final GitHubRepoHubService hub; + final GitHubRepo repo; + final void Function(String url, String label) onOpenUrl; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: hub.loadLatestReleaseSummary(repo), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _InlineInfoBox( + icon: Icons.new_releases_outlined, + color: _blue, + title: 'Reading latest release...', + detail: 'Checking GitHub Releases for APK/zip assets.', + ); + } + if (snapshot.hasError) { + return _InlineInfoBox( + icon: Icons.error_outline, + color: _rose, + title: 'Release assets unavailable', + detail: _compact(snapshot.error.toString(), 130), + ); + } + final release = snapshot.data; + if (release == null) { + return const _InlineInfoBox( + icon: Icons.inventory_2_outlined, + color: _faint, + title: 'No releases yet', + detail: 'This repo has no GitHub Releases visible to the active account.', + ); + } + final assets = release.buildAssets; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _blue.withOpacity(0.05), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _blue.withOpacity(0.18)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.new_releases_outlined, color: _blue, size: 16), + const SizedBox(width: 7), + Expanded( + child: Text( + '${release.tagName} · ${assets.length} APK/zip assets', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900), + ), + ), + if (release.releaseUrl.isNotEmpty) + TextButton( + onPressed: () => onOpenUrl(release.releaseUrl, 'release'), + child: const Text('Release'), + ), + ], + ), + const SizedBox(height: 6), + if (assets.isEmpty) + const Text( + 'Latest release exists, but no APK/zip artifact is attached.', + style: TextStyle(color: _muted, fontSize: 11.5, height: 1.3), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final asset in assets.take(4)) + _ReleaseAssetChip( + asset: asset, + onTap: () => onOpenUrl(asset.downloadUrl, asset.name), + ), + ], + ), + ], + ), + ); + }, + ); + } +} + +class _InlineInfoBox extends StatelessWidget { + const _InlineInfoBox({ + required this.icon, + required this.color, + required this.title, + required this.detail, + }); + + final IconData icon; + final Color color; + final String title; + final String detail; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withOpacity(0.07), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.20)), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 17), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900)), + const SizedBox(height: 2), + Text(detail, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _muted, fontSize: 11, height: 1.25)), + ], + ), + ), + ], + ), + ); + } +} + +class _CodeLine extends StatelessWidget { + const _CodeLine({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _bg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _line), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 78, + child: Text(label, style: const TextStyle(color: _faint, fontSize: 12, fontWeight: FontWeight.w800)), + ), + Expanded( + child: SelectableText(value, style: const TextStyle(color: _text, fontSize: 12.5, height: 1.3)), + ), + ], + ), + ); + } +} + +class _RepoKnowledgeDigestSheet extends StatefulWidget { + const _RepoKnowledgeDigestSheet({ + required this.digest, + required this.roleLibrary, + required this.memory, + required this.onMessage, + }); + + final RepoKnowledgeDigest digest; + final RoleLibraryService roleLibrary; + final MemoryService memory; + final void Function(String message, {bool isError}) onMessage; + + @override + State<_RepoKnowledgeDigestSheet> createState() => _RepoKnowledgeDigestSheetState(); +} + +class _RepoKnowledgeDigestSheetState extends State<_RepoKnowledgeDigestSheet> { + final Set _acceptedRoles = {}; + final Set _ignoredRoles = {}; + final Set _acceptedRules = {}; + final Set _ignoredRules = {}; + + @override + Widget build(BuildContext context) { + final digest = widget.digest; + return FractionallySizedBox( + heightFactor: 0.92, + child: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 20), + children: [ + Center( + child: Container( + width: 42, + height: 4, + decoration: BoxDecoration(color: _line, borderRadius: BorderRadius.circular(99)), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + const Icon(Icons.auto_awesome_outlined, color: _violet, size: 20), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'GitHub repository intelligence', + style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 18), + ), + ), + _HubPill( + label: digest.usedProvider ? 'AI summary' : 'heuristic', + icon: digest.usedProvider ? Icons.cloud_done_outlined : Icons.offline_bolt_outlined, + color: digest.usedProvider ? _mint : _amber, + ), + ], + ), + const SizedBox(height: 8), + Text(digest.summary, style: const TextStyle(color: _muted, height: 1.35)), + if (digest.fallbackReason != null) ...[ + const SizedBox(height: 10), + _InlineInfoBox( + icon: Icons.info_outline, + color: _amber, + title: 'Local fallback', + detail: digest.fallbackReason!, + ), + ], + const SizedBox(height: 12), + Wrap( + spacing: 7, + runSpacing: 7, + children: [ + _HubPill(label: '${digest.analyzedRepos.length} repos', icon: Icons.folder_copy_outlined, color: _blue), + for (final stack in digest.techStacks.take(6)) + _HubPill(label: stack, icon: Icons.code_outlined, color: _violet), + ], + ), + if (digest.analyzedRepos.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text('Evidence repos', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 7), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final repo in digest.analyzedRepos.take(8)) + _HubPill(label: repo, icon: Icons.folder_outlined, color: _faint), + ], + ), + ], + const SizedBox(height: 18), + _DigestSectionTitle( + title: 'Suggested Roles', + subtitle: '保存前不会写入 Roles。', + icon: Icons.groups_2_outlined, + color: _violet, + ), + if (digest.roleProposals.isEmpty) + const _InlineInfoBox( + icon: Icons.info_outline, + color: _faint, + title: 'No role suggestion', + detail: 'Repository evidence was too thin to suggest a useful role.', + ) + else + for (final proposal in digest.roleProposals) + _RoleProposalReviewCard( + proposal: proposal, + accepted: _acceptedRoles.contains(proposal.proposalId), + ignored: _ignoredRoles.contains(proposal.proposalId), + onAccept: () => _acceptRole(proposal), + onEditAccept: () => _editAndAcceptRole(proposal), + onIgnore: () => setState(() => _ignoredRoles.add(proposal.proposalId)), + ), + const SizedBox(height: 18), + _DigestSectionTitle( + title: 'Suggested Memory Rules', + subtitle: '保存到 App Memory,不修改仓库文件。', + icon: Icons.psychology_alt_outlined, + color: _mint, + ), + if (digest.memoryProposals.isEmpty) + const _InlineInfoBox( + icon: Icons.info_outline, + color: _faint, + title: 'No memory rule suggestion', + detail: 'Repository evidence was too thin to create a durable rule.', + ) + else + for (final proposal in digest.memoryProposals) + _MemoryRuleProposalReviewCard( + proposal: proposal, + accepted: _acceptedRules.contains(proposal.proposalId), + ignored: _ignoredRules.contains(proposal.proposalId), + onAccept: () => _acceptRule(proposal), + onEditAccept: () => _editAndAcceptRule(proposal), + onIgnore: () => setState(() => _ignoredRules.add(proposal.proposalId)), + ), + ], + ), + ), + ); + } + + Future _acceptRole(RoleProposal proposal) async { + await widget.roleLibrary.initialize(); + await widget.roleLibrary.upsertCustomRole(proposal.role); + setState(() => _acceptedRoles.add(proposal.proposalId)); + widget.onMessage('Role saved: ${proposal.role.name}'); + } + + Future _editAndAcceptRole(RoleProposal proposal) async { + final edited = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => _RoleProposalEditSheet(role: proposal.role), + ); + if (edited == null) return; + await widget.roleLibrary.initialize(); + await widget.roleLibrary.upsertCustomRole(edited); + setState(() => _acceptedRoles.add(proposal.proposalId)); + widget.onMessage('Edited role saved: ${edited.name}'); + } + + Future _acceptRule(MemoryRuleProposal proposal) async { + await widget.memory.init(); + await widget.memory.upsertMemoryRule(proposal.rule); + setState(() => _acceptedRules.add(proposal.proposalId)); + widget.onMessage('Memory rule saved: ${proposal.rule.title}'); + } + + Future _editAndAcceptRule(MemoryRuleProposal proposal) async { + final edited = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (_) => _MemoryRuleProposalEditSheet(rule: proposal.rule), + ); + if (edited == null) return; + await widget.memory.init(); + await widget.memory.upsertMemoryRule(edited); + setState(() => _acceptedRules.add(proposal.proposalId)); + widget.onMessage('Edited memory rule saved: ${edited.title}'); + } +} + +class _DigestSectionTitle extends StatelessWidget { + const _DigestSectionTitle({ + required this.title, + required this.subtitle, + required this.icon, + required this.color, + }); + + final String title; + final String subtitle; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Icon(icon, color: color, size: 19), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15)), + Text(subtitle, style: const TextStyle(color: _muted, fontSize: 11.5)), + ], + ), + ), + ], + ), + ); + } +} + +class _RoleProposalReviewCard extends StatelessWidget { + const _RoleProposalReviewCard({ + required this.proposal, + required this.accepted, + required this.ignored, + required this.onAccept, + required this.onEditAccept, + required this.onIgnore, + }); + + final RoleProposal proposal; + final bool accepted; + final bool ignored; + final Future Function() onAccept; + final Future Function() onEditAccept; + final VoidCallback onIgnore; + + @override + Widget build(BuildContext context) { + final role = proposal.role; + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: accepted ? _mint.withOpacity(0.07) : _bg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: accepted ? _mint.withOpacity(0.3) : _line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.badge_outlined, color: _violet, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(role.name, style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), + ), + if (accepted) + const _HubPill(label: 'Saved', icon: Icons.check_circle_outline, color: _mint) + else if (ignored) + const _HubPill(label: 'Ignored', icon: Icons.visibility_off_outlined, color: _faint), + ], + ), + const SizedBox(height: 6), + Text(role.summary, style: const TextStyle(color: _muted, height: 1.3)), + const SizedBox(height: 8), + Text(proposal.rationale, style: const TextStyle(color: _faint, fontSize: 11.5, height: 1.3)), + if (!accepted && !ignored) ...[ + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onIgnore, + child: const Text('忽略'), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: OutlinedButton.icon( + onPressed: () => unawaited(onEditAccept()), + icon: const Icon(Icons.edit_note_outlined, size: 16), + label: const Text('编辑后保存'), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: () => unawaited(onAccept()), + icon: const Icon(Icons.library_add_check_outlined, size: 16), + label: const Text('保存到 Roles'), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _MemoryRuleProposalReviewCard extends StatelessWidget { + const _MemoryRuleProposalReviewCard({ + required this.proposal, + required this.accepted, + required this.ignored, + required this.onAccept, + required this.onEditAccept, + required this.onIgnore, + }); + + final MemoryRuleProposal proposal; + final bool accepted; + final bool ignored; + final Future Function() onAccept; + final Future Function() onEditAccept; + final VoidCallback onIgnore; + + @override + Widget build(BuildContext context) { + final rule = proposal.rule; + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: accepted ? _mint.withOpacity(0.07) : _bg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: accepted ? _mint.withOpacity(0.3) : _line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.rule_outlined, color: _mint, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(rule.title, style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), + ), + if (accepted) + const _HubPill(label: 'Saved', icon: Icons.check_circle_outline, color: _mint) + else if (ignored) + const _HubPill(label: 'Ignored', icon: Icons.visibility_off_outlined, color: _faint), + ], + ), + const SizedBox(height: 6), + Text(rule.rule, style: const TextStyle(color: _muted, height: 1.3)), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _HubPill(label: rule.category, icon: Icons.label_outline, color: _blue), + for (final repo in rule.evidenceRepos.take(3)) + _HubPill(label: repo, icon: Icons.folder_outlined, color: _faint), + ], + ), + if (!accepted && !ignored) ...[ + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onIgnore, + child: const Text('忽略'), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: OutlinedButton.icon( + onPressed: () => unawaited(onEditAccept()), + icon: const Icon(Icons.edit_note_outlined, size: 16), + label: const Text('编辑后保存'), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: () => unawaited(onAccept()), + icon: const Icon(Icons.library_add_check_outlined, size: 16), + label: const Text('保存到 Memory'), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _RoleProposalEditSheet extends StatefulWidget { + const _RoleProposalEditSheet({required this.role}); + + final MobileCodeRole role; + + @override + State<_RoleProposalEditSheet> createState() => _RoleProposalEditSheetState(); +} + +class _RoleProposalEditSheetState extends State<_RoleProposalEditSheet> { + late final TextEditingController _name; + late final TextEditingController _summary; + late final TextEditingController _mission; + late final TextEditingController _personality; + late final TextEditingController _responsibilities; + late final TextEditingController _guardrails; + late final TextEditingController _successCriteria; + late final TextEditingController _promptTemplate; + String? _error; + String? _polishNote; + bool _polishing = false; + + @override + void initState() { + super.initState(); + final role = widget.role; + _name = TextEditingController(text: role.name); + _summary = TextEditingController(text: role.summary); + _mission = TextEditingController(text: role.mission); + _personality = TextEditingController(text: role.personality); + _responsibilities = TextEditingController(text: _joinLines(role.responsibilities)); + _guardrails = TextEditingController(text: _joinLines(role.guardrails)); + _successCriteria = TextEditingController(text: _joinLines(role.successCriteria)); + _promptTemplate = TextEditingController(text: role.promptTemplate); + } + + @override + void dispose() { + _name.dispose(); + _summary.dispose(); + _mission.dispose(); + _personality.dispose(); + _responsibilities.dispose(); + _guardrails.dispose(); + _successCriteria.dispose(); + _promptTemplate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FractionallySizedBox( + heightFactor: 0.92, + child: SafeArea( + top: false, + child: ListView( + padding: EdgeInsets.fromLTRB(18, 14, 18, MediaQuery.of(context).viewInsets.bottom + 18), + children: [ + Center( + child: Container( + width: 42, + height: 4, + decoration: BoxDecoration(color: _line, borderRadius: BorderRadius.circular(99)), + ), + ), + const SizedBox(height: 14), + const Text('Edit Role before saving', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 18)), + const SizedBox(height: 6), + const Text( + '把建议角色改成你真正想长期使用的模板。保存后才会写入 Roles。', + style: TextStyle(color: _muted, height: 1.35), + ), + const SizedBox(height: 12), + _PolishActionBox( + title: 'AI 润色标准化', + detail: '先按你的想法粗改字段,再让模型整理成稳定的 Role Card;不可用时会用本地模板兜底。', + note: _polishNote, + polishing: _polishing, + onPressed: _polish, + ), + const SizedBox(height: 14), + _EditTextField(controller: _name, label: 'Role name', icon: Icons.badge_outlined), + _EditTextField(controller: _summary, label: 'Summary', icon: Icons.short_text_outlined, maxLines: 2), + _EditTextField(controller: _mission, label: 'Mission', icon: Icons.flag_outlined, maxLines: 3), + _EditTextField(controller: _personality, label: 'Personality', icon: Icons.psychology_outlined, maxLines: 3), + _EditTextField( + controller: _responsibilities, + label: 'Responsibilities', + icon: Icons.checklist_outlined, + maxLines: 5, + helperText: '每行一条', + ), + _EditTextField( + controller: _guardrails, + label: 'Guardrails', + icon: Icons.security_outlined, + maxLines: 4, + helperText: '每行一条', + ), + _EditTextField( + controller: _successCriteria, + label: 'Success criteria', + icon: Icons.verified_outlined, + maxLines: 4, + helperText: '每行一条', + ), + _EditTextField( + controller: _promptTemplate, + label: 'Prompt template', + icon: Icons.article_outlined, + maxLines: 6, + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: const TextStyle(color: _rose, height: 1.3)), + ], + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: _polishing ? null : _save, + icon: const Icon(Icons.library_add_check_outlined), + label: const Text('Save edited Role'), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _polish() async { + final draft = _draftRole(); + if (_roleDraftIsEmpty(draft)) { + setState(() => _error = 'Please write at least a role name, mission, or prompt template before polishing.'); + return; + } + setState(() { + _polishing = true; + _error = null; + _polishNote = null; + }); + try { + final result = await RepoKnowledgeDigestService().polishRole(draft); + if (!mounted) return; + _applyRole(result.role); + setState(() { + _polishing = false; + _polishNote = result.usedProvider + ? 'AI 已按 MobileCode Role 标准润色,保存前你仍可继续编辑。' + : 'AI 不可用,已用本地模板标准化:${result.fallbackReason ?? 'fallback used.'}'; + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _polishing = false; + _error = _compact(error.toString(), 160); + }); + } + } + + MobileCodeRole _draftRole() { + return widget.role.copyWith( + name: _name.text.trim(), + summary: _summary.text.trim(), + mission: _mission.text.trim(), + personality: _personality.text.trim(), + responsibilities: _splitLines(_responsibilities.text), + guardrails: _splitLines(_guardrails.text), + successCriteria: _splitLines(_successCriteria.text), + promptTemplate: _promptTemplate.text.trim(), + builtIn: false, + enabled: true, + ); + } + + bool _roleDraftIsEmpty(MobileCodeRole role) { + return [ + role.name, + role.summary, + role.mission, + role.personality, + role.promptTemplate, + ...role.responsibilities, + ...role.guardrails, + ...role.successCriteria, + ].every((item) => item.trim().isEmpty); + } + + void _applyRole(MobileCodeRole role) { + _name.text = role.name; + _summary.text = role.summary; + _mission.text = role.mission; + _personality.text = role.personality; + _responsibilities.text = _joinLines(role.responsibilities); + _guardrails.text = _joinLines(role.guardrails); + _successCriteria.text = _joinLines(role.successCriteria); + _promptTemplate.text = role.promptTemplate; + } + + void _save() { + final name = _name.text.trim(); + if (name.isEmpty) { + setState(() => _error = 'Role name is required.'); + return; + } + final edited = widget.role.copyWith( + name: name, + summary: _summary.text.trim(), + mission: _mission.text.trim(), + personality: _personality.text.trim(), + responsibilities: _splitLines(_responsibilities.text), + guardrails: _splitLines(_guardrails.text), + successCriteria: _splitLines(_successCriteria.text), + promptTemplate: _promptTemplate.text.trim(), + builtIn: false, + enabled: true, + ); + Navigator.of(context).pop(edited); + } +} + +class _MemoryRuleProposalEditSheet extends StatefulWidget { + const _MemoryRuleProposalEditSheet({required this.rule}); + + final MemoryRule rule; + + @override + State<_MemoryRuleProposalEditSheet> createState() => _MemoryRuleProposalEditSheetState(); +} + +class _MemoryRuleProposalEditSheetState extends State<_MemoryRuleProposalEditSheet> { + late final TextEditingController _title; + late final TextEditingController _category; + late final TextEditingController _rule; + String? _error; + String? _polishNote; + bool _polishing = false; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.rule.title); + _category = TextEditingController(text: widget.rule.category); + _rule = TextEditingController(text: widget.rule.rule); + } + + @override + void dispose() { + _title.dispose(); + _category.dispose(); + _rule.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FractionallySizedBox( + heightFactor: 0.78, + child: SafeArea( + top: false, + child: ListView( + padding: EdgeInsets.fromLTRB(18, 14, 18, MediaQuery.of(context).viewInsets.bottom + 18), + children: [ + Center( + child: Container( + width: 42, + height: 4, + decoration: BoxDecoration(color: _line, borderRadius: BorderRadius.circular(99)), + ), + ), + const SizedBox(height: 14), + const Text('Edit Memory rule before saving', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 18)), + const SizedBox(height: 6), + const Text( + '只保存长期有价值的偏好、规范和工作流规则。保存后写入 App Memory,不改仓库文件。', + style: TextStyle(color: _muted, height: 1.35), + ), + const SizedBox(height: 12), + _PolishActionBox( + title: 'AI 润色记忆规则', + detail: '把粗略规则整理成长期可复用的 Memory;不会保存 prompt、response 或一次性任务内容。', + note: _polishNote, + polishing: _polishing, + onPressed: _polish, + ), + const SizedBox(height: 14), + _EditTextField(controller: _title, label: 'Title', icon: Icons.rule_outlined), + _EditTextField(controller: _category, label: 'Category', icon: Icons.label_outline), + _EditTextField(controller: _rule, label: 'Rule', icon: Icons.psychology_alt_outlined, maxLines: 6), + if (widget.rule.evidenceRepos.isNotEmpty) ...[ + const SizedBox(height: 8), + const Text('Evidence repos', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 12)), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final repo in widget.rule.evidenceRepos.take(5)) + _HubPill(label: repo, icon: Icons.folder_outlined, color: _faint), + ], + ), + ], + if (_error != null) ...[ + const SizedBox(height: 8), + Text(_error!, style: const TextStyle(color: _rose, height: 1.3)), + ], + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: _polishing ? null : _save, + icon: const Icon(Icons.library_add_check_outlined), + label: const Text('Save edited Memory'), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _polish() async { + final draft = widget.rule.copyWith( + title: _title.text.trim(), + category: _category.text.trim(), + rule: _rule.text.trim(), + enabled: true, + ); + if (draft.title.trim().isEmpty && draft.rule.trim().isEmpty) { + setState(() => _error = 'Please write a title or rule before polishing.'); + return; + } + setState(() { + _polishing = true; + _error = null; + _polishNote = null; + }); + try { + final result = await RepoKnowledgeDigestService().polishMemoryRule(draft); + if (!mounted) return; + _title.text = result.rule.title; + _category.text = result.rule.category; + _rule.text = result.rule.rule; + setState(() { + _polishing = false; + _polishNote = result.usedProvider + ? 'AI 已整理为可长期复用的 Memory 规则。' + : 'AI 不可用,已用本地模板标准化:${result.fallbackReason ?? 'fallback used.'}'; + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _polishing = false; + _error = _compact(error.toString(), 160); + }); + } + } + + void _save() { + final title = _title.text.trim(); + final rule = _rule.text.trim(); + if (title.isEmpty || rule.isEmpty) { + setState(() => _error = 'Title and rule are required.'); + return; + } + Navigator.of(context).pop(widget.rule.copyWith( + title: title, + category: _category.text.trim().isEmpty ? 'repo-insight' : _category.text.trim(), + rule: rule, + enabled: true, + )); + } +} + +class _PolishActionBox extends StatelessWidget { + const _PolishActionBox({ + required this.title, + required this.detail, + required this.polishing, + required this.onPressed, + this.note, + }); + + final String title; + final String detail; + final String? note; + final bool polishing; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _violet.withOpacity(0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _violet.withOpacity(0.22)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.auto_fix_high_outlined, color: _violet, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 13), + ), + ), + OutlinedButton.icon( + onPressed: polishing ? null : onPressed, + icon: polishing + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.auto_awesome_outlined, size: 16), + label: Text(polishing ? '润色中...' : 'AI 润色'), + ), + ], + ), + const SizedBox(height: 6), + Text(detail, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), + if (note != null) ...[ + const SizedBox(height: 8), + Text(note!, style: const TextStyle(color: _violet, fontSize: 11.5, height: 1.3, fontWeight: FontWeight.w700)), + ], + ], + ), + ); + } +} + +class _EditTextField extends StatelessWidget { + const _EditTextField({ + required this.controller, + required this.label, + required this.icon, + this.maxLines = 1, + this.helperText, + }); + + final TextEditingController controller; + final String label; + final IconData icon; + final int maxLines; + final String? helperText; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: controller, + maxLines: maxLines, + minLines: maxLines == 1 ? 1 : null, + decoration: InputDecoration( + labelText: label, + helperText: helperText, + prefixIcon: Icon(icon), + alignLabelWithHint: maxLines > 1, + ), + ), + ); + } +} + +String _joinLines(List values) => values.where((value) => value.trim().isNotEmpty).join('\n'); + +List _splitLines(String value) { + return value + .split(RegExp(r'[\n;]+')) + .map((line) => line.replaceFirst(RegExp(r'^[-*•\d.]+\s*'), '').trim()) + .where((line) => line.isNotEmpty) + .toList(growable: false); +} + +class _ReleaseAssetChip extends StatelessWidget { + const _ReleaseAssetChip({ + required this.asset, + required this.onTap, + }); + + final GitHubReleaseAsset asset; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final size = asset.sizeBytes == null ? null : _bytesLabel(asset.sizeBytes!); + final downloads = asset.downloadCount == null ? null : '${asset.downloadCount} dl'; + return ActionChip( + avatar: const Icon(Icons.download_outlined, color: _blue, size: 16), + label: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: Text( + [asset.name, if (size != null) size, if (downloads != null) downloads].join(' · '), + overflow: TextOverflow.ellipsis, + ), + ), + side: BorderSide(color: _blue.withOpacity(0.25)), + backgroundColor: _panel, + labelStyle: const TextStyle(color: _text, fontSize: 11.5, fontWeight: FontWeight.w800), + onPressed: onTap, + ); + } +} + +class _RepoWorkspaceSheet extends StatefulWidget { + const _RepoWorkspaceSheet({ + required this.repo, + required this.hub, + required this.runtimeManager, + required this.onMessage, + }); + + final GitHubRepo repo; + final GitHubRepoHubService hub; + final RuntimeManager runtimeManager; + final void Function(String message, {bool isError}) onMessage; + + @override + State<_RepoWorkspaceSheet> createState() => _RepoWorkspaceSheetState(); +} + +class _RepoWorkspaceSheetState extends State<_RepoWorkspaceSheet> { + late Future> _tree; + late Future _localState; + late Future> _recentSyncs; + String _path = ''; + bool _openingFile = false; + bool _syncingRuntime = false; + String? _sharedCopyPath; + + @override + void initState() { + super.initState(); + _tree = widget.hub.loadRemoteTree(widget.repo); + _localState = widget.hub.localStateFor(widget.repo); + _recentSyncs = widget.hub.loadRuntimeWorkspaceSyncs(repo: widget.repo); + } + + void _loadPath(String path) { + setState(() { + _path = path; + _tree = widget.hub.loadRemoteTree(widget.repo, path: path); + }); + } + + void _refreshLocalState() { + setState(() { + _localState = widget.hub.localStateFor(widget.repo); + _recentSyncs = widget.hub.loadRuntimeWorkspaceSyncs(repo: widget.repo); + }); + } + + void _goUp() { + if (_path.isEmpty) return; + final segments = _path.split('/')..removeLast(); + _loadPath(segments.join('/')); + } + + Future _openFile(GitHubWorkspaceEntry entry) async { + if (_openingFile) return; + setState(() => _openingFile = true); + try { + final file = await widget.hub.readRemoteFile(widget.repo, entry.path); + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(14))), + builder: (_) => _RemoteFileEditorSheet( + repo: widget.repo, + file: file, + onReload: () => widget.hub.readRemoteFile(widget.repo, file.path), + onCommit: (content, message, sha) async { + await widget.hub.commitRemoteFile( + widget.repo, + path: file.path, + content: content, + message: message, + sha: sha, + ); + widget.onMessage('Committed ${file.path}.'); + _loadPath(_path); + }, + ), + ); + } on Object catch (error) { + if (!mounted) return; + widget.onMessage(_compact(error.toString(), 160), isError: true); + } finally { + if (mounted) setState(() => _openingFile = false); + } + } + + Future _copyRuntimePath(String path, String label) async { + await Clipboard.setData(ClipboardData(text: path)); + if (!mounted) return; + widget.onMessage('$label copied.'); + } + + Future _copyTermuxCdCommand(GitHubRepoLocalState local) async { + final command = 'cd ${_shellArg(local.path)} && ls'; + await Clipboard.setData(ClipboardData(text: command)); + if (!mounted) return; + widget.onMessage('Termux cd command copied.'); + } + + Future _syncRuntimeWorkspace(GitHubRepoLocalState local) async { + if (_syncingRuntime) return; + setState(() => _syncingRuntime = true); + try { + await widget.runtimeManager.initialize(); + final provider = widget.runtimeManager.activeProvider; + if (provider is! TermuxDaemonProvider) { + throw StateError('Active runtime is not a Termux daemon. Start the Termux helper daemon, then retry.'); + } + final sourcePath = local.path.trim(); + final rawWorkspaceRoot = provider.workspaceRoot?.trim(); + final workspaceRoot = rawWorkspaceRoot?.replaceFirst(RegExp(r'/+$'), ''); + if (workspaceRoot != null && + workspaceRoot.isNotEmpty && + sourcePath != workspaceRoot && + !sourcePath.startsWith('$workspaceRoot/')) { + throw StateError('Runtime workspace is outside the Termux daemon workspace root.'); + } + + final targetPath = _sharedRuntimeWorkspacePath(widget.repo); + final mkdirResult = await widget.runtimeManager.execute( + 'mkdir -p ${_shellArg(targetPath)}', + timeout: const Duration(seconds: 20), + ); + if (!mkdirResult.success) throw StateError(_runtimeCommandFailureMessage(mkdirResult)); + + final copyResult = await widget.runtimeManager.execute( + 'cp -R ${_shellArg('$sourcePath/.')} ${_shellArg(targetPath)}', + timeout: const Duration(minutes: 3), + ); + if (!copyResult.success) throw StateError(_runtimeCommandFailureMessage(copyResult)); + + await widget.hub.ensureRuntimeGitWorkspace(widget.repo, runtimePath: sourcePath); + await widget.hub.recordRuntimeWorkspaceSync( + widget.repo, + runtimePath: sourcePath, + sharedPath: targetPath, + ); + await Clipboard.setData(ClipboardData(text: targetPath)); + if (!mounted) return; + setState(() { + _sharedCopyPath = targetPath; + _localState = widget.hub.localStateFor(widget.repo); + _recentSyncs = widget.hub.loadRuntimeWorkspaceSyncs(repo: widget.repo); + }); + widget.onMessage('Runtime workspace synced to shared folder. Path copied: $targetPath'); + } on Object catch (error) { + if (!mounted) return; + widget.onMessage(_friendlyRuntimeSyncError(error), isError: true); + } finally { + if (mounted) setState(() => _syncingRuntime = false); + } + } + + Future _openSharedRuntimeFolder([String? folderPath]) async { + final path = folderPath ?? _sharedCopyPath ?? _sharedRuntimeWorkspacePath(widget.repo); + final opened = await launchUrl(Uri.file(path), mode: LaunchMode.externalApplication); + if (!mounted) return; + if (opened) { + widget.onMessage('Opened shared runtime folder.'); + } else { + await Clipboard.setData(ClipboardData(text: path)); + widget.onMessage('Could not open folder directly. Shared folder path copied.', isError: true); + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 18), + child: FutureBuilder( + future: _localState, + builder: (context, localSnapshot) { + final local = localSnapshot.data; + final runtimeLocal = local?.runtimeGit == true ? local : null; + final listHeightFactor = runtimeLocal != null ? 0.48 : 0.66; + return FutureBuilder>( + future: _tree, + builder: (context, snapshot) { + final entries = snapshot.data ?? const []; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.folder_copy_outlined, color: _blue), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.repo.fullName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 17, fontWeight: FontWeight.w900), + ), + ), + IconButton( + tooltip: 'Refresh workspace', + onPressed: () { + _refreshLocalState(); + _loadPath(_path); + }, + icon: const Icon(Icons.refresh_outlined), + ), + ], + ), + const SizedBox(height: 6), + Text( + _path.isEmpty ? 'API workspace · ${widget.repo.defaultBranch}' : _path, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.3), + ), + if (runtimeLocal != null) ...[ + const SizedBox(height: 10), + FutureBuilder>( + future: _recentSyncs, + builder: (context, syncSnapshot) { + return _RuntimeWorkspacePanel( + local: runtimeLocal, + sharedPath: _sharedCopyPath ?? _sharedRuntimeWorkspacePath(widget.repo), + recentSyncs: syncSnapshot.data ?? const [], + syncing: _syncingRuntime, + onCopyRuntimePath: () => unawaited(_copyRuntimePath(runtimeLocal.path, 'Runtime path')), + onCopyCdCommand: () => unawaited(_copyTermuxCdCommand(runtimeLocal)), + onSyncToShared: () => unawaited(_syncRuntimeWorkspace(runtimeLocal)), + onOpenShared: () => unawaited(_openSharedRuntimeFolder()), + onCopySharedPath: () => unawaited(_copyRuntimePath(_sharedCopyPath ?? _sharedRuntimeWorkspacePath(widget.repo), 'Shared folder path')), + onOpenRecord: (record) => unawaited(_openSharedRuntimeFolder(record.sharedPath)), + onCopyRecord: (record) => unawaited(_copyRuntimePath(record.sharedPath, 'Recent shared folder path')), + ); + }, + ), + ], + const SizedBox(height: 12), + if (_path.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: OutlinedButton.icon( + onPressed: _goUp, + icon: const Icon(Icons.arrow_upward_outlined, size: 16), + label: const Text('Parent folder'), + ), + ), + SizedBox( + height: MediaQuery.of(context).size.height * listHeightFactor, + child: snapshot.connectionState == ConnectionState.waiting + ? const SizedBox(height: 260, child: Center(child: CircularProgressIndicator())) + : snapshot.hasError + ? _HubPanel( + borderColor: _rose, + child: Text(_compact(snapshot.error.toString(), 180), style: const TextStyle(color: _rose, height: 1.35)), + ) + : entries.isEmpty + ? const _HubPanel(child: Text('No files in this folder.', style: TextStyle(color: _muted))) + : ListView.separated( + shrinkWrap: true, + itemCount: entries.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final entry = entries[index]; + return _HubPanel( + child: InkWell( + onTap: entry.isDirectory ? () => _loadPath(entry.path) : () => unawaited(_openFile(entry)), + child: Row( + children: [ + Icon( + entry.isDirectory ? Icons.folder_outlined : Icons.description_outlined, + color: entry.isDirectory ? _amber : _blue, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _text, fontWeight: FontWeight.w800)), + Text( + entry.isDirectory ? 'folder' : '${entry.size ?? 0} bytes', + style: const TextStyle(color: _faint, fontSize: 11), + ), + ], + ), + ), + if (_openingFile && entry.isFile) + const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + else + Icon(entry.isDirectory ? Icons.chevron_right : Icons.edit_outlined, color: _faint, size: 18), + ], + ), + ), + ); + }, + ), + ), + ], + ); + }, + ); + }, + ), + ), + ); + } +} + +class _RuntimeWorkspacePanel extends StatelessWidget { + const _RuntimeWorkspacePanel({ + required this.local, + required this.sharedPath, + required this.recentSyncs, + required this.syncing, + required this.onCopyRuntimePath, + required this.onCopyCdCommand, + required this.onSyncToShared, + required this.onOpenShared, + required this.onCopySharedPath, + required this.onOpenRecord, + required this.onCopyRecord, + }); + + final GitHubRepoLocalState local; + final String sharedPath; + final List recentSyncs; + final bool syncing; + final VoidCallback onCopyRuntimePath; + final VoidCallback onCopyCdCommand; + final VoidCallback onSyncToShared; + final VoidCallback onOpenShared; + final VoidCallback onCopySharedPath; + final ValueChanged onOpenRecord; + final ValueChanged onCopyRecord; + + @override + Widget build(BuildContext context) { + return _HubPanel( + borderColor: _mint.withOpacity(0.28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon(Icons.sync_alt_outlined, color: _mint, size: 18), + SizedBox(width: 8), + Expanded( + child: Text( + 'Runtime workspace', + style: TextStyle(color: _text, fontWeight: FontWeight.w900), + ), + ), + ], + ), + const SizedBox(height: 7), + Text( + local.path, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 11.5, height: 1.3), + ), + const SizedBox(height: 6), + Text( + 'Termux clone lives in the runtime workspace. Sync a copy to $sharedPath when you want to browse it from the phone file manager.', + style: const TextStyle(color: _faint, fontSize: 11.5, height: 1.35), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: onCopyRuntimePath, + icon: const Icon(Icons.copy_outlined, size: 16), + label: const Text('复制 Runtime 路径'), + ), + OutlinedButton.icon( + onPressed: onCopyCdCommand, + icon: const Icon(Icons.terminal_outlined, size: 16), + label: const Text('复制 cd 命令'), + ), + FilledButton.icon( + onPressed: syncing ? null : onSyncToShared, + icon: syncing + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.sync_outlined, size: 16), + label: Text(syncing ? '同步中...' : '同步到共享目录'), + ), + OutlinedButton.icon( + onPressed: onOpenShared, + icon: const Icon(Icons.folder_open_outlined, size: 16), + label: const Text('打开共享目录'), + ), + OutlinedButton.icon( + onPressed: onCopySharedPath, + icon: const Icon(Icons.content_copy_outlined, size: 16), + label: const Text('复制共享路径'), + ), + ], + ), + if (recentSyncs.isNotEmpty) ...[ + const SizedBox(height: 12), + const Text( + '最近同步目录', + style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 12), + ), + const SizedBox(height: 8), + for (final record in recentSyncs.take(3)) ...[ + _RuntimeSyncRecordTile( + record: record, + onOpen: () => onOpenRecord(record), + onCopy: () => onCopyRecord(record), + ), + const SizedBox(height: 7), + ], + ], + ], + ), + ); + } +} + +class _RuntimeSyncRecordTile extends StatelessWidget { + const _RuntimeSyncRecordTile({ + required this.record, + required this.onOpen, + required this.onCopy, + }); + + final GitHubRuntimeWorkspaceSyncRecord record; + final VoidCallback onOpen; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _mint.withOpacity(0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _mint.withOpacity(0.16)), + ), + child: Row( + children: [ + const Icon(Icons.history_outlined, color: _mint, size: 18), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.sharedPath, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 11.5, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 2), + Text( + '${_timeAgo(record.syncedAt)} · ${record.repoFullName}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _faint, fontSize: 10.5), + ), + ], + ), + ), + IconButton( + tooltip: 'Open shared folder', + visualDensity: VisualDensity.compact, + onPressed: onOpen, + icon: const Icon(Icons.folder_open_outlined, color: _blue, size: 18), + ), + IconButton( + tooltip: 'Copy shared path', + visualDensity: VisualDensity.compact, + onPressed: onCopy, + icon: const Icon(Icons.copy_outlined, color: _blue, size: 18), + ), + ], + ), + ); + } +} + +class _RemoteFileEditorSheet extends StatefulWidget { + const _RemoteFileEditorSheet({ + required this.repo, + required this.file, + required this.onReload, + required this.onCommit, + }); + + final GitHubRepo repo; + final GitHubRemoteFile file; + final Future Function() onReload; + final Future Function(String content, String message, String? sha) onCommit; + + @override + State<_RemoteFileEditorSheet> createState() => _RemoteFileEditorSheetState(); +} + +class _RemoteFileEditorSheetState extends State<_RemoteFileEditorSheet> { + late final TextEditingController _content; + late final TextEditingController _message; + late String _originalContent; + String? _sha; + bool _saving = false; + bool _reloading = false; + String? _error; + + @override + void initState() { + super.initState(); + _content = TextEditingController(text: widget.file.content); + _message = TextEditingController(text: 'Update ${widget.file.path} from MobileCode'); + _originalContent = widget.file.content; + _sha = widget.file.sha; + } + + @override + void dispose() { + _content.dispose(); + _message.dispose(); + super.dispose(); + } + + Future _commit() async { + final message = _message.text.trim(); + if (message.isEmpty) { + setState(() => _error = 'Commit message is required.'); + return; + } + setState(() { + _saving = true; + _error = null; + }); + try { + await widget.onCommit(_content.text, message, _sha); + if (!mounted) return; + Navigator.pop(context); + } on GitHubDeepException catch (error) { + if (!mounted) return; + final lower = error.message.toLowerCase(); + final conflict = error.statusCode == 409 || error.statusCode == 422 || lower.contains('sha'); + setState(() { + _saving = false; + _error = conflict + ? 'Remote file changed before this commit. Reload the latest version, review your edits, then commit again.' + : _compact(error.toString(), 180); + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _saving = false; + _error = _compact(error.toString(), 180); + }); + } + } + + Future _reloadRemote() async { + setState(() { + _reloading = true; + _error = null; + }); + try { + final remote = await widget.onReload(); + if (!mounted) return; + setState(() { + _originalContent = remote.content; + _sha = remote.sha; + _content.text = remote.content; + _reloading = false; + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _reloading = false; + _error = _compact(error.toString(), 180); + }); + } + } + + @override + Widget build(BuildContext context) { + final changed = _content.text != _originalContent; + return SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 18 + MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.edit_document, color: _blue), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.file.path, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 16, fontWeight: FontWeight.w900), + ), + ), + ], + ), + const SizedBox(height: 10), + TextField( + controller: _content, + minLines: 12, + maxLines: 18, + onChanged: (_) => setState(() {}), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + decoration: const InputDecoration(labelText: 'Remote file content'), + ), + const SizedBox(height: 10), + TextField( + controller: _message, + enabled: !_saving, + decoration: const InputDecoration(labelText: 'Commit message'), + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Text(_error!, style: const TextStyle(color: _rose, fontSize: 12, height: 1.35)), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _reloading ? null : () => unawaited(_reloadRemote()), + icon: _reloading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.refresh_outlined, size: 16), + label: Text(_reloading ? 'Reloading...' : 'Reload remote file'), + ), + ], + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _saving || !changed ? null : () => unawaited(_commit()), + icon: _saving + ? const SizedBox(width: 17, height: 17, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.commit_outlined), + label: Text(_saving ? 'Committing...' : 'Commit through GitHub API'), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RepoActionsSheet extends StatefulWidget { + const _RepoActionsSheet({ + required this.repo, + required this.hub, + required this.onOpenUrl, + required this.onMessage, + }); + + final GitHubRepo repo; + final GitHubRepoHubService hub; + final Future Function(String? url, String label) onOpenUrl; + final void Function(String message, {bool isError}) onMessage; + + @override + State<_RepoActionsSheet> createState() => _RepoActionsSheetState(); +} + +class _RepoActionsSheetState extends State<_RepoActionsSheet> { + late Future _snapshot; + late Future> _downloads; + bool _dispatching = false; + int? _downloadingArtifactId; + Timer? _pollTimer; + + @override + void initState() { + super.initState(); + _snapshot = widget.hub.loadActionsSnapshot(widget.repo); + _downloads = widget.hub.loadArtifactDownloads(repo: widget.repo); + _pollTimer = Timer.periodic(const Duration(seconds: 8), (_) { + if (!mounted) return; + setState(() => _snapshot = widget.hub.loadActionsSnapshot(widget.repo)); + }); + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + Future _dispatch(String workflowId) async { + setState(() => _dispatching = true); + try { + await widget.hub.dispatchWorkflow(widget.repo, workflowId); + if (!mounted) return; + widget.onMessage('Workflow dispatch requested.'); + setState(() => _snapshot = widget.hub.loadActionsSnapshot(widget.repo)); + } on Object catch (error) { + if (!mounted) return; + widget.onMessage(_compact(error.toString(), 150), isError: true); + } finally { + if (mounted) setState(() => _dispatching = false); + } + } + + Future _downloadArtifact(Map artifact) async { + final id = artifact['id']; + if (id is! int) return; + setState(() => _downloadingArtifactId = id); + try { + final path = await widget.hub.downloadArtifactZip(widget.repo, artifact); + await Clipboard.setData(ClipboardData(text: path)); + final opened = await launchUrl(Uri.file(path), mode: LaunchMode.externalApplication); + if (!mounted) return; + setState(() => _downloads = widget.hub.loadArtifactDownloads(repo: widget.repo)); + widget.onMessage(opened ? 'Artifact downloaded and opened. Path copied.' : 'Artifact downloaded. Path copied.'); + } on Object catch (error) { + if (!mounted) return; + widget.onMessage(_compact(error.toString(), 160), isError: true); + } finally { + if (mounted) setState(() => _downloadingArtifactId = null); + } + } + + Future _openDownloadedPath(String path) async { + final opened = await launchUrl(Uri.file(path), mode: LaunchMode.externalApplication); + if (!mounted) return; + widget.onMessage(opened ? 'Opened downloaded artifact.' : 'Could not open artifact. Path copied.', isError: !opened); + if (!opened) await Clipboard.setData(ClipboardData(text: path)); + } + + Future _openDownloadFolder(String path) async { + final folderPath = p.dirname(path); + final opened = await launchUrl(Uri.file(folderPath), mode: LaunchMode.externalApplication); + if (!mounted) return; + widget.onMessage(opened ? 'Opened artifact folder.' : 'Folder path copied.', isError: !opened); + if (!opened) await Clipboard.setData(ClipboardData(text: folderPath)); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 18), + child: FutureBuilder( + future: _snapshot, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox(height: 220, child: Center(child: CircularProgressIndicator())); + } + if (snapshot.hasError) { + return _HubPanel( + borderColor: _rose, + child: Text(_compact(snapshot.error.toString(), 180), style: const TextStyle(color: _rose, height: 1.35)), + ); + } + final data = snapshot.requireData; + final latest = data.latestRun; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(Icons.play_circle_outline, color: _blue), + const SizedBox(width: 8), + Expanded( + child: Text(widget.repo.fullName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _text, fontSize: 17, fontWeight: FontWeight.w900)), + ), + IconButton( + tooltip: 'Refresh run status', + onPressed: () => setState(() => _snapshot = widget.hub.loadActionsSnapshot(widget.repo)), + icon: const Icon(Icons.refresh_outlined), + ), + ], + ), + const SizedBox(height: 12), + _HubPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Latest GitHub Actions run', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + if (latest == null) + const Text('No workflow runs found yet.', style: TextStyle(color: _muted)) + else ...[ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _HubPill(label: latest['status']?.toString() ?? 'unknown', icon: Icons.sync_outlined, color: _blue), + _HubPill(label: latest['conclusion']?.toString() ?? 'no conclusion', icon: Icons.verified_outlined, color: _mint), + _HubPill(label: '${data.artifacts.length} artifacts', icon: Icons.inventory_2_outlined, color: _violet), + ], + ), + const SizedBox(height: 8), + Text( + '${latest['name'] ?? 'workflow'} · ${latest['head_branch'] ?? widget.repo.defaultBranch}', + style: const TextStyle(color: _muted, height: 1.3), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => unawaited(widget.onOpenUrl(latest['html_url']?.toString(), 'Actions run')), + icon: const Icon(Icons.open_in_new_outlined, size: 16), + label: const Text('Open run'), + ), + if (data.artifacts.isNotEmpty) ...[ + const SizedBox(height: 10), + const Text('Artifacts', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), + const SizedBox(height: 6), + for (final raw in data.artifacts.take(4)) + if (raw is Map) + _ArtifactRow( + artifact: raw, + downloading: _downloadingArtifactId == raw['id'], + onDownload: () => unawaited(_downloadArtifact(raw)), + ), + ], + ], + ], + ), + ), + if (data.jobs.isNotEmpty) ...[ + const SizedBox(height: 12), + _HubPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Run jobs', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + for (final raw in data.jobs.take(4)) + if (raw is Map) _JobRow(job: raw), + ], + ), + ), + ], + const SizedBox(height: 12), + FutureBuilder>( + future: _downloads, + builder: (context, downloadsSnapshot) { + final downloads = downloadsSnapshot.data ?? const []; + if (downloads.isEmpty) { + return const _HubPanel( + child: Text('Downloaded artifacts will appear here with open, folder, and copy actions.', style: TextStyle(color: _muted, height: 1.35)), + ); + } + return _HubPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Recent downloads', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + for (final record in downloads.take(4)) + _DownloadRecordRow( + record: record, + onOpen: () => unawaited(_openDownloadedPath(record.path)), + onOpenFolder: () => unawaited(_openDownloadFolder(record.path)), + onCopyPath: () async { + await Clipboard.setData(ClipboardData(text: record.path)); + widget.onMessage('Artifact path copied.'); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(height: 12), + const Text('Workflows', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + if (data.workflows.isEmpty) + const Text('No workflows found. Add .github/workflows to build on GitHub.', style: TextStyle(color: _muted)) + else + for (final raw in data.workflows) + if (raw is Map) + _WorkflowRow( + workflow: raw, + dispatching: _dispatching, + onOpen: () => unawaited(widget.onOpenUrl(raw['html_url']?.toString(), 'workflow')), + onRun: () => unawaited(_dispatch((raw['id'] ?? raw['path'] ?? raw['name']).toString())), + ), + ], + ), + ); + }, + ), + ), + ); + } +} + +class _ArtifactRow extends StatelessWidget { + const _ArtifactRow({ + required this.artifact, + required this.downloading, + required this.onDownload, + }); + + final Map artifact; + final bool downloading; + final VoidCallback onDownload; + + @override + Widget build(BuildContext context) { + final expired = artifact['expired'] == true; + final size = artifact['size_in_bytes']; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Icon(expired ? Icons.inventory_2_outlined : Icons.archive_outlined, color: expired ? _faint : _violet, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + artifact['name']?.toString() ?? 'Artifact', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + if (size is int) + Text(_bytesLabel(size), style: const TextStyle(color: _faint, fontSize: 11)), + const SizedBox(width: 4), + IconButton( + tooltip: 'Download artifact zip', + onPressed: expired || downloading ? null : onDownload, + icon: downloading + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.download_outlined, size: 18), + ), + ], + ), + ); + } +} + +class _JobRow extends StatelessWidget { + const _JobRow({required this.job}); + + final Map job; + + @override + Widget build(BuildContext context) { + final steps = (job['steps'] as List?) ?? const []; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.account_tree_outlined, color: _blue, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + job['name']?.toString() ?? 'Job', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontWeight: FontWeight.w800), + ), + ), + _HubPill( + label: job['conclusion']?.toString() ?? job['status']?.toString() ?? 'unknown', + icon: Icons.sync_outlined, + color: _mint, + ), + ], + ), + if (steps.isNotEmpty) ...[ + const SizedBox(height: 6), + for (final raw in steps.take(5)) + if (raw is Map) + Padding( + padding: const EdgeInsets.only(left: 24, bottom: 3), + child: Text( + '${raw['status'] ?? 'queued'} · ${raw['name'] ?? 'step'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 11), + ), + ), + ], + ], + ), + ); + } +} + +class _DownloadRecordRow extends StatelessWidget { + const _DownloadRecordRow({ + required this.record, + required this.onOpen, + required this.onOpenFolder, + required this.onCopyPath, + }); + + final GitHubArtifactDownloadRecord record; + final VoidCallback onOpen; + final VoidCallback onOpenFolder; + final VoidCallback onCopyPath; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + const Icon(Icons.archive_outlined, color: _violet, size: 18), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.artifactName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontWeight: FontWeight.w800, fontSize: 12), + ), + Text( + '${_timeAgo(record.downloadedAt)} · ${record.sizeBytes == null ? record.path : '${_bytesLabel(record.sizeBytes!)} · ${record.path}'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _faint, fontSize: 10), + ), + ], + ), + ), + IconButton(tooltip: 'Open artifact', onPressed: onOpen, icon: const Icon(Icons.open_in_new_outlined, size: 18)), + IconButton(tooltip: 'Open folder', onPressed: onOpenFolder, icon: const Icon(Icons.folder_open_outlined, size: 18)), + IconButton(tooltip: 'Copy path', onPressed: onCopyPath, icon: const Icon(Icons.copy_outlined, size: 18)), + ], + ), + ); + } +} + +class _WorkflowRow extends StatelessWidget { + const _WorkflowRow({ + required this.workflow, + required this.dispatching, + required this.onOpen, + required this.onRun, + }); + + final Map workflow; + final bool dispatching; + final VoidCallback onOpen; + final VoidCallback onRun; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _HubPanel( + child: Row( + children: [ + const Icon(Icons.schema_outlined, color: _blue, size: 18), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(workflow['name']?.toString() ?? 'Workflow', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _text, fontWeight: FontWeight.w800)), + Text(workflow['state']?.toString() ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _muted, fontSize: 12)), + ], + ), + ), + IconButton( + tooltip: 'Open workflow', + onPressed: onOpen, + icon: const Icon(Icons.open_in_new_outlined, size: 18), + ), + IconButton( + tooltip: 'Run workflow', + onPressed: dispatching ? null : onRun, + icon: dispatching + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.play_arrow_outlined, size: 20), + ), + ], + ), + ), + ); + } +} + +class _FilterChip extends StatelessWidget { + const _FilterChip({ + required this.label, + required this.value, + required this.selected, + required this.onSelected, + }); + + final String label; + final String value; + final bool selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return ChoiceChip( + label: Text(label), + selected: selected, + onSelected: (_) => onSelected(value), + ); + } +} + +class _HubPanel extends StatelessWidget { + const _HubPanel({ + required this.child, + this.borderColor = _line, + }); + + final Widget child; + final Color borderColor; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _panel, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor), + boxShadow: const [ + BoxShadow( + color: Color(0x0A2555FF), + blurRadius: 16, + offset: Offset(0, 8), + ), + ], + ), + child: child, + ); + } +} + +class _HubPill extends StatelessWidget { + const _HubPill({ + required this.label, + required this.icon, + required this.color, + this.onTap, + }); + + final String label; + final IconData icon; + final Color color; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final pill = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.25)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 13), + const SizedBox(width: 5), + Text(label, style: TextStyle(color: color, fontWeight: FontWeight.w800, fontSize: 11)), + ], + ), + ); + if (onTap == null) return pill; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: pill, + ), + ); + } +} + +String _pagesUrlFor(GitHubRepo repo) { + final homepage = repo.homepage?.trim(); + if (homepage != null && homepage.startsWith(RegExp(r'https?://'))) { + return homepage; + } + final owner = repo.owner.toLowerCase(); + final repoName = repo.name.toLowerCase(); + if (repoName == '$owner.github.io') { + return 'https://$owner.github.io/'; + } + return 'https://$owner.github.io/${repo.name}/'; +} + +String _repoChatPrompt(GitHubRepoHubItem item) { + final repo = item.repo; + final local = item.localState; + final pages = repo.hasPages ? _pagesUrlFor(repo) : 'No GitHub Pages detected'; + final workspace = local.exists ? local.path : 'No phone workspace created yet'; + final mode = local.hasGit + ? 'Git clone workspace (.git exists)' + : local.remoteLinked + ? 'Remote-linked GitHub API workspace (not a git clone)' + : local.exists + ? 'Phone folder without GitHub marker' + : 'Not on phone'; + return ''' +我想围绕 GitHub 仓库 ${repo.fullName} 进行 MobileCode 对话。 + +仓库信息: +- Repo URL: ${repo.webUrl} +- Pages URL: $pages +- 默认分支: ${repo.defaultBranch} +- 主要语言: ${repo.language ?? 'unknown'} +- 手机工作区状态: $mode +- 手机本地路径: $workspace + +请先基于这个仓库上下文回答。若需要修改文件: +- Remote-linked 模式优先通过 GitHub API 读取/提交文件。 +- Git clone 模式才使用本机 git 命令。 +- 若用户明确需要 git push,而当前不是 Git clone,引导先点“Git 克隆”按钮。 +- Not on phone 时先建议创建手机工作区或使用 GitHub API。 +'''.trim(); +} + +String _repoWorkspaceModeLabel(GitHubRepoLocalState local) { + if (local.runtimeGit) return 'Termux git clone'; + if (local.hasGit) return 'Git clone'; + if (local.remoteLinked) return 'Remote-linked'; + if (local.exists) return 'Phone folder'; + return 'Not on phone'; +} + +String _sourceSearchLabel(String source) { + return switch (source) { + 'skill' => 'Search GitHub skills', + 'mcp' => 'Search GitHub MCP', + 'release' => 'Search release repos', + _ => 'Search any GitHub repo', + }; +} + +String _sourceSearchHint(String source) { + return switch (source) { + 'skill' => 'frontend design skill, codex skill, SKILL.md...', + 'mcp' => 'github mcp server, lark mcp, filesystem mcp...', + 'release' => 'flutter apk, android release, github pages...', + _ => 'owner/repo keywords, topic, language, product name...', + }; +} + +String _sourceScopeCopy(String source) { + return switch (source) { + 'owner' => 'Owner repos is the managed lane: your own account is smoothest; org repos still need collaborator permissions.', + 'skill' => 'Skill search is a discovery lane. Load only after reviewing source, manifest, and trust signals.', + 'mcp' => 'MCP search is a discovery lane. Register connectors disabled first, then enable only after review.', + 'release' => 'Release search finds repos with downloadable builds; publishing or pushing still requires repo permission.', + _ => 'Any repo search is discovery-first. Open, copy, chat, or link read-only; push requires fork or write access.', + }; +} + +String _timeAgo(DateTime value) { + final diff = DateTime.now().difference(value); + if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}y ago'; + if (diff.inDays >= 30) return '${(diff.inDays / 30).floor()}mo ago'; + if (diff.inDays >= 1) return '${diff.inDays}d ago'; + if (diff.inHours >= 1) return '${diff.inHours}h ago'; + if (diff.inMinutes >= 1) return '${diff.inMinutes}m ago'; + return 'just now'; +} + +String _cloneFailureMessage(RuntimeCommandResult result) { + final output = [result.stderr, result.stdout] + .where((part) => part.trim().isNotEmpty) + .join(' '); + if (output.trim().isEmpty) { + return 'Clone failed with exit code ${result.exitCode}.'; + } + return 'Clone failed: ${_compact(output, 180)}'; +} + +String _runtimeCommandFailureMessage(RuntimeCommandResult result) { + final output = [result.stderr, result.stdout] + .where((part) => part.trim().isNotEmpty) + .join(' '); + if (output.trim().isEmpty) { + return 'Runtime command failed with exit code ${result.exitCode}.'; + } + return _compact(output, 220); +} + +String _friendlyCloneError(Object error) { + final message = error.toString().replaceFirst(RegExp(r'^Bad state:\s*'), '').trim(); + final lower = message.toLowerCase(); + if (message.contains('Phone workspace already contains files but no .git folder')) { + return '这个手机工作区已有非 Git 文件。请继续使用 Remote-linked/API 提交,或先清理该文件夹后再 Git 克隆。'; + } + if (message.contains('does not expose git')) { + return '当前 runtime 没有 git。可继续使用 Remote-linked/API 工作区,或安装 Helper/Termux git 后再克隆。'; + } + if (lower.contains('authentication failed') || lower.contains('could not read username')) { + return 'Git 克隆需要有效的 GitHub 凭据。公开仓库可先创建 Remote-linked 工作区;私有仓库请检查 token/权限。'; + } + return _compact(message.isEmpty ? error.toString() : message, 180); +} + +String _friendlyRuntimeSyncError(Object error) { + final message = error.toString().replaceFirst(RegExp(r'^Bad state:\s*'), '').trim(); + final lower = message.toLowerCase(); + if (lower.contains('permission denied') && (lower.contains('/sdcard') || lower.contains('/storage'))) { + return 'Termux 还没有共享存储权限。请在 Termux 运行 termux-setup-storage 后重试同步到共享目录。'; + } + if (lower.contains('not a termux daemon')) { + return '当前运行时不是 Termux daemon。请启动 Termux 里的 mobilecode helper daemon 后重试。'; + } + if (lower.contains('outside the termux daemon workspace root')) { + return 'Runtime workspace 路径不在 Termux daemon 工作区内,已阻止同步。请重新创建或刷新该仓库工作区。'; + } + return _compact(message.isEmpty ? error.toString() : message, 220); +} + +String _sharedRuntimeWorkspacePath(GitHubRepo repo) { + return p.posix.join( + '/sdcard/MobileCode/github', + _safeRuntimePathSegment(repo.owner), + _safeRuntimePathSegment(repo.name), + ); +} + +String _safeRuntimePathSegment(String value) { + final normalized = value.trim().replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '-'); + final trimmed = normalized.replaceAll(RegExp(r'^-+|-+$'), ''); + return trimmed.isEmpty ? 'repo' : trimmed; +} + +String _shellArg(String value) { + final escaped = value.replaceAll("'", "'\"'\"'"); + return "'$escaped'"; +} + +String _compact(String value, int limit) { + final singleLine = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (singleLine.length <= limit) return singleLine; + return '${singleLine.substring(0, limit - 1)}…'; +} + +String _bytesLabel(int bytes) { + if (bytes >= 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + if (bytes >= 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '$bytes B'; +} diff --git a/mobile_agent/lib/screens/github_repo_screen.dart b/mobile_agent/lib/screens/github_repo_screen.dart index 0c486b8..a8a5aae 100644 --- a/mobile_agent/lib/screens/github_repo_screen.dart +++ b/mobile_agent/lib/screens/github_repo_screen.dart @@ -1,1689 +1,1702 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../core/theme.dart'; -import '../services/github_deep_service.dart'; -import '../widgets/glass_card_widget.dart'; - -// ── File Icon Mapper ───────────────────────────────────────────────────────── - -final Map _fileIconMap = { - '.dart': Icons.flutter_dash, - '.py': Icons.terminal, - '.js': Icons.javascript, - '.ts': Icons.code, - '.go': Icons.speed, - '.rs': Icons.memory, - '.java': Icons.coffee, - '.cpp': Icons.computer, - '.c': Icons.computer, - '.h': Icons.header_outlined, - '.swift': Icons.apple, - '.kt': Icons.android, - '.rb': Icons.diamond, - '.php': Icons.web, - '.html': Icons.html, - '.css': Icons.style, - '.scss': Icons.style, - '.json': Icons.data_object, - '.yaml': Icons.settings, - '.yml': Icons.settings, - '.md': Icons.article, - '.txt': Icons.description, - '.sh': Icons.terminal, - '.dockerfile': Icons.cloud, - '.gitignore': Icons.remove_circle_outline, - '.toml': Icons.settings, - '.lock': Icons.lock, -}; - +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../core/theme.dart'; +import '../services/github_deep_service.dart'; +import '../widgets/glass_card_widget.dart'; + +// ── File Icon Mapper ───────────────────────────────────────────────────────── + +final Map _fileIconMap = { + '.dart': Icons.flutter_dash, + '.py': Icons.terminal, + '.js': Icons.javascript, + '.ts': Icons.code, + '.go': Icons.speed, + '.rs': Icons.memory, + '.java': Icons.coffee, + '.cpp': Icons.computer, + '.c': Icons.computer, + '.h': Icons.code, + '.swift': Icons.apple, + '.kt': Icons.android, + '.rb': Icons.diamond, + '.php': Icons.web, + '.html': Icons.html, + '.css': Icons.style, + '.scss': Icons.style, + '.json': Icons.data_object, + '.yaml': Icons.settings, + '.yml': Icons.settings, + '.md': Icons.article, + '.txt': Icons.description, + '.sh': Icons.terminal, + '.dockerfile': Icons.cloud, + '.gitignore': Icons.remove_circle_outline, + '.toml': Icons.settings, + '.lock': Icons.lock, +}; + final Map _fileIconColors = { - '.dart': Color(0xFF54C5F8), - '.py': Color(0xFF3572A5), - '.js': Color(0xFFF1E05A), - '.ts': Color(0xFF3178C6), - '.go': Color(0xFF00ADD8), - '.rs': Color(0xFFDEA584), - '.java': Color(0xFFB07219), - '.swift': Color(0xFFFFAC45), - '.kt': Color(0xFFA97BFF), - '.html': Color(0xFFE34C26), - '.css': Color(0xFF563D7C), + '.dart': Color(0xFF54C5F8), + '.py': Color(0xFF3572A5), + '.js': Color(0xFFF1E05A), + '.ts': Color(0xFF3178C6), + '.go': Color(0xFF00ADD8), + '.rs': Color(0xFFDEA584), + '.java': Color(0xFFB07219), + '.swift': Color(0xFFFFAC45), + '.kt': Color(0xFFA97BFF), + '.html': Color(0xFFE34C26), + '.css': Color(0xFF563D7C), '.md': Color(0xFF083FA1), }; -// ── File Item Model ────────────────────────────────────────────────────────── - -class RepoFileItem { - final String name; - final String path; - final String type; // 'file' | 'dir' | 'symlink' - final int? size; - final String? sha; - final String? htmlUrl; - final String? downloadUrl; - final String? gitUrl; - final DateTime? lastModified; - final String? content; // decoded content for files - - RepoFileItem({ - required this.name, - required this.path, - required this.type, - this.size, - this.sha, - this.htmlUrl, - this.downloadUrl, - this.gitUrl, - this.lastModified, - this.content, - }); - - factory RepoFileItem.fromGitHubApi(Map json) { - return RepoFileItem( - name: json['name'] as String? ?? '', - path: json['path'] as String? ?? '', - type: json['type'] as String? ?? 'file', - size: json['size'] as int?, - sha: json['sha'] as String?, - htmlUrl: json['html_url'] as String?, - downloadUrl: json['download_url'] as String?, - gitUrl: json['git_url'] as String?, - lastModified: json['last_modified'] != null - ? DateTime.tryParse(json['last_modified'] as String) - : null, - ); - } - - bool get isDirectory => type == 'dir'; - bool get isFile => type == 'file'; - - String get extension { - final dotIndex = name.lastIndexOf('.'); - if (dotIndex > 0) return name.substring(dotIndex); - return ''; - } - - IconData get icon { - if (isDirectory) return Icons.folder; - final ext = extension.toLowerCase(); - return _fileIconMap[ext] ?? Icons.insert_drive_file; - } - - Color get iconColor { - if (isDirectory) return AppTheme.warning; - final ext = extension.toLowerCase(); - return _fileIconColors[ext] ?? AppTheme.textSecondary; - } - - String get formattedSize { - if (size == null) return ''; - if (size! >= 1024 * 1024) return '${(size! / (1024 * 1024)).toStringAsFixed(1)} MB'; - if (size! >= 1024) return '${(size! / 1024).toStringAsFixed(1)} KB'; - return '$size B'; - } -} - -// ═════════════════════════════════════════════════════════════════════════════ -// GITHUB REPO SCREEN — Enhanced Repository Browser -// ═════════════════════════════════════════════════════════════════════════════ -/// File browser, README preview, branch selector, repo stats, -/// breadcrumb navigation, and context menus. -class GitHubRepoScreen extends StatefulWidget { - final String repoName; - final String owner; - final String? description; - final String? language; - - const GitHubRepoScreen({ - super.key, - required this.repoName, - required this.owner, - this.description, - this.language, - }); - - @override - State createState() => _GitHubRepoScreenState(); -} - -class _GitHubRepoScreenState extends State - with SingleTickerProviderStateMixin { - late TabController _tabController; - final GitHubDeepService _svc = GitHubDeepService(); - - // File browser state - List _currentFiles = []; - final List _pathStack = []; - String _currentBranch = 'main'; - List _branches = ['main', 'master']; - RepoFileItem? _selectedFile; - bool _isEditing = false; - bool _readmeVisible = true; - String? _readmeContent; - - // Repo info - Map _repoDetails = {}; - Map _languageStats = {}; - bool _loading = true; - bool _filesLoading = false; - String? _error; - - // Demo file tree for offline mode - late List _demoRootFiles; - - @override - void initState() { - super.initState(); - _tabController = TabController(length: 2, vsync: this); - _currentBranch = widget.language == 'Dart' ? 'main' : 'master'; - _demoRootFiles = _buildDemoFileTree(); - _currentFiles = _demoRootFiles; - _loadRepoInfo(); - _loadReadme(); - } - - // ── Demo Data ────────────────────────────────────────────────────────────── - - List _buildDemoFileTree() { - return [ - RepoFileItem(name: 'src', path: 'src', type: 'dir', lastModified: DateTime.now().subtract(const Duration(hours: 2))), - RepoFileItem(name: 'tests', path: 'tests', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 1))), - RepoFileItem(name: '.github', path: '.github', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 3))), - RepoFileItem(name: 'README.md', path: 'README.md', type: 'file', size: 2450, lastModified: DateTime.now().subtract(const Duration(days: 2))), - RepoFileItem(name: 'pubspec.yaml', path: 'pubspec.yaml', type: 'file', size: 1200, lastModified: DateTime.now().subtract(const Duration(days: 5))), - RepoFileItem(name: 'analysis_options.yaml', path: 'analysis_options.yaml', type: 'file', size: 450, lastModified: DateTime.now().subtract(const Duration(days: 7))), - RepoFileItem(name: '.gitignore', path: '.gitignore', type: 'file', size: 180, lastModified: DateTime.now().subtract(const Duration(days: 10))), - RepoFileItem(name: 'CHANGELOG.md', path: 'CHANGELOG.md', type: 'file', size: 890, lastModified: DateTime.now().subtract(const Duration(days: 4))), - RepoFileItem(name: 'LICENSE', path: 'LICENSE', type: 'file', size: 1100, lastModified: DateTime.now().subtract(const Duration(days: 14))), - ]; - } - - List _buildDemoSubFiles(String path) { - if (path == 'src') { - return [ - RepoFileItem(name: 'main.dart', path: 'src/main.dart', type: 'file', size: 3200, lastModified: DateTime.now().subtract(const Duration(hours: 1))), - RepoFileItem(name: 'app.dart', path: 'src/app.dart', type: 'file', size: 1800, lastModified: DateTime.now().subtract(const Duration(hours: 3))), - RepoFileItem(name: 'models', path: 'src/models', type: 'dir', lastModified: DateTime.now().subtract(const Duration(hours: 4))), - RepoFileItem(name: 'screens', path: 'src/screens', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 1))), - RepoFileItem(name: 'widgets', path: 'src/widgets', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 1))), - RepoFileItem(name: 'utils.dart', path: 'src/utils.dart', type: 'file', size: 950, lastModified: DateTime.now().subtract(const Duration(days: 2))), - ]; - } - if (path == 'tests') { - return [ - RepoFileItem(name: 'widget_test.dart', path: 'tests/widget_test.dart', type: 'file', size: 1200, lastModified: DateTime.now().subtract(const Duration(days: 2))), - RepoFileItem(name: 'unit_test.dart', path: 'tests/unit_test.dart', type: 'file', size: 800, lastModified: DateTime.now().subtract(const Duration(days: 3))), - ]; - } - if (path == '.github') { - return [ - RepoFileItem(name: 'workflows', path: '.github/workflows', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 5))), - RepoFileItem(name: 'ISSUE_TEMPLATE', path: '.github/ISSUE_TEMPLATE', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 7))), - ]; - } - if (path.endsWith('models')) { - return [ - RepoFileItem(name: 'user.dart', path: 'src/models/user.dart', type: 'file', size: 650, lastModified: DateTime.now().subtract(const Duration(days: 3))), - RepoFileItem(name: 'repo.dart', path: 'src/models/repo.dart', type: 'file', size: 720, lastModified: DateTime.now().subtract(const Duration(days: 2))), - ]; - } - if (path.endsWith('screens')) { - return [ - RepoFileItem(name: 'home_screen.dart', path: 'src/screens/home_screen.dart', type: 'file', size: 2100, lastModified: DateTime.now().subtract(const Duration(hours: 5))), - RepoFileItem(name: 'settings_screen.dart', path: 'src/screens/settings_screen.dart', type: 'file', size: 1500, lastModified: DateTime.now().subtract(const Duration(days: 1))), - ]; - } - if (path.endsWith('widgets')) { - return [ - RepoFileItem(name: 'custom_button.dart', path: 'src/widgets/custom_button.dart', type: 'file', size: 890, lastModified: DateTime.now().subtract(const Duration(days: 2))), - RepoFileItem(name: 'card_widget.dart', path: 'src/widgets/card_widget.dart', type: 'file', size: 1100, lastModified: DateTime.now().subtract(const Duration(days: 1))), - ]; - } - return []; - } - - // ── Data Loading ─────────────────────────────────────────────────────────── - - Future _loadRepoInfo() async { - try { - await _svc.initialize(); - if (_svc.isAuthenticated) { - final details = await _svc.getRepoDetails(widget.owner, widget.repoName); - final branches = await _svc.getBranches(widget.owner, widget.repoName); - setState(() { - _repoDetails = details; - _currentBranch = details['default_branch'] as String? ?? _currentBranch; - _branches = branches.map((b) => b['name'] as String? ?? '').where((n) => n.isNotEmpty).toList(); - _loading = false; - }); - } else { - setState(() => _loading = false); - } - } catch (e) { - setState(() { - _loading = false; - }); - } - } - - Future _loadDirectory(String path) async { - setState(() => _filesLoading = true); - try { - if (_svc.isAuthenticated) { - final contents = await _svc.getContents( - widget.owner, - widget.repoName, - path: path.isEmpty ? null : path, - ref: _currentBranch, - ); - setState(() { - _currentFiles = contents - .map((c) => RepoFileItem.fromGitHubApi(c as Map)) - .toList(); - _filesLoading = false; - }); - } else { - // Demo mode - setState(() { - _currentFiles = _buildDemoSubFiles(path); - _filesLoading = false; - }); - } - } catch (e) { - setState(() => _filesLoading = false); - _toast('Failed to load directory: \$e'); - } - } - - Future _loadReadme() async { - try { - if (_svc.isAuthenticated) { - final content = await _svc.getFileContent( - widget.owner, - widget.repoName, - 'README.md', - ref: _currentBranch, - ); - setState(() => _readmeContent = content); - } else { - setState(() => _readmeContent = '''# ${widget.repoName} - -${widget.description ?? 'A GitHub repository.'} - -## Getting Started - -This project is a starting point for a Flutter application. - -```bash -flutter pub get -flutter run -``` - -## Features - -- Clean architecture -- Dark mode UI -- GitHub integration -- Code editor with syntax highlighting - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Submit a pull request -'''); - } - } catch (_) { - // README may not exist - } - } - - // ── Navigation ───────────────────────────────────────────────────────────── - - void _onFileTap(RepoFileItem file) { - if (file.isDirectory) { - setState(() { - _pathStack.add(file.path); - _selectedFile = null; - }); - _loadDirectory(file.path); - } else { - setState(() => _selectedFile = file); - _loadFileContent(file); - } - } - - Future _loadFileContent(RepoFileItem file) async { - if (_svc.isAuthenticated && file.isFile) { - try { - final content = await _svc.getFileContent( - widget.owner, - widget.repoName, - file.path, - ref: _currentBranch, - ); - setState(() { - _selectedFile = RepoFileItem( - name: file.name, - path: file.path, - type: file.type, - size: file.size, - sha: file.sha, - content: content, - lastModified: file.lastModified, - ); - }); - } catch (e) { - _toast('Failed to load file: \$e'); - } - } - } - - void _onBreadcrumbTap(int index) { - if (index < 0) { - // Root - setState(() { - _pathStack.clear(); - _selectedFile = null; - _currentFiles = _demoRootFiles; - }); - } else { - final newPath = _pathStack[index]; - setState(() { - _pathStack.removeRange(index + 1, _pathStack.length); - _selectedFile = null; - }); - _loadDirectory(newPath); - } - } - - // ── Context Menu ─────────────────────────────────────────────────────────── - - void _showFileContextMenu(RepoFileItem file, Offset position) { - final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; - showMenu( - context: context, - position: RelativeRect.fromRect( - Rect.fromPoints(position, position.translate(0, 0)), - Offset.zero & overlay.size, - ), - color: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - items: [ - PopupMenuItem( - value: 'view', - onTap: () => _onFileTap(file), - child: const Row(children: [ - Icon(Icons.visibility, size: 18, color: AppTheme.primary), - SizedBox(width: 10), - Text('View', style: TextStyle(color: AppTheme.textPrimary)), - ]), - ), - if (file.isFile) ...[ - PopupMenuItem( - value: 'edit', - onTap: () => setState(() { - _selectedFile = file; - _isEditing = true; - }), - child: const Row(children: [ - Icon(Icons.edit, size: 18, color: AppTheme.accent), - SizedBox(width: 10), - Text('Edit', style: TextStyle(color: AppTheme.textPrimary)), - ]), - ), - ], - PopupMenuItem( - value: 'rename', - onTap: () => _showRenameDialog(file), - child: const Row(children: [ - Icon(Icons.drive_file_rename_outline, size: 18, color: AppTheme.info), - SizedBox(width: 10), - Text('Rename', style: TextStyle(color: AppTheme.textPrimary)), - ]), - ), - PopupMenuItem( - value: 'delete', - onTap: () => _showDeleteConfirm(file), - child: const Row(children: [ - Icon(Icons.delete_outline, size: 18, color: AppTheme.error), - SizedBox(width: 10), - Text('Delete', style: TextStyle(color: AppTheme.error)), - ]), - ), - PopupMenuItem( - value: 'copy', - onTap: () { - Clipboard.setData(ClipboardData(text: file.path)); - _toast('Path copied: ${file.path}'); - }, - child: const Row(children: [ - Icon(Icons.copy, size: 18, color: AppTheme.textSecondary), - SizedBox(width: 10), - Text('Copy Path', style: TextStyle(color: AppTheme.textPrimary)), - ]), - ), - ], - ); - } - - void _showRenameDialog(RepoFileItem file) { - final ctrl = TextEditingController(text: file.name); - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Rename', style: TextStyle(color: AppTheme.textPrimary)), - content: TextField( - controller: ctrl, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: const InputDecoration( - labelText: 'New name', - labelStyle: TextStyle(color: AppTheme.textSecondary), - enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.border)), - focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.primary)), - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), - ElevatedButton( - onPressed: () async { - Navigator.pop(ctx); - final newName = ctrl.text.trim(); - if (newName.isEmpty || newName == file.name) return; - try { - await _svc.renameFile( - widget.owner, widget.repoName, - file.path, newName, - 'Rename ${file.name} to \$newName', - branch: _currentBranch, - ); - _toast('Renamed to \$newName'); - _refreshCurrentDir(); - } catch (e) { - _toast('Rename failed: \$e', isError: true); - } - }, - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), - child: const Text('Rename'), - ), - ], - ), - ); - } - - void _showDeleteConfirm(RepoFileItem file) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Delete File?', style: TextStyle(color: AppTheme.error)), - content: Text('Are you sure you want to delete "${file.name}"? This action cannot be undone.', - style: const TextStyle(color: AppTheme.textSecondary)), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), - ElevatedButton( - onPressed: () async { - Navigator.pop(ctx); - try { - await _svc.deleteFile( - widget.owner, widget.repoName, - file.path, - 'Delete ${file.name}', - branch: _currentBranch, - ); - _toast('Deleted ${file.name}'); - _refreshCurrentDir(); - } catch (e) { - _toast('Delete failed: \$e', isError: true); - } - }, - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.error), - child: const Text('Delete'), - ), - ], - ), - ); - } - - void _refreshCurrentDir() { - if (_pathStack.isEmpty) { - _loadRepoInfo(); - } else { - _loadDirectory(_pathStack.last); - } - } - - // ── Create Actions ───────────────────────────────────────────────────────── - - void _showNewFileDialog() { - final nameCtrl = TextEditingController(); - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('New File', style: TextStyle(color: AppTheme.textPrimary)), - content: TextField( - controller: nameCtrl, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: const InputDecoration( - labelText: 'File name', - labelStyle: TextStyle(color: AppTheme.textSecondary), - hintText: 'example.dart', - hintStyle: TextStyle(color: AppTheme.textTertiary), - enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.border)), - focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.primary)), - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), - ElevatedButton( - onPressed: () async { - final name = nameCtrl.text.trim(); - if (name.isEmpty) return; - Navigator.pop(ctx); - final path = _pathStack.isEmpty ? name : '${_pathStack.last}/\$name'; - try { - await _svc.createOrUpdateFile( - widget.owner, widget.repoName, - path, '', - 'Create \$name', - branch: _currentBranch, - ); - _toast('Created \$name'); - _refreshCurrentDir(); - } catch (e) { - _toast('Failed: \$e', isError: true); - } - }, - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), - child: const Text('Create'), - ), - ], - ), - ); - } - - void _showNewFolderDialog() { - final nameCtrl = TextEditingController(); - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('New Folder', style: TextStyle(color: AppTheme.textPrimary)), - content: TextField( - controller: nameCtrl, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: const InputDecoration( - labelText: 'Folder name', - labelStyle: TextStyle(color: AppTheme.textSecondary), - hintText: 'new_folder', - hintStyle: TextStyle(color: AppTheme.textTertiary), - enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.border)), - focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.primary)), - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), - ElevatedButton( - onPressed: () async { - final name = nameCtrl.text.trim(); - if (name.isEmpty) return; - Navigator.pop(ctx); - final path = _pathStack.isEmpty ? '\$name/.gitkeep' : '${_pathStack.last}/\$name/.gitkeep'; - try { - await _svc.createOrUpdateFile( - widget.owner, widget.repoName, - path, '', - 'Create directory \$name', - branch: _currentBranch, - ); - _toast('Created folder \$name'); - _refreshCurrentDir(); - } catch (e) { - _toast('Failed: \$e', isError: true); - } - }, - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), - child: const Text('Create'), - ), - ], - ), - ); - } - - // ── Toasts ───────────────────────────────────────────────────────────────── - - void _toast(String msg, {bool isError = false}) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(msg, style: const TextStyle(color: AppTheme.textPrimary)), - backgroundColor: (isError ? AppTheme.error : AppTheme.success).withOpacity(0.9), - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - duration: const Duration(seconds: 3), - )); - } - - @override - void dispose() { - _tabController.dispose(); - _svc.dispose(); - super.dispose(); - } - - - // ═══════════════════════════════════════════════════════════════════════════ - // BUILD - // ═══════════════════════════════════════════════════════════════════════════ - - @override - Widget build(BuildContext context) { - if (_loading) { - return Scaffold( - backgroundColor: AppTheme.background, - body: const Center( - child: CircularProgressIndicator(color: AppTheme.primary), - ), - ); - } - - return Scaffold( - backgroundColor: AppTheme.background, - body: SafeArea( - child: Column( - children: [ - _buildTopBar(), - // Branch selector + actions - _buildBranchBar(), - // Tabs - Container( - color: AppTheme.backgroundElevated, - child: TabBar( - controller: _tabController, - indicatorColor: AppTheme.primary, - labelColor: AppTheme.primary, - unselectedLabelColor: AppTheme.textTertiary, - labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), - unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13), - tabs: const [ - Tab(icon: Icon(Icons.folder_outlined, size: 18), text: 'Files'), - Tab(icon: Icon(Icons.info_outline, size: 18), text: 'About'), - ], - ), - ), - // Tab content - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _buildFilesTab(), - _buildAboutTab(), - ], - ), - ), - ], - ), - ), - ); - } - - // ── Top Bar with Breadcrumb ──────────────────────────────────────────────── - - Widget _buildTopBar() { - return Container( - height: 52, - decoration: BoxDecoration( - color: AppTheme.backgroundElevated, - border: Border(bottom: BorderSide(color: AppTheme.divider)), - ), - child: Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back, size: 20, color: AppTheme.textSecondary), - ), - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - // Repo name (root) - InkWell( - onTap: () => _onBreadcrumbTap(-1), - child: Text( - widget.repoName, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: _pathStack.isEmpty ? AppTheme.textPrimary : AppTheme.accent, - fontFamily: AppTheme.fontCode, - ), - ), - ), - // Path segments - ..._pathStack.asMap().entries.expand((entry) { - final i = entry.key; - final segment = entry.value.split('/').last; - return [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: Icon(Icons.chevron_right, size: 16, color: AppTheme.textTertiary), - ), - InkWell( - onTap: () => _onBreadcrumbTap(i), - child: Text( - segment, - style: TextStyle( - fontSize: 13, - color: i == _pathStack.length - 1 - ? AppTheme.textPrimary - : AppTheme.accent, - fontWeight: i == _pathStack.length - 1 - ? FontWeight.w500 - : FontWeight.normal, - fontFamily: AppTheme.fontCode, - ), - ), - ), - ]; - }), - ], - ), - ), - ), - // File actions - if (_selectedFile != null && !_selectedFile!.isDirectory) ...[ - if (!_isEditing) - IconButton( - onPressed: () => setState(() => _isEditing = true), - icon: const Icon(Icons.edit, size: 20, color: AppTheme.accent), - tooltip: 'Edit', - ) - else - IconButton( - onPressed: () => _showCommitDialog(), - icon: const Icon(Icons.check, size: 20, color: AppTheme.success), - tooltip: 'Commit', - ), - ], - const SizedBox(width: 8), - ], - ), - ); - } - - // ── Branch Selector Bar ──────────────────────────────────────────────────── - - Widget _buildBranchBar() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppTheme.backgroundElevated, - border: Border(bottom: BorderSide(color: AppTheme.divider)), - ), - child: Row( - children: [ - // Branch selector - InkWell( - onTap: _showBranchSelector, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.surface.withOpacity(0.5), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppTheme.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.account_tree, size: 14, color: AppTheme.primary), - const SizedBox(width: 6), - Text( - _currentBranch, - style: const TextStyle( - fontSize: 12, - fontFamily: AppTheme.fontCode, - color: AppTheme.textPrimary, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 4), - const Icon(Icons.keyboard_arrow_down, size: 16, color: AppTheme.textTertiary), - ], - ), - ), - ), - const Spacer(), - // Quick action buttons - _buildActionButton(Icons.upload_file, 'Upload', _showUploadDialog), - const SizedBox(width: 6), - _buildActionButton(Icons.note_add, 'New', _showNewFileDialog), - const SizedBox(width: 6), - _buildActionButton(Icons.create_new_folder, 'Folder', _showNewFolderDialog), - ], - ), - ); - } - - Widget _buildActionButton(IconData icon, String tooltip, VoidCallback onTap) { - return Tooltip( - message: tooltip, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: AppTheme.surface.withOpacity(0.5), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppTheme.border), - ), - child: Icon(icon, size: 16, color: AppTheme.textSecondary), - ), - ), - ); - } - - void _showBranchSelector() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container(width: 40, height: 4, - decoration: BoxDecoration(color: AppTheme.border, borderRadius: BorderRadius.circular(2)), - ), - ), - const SizedBox(height: 16), - const Text('Switch Branch', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), - ), - const SizedBox(height: 12), - // Search/filter branches - TextField( - style: const TextStyle(color: AppTheme.textPrimary, fontSize: 14), - decoration: InputDecoration( - filled: true, - fillColor: AppTheme.surfaceInput, - hintText: 'Filter branches...', - hintStyle: const TextStyle(color: AppTheme.textTertiary), - prefixIcon: const Icon(Icons.search, size: 18, color: AppTheme.textTertiary), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.border)), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.border)), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ), - onChanged: (v) { - // Filter branches logic handled by setState in real implementation - }, - ), - const SizedBox(height: 8), - SizedBox( - height: 300, - child: ListView.builder( - itemCount: _branches.length, - itemBuilder: (_, i) { - final branch = _branches[i]; - final isActive = branch == _currentBranch; - return ListTile( - dense: true, - leading: Icon( - isActive ? Icons.check_circle : Icons.account_tree, - size: 18, - color: isActive ? AppTheme.primary : AppTheme.textTertiary, - ), - title: Text(branch, - style: TextStyle( - color: isActive ? AppTheme.primary : AppTheme.textPrimary, - fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, - fontFamily: AppTheme.fontCode, - fontSize: 13, - ), - ), - trailing: isActive - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.primary.withOpacity(0.15), - borderRadius: BorderRadius.circular(6), - ), - child: const Text('ACTIVE', style: TextStyle(fontSize: 9, color: AppTheme.primary, fontWeight: FontWeight.w700)), - ) - : null, - onTap: () { - Navigator.pop(ctx); - if (!isActive) { - setState(() => _currentBranch = branch); - _loadReadme(); - if (_pathStack.isNotEmpty) { - _loadDirectory(_pathStack.last); - } - _toast('Switched to branch \$branch'); - } - }, - ); - }, - ), - ), - ], - ), - ), - ); - } - - void _showUploadDialog() { - _toast('File upload coming soon'); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // FILES TAB - // ═══════════════════════════════════════════════════════════════════════════ - - Widget _buildFilesTab() { - if (_selectedFile != null && _selectedFile!.isFile && !_isEditing) { - return _buildFileViewer(); - } - if (_isEditing && _selectedFile != null) { - return _buildFileEditor(); - } - return Column( - children: [ - // File count - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - alignment: Alignment.centerLeft, - child: Text( - '${_currentFiles.length} items', - style: const TextStyle(fontSize: 12, color: AppTheme.textTertiary), - ), - ), - // File list - Expanded( - child: _filesLoading - ? const Center(child: CircularProgressIndicator(color: AppTheme.primary)) - : RefreshIndicator( - onRefresh: () async => _refreshCurrentDir(), - color: AppTheme.primary, - backgroundColor: AppTheme.surface, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - itemCount: _currentFiles.length, - itemBuilder: (_, i) => _buildFileItem(_currentFiles[i]), - ), - ), - ), - // README toggle - if (_readmeContent != null && _pathStack.isEmpty && _selectedFile == null) - _buildReadmeToggle(), - ], - ); - } - - Widget _buildFileItem(RepoFileItem file) { - return InkWell( - onTap: () => _onFileTap(file), - onLongPress: () { - final RenderBox box = context.findRenderObject() as RenderBox; - _showFileContextMenu(file, box.localToGlobal(Offset.zero)); - }, - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - margin: const EdgeInsets.only(bottom: 4), - decoration: BoxDecoration( - color: AppTheme.surface.withOpacity(0.4), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.border.withOpacity(0.5)), - ), - child: Row( - children: [ - // File/directory icon - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: file.iconColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(file.icon, size: 20, color: file.iconColor), - ), - const SizedBox(width: 12), - // File info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - file.name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppTheme.textPrimary, - fontFamily: AppTheme.fontCode, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - if (file.formattedSize.isNotEmpty) - Text( - file.formattedSize, - style: const TextStyle(fontSize: 11, color: AppTheme.textTertiary), - ), - if (file.formattedSize.isNotEmpty) - const SizedBox(width: 10), - if (file.lastModified != null) - Text( - _ago(file.lastModified!), - style: const TextStyle(fontSize: 11, color: AppTheme.textTertiary), - ), - ], - ), - ], - ), - ), - // Trailing icon - if (file.isDirectory) - const Icon(Icons.chevron_right, size: 18, color: AppTheme.textTertiary) - else - IconButton( - icon: const Icon(Icons.more_vert, size: 18, color: AppTheme.textTertiary), - onPressed: () { - final RenderBox box = context.findRenderObject() as RenderBox; - _showFileContextMenu(file, box.localToGlobal(Offset.zero)); - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - ), - ], - ), - ), - ); - } - - // ── File Viewer ──────────────────────────────────────────────────────────── - - Widget _buildFileViewer() { - return Column( - children: [ - // Read-only indicator - Container( - height: 32, - color: AppTheme.backgroundElevated, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.visibility, size: 14, color: AppTheme.textTertiary), - const SizedBox(width: 6), - Text( - '${_selectedFile!.name} \u00B7 ${_selectedFile!.formattedSize}', - style: const TextStyle(fontSize: 12, color: AppTheme.textTertiary), - ), - ], - ), - ), - // File content - Expanded( - child: Container( - color: AppTheme.editorBackground, - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: SelectableText( - _selectedFile!.content ?? 'No content available.', - style: TextStyle( - fontSize: 13, - fontFamily: AppTheme.fontCode, - color: AppTheme.textPrimary.withOpacity(0.9), - height: 1.6, - ), - ), - ), - ), - ), - ], - ); - } - - Widget _buildFileEditor() { - return Column( - children: [ - Container( - height: 32, - color: AppTheme.backgroundElevated, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.edit, size: 14, color: AppTheme.accent), - const SizedBox(width: 6), - Text( - 'Editing: ${_selectedFile!.name}', - style: const TextStyle(fontSize: 12, color: AppTheme.accent), - ), - ], - ), - ), - Expanded( - child: Container( - color: AppTheme.editorBackground, - child: TextField( - controller: TextEditingController(text: _selectedFile!.content ?? ''), - style: TextStyle( - fontSize: 13, - fontFamily: AppTheme.fontCode, - color: AppTheme.textPrimary.withOpacity(0.9), - height: 1.6, - ), - maxLines: null, - expands: true, - decoration: const InputDecoration( - border: InputBorder.none, - contentPadding: EdgeInsets.all(16), - ), - ), - ), - ), - ], - ); - } - - void _showCommitDialog() { - final messageController = TextEditingController(); - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom + 20, - top: 20, - left: 20, - right: 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center(child: Container(width: 40, height: 4, - decoration: BoxDecoration(color: AppTheme.border, borderRadius: BorderRadius.circular(2)), - )), - const SizedBox(height: 20), - const Text('Commit Changes', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), - ), - const SizedBox(height: 8), - Text('File: ${_selectedFile?.name}', - style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), - ), - const SizedBox(height: 16), - TextField( - controller: messageController, - autofocus: true, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: InputDecoration( - filled: true, - fillColor: AppTheme.surfaceInput, - labelText: 'Commit message', - labelStyle: const TextStyle(color: AppTheme.textSecondary), - hintText: 'Update ${_selectedFile?.name}', - hintStyle: const TextStyle(color: AppTheme.textTertiary), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.border)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.primary, width: 1.5)), - ), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () async { - if (messageController.text.isNotEmpty && _selectedFile != null) { - setState(() => _isEditing = false); - Navigator.pop(context); - try { - await _svc.createOrUpdateFile( - widget.owner, widget.repoName, - _selectedFile!.path, - _selectedFile!.content ?? '', - messageController.text, - branch: _currentBranch, - ); - _toast('Committed changes'); - } catch (e) { - _toast('Commit failed: \$e', isError: true); - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.success, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: const Text('Commit', style: TextStyle(fontWeight: FontWeight.w600)), - ), - ), - ], - ), - ), - ); - } - - // ── README ───────────────────────────────────────────────────────────────── - - Widget _buildReadmeToggle() { - return Container( - decoration: BoxDecoration( - color: AppTheme.backgroundElevated, - border: Border(top: BorderSide(color: AppTheme.divider)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: () => setState(() => _readmeVisible = !_readmeVisible), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - const Icon(Icons.article, size: 16, color: AppTheme.accent), - const SizedBox(width: 8), - const Text('README.md', - style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textPrimary), - ), - const Spacer(), - AnimatedRotation( - turns: _readmeVisible ? 0.5 : 0, - duration: const Duration(milliseconds: 200), - child: const Icon(Icons.keyboard_arrow_down, size: 20, color: AppTheme.textTertiary), - ), - ], - ), - ), - ), - if (_readmeVisible && _readmeContent != null) - Container( - height: 300, - color: AppTheme.editorBackground, - child: Markdown( - data: _readmeContent!, - selectable: true, - styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( - p: const TextStyle(fontSize: 14, color: AppTheme.textSecondary, height: 1.6), - h1: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), - h2: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), - h3: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.textPrimary), - code: TextStyle( - fontSize: 12, - fontFamily: AppTheme.fontCode, - color: AppTheme.textPrimary, - backgroundColor: AppTheme.surfaceHover, - ), - codeblockDecoration: BoxDecoration( - color: AppTheme.surfaceHover, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppTheme.border), - ), - blockquote: const TextStyle(fontSize: 14, color: AppTheme.textSecondary, fontStyle: FontStyle.italic), - blockquoteDecoration: BoxDecoration( - border: Border(left: BorderSide(color: AppTheme.primary, width: 3)), - color: AppTheme.primary.withOpacity(0.05), - ), - listBullet: const TextStyle(color: AppTheme.textSecondary), - a: const TextStyle(color: AppTheme.accent, decoration: TextDecoration.underline), - ), - onTapLink: (text, href, title) { - if (href != null) launchUrl(Uri.parse(href)); - }, - ), - ), - ], - ), - ); - } - - - // ═══════════════════════════════════════════════════════════════════════════ - // ABOUT TAB (Repo Info) - // ═══════════════════════════════════════════════════════════════════════════ - - Widget _buildAboutTab() { - final stars = _repoDetails['stargazers_count'] as int? ?? 0; - final forks = _repoDetails['forks_count'] as int? ?? 0; - final watchers = _repoDetails['watchers_count'] as int? ?? 0; - final openIssues = _repoDetails['open_issues_count'] as int? ?? 0; - final topics = (_repoDetails['topics'] as List?)?.cast() ?? []; - final license = (_repoDetails['license'] as Map?)?.cast()?['name'] as String?; - final language = _repoDetails['language'] as String? ?? widget.language; - final description = _repoDetails['description'] as String? ?? widget.description ?? ''; - final isPrivate = _repoDetails['private'] as bool? ?? false; - final createdAt = _repoDetails['created_at'] != null - ? DateTime.tryParse(_repoDetails['created_at'] as String) - : null; - - return RefreshIndicator( - onRefresh: _loadRepoInfo, - color: AppTheme.primary, - backgroundColor: AppTheme.surface, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Repo header card - GlassCardWidget( - padding: const EdgeInsets.all(20), - borderRadius: 16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - '${widget.owner}/${widget.repoName}', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - fontFamily: AppTheme.fontCode, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: isPrivate ? AppTheme.warning.withOpacity(0.15) : AppTheme.success.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: isPrivate ? AppTheme.warning.withOpacity(0.3) : AppTheme.success.withOpacity(0.3), - width: 0.5, - ), - ), - child: Text( - isPrivate ? 'PRIVATE' : 'PUBLIC', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w700, - color: isPrivate ? AppTheme.warning : AppTheme.success, - letterSpacing: 0.5, - ), - ), - ), - ], - ), - if (description.isNotEmpty) ...[ - const SizedBox(height: 10), - Text( - description, - style: const TextStyle(fontSize: 14, color: AppTheme.textSecondary, height: 1.5), - ), - ], - if (createdAt != null) ...[ - const SizedBox(height: 8), - Text( - 'Created ${_ago(createdAt)}', - style: const TextStyle(fontSize: 11, color: AppTheme.textTertiary), - ), - ], - ], - ), - ), - const SizedBox(height: 16), - - // Stats grid - Row( - children: [ - Expanded(child: _buildStatCard(Icons.star, 'Stars', '$stars', AppTheme.warning)), - const SizedBox(width: 8), - Expanded(child: _buildStatCard(Icons.call_split, 'Forks', '$forks', AppTheme.accent)), - const SizedBox(width: 8), - Expanded(child: _buildStatCard(Icons.visibility, 'Watch', '$watchers', AppTheme.info)), - const SizedBox(width: 8), - Expanded(child: _buildStatCard(Icons.error_outline, 'Issues', '$openIssues', AppTheme.error)), - ], - ), - const SizedBox(height: 16), - - // Language bar - if (language != null) ...[ - const Text('Languages', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.textPrimary), - ), - const SizedBox(height: 10), - _buildLanguageBar(language), - const SizedBox(height: 16), - ], - - // Topics - if (topics.isNotEmpty) ...[ - const Text('Topics', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.textPrimary), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: topics.map((t) => Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: BoxDecoration( - color: AppTheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppTheme.primary.withOpacity(0.3)), - ), - child: Text( - t, - style: const TextStyle(fontSize: 12, color: AppTheme.primary, fontWeight: FontWeight.w500), - ), - )).toList(), - ), - const SizedBox(height: 16), - ], - - // License - if (license != null) - _buildInfoRow(Icons.balance, 'License', license), - _buildInfoRow(Icons.code, 'Default Branch', _currentBranch), - _buildInfoRow(Icons.language, 'Language', language ?? 'Unknown'), - - const SizedBox(height: 20), - - // "Open in MobileCode" button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - _toast('Cloning to local workspace...'); - // Integration with local workspace - }, - icon: const Icon(Icons.computer, size: 18), - label: const Text('Open in MobileCode', style: TextStyle(fontWeight: FontWeight.w600)), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.accent, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - const SizedBox(height: 10), - - // GitHub link - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () { - final url = 'https://github.com/${widget.owner}/${widget.repoName}'; - launchUrl(Uri.parse(url)); - }, - icon: const Icon(Icons.open_in_browser, size: 18), - label: const Text('View on GitHub', style: TextStyle(fontWeight: FontWeight.w600)), - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.textPrimary, - side: const BorderSide(color: AppTheme.border), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - ], - ), - ); - } - - Widget _buildStatCard(IconData icon, String label, String value, Color color) { - return GlassCardWidget( - padding: const EdgeInsets.all(12), - borderRadius: 12, - child: Column( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, size: 18, color: color), - ), - const SizedBox(height: 8), - Text( - value, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - label, - style: const TextStyle(fontSize: 11, color: AppTheme.textTertiary), - ), - ], - ), - ); - } - +final Map _languageColors = { + 'Dart': Color(0xFF00B4AB), + 'JavaScript': Color(0xFFF1E05A), + 'TypeScript': Color(0xFF3178C6), + 'Python': Color(0xFF3572A5), + 'Java': Color(0xFFB07219), + 'Kotlin': Color(0xFFA97BFF), + 'Swift': Color(0xFFFFAC45), + 'Go': Color(0xFF00ADD8), + 'Rust': Color(0xFFDEA584), + 'HTML': Color(0xFFE34C26), + 'CSS': Color(0xFF563D7C), +}; + +// ── File Item Model ────────────────────────────────────────────────────────── + +class RepoFileItem { + final String name; + final String path; + final String type; // 'file' | 'dir' | 'symlink' + final int? size; + final String? sha; + final String? htmlUrl; + final String? downloadUrl; + final String? gitUrl; + final DateTime? lastModified; + final String? content; // decoded content for files + + RepoFileItem({ + required this.name, + required this.path, + required this.type, + this.size, + this.sha, + this.htmlUrl, + this.downloadUrl, + this.gitUrl, + this.lastModified, + this.content, + }); + + factory RepoFileItem.fromGitHubApi(Map json) { + return RepoFileItem( + name: json['name'] as String? ?? '', + path: json['path'] as String? ?? '', + type: json['type'] as String? ?? 'file', + size: json['size'] as int?, + sha: json['sha'] as String?, + htmlUrl: json['html_url'] as String?, + downloadUrl: json['download_url'] as String?, + gitUrl: json['git_url'] as String?, + lastModified: json['last_modified'] != null + ? DateTime.tryParse(json['last_modified'] as String) + : null, + ); + } + + bool get isDirectory => type == 'dir'; + bool get isFile => type == 'file'; + + String get extension { + final dotIndex = name.lastIndexOf('.'); + if (dotIndex > 0) return name.substring(dotIndex); + return ''; + } + + IconData get icon { + if (isDirectory) return Icons.folder; + final ext = extension.toLowerCase(); + return _fileIconMap[ext] ?? Icons.insert_drive_file; + } + + Color get iconColor { + if (isDirectory) return AppTheme.warning; + final ext = extension.toLowerCase(); + return _fileIconColors[ext] ?? AppTheme.textSecondary; + } + + String get formattedSize { + if (size == null) return ''; + if (size! >= 1024 * 1024) return '${(size! / (1024 * 1024)).toStringAsFixed(1)} MB'; + if (size! >= 1024) return '${(size! / 1024).toStringAsFixed(1)} KB'; + return '$size B'; + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// GITHUB REPO SCREEN — Enhanced Repository Browser +// ═════════════════════════════════════════════════════════════════════════════ +/// File browser, README preview, branch selector, repo stats, +/// breadcrumb navigation, and context menus. +class GitHubRepoScreen extends StatefulWidget { + final String repoName; + final String owner; + final String? description; + final String? language; + + const GitHubRepoScreen({ + super.key, + required this.repoName, + required this.owner, + this.description, + this.language, + }); + + @override + State createState() => _GitHubRepoScreenState(); +} + +class _GitHubRepoScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + final GitHubDeepService _svc = GitHubDeepService(); + + // File browser state + List _currentFiles = []; + final List _pathStack = []; + String _currentBranch = 'main'; + List _branches = ['main', 'master']; + RepoFileItem? _selectedFile; + bool _isEditing = false; + bool _readmeVisible = true; + String? _readmeContent; + + // Repo info + Map _repoDetails = {}; + Map _languageStats = {}; + bool _loading = true; + bool _filesLoading = false; + String? _error; + + // Demo file tree for offline mode + late List _demoRootFiles; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _currentBranch = widget.language == 'Dart' ? 'main' : 'master'; + _demoRootFiles = _buildDemoFileTree(); + _currentFiles = _demoRootFiles; + _loadRepoInfo(); + _loadReadme(); + } + + // ── Demo Data ────────────────────────────────────────────────────────────── + + List _buildDemoFileTree() { + return [ + RepoFileItem(name: 'src', path: 'src', type: 'dir', lastModified: DateTime.now().subtract(const Duration(hours: 2))), + RepoFileItem(name: 'tests', path: 'tests', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 1))), + RepoFileItem(name: '.github', path: '.github', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 3))), + RepoFileItem(name: 'README.md', path: 'README.md', type: 'file', size: 2450, lastModified: DateTime.now().subtract(const Duration(days: 2))), + RepoFileItem(name: 'pubspec.yaml', path: 'pubspec.yaml', type: 'file', size: 1200, lastModified: DateTime.now().subtract(const Duration(days: 5))), + RepoFileItem(name: 'analysis_options.yaml', path: 'analysis_options.yaml', type: 'file', size: 450, lastModified: DateTime.now().subtract(const Duration(days: 7))), + RepoFileItem(name: '.gitignore', path: '.gitignore', type: 'file', size: 180, lastModified: DateTime.now().subtract(const Duration(days: 10))), + RepoFileItem(name: 'CHANGELOG.md', path: 'CHANGELOG.md', type: 'file', size: 890, lastModified: DateTime.now().subtract(const Duration(days: 4))), + RepoFileItem(name: 'LICENSE', path: 'LICENSE', type: 'file', size: 1100, lastModified: DateTime.now().subtract(const Duration(days: 14))), + ]; + } + + List _buildDemoSubFiles(String path) { + if (path == 'src') { + return [ + RepoFileItem(name: 'main.dart', path: 'src/main.dart', type: 'file', size: 3200, lastModified: DateTime.now().subtract(const Duration(hours: 1))), + RepoFileItem(name: 'app.dart', path: 'src/app.dart', type: 'file', size: 1800, lastModified: DateTime.now().subtract(const Duration(hours: 3))), + RepoFileItem(name: 'models', path: 'src/models', type: 'dir', lastModified: DateTime.now().subtract(const Duration(hours: 4))), + RepoFileItem(name: 'screens', path: 'src/screens', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 1))), + RepoFileItem(name: 'widgets', path: 'src/widgets', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 1))), + RepoFileItem(name: 'utils.dart', path: 'src/utils.dart', type: 'file', size: 950, lastModified: DateTime.now().subtract(const Duration(days: 2))), + ]; + } + if (path == 'tests') { + return [ + RepoFileItem(name: 'widget_test.dart', path: 'tests/widget_test.dart', type: 'file', size: 1200, lastModified: DateTime.now().subtract(const Duration(days: 2))), + RepoFileItem(name: 'unit_test.dart', path: 'tests/unit_test.dart', type: 'file', size: 800, lastModified: DateTime.now().subtract(const Duration(days: 3))), + ]; + } + if (path == '.github') { + return [ + RepoFileItem(name: 'workflows', path: '.github/workflows', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 5))), + RepoFileItem(name: 'ISSUE_TEMPLATE', path: '.github/ISSUE_TEMPLATE', type: 'dir', lastModified: DateTime.now().subtract(const Duration(days: 7))), + ]; + } + if (path.endsWith('models')) { + return [ + RepoFileItem(name: 'user.dart', path: 'src/models/user.dart', type: 'file', size: 650, lastModified: DateTime.now().subtract(const Duration(days: 3))), + RepoFileItem(name: 'repo.dart', path: 'src/models/repo.dart', type: 'file', size: 720, lastModified: DateTime.now().subtract(const Duration(days: 2))), + ]; + } + if (path.endsWith('screens')) { + return [ + RepoFileItem(name: 'home_screen.dart', path: 'src/screens/home_screen.dart', type: 'file', size: 2100, lastModified: DateTime.now().subtract(const Duration(hours: 5))), + RepoFileItem(name: 'settings_screen.dart', path: 'src/screens/settings_screen.dart', type: 'file', size: 1500, lastModified: DateTime.now().subtract(const Duration(days: 1))), + ]; + } + if (path.endsWith('widgets')) { + return [ + RepoFileItem(name: 'custom_button.dart', path: 'src/widgets/custom_button.dart', type: 'file', size: 890, lastModified: DateTime.now().subtract(const Duration(days: 2))), + RepoFileItem(name: 'card_widget.dart', path: 'src/widgets/card_widget.dart', type: 'file', size: 1100, lastModified: DateTime.now().subtract(const Duration(days: 1))), + ]; + } + return []; + } + + // ── Data Loading ─────────────────────────────────────────────────────────── + + Future _loadRepoInfo() async { + try { + await _svc.initialize(); + if (_svc.isAuthenticated) { + final details = await _svc.getRepoDetails(widget.owner, widget.repoName); + final branches = await _svc.getBranches(widget.owner, widget.repoName); + setState(() { + _repoDetails = details; + _currentBranch = details['default_branch'] as String? ?? _currentBranch; + _branches = branches.map((b) => b['name'] as String? ?? '').where((n) => n.isNotEmpty).toList(); + _loading = false; + }); + } else { + setState(() => _loading = false); + } + } catch (e) { + setState(() { + _loading = false; + }); + } + } + + Future _loadDirectory(String path) async { + setState(() => _filesLoading = true); + try { + if (_svc.isAuthenticated) { + final contents = await _svc.getContents( + widget.owner, + widget.repoName, + path: path.isEmpty ? null : path, + ref: _currentBranch, + ); + setState(() { + _currentFiles = contents + .map((c) => RepoFileItem.fromGitHubApi(c as Map)) + .toList(); + _filesLoading = false; + }); + } else { + // Demo mode + setState(() { + _currentFiles = _buildDemoSubFiles(path); + _filesLoading = false; + }); + } + } catch (e) { + setState(() => _filesLoading = false); + _toast('Failed to load directory: \$e'); + } + } + + Future _loadReadme() async { + try { + if (_svc.isAuthenticated) { + final content = await _svc.getFileContent( + widget.owner, + widget.repoName, + 'README.md', + ref: _currentBranch, + ); + setState(() => _readmeContent = content); + } else { + setState(() => _readmeContent = '''# ${widget.repoName} + +${widget.description ?? 'A GitHub repository.'} + +## Getting Started + +This project is a starting point for a Flutter application. + +```bash +flutter pub get +flutter run +``` + +## Features + +- Clean architecture +- Dark mode UI +- GitHub integration +- Code editor with syntax highlighting + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Submit a pull request +'''); + } + } catch (_) { + // README may not exist + } + } + + // ── Navigation ───────────────────────────────────────────────────────────── + + void _onFileTap(RepoFileItem file) { + if (file.isDirectory) { + setState(() { + _pathStack.add(file.path); + _selectedFile = null; + }); + _loadDirectory(file.path); + } else { + setState(() => _selectedFile = file); + _loadFileContent(file); + } + } + + Future _loadFileContent(RepoFileItem file) async { + if (_svc.isAuthenticated && file.isFile) { + try { + final content = await _svc.getFileContent( + widget.owner, + widget.repoName, + file.path, + ref: _currentBranch, + ); + setState(() { + _selectedFile = RepoFileItem( + name: file.name, + path: file.path, + type: file.type, + size: file.size, + sha: file.sha, + content: content, + lastModified: file.lastModified, + ); + }); + } catch (e) { + _toast('Failed to load file: \$e'); + } + } + } + + void _onBreadcrumbTap(int index) { + if (index < 0) { + // Root + setState(() { + _pathStack.clear(); + _selectedFile = null; + _currentFiles = _demoRootFiles; + }); + } else { + final newPath = _pathStack[index]; + setState(() { + _pathStack.removeRange(index + 1, _pathStack.length); + _selectedFile = null; + }); + _loadDirectory(newPath); + } + } + + // ── Context Menu ─────────────────────────────────────────────────────────── + + void _showFileContextMenu(RepoFileItem file, Offset position) { + final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; + showMenu( + context: context, + position: RelativeRect.fromRect( + Rect.fromPoints(position, position.translate(0, 0)), + Offset.zero & overlay.size, + ), + color: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + items: [ + PopupMenuItem( + value: 'view', + onTap: () => _onFileTap(file), + child: const Row(children: [ + Icon(Icons.visibility, size: 18, color: AppTheme.primary), + SizedBox(width: 10), + Text('View', style: TextStyle(color: AppTheme.textPrimary)), + ]), + ), + if (file.isFile) ...[ + PopupMenuItem( + value: 'edit', + onTap: () => setState(() { + _selectedFile = file; + _isEditing = true; + }), + child: const Row(children: [ + Icon(Icons.edit, size: 18, color: AppTheme.accent), + SizedBox(width: 10), + Text('Edit', style: TextStyle(color: AppTheme.textPrimary)), + ]), + ), + ], + PopupMenuItem( + value: 'rename', + onTap: () => _showRenameDialog(file), + child: const Row(children: [ + Icon(Icons.drive_file_rename_outline, size: 18, color: AppTheme.info), + SizedBox(width: 10), + Text('Rename', style: TextStyle(color: AppTheme.textPrimary)), + ]), + ), + PopupMenuItem( + value: 'delete', + onTap: () => _showDeleteConfirm(file), + child: const Row(children: [ + Icon(Icons.delete_outline, size: 18, color: AppTheme.error), + SizedBox(width: 10), + Text('Delete', style: TextStyle(color: AppTheme.error)), + ]), + ), + PopupMenuItem( + value: 'copy', + onTap: () { + Clipboard.setData(ClipboardData(text: file.path)); + _toast('Path copied: ${file.path}'); + }, + child: const Row(children: [ + Icon(Icons.copy, size: 18, color: AppTheme.textSecondary), + SizedBox(width: 10), + Text('Copy Path', style: TextStyle(color: AppTheme.textPrimary)), + ]), + ), + ], + ); + } + + void _showRenameDialog(RepoFileItem file) { + final ctrl = TextEditingController(text: file.name); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Rename', style: TextStyle(color: AppTheme.textPrimary)), + content: TextField( + controller: ctrl, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: const InputDecoration( + labelText: 'New name', + labelStyle: TextStyle(color: AppTheme.textSecondary), + enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.border)), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.primary)), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), + ElevatedButton( + onPressed: () async { + Navigator.pop(ctx); + final newName = ctrl.text.trim(); + if (newName.isEmpty || newName == file.name) return; + try { + await _svc.renameFile( + widget.owner, widget.repoName, + file.path, newName, + 'Rename ${file.name} to \$newName', + branch: _currentBranch, + ); + _toast('Renamed to \$newName'); + _refreshCurrentDir(); + } catch (e) { + _toast('Rename failed: \$e', isError: true); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), + child: const Text('Rename'), + ), + ], + ), + ); + } + + void _showDeleteConfirm(RepoFileItem file) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Delete File?', style: TextStyle(color: AppTheme.error)), + content: Text('Are you sure you want to delete "${file.name}"? This action cannot be undone.', + style: const TextStyle(color: AppTheme.textSecondary)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), + ElevatedButton( + onPressed: () async { + Navigator.pop(ctx); + try { + await _svc.deleteFile( + widget.owner, widget.repoName, + file.path, + 'Delete ${file.name}', + branch: _currentBranch, + ); + _toast('Deleted ${file.name}'); + _refreshCurrentDir(); + } catch (e) { + _toast('Delete failed: \$e', isError: true); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.error), + child: const Text('Delete'), + ), + ], + ), + ); + } + + void _refreshCurrentDir() { + if (_pathStack.isEmpty) { + _loadRepoInfo(); + } else { + _loadDirectory(_pathStack.last); + } + } + + // ── Create Actions ───────────────────────────────────────────────────────── + + void _showNewFileDialog() { + final nameCtrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('New File', style: TextStyle(color: AppTheme.textPrimary)), + content: TextField( + controller: nameCtrl, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: const InputDecoration( + labelText: 'File name', + labelStyle: TextStyle(color: AppTheme.textSecondary), + hintText: 'example.dart', + hintStyle: TextStyle(color: AppTheme.textTertiary), + enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.border)), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.primary)), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), + ElevatedButton( + onPressed: () async { + final name = nameCtrl.text.trim(); + if (name.isEmpty) return; + Navigator.pop(ctx); + final path = _pathStack.isEmpty ? name : '${_pathStack.last}/\$name'; + try { + await _svc.createOrUpdateFile( + widget.owner, widget.repoName, + path, '', + 'Create \$name', + branch: _currentBranch, + ); + _toast('Created \$name'); + _refreshCurrentDir(); + } catch (e) { + _toast('Failed: \$e', isError: true); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), + child: const Text('Create'), + ), + ], + ), + ); + } + + void _showNewFolderDialog() { + final nameCtrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('New Folder', style: TextStyle(color: AppTheme.textPrimary)), + content: TextField( + controller: nameCtrl, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: const InputDecoration( + labelText: 'Folder name', + labelStyle: TextStyle(color: AppTheme.textSecondary), + hintText: 'new_folder', + hintStyle: TextStyle(color: AppTheme.textTertiary), + enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.border)), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: AppTheme.primary)), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), + ElevatedButton( + onPressed: () async { + final name = nameCtrl.text.trim(); + if (name.isEmpty) return; + Navigator.pop(ctx); + final path = _pathStack.isEmpty ? '\$name/.gitkeep' : '${_pathStack.last}/\$name/.gitkeep'; + try { + await _svc.createOrUpdateFile( + widget.owner, widget.repoName, + path, '', + 'Create directory \$name', + branch: _currentBranch, + ); + _toast('Created folder \$name'); + _refreshCurrentDir(); + } catch (e) { + _toast('Failed: \$e', isError: true); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), + child: const Text('Create'), + ), + ], + ), + ); + } + + // ── Toasts ───────────────────────────────────────────────────────────────── + + void _toast(String msg, {bool isError = false}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(msg, style: const TextStyle(color: AppTheme.textPrimary)), + backgroundColor: (isError ? AppTheme.error : AppTheme.success).withOpacity(0.9), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + duration: const Duration(seconds: 3), + )); + } + + @override + void dispose() { + _tabController.dispose(); + _svc.dispose(); + super.dispose(); + } + + + // ═══════════════════════════════════════════════════════════════════════════ + // BUILD + // ═══════════════════════════════════════════════════════════════════════════ + + @override + Widget build(BuildContext context) { + if (_loading) { + return Scaffold( + backgroundColor: AppTheme.background, + body: const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ), + ); + } + + return Scaffold( + backgroundColor: AppTheme.background, + body: SafeArea( + child: Column( + children: [ + _buildTopBar(), + // Branch selector + actions + _buildBranchBar(), + // Tabs + Container( + color: AppTheme.backgroundElevated, + child: TabBar( + controller: _tabController, + indicatorColor: AppTheme.primary, + labelColor: AppTheme.primary, + unselectedLabelColor: AppTheme.textTertiary, + labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13), + tabs: const [ + Tab(icon: Icon(Icons.folder_outlined, size: 18), text: 'Files'), + Tab(icon: Icon(Icons.info_outline, size: 18), text: 'About'), + ], + ), + ), + // Tab content + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildFilesTab(), + _buildAboutTab(), + ], + ), + ), + ], + ), + ), + ); + } + + // ── Top Bar with Breadcrumb ──────────────────────────────────────────────── + + Widget _buildTopBar() { + return Container( + height: 52, + decoration: BoxDecoration( + color: AppTheme.backgroundElevated, + border: Border(bottom: BorderSide(color: AppTheme.divider)), + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back, size: 20, color: AppTheme.textSecondary), + ), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + // Repo name (root) + InkWell( + onTap: () => _onBreadcrumbTap(-1), + child: Text( + widget.repoName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _pathStack.isEmpty ? AppTheme.textPrimary : AppTheme.accent, + fontFamily: AppTheme.fontCode, + ), + ), + ), + // Path segments + ..._pathStack.asMap().entries.expand((entry) { + final i = entry.key; + final segment = entry.value.split('/').last; + return [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.chevron_right, size: 16, color: AppTheme.textTertiary), + ), + InkWell( + onTap: () => _onBreadcrumbTap(i), + child: Text( + segment, + style: TextStyle( + fontSize: 13, + color: i == _pathStack.length - 1 + ? AppTheme.textPrimary + : AppTheme.accent, + fontWeight: i == _pathStack.length - 1 + ? FontWeight.w500 + : FontWeight.normal, + fontFamily: AppTheme.fontCode, + ), + ), + ), + ]; + }), + ], + ), + ), + ), + // File actions + if (_selectedFile != null && !_selectedFile!.isDirectory) ...[ + if (!_isEditing) + IconButton( + onPressed: () => setState(() => _isEditing = true), + icon: const Icon(Icons.edit, size: 20, color: AppTheme.accent), + tooltip: 'Edit', + ) + else + IconButton( + onPressed: () => _showCommitDialog(), + icon: const Icon(Icons.check, size: 20, color: AppTheme.success), + tooltip: 'Commit', + ), + ], + const SizedBox(width: 8), + ], + ), + ); + } + + // ── Branch Selector Bar ──────────────────────────────────────────────────── + + Widget _buildBranchBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.backgroundElevated, + border: Border(bottom: BorderSide(color: AppTheme.divider)), + ), + child: Row( + children: [ + // Branch selector + InkWell( + onTap: _showBranchSelector, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.surface.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.account_tree, size: 14, color: AppTheme.primary), + const SizedBox(width: 6), + Text( + _currentBranch, + style: const TextStyle( + fontSize: 12, + fontFamily: AppTheme.fontCode, + color: AppTheme.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.keyboard_arrow_down, size: 16, color: AppTheme.textTertiary), + ], + ), + ), + ), + const Spacer(), + // Quick action buttons + _buildActionButton(Icons.upload_file, 'Upload', _showUploadDialog), + const SizedBox(width: 6), + _buildActionButton(Icons.note_add, 'New', _showNewFileDialog), + const SizedBox(width: 6), + _buildActionButton(Icons.create_new_folder, 'Folder', _showNewFolderDialog), + ], + ), + ); + } + + Widget _buildActionButton(IconData icon, String tooltip, VoidCallback onTap) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppTheme.surface.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.border), + ), + child: Icon(icon, size: 16, color: AppTheme.textSecondary), + ), + ), + ); + } + + void _showBranchSelector() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container(width: 40, height: 4, + decoration: BoxDecoration(color: AppTheme.border, borderRadius: BorderRadius.circular(2)), + ), + ), + const SizedBox(height: 16), + const Text('Switch Branch', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), + ), + const SizedBox(height: 12), + // Search/filter branches + TextField( + style: const TextStyle(color: AppTheme.textPrimary, fontSize: 14), + decoration: InputDecoration( + filled: true, + fillColor: AppTheme.surfaceInput, + hintText: 'Filter branches...', + hintStyle: const TextStyle(color: AppTheme.textTertiary), + prefixIcon: const Icon(Icons.search, size: 18, color: AppTheme.textTertiary), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.border)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.border)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + onChanged: (v) { + // Filter branches logic handled by setState in real implementation + }, + ), + const SizedBox(height: 8), + SizedBox( + height: 300, + child: ListView.builder( + itemCount: _branches.length, + itemBuilder: (_, i) { + final branch = _branches[i]; + final isActive = branch == _currentBranch; + return ListTile( + dense: true, + leading: Icon( + isActive ? Icons.check_circle : Icons.account_tree, + size: 18, + color: isActive ? AppTheme.primary : AppTheme.textTertiary, + ), + title: Text(branch, + style: TextStyle( + color: isActive ? AppTheme.primary : AppTheme.textPrimary, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + fontFamily: AppTheme.fontCode, + fontSize: 13, + ), + ), + trailing: isActive + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(6), + ), + child: const Text('ACTIVE', style: TextStyle(fontSize: 9, color: AppTheme.primary, fontWeight: FontWeight.w700)), + ) + : null, + onTap: () { + Navigator.pop(ctx); + if (!isActive) { + setState(() => _currentBranch = branch); + _loadReadme(); + if (_pathStack.isNotEmpty) { + _loadDirectory(_pathStack.last); + } + _toast('Switched to branch \$branch'); + } + }, + ); + }, + ), + ), + ], + ), + ), + ); + } + + void _showUploadDialog() { + _toast('File upload coming soon'); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // FILES TAB + // ═══════════════════════════════════════════════════════════════════════════ + + Widget _buildFilesTab() { + if (_selectedFile != null && _selectedFile!.isFile && !_isEditing) { + return _buildFileViewer(); + } + if (_isEditing && _selectedFile != null) { + return _buildFileEditor(); + } + return Column( + children: [ + // File count + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.centerLeft, + child: Text( + '${_currentFiles.length} items', + style: const TextStyle(fontSize: 12, color: AppTheme.textTertiary), + ), + ), + // File list + Expanded( + child: _filesLoading + ? const Center(child: CircularProgressIndicator(color: AppTheme.primary)) + : RefreshIndicator( + onRefresh: () async => _refreshCurrentDir(), + color: AppTheme.primary, + backgroundColor: AppTheme.surface, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemCount: _currentFiles.length, + itemBuilder: (_, i) => _buildFileItem(_currentFiles[i]), + ), + ), + ), + // README toggle + if (_readmeContent != null && _pathStack.isEmpty && _selectedFile == null) + _buildReadmeToggle(), + ], + ); + } + + Widget _buildFileItem(RepoFileItem file) { + return InkWell( + onTap: () => _onFileTap(file), + onLongPress: () { + final RenderBox box = context.findRenderObject() as RenderBox; + _showFileContextMenu(file, box.localToGlobal(Offset.zero)); + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + color: AppTheme.surface.withOpacity(0.4), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.border.withOpacity(0.5)), + ), + child: Row( + children: [ + // File/directory icon + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: file.iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(file.icon, size: 20, color: file.iconColor), + ), + const SizedBox(width: 12), + // File info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + file.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + fontFamily: AppTheme.fontCode, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + if (file.formattedSize.isNotEmpty) + Text( + file.formattedSize, + style: const TextStyle(fontSize: 11, color: AppTheme.textTertiary), + ), + if (file.formattedSize.isNotEmpty) + const SizedBox(width: 10), + if (file.lastModified != null) + Text( + _ago(file.lastModified!), + style: const TextStyle(fontSize: 11, color: AppTheme.textTertiary), + ), + ], + ), + ], + ), + ), + // Trailing icon + if (file.isDirectory) + const Icon(Icons.chevron_right, size: 18, color: AppTheme.textTertiary) + else + IconButton( + icon: const Icon(Icons.more_vert, size: 18, color: AppTheme.textTertiary), + onPressed: () { + final RenderBox box = context.findRenderObject() as RenderBox; + _showFileContextMenu(file, box.localToGlobal(Offset.zero)); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ), + ), + ); + } + + // ── File Viewer ──────────────────────────────────────────────────────────── + + Widget _buildFileViewer() { + return Column( + children: [ + // Read-only indicator + Container( + height: 32, + color: AppTheme.backgroundElevated, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.visibility, size: 14, color: AppTheme.textTertiary), + const SizedBox(width: 6), + Text( + '${_selectedFile!.name} \u00B7 ${_selectedFile!.formattedSize}', + style: const TextStyle(fontSize: 12, color: AppTheme.textTertiary), + ), + ], + ), + ), + // File content + Expanded( + child: Container( + color: AppTheme.editorBackground, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: SelectableText( + _selectedFile!.content ?? 'No content available.', + style: TextStyle( + fontSize: 13, + fontFamily: AppTheme.fontCode, + color: AppTheme.textPrimary.withOpacity(0.9), + height: 1.6, + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildFileEditor() { + return Column( + children: [ + Container( + height: 32, + color: AppTheme.backgroundElevated, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.edit, size: 14, color: AppTheme.accent), + const SizedBox(width: 6), + Text( + 'Editing: ${_selectedFile!.name}', + style: const TextStyle(fontSize: 12, color: AppTheme.accent), + ), + ], + ), + ), + Expanded( + child: Container( + color: AppTheme.editorBackground, + child: TextField( + controller: TextEditingController(text: _selectedFile!.content ?? ''), + style: TextStyle( + fontSize: 13, + fontFamily: AppTheme.fontCode, + color: AppTheme.textPrimary.withOpacity(0.9), + height: 1.6, + ), + maxLines: null, + expands: true, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + ), + ), + ), + ), + ], + ); + } + + void _showCommitDialog() { + final messageController = TextEditingController(); + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + top: 20, + left: 20, + right: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center(child: Container(width: 40, height: 4, + decoration: BoxDecoration(color: AppTheme.border, borderRadius: BorderRadius.circular(2)), + )), + const SizedBox(height: 20), + const Text('Commit Changes', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), + ), + const SizedBox(height: 8), + Text('File: ${_selectedFile?.name}', + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + TextField( + controller: messageController, + autofocus: true, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: InputDecoration( + filled: true, + fillColor: AppTheme.surfaceInput, + labelText: 'Commit message', + labelStyle: const TextStyle(color: AppTheme.textSecondary), + hintText: 'Update ${_selectedFile?.name}', + hintStyle: const TextStyle(color: AppTheme.textTertiary), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppTheme.primary, width: 1.5)), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + if (messageController.text.isNotEmpty && _selectedFile != null) { + setState(() => _isEditing = false); + Navigator.pop(context); + try { + await _svc.createOrUpdateFile( + widget.owner, widget.repoName, + _selectedFile!.path, + _selectedFile!.content ?? '', + messageController.text, + branch: _currentBranch, + ); + _toast('Committed changes'); + } catch (e) { + _toast('Commit failed: \$e', isError: true); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.success, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text('Commit', style: TextStyle(fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ), + ); + } + + // ── README ───────────────────────────────────────────────────────────────── + + Widget _buildReadmeToggle() { + return Container( + decoration: BoxDecoration( + color: AppTheme.backgroundElevated, + border: Border(top: BorderSide(color: AppTheme.divider)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => setState(() => _readmeVisible = !_readmeVisible), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + const Icon(Icons.article, size: 16, color: AppTheme.accent), + const SizedBox(width: 8), + const Text('README.md', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textPrimary), + ), + const Spacer(), + AnimatedRotation( + turns: _readmeVisible ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: const Icon(Icons.keyboard_arrow_down, size: 20, color: AppTheme.textTertiary), + ), + ], + ), + ), + ), + if (_readmeVisible && _readmeContent != null) + Container( + height: 300, + color: AppTheme.editorBackground, + child: Markdown( + data: _readmeContent!, + selectable: true, + styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( + p: const TextStyle(fontSize: 14, color: AppTheme.textSecondary, height: 1.6), + h1: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), + h2: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppTheme.textPrimary), + h3: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.textPrimary), + code: TextStyle( + fontSize: 12, + fontFamily: AppTheme.fontCode, + color: AppTheme.textPrimary, + backgroundColor: AppTheme.surfaceHover, + ), + codeblockDecoration: BoxDecoration( + color: AppTheme.surfaceHover, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.border), + ), + blockquote: const TextStyle(fontSize: 14, color: AppTheme.textSecondary, fontStyle: FontStyle.italic), + blockquoteDecoration: BoxDecoration( + border: Border(left: BorderSide(color: AppTheme.primary, width: 3)), + color: AppTheme.primary.withOpacity(0.05), + ), + listBullet: const TextStyle(color: AppTheme.textSecondary), + a: const TextStyle(color: AppTheme.accent, decoration: TextDecoration.underline), + ), + onTapLink: (text, href, title) { + if (href != null) launchUrl(Uri.parse(href)); + }, + ), + ), + ], + ), + ); + } + + + // ═══════════════════════════════════════════════════════════════════════════ + // ABOUT TAB (Repo Info) + // ═══════════════════════════════════════════════════════════════════════════ + + Widget _buildAboutTab() { + final stars = _repoDetails['stargazers_count'] as int? ?? 0; + final forks = _repoDetails['forks_count'] as int? ?? 0; + final watchers = _repoDetails['watchers_count'] as int? ?? 0; + final openIssues = _repoDetails['open_issues_count'] as int? ?? 0; + final topics = (_repoDetails['topics'] as List?)?.cast() ?? []; + final license = (_repoDetails['license'] as Map?)?.cast()?['name'] as String?; + final language = _repoDetails['language'] as String? ?? widget.language; + final description = _repoDetails['description'] as String? ?? widget.description ?? ''; + final isPrivate = _repoDetails['private'] as bool? ?? false; + final createdAt = _repoDetails['created_at'] != null + ? DateTime.tryParse(_repoDetails['created_at'] as String) + : null; + + return RefreshIndicator( + onRefresh: _loadRepoInfo, + color: AppTheme.primary, + backgroundColor: AppTheme.surface, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Repo header card + GlassCardWidget( + padding: const EdgeInsets.all(20), + borderRadius: 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '${widget.owner}/${widget.repoName}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + fontFamily: AppTheme.fontCode, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isPrivate ? AppTheme.warning.withOpacity(0.15) : AppTheme.success.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isPrivate ? AppTheme.warning.withOpacity(0.3) : AppTheme.success.withOpacity(0.3), + width: 0.5, + ), + ), + child: Text( + isPrivate ? 'PRIVATE' : 'PUBLIC', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: isPrivate ? AppTheme.warning : AppTheme.success, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + if (description.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + description, + style: const TextStyle(fontSize: 14, color: AppTheme.textSecondary, height: 1.5), + ), + ], + if (createdAt != null) ...[ + const SizedBox(height: 8), + Text( + 'Created ${_ago(createdAt)}', + style: const TextStyle(fontSize: 11, color: AppTheme.textTertiary), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + + // Stats grid + Row( + children: [ + Expanded(child: _buildStatCard(Icons.star, 'Stars', '$stars', AppTheme.warning)), + const SizedBox(width: 8), + Expanded(child: _buildStatCard(Icons.call_split, 'Forks', '$forks', AppTheme.accent)), + const SizedBox(width: 8), + Expanded(child: _buildStatCard(Icons.visibility, 'Watch', '$watchers', AppTheme.info)), + const SizedBox(width: 8), + Expanded(child: _buildStatCard(Icons.error_outline, 'Issues', '$openIssues', AppTheme.error)), + ], + ), + const SizedBox(height: 16), + + // Language bar + if (language != null) ...[ + const Text('Languages', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.textPrimary), + ), + const SizedBox(height: 10), + _buildLanguageBar(language), + const SizedBox(height: 16), + ], + + // Topics + if (topics.isNotEmpty) ...[ + const Text('Topics', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.textPrimary), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: topics.map((t) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppTheme.primary.withOpacity(0.3)), + ), + child: Text( + t, + style: const TextStyle(fontSize: 12, color: AppTheme.primary, fontWeight: FontWeight.w500), + ), + )).toList(), + ), + const SizedBox(height: 16), + ], + + // License + if (license != null) + _buildInfoRow(Icons.balance, 'License', license), + _buildInfoRow(Icons.code, 'Default Branch', _currentBranch), + _buildInfoRow(Icons.language, 'Language', language ?? 'Unknown'), + + const SizedBox(height: 20), + + // "Open in MobileCode" button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + _toast('Cloning to local workspace...'); + // Integration with local workspace + }, + icon: const Icon(Icons.computer, size: 18), + label: const Text('Open in MobileCode', style: TextStyle(fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(height: 10), + + // GitHub link + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + final url = 'https://github.com/${widget.owner}/${widget.repoName}'; + launchUrl(Uri.parse(url)); + }, + icon: const Icon(Icons.open_in_browser, size: 18), + label: const Text('View on GitHub', style: TextStyle(fontWeight: FontWeight.w600)), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.textPrimary, + side: const BorderSide(color: AppTheme.border), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ], + ), + ); + } + + Widget _buildStatCard(IconData icon, String label, String value, Color color) { + return GlassCardWidget( + padding: const EdgeInsets.all(12), + borderRadius: 12, + child: Column( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle(fontSize: 11, color: AppTheme.textTertiary), + ), + ], + ), + ); + } + Widget _buildLanguageBar(String primaryLanguage) { - final colors = _GitHubRepoScreenState._languageColors; - final primaryColor = colors[primaryLanguage] ?? AppTheme.primary; - // Simulated language distribution - final langSegments = [ - (primaryLanguage, 0.65, primaryColor), - ('Other', 0.35, AppTheme.textTertiary), - ]; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Bar - Container( - height: 8, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: AppTheme.surfaceHover, - ), - child: Row( - children: langSegments.map((seg) { - final (_, pct, color) = seg; - return Expanded( - flex: (pct * 100).round(), - child: Container( - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(4), - ), - ), - ); - }).toList(), - ), - ), - const SizedBox(height: 8), - // Legend - Wrap( - spacing: 12, - children: langSegments.map((seg) { - final (name, pct, color) = seg; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 4), - Text( - '$name ${(pct * 100).toInt()}%', - style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary), - ), - ], - ); - }).toList(), - ), - ], - ); - } - - Widget _buildInfoRow(IconData icon, String label, String value) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: AppTheme.divider.withOpacity(0.5))), - ), - child: Row( - children: [ - Icon(icon, size: 16, color: AppTheme.textTertiary), - const SizedBox(width: 10), - Text( - label, - style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), - ), - const Spacer(), - Text( - value, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppTheme.textPrimary, - fontFamily: AppTheme.fontCode, - ), - ), - ], - ), - ); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // UTILITIES - // ═══════════════════════════════════════════════════════════════════════════ - - String _ago(DateTime d) { - final diff = DateTime.now().difference(d); - if (diff.inDays > 365) return '${diff.inDays ~/ 365}y ago'; - if (diff.inDays > 30) return '${diff.inDays ~/ 30}mo ago'; - if (diff.inDays > 0) return '${diff.inDays}d ago'; - if (diff.inHours > 0) return '${diff.inHours}h ago'; - if (diff.inMinutes > 0) return '${diff.inMinutes}m ago'; - return 'just now'; - } -} + final primaryColor = _languageColors[primaryLanguage] ?? AppTheme.primary; + // Simulated language distribution + final langSegments = [ + (primaryLanguage, 0.65, primaryColor), + ('Other', 0.35, AppTheme.textTertiary), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Bar + Container( + height: 8, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: AppTheme.surfaceHover, + ), + child: Row( + children: langSegments.map((seg) { + final (_, pct, color) = seg; + return Expanded( + flex: (pct * 100).round(), + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 8), + // Legend + Wrap( + spacing: 12, + children: langSegments.map((seg) { + final (name, pct, color) = seg; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text( + '$name ${(pct * 100).toInt()}%', + style: const TextStyle(fontSize: 11, color: AppTheme.textSecondary), + ), + ], + ); + }).toList(), + ), + ], + ); + } + + Widget _buildInfoRow(IconData icon, String label, String value) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: AppTheme.divider.withOpacity(0.5))), + ), + child: Row( + children: [ + Icon(icon, size: 16, color: AppTheme.textTertiary), + const SizedBox(width: 10), + Text( + label, + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), + ), + const Spacer(), + Text( + value, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + fontFamily: AppTheme.fontCode, + ), + ), + ], + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // UTILITIES + // ═══════════════════════════════════════════════════════════════════════════ + + String _ago(DateTime d) { + final diff = DateTime.now().difference(d); + if (diff.inDays > 365) return '${diff.inDays ~/ 365}y ago'; + if (diff.inDays > 30) return '${diff.inDays ~/ 30}mo ago'; + if (diff.inDays > 0) return '${diff.inDays}d ago'; + if (diff.inHours > 0) return '${diff.inHours}h ago'; + if (diff.inMinutes > 0) return '${diff.inMinutes}m ago'; + return 'just now'; + } +} diff --git a/mobile_agent/lib/screens/github_screen.dart b/mobile_agent/lib/screens/github_screen.dart index 8acb97e..527a0fc 100644 --- a/mobile_agent/lib/screens/github_screen.dart +++ b/mobile_agent/lib/screens/github_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -5,3419 +7,3570 @@ import 'package:url_launcher/url_launcher.dart'; import '../core/theme.dart'; import '../models/github_repo.dart'; import '../services/github_deep_service.dart'; +import '../services/github_oauth_flow.dart'; +import '../services/repo_intent_polish_service.dart'; import '../widgets/glass_card_widget.dart'; -import 'github_repo_screen.dart'; -import 'github_issue_detail_screen.dart'; -import 'github_pr_review_screen.dart'; - -// ═════════════════════════════════════════════════════════════════════════════ -// GITHUB SCREEN — Deep Integration Hub (Enhanced UX) -// ═════════════════════════════════════════════════════════════════════════════ -/// Tabbed GitHub interface: Repositories, Issues, Pull Requests, Notifications. -/// Full auth (PAT + OAuth), repo CRUD, issue/PR management, code review, -/// notifications, and repository search. -/// -/// UX Enhancements: -/// - Sorting & filtering on all tabs -/// - Multi-account switching -/// - Language color dots, CI status, review badges -/// - Pull-to-refresh, FABs, empty states -/// - Grouped notifications with swipe-to-dismiss -/// - Persisted search across tab switches +import 'github_repo_screen.dart'; +import 'github_issue_detail_screen.dart'; +import 'github_pr_review_screen.dart'; + +// ═════════════════════════════════════════════════════════════════════════════ +// GITHUB SCREEN — Deep Integration Hub (Enhanced UX) +// ═════════════════════════════════════════════════════════════════════════════ class GitHubScreen extends StatefulWidget { const GitHubScreen({super.key}); - - @override - State createState() => _GitHubScreenState(); -} - + + @override + State createState() => _GitHubScreenState(); +} + class _GitHubScreenState extends State - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + with TickerProviderStateMixin, AutomaticKeepAliveClientMixin, WidgetsBindingObserver { late TabController _tabCtrl; final GitHubDeepService _svc = GitHubDeepService(); final _tokenCtrl = TextEditingController(); final _searchCtrl = TextEditingController(); - - bool _loading = true; - bool _auth = false; - String? _error; - int _unreadCount = 0; - - // Data caches per tab - List _repos = []; - List _issues = []; - List _prs = []; - List _notifications = []; - List _searchResults = []; - - // Filters & Sorting - String _repoFilter = 'all'; - String _repoSort = 'pushed'; - String? _repoLanguageFilter; - String _issueFilter = 'open'; - String? _issueLabelFilter; - String? _issueAssigneeFilter; - int? _issueMilestoneFilter; - String _prFilter = 'open'; - - // UI state + + bool _loading = true; + bool _auth = false; + String? _error; + int _unreadCount = 0; + + // Data caches per tab + List _repos = []; + List _issues = []; + List _prs = []; + List _notifications = []; + List _searchResults = []; + + // Filters & Sorting + String _repoFilter = 'all'; + String _repoSort = 'pushed'; + String? _repoLanguageFilter; + String _issueFilter = 'open'; + String? _issueLabelFilter; + String? _issueAssigneeFilter; + int? _issueMilestoneFilter; + String _prFilter = 'open'; + + // UI state bool _searching = false; bool _showSearchInRepos = false; - - // Issue/PR dynamic filters - List _repoLabels = []; - List _repoMilestones = []; - List _repoAssignees = []; - - @override - bool get wantKeepAlive => true; - - @override + bool _oauthBusy = false; + + // Issue/PR dynamic filters + List _repoLabels = []; + List _repoMilestones = []; + List _repoAssignees = []; + + @override + bool get wantKeepAlive => true; + + @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _tabCtrl = TabController(length: 4, vsync: this); _tabCtrl.addListener(_onTab); _init(); } - Future _init() async { - setState(() => _loading = true); + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_handlePendingOAuthCallback().then((_) => _refreshAuthState())); + } + } + + Future _refreshAuthState() async { try { await _svc.initialize(); - if (_svc.isAuthenticated) await _loadRepos(); + await _handlePendingOAuthCallback(); + if (!mounted) return; + if (_svc.isAuthenticated) { + await _loadRepos(); + } + if (!mounted) return; setState(() { _auth = _svc.isAuthenticated; _loading = false; + if (_auth) _error = null; }); - } catch (e) { - setState(() { - _error = e.toString(); - _loading = false; - }); - } - } - - void _onTab() { - if (!_tabCtrl.indexIsChanging) { - final i = _tabCtrl.index; - if (_auth) { - if (i == 0) _loadRepos(); - if (i == 1 && _issues.isEmpty) _loadIssues(); - if (i == 2 && _prs.isEmpty) _loadPRs(); - if (i == 3) _loadNotifications(); - } - } - } - - // ── Data Loading ─────────────────────────────────────────────────────────── - - Future _loadRepos() async { - try { - final r = await _svc.getRepos( - type: _repoFilter == 'all' ? 'all' : _repoFilter, - sort: _repoSort, - ); - setState(() => _repos = r); - } catch (e) { - _toast('Failed to load repos: \$e', isError: true); + } catch (_) { + if (!mounted) return; + setState(() => _loading = false); } } - - Future _loadIssues() async { - if (_repos.isEmpty) return; + + Future _init() async { + setState(() => _loading = true); try { - // Load dynamic filters if empty - if (_repoLabels.isEmpty) _loadIssueFilters(); - - final list = await _svc.getIssues( - _repos.first.owner, - _repos.first.name, - state: _issueFilter, - labels: _issueLabelFilter, - assignee: _issueAssigneeFilter, - ); - setState(() => _issues = list); - } catch (e) { - _toast('Failed to load issues: \$e', isError: true); - } + await _svc.initialize(); + await _handlePendingOAuthCallback(); + if (_svc.isAuthenticated) await _loadRepos(); + setState(() { + _auth = _svc.isAuthenticated; + _loading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + void _onTab() { + if (!_tabCtrl.indexIsChanging) { + final i = _tabCtrl.index; + if (_auth) { + if (i == 0) _loadRepos(); + if (i == 1 && _issues.isEmpty) _loadIssues(); + if (i == 2 && _prs.isEmpty) _loadPRs(); + if (i == 3) _loadNotifications(); + } + } + } + + // ── Data Loading ─────────────────────────────────────────────────────────── + + Future _loadRepos() async { + try { + final r = await _svc.getRepos( + type: _repoFilter == 'all' ? 'all' : _repoFilter, + sort: _repoSort, + ); + setState(() => _repos = r); + } catch (e) { + _toast('Failed to load repos: \$e', isError: true); + } + } + + Future _loadIssues() async { + if (_repos.isEmpty) return; + try { + // Load dynamic filters if empty + if (_repoLabels.isEmpty) _loadIssueFilters(); + + final list = await _svc.getIssues( + _repos.first.owner, + _repos.first.name, + state: _issueFilter, + labels: _issueLabelFilter, + assignee: _issueAssigneeFilter, + ); + setState(() => _issues = list); + } catch (e) { + _toast('Failed to load issues: \$e', isError: true); + } + } + + Future _loadIssueFilters() async { + if (_repos.isEmpty) return; + try { + final labels = await _svc.getLabels(_repos.first.owner, _repos.first.name); + final milestones = + await _svc.getMilestones(_repos.first.owner, _repos.first.name); + setState(() { + _repoLabels = labels; + _repoMilestones = milestones; + }); + } catch (_) { + // Silently fail - filters are optional + } + } + + Future _loadPRs() async { + if (_repos.isEmpty) return; + try { + final list = await _svc.getPullRequests( + _repos.first.owner, + _repos.first.name, + state: _prFilter, + ); + setState(() => _prs = list); + } catch (e) { + _toast('Failed to load PRs: \$e', isError: true); + } + } + + Future _loadNotifications() async { + try { + final n = await _svc.getNotifications(all: false); + final count = await _svc.getUnreadNotificationCount(); + setState(() { + _notifications = n; + _unreadCount = count; + }); + } catch (e) { + _toast('Failed to load notifications: \$e', isError: true); + } + } + + // ── Auth ─────────────────────────────────────────────────────────────────── + + Future _authenticate() async { + final token = _tokenCtrl.text.trim(); + if (token.isEmpty) return; + setState(() => _loading = true); + try { + final ok = await _svc.authenticate(token); + if (ok) { + _tokenCtrl.clear(); + await _loadRepos(); + setState(() { + _auth = true; + _loading = false; + }); + } else { + setState(() { + _error = 'Invalid token. Please check and try again.'; + _loading = false; + }); + } + } catch (e) { + setState(() { + _error = 'Auth failed: \$e'; + _loading = false; + }); + } + } + + Future _logout() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Remove GitHub access?', style: TextStyle(color: AppTheme.textPrimary)), + content: const Text( + 'This removes the active token from this device. Public search still works, but private repos and write actions will require adding access again.', + style: TextStyle(color: AppTheme.textSecondary), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + ElevatedButton.icon( + onPressed: () => Navigator.pop(ctx, true), + icon: const Icon(Icons.logout, size: 16), + label: const Text('Remove access'), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.error), + ), + ], + ), + ); + if (confirmed != true) return; + await _svc.logout(); + setState(() { + _auth = false; + _repos.clear(); + _issues.clear(); + _prs.clear(); + _notifications.clear(); + _unreadCount = 0; + _repoLabels.clear(); + _repoMilestones.clear(); + }); } - Future _loadIssueFilters() async { - if (_repos.isEmpty) return; + Future _launchOAuth() async { + setState(() { + _oauthBusy = true; + _error = null; + }); try { - final labels = await _svc.getLabels(_repos.first.owner, _repos.first.name); - final milestones = - await _svc.getMilestones(_repos.first.owner, _repos.first.name); + final result = await GitHubOAuthFlow.launchAuthorization(); + if (!mounted) return; setState(() { - _repoLabels = labels; - _repoMilestones = milestones; + _oauthBusy = false; + if (!result.startedOAuth) _error = result.message; }); - } catch (_) { - // Silently fail - filters are optional - } - } - - Future _loadPRs() async { - if (_repos.isEmpty) return; - try { - final list = await _svc.getPullRequests( - _repos.first.owner, - _repos.first.name, - state: _prFilter, - ); - setState(() => _prs = list); } catch (e) { - _toast('Failed to load PRs: \$e', isError: true); + if (mounted) setState(() => _oauthBusy = false); + _toast('Could not open GitHub: $e', isError: true); } } - Future _loadNotifications() async { - try { - final n = await _svc.getNotifications(all: false); - final count = await _svc.getUnreadNotificationCount(); + Future _handlePendingOAuthCallback() async { + if (_oauthBusy) return; + final uri = await GitHubOAuthFlow.consumePendingCallbackUri(); + if (uri == null) return; + if (mounted) { setState(() { - _notifications = n; - _unreadCount = count; + _oauthBusy = true; + _loading = true; + _error = null; }); - } catch (e) { - _toast('Failed to load notifications: \$e', isError: true); } - } - - // ── Auth ─────────────────────────────────────────────────────────────────── - - Future _authenticate() async { - final token = _tokenCtrl.text.trim(); - if (token.isEmpty) return; - setState(() => _loading = true); try { - final ok = await _svc.authenticate(token); - if (ok) { - _tokenCtrl.clear(); + final result = await GitHubOAuthFlow.completeCallbackUri(uri, _svc); + if (!mounted) return; + if (result.success) { await _loadRepos(); setState(() { _auth = true; _loading = false; + _oauthBusy = false; + _error = null; }); + _toast(result.message ?? 'GitHub OAuth login connected'); } else { setState(() { - _error = 'Invalid token. Please check and try again.'; _loading = false; + _oauthBusy = false; + _error = result.message ?? 'GitHub OAuth login failed.'; }); } } catch (e) { + if (!mounted) return; setState(() { - _error = 'Auth failed: \$e'; _loading = false; + _oauthBusy = false; + _error = 'GitHub OAuth callback failed: $e'; }); } } - - Future _logout() async { - await _svc.logout(); - setState(() { - _auth = false; - _repos.clear(); - _issues.clear(); - _prs.clear(); - _notifications.clear(); - _unreadCount = 0; - _repoLabels.clear(); - _repoMilestones.clear(); - }); - } - - Future _launchOAuth() async { - const clientId = 'YOUR_GITHUB_CLIENT_ID'; - const redirectUri = 'mobileagent://callback'; - const url = - 'https://github.com/login/oauth/authorize?client_id=\$clientId&redirect_uri=\$redirectUri&scope=repo,user,notifications'; - try { - await launchUrl(Uri.parse(url), - mode: LaunchMode.externalApplication); - } catch (e) { - _toast('Could not launch OAuth: \$e', isError: true); - } - } - - Future _search() async { - final q = _searchCtrl.text.trim(); - if (q.isEmpty) return; - setState(() => _searching = true); - try { - final r = await _svc.searchRepositories(q, - language: _repoLanguageFilter); - setState(() { - _searchResults = r; - _searching = false; - }); - } catch (e) { - setState(() => _searching = false); - _toast('Search failed: \$e', isError: true); - } - } - - // ── Actions ──────────────────────────────────────────────────────────────── - - Future _forkRepo(GitHubRepo repo) async { - try { - await _svc.forkRepo(repo.owner, repo.name); - _toast('Forking \${repo.name}...'); - await _loadRepos(); - } catch (e) { - _toast('Fork failed: \$e', isError: true); - } - } - - Future _starRepo(GitHubRepo repo) async { - try { - final starred = await _svc.isStarred(repo.owner, repo.name); - if (starred) { - await _svc.unstarRepo(repo.owner, repo.name); - _toast('Unstarred \${repo.name}'); - } else { - await _svc.starRepo(repo.owner, repo.name); - _toast('Starred \${repo.name}'); - } - } catch (e) { - _toast('Failed: \$e', isError: true); - } - } - - Future _submitReview(dynamic pr, String event) async { - if (_repos.isEmpty) return; - try { - await _svc.submitPullRequestReview( - _repos.first.owner, _repos.first.name, pr['number'] as int, - event: event); - _toast('Review submitted: \$event'); - } catch (e) { - _toast('Review failed: \$e', isError: true); - } - } - - // ── Toasts ───────────────────────────────────────────────────────────────── - - void _toast(String msg, {bool isError = false}) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: - Text(msg, style: const TextStyle(color: AppTheme.textPrimary)), - backgroundColor: - (isError ? AppTheme.error : AppTheme.success).withOpacity(0.9), - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - duration: const Duration(seconds: 3), - )); - } - + + Future _search() async { + final q = _searchCtrl.text.trim(); + if (q.isEmpty) return; + setState(() => _searching = true); + try { + final r = await _svc.searchRepositories(q, + language: _repoLanguageFilter); + setState(() { + _searchResults = r; + _searching = false; + }); + } catch (e) { + setState(() => _searching = false); + _toast('Search failed: \$e', isError: true); + } + } + + // ── Actions ──────────────────────────────────────────────────────────────── + + Future _forkRepo(GitHubRepo repo) async { + try { + await _svc.forkRepo(repo.owner, repo.name); + _toast('Forking \${repo.name}...'); + await _loadRepos(); + } catch (e) { + _toast('Fork failed: \$e', isError: true); + } + } + + Future _starRepo(GitHubRepo repo) async { + try { + final starred = await _svc.isStarred(repo.owner, repo.name); + if (starred) { + await _svc.unstarRepo(repo.owner, repo.name); + _toast('Unstarred \${repo.name}'); + } else { + await _svc.starRepo(repo.owner, repo.name); + _toast('Starred \${repo.name}'); + } + } catch (e) { + _toast('Failed: \$e', isError: true); + } + } + + Future _submitReview(dynamic pr, String event) async { + if (_repos.isEmpty) return; + try { + await _svc.submitPullRequestReview( + _repos.first.owner, _repos.first.name, pr['number'] as int, + event: event); + _toast('Review submitted: \$event'); + } catch (e) { + _toast('Review failed: \$e', isError: true); + } + } + + // ── Toasts ───────────────────────────────────────────────────────────────── + + void _toast(String msg, {bool isError = false}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: + Text(msg, style: const TextStyle(color: AppTheme.textPrimary)), + backgroundColor: + (isError ? AppTheme.error : AppTheme.success).withOpacity(0.9), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + duration: const Duration(seconds: 3), + )); + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _tabCtrl.dispose(); - _tokenCtrl.dispose(); - _searchCtrl.dispose(); - _svc.dispose(); - super.dispose(); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // BUILD - // ═══════════════════════════════════════════════════════════════════════════ - - @override - Widget build(BuildContext context) { - super.build(context); - return Scaffold( - backgroundColor: AppTheme.background, - body: SafeArea( - child: _loading - ? _buildLoading() - : !_auth - ? _buildAuth() - : _buildMain()), - floatingActionButton: _auth ? _buildFAB() : null, - ); - } - - // ── FAB ──────────────────────────────────────────────────────────────────── - - Widget? _buildFAB() { - VoidCallback? onPressed; - String tooltip; - IconData icon; - - switch (_tabCtrl.index) { - case 0: - onPressed = _showCreateRepoSheet; - tooltip = 'Create Repository'; - icon = Icons.create_new_folder; - case 1: - onPressed = _createIssueSheet; - tooltip = 'Create Issue'; - icon = Icons.add_comment; - case 2: - onPressed = _createPRSheet; - tooltip = 'Create Pull Request'; - icon = Icons.call_merge; - case 3: - onPressed = _markAllNotificationsRead; - tooltip = 'Mark All Read'; - icon = Icons.done_all; - default: - return null; - } - - return FloatingActionButton.extended( - onPressed: onPressed, - backgroundColor: AppTheme.primary, - icon: Icon(icon, size: 20, color: Colors.white), - label: Text( - tooltip, - style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13), - ), - ); - } - - Future _markAllNotificationsRead() async { - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Mark All as Read?', - style: TextStyle(color: AppTheme.textPrimary)), - content: const Text( - 'This will mark all notifications as read. This action cannot be undone.', - style: TextStyle(color: AppTheme.textSecondary)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel')), - ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), - child: const Text('Mark All Read'), - ), - ], - ), - ); - if (confirmed == true) { - try { - await _svc.markAllNotificationsRead(); - await _loadNotifications(); - _toast('All notifications marked as read'); - } catch (e) { - _toast('Failed: \$e', isError: true); - } - } - } - - // ── Loading ──────────────────────────────────────────────────────────────── - - Widget _buildLoading() => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: 48, - height: 48, - child: CircularProgressIndicator( - strokeWidth: 3, - valueColor: AlwaysStoppedAnimation( - AppTheme.primary.withOpacity(0.8)))), - const SizedBox(height: 20), - Text('Connecting to GitHub...', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary)), - ]), - ); - - // ── Auth Screen ──────────────────────────────────────────────────────────── - - Widget _buildAuth() => SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 40), - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - gradient: AppTheme.accentGradient, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: AppTheme.accent.withOpacity(0.3), - blurRadius: 20, - spreadRadius: 4) - ], - ), - child: const Icon(Icons.code, size: 40, color: Colors.white), - ), - const SizedBox(height: 24), - Text('Connect to GitHub', - style: Theme.of(context).textTheme.displaySmall?.copyWith( - color: AppTheme.textPrimary, - fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text( - 'Access your repositories, issues, pull requests,\nand notifications in one place.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary)), - const SizedBox(height: 40), - // PAT Card + _tokenCtrl.dispose(); + _searchCtrl.dispose(); + _svc.dispose(); + super.dispose(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // BUILD + // ═══════════════════════════════════════════════════════════════════════════ + + @override + Widget build(BuildContext context) { + super.build(context); + return Scaffold( + backgroundColor: AppTheme.background, + body: SafeArea( + child: _loading + ? _buildLoading() + : !_auth + ? _buildAuth() + : _buildMain()), + floatingActionButton: _auth ? _buildFAB() : null, + ); + } + + // ── FAB ──────────────────────────────────────────────────────────────────── + + Widget? _buildFAB() { + VoidCallback? onPressed; + String tooltip; + IconData icon; + + switch (_tabCtrl.index) { + case 0: + onPressed = _showCreateRepoSheet; + tooltip = 'Create Repository'; + icon = Icons.create_new_folder; + case 1: + onPressed = _createIssueSheet; + tooltip = 'Create Issue'; + icon = Icons.add_comment; + case 2: + onPressed = _createPRSheet; + tooltip = 'Create Pull Request'; + icon = Icons.call_merge; + case 3: + onPressed = _markAllNotificationsRead; + tooltip = 'Mark All Read'; + icon = Icons.done_all; + default: + return null; + } + + return FloatingActionButton.extended( + onPressed: onPressed, + backgroundColor: AppTheme.primary, + icon: Icon(icon, size: 20, color: Colors.white), + label: Text( + tooltip, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13), + ), + ); + } + + Future _markAllNotificationsRead() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Mark All as Read?', + style: TextStyle(color: AppTheme.textPrimary)), + content: const Text( + 'This will mark all notifications as read. This action cannot be undone.', + style: TextStyle(color: AppTheme.textSecondary)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary), + child: const Text('Mark All Read'), + ), + ], + ), + ); + if (confirmed == true) { + try { + await _svc.markAllNotificationsRead(); + await _loadNotifications(); + _toast('All notifications marked as read'); + } catch (e) { + _toast('Failed: \$e', isError: true); + } + } + } + + // ── Loading ──────────────────────────────────────────────────────────────── + + Widget _buildLoading() => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + AppTheme.primary.withOpacity(0.8)))), + const SizedBox(height: 20), + Text('Connecting to GitHub...', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary)), + ]), + ); + + // ── Auth Screen ──────────────────────────────────────────────────────────── + + Widget _buildAuth() => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 40), + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: AppTheme.accentGradient, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: AppTheme.accent.withOpacity(0.3), + blurRadius: 20, + spreadRadius: 4) + ], + ), + child: const Icon(Icons.code, size: 40, color: Colors.white), + ), + const SizedBox(height: 24), + Text('Connect to GitHub', + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text( + 'Access your repositories, issues, pull requests,\nand notifications in one place.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary)), + const SizedBox(height: 40), + // PAT Card + GlassCardWidget( + padding: const EdgeInsets.all(20), + borderRadius: 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Personal Access Token', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Text( + 'Generate a token at github.com/settings/tokens with repo, user, and notifications scopes.', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: AppTheme.textTertiary)), + const SizedBox(height: 16), + TextField( + controller: _tokenCtrl, + obscureText: true, + style: const TextStyle( + color: AppTheme.textPrimary, + fontFamily: AppTheme.fontCode), + decoration: _inputDecoration( + 'ghp_xxxxxxxxxxxxxxxxxxxx', + icon: Icons.vpn_key)), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _authenticate, + icon: const Icon(Icons.login, size: 18), + label: const Text('Connect with Token'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: AppTheme.textOnPrimary, + padding: + const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10))), + )), + ])), + const SizedBox(height: 20), + // OAuth is enabled only when the release build provides a GitHub OAuth client id. GlassCardWidget( - padding: const EdgeInsets.all(20), - borderRadius: 16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Personal Access Token', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600)), - const SizedBox(height: 8), + padding: const EdgeInsets.all(20), + borderRadius: 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(GitHubOAuthFlow.authModeLabel, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600)), + const SizedBox(height: 8), Text( - 'Generate a token at github.com/settings/tokens with repo, user, and notifications scopes.', - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: AppTheme.textTertiary)), - const SizedBox(height: 16), - TextField( - controller: _tokenCtrl, - obscureText: true, - style: const TextStyle( - color: AppTheme.textPrimary, - fontFamily: AppTheme.fontCode), - decoration: _inputDecoration( - 'ghp_xxxxxxxxxxxxxxxxxxxx', - icon: Icons.vpn_key)), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _authenticate, - icon: const Icon(Icons.login, size: 18), - label: const Text('Connect with Token'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primary, - foregroundColor: AppTheme.textOnPrimary, - padding: - const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10))), - )), - ])), - const SizedBox(height: 20), - // OAuth Card - GlassCardWidget( - padding: const EdgeInsets.all(20), - borderRadius: 16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('OAuth Web Login', - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - color: AppTheme.textPrimary, - fontWeight: FontWeight.w600)), - const SizedBox(height: 8), - Text('Sign in securely via GitHub\'s official OAuth flow.', + GitHubOAuthFlow.authModeDescription, style: Theme.of(context) .textTheme .bodySmall - ?.copyWith(color: AppTheme.textTertiary)), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _launchOAuth, - icon: const Icon(Icons.open_in_browser, size: 18), - label: const Text('Login with GitHub'), - style: OutlinedButton.styleFrom( - foregroundColor: AppTheme.accent, - side: const BorderSide(color: AppTheme.accent), - padding: - const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10))), - )), - ])), - if (_error != null) ...[ - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.error.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: AppTheme.error.withOpacity(0.3))), - child: Row(children: [ - const Icon(Icons.error_outline, - color: AppTheme.error, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text(_error!, - style: const TextStyle( - color: AppTheme.error, fontSize: 13))), - ]), - ), - ], - ]), - ); - - // ── Main Screen with Tabs ────────────────────────────────────────────────── - - Widget _buildMain() => Column(children: [ - _buildHeader(), - Container( - color: AppTheme.backgroundElevated, - child: TabBar( - controller: _tabCtrl, - labelColor: AppTheme.primary, - unselectedLabelColor: AppTheme.textTertiary, - indicatorColor: AppTheme.primary, - indicatorWeight: 2.5, - labelStyle: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 13, - fontWeight: FontWeight.w600), - unselectedLabelStyle: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 13, - fontWeight: FontWeight.w500), - tabs: [ - const Tab( - icon: Icon(Icons.folder_outlined, size: 20), text: 'Repos'), - const Tab( - icon: Icon(Icons.error_outline, size: 20), text: 'Issues'), - const Tab( - icon: Icon(Icons.call_merge, size: 20), text: 'Pull Req'), - Tab( - icon: Stack(clipBehavior: Clip.none, children: [ - const Icon(Icons.notifications_outlined, size: 20), - if (_unreadCount > 0) - Positioned( - right: -6, - top: -4, - child: Container( - padding: const EdgeInsets.all(2), - decoration: const BoxDecoration( - color: AppTheme.error, - shape: BoxShape.circle), - constraints: const BoxConstraints( - minWidth: 14, minHeight: 14), - child: Text( - _unreadCount > 99 - ? '99+' - : '\$_unreadCount', - style: const TextStyle( - color: Colors.white, - fontSize: 8, - fontWeight: FontWeight.bold), - textAlign: TextAlign.center))), - ]), - text: 'Alerts'), - ], - )), - Expanded( - child: TabBarView(controller: _tabCtrl, children: [ - _buildReposTab(), - _buildIssuesTab(), - _buildPRsTab(), - _buildNotificationsTab(), - ])), - ]); - - // ── Header with Multi-Account Switcher ───────────────────────────────────── - - Widget _buildHeader() => Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: AppTheme.backgroundElevated, - border: Border(bottom: BorderSide(color: AppTheme.divider))), - child: Row(children: [ - // Account switcher avatar - _buildAccountSwitcher(), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_svc.currentUser ?? 'GitHub User', - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary), - overflow: TextOverflow.ellipsis), - Text('${_repos.length} repositories', - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 11, - color: AppTheme.textTertiary)), - ])), - IconButton( - onPressed: _showSearchSheet, - icon: const Icon(Icons.search, - size: 20, color: AppTheme.textSecondary)), - IconButton( - onPressed: () { - final i = _tabCtrl.index; - if (i == 0) _loadRepos(); - if (i == 1) _loadIssues(); - if (i == 2) _loadPRs(); - if (i == 3) _loadNotifications(); - }, - icon: const Icon(Icons.refresh, - size: 20, color: AppTheme.textSecondary)), + ?.copyWith(color: AppTheme.textTertiary)), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _oauthBusy ? null : _launchOAuth, + icon: _oauthBusy + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.open_in_browser, size: 18), + label: Text(GitHubOAuthFlow.actionLabel), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.accent, + side: const BorderSide(color: AppTheme.accent), + padding: + const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10))), + )), + ])), + if (_error != null) ...[ + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppTheme.error.withOpacity(0.3))), + child: Row(children: [ + const Icon(Icons.error_outline, + color: AppTheme.error, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text(_error!, + style: const TextStyle( + color: AppTheme.error, fontSize: 13))), + ]), + ), + ], + ]), + ); + + // ── Main Screen with Tabs ────────────────────────────────────────────────── + + Widget _buildMain() => Column(children: [ + _buildHeader(), + Container( + color: AppTheme.backgroundElevated, + child: TabBar( + controller: _tabCtrl, + labelColor: AppTheme.primary, + unselectedLabelColor: AppTheme.textTertiary, + indicatorColor: AppTheme.primary, + indicatorWeight: 2.5, + labelStyle: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + fontWeight: FontWeight.w600), + unselectedLabelStyle: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + fontWeight: FontWeight.w500), + tabs: [ + const Tab( + icon: Icon(Icons.folder_outlined, size: 20), text: 'Repos'), + const Tab( + icon: Icon(Icons.error_outline, size: 20), text: 'Issues'), + const Tab( + icon: Icon(Icons.call_merge, size: 20), text: 'Pull Req'), + Tab( + icon: Stack(clipBehavior: Clip.none, children: [ + const Icon(Icons.notifications_outlined, size: 20), + if (_unreadCount > 0) + Positioned( + right: -6, + top: -4, + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: AppTheme.error, + shape: BoxShape.circle), + constraints: const BoxConstraints( + minWidth: 14, minHeight: 14), + child: Text( + _unreadCount > 99 + ? '99+' + : '\$_unreadCount', + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold), + textAlign: TextAlign.center))), + ]), + text: 'Alerts'), + ], + )), + Expanded( + child: TabBarView(controller: _tabCtrl, children: [ + _buildReposTab(), + _buildIssuesTab(), + _buildPRsTab(), + _buildNotificationsTab(), + ])), + ]); + + // ── Header with Multi-Account Switcher ───────────────────────────────────── + + Widget _buildHeader() => Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppTheme.backgroundElevated, + border: Border(bottom: BorderSide(color: AppTheme.divider))), + child: Row(children: [ + // Account switcher avatar + _buildAccountSwitcher(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_svc.currentUser ?? 'GitHub User', + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary), + overflow: TextOverflow.ellipsis), + Text('${_repos.length} repositories', + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 11, + color: AppTheme.textTertiary)), + ])), + IconButton( + onPressed: _showSearchSheet, + icon: const Icon(Icons.search, + size: 20, color: AppTheme.textSecondary)), + IconButton( + onPressed: () { + final i = _tabCtrl.index; + if (i == 0) _loadRepos(); + if (i == 1) _loadIssues(); + if (i == 2) _loadPRs(); + if (i == 3) _loadNotifications(); + }, + icon: const Icon(Icons.refresh, + size: 20, color: AppTheme.textSecondary)), if (_svc.accountList.length > 1) IconButton( onPressed: _showAccountSwitcher, icon: const Icon(Icons.switch_account, size: 20, color: AppTheme.accent)), IconButton( + tooltip: 'Remove GitHub access', onPressed: _logout, icon: const Icon(Icons.logout, - size: 18, color: AppTheme.textTertiary)), + size: 18, color: AppTheme.error)), ]), ); - - Widget _buildAccountSwitcher() { - final avatarUrl = _svc.activeSession?.avatarUrl ?? ''; - return InkWell( - onTap: _svc.accountList.length > 1 ? _showAccountSwitcher : null, - borderRadius: BorderRadius.circular(10), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - gradient: avatarUrl.isEmpty ? AppTheme.primaryGradient : null, - borderRadius: BorderRadius.circular(10), - image: avatarUrl.isNotEmpty - ? DecorationImage( - image: NetworkImage(avatarUrl), fit: BoxFit.cover) - : null, - ), - child: avatarUrl.isEmpty - ? Center( - child: Text( - (_svc.currentUser ?? 'G')[0].toUpperCase(), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white))) - : null, - ), - ); - } - - void _showAccountSwitcher() { + + Widget _buildAccountSwitcher() { + final avatarUrl = _svc.activeSession?.avatarUrl ?? ''; + return InkWell( + onTap: _svc.accountList.length > 1 ? _showAccountSwitcher : null, + borderRadius: BorderRadius.circular(10), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: avatarUrl.isEmpty ? AppTheme.primaryGradient : null, + borderRadius: BorderRadius.circular(10), + image: avatarUrl.isNotEmpty + ? DecorationImage( + image: NetworkImage(avatarUrl), fit: BoxFit.cover) + : null, + ), + child: avatarUrl.isEmpty + ? Center( + child: Text( + (_svc.currentUser ?? 'G')[0].toUpperCase(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white))) + : null, + ), + ); + } + + void _showAccountSwitcher() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(20), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _sheetHandle(), + const SizedBox(height: 16), + const Text('Switch Account', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary)), + const SizedBox(height: 16), + ..._svc.accountList.map((username) { + final isActive = username == _svc.currentUser; + return ListTile( + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: AppTheme.accentGradient, + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text(username[0].toUpperCase(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.white))), + ), + title: Text(username, + style: TextStyle( + color: isActive ? AppTheme.primary : AppTheme.textPrimary, + fontWeight: + isActive ? FontWeight.w700 : FontWeight.w500)), + trailing: isActive + ? const Icon(Icons.check_circle, color: AppTheme.success) + : null, + onTap: () async { + Navigator.pop(ctx); + if (!isActive) { + setState(() => _loading = true); + await _svc.switchAccount(username); + await _loadRepos(); + setState(() { + _issues.clear(); + _prs.clear(); + _notifications.clear(); + _loading = false; + }); + _toast('Switched to \$username'); + } + }, + ); + }), + const Divider(color: AppTheme.divider), + ListTile( + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppTheme.surfaceHover, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.border), + ), + child: const Icon(Icons.add, color: AppTheme.textSecondary), + ), + title: const Text('Add Account', + style: TextStyle(color: AppTheme.textSecondary)), + onTap: () { + Navigator.pop(ctx); + _showAddAccountSheet(); + }, + ), + ]), + ), + ); + } + + void _showAddAccountSheet() { + final addTokenCtrl = TextEditingController(); + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + top: 20, + left: 20, + right: 20, + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _sheetHandle(), + const SizedBox(height: 16), + const Text('Add Another Account', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary)), + const SizedBox(height: 16), + TextField( + controller: addTokenCtrl, + obscureText: true, + style: const TextStyle( + color: AppTheme.textPrimary, fontFamily: AppTheme.fontCode), + decoration: _inputDecoration('ghp_xxxxxxxxxxxxxxxxxxxx', + icon: Icons.vpn_key), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () async { + final token = addTokenCtrl.text.trim(); + if (token.isEmpty) return; + Navigator.pop(ctx); + setState(() => _loading = true); + try { + final ok = await _svc.authenticate(token); + if (ok) { + await _loadRepos(); + setState(() => _loading = false); + _toast('Account added'); + } else { + setState(() => _loading = false); + _toast('Invalid token', isError: true); + } + } catch (e) { + setState(() => _loading = false); + _toast('Failed: \$e', isError: true); + } + }, + icon: const Icon(Icons.login, size: 18), + label: const Text('Connect'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + ]), + ), + ); + } + + + // ═══════════════════════════════════════════════════════════════════════════ + // REPOSITORIES TAB (Enhanced) + // ═══════════════════════════════════════════════════════════════════════════ + + // Language color map for popular programming languages + static final Map _languageColors = { + 'Dart': const Color(0xFF00B4AB), + 'Python': const Color(0xFF3572A5), + 'JavaScript': const Color(0xFFF1E05A), + 'TypeScript': const Color(0xFF3178C6), + 'Go': const Color(0xFF00ADD8), + 'Rust': const Color(0xFFDEA584), + 'Java': const Color(0xFFB07219), + 'C++': const Color(0xFFF34B7D), + 'C': const Color(0xFF555555), + 'C#': const Color(0xFF178600), + 'Swift': const Color(0xFFFFAC45), + 'Kotlin': const Color(0xFFA97BFF), + 'Ruby': const Color(0xFF701516), + 'PHP': const Color(0xFF4F5D95), + 'HTML': const Color(0xFFE34C26), + 'CSS': const Color(0xFF563D7C), + 'Shell': const Color(0xFF89E051), + 'Lua': const Color(0xFF000080), + 'Scala': const Color(0xFFC22D40), + 'Elixir': const Color(0xFF6E4A7E), + 'Vue': const Color(0xFF41B883), + 'Flutter': const Color(0xFF54C5F8), + }; + + static final List _popularLanguages = [ + 'Dart', 'Python', 'JavaScript', 'TypeScript', 'Go', 'Rust', 'Java', 'C++', + 'Swift', 'Kotlin', 'Ruby', 'Vue' + ]; + + Widget _buildReposTab() { + // Apply language filter to displayed repos + var display = _repos; + if (_repoLanguageFilter != null) { + display = display.where((r) { + // Check if repo's language matches filter + return r.language?.toLowerCase() == + _repoLanguageFilter!.toLowerCase() || + r.name.toLowerCase().contains(_repoLanguageFilter!.toLowerCase()); + }).toList(); + } + + // Sort repos + switch (_repoSort) { + case 'name_asc': + display.sort((a, b) => a.name.compareTo(b.name)); + case 'name_desc': + display.sort((a, b) => b.name.compareTo(a.name)); + case 'stars': + display.sort((a, b) => b.stars.compareTo(a.stars)); + case 'updated': + // Already sorted by pushed from API + break; + } + + // Search overlay + final isSearching = _searchCtrl.text.isNotEmpty; + final searchDisplay = isSearching + ? _searchResults + .map((r) => GitHubRepo.fromGitHubApi(r as Map)) + .toList() + : []; + + if (display.isEmpty && !_searching && !isSearching && _repos.isEmpty) { + return _buildFirstTimeEmpty(); + } + + return Column(children: [ + // Ownership filter chips + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + _filterChip('all', 'All', _repoFilter, (v) { + setState(() => _repoFilter = v); + _loadRepos(); + }), + const SizedBox(width: 8), + _filterChip('owner', 'Owned', _repoFilter, (v) { + setState(() => _repoFilter = v); + _loadRepos(); + }), + const SizedBox(width: 8), + _filterChip('member', 'Member', _repoFilter, (v) { + setState(() => _repoFilter = v); + _loadRepos(); + }), + const SizedBox(width: 8), + _filterChip('collaborator', 'Collab', _repoFilter, (v) { + setState(() => _repoFilter = v); + _loadRepos(); + }), + ]), + ), + ), + // Sort + Language filter + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row(children: [ + // Sort dropdown + Expanded( + child: InkWell( + onTap: _showSortMenu, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.surface.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.border), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.sort, size: 14, color: AppTheme.textTertiary), + const SizedBox(width: 6), + Text(_sortLabel(_repoSort), + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const Icon(Icons.keyboard_arrow_down, + size: 16, color: AppTheme.textTertiary), + ]), + ), + ), + ), + const SizedBox(width: 8), + // Language filter dropdown + Expanded( + child: InkWell( + onTap: _showLanguageFilterMenu, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: _repoLanguageFilter != null + ? AppTheme.primary.withOpacity(0.15) + : AppTheme.surface.withOpacity(0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _repoLanguageFilter != null + ? AppTheme.primary.withOpacity(0.5) + : AppTheme.border), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.code, + size: 14, + color: _repoLanguageFilter != null + ? AppTheme.primary + : AppTheme.textTertiary), + const SizedBox(width: 6), + Expanded( + child: Text( + _repoLanguageFilter ?? 'Language', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: _repoLanguageFilter != null + ? AppTheme.primary + : AppTheme.textSecondary)), + ), + if (_repoLanguageFilter != null) + InkWell( + onTap: () { + setState(() => _repoLanguageFilter = null); + }, + child: const Icon(Icons.close, + size: 14, color: AppTheme.primary), + ) + else + const Icon(Icons.keyboard_arrow_down, + size: 16, color: AppTheme.textTertiary), + ]), + ), + ), + ), + ]), + ), + // Language quick chips + if (!isSearching) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + ..._popularLanguages.take(8).map((lang) { + final isSelected = _repoLanguageFilter == lang; + final color = _languageColors[lang] ?? AppTheme.textTertiary; + return Padding( + padding: const EdgeInsets.only(right: 6), + child: InkWell( + onTap: () { + setState(() => _repoLanguageFilter = + isSelected ? null : lang); + }, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isSelected + ? color.withOpacity(0.2) + : AppTheme.surface.withOpacity(0.4), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected + ? color.withOpacity(0.5) + : AppTheme.border, + width: 0.8), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 5), + Text(lang, + style: TextStyle( + fontSize: 11, + color: isSelected ? color : AppTheme.textSecondary, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400)), + ]), + ), + ), + ); + }), + ]), + ), + ), + // Search indicator + if (isSearching) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row(children: [ + const Icon(Icons.search, size: 14, color: AppTheme.primary), + const SizedBox(width: 6), + Text('Search: "${_searchCtrl.text}"', + style: const TextStyle( + fontSize: 12, + color: AppTheme.primary, + fontWeight: FontWeight.w500)), + const Spacer(), + InkWell( + onTap: () { + setState(() { + _searchCtrl.clear(); + _searchResults.clear(); + }); + }, + child: const Icon(Icons.close, size: 16, color: AppTheme.textTertiary), + ), + ]), + ), + // Repo list + Expanded( + child: RefreshIndicator( + onRefresh: _loadRepos, + color: AppTheme.primary, + backgroundColor: AppTheme.surface, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemCount: isSearching ? searchDisplay.length : display.length, + itemBuilder: (_, i) => isSearching + ? _repoCard(searchDisplay[i]) + : _repoCard(display[i]), + ), + ), + ), + ]); + } + + Widget _buildFirstTimeEmpty() => Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + gradient: AppTheme.accentGradient, + borderRadius: BorderRadius.circular(28), + boxShadow: [ + BoxShadow( + color: AppTheme.accent.withOpacity(0.2), + blurRadius: 24, + spreadRadius: 4) + ], + ), + child: + const Icon(Icons.folder_open, size: 48, color: Colors.white), + ), + const SizedBox(height: 24), + Text('Welcome to GitHub!', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text( + 'You don\'t have any repositories yet. Create your first one to get started.', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: AppTheme.textSecondary)), + const SizedBox(height: 8), + Text('Or tap the search icon to find repositories.', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: AppTheme.textTertiary)), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _showCreateRepoSheet, + icon: const Icon(Icons.create_new_folder), + label: const Text('Create Repository'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 14), + ), + ), + ]), + ), + ); + + Widget _repoCard(GitHubRepo repo) { + final langColor = _languageColors[repo.language] ?? AppTheme.textTertiary; + final pushedAgo = _ago(repo.lastSynced); + return GlassCardWidget( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + borderRadius: 12, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GitHubRepoScreen( + repoName: repo.name, + owner: repo.owner, + description: repo.description, + language: repo.language))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(repo.isPrivate ? Icons.lock_outline : Icons.folder_outlined, + size: 16, + color: repo.isPrivate ? AppTheme.warning : AppTheme.accent), + const SizedBox(width: 8), + Expanded( + child: Text(repo.fullName, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary), + overflow: TextOverflow.ellipsis)), + Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.star, size: 13, color: AppTheme.warning), + const SizedBox(width: 3), + Text('${repo.stars}', + style: const TextStyle( + fontSize: 12, color: AppTheme.textTertiary)), + ]), + ]), + if (repo.description.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(repo.description, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + color: AppTheme.textSecondary), + maxLines: 2, + overflow: TextOverflow.ellipsis) + ], + const SizedBox(height: 10), + Row(children: [ + // Language dot + if (repo.language != null) ...[ + Container( + width: 8, + height: 8, + decoration: + BoxDecoration(color: langColor, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text(repo.language!, + style: TextStyle( + fontSize: 11, + fontFamily: AppTheme.fontCode, + color: langColor)), + const SizedBox(width: 12), + ], + // Default branch + Text(repo.defaultBranch, + style: const TextStyle( + fontSize: 11, + fontFamily: AppTheme.fontCode, + color: AppTheme.textTertiary)), + const Spacer(), + // Fork count + if (repo.forks != null && repo.forks! > 0) ...[ + const Icon(Icons.call_split, + size: 12, color: AppTheme.textTertiary), + const SizedBox(width: 3), + Text('${repo.forks}', + style: const TextStyle( + fontSize: 11, color: AppTheme.textTertiary)), + const SizedBox(width: 10), + ], + // Last pushed + const Icon(Icons.schedule, size: 11, color: AppTheme.textTertiary), + const SizedBox(width: 3), + Text(pushedAgo, + style: const TextStyle( + fontSize: 10, color: AppTheme.textTertiary)), + ]), + const SizedBox(height: 8), + // Action buttons + Row(children: [ + _miniAction(Icons.content_copy, 'Clone', () => _cloneSheet(repo)), + const SizedBox(width: 12), + _miniAction(Icons.call_split, 'Fork', () => _forkRepo(repo)), + const SizedBox(width: 12), + _miniAction(Icons.star_border, 'Star', () => _starRepo(repo)), + const Spacer(), + _miniAction(Icons.open_in_browser, 'Open', + () => launchUrl(Uri.parse(repo.webUrl))), + ]), + ]), + ); + } + + String _sortLabel(String sort) => switch (sort) { + 'name_asc' => 'Name \u2191', + 'name_desc' => 'Name \u2193', + 'pushed' => 'Recent Push', + 'updated' => 'Updated', + 'stars' => 'Most Stars', + _ => 'Recent Push', + }; + + void _showSortMenu() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(16), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _sheetHandle(), + const SizedBox(height: 12), + const Text('Sort Repositories', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 12), + ...[ + ('pushed', 'Recently Pushed'), + ('updated', 'Recently Updated'), + ('stars', 'Most Stars'), + ('name_asc', 'Name (A-Z)'), + ('name_desc', 'Name (Z-A)'), + ].map((item) { + final (value, label) = item; + final selected = _repoSort == value; + return ListTile( + dense: true, + title: Text(label, + style: TextStyle( + color: selected ? AppTheme.primary : AppTheme.textPrimary, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w400)), + trailing: selected + ? const Icon(Icons.check, color: AppTheme.primary, size: 20) + : null, + onTap: () { + setState(() => _repoSort = value); + Navigator.pop(ctx); + }, + ); + }), + ]), + ), + ); + } + + void _showLanguageFilterMenu() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(16), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _sheetHandle(), + const SizedBox(height: 12), + const Text('Filter by Language', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 12), + ListTile( + dense: true, + title: const Text('All Languages', + style: TextStyle(color: AppTheme.textPrimary)), + trailing: _repoLanguageFilter == null + ? const Icon(Icons.check, color: AppTheme.primary, size: 20) + : null, + onTap: () { + setState(() => _repoLanguageFilter = null); + Navigator.pop(ctx); + }, + ), + const Divider(color: AppTheme.divider, height: 8), + ..._popularLanguages.map((lang) { + final selected = _repoLanguageFilter == lang; + final color = _languageColors[lang] ?? AppTheme.textTertiary; + return ListTile( + dense: true, + leading: Container( + width: 10, + height: 10, + decoration: + BoxDecoration(color: color, shape: BoxShape.circle), + ), + title: Text(lang, + style: TextStyle( + color: + selected ? AppTheme.primary : AppTheme.textPrimary, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w400)), + trailing: selected + ? const Icon(Icons.check, color: AppTheme.primary, size: 20) + : null, + onTap: () { + setState(() => _repoLanguageFilter = lang); + Navigator.pop(ctx); + }, + ); + }), + ]), + ), + ); + } + + + // ═══════════════════════════════════════════════════════════════════════════ + // ISSUES TAB (Enhanced) + // ═══════════════════════════════════════════════════════════════════════════ + + Widget _buildIssuesTab() { + if (_issues.isEmpty && !_loading) { + return _empty(Icons.check_circle_outline, 'No issues found', + 'All clear! Create an issue to get started.'); + } + return Column(children: [ + // State filter chips + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + _filterChip('open', 'Open', _issueFilter, (v) { + setState(() => _issueFilter = v); + _loadIssues(); + }), + const SizedBox(width: 8), + _filterChip('closed', 'Closed', _issueFilter, (v) { + setState(() => _issueFilter = v); + _loadIssues(); + }), + const SizedBox(width: 8), + _filterChip('all', 'All', _issueFilter, (v) { + setState(() => _issueFilter = v); + _loadIssues(); + }), + const SizedBox(width: 8), + // Label filter chip + if (_repoLabels.isNotEmpty) + InkWell( + onTap: _showIssueLabelFilter, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _issueLabelFilter != null + ? AppTheme.primary.withOpacity(0.2) + : AppTheme.surface.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: _issueLabelFilter != null + ? AppTheme.primary.withOpacity(0.5) + : AppTheme.border, + width: 1), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.label_outline, + size: 12, + color: _issueLabelFilter != null + ? AppTheme.primary + : AppTheme.textSecondary), + const SizedBox(width: 4), + Text( + _issueLabelFilter ?? + 'Labels (${_repoLabels.length})', + style: TextStyle( + fontSize: 11, + fontWeight: _issueLabelFilter != null + ? FontWeight.w600 + : FontWeight.w500, + color: _issueLabelFilter != null + ? AppTheme.primary + : AppTheme.textSecondary)), + if (_issueLabelFilter != null) ...[ + const SizedBox(width: 4), + InkWell( + onTap: () { + setState(() => _issueLabelFilter = null); + _loadIssues(); + }, + child: const Icon(Icons.close, + size: 12, color: AppTheme.primary), + ), + ], + ]), + ), + ), + ]), + ), + ), + // Assignee + Milestone filters + if (_issueLabelFilter != null || _issueAssigneeFilter != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row(children: [ + if (_issueLabelFilter != null) + _activeFilterChip( + 'Label: $_issueLabelFilter', + () => setState(() { + _issueLabelFilter = null; + _loadIssues(); + })), + ]), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _loadIssues, + color: AppTheme.primary, + backgroundColor: AppTheme.surface, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemCount: _issues.length, + itemBuilder: (_, i) => _issueCard(_issues[i]), + ), + ), + ), + ]); + } + + Widget _activeFilterChip(String label, VoidCallback onRemove) => Container( + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.primary.withOpacity(0.3)), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(label, + style: const TextStyle( + fontSize: 10, + color: AppTheme.primary, + fontWeight: FontWeight.w500)), + const SizedBox(width: 4), + InkWell( + onTap: onRemove, + child: const Icon(Icons.close, size: 12, color: AppTheme.primary), + ), + ]), + ); + + void _showIssueLabelFilter() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(16), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _sheetHandle(), + const SizedBox(height: 12), + const Text('Filter by Label', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 12), + ListTile( + dense: true, + title: const Text('All Labels', + style: TextStyle(color: AppTheme.textPrimary)), + trailing: _issueLabelFilter == null + ? const Icon(Icons.check, color: AppTheme.primary, size: 20) + : null, + onTap: () { + setState(() => _issueLabelFilter = null); + _loadIssues(); + Navigator.pop(ctx); + }, + ), + const Divider(color: AppTheme.divider, height: 8), + ..._repoLabels.map((label) { + final name = label['name'] as String? ?? ''; + final color = _hexColor(label['color'] as String? ?? '666666'); + final selected = _issueLabelFilter == name; + return ListTile( + dense: true, + leading: Container( + width: 10, + height: 10, + decoration: + BoxDecoration(color: color, shape: BoxShape.circle), + ), + title: Text(name, + style: TextStyle( + color: + selected ? AppTheme.primary : AppTheme.textPrimary, + fontWeight: + selected ? FontWeight.w600 : FontWeight.w400)), + trailing: selected + ? const Icon(Icons.check, color: AppTheme.primary, size: 20) + : null, + onTap: () { + setState(() => _issueLabelFilter = name); + _loadIssues(); + Navigator.pop(ctx); + }, + ); + }), + ]), + ), + ); + } + + Widget _issueCard(dynamic issue) { + final open = issue['state'] == 'open'; + final labels = (issue['labels'] as List?) ?? []; + final num = issue['number'] as int? ?? 0; + final title = issue['title'] as String? ?? 'Untitled'; + final author = + ((issue['user'] as Map?)?.cast())?[ + 'login'] + as String? ?? + 'unknown'; + final created = issue['created_at'] != null + ? DateTime.parse(issue['created_at'] as String) + : DateTime.now(); + final comments = issue['comments'] as int? ?? 0; + final assignees = (issue['assignees'] as List?) ?? []; + + return GlassCardWidget( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + borderRadius: 12, + onTap: () => _navigateToIssueDetail(issue), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(open ? Icons.error_outline : Icons.check_circle, + size: 18, color: open ? AppTheme.success : AppTheme.textTertiary), + const SizedBox(width: 8), + Text('#$num', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textTertiary, + fontFamily: AppTheme.fontCode)), + const Spacer(), + Text(_ago(created), + style: const TextStyle( + fontSize: 11, color: AppTheme.textTertiary)), + ]), + const SizedBox(height: 8), + Text(title, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + if (labels.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 4, + children: labels.map((l) { + final name = l['name'] as String? ?? ''; + final c = _hexColor(l['color'] as String? ?? '666666'); + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: c.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: c.withOpacity(0.3), width: 0.5)), + child: Text(name, + style: TextStyle( + fontSize: 10, + color: c, + fontWeight: FontWeight.w500))); + }).toList()) + ], + const SizedBox(height: 10), + Row(children: [ + _avatar(author, gradient: AppTheme.accentGradient), + const SizedBox(width: 6), + Text(author, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const Spacer(), + // Assignees + if (assignees.isNotEmpty) ...[ + Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.person_outline, + size: 12, color: AppTheme.textTertiary), + const SizedBox(width: 2), + Text('${assignees.length}', + style: const TextStyle( + fontSize: 11, color: AppTheme.textTertiary)), + ]), + const SizedBox(width: 10), + ], + // Comments count + if (comments > 0) + Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.comment_outlined, + size: 13, color: AppTheme.textTertiary), + const SizedBox(width: 3), + Text('$comments', + style: const TextStyle( + fontSize: 11, color: AppTheme.textTertiary)), + ]), + ]), + ]), + ); + } + + void _navigateToIssueDetail(dynamic issue) { + if (_repos.isEmpty) return; + final num = issue['number'] as int? ?? 0; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GitHubIssueDetailScreen( + owner: _repos.first.owner, + repo: _repos.first.name, + issueNumber: num, + ), + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PULL REQUESTS TAB (Enhanced) + // ═══════════════════════════════════════════════════════════════════════════ + + Widget _buildPRsTab() { + if (_prs.isEmpty && !_loading) { + return _empty(Icons.call_merge, 'No pull requests', + 'Create a pull request to propose changes.'); + } + return Column(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + _filterChip('open', 'Open', _prFilter, (v) { + setState(() => _prFilter = v); + _loadPRs(); + }), + const SizedBox(width: 8), + _filterChip('closed', 'Closed', _prFilter, (v) { + setState(() => _prFilter = v); + _loadPRs(); + }), + const SizedBox(width: 8), + _filterChip('all', 'All', _prFilter, (v) { + setState(() => _prFilter = v); + _loadPRs(); + }), + ]), + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _loadPRs, + color: AppTheme.primary, + backgroundColor: AppTheme.surface, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemCount: _prs.length, + itemBuilder: (_, i) => _prCard(_prs[i]), + ), + ), + ), + ]); + } + + Widget _prCard(dynamic pr) { + final state = pr['state'] as String? ?? 'open'; + final merged = pr['merged'] == true; + final draft = pr['draft'] == true; + final num = pr['number'] as int? ?? 0; + final title = pr['title'] as String? ?? 'Untitled'; + final author = + ((pr['user'] as Map?)?.cast())?[ + 'login'] + as String? ?? + 'unknown'; + final head = + ((pr['head'] as Map?)?.cast())?[ + 'ref'] + as String? ?? + 'unknown'; + final base = + ((pr['base'] as Map?)?.cast())?[ + 'ref'] + as String? ?? + 'unknown'; + + // CI status + final ciStatus = _getCIStatus(pr); + + // Review state + final reviewState = pr['review_state'] as String?; + + late final Color sColor; + late final IconData sIcon; + if (merged) { + sColor = AppTheme.primary; + sIcon = Icons.merge_type; + } else if (draft) { + sColor = AppTheme.textTertiary; + sIcon = Icons.drafts; + } else if (state == 'open') { + sColor = AppTheme.success; + sIcon = Icons.call_merge; + } else { + sColor = AppTheme.error; + sIcon = Icons.cancel; + } + + return GlassCardWidget( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + borderRadius: 12, + onTap: () => _navigateToPRReview(pr), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(sIcon, size: 18, color: sColor), + const SizedBox(width: 8), + Text('#$num', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textTertiary, + fontFamily: AppTheme.fontCode)), + const SizedBox(width: 8), + // CI status indicator + if (ciStatus != null) ...[ + _ciStatusIcon(ciStatus), + const SizedBox(width: 6), + ], + const Spacer(), + // Review status badge + if (reviewState != null) + _reviewStatusBadge(reviewState) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: sColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + border: + Border.all(color: sColor.withOpacity(0.3), width: 0.5)), + child: Text(merged ? 'MERGED' : draft ? 'DRAFT' : state.toUpperCase(), + style: TextStyle( + fontSize: 9, + color: sColor, + fontWeight: FontWeight.w700, + letterSpacing: 0.5)), + ), + ]), + const SizedBox(height: 8), + Text(title, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 8), + // Branch info + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppTheme.surfaceHover, + borderRadius: BorderRadius.circular(8)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(head, + style: const TextStyle( + fontSize: 11, + fontFamily: AppTheme.fontCode, + color: AppTheme.accent)), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, + size: 12, color: AppTheme.textTertiary), + const SizedBox(width: 8), + Text(base, + style: const TextStyle( + fontSize: 11, + fontFamily: AppTheme.fontCode, + color: AppTheme.textSecondary)), + ]), + ), + const SizedBox(height: 10), + Row(children: [ + _avatar(author, gradient: AppTheme.primaryGradient), + const SizedBox(width: 6), + Text(author, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const Spacer(), + // PR comments + if ((pr['comments'] as int? ?? 0) > 0) ...[ + const Icon(Icons.comment_outlined, + size: 12, color: AppTheme.textTertiary), + const SizedBox(width: 3), + Text('${pr['comments']}', + style: const TextStyle( + fontSize: 11, color: AppTheme.textTertiary)), + const SizedBox(width: 10), + ], + // Changed files count + if ((pr['changed_files'] as int? ?? 0) > 0) + Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.insert_drive_file_outlined, + size: 12, color: AppTheme.textTertiary), + const SizedBox(width: 3), + Text('${pr['changed_files']}', + style: const TextStyle( + fontSize: 11, color: AppTheme.textTertiary)), + ]), + ]), + ]), + ); + } + + String? _getCIStatus(dynamic pr) { + // Check for CI status from various API fields + if (pr['statuses_url'] != null) { + // Could fetch real CI status from API + // For now, return simulated status based on mergeable state + final mergeableState = pr['mergeable_state'] as String?; + if (mergeableState == 'clean') return 'success'; + if (mergeableState == 'dirty') return 'failure'; + if (mergeableState == 'unstable') return 'pending'; + } + final state = pr['state'] as String?; + if (state == 'closed' && pr['merged'] != true) return null; + return null; + } + + Widget _ciStatusIcon(String status) { + late final IconData icon; + late final Color color; + switch (status) { + case 'success': + icon = Icons.check_circle; + color = AppTheme.success; + case 'failure': + icon = Icons.cancel; + color = AppTheme.error; + case 'pending': + icon = Icons.pending; + color = AppTheme.warning; + default: + icon = Icons.circle; + color = AppTheme.textTertiary; + } + return Icon(icon, size: 16, color: color); + } + + Widget _reviewStatusBadge(String state) { + late final Color color; + late final String label; + late final IconData icon; + switch (state) { + case 'APPROVED': + color = AppTheme.success; + label = 'APPROVED'; + icon = Icons.check_circle; + case 'CHANGES_REQUESTED': + color = AppTheme.error; + label = 'CHANGES'; + icon = Icons.cancel; + case 'COMMENTED': + color = AppTheme.info; + label = 'REVIEWED'; + icon = Icons.comment; + default: + color = AppTheme.textTertiary; + label = 'PENDING'; + icon = Icons.pending; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.3), width: 0.5)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 3), + Text(label, + style: TextStyle( + fontSize: 8, + color: color, + fontWeight: FontWeight.w700, + letterSpacing: 0.5)), + ]), + ); + } + + void _navigateToPRReview(dynamic pr) { + if (_repos.isEmpty) return; + final num = pr['number'] as int? ?? 0; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GitHubPrReviewScreen( + owner: _repos.first.owner, + repo: _repos.first.name, + pullNumber: num, + ), + ), + ); + } + + + // ═══════════════════════════════════════════════════════════════════════════ + // NOTIFICATIONS TAB (Enhanced) + // ═══════════════════════════════════════════════════════════════════════════ + + Widget _buildNotificationsTab() { + if (_notifications.isEmpty) { + return _empty(Icons.notifications_off_outlined, 'No new notifications', + 'You\'re all caught up!'); + } + + // Group notifications by repository + final grouped = >{}; + for (final n in _notifications) { + final repoName = + ((n['repository'] as Map?)?.cast())?[ + 'full_name'] + as String? ?? + 'unknown/repo'; + grouped.putIfAbsent(repoName, () => []).add(n); + } + + return Column(children: [ + // Mark all read + count + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row(children: [ + TextButton.icon( + onPressed: _markAllNotificationsRead, + icon: const Icon(Icons.done_all, size: 16), + label: const Text('Mark all read', style: TextStyle(fontSize: 12)), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppTheme.error.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Text('$_unreadCount unread', + style: const TextStyle( + fontSize: 12, + color: AppTheme.error, + fontWeight: FontWeight.w600)), + ), + ]), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _loadNotifications, + color: AppTheme.primary, + backgroundColor: AppTheme.surface, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemCount: grouped.length, + itemBuilder: (_, groupIndex) { + final repoName = grouped.keys.elementAt(groupIndex); + final items = grouped[repoName]!; + return _buildNotificationGroup(repoName, items); + }, + ), + ), + ), + ]); + } + + Widget _buildNotificationGroup(String repoName, List items) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Repo header + Padding( + padding: const EdgeInsets.fromLTRB(4, 12, 4, 6), + child: Row(children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppTheme.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text(repoName, + style: const TextStyle( + fontSize: 13, + fontFamily: AppTheme.fontCode, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text('${items.length}', + style: const TextStyle( + fontSize: 10, + color: AppTheme.primary, + fontWeight: FontWeight.w600)), + ), + ]), + ), + // Notification items with swipe-to-dismiss + ...items.map((n) => _buildDismissibleNotification(n)), + const Divider(color: AppTheme.divider, height: 16), + ], + ); + } + + Widget _buildDismissibleNotification(dynamic n) { + final id = n['id'] as String? ?? ''; + return Dismissible( + key: Key('notif_$id'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + decoration: BoxDecoration( + color: AppTheme.success.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.done_all, color: AppTheme.success, size: 20), + SizedBox(width: 6), + Text('Mark Read', + style: TextStyle( + color: AppTheme.success, + fontWeight: FontWeight.w600, + fontSize: 12)), + ], + ), + ), + onDismissed: (_) async { + try { + await _svc.markNotificationRead(id); + setState(() { + _notifications.remove(n); + _unreadCount = (_unreadCount - 1).clamp(0, 999); + }); + } catch (e) { + _toast('Failed: \$e', isError: true); + } + }, + child: _notifCard(n), + ); + } + + Widget _notifCard(dynamic n) { + final sub = (n['subject'] as Map?)?.cast(); + final title = sub?['title'] as String? ?? 'Notification'; + final type = sub?['type'] as String? ?? 'Unknown'; + final repoName = + ((n['repository'] as Map?)?.cast())?[ + 'full_name'] + as String? ?? + 'unknown/repo'; + final updated = n['updated_at'] != null + ? DateTime.parse(n['updated_at'] as String) + : DateTime.now(); + final reason = n['reason'] as String? ?? ''; + + late final IconData tIcon; + late final Color tColor; + switch (type) { + case 'PullRequest': + tIcon = Icons.call_merge; + tColor = AppTheme.primary; + case 'Issue': + tIcon = Icons.error_outline; + tColor = AppTheme.success; + case 'Release': + tIcon = Icons.new_releases_outlined; + tColor = AppTheme.accent; + case 'Commit': + tIcon = Icons.commit; + tColor = AppTheme.info; + case 'Discussion': + tIcon = Icons.forum_outlined; + tColor = AppTheme.warning; + default: + tIcon = Icons.notifications_none; + tColor = AppTheme.textTertiary; + } + + return GlassCardWidget( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.all(12), + borderRadius: 10, + child: Row(children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: tColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(10)), + child: Icon(tIcon, size: 18, color: tColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary), + maxLines: 2, + overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Row(children: [ + Text(type, + style: TextStyle( + fontSize: 10, + fontFamily: AppTheme.fontCode, + color: tColor, + fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + if (reason.isNotEmpty) + Text(reason, + style: const TextStyle( + fontSize: 10, color: AppTheme.textTertiary)), + const Spacer(), + Text(_ago(updated), + style: const TextStyle( + fontSize: 10, color: AppTheme.textTertiary)), + ]), + ])), + IconButton( + onPressed: () async { + final id = n['id'] as String?; + if (id != null) { + await _svc.markNotificationRead(id); + setState(() { + _notifications.remove(n); + _unreadCount = (_unreadCount - 1).clamp(0, 999); + }); + } + }, + icon: const Icon(Icons.done, size: 18, color: AppTheme.success)), + ]), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // BOTTOM SHEETS & DIALOGS + // ═══════════════════════════════════════════════════════════════════════════ + + void _showSearchSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSt) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(20)), + border: Border(top: BorderSide(color: AppTheme.border)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, + top: 16, + left: 16, + right: 16, + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.border, + borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 16), + TextField( + controller: _searchCtrl, + autofocus: true, + style: const TextStyle(color: AppTheme.textPrimary), + onSubmitted: (_) async { + setSt(() {}); + await _search(); + setSt(() {}); + }, + decoration: _inputDecoration('Search repositories on GitHub...', + icon: Icons.search, + suffix: _searching + ? Container( + width: 20, + height: 20, + margin: const EdgeInsets.all(12), + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppTheme.primary))) + : IconButton( + icon: const Icon(Icons.arrow_forward, + color: AppTheme.primary), + onPressed: () async { + setSt(() {}); + await _search(); + setSt(() {}); + })), + ), + if (_searchResults.isNotEmpty) ...[ + const SizedBox(height: 12), + SizedBox( + height: 400, + child: ListView.builder( + itemCount: _searchResults.length, + itemBuilder: (_, i) { + final r = _searchResults[i]; + final lang = r['language'] as String?; + final langColor = _languageColors[lang] ?? AppTheme.textTertiary; + return ListTile( + dense: true, + leading: Icon( + r['private'] == true + ? Icons.lock + : Icons.folder_outlined, + size: 18, + color: AppTheme.textSecondary), + title: Text(r['full_name'] as String? ?? 'Unknown', + style: const TextStyle( + fontSize: 13, + color: AppTheme.textPrimary, + fontWeight: FontWeight.w500)), + subtitle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (lang != null) ...[ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: langColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text(lang, + style: const TextStyle( + fontSize: 10, color: AppTheme.textTertiary)), + const SizedBox(width: 8), + ], + Text(r['description'] as String? ?? '', + style: const TextStyle( + fontSize: 11, color: AppTheme.textTertiary), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.star, + size: 13, color: AppTheme.warning), + const SizedBox(width: 3), + Text('${r['stargazers_count'] ?? 0}', + style: const TextStyle( + fontSize: 11, color: AppTheme.textTertiary)), + ]), + onTap: () { + Navigator.pop(ctx); + final owner = + (r['owner'] as Map?)?['login'] + as String? ?? + ''; + final name = r['name'] as String? ?? ''; + if (owner.isNotEmpty && name.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GitHubRepoScreen( + repoName: name, + owner: owner, + description: + r['description'] as String?, + language: r['language'] as String?))); + } + }, + ); + }, + ), + ), + ], + ]), + ), + ), + ); + } + + void _showCreateRepoSheet() { + final intentCtrl = TextEditingController(); + final nameCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + bool priv = false; + bool initReadme = false; + bool polishing = false; + String? polishNote; + String selectedLang = 'Dart'; + final polishService = RepoIntentPolishService(); showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: const EdgeInsets.all(20), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - _sheetHandle(), - const SizedBox(height: 16), - const Text('Switch Account', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary)), - const SizedBox(height: 16), - ..._svc.accountList.map((username) { - final isActive = username == _svc.currentUser; - return ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - gradient: AppTheme.accentGradient, - borderRadius: BorderRadius.circular(10), + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSt) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + top: 20, + left: 20, + right: 20, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sheetHandle(), + const SizedBox(height: 20), + const Text('Create Repository', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary)), + const SizedBox(height: 16), + TextField( + controller: intentCtrl, + style: const TextStyle(color: AppTheme.textPrimary), + maxLines: 3, + decoration: _inputDecoration('Repository intent').copyWith( + labelText: 'Intent', + hintText: 'Describe the repo in one sentence, then polish it into a clean GitHub draft.')), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: polishing + ? null + : () async { + final intent = intentCtrl.text.trim(); + if (intent.isEmpty) { + _toast('Describe the repository intent first.', isError: true); + return; + } + setSt(() { + polishing = true; + polishNote = null; + }); + final draft = await polishService.polish(intent); + if (!mounted || !ctx.mounted) return; + setSt(() { + nameCtrl.text = draft.name; + descCtrl.text = draft.description; + selectedLang = draft.language; + priv = draft.isPrivate; + initReadme = draft.addReadme; + polishing = false; + polishNote = draft.usedProvider + ? 'AI provider generated this draft.' + : 'AI unavailable: ${draft.fallbackReason ?? 'using local fallback.'}'; + }); + _toast(draft.usedProvider ? 'Repository draft polished by AI provider.' : 'AI unavailable, used local fallback draft.'); + }, + icon: polishing + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.auto_fix_high_outlined, size: 16), + label: Text(polishing ? '润色中...' : 'AI 润色填表'), + ), ), - child: Center( - child: Text(username[0].toUpperCase(), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.white))), - ), - title: Text(username, - style: TextStyle( - color: isActive ? AppTheme.primary : AppTheme.textPrimary, - fontWeight: - isActive ? FontWeight.w700 : FontWeight.w500)), - trailing: isActive - ? const Icon(Icons.check_circle, color: AppTheme.success) - : null, - onTap: () async { - Navigator.pop(ctx); - if (!isActive) { - setState(() => _loading = true); - await _svc.switchAccount(username); - await _loadRepos(); - setState(() { - _issues.clear(); - _prs.clear(); - _notifications.clear(); - _loading = false; - }); - _toast('Switched to \$username'); - } - }, - ); - }), - const Divider(color: AppTheme.divider), - ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppTheme.surfaceHover, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.border), - ), - child: const Icon(Icons.add, color: AppTheme.textSecondary), - ), - title: const Text('Add Account', - style: TextStyle(color: AppTheme.textSecondary)), - onTap: () { - Navigator.pop(ctx); - _showAddAccountSheet(); - }, + if (polishNote != null) ...[ + const SizedBox(height: 6), + Text(polishNote!, style: const TextStyle(color: AppTheme.textTertiary, fontSize: 12)), + ], + const SizedBox(height: 12), + TextField( + controller: nameCtrl, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: _inputDecoration('Repository name') + .copyWith(labelText: 'Repository name')), + const SizedBox(height: 12), + TextField( + controller: descCtrl, + style: const TextStyle(color: AppTheme.textPrimary), + maxLines: 2, + decoration: _inputDecoration('Description (optional)') + .copyWith(labelText: 'Description')), + const SizedBox(height: 12), + // Language selection + Text('Primary Language', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: ['Dart', 'Python', 'JavaScript', 'TypeScript', 'Go'] + .map((lang) { + final color = _languageColors[lang] ?? AppTheme.textTertiary; + final isSelected = selectedLang == lang; + return InkWell( + onTap: () => setSt(() => selectedLang = lang), + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: isSelected + ? color.withOpacity(0.2) + : AppTheme.surfaceHover, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? color.withOpacity(0.5) + : AppTheme.border, + width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 5), + Text(lang, + style: TextStyle( + fontSize: 12, + color: isSelected ? color : AppTheme.textSecondary)), + ], + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + SwitchListTile( + value: priv, + onChanged: (v) => setSt(() => priv = v), + title: const Text('Private repository', + style: TextStyle( + fontSize: 14, color: AppTheme.textPrimary)), + subtitle: const Text( + 'Only visible to you and collaborators', + style: TextStyle( + fontSize: 12, color: AppTheme.textTertiary)), + activeColor: AppTheme.primary, + contentPadding: EdgeInsets.zero), + SwitchListTile( + value: initReadme, + onChanged: (v) => setSt(() => initReadme = v), + title: const Text('Add README', + style: TextStyle( + fontSize: 14, color: AppTheme.textPrimary)), + subtitle: const Text( + 'Initialize with a README.md file', + style: TextStyle( + fontSize: 12, color: AppTheme.textTertiary)), + activeColor: AppTheme.primary, + contentPadding: EdgeInsets.zero), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + final n = nameCtrl.text.trim(); + if (n.isEmpty) return; + Navigator.pop(ctx); + try { + await _svc.createRepo(n, + description: descCtrl.text.trim(), + isPrivate: priv, + autoInit: initReadme); + _toast('Repository "$n" created'); + await _loadRepos(); + } catch (e) { + _toast('Failed to create repo: $e', isError: true); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10))), + child: const Text('Create Repository', + style: TextStyle(fontWeight: FontWeight.w600)), + )), + ]), ), - ]), - ), - ); - } - - void _showAddAccountSheet() { - final addTokenCtrl = TextEditingController(); - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (ctx) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), - padding: EdgeInsets.only( - bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, - top: 20, - left: 20, - right: 20, - ), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - _sheetHandle(), - const SizedBox(height: 16), - const Text('Add Another Account', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary)), - const SizedBox(height: 16), - TextField( - controller: addTokenCtrl, - obscureText: true, - style: const TextStyle( - color: AppTheme.textPrimary, fontFamily: AppTheme.fontCode), - decoration: _inputDecoration('ghp_xxxxxxxxxxxxxxxxxxxx', - icon: Icons.vpn_key), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () async { - final token = addTokenCtrl.text.trim(); - if (token.isEmpty) return; - Navigator.pop(ctx); - setState(() => _loading = true); - try { - final ok = await _svc.authenticate(token); - if (ok) { - await _loadRepos(); - setState(() => _loading = false); - _toast('Account added'); - } else { - setState(() => _loading = false); - _toast('Invalid token', isError: true); - } - } catch (e) { - setState(() => _loading = false); - _toast('Failed: \$e', isError: true); - } - }, - icon: const Icon(Icons.login, size: 18), - label: const Text('Connect'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ), - ]), ), ); } - - - // ═══════════════════════════════════════════════════════════════════════════ - // REPOSITORIES TAB (Enhanced) - // ═══════════════════════════════════════════════════════════════════════════ - - // Language color map for popular programming languages - static final Map _languageColors = { - 'Dart': const Color(0xFF00B4AB), - 'Python': const Color(0xFF3572A5), - 'JavaScript': const Color(0xFFF1E05A), - 'TypeScript': const Color(0xFF3178C6), - 'Go': const Color(0xFF00ADD8), - 'Rust': const Color(0xFFDEA584), - 'Java': const Color(0xFFB07219), - 'C++': const Color(0xFFF34B7D), - 'C': const Color(0xFF555555), - 'C#': const Color(0xFF178600), - 'Swift': const Color(0xFFFFAC45), - 'Kotlin': const Color(0xFFA97BFF), - 'Ruby': const Color(0xFF701516), - 'PHP': const Color(0xFF4F5D95), - 'HTML': const Color(0xFFE34C26), - 'CSS': const Color(0xFF563D7C), - 'Shell': const Color(0xFF89E051), - 'Lua': const Color(0xFF000080), - 'Scala': const Color(0xFFC22D40), - 'Elixir': const Color(0xFF6E4A7E), - 'Vue': const Color(0xFF41B883), - 'Flutter': const Color(0xFF54C5F8), - }; - - static final List _popularLanguages = [ - 'Dart', 'Python', 'JavaScript', 'TypeScript', 'Go', 'Rust', 'Java', 'C++', - 'Swift', 'Kotlin', 'Ruby', 'Vue' - ]; - - Widget _buildReposTab() { - // Apply language filter to displayed repos - var display = _repos; - if (_repoLanguageFilter != null) { - display = display.where((r) { - // Check if repo's language matches filter - return r.language?.toLowerCase() == - _repoLanguageFilter!.toLowerCase() || - r.name.toLowerCase().contains(_repoLanguageFilter!.toLowerCase()); - }).toList(); - } - - // Sort repos - switch (_repoSort) { - case 'name_asc': - display.sort((a, b) => a.name.compareTo(b.name)); - case 'name_desc': - display.sort((a, b) => b.name.compareTo(a.name)); - case 'stars': - display.sort((a, b) => b.stars.compareTo(a.stars)); - case 'updated': - // Already sorted by pushed from API - break; - } - - // Search overlay - final isSearching = _searchCtrl.text.isNotEmpty; - final searchDisplay = isSearching - ? _searchResults - .map((r) => GitHubRepo.fromGitHubApi(r as Map)) - .toList() - : []; - - if (display.isEmpty && !_searching && !isSearching && _repos.isEmpty) { - return _buildFirstTimeEmpty(); - } - - return Column(children: [ - // Ownership filter chips - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: [ - _filterChip('all', 'All', _repoFilter, (v) { - setState(() => _repoFilter = v); - _loadRepos(); - }), - const SizedBox(width: 8), - _filterChip('owner', 'Owned', _repoFilter, (v) { - setState(() => _repoFilter = v); - _loadRepos(); - }), - const SizedBox(width: 8), - _filterChip('member', 'Member', _repoFilter, (v) { - setState(() => _repoFilter = v); - _loadRepos(); - }), - const SizedBox(width: 8), - _filterChip('collaborator', 'Collab', _repoFilter, (v) { - setState(() => _repoFilter = v); - _loadRepos(); - }), - ]), - ), - ), - // Sort + Language filter - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Row(children: [ - // Sort dropdown - Expanded( - child: InkWell( - onTap: _showSortMenu, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: AppTheme.surface.withOpacity(0.5), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppTheme.border), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.sort, size: 14, color: AppTheme.textTertiary), - const SizedBox(width: 6), - Text(_sortLabel(_repoSort), - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - const Icon(Icons.keyboard_arrow_down, - size: 16, color: AppTheme.textTertiary), - ]), - ), - ), - ), - const SizedBox(width: 8), - // Language filter dropdown - Expanded( - child: InkWell( - onTap: _showLanguageFilterMenu, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: _repoLanguageFilter != null - ? AppTheme.primary.withOpacity(0.15) - : AppTheme.surface.withOpacity(0.5), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _repoLanguageFilter != null - ? AppTheme.primary.withOpacity(0.5) - : AppTheme.border), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.code, - size: 14, - color: _repoLanguageFilter != null - ? AppTheme.primary - : AppTheme.textTertiary), - const SizedBox(width: 6), - Expanded( - child: Text( - _repoLanguageFilter ?? 'Language', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: _repoLanguageFilter != null - ? AppTheme.primary - : AppTheme.textSecondary)), - ), - if (_repoLanguageFilter != null) - InkWell( - onTap: () { - setState(() => _repoLanguageFilter = null); - }, - child: const Icon(Icons.close, - size: 14, color: AppTheme.primary), - ) - else - const Icon(Icons.keyboard_arrow_down, - size: 16, color: AppTheme.textTertiary), - ]), - ), - ), - ), - ]), - ), - // Language quick chips - if (!isSearching) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: [ - ..._popularLanguages.take(8).map((lang) { - final isSelected = _repoLanguageFilter == lang; - final color = _languageColors[lang] ?? AppTheme.textTertiary; - return Padding( - padding: const EdgeInsets.only(right: 6), - child: InkWell( - onTap: () { - setState(() => _repoLanguageFilter = - isSelected ? null : lang); - }, - borderRadius: BorderRadius.circular(14), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: isSelected - ? color.withOpacity(0.2) - : AppTheme.surface.withOpacity(0.4), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: isSelected - ? color.withOpacity(0.5) - : AppTheme.border, - width: 0.8), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 5), - Text(lang, - style: TextStyle( - fontSize: 11, - color: isSelected ? color : AppTheme.textSecondary, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400)), - ]), - ), - ), - ); - }), - ]), - ), - ), - // Search indicator - if (isSearching) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Row(children: [ - const Icon(Icons.search, size: 14, color: AppTheme.primary), - const SizedBox(width: 6), - Text('Search: "${_searchCtrl.text}"', - style: const TextStyle( - fontSize: 12, - color: AppTheme.primary, - fontWeight: FontWeight.w500)), - const Spacer(), - InkWell( - onTap: () { - setState(() { - _searchCtrl.clear(); - _searchResults.clear(); - }); - }, - child: const Icon(Icons.close, size: 16, color: AppTheme.textTertiary), - ), - ]), - ), - // Repo list - Expanded( - child: RefreshIndicator( - onRefresh: _loadRepos, - color: AppTheme.primary, - backgroundColor: AppTheme.surface, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - itemCount: isSearching ? searchDisplay.length : display.length, - itemBuilder: (_, i) => isSearching - ? _repoCard(searchDisplay[i]) - : _repoCard(display[i]), - ), - ), - ), - ]); - } - - Widget _buildFirstTimeEmpty() => Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - gradient: AppTheme.accentGradient, - borderRadius: BorderRadius.circular(28), - boxShadow: [ - BoxShadow( - color: AppTheme.accent.withOpacity(0.2), - blurRadius: 24, - spreadRadius: 4) - ], - ), - child: - const Icon(Icons.folder_open, size: 48, color: Colors.white), - ), - const SizedBox(height: 24), - Text('Welcome to GitHub!', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: AppTheme.textPrimary, - fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text( - 'You don\'t have any repositories yet. Create your first one to get started.', - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: AppTheme.textSecondary)), - const SizedBox(height: 8), - Text('Or tap the search icon to find repositories.', - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith(color: AppTheme.textTertiary)), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: _showCreateRepoSheet, - icon: const Icon(Icons.create_new_folder), - label: const Text('Create Repository'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 24, vertical: 14), - ), - ), - ]), - ), - ); - - Widget _repoCard(GitHubRepo repo) { - final langColor = _languageColors[repo.language] ?? AppTheme.textTertiary; - final pushedAgo = _ago(repo.lastSynced); - return GlassCardWidget( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(14), - borderRadius: 12, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => GitHubRepoScreen( - repoName: repo.name, - owner: repo.owner, - description: repo.description, - language: repo.language))), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(repo.isPrivate ? Icons.lock_outline : Icons.folder_outlined, - size: 16, - color: repo.isPrivate ? AppTheme.warning : AppTheme.accent), - const SizedBox(width: 8), - Expanded( - child: Text(repo.fullName, - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary), - overflow: TextOverflow.ellipsis)), - Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.star, size: 13, color: AppTheme.warning), - const SizedBox(width: 3), - Text('${repo.stars}', - style: const TextStyle( - fontSize: 12, color: AppTheme.textTertiary)), - ]), - ]), - if (repo.description.isNotEmpty) ...[ - const SizedBox(height: 6), - Text(repo.description, - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 12, - color: AppTheme.textSecondary), - maxLines: 2, - overflow: TextOverflow.ellipsis) - ], - const SizedBox(height: 10), - Row(children: [ - // Language dot - if (repo.language != null) ...[ - Container( - width: 8, - height: 8, - decoration: - BoxDecoration(color: langColor, shape: BoxShape.circle), - ), - const SizedBox(width: 4), - Text(repo.language!, - style: TextStyle( - fontSize: 11, - fontFamily: AppTheme.fontCode, - color: langColor)), - const SizedBox(width: 12), - ], - // Default branch - Text(repo.defaultBranch, - style: const TextStyle( - fontSize: 11, - fontFamily: AppTheme.fontCode, - color: AppTheme.textTertiary)), - const Spacer(), - // Fork count - if (repo.forks != null && repo.forks! > 0) ...[ - const Icon(Icons.call_split, - size: 12, color: AppTheme.textTertiary), - const SizedBox(width: 3), - Text('${repo.forks}', - style: const TextStyle( - fontSize: 11, color: AppTheme.textTertiary)), - const SizedBox(width: 10), - ], - // Last pushed - const Icon(Icons.schedule, size: 11, color: AppTheme.textTertiary), - const SizedBox(width: 3), - Text(pushedAgo, - style: const TextStyle( - fontSize: 10, color: AppTheme.textTertiary)), - ]), - const SizedBox(height: 8), - // Action buttons - Row(children: [ - _miniAction(Icons.content_copy, 'Clone', () => _cloneSheet(repo)), - const SizedBox(width: 12), - _miniAction(Icons.call_split, 'Fork', () => _forkRepo(repo)), - const SizedBox(width: 12), - _miniAction(Icons.star_border, 'Star', () => _starRepo(repo)), - const Spacer(), - _miniAction(Icons.open_in_browser, 'Open', - () => launchUrl(Uri.parse(repo.webUrl))), - ]), - ]), - ); - } - - String _sortLabel(String sort) => switch (sort) { - 'name_asc' => 'Name \u2191', - 'name_desc' => 'Name \u2193', - 'pushed' => 'Recent Push', - 'updated' => 'Updated', - 'stars' => 'Most Stars', - _ => 'Recent Push', - }; - - void _showSortMenu() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: const EdgeInsets.all(16), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - _sheetHandle(), - const SizedBox(height: 12), - const Text('Sort Repositories', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(height: 12), - ...[ - ('pushed', 'Recently Pushed'), - ('updated', 'Recently Updated'), - ('stars', 'Most Stars'), - ('name_asc', 'Name (A-Z)'), - ('name_desc', 'Name (Z-A)'), - ].map((item) { - final (value, label) = item; - final selected = _repoSort == value; - return ListTile( - dense: true, - title: Text(label, - style: TextStyle( - color: selected ? AppTheme.primary : AppTheme.textPrimary, - fontWeight: - selected ? FontWeight.w600 : FontWeight.w400)), - trailing: selected - ? const Icon(Icons.check, color: AppTheme.primary, size: 20) - : null, - onTap: () { - setState(() => _repoSort = value); - Navigator.pop(ctx); - }, - ); - }), - ]), - ), - ); - } - - void _showLanguageFilterMenu() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: const EdgeInsets.all(16), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - _sheetHandle(), - const SizedBox(height: 12), - const Text('Filter by Language', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(height: 12), - ListTile( - dense: true, - title: const Text('All Languages', - style: TextStyle(color: AppTheme.textPrimary)), - trailing: _repoLanguageFilter == null - ? const Icon(Icons.check, color: AppTheme.primary, size: 20) - : null, - onTap: () { - setState(() => _repoLanguageFilter = null); - Navigator.pop(ctx); - }, - ), - const Divider(color: AppTheme.divider, height: 8), - ..._popularLanguages.map((lang) { - final selected = _repoLanguageFilter == lang; - final color = _languageColors[lang] ?? AppTheme.textTertiary; - return ListTile( - dense: true, - leading: Container( - width: 10, - height: 10, - decoration: - BoxDecoration(color: color, shape: BoxShape.circle), - ), - title: Text(lang, - style: TextStyle( - color: - selected ? AppTheme.primary : AppTheme.textPrimary, - fontWeight: - selected ? FontWeight.w600 : FontWeight.w400)), - trailing: selected - ? const Icon(Icons.check, color: AppTheme.primary, size: 20) - : null, - onTap: () { - setState(() => _repoLanguageFilter = lang); - Navigator.pop(ctx); - }, - ); - }), - ]), - ), - ); - } - - - // ═══════════════════════════════════════════════════════════════════════════ - // ISSUES TAB (Enhanced) - // ═══════════════════════════════════════════════════════════════════════════ - - Widget _buildIssuesTab() { - if (_issues.isEmpty && !_loading) { - return _empty(Icons.check_circle_outline, 'No issues found', - 'All clear! Create an issue to get started.'); - } - return Column(children: [ - // State filter chips - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: [ - _filterChip('open', 'Open', _issueFilter, (v) { - setState(() => _issueFilter = v); - _loadIssues(); - }), - const SizedBox(width: 8), - _filterChip('closed', 'Closed', _issueFilter, (v) { - setState(() => _issueFilter = v); - _loadIssues(); - }), - const SizedBox(width: 8), - _filterChip('all', 'All', _issueFilter, (v) { - setState(() => _issueFilter = v); - _loadIssues(); - }), - const SizedBox(width: 8), - // Label filter chip - if (_repoLabels.isNotEmpty) - InkWell( - onTap: _showIssueLabelFilter, - borderRadius: BorderRadius.circular(20), - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: _issueLabelFilter != null - ? AppTheme.primary.withOpacity(0.2) - : AppTheme.surface.withOpacity(0.5), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: _issueLabelFilter != null - ? AppTheme.primary.withOpacity(0.5) - : AppTheme.border, - width: 1), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.label_outline, - size: 12, - color: _issueLabelFilter != null - ? AppTheme.primary - : AppTheme.textSecondary), - const SizedBox(width: 4), - Text( - _issueLabelFilter ?? - 'Labels (${_repoLabels.length})', - style: TextStyle( - fontSize: 11, - fontWeight: _issueLabelFilter != null - ? FontWeight.w600 - : FontWeight.w500, - color: _issueLabelFilter != null - ? AppTheme.primary - : AppTheme.textSecondary)), - if (_issueLabelFilter != null) ...[ - const SizedBox(width: 4), - InkWell( - onTap: () { - setState(() => _issueLabelFilter = null); - _loadIssues(); - }, - child: const Icon(Icons.close, - size: 12, color: AppTheme.primary), - ), - ], - ]), - ), - ), - ]), - ), - ), - // Assignee + Milestone filters - if (_issueLabelFilter != null || _issueAssigneeFilter != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Row(children: [ - if (_issueLabelFilter != null) - _activeFilterChip( - 'Label: $_issueLabelFilter', - () => setState(() { - _issueLabelFilter = null; - _loadIssues(); - })), - ]), - ), - Expanded( - child: RefreshIndicator( - onRefresh: _loadIssues, - color: AppTheme.primary, - backgroundColor: AppTheme.surface, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - itemCount: _issues.length, - itemBuilder: (_, i) => _issueCard(_issues[i]), - ), - ), - ), - ]); - } - - Widget _activeFilterChip(String label, VoidCallback onRemove) => Container( - margin: const EdgeInsets.only(right: 6), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppTheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppTheme.primary.withOpacity(0.3)), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text(label, - style: const TextStyle( - fontSize: 10, - color: AppTheme.primary, - fontWeight: FontWeight.w500)), - const SizedBox(width: 4), - InkWell( - onTap: onRemove, - child: const Icon(Icons.close, size: 12, color: AppTheme.primary), - ), - ]), - ); - - void _showIssueLabelFilter() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: const EdgeInsets.all(16), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - _sheetHandle(), - const SizedBox(height: 12), - const Text('Filter by Label', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(height: 12), - ListTile( - dense: true, - title: const Text('All Labels', - style: TextStyle(color: AppTheme.textPrimary)), - trailing: _issueLabelFilter == null - ? const Icon(Icons.check, color: AppTheme.primary, size: 20) - : null, - onTap: () { - setState(() => _issueLabelFilter = null); - _loadIssues(); - Navigator.pop(ctx); - }, - ), - const Divider(color: AppTheme.divider, height: 8), - ..._repoLabels.map((label) { - final name = label['name'] as String? ?? ''; - final color = _hexColor(label['color'] as String? ?? '666666'); - final selected = _issueLabelFilter == name; - return ListTile( - dense: true, - leading: Container( - width: 10, - height: 10, - decoration: - BoxDecoration(color: color, shape: BoxShape.circle), - ), - title: Text(name, - style: TextStyle( - color: - selected ? AppTheme.primary : AppTheme.textPrimary, - fontWeight: - selected ? FontWeight.w600 : FontWeight.w400)), - trailing: selected - ? const Icon(Icons.check, color: AppTheme.primary, size: 20) - : null, - onTap: () { - setState(() => _issueLabelFilter = name); - _loadIssues(); - Navigator.pop(ctx); - }, - ); - }), - ]), - ), - ); - } - - Widget _issueCard(dynamic issue) { - final open = issue['state'] == 'open'; - final labels = (issue['labels'] as List?) ?? []; - final num = issue['number'] as int? ?? 0; - final title = issue['title'] as String? ?? 'Untitled'; - final author = - ((issue['user'] as Map?)?.cast())?[ - 'login'] - as String? ?? - 'unknown'; - final created = issue['created_at'] != null - ? DateTime.parse(issue['created_at'] as String) - : DateTime.now(); - final comments = issue['comments'] as int? ?? 0; - final assignees = (issue['assignees'] as List?) ?? []; - - return GlassCardWidget( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(14), - borderRadius: 12, - onTap: () => _navigateToIssueDetail(issue), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(open ? Icons.error_outline : Icons.check_circle, - size: 18, color: open ? AppTheme.success : AppTheme.textTertiary), - const SizedBox(width: 8), - Text('#$num', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textTertiary, - fontFamily: AppTheme.fontCode)), - const Spacer(), - Text(_ago(created), - style: const TextStyle( - fontSize: 11, color: AppTheme.textTertiary)), - ]), - const SizedBox(height: 8), - Text(title, - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - if (labels.isNotEmpty) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 4, - children: labels.map((l) { - final name = l['name'] as String? ?? ''; - final c = _hexColor(l['color'] as String? ?? '666666'); - return Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: c.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: c.withOpacity(0.3), width: 0.5)), - child: Text(name, - style: TextStyle( - fontSize: 10, - color: c, - fontWeight: FontWeight.w500))); - }).toList()) - ], - const SizedBox(height: 10), - Row(children: [ - _avatar(author, gradient: AppTheme.accentGradient), - const SizedBox(width: 6), - Text(author, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - const Spacer(), - // Assignees - if (assignees.isNotEmpty) ...[ - Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.person_outline, - size: 12, color: AppTheme.textTertiary), - const SizedBox(width: 2), - Text('${assignees.length}', - style: const TextStyle( - fontSize: 11, color: AppTheme.textTertiary)), - ]), - const SizedBox(width: 10), - ], - // Comments count - if (comments > 0) - Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.comment_outlined, - size: 13, color: AppTheme.textTertiary), - const SizedBox(width: 3), - Text('$comments', - style: const TextStyle( - fontSize: 11, color: AppTheme.textTertiary)), - ]), - ]), - ]), - ); - } - - void _navigateToIssueDetail(dynamic issue) { - if (_repos.isEmpty) return; - final num = issue['number'] as int? ?? 0; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => GitHubIssueDetailScreen( - owner: _repos.first.owner, - repo: _repos.first.name, - issueNumber: num, - ), - ), - ); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // PULL REQUESTS TAB (Enhanced) - // ═══════════════════════════════════════════════════════════════════════════ - - Widget _buildPRsTab() { - if (_prs.isEmpty && !_loading) { - return _empty(Icons.call_merge, 'No pull requests', - 'Create a pull request to propose changes.'); - } - return Column(children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: [ - _filterChip('open', 'Open', _prFilter, (v) { - setState(() => _prFilter = v); - _loadPRs(); - }), - const SizedBox(width: 8), - _filterChip('closed', 'Closed', _prFilter, (v) { - setState(() => _prFilter = v); - _loadPRs(); - }), - const SizedBox(width: 8), - _filterChip('all', 'All', _prFilter, (v) { - setState(() => _prFilter = v); - _loadPRs(); - }), - ]), - ), - ), - Expanded( - child: RefreshIndicator( - onRefresh: _loadPRs, - color: AppTheme.primary, - backgroundColor: AppTheme.surface, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - itemCount: _prs.length, - itemBuilder: (_, i) => _prCard(_prs[i]), - ), - ), - ), - ]); - } - - Widget _prCard(dynamic pr) { - final state = pr['state'] as String? ?? 'open'; - final merged = pr['merged'] == true; - final draft = pr['draft'] == true; - final num = pr['number'] as int? ?? 0; - final title = pr['title'] as String? ?? 'Untitled'; - final author = - ((pr['user'] as Map?)?.cast())?[ - 'login'] - as String? ?? - 'unknown'; - final head = - ((pr['head'] as Map?)?.cast())?[ - 'ref'] - as String? ?? - 'unknown'; - final base = - ((pr['base'] as Map?)?.cast())?[ - 'ref'] - as String? ?? - 'unknown'; - - // CI status - final ciStatus = _getCIStatus(pr); - - // Review state - final reviewState = pr['review_state'] as String?; - - late final Color sColor; - late final IconData sIcon; - if (merged) { - sColor = AppTheme.primary; - sIcon = Icons.merge_type; - } else if (draft) { - sColor = AppTheme.textTertiary; - sIcon = Icons.drafts; - } else if (state == 'open') { - sColor = AppTheme.success; - sIcon = Icons.call_merge; - } else { - sColor = AppTheme.error; - sIcon = Icons.cancel; - } - - return GlassCardWidget( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(14), - borderRadius: 12, - onTap: () => _navigateToPRReview(pr), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(sIcon, size: 18, color: sColor), - const SizedBox(width: 8), - Text('#$num', - style: const TextStyle( - fontSize: 12, - color: AppTheme.textTertiary, - fontFamily: AppTheme.fontCode)), - const SizedBox(width: 8), - // CI status indicator - if (ciStatus != null) ...[ - _ciStatusIcon(ciStatus), - const SizedBox(width: 6), - ], - const Spacer(), - // Review status badge - if (reviewState != null) - _reviewStatusBadge(reviewState) - else - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: sColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - border: - Border.all(color: sColor.withOpacity(0.3), width: 0.5)), - child: Text(merged ? 'MERGED' : draft ? 'DRAFT' : state.toUpperCase(), - style: TextStyle( - fontSize: 9, - color: sColor, - fontWeight: FontWeight.w700, - letterSpacing: 0.5)), - ), - ]), - const SizedBox(height: 8), - Text(title, - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(height: 8), - // Branch info - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: AppTheme.surfaceHover, - borderRadius: BorderRadius.circular(8)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text(head, - style: const TextStyle( - fontSize: 11, - fontFamily: AppTheme.fontCode, - color: AppTheme.accent)), - const SizedBox(width: 8), - const Icon(Icons.arrow_forward, - size: 12, color: AppTheme.textTertiary), - const SizedBox(width: 8), - Text(base, - style: const TextStyle( - fontSize: 11, - fontFamily: AppTheme.fontCode, - color: AppTheme.textSecondary)), - ]), - ), - const SizedBox(height: 10), - Row(children: [ - _avatar(author, gradient: AppTheme.primaryGradient), - const SizedBox(width: 6), - Text(author, - style: const TextStyle( - fontSize: 12, color: AppTheme.textSecondary)), - const Spacer(), - // PR comments - if ((pr['comments'] as int? ?? 0) > 0) ...[ - const Icon(Icons.comment_outlined, - size: 12, color: AppTheme.textTertiary), - const SizedBox(width: 3), - Text('${pr['comments']}', - style: const TextStyle( - fontSize: 11, color: AppTheme.textTertiary)), - const SizedBox(width: 10), - ], - // Changed files count - if ((pr['changed_files'] as int? ?? 0) > 0) - Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.insert_drive_file_outlined, - size: 12, color: AppTheme.textTertiary), - const SizedBox(width: 3), - Text('${pr['changed_files']}', - style: const TextStyle( - fontSize: 11, color: AppTheme.textTertiary)), - ]), - ]), - ]), - ); - } - - String? _getCIStatus(dynamic pr) { - // Check for CI status from various API fields - if (pr['statuses_url'] != null) { - // Could fetch real CI status from API - // For now, return simulated status based on mergeable state - final mergeableState = pr['mergeable_state'] as String?; - if (mergeableState == 'clean') return 'success'; - if (mergeableState == 'dirty') return 'failure'; - if (mergeableState == 'unstable') return 'pending'; - } - final state = pr['state'] as String?; - if (state == 'closed' && pr['merged'] != true) return null; - return null; - } - - Widget _ciStatusIcon(String status) { - late final IconData icon; - late final Color color; - switch (status) { - case 'success': - icon = Icons.check_circle; - color = AppTheme.success; - case 'failure': - icon = Icons.cancel; - color = AppTheme.error; - case 'pending': - icon = Icons.pending; - color = AppTheme.warning; - default: - icon = Icons.circle; - color = AppTheme.textTertiary; - } - return Icon(icon, size: 16, color: color); - } - - Widget _reviewStatusBadge(String state) { - late final Color color; - late final String label; - late final IconData icon; - switch (state) { - case 'APPROVED': - color = AppTheme.success; - label = 'APPROVED'; - icon = Icons.check_circle; - case 'CHANGES_REQUESTED': - color = AppTheme.error; - label = 'CHANGES'; - icon = Icons.cancel; - case 'COMMENTED': - color = AppTheme.info; - label = 'REVIEWED'; - icon = Icons.comment; - default: - color = AppTheme.textTertiary; - label = 'PENDING'; - icon = Icons.pending; - } - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withOpacity(0.3), width: 0.5)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 10, color: color), - const SizedBox(width: 3), - Text(label, - style: TextStyle( - fontSize: 8, - color: color, - fontWeight: FontWeight.w700, - letterSpacing: 0.5)), - ]), - ); - } - - void _navigateToPRReview(dynamic pr) { - if (_repos.isEmpty) return; - final num = pr['number'] as int? ?? 0; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => GitHubPrReviewScreen( - owner: _repos.first.owner, - repo: _repos.first.name, - pullNumber: num, - ), - ), - ); - } - - - // ═══════════════════════════════════════════════════════════════════════════ - // NOTIFICATIONS TAB (Enhanced) - // ═══════════════════════════════════════════════════════════════════════════ - - Widget _buildNotificationsTab() { - if (_notifications.isEmpty) { - return _empty(Icons.notifications_off_outlined, 'No new notifications', - 'You\'re all caught up!'); - } - - // Group notifications by repository - final grouped = >{}; - for (final n in _notifications) { - final repoName = - ((n['repository'] as Map?)?.cast())?[ - 'full_name'] - as String? ?? - 'unknown/repo'; - grouped.putIfAbsent(repoName, () => []).add(n); - } - - return Column(children: [ - // Mark all read + count - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row(children: [ - TextButton.icon( - onPressed: _markAllNotificationsRead, - icon: const Icon(Icons.done_all, size: 16), - label: const Text('Mark all read', style: TextStyle(fontSize: 12)), - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.error.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Text('$_unreadCount unread', - style: const TextStyle( - fontSize: 12, - color: AppTheme.error, - fontWeight: FontWeight.w600)), - ), - ]), - ), - Expanded( - child: RefreshIndicator( - onRefresh: _loadNotifications, - color: AppTheme.primary, - backgroundColor: AppTheme.surface, - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - itemCount: grouped.length, - itemBuilder: (_, groupIndex) { - final repoName = grouped.keys.elementAt(groupIndex); - final items = grouped[repoName]!; - return _buildNotificationGroup(repoName, items); - }, - ), - ), - ), - ]); - } - - Widget _buildNotificationGroup(String repoName, List items) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Repo header - Padding( - padding: const EdgeInsets.fromLTRB(4, 12, 4, 6), - child: Row(children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppTheme.primary, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text(repoName, - style: const TextStyle( - fontSize: 13, - fontFamily: AppTheme.fontCode, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: AppTheme.primary.withOpacity(0.15), - borderRadius: BorderRadius.circular(6), - ), - child: Text('${items.length}', - style: const TextStyle( - fontSize: 10, - color: AppTheme.primary, - fontWeight: FontWeight.w600)), - ), - ]), - ), - // Notification items with swipe-to-dismiss - ...items.map((n) => _buildDismissibleNotification(n)), - const Divider(color: AppTheme.divider, height: 16), - ], - ); - } - - Widget _buildDismissibleNotification(dynamic n) { - final id = n['id'] as String? ?? ''; - return Dismissible( - key: Key('notif_$id'), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 16), - decoration: BoxDecoration( - color: AppTheme.success.withOpacity(0.15), - borderRadius: BorderRadius.circular(12), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.done_all, color: AppTheme.success, size: 20), - SizedBox(width: 6), - Text('Mark Read', - style: TextStyle( - color: AppTheme.success, - fontWeight: FontWeight.w600, - fontSize: 12)), - ], - ), - ), - onDismissed: (_) async { - try { - await _svc.markNotificationRead(id); - setState(() { - _notifications.remove(n); - _unreadCount = (_unreadCount - 1).clamp(0, 999); - }); - } catch (e) { - _toast('Failed: \$e', isError: true); - } - }, - child: _notifCard(n), - ); - } - - Widget _notifCard(dynamic n) { - final sub = (n['subject'] as Map?)?.cast(); - final title = sub?['title'] as String? ?? 'Notification'; - final type = sub?['type'] as String? ?? 'Unknown'; - final repoName = - ((n['repository'] as Map?)?.cast())?[ - 'full_name'] - as String? ?? - 'unknown/repo'; - final updated = n['updated_at'] != null - ? DateTime.parse(n['updated_at'] as String) - : DateTime.now(); - final reason = n['reason'] as String? ?? ''; - - late final IconData tIcon; - late final Color tColor; - switch (type) { - case 'PullRequest': - tIcon = Icons.call_merge; - tColor = AppTheme.primary; - case 'Issue': - tIcon = Icons.error_outline; - tColor = AppTheme.success; - case 'Release': - tIcon = Icons.new_releases_outlined; - tColor = AppTheme.accent; - case 'Commit': - tIcon = Icons.commit; - tColor = AppTheme.info; - case 'Discussion': - tIcon = Icons.forum_outlined; - tColor = AppTheme.warning; - default: - tIcon = Icons.notifications_none; - tColor = AppTheme.textTertiary; - } - - return GlassCardWidget( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.all(12), - borderRadius: 10, - child: Row(children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: tColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(10)), - child: Icon(tIcon, size: 18, color: tColor), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppTheme.textPrimary), - maxLines: 2, - overflow: TextOverflow.ellipsis), - const SizedBox(height: 4), - Row(children: [ - Text(type, - style: TextStyle( - fontSize: 10, - fontFamily: AppTheme.fontCode, - color: tColor, - fontWeight: FontWeight.w500)), - const SizedBox(width: 8), - if (reason.isNotEmpty) - Text(reason, - style: const TextStyle( - fontSize: 10, color: AppTheme.textTertiary)), - const Spacer(), - Text(_ago(updated), - style: const TextStyle( - fontSize: 10, color: AppTheme.textTertiary)), - ]), - ])), - IconButton( - onPressed: () async { - final id = n['id'] as String?; - if (id != null) { - await _svc.markNotificationRead(id); - setState(() { - _notifications.remove(n); - _unreadCount = (_unreadCount - 1).clamp(0, 999); - }); - } - }, - icon: const Icon(Icons.done, size: 18, color: AppTheme.success)), - ]), - ); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // BOTTOM SHEETS & DIALOGS - // ═══════════════════════════════════════════════════════════════════════════ - - void _showSearchSheet() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setSt) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - border: Border(top: BorderSide(color: AppTheme.border)), - ), - padding: EdgeInsets.only( - bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, - top: 16, - left: 16, - right: 16, - ), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.border, - borderRadius: BorderRadius.circular(2))), - const SizedBox(height: 16), - TextField( - controller: _searchCtrl, - autofocus: true, - style: const TextStyle(color: AppTheme.textPrimary), - onSubmitted: (_) async { - setSt(() {}); - await _search(); - setSt(() {}); - }, - decoration: _inputDecoration('Search repositories on GitHub...', - icon: Icons.search, - suffix: _searching - ? Container( - width: 20, - height: 20, - margin: const EdgeInsets.all(12), - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - AppTheme.primary))) - : IconButton( - icon: const Icon(Icons.arrow_forward, - color: AppTheme.primary), - onPressed: () async { - setSt(() {}); - await _search(); - setSt(() {}); - })), - ), - if (_searchResults.isNotEmpty) ...[ - const SizedBox(height: 12), - SizedBox( - height: 400, - child: ListView.builder( - itemCount: _searchResults.length, - itemBuilder: (_, i) { - final r = _searchResults[i]; - final lang = r['language'] as String?; - final langColor = _languageColors[lang] ?? AppTheme.textTertiary; - return ListTile( - dense: true, - leading: Icon( - r['private'] == true - ? Icons.lock - : Icons.folder_outlined, - size: 18, - color: AppTheme.textSecondary), - title: Text(r['full_name'] as String? ?? 'Unknown', - style: const TextStyle( - fontSize: 13, - color: AppTheme.textPrimary, - fontWeight: FontWeight.w500)), - subtitle: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (lang != null) ...[ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: langColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 4), - Text(lang, - style: const TextStyle( - fontSize: 10, color: AppTheme.textTertiary)), - const SizedBox(width: 8), - ], - Text(r['description'] as String? ?? '', - style: const TextStyle( - fontSize: 11, color: AppTheme.textTertiary), - maxLines: 1, - overflow: TextOverflow.ellipsis), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.star, - size: 13, color: AppTheme.warning), - const SizedBox(width: 3), - Text('${r['stargazers_count'] ?? 0}', - style: const TextStyle( - fontSize: 11, color: AppTheme.textTertiary)), - ]), - onTap: () { - Navigator.pop(ctx); - final owner = - (r['owner'] as Map?)?['login'] - as String? ?? - ''; - final name = r['name'] as String? ?? ''; - if (owner.isNotEmpty && name.isNotEmpty) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => GitHubRepoScreen( - repoName: name, - owner: owner, - description: - r['description'] as String?, - language: r['language'] as String?))); - } - }, - ); - }, - ), - ), - ], - ]), - ), - ), - ); - } - - void _showCreateRepoSheet() { - final nameCtrl = TextEditingController(); - final descCtrl = TextEditingController(); - bool priv = false; - bool initReadme = false; - String selectedLang = 'Dart'; - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setSt) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: EdgeInsets.only( - bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, - top: 20, - left: 20, - right: 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sheetHandle(), - const SizedBox(height: 20), - const Text('Create Repository', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary)), - const SizedBox(height: 16), - TextField( - controller: nameCtrl, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: _inputDecoration('Repository name') - .copyWith(labelText: 'Repository name')), - const SizedBox(height: 12), - TextField( - controller: descCtrl, - style: const TextStyle(color: AppTheme.textPrimary), - maxLines: 2, - decoration: _inputDecoration('Description (optional)') - .copyWith(labelText: 'Description')), - const SizedBox(height: 12), - // Language selection - Text('Primary Language', - style: TextStyle( - fontSize: 13, - color: AppTheme.textSecondary, - fontWeight: FontWeight.w500)), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: ['Dart', 'Python', 'JavaScript', 'TypeScript', 'Go'] - .map((lang) { - final color = _languageColors[lang] ?? AppTheme.textTertiary; - final isSelected = selectedLang == lang; - return InkWell( - onTap: () => setSt(() => selectedLang = lang), - borderRadius: BorderRadius.circular(20), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 5), - decoration: BoxDecoration( - color: isSelected - ? color.withOpacity(0.2) - : AppTheme.surfaceHover, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: isSelected - ? color.withOpacity(0.5) - : AppTheme.border, - width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 5), - Text(lang, - style: TextStyle( - fontSize: 12, - color: isSelected ? color : AppTheme.textSecondary)), - ], - ), - ), - ); - }).toList(), - ), - const SizedBox(height: 12), - SwitchListTile( - value: priv, - onChanged: (v) => setSt(() => priv = v), - title: const Text('Private repository', - style: TextStyle( - fontSize: 14, color: AppTheme.textPrimary)), - subtitle: const Text( - 'Only visible to you and collaborators', - style: TextStyle( - fontSize: 12, color: AppTheme.textTertiary)), - activeColor: AppTheme.primary, - contentPadding: EdgeInsets.zero), - SwitchListTile( - value: initReadme, - onChanged: (v) => setSt(() => initReadme = v), - title: const Text('Add README', - style: TextStyle( - fontSize: 14, color: AppTheme.textPrimary)), - subtitle: const Text( - 'Initialize with a README.md file', - style: TextStyle( - fontSize: 12, color: AppTheme.textTertiary)), - activeColor: AppTheme.primary, - contentPadding: EdgeInsets.zero), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () async { - final n = nameCtrl.text.trim(); - if (n.isEmpty) return; - Navigator.pop(ctx); - try { - await _svc.createRepo(n, - description: descCtrl.text.trim(), - isPrivate: priv, - autoInit: initReadme); - _toast('Repository "$n" created'); - await _loadRepos(); - } catch (e) { - _toast('Failed to create repo: \$e', isError: true); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10))), - child: const Text('Create Repository', - style: TextStyle(fontWeight: FontWeight.w600)), - )), - ]), - ), - ), - ); - } - - void _createIssueSheet() { - if (_repos.isEmpty) return; - final titleCtrl = TextEditingController(); - final bodyCtrl = TextEditingController(); - String repoName = _repos.first.fullName; - // Pre-populate label filter as issue label if set - List selectedLabels = []; - if (_issueLabelFilter != null) selectedLabels.add(_issueLabelFilter!); - - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setSt) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: EdgeInsets.only( - bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, - top: 20, - left: 20, - right: 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sheetHandle(), - const SizedBox(height: 20), - const Text('Create Issue', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary)), - const SizedBox(height: 16), - if (_repos.length > 1) - DropdownButtonFormField( - value: repoName, - dropdownColor: AppTheme.surfaceHover, - style: const TextStyle( - color: AppTheme.textPrimary, fontSize: 14), - decoration: _inputDecoration('Repository') - .copyWith(labelText: 'Repository'), - items: _repos - .map((r) => DropdownMenuItem( - value: r.fullName, child: Text(r.fullName))) - .toList(), - onChanged: (v) => setSt(() => repoName = v!)), - if (_repos.length > 1) const SizedBox(height: 12), - TextField( - controller: titleCtrl, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: _inputDecoration('Title') - .copyWith(labelText: 'Title')), - const SizedBox(height: 12), - TextField( - controller: bodyCtrl, - style: const TextStyle(color: AppTheme.textPrimary), - maxLines: 4, - decoration: _inputDecoration( - 'Description (supports Markdown)') - .copyWith(labelText: 'Description')), - const SizedBox(height: 12), - // Label selector - if (_repoLabels.isNotEmpty) ...[ - Text('Labels', - style: TextStyle( - fontSize: 13, - color: AppTheme.textSecondary, - fontWeight: FontWeight.w500)), - const SizedBox(height: 6), - Wrap( - spacing: 6, - runSpacing: 6, - children: _repoLabels.map((l) { - final name = l['name'] as String? ?? ''; - final color = - _hexColor(l['color'] as String? ?? '666666'); - final isSelected = selectedLabels.contains(name); - return InkWell( - onTap: () => setSt(() { - if (isSelected) { - selectedLabels.remove(name); - } else { - selectedLabels.add(name); - } - }), - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: isSelected - ? color.withOpacity(0.25) - : color.withOpacity(0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isSelected - ? color.withOpacity(0.6) - : color.withOpacity(0.2), - width: 1), - ), - child: Text(name, - style: TextStyle( - fontSize: 11, - color: isSelected ? color : color.withOpacity(0.6), - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400)), - ), - ); - }).toList(), - ), - const SizedBox(height: 12), - ], - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () async { - final t = titleCtrl.text.trim(); - if (t.isEmpty) return; - final p = repoName.split('/'); - Navigator.pop(ctx); - try { - await _svc.createIssue(p[0], p[1], t, - body: bodyCtrl.text.trim(), - labels: selectedLabels.isEmpty - ? null - : selectedLabels); - _toast('Issue created'); - if (_tabCtrl.index == 1) await _loadIssues(); - } catch (e) { - _toast('Failed: \$e', isError: true); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.success, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10))), - child: const Text('Create Issue', - style: TextStyle(fontWeight: FontWeight.w600)), - )), - ]), - ), - ), - ); - } - - void _createPRSheet() { - if (_repos.isEmpty) return; - final titleCtrl = TextEditingController(); - final bodyCtrl = TextEditingController(); - final headCtrl = TextEditingController(); - final baseCtrl = TextEditingController(text: 'main'); - String repoName = _repos.first.fullName; - bool draft = false; - - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setSt) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: EdgeInsets.only( - bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, - top: 20, - left: 20, - right: 20, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sheetHandle(), - const SizedBox(height: 20), - const Text('Create Pull Request', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary)), - const SizedBox(height: 16), - if (_repos.length > 1) - DropdownButtonFormField( - value: repoName, - dropdownColor: AppTheme.surfaceHover, - style: const TextStyle( - color: AppTheme.textPrimary, fontSize: 14), - decoration: _inputDecoration('Repository') - .copyWith(labelText: 'Repository'), - items: _repos - .map((r) => DropdownMenuItem( - value: r.fullName, child: Text(r.fullName))) - .toList(), - onChanged: (v) => setSt(() => repoName = v!)), - if (_repos.length > 1) const SizedBox(height: 12), - TextField( - controller: titleCtrl, - style: const TextStyle(color: AppTheme.textPrimary), - decoration: _inputDecoration('Title') - .copyWith(labelText: 'Title')), - const SizedBox(height: 12), - Row(children: [ - Expanded( - child: TextField( - controller: headCtrl, - style: const TextStyle( - color: AppTheme.textPrimary, fontSize: 13), - decoration: _inputDecoration('From branch') - .copyWith(labelText: 'From branch'))), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.arrow_forward, - color: AppTheme.textTertiary, size: 18)), - Expanded( - child: TextField( - controller: baseCtrl, - style: const TextStyle( - color: AppTheme.textPrimary, fontSize: 13), - decoration: _inputDecoration('To branch') - .copyWith(labelText: 'To branch'))), - ]), - const SizedBox(height: 12), - TextField( - controller: bodyCtrl, - style: const TextStyle(color: AppTheme.textPrimary), - maxLines: 3, - decoration: _inputDecoration('Description') - .copyWith(labelText: 'Description')), - const SizedBox(height: 8), - SwitchListTile( - value: draft, - onChanged: (v) => setSt(() => draft = v), - title: const Text('Draft PR', - style: TextStyle( - fontSize: 14, color: AppTheme.textPrimary)), - subtitle: const Text( - 'Cannot be merged until marked ready', - style: TextStyle( - fontSize: 12, color: AppTheme.textTertiary)), - activeColor: AppTheme.primary, - contentPadding: EdgeInsets.zero, - dense: true, - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () async { - final t = titleCtrl.text.trim(); - final h = headCtrl.text.trim(); - final b = baseCtrl.text.trim(); - if (t.isEmpty || h.isEmpty || b.isEmpty) return; - final p = repoName.split('/'); - Navigator.pop(ctx); - try { - await _svc.createPullRequest(p[0], p[1], t, h, b, - body: bodyCtrl.text.trim(), draft: draft); - _toast('Pull request created'); - if (_tabCtrl.index == 2) await _loadPRs(); - } catch (e) { - _toast('Failed: \$e', isError: true); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10))), - child: const Text('Create Pull Request', - style: TextStyle(fontWeight: FontWeight.w600)), - )), - ]), - ), - ), - ); - } - - void _cloneSheet(GitHubRepo repo) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: const EdgeInsets.all(20), - child: - Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _sheetHandle(), - const SizedBox(height: 20), - const Text('Clone Repository', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary)), - const SizedBox(height: 16), - if (repo.cloneUrl != null) ...[ - _cloneUrlTile('HTTPS', repo.cloneUrl!), - const SizedBox(height: 8) - ], - if (repo.sshUrl != null) _cloneUrlTile('SSH', repo.sshUrl!), - const SizedBox(height: 8), - if (repo.htmlUrl != null) _cloneUrlTile('GitHub', repo.htmlUrl!), - const SizedBox(height: 12), - Text('Clone locally to edit files, commit changes, and push.', - style: TextStyle( - fontSize: 12, color: AppTheme.textTertiary.withOpacity(0.7))), - ]), - ), - ); - } - - Widget _cloneUrlTile(String label, String url) => InkWell( - onTap: () { - Clipboard.setData(ClipboardData(text: url)); - _toast('URL copied'); - }, - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.surfaceInput, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.border)), - child: Row(children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.primary.withOpacity(0.2), - borderRadius: BorderRadius.circular(6)), - child: Text(label, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppTheme.primary))), - const SizedBox(width: 12), - Expanded( - child: Text(url, - style: const TextStyle( - fontFamily: AppTheme.fontCode, - fontSize: 12, - color: AppTheme.textSecondary), - overflow: TextOverflow.ellipsis)), - const Icon(Icons.copy, size: 16, color: AppTheme.textTertiary), - ])), - ); - - void _issueDetailSheet(dynamic issue) { - // Legacy bottom sheet for quick preview; use GitHubIssueDetailScreen for full view - final open = issue['state'] == 'open'; - final title = issue['title'] as String? ?? 'Untitled'; - final body = issue['body'] as String? ?? 'No description provided.'; - final num = issue['number'] as int? ?? 0; - final labels = (issue['labels'] as List?) ?? []; - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (ctx) => DraggableScrollableSheet( - initialChildSize: 0.85, - minChildSize: 0.5, - maxChildSize: 0.95, - builder: (ctx, sc) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20))), - child: Column(children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: - Border(bottom: BorderSide(color: AppTheme.divider))), - child: Row(children: [ - Expanded( - child: Text('#$num: $title', - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary))), - IconButton( - onPressed: () => _navigateToIssueDetail(issue), - icon: const Icon(Icons.open_in_full, - color: AppTheme.accent, size: 20)), - IconButton( - onPressed: () async { - if (_repos.isEmpty) return; - try { - final ns = open ? 'closed' : 'open'; - await _svc.updateIssue(_repos.first.owner, - _repos.first.name, num, - state: ns); - Navigator.pop(ctx); - _toast('Issue ${open ? 'closed' : 'reopened'}'); - await _loadIssues(); - } catch (e) { - _toast('Failed: \$e', isError: true); - } - }, - icon: Icon(open ? Icons.check_circle : Icons.replay, - color: - open ? AppTheme.success : AppTheme.accent)), - IconButton( - onPressed: () => Navigator.pop(ctx), - icon: const Icon(Icons.close, - color: AppTheme.textTertiary)), - ])), - Expanded( - child: SingleChildScrollView( - controller: sc, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: (open - ? AppTheme.success - : AppTheme.textTertiary) - .withOpacity(0.15), - borderRadius: - BorderRadius.circular(12)), - child: Text(open ? 'OPEN' : 'CLOSED', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: open - ? AppTheme.success - : AppTheme.textTertiary, - letterSpacing: 0.5))), - if (labels.isNotEmpty) ...[ - const SizedBox(height: 12), - Wrap( - spacing: 6, - runSpacing: 4, - children: labels.map((l) { - final name = l['name'] as String? ?? ''; - final c = _hexColor( - l['color'] as String? ?? - '666666'); - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: c.withOpacity(0.15), - borderRadius: - BorderRadius.circular(10), - border: Border.all( - color: c.withOpacity(0.3), - width: 0.5)), - child: Text(name, - style: TextStyle( - fontSize: 10, - color: c, - fontWeight: - FontWeight.w500))); - }).toList()), - ], - const SizedBox(height: 16), - Text(body, - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 14, - color: AppTheme.textSecondary, - height: 1.6)), - ]))), - ]), - )), - ); - } - - void _prDetailSheet(dynamic pr) { - final title = pr['title'] as String? ?? 'Untitled'; - final body = pr['body'] as String? ?? ''; - final num = pr['number'] as int? ?? 0; - final merged = pr['merged'] == true; - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (ctx) => DraggableScrollableSheet( - initialChildSize: 0.85, - minChildSize: 0.5, - maxChildSize: 0.95, - builder: (ctx, sc) => Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(20))), - child: Column(children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: - Border(bottom: BorderSide(color: AppTheme.divider))), - child: Row(children: [ - Expanded( - child: Text('#$num: $title', - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary))), - ElevatedButton.icon( - onPressed: () => _navigateToPRReview(pr), - icon: const Icon(Icons.rate_review, size: 16), - label: const Text('Review'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600))), - const SizedBox(width: 8), - if (!merged && pr['state'] == 'open') - ElevatedButton.icon( - onPressed: () => _mergeDialog(pr), - icon: const Icon(Icons.merge_type, size: 16), - label: const Text('Merge'), - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.accent, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), - textStyle: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600))), - const SizedBox(width: 8), - IconButton( - onPressed: () => Navigator.pop(ctx), - icon: const Icon(Icons.close, - color: AppTheme.textTertiary, size: 20)), - ])), - Expanded( - child: SingleChildScrollView( - controller: sc, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (body.isNotEmpty) - Text(body, - style: const TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 14, - color: AppTheme.textSecondary, - height: 1.6)), - if (body.isNotEmpty) - const SizedBox(height: 16), - const Text('Review Actions', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppTheme.textPrimary)), - const SizedBox(height: 12), - Row(children: [ - _reviewBtn(Icons.check_circle, 'Approve', - AppTheme.success, () => _submitReview(pr, 'APPROVE')), - const SizedBox(width: 8), - _reviewBtn( - Icons.cancel, - 'Request Changes', - AppTheme.error, - () => _submitReview(pr, 'REQUEST_CHANGES')), - const SizedBox(width: 8), - _reviewBtn(Icons.comment, 'Comment', - AppTheme.info, () => _submitReview(pr, 'COMMENT')), - ]), - ]))), - ]), - )), - ); - } - - void _mergeDialog(dynamic pr) { - final num = pr['number'] as int? ?? 0; - String method = 'merge'; - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: AppTheme.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - title: const Text('Merge Pull Request', - style: TextStyle( - color: AppTheme.textPrimary, fontFamily: AppTheme.fontBody)), - content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Choose merge method:', - style: TextStyle(color: AppTheme.textSecondary, fontSize: 14)), - const SizedBox(height: 12), - StatefulBuilder( - builder: (ctx, setSt) => Column(children: [ - _mergeRadio('merge', 'Create a merge commit', method, - (v) => setSt(() => method = v)), - _mergeRadio('squash', 'Squash and merge', method, - (v) => setSt(() => method = v)), - _mergeRadio('rebase', 'Rebase and merge', method, - (v) => setSt(() => method = v)), - ])), - ]), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel', - style: TextStyle(color: AppTheme.textTertiary))), - ElevatedButton( - onPressed: () async { - Navigator.pop(ctx); - if (_repos.isEmpty) return; - try { - await _svc.mergePullRequest( - _repos.first.owner, _repos.first.name, num, - method: method); - _toast('PR merged'); - await _loadPRs(); - } catch (e) { - _toast('Merge failed: \$e', isError: true); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.primary, - foregroundColor: Colors.white), - child: const Text('Merge'), - ), - ], - ), - ); - } - - // ═══════════════════════════════════════════════════════════════════════════ - // SHARED WIDGET BUILDERS - // ═══════════════════════════════════════════════════════════════════════════ - - Widget _empty(IconData icon, String title, String subtitle) => Center( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 56, color: AppTheme.textTertiary.withOpacity(0.4)), - const SizedBox(height: 16), - Text(title, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: AppTheme.textSecondary.withOpacity(0.7))), - const SizedBox(height: 8), - Text(subtitle, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, color: AppTheme.textTertiary.withOpacity(0.6))), - ]), - ); - - Widget _filterChip(String value, String label, String selected, - ValueChanged onSelect) => - InkWell( - onTap: () => onSelect(value), - borderRadius: BorderRadius.circular(20), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), - decoration: BoxDecoration( - color: selected == value - ? AppTheme.primary.withOpacity(0.2) - : AppTheme.surface.withOpacity(0.5), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: selected == value - ? AppTheme.primary.withOpacity(0.5) - : AppTheme.border, - width: 1), - ), - child: Text(label, - style: TextStyle( - fontSize: 12, - fontWeight: selected == value - ? FontWeight.w600 - : FontWeight.w500, - color: selected == value - ? AppTheme.primary - : AppTheme.textSecondary))), - ); - - Widget _miniAction(IconData icon, String label, VoidCallback onTap) => - InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 13, color: AppTheme.textTertiary), - const SizedBox(width: 3), - Text(label, - style: const TextStyle( - fontSize: 11, - color: AppTheme.textTertiary, - fontWeight: FontWeight.w500)), - ])), - ); - - Widget _avatar(String name, {required Gradient gradient}) => Container( - width: 20, - height: 20, - decoration: - BoxDecoration(gradient: gradient, borderRadius: BorderRadius.circular(6)), - child: Center( - child: Text(name[0].toUpperCase(), - style: const TextStyle( - fontSize: 9, fontWeight: FontWeight.bold, color: Colors.white)))); - - Widget _reviewBtn(IconData icon, String label, Color color, - VoidCallback onTap) => - Expanded( - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: color.withOpacity(0.3))), - child: Column(children: [ - Icon(icon, size: 22, color: color), - const SizedBox(height: 4), - Text(label, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: color)) - ]))), - ); - - Widget _mergeRadio( - String val, String label, String sel, ValueChanged onCh) => - RadioListTile( - value: val, - groupValue: sel, - onChanged: (v) => onCh(v!), - title: Text(label, - style: TextStyle( - fontSize: 13, - color: sel == val ? AppTheme.textPrimary : AppTheme.textSecondary)), - activeColor: AppTheme.primary, - dense: true, - contentPadding: EdgeInsets.zero); - - Widget _sheetHandle() => Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppTheme.border, borderRadius: BorderRadius.circular(2)))); - - InputDecoration _inputDecoration(String hint, {IconData? icon, Widget? suffix}) => - InputDecoration( - filled: true, - fillColor: AppTheme.surfaceInput, - hintText: hint, - hintStyle: const TextStyle(color: AppTheme.textTertiary), - prefixIcon: icon != null - ? Icon(icon, color: AppTheme.textTertiary, size: 18) - : null, - suffixIcon: suffix, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: AppTheme.border)), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: AppTheme.border)), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: AppTheme.primary, width: 1.5)), - labelStyle: const TextStyle(color: AppTheme.textSecondary), - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 14)); - - String _ago(DateTime d) { - final diff = DateTime.now().difference(d); - if (diff.inDays > 365) return '${diff.inDays ~/ 365}y ago'; - if (diff.inDays > 30) return '${diff.inDays ~/ 30}mo ago'; - if (diff.inDays > 0) return '${diff.inDays}d ago'; - if (diff.inHours > 0) return '${diff.inHours}h ago'; - if (diff.inMinutes > 0) return '${diff.inMinutes}m ago'; - return 'just now'; - } - - Color _hexColor(String hex) { - final b = StringBuffer(); - if (hex.length == 6) b.write('FF'); - b.write(hex); - return Color(int.parse(b.toString(), radix: 16)); - } -} + + void _createIssueSheet() { + if (_repos.isEmpty) return; + final titleCtrl = TextEditingController(); + final bodyCtrl = TextEditingController(); + String repoName = _repos.first.fullName; + // Pre-populate label filter as issue label if set + List selectedLabels = []; + if (_issueLabelFilter != null) selectedLabels.add(_issueLabelFilter!); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSt) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + top: 20, + left: 20, + right: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sheetHandle(), + const SizedBox(height: 20), + const Text('Create Issue', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary)), + const SizedBox(height: 16), + if (_repos.length > 1) + DropdownButtonFormField( + value: repoName, + dropdownColor: AppTheme.surfaceHover, + style: const TextStyle( + color: AppTheme.textPrimary, fontSize: 14), + decoration: _inputDecoration('Repository') + .copyWith(labelText: 'Repository'), + items: _repos + .map((r) => DropdownMenuItem( + value: r.fullName, child: Text(r.fullName))) + .toList(), + onChanged: (v) => setSt(() => repoName = v!)), + if (_repos.length > 1) const SizedBox(height: 12), + TextField( + controller: titleCtrl, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: _inputDecoration('Title') + .copyWith(labelText: 'Title')), + const SizedBox(height: 12), + TextField( + controller: bodyCtrl, + style: const TextStyle(color: AppTheme.textPrimary), + maxLines: 4, + decoration: _inputDecoration( + 'Description (supports Markdown)') + .copyWith(labelText: 'Description')), + const SizedBox(height: 12), + // Label selector + if (_repoLabels.isNotEmpty) ...[ + Text('Labels', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: _repoLabels.map((l) { + final name = l['name'] as String? ?? ''; + final color = + _hexColor(l['color'] as String? ?? '666666'); + final isSelected = selectedLabels.contains(name); + return InkWell( + onTap: () => setSt(() { + if (isSelected) { + selectedLabels.remove(name); + } else { + selectedLabels.add(name); + } + }), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isSelected + ? color.withOpacity(0.25) + : color.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected + ? color.withOpacity(0.6) + : color.withOpacity(0.2), + width: 1), + ), + child: Text(name, + style: TextStyle( + fontSize: 11, + color: isSelected ? color : color.withOpacity(0.6), + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400)), + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + ], + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + final t = titleCtrl.text.trim(); + if (t.isEmpty) return; + final p = repoName.split('/'); + Navigator.pop(ctx); + try { + await _svc.createIssue(p[0], p[1], t, + body: bodyCtrl.text.trim(), + labels: selectedLabels.isEmpty + ? null + : selectedLabels); + _toast('Issue created'); + if (_tabCtrl.index == 1) await _loadIssues(); + } catch (e) { + _toast('Failed: \$e', isError: true); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.success, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10))), + child: const Text('Create Issue', + style: TextStyle(fontWeight: FontWeight.w600)), + )), + ]), + ), + ), + ); + } + + void _createPRSheet() { + if (_repos.isEmpty) return; + final titleCtrl = TextEditingController(); + final bodyCtrl = TextEditingController(); + final headCtrl = TextEditingController(); + final baseCtrl = TextEditingController(text: 'main'); + String repoName = _repos.first.fullName; + bool draft = false; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSt) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + top: 20, + left: 20, + right: 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sheetHandle(), + const SizedBox(height: 20), + const Text('Create Pull Request', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary)), + const SizedBox(height: 16), + if (_repos.length > 1) + DropdownButtonFormField( + value: repoName, + dropdownColor: AppTheme.surfaceHover, + style: const TextStyle( + color: AppTheme.textPrimary, fontSize: 14), + decoration: _inputDecoration('Repository') + .copyWith(labelText: 'Repository'), + items: _repos + .map((r) => DropdownMenuItem( + value: r.fullName, child: Text(r.fullName))) + .toList(), + onChanged: (v) => setSt(() => repoName = v!)), + if (_repos.length > 1) const SizedBox(height: 12), + TextField( + controller: titleCtrl, + style: const TextStyle(color: AppTheme.textPrimary), + decoration: _inputDecoration('Title') + .copyWith(labelText: 'Title')), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: TextField( + controller: headCtrl, + style: const TextStyle( + color: AppTheme.textPrimary, fontSize: 13), + decoration: _inputDecoration('From branch') + .copyWith(labelText: 'From branch'))), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.arrow_forward, + color: AppTheme.textTertiary, size: 18)), + Expanded( + child: TextField( + controller: baseCtrl, + style: const TextStyle( + color: AppTheme.textPrimary, fontSize: 13), + decoration: _inputDecoration('To branch') + .copyWith(labelText: 'To branch'))), + ]), + const SizedBox(height: 12), + TextField( + controller: bodyCtrl, + style: const TextStyle(color: AppTheme.textPrimary), + maxLines: 3, + decoration: _inputDecoration('Description') + .copyWith(labelText: 'Description')), + const SizedBox(height: 8), + SwitchListTile( + value: draft, + onChanged: (v) => setSt(() => draft = v), + title: const Text('Draft PR', + style: TextStyle( + fontSize: 14, color: AppTheme.textPrimary)), + subtitle: const Text( + 'Cannot be merged until marked ready', + style: TextStyle( + fontSize: 12, color: AppTheme.textTertiary)), + activeColor: AppTheme.primary, + contentPadding: EdgeInsets.zero, + dense: true, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + final t = titleCtrl.text.trim(); + final h = headCtrl.text.trim(); + final b = baseCtrl.text.trim(); + if (t.isEmpty || h.isEmpty || b.isEmpty) return; + final p = repoName.split('/'); + Navigator.pop(ctx); + try { + await _svc.createPullRequest(p[0], p[1], t, h, b, + body: bodyCtrl.text.trim(), draft: draft); + _toast('Pull request created'); + if (_tabCtrl.index == 2) await _loadPRs(); + } catch (e) { + _toast('Failed: \$e', isError: true); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10))), + child: const Text('Create Pull Request', + style: TextStyle(fontWeight: FontWeight.w600)), + )), + ]), + ), + ), + ); + } + + void _cloneSheet(GitHubRepo repo) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(20), + child: + Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + _sheetHandle(), + const SizedBox(height: 20), + const Text('Clone Repository', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary)), + const SizedBox(height: 16), + if (repo.cloneUrl != null) ...[ + _cloneUrlTile('HTTPS', repo.cloneUrl!), + const SizedBox(height: 8) + ], + if (repo.sshUrl != null) _cloneUrlTile('SSH', repo.sshUrl!), + const SizedBox(height: 8), + if (repo.htmlUrl != null) _cloneUrlTile('GitHub', repo.htmlUrl!), + const SizedBox(height: 12), + Text('Clone locally to edit files, commit changes, and push.', + style: TextStyle( + fontSize: 12, color: AppTheme.textTertiary.withOpacity(0.7))), + ]), + ), + ); + } + + Widget _cloneUrlTile(String label, String url) => InkWell( + onTap: () { + Clipboard.setData(ClipboardData(text: url)); + _toast('URL copied'); + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.surfaceInput, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.border)), + child: Row(children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(6)), + child: Text(label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppTheme.primary))), + const SizedBox(width: 12), + Expanded( + child: Text(url, + style: const TextStyle( + fontFamily: AppTheme.fontCode, + fontSize: 12, + color: AppTheme.textSecondary), + overflow: TextOverflow.ellipsis)), + const Icon(Icons.copy, size: 16, color: AppTheme.textTertiary), + ])), + ); + + void _issueDetailSheet(dynamic issue) { + // Legacy bottom sheet for quick preview; use GitHubIssueDetailScreen for full view + final open = issue['state'] == 'open'; + final title = issue['title'] as String? ?? 'Untitled'; + final body = issue['body'] as String? ?? 'No description provided.'; + final num = issue['number'] as int? ?? 0; + final labels = (issue['labels'] as List?) ?? []; + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (ctx, sc) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20))), + child: Column(children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: + Border(bottom: BorderSide(color: AppTheme.divider))), + child: Row(children: [ + Expanded( + child: Text('#$num: $title', + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary))), + IconButton( + onPressed: () => _navigateToIssueDetail(issue), + icon: const Icon(Icons.open_in_full, + color: AppTheme.accent, size: 20)), + IconButton( + onPressed: () async { + if (_repos.isEmpty) return; + try { + final ns = open ? 'closed' : 'open'; + await _svc.updateIssue(_repos.first.owner, + _repos.first.name, num, + state: ns); + Navigator.pop(ctx); + _toast('Issue ${open ? 'closed' : 'reopened'}'); + await _loadIssues(); + } catch (e) { + _toast('Failed: \$e', isError: true); + } + }, + icon: Icon(open ? Icons.check_circle : Icons.replay, + color: + open ? AppTheme.success : AppTheme.accent)), + IconButton( + onPressed: () => Navigator.pop(ctx), + icon: const Icon(Icons.close, + color: AppTheme.textTertiary)), + ])), + Expanded( + child: SingleChildScrollView( + controller: sc, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: (open + ? AppTheme.success + : AppTheme.textTertiary) + .withOpacity(0.15), + borderRadius: + BorderRadius.circular(12)), + child: Text(open ? 'OPEN' : 'CLOSED', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: open + ? AppTheme.success + : AppTheme.textTertiary, + letterSpacing: 0.5))), + if (labels.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 6, + runSpacing: 4, + children: labels.map((l) { + final name = l['name'] as String? ?? ''; + final c = _hexColor( + l['color'] as String? ?? + '666666'); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: c.withOpacity(0.15), + borderRadius: + BorderRadius.circular(10), + border: Border.all( + color: c.withOpacity(0.3), + width: 0.5)), + child: Text(name, + style: TextStyle( + fontSize: 10, + color: c, + fontWeight: + FontWeight.w500))); + }).toList()), + ], + const SizedBox(height: 16), + Text(body, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + color: AppTheme.textSecondary, + height: 1.6)), + ]))), + ]), + )), + ); + } + + void _prDetailSheet(dynamic pr) { + final title = pr['title'] as String? ?? 'Untitled'; + final body = pr['body'] as String? ?? ''; + final num = pr['number'] as int? ?? 0; + final merged = pr['merged'] == true; + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (ctx, sc) => Container( + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20))), + child: Column(children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: + Border(bottom: BorderSide(color: AppTheme.divider))), + child: Row(children: [ + Expanded( + child: Text('#$num: $title', + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary))), + ElevatedButton.icon( + onPressed: () => _navigateToPRReview(pr), + icon: const Icon(Icons.rate_review, size: 16), + label: const Text('Review'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600))), + const SizedBox(width: 8), + if (!merged && pr['state'] == 'open') + ElevatedButton.icon( + onPressed: () => _mergeDialog(pr), + icon: const Icon(Icons.merge_type, size: 16), + label: const Text('Merge'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600))), + const SizedBox(width: 8), + IconButton( + onPressed: () => Navigator.pop(ctx), + icon: const Icon(Icons.close, + color: AppTheme.textTertiary, size: 20)), + ])), + Expanded( + child: SingleChildScrollView( + controller: sc, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (body.isNotEmpty) + Text(body, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + color: AppTheme.textSecondary, + height: 1.6)), + if (body.isNotEmpty) + const SizedBox(height: 16), + const Text('Review Actions', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary)), + const SizedBox(height: 12), + Row(children: [ + _reviewBtn(Icons.check_circle, 'Approve', + AppTheme.success, () => _submitReview(pr, 'APPROVE')), + const SizedBox(width: 8), + _reviewBtn( + Icons.cancel, + 'Request Changes', + AppTheme.error, + () => _submitReview(pr, 'REQUEST_CHANGES')), + const SizedBox(width: 8), + _reviewBtn(Icons.comment, 'Comment', + AppTheme.info, () => _submitReview(pr, 'COMMENT')), + ]), + ]))), + ]), + )), + ); + } + + void _mergeDialog(dynamic pr) { + final num = pr['number'] as int? ?? 0; + String method = 'merge'; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('Merge Pull Request', + style: TextStyle( + color: AppTheme.textPrimary, fontFamily: AppTheme.fontBody)), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('Choose merge method:', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 14)), + const SizedBox(height: 12), + StatefulBuilder( + builder: (ctx, setSt) => Column(children: [ + _mergeRadio('merge', 'Create a merge commit', method, + (v) => setSt(() => method = v)), + _mergeRadio('squash', 'Squash and merge', method, + (v) => setSt(() => method = v)), + _mergeRadio('rebase', 'Rebase and merge', method, + (v) => setSt(() => method = v)), + ])), + ]), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel', + style: TextStyle(color: AppTheme.textTertiary))), + ElevatedButton( + onPressed: () async { + Navigator.pop(ctx); + if (_repos.isEmpty) return; + try { + await _svc.mergePullRequest( + _repos.first.owner, _repos.first.name, num, + method: method); + _toast('PR merged'); + await _loadPRs(); + } catch (e) { + _toast('Merge failed: \$e', isError: true); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: Colors.white), + child: const Text('Merge'), + ), + ], + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SHARED WIDGET BUILDERS + // ═══════════════════════════════════════════════════════════════════════════ + + Widget _empty(IconData icon, String title, String subtitle) => Center( + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 56, color: AppTheme.textTertiary.withOpacity(0.4)), + const SizedBox(height: 16), + Text(title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppTheme.textSecondary.withOpacity(0.7))), + const SizedBox(height: 8), + Text(subtitle, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, color: AppTheme.textTertiary.withOpacity(0.6))), + ]), + ); + + Widget _filterChip(String value, String label, String selected, + ValueChanged onSelect) => + InkWell( + onTap: () => onSelect(value), + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: selected == value + ? AppTheme.primary.withOpacity(0.2) + : AppTheme.surface.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected == value + ? AppTheme.primary.withOpacity(0.5) + : AppTheme.border, + width: 1), + ), + child: Text(label, + style: TextStyle( + fontSize: 12, + fontWeight: selected == value + ? FontWeight.w600 + : FontWeight.w500, + color: selected == value + ? AppTheme.primary + : AppTheme.textSecondary))), + ); + + Widget _miniAction(IconData icon, String label, VoidCallback onTap) => + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 13, color: AppTheme.textTertiary), + const SizedBox(width: 3), + Text(label, + style: const TextStyle( + fontSize: 11, + color: AppTheme.textTertiary, + fontWeight: FontWeight.w500)), + ])), + ); + + Widget _avatar(String name, {required Gradient gradient}) => Container( + width: 20, + height: 20, + decoration: + BoxDecoration(gradient: gradient, borderRadius: BorderRadius.circular(6)), + child: Center( + child: Text(name[0].toUpperCase(), + style: const TextStyle( + fontSize: 9, fontWeight: FontWeight.bold, color: Colors.white)))); + + Widget _reviewBtn(IconData icon, String label, Color color, + VoidCallback onTap) => + Expanded( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.3))), + child: Column(children: [ + Icon(icon, size: 22, color: color), + const SizedBox(height: 4), + Text(label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color)) + ]))), + ); + + Widget _mergeRadio( + String val, String label, String sel, ValueChanged onCh) => + RadioListTile( + value: val, + groupValue: sel, + onChanged: (v) => onCh(v!), + title: Text(label, + style: TextStyle( + fontSize: 13, + color: sel == val ? AppTheme.textPrimary : AppTheme.textSecondary)), + activeColor: AppTheme.primary, + dense: true, + contentPadding: EdgeInsets.zero); + + Widget _sheetHandle() => Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.border, borderRadius: BorderRadius.circular(2)))); + + InputDecoration _inputDecoration(String hint, {IconData? icon, Widget? suffix}) => + InputDecoration( + filled: true, + fillColor: AppTheme.surfaceInput, + hintText: hint, + hintStyle: const TextStyle(color: AppTheme.textTertiary), + prefixIcon: icon != null + ? Icon(icon, color: AppTheme.textTertiary, size: 18) + : null, + suffixIcon: suffix, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppTheme.border)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppTheme.border)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppTheme.primary, width: 1.5)), + labelStyle: const TextStyle(color: AppTheme.textSecondary), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14)); + + String _ago(DateTime d) { + final diff = DateTime.now().difference(d); + if (diff.inDays > 365) return '${diff.inDays ~/ 365}y ago'; + if (diff.inDays > 30) return '${diff.inDays ~/ 30}mo ago'; + if (diff.inDays > 0) return '${diff.inDays}d ago'; + if (diff.inHours > 0) return '${diff.inHours}h ago'; + if (diff.inMinutes > 0) return '${diff.inMinutes}m ago'; + return 'just now'; + } + + Color _hexColor(String hex) { + final b = StringBuffer(); + if (hex.length == 6) b.write('FF'); + b.write(hex); + return Color(int.parse(b.toString(), radix: 16)); + } +} diff --git a/mobile_agent/lib/screens/home_screen.dart b/mobile_agent/lib/screens/home_screen.dart index 914694a..20a0859 100644 --- a/mobile_agent/lib/screens/home_screen.dart +++ b/mobile_agent/lib/screens/home_screen.dart @@ -1,345 +1,449 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:webview_flutter/webview_flutter.dart'; +import 'agent_dashboard_screen.dart'; +import 'api_usage_screen.dart'; +import 'device_telemetry_screen.dart'; +import 'downloads_shared_folders_screen.dart'; +import 'editor_screen.dart'; +import 'github_repo_hub_screen.dart'; +import 'github_screen.dart'; +import 'hook_registry_screen.dart'; +import 'memory_manager_screen.dart'; +import 'mcp_manager_screen.dart'; +import 'role_manager_screen.dart'; +import 'skill_manager_screen.dart'; +import '../services/feature_flags_service.dart'; +import '../services/github_deep_service.dart'; +import '../services/github_pages_service.dart'; +import '../services/github_repo_hub_service.dart'; +import '../services/html_publish_readiness_service.dart'; +import '../services/role_library_service.dart'; import '../services/runtime_manager.dart'; import '../services/runtime_actions.dart'; import '../services/runtime_provider.dart'; +import '../services/device_telemetry_service.dart'; +import '../services/skill_manager_service.dart'; import '../services/termux_service.dart'; +import '../services/token_usage_service.dart'; import '../services/voice_service.dart'; - -enum _ApiFlavor { openAi, anthropic } - -enum _HealthState { unknown, checking, healthy, failed } - -enum _HomeTab { control, ai, ship, guard, insight } - -enum _CapabilityStatus { ready, needsConfig, local, preview } - -enum _AgentStepState { queued, running, done, failed } - -enum _MiniAgentEventKind { - system, - thought, - toolCall, - observation, - fileWrite, - diff, - preview, - finalAnswer, - error, -} - -enum _ModuleAction { - aiChat, - apiConfig, - healthCheck, - webDemo, - githubTest, - diary, - toolLab, - termuxCheck, - newFile, - snippet, - project, - terminal, - deepDive, - build, - inspect, -} - -const _bg = Color(0xFFF7FAFF); -const _panel = Color(0xFFFFFFFF); -const _panelSoft = Color(0xFFF0F5FF); -const _line = Color(0xFFDDE7F7); -const _text = Color(0xFF0B1020); -const _muted = Color(0xFF536079); -const _faint = Color(0xFF8B97AD); -const _mint = Color(0xFF0B9B7E); -const _cyan = Color(0xFF16B9C7); -const _amber = Color(0xFFB7791F); -const _rose = Color(0xFFE0526E); -const _lime = Color(0xFF4F8F2D); -const _violet = Color(0xFF7557E8); -const _blue = Color(0xFF2555FF); -const _defaultBaseUrl = 'https://token-plan-cn.xiaomimimo.com/anthropic'; -const _defaultModel = 'mimo-v2.5-pro'; -const _managedProviderEnabled = bool.fromEnvironment('MOBILECODE_MANAGED_PROVIDER'); -const _managedBaseUrl = String.fromEnvironment( - 'MOBILECODE_MANAGED_BASE_URL', - defaultValue: _defaultBaseUrl, -); -const _managedModel = String.fromEnvironment( - 'MOBILECODE_MANAGED_MODEL', - defaultValue: _defaultModel, -); -const _managedApiKey = String.fromEnvironment('MOBILECODE_MANAGED_API_KEY'); -const _demo2048Url = 'https://harzva.github.io/mobilecode/demo/2048/'; -const _githubTestUrl = 'https://harzva.github.io/mobilecode/github-test/'; -const _releaseUrl = 'https://github.com/Harzva/mobilecode/releases/tag/v0.1.0'; -const _androidSmokeRunUrl = 'https://github.com/Harzva/mobilecode/actions/workflows/android-app-test.yml'; -const _iosSimulatorRunUrl = 'https://github.com/Harzva/mobilecode/actions/workflows/ios-simulator.yml'; -const _releaseBuildLabel = 'v0.1.0+19'; -const _systemToolsChannel = MethodChannel('mobilecode/system_tools'); - -class _ProbeResult { - const _ProbeResult({ - required this.uri, - required this.statusCode, - required this.latencyMs, - required this.message, - }); - - final Uri uri; - final int? statusCode; - final int latencyMs; - final String message; - - bool get isHealthy => statusCode != null && statusCode! >= 200 && statusCode! < 300; -} - -class _CapabilityLayer { - const _CapabilityLayer({ - required this.name, - required this.subtitle, - required this.icon, - required this.color, - required this.capabilities, - }); - - final String name; - final String subtitle; - final IconData icon; - final Color color; - final List<_Capability> capabilities; - - int get serviceCount { - final services = {}; - for (final capability in capabilities) { - services.addAll(capability.services); - } - return services.length; - } -} - -class _Capability { - const _Capability({ - required this.title, - required this.subtitle, - required this.icon, - required this.status, - required this.services, - required this.actions, - required this.primaryAction, - this.surface = 'Console panel', - }); - - final String title; - final String subtitle; - final IconData icon; - final _CapabilityStatus status; - final List services; - final List actions; - final _ModuleAction primaryAction; - final String surface; -} - -class _ActivityLog { - const _ActivityLog({ - required this.title, - required this.detail, - required this.icon, - required this.color, - required this.time, - }); - - final String title; - final String detail; - final IconData icon; - final Color color; - final DateTime time; -} - -class _DraftFile { - const _DraftFile({ - required this.name, - required this.language, - required this.createdAt, - }); - - final String name; - final String language; - final DateTime createdAt; -} - -class _SnippetDraft { - const _SnippetDraft({ - required this.title, - required this.language, - required this.createdAt, - }); - - final String title; - final String language; - final DateTime createdAt; -} - -class _ChatTurn { - const _ChatTurn({ - required this.role, - required this.content, - required this.time, - }); - - final String role; - final String content; - final DateTime time; - - Map toJson() { - return { - 'role': role, - 'content': content, - 'time': time.toIso8601String(), - }; - } - - factory _ChatTurn.fromJson(Map json) { - return _ChatTurn( - role: json['role'] as String? ?? 'user', - content: json['content'] as String? ?? '', - time: DateTime.tryParse(json['time'] as String? ?? '') ?? DateTime.now(), - ); - } -} - -class _ChatSession { - const _ChatSession({ - required this.id, - required this.title, - required this.createdAt, - required this.updatedAt, - required this.turns, - }); - - final String id; - final String title; - final DateTime createdAt; - final DateTime updatedAt; - final List<_ChatTurn> turns; - - _ChatSession copyWith({ - String? title, - DateTime? updatedAt, - List<_ChatTurn>? turns, - }) { - return _ChatSession( - id: id, - title: title ?? this.title, - createdAt: createdAt, - updatedAt: updatedAt ?? this.updatedAt, - turns: turns ?? this.turns, - ); - } - - Map toJson() { - return { - 'id': id, - 'title': title, - 'createdAt': createdAt.toIso8601String(), - 'updatedAt': updatedAt.toIso8601String(), - 'turns': turns.map((turn) => turn.toJson()).toList(), - }; - } - - factory _ChatSession.fromJson(Map json) { - return _ChatSession( - id: json['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(), - title: json['title'] as String? ?? 'Untitled chat', - createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now(), - updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? DateTime.now(), - turns: ((json['turns'] as List?) ?? const []) - .whereType() - .map((turn) => _ChatTurn.fromJson(Map.from(turn))) - .toList(), - ); - } -} - -class _DiaryEntry { - const _DiaryEntry({ - required this.title, - required this.body, - required this.time, - }); - - final String title; - final String body; - final DateTime time; - - Map toJson() { - return { - 'title': title, - 'body': body, - 'time': time.toIso8601String(), - }; - } - - factory _DiaryEntry.fromJson(Map json) { - return _DiaryEntry( - title: json['title'] as String? ?? 'Untitled', - body: json['body'] as String? ?? '', - time: DateTime.tryParse(json['time'] as String? ?? '') ?? DateTime.now(), - ); - } -} - -class _ToolProbe { - const _ToolProbe({ - required this.name, - required this.detail, - required this.icon, - required this.action, - }); - - final String name; - final String detail; - final IconData icon; - final String action; -} - -class _ToolProbeResult { - const _ToolProbeResult({ - required this.name, - required this.ok, - required this.message, - }); - - final String name; - final bool ok; - final String message; -} - + +enum _ApiFlavor { openAi, anthropic } + +enum _ProviderPreset { mimo, anthropic, openAi, custom } + +enum _HealthState { unknown, checking, healthy, failed } + +enum _HomeTab { control, ai, ship, guard, insight } + +enum _CapabilityStatus { ready, needsConfig, local, preview } + +enum _AgentStepState { queued, running, done, failed } + +enum _LocalToolEventKind { + system, + thought, + toolCall, + observation, + fileWrite, + diff, + preview, + finalAnswer, + error, +} + +enum _ModuleAction { + aiChat, + apiConfig, + healthCheck, + webDemo, + githubTest, + diary, + toolLab, + termuxCheck, + newFile, + snippet, + project, + terminal, + deepDive, + build, + githubRepoHub, + larkCli, + tokenUsage, + deviceTelemetry, + downloadsShared, + inspect, +} + +const _bg = Color(0xFFF7FAFF); +const _panel = Color(0xFFFFFFFF); +const _panelSoft = Color(0xFFF0F5FF); +const _line = Color(0xFFDDE7F7); +const _text = Color(0xFF0B1020); +const _muted = Color(0xFF536079); +const _faint = Color(0xFF8B97AD); +const _mint = Color(0xFF0B9B7E); +const _cyan = Color(0xFF16B9C7); +const _amber = Color(0xFFB7791F); +const _rose = Color(0xFFE0526E); +const _lime = Color(0xFF4F8F2D); +const _violet = Color(0xFF7557E8); +const _blue = Color(0xFF2555FF); +const _defaultBaseUrl = 'https://token-plan-cn.xiaomimimo.com/anthropic'; +const _defaultModel = 'mimo-v2.5-pro'; +const _managedProviderEnabled = bool.fromEnvironment('MOBILECODE_MANAGED_PROVIDER'); +const _managedBaseUrl = String.fromEnvironment( + 'MOBILECODE_MANAGED_BASE_URL', + defaultValue: _defaultBaseUrl, +); +const _managedModel = String.fromEnvironment( + 'MOBILECODE_MANAGED_MODEL', + defaultValue: _defaultModel, +); +const _managedApiKey = String.fromEnvironment('MOBILECODE_MANAGED_API_KEY'); +const _demo2048Url = 'https://harzva.github.io/mobilecode/demo/2048/'; +const _githubTestUrl = 'https://harzva.github.io/mobilecode/github-test/'; +const _releaseUrl = 'https://github.com/Harzva/mobilecode/releases/tag/v0.1.30'; +const _androidSmokeRunUrl = 'https://github.com/Harzva/mobilecode/actions/workflows/android-app-test.yml'; +const _iosSimulatorRunUrl = 'https://github.com/Harzva/mobilecode/actions/workflows/ios-simulator.yml'; +const _releaseBuildLabel = 'v0.1.30+49'; +const _systemToolsChannel = MethodChannel('mobilecode/system_tools'); +const _mobileCodeProjectsFolderName = 'mobilecode_projects'; +const _browserOpenModeSystem = 'systemDefault'; +const _browserOpenModeInApp = 'inAppBrowser'; +const _rrModeAvatarAsset = 'assets/role_avatars/avatar-batch2-24-rounded-icon.svg'; + +String _normalizeBrandTheme(String value) { + return value == 'claudeYellow' ? 'claudeYellow' : 'codexBlue'; +} + +String _normalizeBrowserOpenMode(String? value) { + return value == _browserOpenModeInApp ? _browserOpenModeInApp : _browserOpenModeSystem; +} + +String _browserOpenModeLabel(String value) { + return _normalizeBrowserOpenMode(value) == _browserOpenModeInApp ? 'App 内浏览器优先' : '系统默认浏览器'; +} + +TokenUsageSnapshot _providerUsageFromBody(_ApiFlavor flavor, String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + return flavor == _ApiFlavor.anthropic + ? TokenUsageService.parseAnthropicUsage(decoded) + : TokenUsageService.parseOpenAiUsage(decoded); + } + } catch (_) { + // Providers without usage metadata fall back to local estimation. + } + return TokenUsageSnapshot.empty; +} + +String? runtimeFailureKindHint(RuntimeTaskFailureKind kind) { + switch (kind) { + case RuntimeTaskFailureKind.none: + return null; + case RuntimeTaskFailureKind.timeout: + return 'Increase the timeout, inspect recent logs, then retry or move the task to cloud runtime.'; + case RuntimeTaskFailureKind.cancelled: + return 'The task was stopped intentionally. Retry the same taskId when ready.'; + case RuntimeTaskFailureKind.dependencyMissing: + return 'Install the missing dependency in Helper or Termux before retrying.'; + case RuntimeTaskFailureKind.commandBlocked: + return 'Use a structured runtime action or confirm the command before running it.'; + case RuntimeTaskFailureKind.cwdOutsideWorkspace: + return 'Move the project under the MobileCode workspace and retry.'; + case RuntimeTaskFailureKind.authFailed: + return 'Check the provider token, GitHub token, or Helper auth token.'; + case RuntimeTaskFailureKind.processFailed: + return 'Open task details, copy the failure summary, fix the command error, then retry.'; + case RuntimeTaskFailureKind.runtimeLost: + return 'Restart MobileCode Helper or Termux daemon, refresh runtime status, then retry.'; + case RuntimeTaskFailureKind.unknown: + return 'Check task logs and runtime health before retrying.'; + } +} + +class _ProbeResult { + const _ProbeResult({ + required this.uri, + required this.statusCode, + required this.latencyMs, + required this.message, + }); + + final Uri uri; + final int? statusCode; + final int latencyMs; + final String message; + + bool get isHealthy => statusCode != null && statusCode! >= 200 && statusCode! < 300; +} + +class _CapabilityLayer { + const _CapabilityLayer({ + required this.name, + required this.subtitle, + required this.icon, + required this.color, + required this.capabilities, + }); + + final String name; + final String subtitle; + final IconData icon; + final Color color; + final List<_Capability> capabilities; + + int get serviceCount { + final services = {}; + for (final capability in capabilities) { + services.addAll(capability.services); + } + return services.length; + } +} + +class _Capability { + const _Capability({ + required this.title, + required this.subtitle, + required this.icon, + required this.status, + required this.services, + required this.actions, + required this.primaryAction, + this.surface = 'Console panel', + }); + + final String title; + final String subtitle; + final IconData icon; + final _CapabilityStatus status; + final List services; + final List actions; + final _ModuleAction primaryAction; + final String surface; +} + +class _ActivityLog { + const _ActivityLog({ + required this.title, + required this.detail, + required this.icon, + required this.color, + required this.time, + }); + + final String title; + final String detail; + final IconData icon; + final Color color; + final DateTime time; +} + +class _DraftFile { + const _DraftFile({ + required this.name, + required this.language, + required this.createdAt, + }); + + final String name; + final String language; + final DateTime createdAt; +} + +class _SnippetDraft { + const _SnippetDraft({ + required this.title, + required this.language, + required this.createdAt, + }); + + final String title; + final String language; + final DateTime createdAt; +} + +class _ChatTurn { + const _ChatTurn({ + required this.role, + required this.content, + required this.time, + this.bookmarked = false, + }); + + final String role; + final String content; + final DateTime time; + final bool bookmarked; + + _ChatTurn copyWith({ + String? role, + String? content, + DateTime? time, + bool? bookmarked, + }) { + return _ChatTurn( + role: role ?? this.role, + content: content ?? this.content, + time: time ?? this.time, + bookmarked: bookmarked ?? this.bookmarked, + ); + } + + Map toJson() { + return { + 'role': role, + 'content': content, + 'time': time.toIso8601String(), + 'bookmarked': bookmarked, + }; + } + + factory _ChatTurn.fromJson(Map json) { + return _ChatTurn( + role: json['role'] as String? ?? 'user', + content: json['content'] as String? ?? '', + time: DateTime.tryParse(json['time'] as String? ?? '') ?? DateTime.now(), + bookmarked: json['bookmarked'] as bool? ?? false, + ); + } +} + +class _ChatSession { + const _ChatSession({ + required this.id, + required this.title, + required this.createdAt, + required this.updatedAt, + required this.turns, + }); + + final String id; + final String title; + final DateTime createdAt; + final DateTime updatedAt; + final List<_ChatTurn> turns; + + _ChatSession copyWith({ + String? title, + DateTime? updatedAt, + List<_ChatTurn>? turns, + }) { + return _ChatSession( + id: id, + title: title ?? this.title, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + turns: turns ?? this.turns, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'turns': turns.map((turn) => turn.toJson()).toList(), + }; + } + + factory _ChatSession.fromJson(Map json) { + return _ChatSession( + id: json['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(), + title: json['title'] as String? ?? 'Untitled chat', + createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now(), + updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? DateTime.now(), + turns: ((json['turns'] as List?) ?? const []) + .whereType() + .map((turn) => _ChatTurn.fromJson(Map.from(turn))) + .toList(), + ); + } +} + +class _DiaryEntry { + const _DiaryEntry({ + required this.title, + required this.body, + required this.time, + }); + + final String title; + final String body; + final DateTime time; + + Map toJson() { + return { + 'title': title, + 'body': body, + 'time': time.toIso8601String(), + }; + } + + factory _DiaryEntry.fromJson(Map json) { + return _DiaryEntry( + title: json['title'] as String? ?? 'Untitled', + body: json['body'] as String? ?? '', + time: DateTime.tryParse(json['time'] as String? ?? '') ?? DateTime.now(), + ); + } +} + +class _ToolProbe { + const _ToolProbe({ + required this.name, + required this.detail, + required this.icon, + required this.action, + }); + + final String name; + final String detail; + final IconData icon; + final String action; +} + +class _ToolProbeResult { + const _ToolProbeResult({ + required this.name, + required this.ok, + required this.message, + }); + + final String name; + final bool ok; + final String message; +} + class _RootProbeResult { const _RootProbeResult({ required this.available, required this.detail, }); - - final bool available; - final String detail; - - factory _RootProbeResult.fromMap(Map map) { - return _RootProbeResult( - available: map['available'] == true, - detail: map['detail'] as String? ?? 'Root status returned without detail.', - ); + + final bool available; + final String detail; + + factory _RootProbeResult.fromMap(Map map) { + return _RootProbeResult( + available: map['available'] == true, + detail: map['detail'] as String? ?? 'Root status returned without detail.', + ); } } @@ -354,124 +458,136 @@ class _HelperDaemonProbeResult { } class _AgentTraceStep { - const _AgentTraceStep({ - required this.title, - required this.detail, - required this.icon, - this.state = _AgentStepState.queued, - this.finishedAt, - }); - - final String title; - final String detail; - final IconData icon; - final _AgentStepState state; - final DateTime? finishedAt; - - _AgentTraceStep copyWith({ - String? title, - String? detail, - IconData? icon, - _AgentStepState? state, - DateTime? finishedAt, - }) { - return _AgentTraceStep( - title: title ?? this.title, - detail: detail ?? this.detail, - icon: icon ?? this.icon, - state: state ?? this.state, - finishedAt: finishedAt ?? this.finishedAt, - ); - } -} - -class _MiniAgentEvent { - const _MiniAgentEvent({ - required this.kind, - required this.title, - required this.detail, - required this.time, - this.toolName, - this.path, - this.durationMs, - this.ok = true, - }); - - final _MiniAgentEventKind kind; - final String title; - final String detail; - final DateTime time; - final String? toolName; - final String? path; - final int? durationMs; - final bool ok; - - Map toJson() { - return { - 'kind': kind.name, - 'title': title, - 'detail': detail, - 'toolName': toolName, - 'path': path, - 'durationMs': durationMs, - 'ok': ok, - 'time': time.toIso8601String(), - }; - } -} - -class _MiniAgentToolSpec { - const _MiniAgentToolSpec({ - required this.name, - required this.description, - required this.surface, - required this.icon, - required this.color, - required this.risk, - }); - - final String name; - final String description; - final String surface; - final IconData icon; - final Color color; - final String risk; -} - -const _miniAgentTools = [ - _MiniAgentToolSpec( - name: 'list_files', - description: 'Inspect app-owned project folders before writing.', - surface: 'Android app documents', - icon: Icons.folder_open_outlined, - color: _mint, - risk: 'read-only', - ), - _MiniAgentToolSpec( - name: 'write_file', - description: 'Write generated code with a temp-file rename.', - surface: 'Android app documents', - icon: Icons.edit_note_outlined, - color: _cyan, - risk: 'guarded', - ), - _MiniAgentToolSpec( - name: 'read_file', - description: 'Read generated files back for preview and copy.', - surface: 'Android app documents', - icon: Icons.description_outlined, - color: _blue, - risk: 'read-only', - ), - _MiniAgentToolSpec( - name: 'preview_webview', - description: 'Load HTML/CSS/JS into the in-app Android WebView.', - surface: 'Android WebView', - icon: Icons.preview_outlined, - color: _violet, - risk: 'local', - ), - _MiniAgentToolSpec( + const _AgentTraceStep({ + required this.title, + required this.detail, + required this.icon, + this.avatarAsset, + this.toolName, + this.details = const {}, + this.state = _AgentStepState.queued, + this.finishedAt, + }); + + final String title; + final String detail; + final IconData icon; + final String? avatarAsset; + final String? toolName; + final Map details; + final _AgentStepState state; + final DateTime? finishedAt; + + _AgentTraceStep copyWith({ + String? title, + String? detail, + IconData? icon, + String? avatarAsset, + String? toolName, + Map? details, + _AgentStepState? state, + DateTime? finishedAt, + }) { + return _AgentTraceStep( + title: title ?? this.title, + detail: detail ?? this.detail, + icon: icon ?? this.icon, + avatarAsset: avatarAsset ?? this.avatarAsset, + toolName: toolName ?? this.toolName, + details: details ?? this.details, + state: state ?? this.state, + finishedAt: finishedAt ?? this.finishedAt, + ); + } +} + +class _LocalToolEvent { + const _LocalToolEvent({ + required this.kind, + required this.title, + required this.detail, + required this.time, + this.toolName, + this.path, + this.durationMs, + this.ok = true, + }); + + final _LocalToolEventKind kind; + final String title; + final String detail; + final DateTime time; + final String? toolName; + final String? path; + final int? durationMs; + final bool ok; + + Map toJson() { + return { + 'kind': kind.name, + 'title': title, + 'detail': detail, + 'toolName': toolName, + 'path': path, + 'durationMs': durationMs, + 'ok': ok, + 'time': time.toIso8601String(), + }; + } +} + +class _LocalToolSpec { + const _LocalToolSpec({ + required this.name, + required this.description, + required this.surface, + required this.icon, + required this.color, + required this.risk, + }); + + final String name; + final String description; + final String surface; + final IconData icon; + final Color color; + final String risk; +} + +const _localToolSpecs = [ + _LocalToolSpec( + name: 'list_files', + description: 'Inspect app-owned project folders before writing.', + surface: 'Android app documents', + icon: Icons.folder_open_outlined, + color: _mint, + risk: 'read-only', + ), + _LocalToolSpec( + name: 'write_file', + description: 'Write generated code with a temp-file rename.', + surface: 'Android app documents', + icon: Icons.edit_note_outlined, + color: _cyan, + risk: 'guarded', + ), + _LocalToolSpec( + name: 'read_file', + description: 'Read generated files back for preview and copy.', + surface: 'Android app documents', + icon: Icons.description_outlined, + color: _blue, + risk: 'read-only', + ), + _LocalToolSpec( + name: 'preview_webview', + description: 'Load HTML/CSS/JS into the in-app Android WebView.', + surface: 'Android WebView', + icon: Icons.preview_outlined, + color: _violet, + risk: 'local', + ), + _LocalToolSpec( name: 'termux_probe', description: 'Check local runtime providers for shell-like mobile builds.', surface: 'Runtime provider bridge', @@ -479,4470 +595,13026 @@ const _miniAgentTools = [ color: _lime, risk: 'controlled shell', ), - _MiniAgentToolSpec( - name: 'github_connect', - description: 'Open the GitHub Pages token/repo connectivity tester.', - surface: 'GitHub API', - icon: Icons.hub_outlined, - color: _amber, - risk: 'network', - ), -]; - -String _normalizedBaseUrl(String baseUrl) { - return baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; -} - -String _savedOrDefault(String? value, String fallback) { - final trimmed = value?.trim(); - return trimmed == null || trimmed.isEmpty ? fallback : trimmed; -} - -_ApiFlavor _detectApiFlavor(String baseUrl, String model) { - final probe = '$baseUrl $model'.toLowerCase(); - if (probe.contains('anthropic') || - probe.contains('claude') || - probe.contains('mimo-')) { - return _ApiFlavor.anthropic; - } - return _ApiFlavor.openAi; -} - -Uri _parseBaseUrl(String baseUrl) { - final uri = Uri.parse(_normalizedBaseUrl(baseUrl)); - if (!uri.hasScheme || uri.host.isEmpty) { - throw const FormatException('Invalid URL'); - } - return uri; -} - -Uri _openAiChatUri(String baseUrl) { - final normalized = _normalizedBaseUrl(baseUrl); - final uri = _parseBaseUrl(normalized); - if (normalized.endsWith('/chat/completions')) return uri; - return Uri.parse('$normalized/chat/completions'); -} - -Uri _anthropicMessagesUri(String baseUrl) { - final normalized = _normalizedBaseUrl(baseUrl); - final uri = _parseBaseUrl(normalized); - if (normalized.endsWith('/v1/messages') || normalized.endsWith('/messages')) { - return uri; - } - if (normalized.endsWith('/v1')) { - return Uri.parse('$normalized/messages'); - } - return Uri.parse('$normalized/v1/messages'); -} - -String _chatEndpointLabel(String baseUrl, _ApiFlavor flavor) { - return switch (flavor) { - _ApiFlavor.anthropic => _anthropicMessagesUri(baseUrl).toString(), - _ApiFlavor.openAi => _openAiChatUri(baseUrl).toString(), - }; -} - -String _compact(String value, {int limit = 800}) { - final trimmed = value.trim().replaceAll(RegExp(r'\s+'), ' '); - if (trimmed.length <= limit) return trimmed; - return '${trimmed.substring(0, limit)}...'; -} - -String _friendlySocketError(SocketException error) { - final raw = error.message.trim().isEmpty ? error.toString() : error.message.trim(); - final lower = raw.toLowerCase(); - if (lower.contains('failed host lookup') || - lower.contains('no address associated') || - lower.contains('temporary failure in name resolution')) { - return '$raw. Network/DNS/proxy issue: the device cannot resolve the provider host, so the token was not checked.'; - } - return raw; -} - -List _chunkText(String value, int chunkSize) { - final chunks = []; - for (var offset = 0; offset < value.length; offset += chunkSize) { - final end = offset + chunkSize > value.length ? value.length : offset + chunkSize; - chunks.add(value.substring(offset, end)); - } - return chunks; -} - -String _clockLabel(DateTime time) { - final hour = time.hour.toString().padLeft(2, '0'); - final minute = time.minute.toString().padLeft(2, '0'); - final second = time.second.toString().padLeft(2, '0'); - return '$hour:$minute:$second'; -} - -bool _promptTargets2048(String prompt) { - final lower = prompt.toLowerCase(); - return lower.contains('2048') || prompt.contains('二零四八'); -} - -bool _promptTargetsSnake(String prompt) { - final lower = prompt.toLowerCase(); - return lower.contains('snake') || prompt.contains('贪吃蛇'); -} - -bool _promptTargetsDiary(String prompt) { - final lower = prompt.toLowerCase(); - return lower.contains('diary') || prompt.contains('日记'); -} - -String _agentToolNameForPrompt(String prompt) { - final lower = prompt.toLowerCase(); - if (_promptTargetsSnake(prompt)) return 'mobile_coding.generate_snake_preview'; - if (_promptTargets2048(prompt)) return 'mobile_coding.generate_2048_preview'; - if (_promptTargetsDiary(prompt)) return 'mobile_coding.build_diary_demo'; - if (lower.contains('termux') || lower.contains('terminal') || lower.contains('shell')) { - return 'mobile_tools.termux_probe'; - } - if (lower.contains('github') || lower.contains('repo')) { - return 'github.connectivity_test'; - } - if (lower.contains('game') || lower.contains('web') || lower.contains('html') || lower.contains('preview')) { - return 'mobile_coding.generate_web_preview'; - } - return 'mobile_tools.core_probe'; -} - -List<_AgentTraceStep> _agentRunTraceTemplate(String prompt) { - final tool = _agentToolNameForPrompt(prompt); - return [ - const _AgentTraceStep( - title: 'Parse instruction', - detail: 'Read the user request and decide whether this is chat, coding, preview, GitHub, or device tooling.', - icon: Icons.manage_search_outlined, - ), - _AgentTraceStep( - title: 'Select tool', - detail: tool, - icon: Icons.psychology_alt_outlined, - ), - const _AgentTraceStep( - title: 'Call model provider', - detail: 'Send the prompt and chat context to the configured provider. If this fails, the agent must stop.', - icon: Icons.cloud_sync_outlined, - ), - const _AgentTraceStep( - title: 'Write generated artifact', - detail: 'Persist model-generated code only after the provider returns real output.', - icon: Icons.account_tree_outlined, - ), - const _AgentTraceStep( - title: 'Report in chat', - detail: 'Keep the process, generated content, paths, and failure state in this conversation.', - icon: Icons.play_arrow_outlined, - ), - ]; -} - -Future _isAndroidPackageInstalled(String packageName) async { - try { - return await _systemToolsChannel.invokeMethod('isPackageInstalled', { - 'packageName': packageName, - }); - } on MissingPluginException { - return null; - } on PlatformException { - return null; - } -} - -Future _launchAndroidPackage(String packageName) async { - try { - return await _systemToolsChannel.invokeMethod('launchPackage', { - 'packageName': packageName, - }) ?? - false; - } on MissingPluginException { - return false; - } on PlatformException { - return false; - } -} - -Future<_RootProbeResult?> _probeRootAvailability() async { - try { - final result = await _systemToolsChannel.invokeMethod>('rootProbe'); - if (result == null) return null; - return _RootProbeResult.fromMap(result); - } on MissingPluginException { - return null; - } on PlatformException { - return null; + _LocalToolSpec( + name: 'github_connect', + description: 'Open the GitHub Pages token/repo connectivity tester.', + surface: 'GitHub API', + icon: Icons.hub_outlined, + color: _amber, + risk: 'network', + ), +]; + +String _normalizedBaseUrl(String baseUrl) { + return baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; +} + +String _savedOrDefault(String? value, String fallback) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? fallback : trimmed; +} + +_ApiFlavor _detectApiFlavor(String baseUrl, String model) { + final probe = '$baseUrl $model'.toLowerCase(); + if (probe.contains('anthropic') || + probe.contains('claude') || + probe.contains('mimo-')) { + return _ApiFlavor.anthropic; } + return _ApiFlavor.openAi; } -Future _startMobileCodeHelperService() async { - try { - return await _systemToolsChannel.invokeMethod('startHelperService'); - } on MissingPluginException { - return null; - } on PlatformException { - return false; +_ProviderPreset _detectProviderPreset(String baseUrl, String model) { + final probe = '$baseUrl $model'.toLowerCase(); + if (probe.contains('xiaomimimo') || probe.contains('mimo-')) { + return _ProviderPreset.mimo; } + if (probe.contains('anthropic') || probe.contains('claude')) { + return _ProviderPreset.anthropic; + } + if (probe.contains('openai') || probe.contains('gpt-')) { + return _ProviderPreset.openAi; + } + return _ProviderPreset.custom; } -String _agent2048Html() { - return r''' - - - - - MobileCode Agent 2048 - - - -
-
-
-

2048

-

Generated locally by MobileCode Agent

-
-
-
score0
-
best0
-
-
-
-
- - -
-

Swipe on the board. Merge tiles until 2048.

-
- - -'''; -} - -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); - - @override - State createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State { - static const _baseUrlKey = 'mobilecode.baseUrl'; - static const _apiKeyKey = 'mobilecode.apiKey'; - static const _modelKey = 'mobilecode.model'; - - final _scaffoldKey = GlobalKey(); - final _chatPanelKey = GlobalKey<_ChatPanelState>(); - final _baseUrlController = TextEditingController(text: _defaultBaseUrl); - final _apiKeyController = TextEditingController(); - final _modelController = TextEditingController(text: _defaultModel); - - _HealthState _healthState = _HealthState.unknown; - String _healthMessage = 'Not checked'; - bool _saving = false; - _HomeTab _tab = _HomeTab.control; - int _selectedLayerIndex = 0; - bool _showCapabilityMap = false; - bool _runtimeChecking = false; - bool? _termuxInstalled; - bool? _termuxApiInstalled; - bool? _rootAvailable; - String _runtimeMessage = 'Checking runtime providers...'; - List _runtimeHealth = const []; - RuntimeCapabilities _runtimeCapabilities = RuntimeCapabilities.none; - List<_ChatSession> _drawerSessions = const []; - String? _drawerActiveSessionId; +String _providerPresetLabel(_ProviderPreset preset) { + return switch (preset) { + _ProviderPreset.mimo => 'Mimo', + _ProviderPreset.anthropic => 'Anthropic', + _ProviderPreset.openAi => 'OpenAI', + _ProviderPreset.custom => 'Custom', + }; +} - late final RuntimeManager _runtimeManager = RuntimeManager.withExternalTermux(TermuxService()); - - final List<_ActivityLog> _activity = [ - _ActivityLog( - title: 'Frontend map loaded', - detail: 'AI, Agents, Code, Remote, Guard, Analytics, Tools, Performance, Team', - icon: Icons.dashboard_customize_outlined, - color: _mint, - time: DateTime.now(), - ), - ]; - final List<_DraftFile> _drafts = []; - final List<_SnippetDraft> _snippets = []; - - List<_CapabilityLayer> get _layers => _capabilityLayers; - - int get _safeLayerIndex { - if (_selectedLayerIndex < 0) return 0; - if (_selectedLayerIndex >= _layers.length) return _layers.length - 1; - return _selectedLayerIndex; - } - - _CapabilityLayer get _activeLayer => _layers[_safeLayerIndex]; - - bool get _managedProviderActive => _managedProviderEnabled && _managedApiKey.trim().isNotEmpty; - - String get _effectiveBaseUrl => _managedProviderActive ? _managedBaseUrl : _baseUrlController.text.trim(); - - String get _effectiveApiKey => _managedProviderActive ? _managedApiKey : _apiKeyController.text.trim(); - - String get _effectiveModel => _managedProviderActive ? _managedModel : _modelController.text.trim(); - - _ApiFlavor get _flavor { - return _detectApiFlavor(_effectiveBaseUrl, _effectiveModel); +String _providerPresetBaseUrl(_ProviderPreset preset) { + return switch (preset) { + _ProviderPreset.mimo => _defaultBaseUrl, + _ProviderPreset.anthropic => 'https://api.anthropic.com', + _ProviderPreset.openAi => 'https://api.openai.com/v1', + _ProviderPreset.custom => '', + }; +} + +String _providerPresetModel(_ProviderPreset preset) { + return switch (preset) { + _ProviderPreset.mimo => _defaultModel, + _ProviderPreset.anthropic => 'claude-3-5-sonnet-latest', + _ProviderPreset.openAi => 'gpt-4o-mini', + _ProviderPreset.custom => '', + }; +} + +Uri _parseBaseUrl(String baseUrl) { + final uri = Uri.parse(_normalizedBaseUrl(baseUrl)); + if (!uri.hasScheme || uri.host.isEmpty) { + throw const FormatException('Invalid URL'); } + return uri; +} - RuntimeHealth? get _bestRuntimeHealth { - final active = _runtimeManager.activeHealth; - if (active != null) return active; - for (final health in _runtimeHealth) { - if (health.ready) return health; - } - return _runtimeHealth.isNotEmpty ? _runtimeHealth.first : null; +Uri _openAiChatUri(String baseUrl) { + final normalized = _normalizedBaseUrl(baseUrl); + final uri = _parseBaseUrl(normalized); + if (normalized.endsWith('/chat/completions')) return uri; + return Uri.parse('$normalized/chat/completions'); +} + +Uri _anthropicMessagesUri(String baseUrl) { + final normalized = _normalizedBaseUrl(baseUrl); + final uri = _parseBaseUrl(normalized); + if (normalized.endsWith('/v1/messages') || normalized.endsWith('/messages')) { + return uri; + } + if (normalized.endsWith('/v1')) { + return Uri.parse('$normalized/messages'); } + return Uri.parse('$normalized/v1/messages'); +} - String get _activeRuntimeName => _bestRuntimeHealth?.name ?? 'WebView Only'; +String _chatEndpointLabel(String baseUrl, _ApiFlavor flavor) { + return switch (flavor) { + _ApiFlavor.anthropic => _anthropicMessagesUri(baseUrl).toString(), + _ApiFlavor.openAi => _openAiChatUri(baseUrl).toString(), + }; +} - bool get _runtimeReady => _bestRuntimeHealth?.ready == true; +String _compact(String value, {int limit = 800}) { + final trimmed = value.trim().replaceAll(RegExp(r'\s+'), ' '); + if (trimmed.length <= limit) return trimmed; + return '${trimmed.substring(0, limit)}...'; +} - String get _runtimeDrawerLabel { - final runtime = _bestRuntimeHealth; - final capabilityLabel = _runtimeCapabilityLabel(_runtimeCapabilities); - final fallback = [ - if (_termuxInstalled == true) _termuxApiInstalled == true ? 'Termux API fallback' : 'Termux fallback', - if (_rootAvailable == true) 'root keepalive', - ]; - if (runtime == null) { - return fallback.isEmpty ? 'Runtime discovery pending' : fallback.join(' · '); +String _friendlySocketError(SocketException error) { + final raw = error.message.trim().isEmpty ? error.toString() : error.message.trim(); + final lower = raw.toLowerCase(); + if (lower.contains('failed host lookup') || + lower.contains('no address associated') || + lower.contains('temporary failure in name resolution')) { + return '$raw. Network/DNS/proxy issue: the device cannot resolve the provider host, so the token was not checked.'; + } + return raw; +} + +List _chunkText(String value, int chunkSize) { + final chunks = []; + for (var offset = 0; offset < value.length; offset += chunkSize) { + final end = offset + chunkSize > value.length ? value.length : offset + chunkSize; + chunks.add(value.substring(offset, end)); + } + return chunks; +} + +String _clockLabel(DateTime time) { + final hour = time.hour.toString().padLeft(2, '0'); + final minute = time.minute.toString().padLeft(2, '0'); + final second = time.second.toString().padLeft(2, '0'); + return '$hour:$minute:$second'; +} + +String _sessionTurnLabel(_ChatSession session) { + final messages = session.turns.where((turn) => turn.content.trim().isNotEmpty).length; + if (messages == 0) return 'Ready to start'; + final userMessages = session.turns.where((turn) => turn.role == 'user' && turn.content.trim().isNotEmpty).length; + final assistantMessages = session.turns.where((turn) => turn.role == 'assistant' && turn.content.trim().isNotEmpty).length; + if (userMessages > 0 && assistantMessages > 0) { + return '$userMessages prompts · $assistantMessages replies'; + } + return messages == 1 ? '1 message' : '$messages messages'; +} + +Future _mobileCodeProjectsRootDirectory() async { + final directory = await getApplicationDocumentsDirectory(); + final root = Directory(p.join(directory.path, _mobileCodeProjectsFolderName)); + await root.create(recursive: true); + return root; +} + +String _projectDirectoryForArtifact(String path) => p.dirname(path); + +Future _findGitRootForPath(String path) async { + var current = path; + try { + if (!await FileSystemEntity.isDirectory(current)) { + current = p.dirname(current); } - return fallback.isEmpty ? '${runtime.name} · $capabilityLabel' : '${runtime.name} · $capabilityLabel · ${fallback.join(' · ')}'; + } on Object { + current = p.dirname(current); } - @override - void initState() { - super.initState(); - _loadConfig(); - unawaited(_checkRuntime(silent: true)); - } - - @override - void dispose() { - unawaited(_runtimeManager.dispose()); - _baseUrlController.dispose(); - _apiKeyController.dispose(); - _modelController.dispose(); - super.dispose(); - } - - Future _loadConfig() async { - try { - final prefs = await SharedPreferences.getInstance(); - if (!mounted) return; - setState(() { - if (_managedProviderActive) { - _baseUrlController.text = ''; - _apiKeyController.text = ''; - _modelController.text = ''; - } else { - _baseUrlController.text = _savedOrDefault(prefs.getString(_baseUrlKey), _defaultBaseUrl); - _apiKeyController.text = prefs.getString(_apiKeyKey) ?? ''; - _modelController.text = _savedOrDefault(prefs.getString(_modelKey), _defaultModel); - } - }); - } on Object catch (error) { - _addLog('Config load failed', _compact(error.toString(), limit: 120), Icons.error_outline, _rose); - } - } - - void _applyDefaultProvider() { - if (_managedProviderActive) { - _showMessage('Managed provider is already active'); - return; - } - setState(() { - _baseUrlController.text = _defaultBaseUrl; - _modelController.text = _defaultModel; - }); - _addLog('Mimo provider applied', 'Default Base URL and model filled. API key stays private.', Icons.tune_outlined, _mint); - } - - Future _saveConfig() async { - if (_managedProviderActive) { - _showMessage('Managed provider uses hidden debug credentials'); - return; - } - setState(() => _saving = true); - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_baseUrlKey, _baseUrlController.text.trim()); - await prefs.setString(_apiKeyKey, _apiKeyController.text.trim()); - await prefs.setString(_modelKey, _modelController.text.trim()); - if (!mounted) return; - _addLog( - 'API profile saved', - '${_flavorLabel(_flavor)} - ${_effectiveModel.isEmpty ? 'default model' : _effectiveModel}', - Icons.key_outlined, - _mint, - ); - _showMessage('API config saved'); - } on Object catch (error) { - if (!mounted) return; - _addLog('API save failed', _compact(error.toString(), limit: 140), Icons.error_outline, _rose); - _showMessage('API config save failed'); - } finally { - if (mounted) setState(() => _saving = false); - } - } - - Future _checkHealth() async { - final baseUrl = _effectiveBaseUrl; - if (baseUrl.isEmpty) { - _showMessage('Set Base URL first'); - return; - } - - final flavor = _flavor; - try { - _parseBaseUrl(baseUrl); - } catch (_) { - _showMessage('Base URL is not valid'); - return; - } - - setState(() { - _healthState = _HealthState.checking; - _healthMessage = 'Checking ${_chatEndpointLabel(baseUrl, flavor)}'; - }); - - final client = HttpClient()..connectionTimeout = const Duration(seconds: 8); - try { - final apiKey = _effectiveApiKey; - final model = _effectiveModel; - late final _ProbeResult result; - - if (flavor == _ApiFlavor.anthropic) { - result = await _probeAnthropic(client, baseUrl, apiKey, model); - } else { - _ProbeResult? lastResult; - for (final probe in _openAiHealthUris(baseUrl)) { - final probeResult = await _probeGet(client, probe, apiKey); - lastResult = probeResult; - if (probeResult.isHealthy) break; - } - result = lastResult!; - } - - if (!mounted) return; - setState(() { - _healthState = result.isHealthy ? _HealthState.healthy : _HealthState.failed; - _healthMessage = result.message; - }); - _addLog( - result.isHealthy ? 'Provider healthy' : 'Provider unhealthy', - result.message, - result.isHealthy ? Icons.check_circle_outline : Icons.error_outline, - result.isHealthy ? _mint : _rose, - ); - } on SocketException catch (error) { - if (!mounted) return; - final message = _friendlySocketError(error); - setState(() { - _healthState = _HealthState.failed; - _healthMessage = message; - }); - _addLog('Health check failed', _compact(message, limit: 140), Icons.error_outline, _rose); - } on Object catch (error) { - if (!mounted) return; - final message = error.toString().replaceFirst('Exception: ', ''); - setState(() { - _healthState = _HealthState.failed; - _healthMessage = message; - }); - _addLog('Health check failed', _compact(message, limit: 140), Icons.error_outline, _rose); - } finally { - client.close(force: true); - } - } - - Future<_ProbeResult> _probeGet(HttpClient client, Uri uri, String apiKey) async { - final started = DateTime.now(); - final request = await client.getUrl(uri).timeout(const Duration(seconds: 8)); - if (apiKey.isNotEmpty) { - request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey'); - } - final response = await request.close().timeout(const Duration(seconds: 12)); - await response.drain(); - final ms = DateTime.now().difference(started).inMilliseconds; - return _ProbeResult( - uri: uri, - statusCode: response.statusCode, - latencyMs: ms, - message: '${uri.path} HTTP ${response.statusCode} - ${ms}ms', - ); - } - - Future<_ProbeResult> _probeAnthropic( - HttpClient client, - String baseUrl, - String apiKey, - String model, - ) async { - final uri = _anthropicMessagesUri(baseUrl); - final started = DateTime.now(); - final request = await client.postUrl(uri).timeout(const Duration(seconds: 8)); - request.headers.contentType = ContentType.json; - request.headers.set('anthropic-version', '2023-06-01'); - if (apiKey.isNotEmpty) { - request.headers.set('x-api-key', apiKey); - request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey'); - } - request.write(jsonEncode({ - 'model': model.isEmpty ? 'claude-3-5-haiku-latest' : model, - 'max_tokens': 1, - 'messages': [ - {'role': 'user', 'content': 'ping'}, - ], - })); - - final response = await request.close().timeout(const Duration(seconds: 30)); - final body = await utf8.decodeStream(response); - final ms = DateTime.now().difference(started).inMilliseconds; - return _ProbeResult( - uri: uri, - statusCode: response.statusCode, - latencyMs: ms, - message: response.statusCode >= 200 && response.statusCode < 300 - ? '${uri.path} HTTP ${response.statusCode} - ${ms}ms' - : '${uri.path} HTTP ${response.statusCode} - ${_compact(body, limit: 140)}', - ); - } - - List _openAiHealthUris(String baseUrl) { - final normalized = _normalizedBaseUrl(baseUrl); - final probes = [ - Uri.parse('$normalized/health'), - Uri.parse('$normalized/models'), - ]; - if (!normalized.endsWith('/v1')) { - probes.add(Uri.parse('$normalized/v1/models')); - } - return probes; - } - - Future _checkRuntime({bool silent = false}) async { - if (_runtimeChecking) return; - setState(() { - _runtimeChecking = true; - if (!silent) _runtimeMessage = 'Checking MobileCode runtime providers...'; - }); - try { - if (!silent) { - await _startMobileCodeHelperService(); + var directory = Directory(current); + for (var depth = 0; depth < 8; depth++) { + if (await Directory(p.join(directory.path, '.git')).exists()) { + return directory.path; + } + final parent = directory.parent; + if (p.equals(parent.path, directory.path)) break; + directory = parent; + } + return null; +} + +String _workspaceRelativePath(String path, String workspaceRoot) { + try { + final relative = p.relative(path, from: workspaceRoot); + return relative.startsWith('..') ? path : relative; + } on Object { + return path; + } +} + +Future _launchUrlWithBrowserMode(Uri uri, String browserOpenMode) async { + final prefersInApp = _normalizeBrowserOpenMode(browserOpenMode) == _browserOpenModeInApp && + (uri.scheme == 'http' || uri.scheme == 'https'); + final firstMode = prefersInApp ? LaunchMode.inAppWebView : LaunchMode.externalApplication; + if (await launchUrl(uri, mode: firstMode)) return true; + if (firstMode != LaunchMode.externalApplication && await launchUrl(uri, mode: LaunchMode.externalApplication)) { + return true; + } + return launchUrl(uri, mode: LaunchMode.platformDefault); +} + +String? _artifactPathFromContent(String content) { + final patterns = [ + RegExp(r'Saved generated artifact:\s*`([^`]+)`'), + RegExp(r'Code file:\s*`([^`]+)`'), + RegExp(r'代码文件:\s*`([^`]+)`'), + ]; + for (final pattern in patterns) { + final match = pattern.firstMatch(content); + final path = match?.group(1)?.trim(); + if (path != null && path.isNotEmpty) return path; + } + return null; +} + +String? _backtickedValue(String content, String label) { + final escaped = RegExp.escape(label); + return RegExp('$escaped:\\s*`([^`]+)`').firstMatch(content)?.group(1)?.trim(); +} + +class _PublishedArtifactInfo { + const _PublishedArtifactInfo({ + required this.pagesUrl, + required this.repositoryUrl, + required this.artifactPath, + required this.publishedAt, + this.readinessSummary, + this.screenshotPath, + }); + + final String pagesUrl; + final String repositoryUrl; + final String artifactPath; + final DateTime publishedAt; + final String? readinessSummary; + final String? screenshotPath; + + String get title { + final file = artifactPath.split(RegExp(r'[\\/]')).last; + if (file.isEmpty) return 'Published web page'; + return file; + } +} + +_PublishedArtifactInfo? _pagesDeploymentFromContent(String content, DateTime fallbackTime) { + if (!content.contains('GitHub Pages deployment completed.')) return null; + final pagesUrl = _backtickedValue(content, 'Web URL'); + final repositoryUrl = _backtickedValue(content, 'Repository'); + final artifactPath = _backtickedValue(content, 'Code file'); + if (pagesUrl == null || repositoryUrl == null || artifactPath == null) return null; + final publishedAtRaw = _backtickedValue(content, 'Published at'); + return _PublishedArtifactInfo( + pagesUrl: pagesUrl, + repositoryUrl: repositoryUrl, + artifactPath: artifactPath, + publishedAt: DateTime.tryParse(publishedAtRaw ?? '') ?? fallbackTime, + readinessSummary: _backtickedValue(content, 'Pre-publish check'), + screenshotPath: _backtickedValue(content, 'Screenshot'), + ); +} + +bool _isWebArtifactPath(String path) => path.toLowerCase().endsWith('.html') || path.toLowerCase().endsWith('.htm'); + +bool _isAgentResultTurn(String content) { + final trimmed = content.trimLeft(); + return trimmed.startsWith('Agent run completed via provider:') || + trimmed.startsWith('Agent run failed while using') || + trimmed.startsWith('Agent run stopped before writing'); +} + +bool _isFinalResultTurn(String content) { + final trimmed = content.trimLeft(); + return _isAgentResultTurn(content) || + trimmed.startsWith('GitHub Pages deployment completed.') || + trimmed.contains('Saved generated artifact:') || + trimmed.contains('Code file:'); +} + +bool _isPublishResultTurn(String content) { + final trimmed = content.trimLeft(); + return trimmed.startsWith('GitHub Pages deployment completed.') || + trimmed.contains('Pages URL:') || + trimmed.contains('GitHub Pages URL') || + trimmed.contains('发布 GitHub Pages 成功'); +} + +bool _isCodeResultTurn(String content) { + final trimmed = content.trimLeft(); + return trimmed.contains('```') || + trimmed.contains(' complete HTML code -> app writes index.html -> WebView preview and browser-open actions become available.'; + } + if (tool == 'mobile_coding.build_diary_demo') { + return 'Provider response -> app writes agent_response.md so the user can inspect or copy the implementation plan.'; + } + if (tool == 'mobile_tools.termux_probe') { + return 'Runtime status explanation only. It should not claim that a shell command ran unless the runtime provider reports it.'; + } + if (tool == 'github.connectivity_test') { + return 'Provider-guided connectivity checklist and failure explanation for token/repository access.'; + } + return 'Provider response is reported in chat without writing a project artifact.'; +} + +String _agentLocalToolChainFor(String tool) { + if (tool == 'mobile_coding.generate_snake_preview' || + tool == 'mobile_coding.generate_2048_preview' || + tool == 'mobile_coding.generate_web_preview') { + return '1. Parse complete HTML from provider output\n' + '2. write_file: save index.html inside app documents with temp-file rename\n' + '3. read_file: verify the saved artifact path/content\n' + '4. preview_webview: expose the in-app WebView preview action'; + } + if (tool == 'mobile_coding.build_diary_demo') { + return '1. Validate provider output is not empty\n' + '2. write_file: save agent_response.md inside app documents\n' + '3. Show copy/open actions for inspection'; + } + if (tool == 'mobile_tools.termux_probe') { + return 'No shell claim is made from provider text alone. Runtime checks must come from RuntimeProvider capability/status results.'; + } + if (tool == 'github.connectivity_test') { + return 'GitHub checks must use the GitHub test surface/API result, not only the model explanation.'; + } + return 'No local write tool is expected for this prompt. The final answer stays as chat text.'; +} + +Map _agentToolCallDetails(String tool, String prompt) { + final writesFile = tool.startsWith('mobile_coding.'); + final isWeb = tool == 'mobile_coding.generate_snake_preview' || + tool == 'mobile_coding.generate_2048_preview' || + tool == 'mobile_coding.generate_web_preview'; + return { + 'Tool': tool, + 'Why selected': _agentToolSelectionReason(tool, prompt), + 'Input': _compact(prompt, limit: 420), + 'Expected output': _agentToolExpectedOutput(tool), + 'Write target': writesFile + ? (isWeb ? 'App documents/mobilecode_projects//index.html' : 'App documents/mobilecode_projects//agent_response.md') + : 'No file write for this tool selection step.', + 'Safety boundary': 'No arbitrary shell is executed during tool selection. File writes stay inside MobileCode app documents.', + }; +} + +List<_AgentTraceStep> _agentRunTraceTemplate(String prompt) { + final tool = _agentToolNameForPrompt(prompt); + return [ + _AgentTraceStep( + title: 'Parse instruction', + detail: 'Read the user request and decide whether this is chat, coding, preview, GitHub, or device tooling.', + icon: Icons.manage_search_outlined, + avatarAsset: 'assets/role_avatars/claude-pet-animated-magic.svg', + details: { + 'Input': _compact(prompt, limit: 420), + 'Decision rule': 'Classify the prompt before any provider call or file write.', + }, + ), + _AgentTraceStep( + title: 'Select tool', + detail: '$tool · tap for call details', + icon: Icons.psychology_alt_outlined, + avatarAsset: 'assets/role_avatars/claude-pet-animated-coder.svg', + toolName: tool, + details: _agentToolCallDetails(tool, prompt), + ), + _AgentTraceStep( + title: 'Call model provider', + detail: + 'Send the prompt and chat context to the configured provider, then continue into visible local tool actions.', + icon: Icons.cloud_sync_outlined, + avatarAsset: 'assets/role_avatars/claude-girl-dancer.svg', + details: { + 'Provider call': 'Uses the configured Base URL, model, API flavor, and current chat context.', + 'Streaming': 'Tokens are streamed into this trace while the provider responds.', + 'Local tool chain': _agentLocalToolChainFor(tool), + 'Transparency rule': + 'Provider text is not treated as completion by itself. MobileCode must show the selected local tool, file write target, saved path, and preview action before reporting done.', + 'Cancel behavior': 'Pause closes the current provider request and prevents additional file writes.', + }, + ), + _AgentTraceStep( + title: 'Write generated artifact', + detail: 'Persist model-generated code only after the provider returns real output.', + icon: Icons.account_tree_outlined, + avatarAsset: 'assets/role_avatars/claude-pet-animated-rocket.svg', + details: { + 'Write rule': 'Generated files are written only after provider text is received and validated.', + 'Web validation': 'HTML tools require a complete HTML document before index.html is replaced.', + 'Atomicity': 'Web artifacts use a temp file followed by rename to avoid partial index.html output.', + }, + ), + _AgentTraceStep( + title: 'Report in chat', + detail: 'Keep the process, generated content, paths, and failure state in this conversation.', + icon: Icons.play_arrow_outlined, + avatarAsset: 'assets/role_avatars/claude-pet-animated-wave.svg', + details: { + 'Chat report': 'The final assistant message includes the selected tool, saved path, and generated content.', + 'Artifact actions': 'Generated artifacts can be opened as code, previewed in WebView, copied, or opened in a browser when HTML.', + }, + ), + ]; +} + +Future _isAndroidPackageInstalled(String packageName) async { + try { + return await _systemToolsChannel.invokeMethod('isPackageInstalled', { + 'packageName': packageName, + }); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } +} + +Future _launchAndroidPackage(String packageName) async { + try { + return await _systemToolsChannel.invokeMethod('launchPackage', { + 'packageName': packageName, + }) ?? + false; + } on MissingPluginException { + return false; + } on PlatformException { + return false; + } +} + +Future<_RootProbeResult?> _probeRootAvailability() async { + try { + final result = await _systemToolsChannel.invokeMethod>('rootProbe'); + if (result == null) return null; + return _RootProbeResult.fromMap(result); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } +} + +Future _startMobileCodeHelperService() async { + try { + return await _systemToolsChannel.invokeMethod('startHelperService'); + } on MissingPluginException { + return null; + } on PlatformException { + return false; + } +} + +String _localTool2048Html() { + return r''' + + + + + MobileCode Agent 2048 + + + +
+
+
+

2048

+

Generated locally by MobileCode Agent

+
+
+
score0
+
best0
+
+
+
+
+ + +
+

Swipe on the board. Merge tiles until 2048.

+
+ + +'''; +} + +class HomeScreen extends StatefulWidget { + const HomeScreen({ + super.key, + this.brandTheme = 'codexBlue', + this.onBrandThemeChanged, + }); + + final String brandTheme; + final ValueChanged? onBrandThemeChanged; + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + static const _baseUrlKey = 'mobilecode.baseUrl'; + static const _apiKeyKey = 'mobilecode.apiKey'; + static const _modelKey = 'mobilecode.model'; + static const _providerModeKey = 'mobilecode.providerMode'; + static const _brandThemeKey = 'mobilecode.brandTheme'; + static const _browserOpenModeKey = 'mobilecode.browserOpenMode'; + + final _scaffoldKey = GlobalKey(); + final _chatPanelKey = GlobalKey<_ChatPanelState>(); + final _baseUrlController = TextEditingController(text: _defaultBaseUrl); + final _apiKeyController = TextEditingController(); + final _modelController = TextEditingController(text: _defaultModel); + + _HealthState _healthState = _HealthState.unknown; + String _healthMessage = 'Not checked'; + bool _saving = false; + _HomeTab _tab = _HomeTab.control; + int _selectedLayerIndex = 0; + bool _showCapabilityMap = false; + bool _runtimeChecking = false; + bool _customProviderOverride = false; + String _brandTheme = 'codexBlue'; + String _browserOpenMode = _browserOpenModeSystem; + bool? _termuxInstalled; + bool? _termuxApiInstalled; + bool? _rootAvailable; + String _runtimeMessage = 'Checking runtime providers...'; + List _runtimeHealth = const []; + RuntimeCapabilities _runtimeCapabilities = RuntimeCapabilities.none; + List<_ChatSession> _drawerSessions = const []; + String? _drawerActiveSessionId; + + late final RuntimeManager _runtimeManager = RuntimeManager.withExternalTermux(TermuxService()); + + final List<_ActivityLog> _activity = [ + _ActivityLog( + title: 'Frontend map loaded', + detail: 'AI, Agents, Code, Remote, Guard, Analytics, Tools, Performance, Team', + icon: Icons.dashboard_customize_outlined, + color: _mint, + time: DateTime.now(), + ), + ]; + final List<_DraftFile> _drafts = []; + final List<_SnippetDraft> _snippets = []; + + List<_CapabilityLayer> get _layers => _capabilityLayers; + + int get _safeLayerIndex { + if (_selectedLayerIndex < 0) return 0; + if (_selectedLayerIndex >= _layers.length) return _layers.length - 1; + return _selectedLayerIndex; + } + + _CapabilityLayer get _activeLayer => _layers[_safeLayerIndex]; + + bool get _managedProviderAvailable => _managedProviderEnabled && _managedApiKey.trim().isNotEmpty; + + bool get _managedProviderActive => _managedProviderAvailable && !_customProviderOverride; + + String get _effectiveBaseUrl => _managedProviderActive ? _managedBaseUrl : _baseUrlController.text.trim(); + + String get _effectiveApiKey => _managedProviderActive ? _managedApiKey : _apiKeyController.text.trim(); + + String get _effectiveModel => _managedProviderActive ? _managedModel : _modelController.text.trim(); + + _ApiFlavor get _flavor { + return _detectApiFlavor(_effectiveBaseUrl, _effectiveModel); + } + + RuntimeHealth? get _bestRuntimeHealth { + final active = _runtimeManager.activeHealth; + if (active != null) return active; + for (final health in _runtimeHealth) { + if (health.ready) return health; + } + return _runtimeHealth.isNotEmpty ? _runtimeHealth.first : null; + } + + String get _activeRuntimeName => _bestRuntimeHealth?.name ?? 'WebView Only'; + + bool get _runtimeReady => _bestRuntimeHealth?.ready == true; + + String get _runtimeDrawerLabel { + final runtime = _bestRuntimeHealth; + final capabilityLabel = _runtimeCapabilityLabel(_runtimeCapabilities); + final fallback = [ + if (_termuxInstalled == true) _termuxApiInstalled == true ? 'External Termux API fallback' : 'External Termux fallback', + if (_rootAvailable == true) 'root keepalive', + ]; + if (runtime == null) { + return fallback.isEmpty ? 'Runtime discovery pending' : fallback.join(' · '); + } + return fallback.isEmpty ? '${runtime.name} · $capabilityLabel' : '${runtime.name} · $capabilityLabel · ${fallback.join(' · ')}'; + } + + @override + void initState() { + super.initState(); + _brandTheme = _normalizeBrandTheme(widget.brandTheme); + _loadConfig(); + unawaited(_checkRuntime(silent: true)); + } + + @override + void didUpdateWidget(covariant HomeScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.brandTheme != widget.brandTheme) { + _brandTheme = _normalizeBrandTheme(widget.brandTheme); + } + } + + @override + void dispose() { + unawaited(_runtimeManager.dispose()); + _baseUrlController.dispose(); + _apiKeyController.dispose(); + _modelController.dispose(); + super.dispose(); + } + + Future _loadConfig() async { + try { + final prefs = await SharedPreferences.getInstance(); if (!mounted) return; setState(() { - _termuxInstalled = termux; - _termuxApiInstalled = termuxApi; - _rootAvailable = root; - _runtimeHealth = runtimeHealth; - _runtimeCapabilities = runtimeCapabilities; - _runtimeMessage = message; - }); - if (!silent) { - final ready = activeRuntime?.ready == true; - _addLog( - ready ? 'Runtime ready' : 'Runtime needs setup', - _runtimeMessage, - ready ? Icons.verified_outlined : Icons.warning_amber_outlined, - ready ? _mint : _amber, + _customProviderOverride = prefs.getString(_providerModeKey) == 'custom'; + _brandTheme = _normalizeBrandTheme( + prefs.getString(_brandThemeKey) ?? widget.brandTheme, ); + _browserOpenMode = _normalizeBrowserOpenMode(prefs.getString(_browserOpenModeKey)); + if (_managedProviderActive) { + _baseUrlController.text = ''; + _apiKeyController.text = ''; + _modelController.text = ''; + } else { + _baseUrlController.text = _savedOrDefault(prefs.getString(_baseUrlKey), _defaultBaseUrl); + _apiKeyController.text = prefs.getString(_apiKeyKey) ?? ''; + _modelController.text = _savedOrDefault(prefs.getString(_modelKey), _defaultModel); + } + }); + } on Object catch (error) { + _addLog('Config load failed', _compact(error.toString(), limit: 120), Icons.error_outline, _rose); + } + } + + void _applyDefaultProvider() { + _applyProviderPreset(_ProviderPreset.mimo); + } + + Future _setProviderMode({required bool useCustom}) async { + if (!useCustom && !_managedProviderAvailable) { + _showMessage('Managed provider is not available in this build'); + return; + } + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_providerModeKey, useCustom ? 'custom' : 'managed'); + if (!mounted) return; + setState(() { + _customProviderOverride = useCustom; + if (useCustom) { + _baseUrlController.text = _savedOrDefault(prefs.getString(_baseUrlKey), _defaultBaseUrl); + _apiKeyController.text = prefs.getString(_apiKeyKey) ?? ''; + _modelController.text = _savedOrDefault(prefs.getString(_modelKey), _defaultModel); + } else { + _baseUrlController.text = ''; + _apiKeyController.text = ''; + _modelController.text = ''; } - } on Object catch (error) { - if (!mounted) return; - setState(() { - _runtimeMessage = _compact(error.toString(), limit: 160); - }); - } finally { - if (mounted) setState(() => _runtimeChecking = false); - } + }); + _addLog( + useCustom ? 'Custom provider enabled' : 'Managed provider enabled', + useCustom ? 'Base URL, API key, and model fields are editable.' : 'Bundled managed provider credentials are active.', + Icons.tune_outlined, + useCustom ? _cyan : _mint, + ); } - String _runtimeStatusMessage( - RuntimeHealth? activeRuntime, - RuntimeCapabilities capabilities, - _RootProbeResult? rootProbe, - ) { - final active = activeRuntime?.name ?? 'No runtime'; - final status = activeRuntime?.status ?? 'No runtime provider responded.'; - final caps = _runtimeCapabilityLabel(capabilities); - final rootDetail = rootProbe?.detail; - final rootSuffix = rootDetail != null && rootProbe?.available != true ? ' $rootDetail' : ''; - return '$active: $status Capabilities: $caps.$rootSuffix'; + Future _setBrandTheme(String value) async { + final normalized = _normalizeBrandTheme(value); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_brandThemeKey, normalized); + if (!mounted) return; + setState(() => _brandTheme = normalized); + widget.onBrandThemeChanged?.call(normalized); + _addLog( + normalized == 'claudeYellow' ? 'Claude Yellow theme enabled' : 'Codex Blue theme enabled', + 'Theme preference saved on this device.', + Icons.palette_outlined, + normalized == 'claudeYellow' ? _amber : _blue, + ); + } + + Future _setBrowserOpenMode(String value) async { + final normalized = _normalizeBrowserOpenMode(value); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_browserOpenModeKey, normalized); + if (!mounted) return; + setState(() => _browserOpenMode = normalized); + _addLog( + 'Browser open mode saved', + _browserOpenModeLabel(normalized), + normalized == _browserOpenModeInApp ? Icons.web_asset_outlined : Icons.open_in_browser_outlined, + normalized == _browserOpenModeInApp ? _violet : _amber, + ); + } + + void _applyProviderPreset(_ProviderPreset preset) { + if (_managedProviderActive) { + _customProviderOverride = true; + unawaited(SharedPreferences.getInstance().then((prefs) => prefs.setString(_providerModeKey, 'custom'))); + } + setState(() { + final presetBaseUrl = _providerPresetBaseUrl(preset); + final presetModel = _providerPresetModel(preset); + if (presetBaseUrl.isNotEmpty) { + _baseUrlController.text = presetBaseUrl; + } + if (presetModel.isNotEmpty) { + _modelController.text = presetModel; + } + }); + final label = _providerPresetLabel(preset); + _addLog( + '$label provider selected', + preset == _ProviderPreset.custom + ? 'Custom mode keeps your current Base URL/model so you can edit them directly.' + : 'Base URL and model filled. API key stays private.', + Icons.tune_outlined, + preset == _ProviderPreset.custom ? _cyan : _mint, + ); + } + + Future _saveConfig() async { + if (_managedProviderActive) { + _showMessage('Switch to Custom provider before saving your own Base URL'); + return; + } + setState(() => _saving = true); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_providerModeKey, 'custom'); + await prefs.setString(_baseUrlKey, _baseUrlController.text.trim()); + await prefs.setString(_apiKeyKey, _apiKeyController.text.trim()); + await prefs.setString(_modelKey, _modelController.text.trim()); + if (!mounted) return; + setState(() => _customProviderOverride = true); + _addLog( + 'API profile saved', + '${_flavorLabel(_flavor)} - ${_effectiveModel.isEmpty ? 'default model' : _effectiveModel}', + Icons.key_outlined, + _mint, + ); + _showMessage('API config saved'); + } on Object catch (error) { + if (!mounted) return; + _addLog('API save failed', _compact(error.toString(), limit: 140), Icons.error_outline, _rose); + _showMessage('API config save failed'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _checkHealth() async { + final baseUrl = _effectiveBaseUrl; + if (baseUrl.isEmpty) { + _showMessage('Set Base URL first'); + return; + } + + final flavor = _flavor; + try { + _parseBaseUrl(baseUrl); + } catch (_) { + _showMessage('Base URL is not valid'); + return; + } + + setState(() { + _healthState = _HealthState.checking; + _healthMessage = 'Checking ${_chatEndpointLabel(baseUrl, flavor)}'; + }); + + final client = HttpClient()..connectionTimeout = const Duration(seconds: 8); + try { + final apiKey = _effectiveApiKey; + final model = _effectiveModel; + late final _ProbeResult result; + + if (flavor == _ApiFlavor.anthropic) { + result = await _probeAnthropic(client, baseUrl, apiKey, model); + } else { + _ProbeResult? lastResult; + for (final probe in _openAiHealthUris(baseUrl)) { + final probeResult = await _probeGet(client, probe, apiKey); + lastResult = probeResult; + if (probeResult.isHealthy) break; + } + result = lastResult!; + } + + if (!mounted) return; + setState(() { + _healthState = result.isHealthy ? _HealthState.healthy : _HealthState.failed; + _healthMessage = result.message; + }); + _addLog( + result.isHealthy ? 'Provider healthy' : 'Provider unhealthy', + result.message, + result.isHealthy ? Icons.check_circle_outline : Icons.error_outline, + result.isHealthy ? _mint : _rose, + ); + } on SocketException catch (error) { + if (!mounted) return; + final message = _friendlySocketError(error); + setState(() { + _healthState = _HealthState.failed; + _healthMessage = message; + }); + _addLog('Health check failed', _compact(message, limit: 140), Icons.error_outline, _rose); + } on Object catch (error) { + if (!mounted) return; + final message = error.toString().replaceFirst('Exception: ', ''); + setState(() { + _healthState = _HealthState.failed; + _healthMessage = message; + }); + _addLog('Health check failed', _compact(message, limit: 140), Icons.error_outline, _rose); + } finally { + client.close(force: true); + } + } + + Future<_ProbeResult> _probeGet(HttpClient client, Uri uri, String apiKey) async { + final started = DateTime.now(); + final request = await client.getUrl(uri).timeout(const Duration(seconds: 8)); + if (apiKey.isNotEmpty) { + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey'); + } + final response = await request.close().timeout(const Duration(seconds: 12)); + await response.drain(); + final ms = DateTime.now().difference(started).inMilliseconds; + return _ProbeResult( + uri: uri, + statusCode: response.statusCode, + latencyMs: ms, + message: '${uri.path} HTTP ${response.statusCode} - ${ms}ms', + ); + } + + Future<_ProbeResult> _probeAnthropic( + HttpClient client, + String baseUrl, + String apiKey, + String model, + ) async { + final uri = _anthropicMessagesUri(baseUrl); + final started = DateTime.now(); + final request = await client.postUrl(uri).timeout(const Duration(seconds: 8)); + request.headers.contentType = ContentType.json; + request.headers.set('anthropic-version', '2023-06-01'); + if (apiKey.isNotEmpty) { + request.headers.set('x-api-key', apiKey); + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey'); + } + request.write(jsonEncode({ + 'model': model.isEmpty ? 'claude-3-5-haiku-latest' : model, + 'max_tokens': 1, + 'messages': [ + {'role': 'user', 'content': 'ping'}, + ], + })); + + final response = await request.close().timeout(const Duration(seconds: 30)); + final body = await utf8.decodeStream(response); + final ms = DateTime.now().difference(started).inMilliseconds; + return _ProbeResult( + uri: uri, + statusCode: response.statusCode, + latencyMs: ms, + message: response.statusCode >= 200 && response.statusCode < 300 + ? '${uri.path} HTTP ${response.statusCode} - ${ms}ms' + : '${uri.path} HTTP ${response.statusCode} - ${_compact(body, limit: 140)}', + ); + } + + Future _polishRoleIntent(String intent) async { + final baseUrl = _effectiveBaseUrl; + final apiKey = _effectiveApiKey; + final model = _effectiveModel; + if (baseUrl.isEmpty) { + throw Exception('Provider is not configured: Base URL is empty.'); + } + if (apiKey.isEmpty) { + throw Exception('Provider is not configured: API key is empty.'); + } + + final flavor = _detectApiFlavor(baseUrl, model); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 12); + final usageStarted = DateTime.now(); + try { + final request = await client + .postUrl(flavor == _ApiFlavor.anthropic ? _anthropicMessagesUri(baseUrl) : _openAiChatUri(baseUrl)) + .timeout(const Duration(seconds: 12)); + request.headers.contentType = ContentType.json; + if (flavor == _ApiFlavor.anthropic) { + request.headers.set('anthropic-version', '2023-06-01'); + request.headers.set('x-api-key', apiKey); + } + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey'); + request.write(jsonEncode(_rolePolishRequestBody(flavor, model, intent))); + + final response = await request.close().timeout(const Duration(seconds: 90)); + final body = await utf8.decodeStream(response); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('AI role polish HTTP ${response.statusCode}: ${_compact(body, limit: 220)}'); + } + final answer = _extractProviderText(body); + if (answer.trim().isEmpty) { + throw Exception('AI role polish returned an empty response.'); + } + await TokenUsageService.instance.recordCompleted( + provider: _flavorLabel(flavor), + model: model, + endpoint: 'role_polish', + durationMs: DateTime.now().difference(usageStarted).inMilliseconds, + success: true, + usage: _providerUsageFromBody(flavor, body), + inputChars: rolePolishSystemPrompt.length + intent.length, + outputChars: answer.length, + ); + return answer; + } on SocketException catch (error) { + await TokenUsageService.instance.recordCompleted( + provider: _flavorLabel(flavor), + model: model, + endpoint: 'role_polish', + durationMs: DateTime.now().difference(usageStarted).inMilliseconds, + success: false, + inputChars: rolePolishSystemPrompt.length + intent.length, + outputChars: 0, + ); + throw Exception('AI role polish network error: ${_friendlySocketError(error)}'); + } on TimeoutException { + await TokenUsageService.instance.recordCompleted( + provider: _flavorLabel(flavor), + model: model, + endpoint: 'role_polish', + durationMs: DateTime.now().difference(usageStarted).inMilliseconds, + success: false, + inputChars: rolePolishSystemPrompt.length + intent.length, + outputChars: 0, + ); + throw TimeoutException('AI role polish timed out while waiting for the provider.'); + } finally { + client.close(force: true); + } + } + + Map _rolePolishRequestBody(_ApiFlavor flavor, String model, String intent) { + final resolvedModel = model.isEmpty + ? (flavor == _ApiFlavor.anthropic ? _defaultModel : 'gpt-4o-mini') + : model; + if (flavor == _ApiFlavor.anthropic) { + return { + 'model': resolvedModel, + 'system': rolePolishSystemPrompt, + 'max_tokens': 1200, + 'temperature': 0.2, + 'messages': [ + {'role': 'user', 'content': intent}, + ], + }; + } + return { + 'model': resolvedModel, + 'temperature': 0.2, + 'messages': [ + {'role': 'system', 'content': rolePolishSystemPrompt}, + {'role': 'user', 'content': intent}, + ], + }; + } + + String _extractProviderText(String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final choices = decoded['choices']; + if (choices is List && choices.isNotEmpty) { + final first = choices.first; + if (first is Map) { + final message = first['message']; + if (message is Map) { + final content = message['content']; + if (content is String && content.trim().isNotEmpty) return content.trim(); + } + final text = first['text']; + if (text is String && text.trim().isNotEmpty) return text.trim(); + } + } + final content = decoded['content']; + if (content is List && content.isNotEmpty) { + final parts = []; + for (final item in content) { + if (item is Map) { + final text = item['text']; + if (text is String && text.trim().isNotEmpty) parts.add(text.trim()); + } + } + if (parts.isNotEmpty) return parts.join('\n\n'); + } + } + } catch (_) { + // Fall back to the compact raw body below. + } + return _compact(body, limit: 1600); + } + + List _openAiHealthUris(String baseUrl) { + final normalized = _normalizedBaseUrl(baseUrl); + final probes = [ + Uri.parse('$normalized/health'), + Uri.parse('$normalized/models'), + ]; + if (!normalized.endsWith('/v1')) { + probes.add(Uri.parse('$normalized/v1/models')); + } + return probes; + } + + Future _checkRuntime({bool silent = false}) async { + if (_runtimeChecking) return; + setState(() { + _runtimeChecking = true; + if (!silent) _runtimeMessage = 'Checking MobileCode runtime providers...'; + }); + try { + if (!silent) { + await _startMobileCodeHelperService(); + } + await _runtimeManager.initialize(); + final runtimeHealth = await _runtimeManager.refresh(); + final runtimeCapabilities = await _runtimeManager.capabilities(); + final activeRuntime = _runtimeManager.activeHealth; + final termux = await _isAndroidPackageInstalled('com.termux'); + final termuxApi = await _isAndroidPackageInstalled('com.termux.api'); + final rootProbe = await _probeRootAvailability(); + final root = rootProbe?.available; + final message = _runtimeStatusMessage(activeRuntime, runtimeCapabilities, rootProbe); + if (!mounted) return; + setState(() { + _termuxInstalled = termux; + _termuxApiInstalled = termuxApi; + _rootAvailable = root; + _runtimeHealth = runtimeHealth; + _runtimeCapabilities = runtimeCapabilities; + _runtimeMessage = message; + }); + if (!silent) { + final ready = activeRuntime?.ready == true; + _addLog( + ready ? 'Runtime ready' : 'Runtime needs setup', + _runtimeMessage, + ready ? Icons.verified_outlined : Icons.warning_amber_outlined, + ready ? _mint : _amber, + ); + } + } on Object catch (error) { + if (!mounted) return; + setState(() { + _runtimeMessage = _compact(error.toString(), limit: 160); + }); + } finally { + if (mounted) setState(() => _runtimeChecking = false); + } + } + + String _runtimeStatusMessage( + RuntimeHealth? activeRuntime, + RuntimeCapabilities capabilities, + _RootProbeResult? rootProbe, + ) { + final active = activeRuntime?.name ?? 'No runtime'; + final status = activeRuntime?.status ?? 'No runtime provider responded.'; + final caps = _runtimeCapabilityLabel(capabilities); + final rootDetail = rootProbe?.detail; + final rootSuffix = rootDetail != null && rootProbe?.available != true ? ' $rootDetail' : ''; + return '$active: $status Capabilities: $caps.$rootSuffix'; + } + + String _runtimeCapabilityLabel(RuntimeCapabilities capabilities) { + final labels = [ + if (capabilities.shell) 'shell', + if (capabilities.git) 'git', + if (capabilities.node) 'node', + if (capabilities.python) 'python', + if (capabilities.flutter) 'flutter', + if (capabilities.androidBuild) 'apk', + if (capabilities.pty) 'pty', + if (capabilities.backgroundService) 'bg', + if (capabilities.cloudBuild) 'cloud', + if (capabilities.webViewPreview) 'webview', + ]; + return labels.isEmpty ? 'webview-only' : labels.join(', '); + } + + void _setTab(_HomeTab tab) { + setState(() { + _tab = tab; + _selectedLayerIndex = switch (tab) { + _HomeTab.control => 0, + _HomeTab.ai => 2, + _HomeTab.ship => 3, + _HomeTab.guard => 4, + _HomeTab.insight => 5, + }; + }); + } + + void _runAction(_ModuleAction action, [_Capability? capability]) { + switch (action) { + case _ModuleAction.aiChat: + _openChatSheet(); + break; + case _ModuleAction.apiConfig: + _showMessage('API configuration is at the top of this screen'); + break; + case _ModuleAction.healthCheck: + _checkHealth(); + break; + case _ModuleAction.webDemo: + _openMobileCodingLabSheet(autoGenerate: true); + break; + case _ModuleAction.githubTest: + _openGitHubTestSheet(); + break; + case _ModuleAction.diary: + _openDiarySheet(); + break; + case _ModuleAction.toolLab: + _openToolLabSheet(); + break; + case _ModuleAction.termuxCheck: + _openTermuxSheet(); + break; + case _ModuleAction.newFile: + _openDraftSheet(); + break; + case _ModuleAction.snippet: + _openSnippetSheet(); + break; + case _ModuleAction.project: + unawaited(_openProjectSheet()); + break; + case _ModuleAction.terminal: + _openCommandSheet(); + break; + case _ModuleAction.deepDive: + unawaited(_openDeepDiveSheet()); + break; + case _ModuleAction.build: + _openBuildSheet(); + break; + case _ModuleAction.githubRepoHub: + unawaited(_openGitHubRepoHub()); + break; + case _ModuleAction.larkCli: + _openLarkCliSheet(); + break; + case _ModuleAction.tokenUsage: + _openManagementScreen('Token Usage', const ApiUsageScreen()); + break; + case _ModuleAction.deviceTelemetry: + _openManagementScreen('Device Telemetry', const DeviceTelemetryScreen()); + break; + case _ModuleAction.downloadsShared: + _openManagementScreen('Downloads / Shared folders', const DownloadsSharedFoldersScreen()); + break; + case _ModuleAction.inspect: + if (capability != null) _openCapabilitySheet(capability); + break; + } + } + + void _openCapabilitySheet(_Capability capability) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _CapabilitySheet( + capability: capability, + onRun: () { + Navigator.pop(context); + _runAction(capability.primaryAction, capability); + }, + onCopy: () { + Clipboard.setData(ClipboardData(text: capability.services.join('\n'))); + _showMessage('Service list copied'); + }, + ), + ); + } + + void _openChatSheet() { + if (_effectiveBaseUrl.isEmpty) { + _showMessage('Configure Base URL first'); + return; + } + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _ChatPanel( + baseUrl: _effectiveBaseUrl, + apiKey: _effectiveApiKey, + model: _effectiveModel, + browserOpenMode: _browserOpenMode, + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + onAgentPrompt: _handleAgentPrompt, + ), + ); + } + + Future _handleAgentPrompt(String prompt) async { + final toolName = _agentToolNameForPrompt(prompt); + _addLog('Agent process stayed in chat', toolName, Icons.psychology_alt_outlined, _violet); + } + + void _openMobileCodingLabSheet({bool autoGenerate = false}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _MobileCodingLabSheet( + autoGenerate: autoGenerate, + onOpenOnlineDemo: () => _openUrl(_demo2048Url, 'published 2048 demo'), + onOpenGitHub: () => _openUrl(_githubTestUrl, 'GitHub test page'), + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + ), + ); + } + + Future _openUrl(String url, String label) async { + try { + final uri = Uri.parse(url); + final opened = await _launchUrlWithBrowserMode(uri, _browserOpenMode); + _addLog( + opened ? 'Opened $label' : 'Failed to open $label', + '$url · ${_browserOpenModeLabel(_browserOpenMode)}', + opened ? Icons.open_in_browser_outlined : Icons.error_outline, + opened ? _mint : _rose, + ); + if (!opened) { + _showMessage('Could not open $label'); + } + } on Object catch (error) { + _addLog('Open URL failed', _compact(error.toString(), limit: 120), Icons.error_outline, _rose); + _showMessage('Could not open $label'); + } + } + + Future _openWorkspaceFile(String path) async { + try { + final file = File(path); + if (!await file.exists()) { + _showMessage('File was not found on this phone'); + return; + } + final code = await file.readAsString(); + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _CodeFileSheet( + path: path, + code: code, + onOpenEditor: () => unawaited(_openWorkspaceFileInEditor(path, initialContent: code)), + ), + ); + } on Object catch (error) { + if (!mounted) return; + _showMessage(_compact(error.toString(), limit: 140)); + } + } + + Future _openWorkspaceFileInEditor(String path, {String? initialContent}) async { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditorScreen( + initialFilePath: path, + initialContent: initialContent, + fileName: p.basename(path), + ), + ), + ); + } + + Future _openWorkspaceFolder(String path) async { + try { + final root = await _mobileCodeProjectsRootDirectory(); + final folder = Directory(path); + if (!await folder.exists()) { + _showMessage('Project folder was not found on this phone'); + return; + } + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _ProjectFolderSheet( + initialPath: folder.path, + workspaceRoot: root.path, + onOpenFile: (filePath) => unawaited(_openWorkspaceFile(filePath)), + ), + ); + } on Object catch (error) { + if (!mounted) return; + _showMessage(_compact(error.toString(), limit: 140)); + } + } + + void _openManagementScreen(String label, Widget screen) { + _addLog('Opened $label', 'Management surface route', Icons.dashboard_customize_outlined, _cyan); + Navigator.of(context).push(MaterialPageRoute(builder: (_) => screen)); + } + + Future _openGitHubRepoHub() async { + _addLog('Opened GitHub Repo Hub', 'Management surface route', Icons.dashboard_customize_outlined, _cyan); + final request = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const GitHubRepoHubScreen()), + ); + if (!mounted || request == null) return; + final chat = await _focusChatPanel(); + if (chat == null) { + _showMessage('Chat panel is still loading'); + return; + } + await chat.createSessionFromShell(); + await chat.bindRepoFromShell(request); + await chat.setPromptFromShell(request.prompt); + _addLog( + 'Repo chat ready', + 'Chat opened for ${request.repoFullName}.', + Icons.chat_bubble_outline, + _blue, + ); + } + + void _openGitHubTestSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _GitHubTestSheet( + onOpenWeb: () => _openUrl(_githubTestUrl, 'GitHub test page'), + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + ), + ); + } + + void _openDiarySheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _DiarySheet( + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + ), + ); + } + + void _openToolLabSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _ToolLabSheet( + baseUrl: _effectiveBaseUrl, + apiKey: _effectiveApiKey, + model: _effectiveModel, + onOpen2048: () => _openMobileCodingLabSheet(autoGenerate: true), + onOpenGitHubWeb: () => _openUrl(_githubTestUrl, 'GitHub test page'), + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + ), + ); + } + + void _openTermuxSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _RuntimeDiagnosticsSheet( + runtimeManager: _runtimeManager, + initialHealth: _runtimeHealth, + initialCapabilities: _runtimeCapabilities, + termuxInstalled: _termuxInstalled, + termuxApiInstalled: _termuxApiInstalled, + rootAvailable: _rootAvailable, + onOpenInstall: () => _openUrl('https://f-droid.org/packages/com.termux/', 'External Termux install page'), + onRefreshParent: () => _checkRuntime(), + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + ), + ); + } + + void _openDraftSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _DraftSheet( + onCreate: (name, language) { + setState(() { + _drafts.insert(0, _DraftFile(name: name, language: language, createdAt: DateTime.now())); + }); + _addLog('File draft created', '$language - $name', Icons.note_add_outlined, _cyan); + }, + ), + ); + } + + void _openSnippetSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _SnippetSheet( + onCreate: (title, language) { + setState(() { + _snippets.insert(0, _SnippetDraft(title: title, language: language, createdAt: DateTime.now())); + }); + _addLog('Snippet captured', '$language - $title', Icons.data_object_outlined, _lime); + }, + ), + ); + } + + Future _openProjectSheet() async { + final workspaceRoot = (await _mobileCodeProjectsRootDirectory()).path; + if (!mounted) return; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _ProjectConsoleSheet( + runtimeManager: _runtimeManager, + defaultProjectPath: workspaceRoot, + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + ), + ); + } + + void _openCommandSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _RuntimeActionsSheet( + icon: Icons.terminal_outlined, + title: 'Runtime Actions', + subtitle: 'Run structured mobile coding actions through the active RuntimeProvider.', + runtimeManager: _runtimeManager, + defaultPackageManager: 'npm', + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + ), + ); + } + + Future _openDeepDiveSheet() async { + final workspaceRoot = (await _mobileCodeProjectsRootDirectory()).path; + if (!mounted) return; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (_) => _DeepDiveConsoleSheet( + runtimeManager: _runtimeManager, + defaultProjectPath: workspaceRoot, + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + onStartInChat: (prompt) { + _addLog('Deep Dive started', 'Prompt sent to Chat Agent with RuntimeManager context', Icons.psychology_alt_outlined, _violet); + unawaited(_usePromptShortcut(prompt, runAgent: true)); + }, + ), + ); + } + + void _openBuildSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _RuntimeActionsSheet( + icon: Icons.rocket_launch_outlined, + title: 'Build and Release Actions', + subtitle: 'Install, test, build preview, commit, and publish through RuntimeManager.', + runtimeManager: _runtimeManager, + defaultPackageManager: 'flutter', + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + ), + ); + } + + void _openLarkCliSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _LarkCliDiagnosticsSheet( + runtimeManager: _runtimeManager, + onOpenDocs: () => _openUrl('https://github.com/larksuite/cli', 'Lark CLI docs'), + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + ), + ); + } + + void _addLog(String title, String detail, IconData icon, Color color) { + setState(() { + _activity.insert( + 0, + _ActivityLog( + title: title, + detail: detail, + icon: icon, + color: color, + time: DateTime.now(), + ), + ); + if (_activity.length > 8) { + _activity.removeLast(); + } + }); + } + + void _showMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + void _syncDrawerSessions(List<_ChatSession> sessions, String? activeSessionId) { + if (!mounted) return; + setState(() { + _drawerSessions = List<_ChatSession>.unmodifiable(sessions); + _drawerActiveSessionId = activeSessionId; + }); + } + + void _openDrawer() { + _scaffoldKey.currentState?.openDrawer(); + } + + Future _closeDrawerIfOpen() async { + final scaffold = _scaffoldKey.currentState; + if (scaffold?.isDrawerOpen == true) { + scaffold?.closeDrawer(); + await Future.delayed(const Duration(milliseconds: 80)); + } + } + + Future<_ChatPanelState?> _focusChatPanel() async { + _setTab(_HomeTab.control); + for (var attempt = 0; attempt < 4; attempt++) { + final state = _chatPanelKey.currentState; + if (state != null) return state; + await Future.delayed(const Duration(milliseconds: 24)); + } + return _chatPanelKey.currentState; + } + + Future _newChatFromDrawer() async { + await _closeDrawerIfOpen(); + final chat = await _focusChatPanel(); + if (chat == null) { + _showMessage('Chat panel is still loading'); + return; + } + await chat.createSessionFromShell(); + _addLog('New chat created', 'A fresh conversation is ready.', Icons.add_comment_outlined, _mint); + } + + Future _selectChatFromDrawer(String id) async { + await _closeDrawerIfOpen(); + final chat = await _focusChatPanel(); + if (chat == null) { + _showMessage('Chat panel is still loading'); + return; + } + await chat.selectSessionFromShell(id); + } + + Future _usePromptShortcut(String prompt, {bool runAgent = false}) async { + await _closeDrawerIfOpen(); + final chat = await _focusChatPanel(); + if (chat == null) { + _showMessage('Chat panel is still loading'); + return; + } + await chat.setPromptFromShell(prompt, runAgent: runAgent); + } + + int get _simpleTabIndex { + return switch (_tab) { + _HomeTab.control => 0, + _HomeTab.ai => 0, + _HomeTab.ship => 1, + _HomeTab.guard => 2, + _HomeTab.insight => 2, + }; + } + + Widget _buildChatTab() { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 14, 6), + child: _MobileChatTopBar( + title: 'MobileCode', + onMenu: _openDrawer, + ), + ), + Expanded( + child: _ChatPanel( + key: _chatPanelKey, + baseUrl: _effectiveBaseUrl, + apiKey: _effectiveApiKey, + model: _effectiveModel, + browserOpenMode: _browserOpenMode, + embedded: true, + onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), + onAgentPrompt: _handleAgentPrompt, + onSessionsChanged: _syncDrawerSessions, + ), + ), + ], + ); + } + + Widget _buildCommandsTab() { + final commands = [ + _CommandShortcut( + icon: Icons.videogame_asset_outlined, + title: '帮我做一个贪吃蛇游戏', + subtitle: '填入提示词,Run Agent 后展示写代码、写文件、预览流程。', + color: _mint, + action: _ModuleAction.aiChat, + ), + _CommandShortcut( + icon: Icons.grid_4x4_outlined, + title: '做 2048 网页小游戏', + subtitle: '生成本地 HTML/CSS/JS,并一键进入 Android WebView 预览。', + color: _cyan, + action: _ModuleAction.webDemo, + ), + _CommandShortcut( + icon: Icons.edit_note_outlined, + title: '做一个最小日记 App', + subtitle: '验证 APK 内本地写入、读取、列表和空状态体验。', + color: _amber, + action: _ModuleAction.diary, + ), + _CommandShortcut( + icon: Icons.note_add_outlined, + title: '新建代码文件', + subtitle: '为移动端工作区创建文件草稿,后续交给 agent 修改。', + color: _violet, + action: _ModuleAction.newFile, + ), + _CommandShortcut( + icon: Icons.data_object_outlined, + title: '保存代码片段', + subtitle: '把常用片段存入本地 snippet 面板。', + color: _lime, + action: _ModuleAction.snippet, + ), + _CommandShortcut( + icon: Icons.psychology_alt_outlined, + title: '深潜一个任务', + subtitle: '显示 agent 的计划、工具调用、观察和完成状态。', + color: _rose, + action: _ModuleAction.deepDive, + ), + ]; + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 20), + cacheExtent: 700, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + _SimpleHeader( + title: 'Create', + subtitle: 'Prompt shortcuts for mobile coding tasks.', + leading: IconButton.filledTonal( + tooltip: 'Open conversations', + onPressed: _openDrawer, + icon: const Icon(Icons.menu_rounded), + ), + ), + const SizedBox(height: 12), + _PromptLaunchPanel(onPrompt: _usePromptShortcut), + const SizedBox(height: 12), + for (final command in commands) ...[ + _CommandShortcutTile( + command: command, + onTap: () { + if (command.title.contains('贪吃蛇')) { + _usePromptShortcut('帮我在手机端创建一个可运行的贪吃蛇网页小游戏,生成 index.html、展示写代码过程,并用 WebView 预览。', runAgent: true); + } else if (command.title.contains('2048')) { + _usePromptShortcut('帮我创建一个 2048 网页小游戏,保存为 index.html,并打开本地 WebView 预览。', runAgent: true); + } else { + _runAction(command.action); + } + }, + ), + const SizedBox(height: 10), + ], + ], + ); + } + + Widget _buildToolsTab() { + final tools = [ + _CommandShortcut( + icon: Icons.handyman_outlined, + title: 'Tool tests', + subtitle: '测试 provider、GitHub、WebView、storage、runtime、root。', + color: _cyan, + action: _ModuleAction.toolLab, + ), + _CommandShortcut( + icon: Icons.terminal_outlined, + title: 'Runtime providers', + subtitle: '检查 Helper、External Termux fallback、root keepalive 和后端状态。', + color: _amber, + action: _ModuleAction.termuxCheck, + ), + _CommandShortcut( + icon: Icons.hub_outlined, + title: 'GitHub test', + subtitle: '填写 GitHub token 后验证 /user、repo、Pages 能否联通。', + color: _violet, + action: _ModuleAction.githubTest, + ), + _CommandShortcut( + icon: Icons.account_tree_outlined, + title: 'GitHub Repo Hub', + subtitle: '列出仓库、关注名单、本机工作区、Pages 和 Actions 状态。', + color: _blue, + action: _ModuleAction.githubRepoHub, + ), + _CommandShortcut( + icon: Icons.folder_shared_outlined, + title: 'Downloads / Shared folders', + subtitle: '统一查看 Actions artifact 下载和 Runtime 同步共享目录。', + color: _mint, + action: _ModuleAction.downloadsShared, + ), + _CommandShortcut( + icon: Icons.business_center_outlined, + title: 'Lark CLI connector', + subtitle: '受控检测 lark-cli、auth status 和推荐登录命令。', + color: _lime, + action: _ModuleAction.larkCli, + ), + _CommandShortcut( + icon: Icons.token_outlined, + title: 'Token Usage', + subtitle: '查看 provider token、cache hit、估算费用和最近 run。', + color: _violet, + action: _ModuleAction.tokenUsage, + ), + _CommandShortcut( + icon: Icons.speed_outlined, + title: 'Device telemetry', + subtitle: '手机 CPU、RAM、存储、电量、温度和 App 内存采样。', + color: _cyan, + action: _ModuleAction.deviceTelemetry, + ), + _CommandShortcut( + icon: Icons.rocket_launch_outlined, + title: 'Build / release', + subtitle: '查看 GitHub Release、APK、iOS simulator 和 smoke report。', + color: _rose, + action: _ModuleAction.build, + ), + ]; + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 20), + cacheExtent: 700, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + _SimpleHeader( + title: 'Tools', + subtitle: 'Phone runtime, backend bridge, GitHub, and release checks.', + leading: IconButton.filledTonal( + tooltip: 'Open conversations', + onPressed: _openDrawer, + icon: const Icon(Icons.menu_rounded), + ), + ), + const SizedBox(height: 12), + _RuntimePermissionBanner( + activeRuntimeName: _activeRuntimeName, + ready: _runtimeReady, + capabilitiesLabel: _runtimeCapabilityLabel(_runtimeCapabilities), + checking: _runtimeChecking, + message: _runtimeMessage, + onCheck: () => _checkRuntime(), + onOpenRuntime: _openTermuxSheet, + ), + const SizedBox(height: 12), + _ManagementSurfacePanel( + onOpenAgent: () => _openManagementScreen('Agent Manager', const AgentDashboardScreen()), + onOpenRoles: () => _openManagementScreen( + 'Role Library', + RoleManagerScreen(onPolishRoleIntent: _polishRoleIntent), + ), + onOpenSkills: () => _openManagementScreen('Skill Manager', const SkillManagerScreen()), + onOpenMcp: () => _openManagementScreen('MCP Manager', const McpManagerScreen()), + onOpenMemory: () => _openManagementScreen('Memory Manager', const MemoryManagerScreen()), + onOpenHooks: () => _openManagementScreen('Hook Registry', const HookRegistryScreen()), + onOpenUsage: () => _openManagementScreen('Token Usage', const ApiUsageScreen()), + onOpenDevice: () => _openManagementScreen('Device Telemetry', const DeviceTelemetryScreen()), + ), + const SizedBox(height: 12), + for (final tool in tools) ...[ + _CommandShortcutTile( + command: tool, + onTap: () => _runAction(tool.action), + ), + const SizedBox(height: 10), + ], + ], + ); + } + + Widget _buildSettingsTab() { + return ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 20), + cacheExtent: 700, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + _SimpleHeader( + title: 'Settings', + subtitle: 'Runtime, model, release, and advanced capability surfaces.', + leading: IconButton.filledTonal( + tooltip: 'Open conversations', + onPressed: _openDrawer, + icon: const Icon(Icons.menu_rounded), + ), + ), + const SizedBox(height: 12), + _ApiConfigCard( + baseUrlController: _baseUrlController, + apiKeyController: _apiKeyController, + modelController: _modelController, + saving: _saving, + flavor: _flavor, + providerPreset: _detectProviderPreset(_effectiveBaseUrl, _effectiveModel), + managedProviderAvailable: _managedProviderAvailable, + managedProviderActive: _managedProviderActive, + onPreset: _applyDefaultProvider, + onProviderPreset: _applyProviderPreset, + onUseManagedProvider: () => unawaited(_setProviderMode(useCustom: false)), + onUseCustomProvider: () => unawaited(_setProviderMode(useCustom: true)), + onSave: _saveConfig, + onHealth: _checkHealth, + ), + const SizedBox(height: 12), + _HealthCard( + state: _healthState, + message: _healthMessage, + flavor: _flavor, + onCheck: _checkHealth, + ), + const SizedBox(height: 12), + _ThemePreferenceCard( + selectedTheme: _brandTheme, + onChanged: (value) => unawaited(_setBrandTheme(value)), + ), + const SizedBox(height: 12), + _BrowserOpenPreferenceCard( + selectedMode: _browserOpenMode, + onChanged: (value) => unawaited(_setBrowserOpenMode(value)), + ), + const SizedBox(height: 12), + _WorkspaceRootCard( + onOpenFolder: (path) => unawaited(_openWorkspaceFolder(path)), + ), + const SizedBox(height: 12), + _SideloadStatusPanel( + managedProviderActive: _managedProviderActive, + onOpenRelease: () => _openUrl(_releaseUrl, 'GitHub Release'), + onOpenAndroidReport: () => _openUrl(_androidSmokeRunUrl, 'Android smoke report'), + onOpenIosReport: () => _openUrl(_iosSimulatorRunUrl, 'iOS simulator report'), + ), + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + const Icon(Icons.account_tree_outlined, color: _cyan), + const SizedBox(width: 10), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Advanced backend map', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), + SizedBox(height: 2), + Text('Hidden by default. Keep the product chat-first.', style: TextStyle(color: _muted, fontSize: 12)), + ], + ), + ), + TextButton.icon( + onPressed: () => setState(() => _showCapabilityMap = !_showCapabilityMap), + icon: Icon(_showCapabilityMap ? Icons.expand_less_outlined : Icons.expand_more_outlined), + label: Text(_showCapabilityMap ? 'Hide' : 'Show'), + ), + ], + ), + ), + if (_showCapabilityMap) ...[ + const SizedBox(height: 14), + _LayerSelector( + layers: _layers, + selectedIndex: _safeLayerIndex, + onSelected: (index) => setState(() => _selectedLayerIndex = index), + ), + const SizedBox(height: 12), + _LayerHeader(layer: _activeLayer), + const SizedBox(height: 10), + for (final capability in _activeLayer.capabilities) ...[ + _CapabilityCard( + capability: capability, + layerColor: _activeLayer.color, + onRun: () => _runAction(capability.primaryAction, capability), + onInspect: () => _openCapabilitySheet(capability), + ), + const SizedBox(height: 10), + ], + ], + const SizedBox(height: 14), + _OperationsBoard( + activity: _activity, + drafts: _drafts, + snippets: _snippets, + healthState: _healthState, + layerCount: _layers.length, + serviceCount: _layers.fold(0, (sum, layer) => sum + layer.serviceCount), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; + return Scaffold( + key: _scaffoldKey, + resizeToAvoidBottomInset: true, + backgroundColor: _bg, + drawer: _MobileCodeDrawer( + sessions: _drawerSessions, + activeSessionId: _drawerActiveSessionId, + runtimeReady: _runtimeReady, + runtimeLabel: _runtimeDrawerLabel, + onNewChat: _newChatFromDrawer, + onSelectSession: _selectChatFromDrawer, + onPrompt: _usePromptShortcut, + onOpenSettings: () { + Navigator.of(context).pop(); + _setTab(_HomeTab.guard); + }, + onOpenTools: () { + Navigator.of(context).pop(); + _setTab(_HomeTab.ship); + }, + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: IndexedStack( + index: _simpleTabIndex, + children: [ + _buildChatTab(), + _buildToolsTab(), + _buildSettingsTab(), + ], + ), + ), + if (!keyboardOpen) _BottomNav(tab: _tab, onChanged: _setTab), + ], + ), + ), + ); + } +} + +class _TopBar extends StatelessWidget { + const _TopBar({ + required this.healthState, + required this.flavor, + required this.onChat, + }); + + final _HealthState healthState; + final _ApiFlavor flavor; + final VoidCallback onChat; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: _panel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _line), + ), + child: const Icon(Icons.code_rounded, color: _mint), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'MobileCode', + style: TextStyle(color: _text, fontSize: 28, fontWeight: FontWeight.w700), + ), + SizedBox(height: 2), + Text( + 'Phone-native AI coding harness', + style: TextStyle(color: _muted, fontSize: 13), + ), + ], + ), + ), + Tooltip( + message: 'Open AI Chat', + child: IconButton.filledTonal( + onPressed: onChat, + icon: const Icon(Icons.forum_outlined), + ), + ), + ], + ); + } +} + +class _SimpleHeader extends StatelessWidget { + const _SimpleHeader({ + required this.title, + required this.subtitle, + this.leading, + this.trailing, + }); + + final String title; + final String subtitle; + final Widget? leading; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + leading ?? + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: _panel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _line), + ), + child: const Icon(Icons.code_rounded, color: _mint), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(color: _text, fontSize: 24, fontWeight: FontWeight.w900)), + const SizedBox(height: 2), + Text(subtitle, style: const TextStyle(color: _muted, fontSize: 12)), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 10), + trailing!, + ], + ], + ); + } +} + +class _MobileChatTopBar extends StatelessWidget { + const _MobileChatTopBar({ + required this.title, + required this.onMenu, + }); + + final String title; + final VoidCallback onMenu; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + IconButton( + tooltip: 'Open conversations', + onPressed: onMenu, + icon: const Icon(Icons.menu_rounded, color: _text, size: 28), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 20, fontWeight: FontWeight.w900), + ), + ), + const SizedBox(width: 6), + const _CpuTelemetryChip(), + ], + ); + } +} + +class _CpuTelemetryChip extends StatefulWidget { + const _CpuTelemetryChip(); + + @override + State<_CpuTelemetryChip> createState() => _CpuTelemetryChipState(); +} + +class _CpuTelemetryChipState extends State<_CpuTelemetryChip> { + late final Stream _stream; + + @override + void initState() { + super.initState(); + _stream = DeviceTelemetryService.instance.watchTelemetry(interval: const Duration(seconds: 2)); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _stream, + builder: (context, snapshot) { + final data = snapshot.data; + final label = data == null ? 'CPU --' : '${data.cpuUsagePercent.clamp(0, 100).toStringAsFixed(0)}%'; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: data == null ? null : () => _showTelemetryDetails(context, data), + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: _mint.withOpacity(0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _mint.withOpacity(0.34)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.speed_outlined, size: 13, color: _mint), + const SizedBox(width: 4), + Text(label, style: const TextStyle(color: _mint, fontSize: 11, fontWeight: FontWeight.w900)), + ], + ), + ), + ), + ); + }, + ); + } + + void _showTelemetryDetails(BuildContext context, DeviceTelemetrySnapshot data) { + final deviceLabel = [data.manufacturer, data.model] + .where((item) => item.trim().isNotEmpty) + .join(' ') + .trim(); + showModalBottomSheet( + context: context, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) => SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 42, + height: 4, + decoration: BoxDecoration(color: _line, borderRadius: BorderRadius.circular(99)), + ), + ), + const SizedBox(height: 14), + const Row( + children: [ + Icon(Icons.speed_outlined, color: _mint, size: 18), + SizedBox(width: 8), + Text('Device telemetry', style: TextStyle(color: _text, fontSize: 18, fontWeight: FontWeight.w900)), + ], + ), + const SizedBox(height: 12), + _TelemetryDetailRow(label: 'CPU', value: '${data.cpuUsagePercent.clamp(0, 100).toStringAsFixed(0)}% · ${data.cpuCores} cores'), + _TelemetryDetailRow(label: 'Memory', value: data.totalMemoryMb > 0 ? '${data.availableMemoryMb} MB free / ${data.totalMemoryMb} MB' : 'Fallback unavailable'), + _TelemetryDetailRow(label: 'App RSS / Heap', value: '${data.appRssMb} MB / ${data.appHeapMb} MB'), + _TelemetryDetailRow(label: 'Storage', value: data.storageTotalMb > 0 ? '${data.storageFreeMb} MB free / ${data.storageTotalMb} MB' : 'Unavailable'), + _TelemetryDetailRow( + label: 'Battery', + value: data.batteryLevel >= 0 + ? '${data.batteryLevel}%${data.batteryCharging ? ' · charging' : ''}${data.batteryTemperatureC > 0 ? ' · ${data.batteryTemperatureC.toStringAsFixed(1)}°C' : ''}' + : 'Unavailable', + ), + _TelemetryDetailRow(label: 'Device', value: deviceLabel.isEmpty ? data.platform : deviceLabel), + if (data.fallback) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'Using Flutter fallback telemetry. Android native telemetry is unavailable in this build/runtime.', + style: TextStyle(color: _amber, fontSize: 12, height: 1.35), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _TelemetryDetailRow extends StatelessWidget { + const _TelemetryDetailRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 96, + child: Text(label, style: const TextStyle(color: _muted, fontSize: 12, fontWeight: FontWeight.w700)), + ), + Expanded( + child: Text(value, style: const TextStyle(color: _text, fontSize: 12, height: 1.3)), + ), + ], + ), + ); + } +} + +class _MobileCodeDrawer extends StatelessWidget { + const _MobileCodeDrawer({ + required this.sessions, + required this.activeSessionId, + required this.runtimeReady, + required this.runtimeLabel, + required this.onNewChat, + required this.onSelectSession, + required this.onPrompt, + required this.onOpenSettings, + required this.onOpenTools, + }); + + final List<_ChatSession> sessions; + final String? activeSessionId; + final bool runtimeReady; + final String runtimeLabel; + final VoidCallback onNewChat; + final ValueChanged onSelectSession; + final Future Function(String prompt, {bool runAgent}) onPrompt; + final VoidCallback onOpenSettings; + final VoidCallback onOpenTools; + + @override + Widget build(BuildContext context) { + final runtimeColor = runtimeReady ? _mint : _amber; + + return Drawer( + width: MediaQuery.of(context).size.width.clamp(280, 360).toDouble(), + backgroundColor: _bg, + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 14, 14, 10), + child: Row( + children: [ + const Expanded( + child: Text('MobileCode', style: TextStyle(color: _text, fontSize: 24, fontWeight: FontWeight.w900)), + ), + IconButton.filledTonal( + tooltip: 'New chat', + onPressed: onNewChat, + icon: const Icon(Icons.edit_square), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _Panel( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.admin_panel_settings_outlined, color: runtimeColor, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(runtimeLabel, style: const TextStyle(color: _muted, fontSize: 12, height: 1.3)), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + _DrawerAction( + icon: Icons.add_comment_outlined, + label: '新会话', + onTap: onNewChat, + ), + _DrawerAction( + icon: Icons.videogame_asset_outlined, + label: '帮我做贪吃蛇游戏', + onTap: () => onPrompt('帮我在手机端创建一个可运行的贪吃蛇网页小游戏,生成 index.html、展示写代码过程,并用 WebView 预览。', runAgent: true), + ), + _DrawerAction( + icon: Icons.handyman_outlined, + label: '工具与权限', + onTap: onOpenTools, + ), + _DrawerAction( + icon: Icons.tune_outlined, + label: '模型与设置', + onTap: onOpenSettings, + ), + const Padding( + padding: EdgeInsets.fromLTRB(18, 18, 18, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text('Recent chats', style: TextStyle(color: _faint, fontSize: 12, fontWeight: FontWeight.w900)), + ), + ), + Expanded( + child: sessions.isEmpty + ? Padding( + padding: EdgeInsets.all(18), + child: Text( + activeSessionId == null ? 'Loading chat history...' : 'No chat history yet', + style: const TextStyle(color: _muted), + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 12), + itemCount: sessions.length, + itemBuilder: (context, index) { + final session = sessions[index]; + return _DrawerSessionTile( + session: session, + selected: session.id == activeSessionId, + onTap: () => onSelectSession(session.id), + ); + }, + ), + ), + Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: _line)), + ), + child: Row( + children: [ + const CircleAvatar( + radius: 16, + backgroundColor: _panelSoft, + child: Icon(Icons.person_outline, color: _mint, size: 18), + ), + const SizedBox(width: 10), + const Expanded( + child: Text('Local user', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), + ), + IconButton( + tooltip: 'Settings', + onPressed: onOpenSettings, + icon: const Icon(Icons.settings_outlined, color: _muted), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _DrawerAction extends StatelessWidget { + const _DrawerAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon, color: _text), + title: Text(label, style: const TextStyle(color: _text, fontWeight: FontWeight.w800)), + onTap: onTap, + minLeadingWidth: 26, + ); + } +} + +class _DrawerSessionTile extends StatelessWidget { + const _DrawerSessionTile({ + required this.session, + required this.selected, + required this.onTap, + }); + + final _ChatSession session; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + selected: selected, + selectedTileColor: _mint.withOpacity(0.10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + leading: CircleAvatar( + radius: 15, + backgroundColor: selected ? _mint.withOpacity(0.18) : _panelSoft, + child: Icon(Icons.chat_bubble_outline, color: selected ? _mint : _muted, size: 16), + ), + title: Text( + session.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: selected ? _text : _muted, fontWeight: FontWeight.w800), + ), + subtitle: Text(_sessionTurnLabel(session), style: const TextStyle(color: _faint, fontSize: 11)), + onTap: onTap, + ); + } +} + +class _RuntimePermissionBanner extends StatelessWidget { + const _RuntimePermissionBanner({ + required this.activeRuntimeName, + required this.ready, + required this.capabilitiesLabel, + required this.checking, + required this.message, + required this.onCheck, + required this.onOpenRuntime, + }); + + final String activeRuntimeName; + final bool ready; + final String capabilitiesLabel; + final bool checking; + final String message; + final VoidCallback onCheck; + final VoidCallback onOpenRuntime; + + @override + Widget build(BuildContext context) { + final color = ready ? _mint : _amber; + final title = ready ? 'Runtime ready' : 'Runtime setup needed'; + final statusLine = '$title · $activeRuntimeName · $capabilitiesLabel'; + return _Panel( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Icon(ready ? Icons.verified_outlined : Icons.hub_outlined, color: color, size: 20), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + statusLine, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 13), + ), + const SizedBox(height: 2), + Text( + message, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 11, height: 1.2), + ), + ], + ), + ), + const SizedBox(width: 6), + IconButton( + tooltip: 'Open runtime diagnostics', + visualDensity: VisualDensity.compact, + onPressed: onOpenRuntime, + icon: Icon(Icons.monitor_heart_outlined, color: _violet, size: 18), + ), + IconButton( + tooltip: 'Check runtime', + visualDensity: VisualDensity.compact, + onPressed: checking ? null : onCheck, + icon: checking + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.refresh_outlined, size: 18), + ), + ], + ), + ); + } +} + +class _ThemePreferenceCard extends StatelessWidget { + const _ThemePreferenceCard({ + required this.selectedTheme, + required this.onChanged, + }); + + final String selectedTheme; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selected = selectedTheme == 'claudeYellow' ? 'claudeYellow' : 'codexBlue'; + return _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: _blue.withOpacity(0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _line), + ), + child: const Icon(Icons.palette_outlined, color: _blue, size: 20), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '视觉主题', + style: TextStyle( + color: _text, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + SizedBox(height: 3), + Text( + 'Codex Blue 默认适合构建,Claude Yellow 适合阅读和复盘。', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _ThemeChoiceChip( + id: 'codexBlue', + label: 'Codex Blue', + colors: const [Color(0xFF2555FF), Color(0xFF16B9C7)], + selected: selected == 'codexBlue', + onTap: onChanged, + ), + _ThemeChoiceChip( + id: 'claudeYellow', + label: 'Claude Yellow', + colors: const [Color(0xFFD97706), Color(0xFFFFB86B)], + selected: selected == 'claudeYellow', + onTap: onChanged, + ), + ], + ), + ], + ), + ); + } +} + +class _BrowserOpenPreferenceCard extends StatelessWidget { + const _BrowserOpenPreferenceCard({ + required this.selectedMode, + required this.onChanged, + }); + + final String selectedMode; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selected = _normalizeBrowserOpenMode(selectedMode); + return _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: _amber.withOpacity(0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _line), + ), + child: const Icon(Icons.open_in_browser_outlined, color: _amber, size: 20), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '浏览器打开方式', + style: TextStyle(color: _text, fontSize: 16, fontWeight: FontWeight.w900), + ), + SizedBox(height: 3), + Text( + '网页链接可用系统默认浏览器或 App 内浏览器;本地 HTML 仍可用 WebView 预览。', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _ThemeChoiceChip( + id: _browserOpenModeSystem, + label: '系统默认浏览器', + colors: const [Color(0xFFB7791F), Color(0xFFFFB86B)], + selected: selected == _browserOpenModeSystem, + onTap: onChanged, + ), + _ThemeChoiceChip( + id: _browserOpenModeInApp, + label: 'App 内浏览器', + colors: const [Color(0xFF7557E8), Color(0xFF16B9C7)], + selected: selected == _browserOpenModeInApp, + onTap: onChanged, + ), + ], + ), + ], + ), + ); + } +} + +class _WorkspaceRootCard extends StatelessWidget { + const _WorkspaceRootCard({required this.onOpenFolder}); + + final ValueChanged onOpenFolder; + + Future _copyPath(BuildContext context, String path) async { + await Clipboard.setData(ClipboardData(text: path)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Workspace path copied.'))); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _mobileCodeProjectsRootDirectory(), + builder: (context, snapshot) { + final path = snapshot.data?.path ?? 'Preparing MobileCode workspace...'; + return _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: _mint.withOpacity(0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _line), + ), + child: const Icon(Icons.folder_special_outlined, color: _mint, size: 20), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('MobileCode Projects', style: TextStyle(color: _text, fontSize: 16, fontWeight: FontWeight.w900)), + SizedBox(height: 3), + Text( + 'Generated pages and GitHub clone/import projects should live under this app-owned workspace.', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + SelectableText( + path, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: snapshot.hasData ? () => onOpenFolder(snapshot.data!.path) : null, + icon: const Icon(Icons.folder_open_outlined, size: 16), + label: const Text('打开工程文件夹'), + ), + OutlinedButton.icon( + onPressed: snapshot.hasData ? () => unawaited(_copyPath(context, snapshot.data!.path)) : null, + icon: const Icon(Icons.copy_outlined, size: 16), + label: const Text('复制路径'), + ), + ], + ), + ], + ), + ); + }, + ); + } +} + +class _ThemeChoiceChip extends StatelessWidget { + const _ThemeChoiceChip({ + required this.id, + required this.label, + required this.colors, + required this.selected, + required this.onTap, + }); + + final String id; + final String label; + final List colors; + final bool selected; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final primary = colors.first; + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => onTap(id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: selected ? primary.withOpacity(0.10) : _panel, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: selected ? primary.withOpacity(0.55) : _line), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient(colors: colors), + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: primary.withOpacity(0.22), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + ), + const SizedBox(width: 9), + Text( + label, + style: TextStyle( + color: selected ? primary : _text, + fontSize: 13, + fontWeight: FontWeight.w900, + ), + ), + if (selected) ...[ + const SizedBox(width: 7), + Icon(Icons.check_circle, color: primary, size: 16), + ], + ], + ), + ), + ); + } +} + +class _SideloadStatusPanel extends StatelessWidget { + const _SideloadStatusPanel({ + required this.managedProviderActive, + required this.onOpenRelease, + required this.onOpenAndroidReport, + required this.onOpenIosReport, + }); + + final bool managedProviderActive; + final VoidCallback onOpenRelease; + final VoidCallback onOpenAndroidReport; + final VoidCallback onOpenIosReport; + + @override + Widget build(BuildContext context) { + return _Panel( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: _mint.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _mint.withOpacity(0.34)), + ), + child: const Icon(Icons.verified_outlined, color: _mint, size: 19), + ), + const SizedBox(width: 10), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('GitHub install build', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15)), + SizedBox(height: 2), + Text(_releaseBuildLabel, style: TextStyle(color: _muted, fontSize: 12)), + ], + ), + ), + FilledButton.icon( + onPressed: onOpenRelease, + icon: const Icon(Icons.download_outlined, size: 18), + label: const Text('Release'), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _StatusActionChip( + label: 'Android smoke passed', + icon: Icons.android_outlined, + color: _mint, + onTap: onOpenAndroidReport, + ), + _StatusActionChip( + label: 'iOS simulator passed', + icon: Icons.phone_iphone_outlined, + color: _cyan, + onTap: onOpenIosReport, + ), + _StatusActionChip( + label: managedProviderActive ? 'Managed model active' : 'Bring your key', + icon: managedProviderActive ? Icons.lock_outline : Icons.key_outlined, + color: managedProviderActive ? _amber : _faint, + onTap: onOpenRelease, + ), + ], + ), + ], + ), + ); + } +} + +class _StatusActionChip extends StatelessWidget { + const _StatusActionChip({ + required this.label, + required this.icon, + required this.color, + required this.onTap, + }); + + final String label; + final IconData icon; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: color.withOpacity(0.09), + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 7), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.28)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 15), + const SizedBox(width: 6), + Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800)), + ], + ), + ), + ), + ); + } +} + +class _FocusPanel extends StatelessWidget { + const _FocusPanel({ + required this.tab, + required this.healthState, + required this.onPrimary, + required this.onSecondary, + }); + + final _HomeTab tab; + final _HealthState healthState; + final VoidCallback onPrimary; + final VoidCallback onSecondary; + + @override + Widget build(BuildContext context) { + final accent = _focusColor(tab); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: accent.withOpacity(0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: accent.withOpacity(0.30)), + ), + child: Row( + children: [ + Icon(_focusIcon(tab), color: accent, size: 26), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + _focusTitle(tab), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 16, fontWeight: FontWeight.w900), + ), + ), + _MiniChip(label: _focusHealthLabel(healthState), color: _healthColor(healthState)), + ], + ), + const SizedBox(height: 4), + Text( + _focusSubtitle(tab), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.32), + ), + ], + ), + ), + const SizedBox(width: 10), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton.filledTonal( + tooltip: _focusPrimaryLabel(tab), + onPressed: onPrimary, + icon: Icon(_focusPrimaryIcon(tab), size: 18), + ), + const SizedBox(height: 6), + IconButton.outlined( + tooltip: _focusSecondaryLabel(tab), + onPressed: onSecondary, + icon: Icon(_focusSecondaryIcon(tab), size: 18), + ), + ], + ), + ], + ), + ); + } +} + +class _ApiConfigCard extends StatelessWidget { + const _ApiConfigCard({ + required this.baseUrlController, + required this.apiKeyController, + required this.modelController, + required this.saving, + required this.flavor, + required this.providerPreset, + required this.managedProviderAvailable, + required this.managedProviderActive, + required this.onPreset, + required this.onProviderPreset, + required this.onUseManagedProvider, + required this.onUseCustomProvider, + required this.onSave, + required this.onHealth, + }); + + final TextEditingController baseUrlController; + final TextEditingController apiKeyController; + final TextEditingController modelController; + final bool saving; + final _ApiFlavor flavor; + final _ProviderPreset providerPreset; + final bool managedProviderAvailable; + final bool managedProviderActive; + final VoidCallback onPreset; + final ValueChanged<_ProviderPreset> onProviderPreset; + final VoidCallback onUseManagedProvider; + final VoidCallback onUseCustomProvider; + final VoidCallback onSave; + final VoidCallback onHealth; + + @override + Widget build(BuildContext context) { + return _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.tune_outlined, color: _mint), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'API Configuration', + style: TextStyle(color: _text, fontSize: 18, fontWeight: FontWeight.w700), + ), + ), + if (!managedProviderActive) ...[ + Tooltip( + message: 'Use Mimo Anthropic preset', + child: IconButton.filledTonal( + onPressed: onPreset, + icon: const Icon(Icons.auto_fix_high_outlined, size: 18), + ), + ), + const SizedBox(width: 8), + ], + _Pill( + label: _flavorLabel(flavor), + icon: flavor == _ApiFlavor.anthropic ? Icons.hub_outlined : Icons.api_outlined, + color: flavor == _ApiFlavor.anthropic ? _amber : _cyan, + ), + ], + ), + const SizedBox(height: 14), + if (managedProviderActive) ...[ + const _InlineStatus( + icon: Icons.admin_panel_settings_outlined, + label: 'Managed debug provider active - credentials are hidden in the UI.', + color: _mint, + ), + const SizedBox(height: 10), + const Text( + 'MobileCode is using bundled managed credentials by default. You can switch to Custom Provider when you need your own Base URL, key, or model.', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: onUseCustomProvider, + icon: const Icon(Icons.tune_outlined), + label: const Text('Use Custom'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: onHealth, + icon: const Icon(Icons.monitor_heart_outlined), + label: const Text('Check Managed'), + ), + ), + ], + ), + ] else ...[ + if (managedProviderAvailable) ...[ + _InlineStatus( + icon: Icons.tune_outlined, + label: 'Custom provider override is active. Managed provider remains available as fallback.', + color: _cyan, + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: onUseManagedProvider, + icon: const Icon(Icons.admin_panel_settings_outlined), + label: const Text('Use Managed Provider'), + ), + const SizedBox(height: 12), + ], + const Text( + 'Provider', + style: TextStyle(color: _muted, fontSize: 12, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final preset in _ProviderPreset.values) + ChoiceChip( + label: Text(_providerPresetLabel(preset)), + selected: providerPreset == preset, + onSelected: (_) => onProviderPreset(preset), + avatar: Icon( + preset == _ProviderPreset.custom + ? Icons.tune_outlined + : preset == _ProviderPreset.openAi + ? Icons.api_outlined + : Icons.hub_outlined, + size: 16, + color: providerPreset == preset ? _blue : _muted, + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: baseUrlController, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Base URL', + hintText: _defaultBaseUrl, + prefixIcon: Icon(Icons.link_outlined), + ), + ), + const SizedBox(height: 10), + TextField( + controller: apiKeyController, + obscureText: true, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'API Key', + hintText: 'sk-... or provider token', + prefixIcon: Icon(Icons.key_outlined), + ), + ), + const SizedBox(height: 10), + TextField( + controller: modelController, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Model', + hintText: _defaultModel, + prefixIcon: Icon(Icons.memory_outlined), + ), + ), + const SizedBox(height: 8), + const Text( + 'Choose Custom for any OpenAI-compatible or Anthropic-compatible endpoint. Paste your API key locally; it is never shipped inside the APK.', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: saving ? null : onSave, + icon: saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(saving ? 'Saving' : 'Save'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: onHealth, + icon: const Icon(Icons.monitor_heart_outlined), + label: const Text('Check'), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _HealthCard extends StatelessWidget { + const _HealthCard({ + required this.state, + required this.message, + required this.flavor, + required this.onCheck, + }); + + final _HealthState state; + final String message; + final _ApiFlavor flavor; + final VoidCallback onCheck; + + @override + Widget build(BuildContext context) { + final color = switch (state) { + _HealthState.healthy => _mint, + _HealthState.failed => _rose, + _HealthState.checking => _amber, + _HealthState.unknown => _faint, + }; + final label = switch (state) { + _HealthState.healthy => 'Healthy', + _HealthState.failed => 'Unhealthy', + _HealthState.checking => 'Checking', + _HealthState.unknown => 'Unknown', + }; + return _Panel( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + boxShadow: [BoxShadow(color: color.withOpacity(0.35), blurRadius: 12)], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Provider Health - $label', + style: const TextStyle(color: _text, fontWeight: FontWeight.w700, fontSize: 15), + ), + const SizedBox(height: 4), + Text( + message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 12), + ), + ], + ), + ), + const SizedBox(width: 10), + _Pill( + label: _flavorLabel(flavor), + icon: Icons.route_outlined, + color: flavor == _ApiFlavor.anthropic ? _amber : _cyan, + ), + const SizedBox(width: 4), + Tooltip( + message: 'Run health check', + child: IconButton( + onPressed: state == _HealthState.checking ? null : onCheck, + icon: const Icon(Icons.refresh_outlined), + ), + ), + ], + ), + ); + } +} + +class _DemoLabPanel extends StatelessWidget { + const _DemoLabPanel({ + required this.onOpen2048, + required this.onGitHub, + required this.onDiary, + required this.onChat, + required this.onTools, + required this.onTermux, + }); + + final VoidCallback onOpen2048; + final VoidCallback onGitHub; + final VoidCallback onDiary; + final VoidCallback onChat; + final VoidCallback onTools; + final VoidCallback onTermux; + + @override + Widget build(BuildContext context) { + return _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.science_outlined, color: _mint), + const SizedBox(width: 10), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Demo Lab', style: TextStyle(color: _text, fontSize: 20, fontWeight: FontWeight.w900)), + SizedBox(height: 2), + Text('The focused path: play, connect GitHub, build diary, chat with memory, test tools, check runtime.', style: TextStyle(color: _muted, fontSize: 12, height: 1.35)), + ], + ), + ), + _Pill(label: 'Priority', icon: Icons.flag_outlined, color: _amber), + ], + ), + const SizedBox(height: 14), + _HeroDemoTile( + title: 'Agent codes 2048', + subtitle: 'Generate a real local HTML/CSS/JS project on the phone, save it, then preview it inside MobileCode WebView.', + icon: Icons.grid_4x4_outlined, + color: _mint, + primaryLabel: 'Code + preview', + secondaryLabel: 'GitHub test', + onPrimary: onOpen2048, + onSecondary: onGitHub, + ), + const SizedBox(height: 10), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 680 ? 4 : 2; + final items = [ + _DemoAction(Icons.edit_note_outlined, 'Diary APK', 'Local diary demo inside this APK', _ModuleAction.diary, _amber), + _DemoAction(Icons.forum_outlined, 'Chat Memory', 'Conversation list and context', _ModuleAction.aiChat, _mint), + _DemoAction(Icons.handyman_outlined, 'Tool Tests', 'Run mobile tool probes', _ModuleAction.toolLab, _cyan), + _DemoAction(Icons.terminal_outlined, 'Runtime', 'Check Helper and Runtime fallback setup', _ModuleAction.termuxCheck, _lime), + ]; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: items.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: columns == 2 ? 1.55 : 1.25, + ), + itemBuilder: (context, index) { + final item = items[index]; + return _DemoActionTile( + item: item, + onTap: switch (item.action) { + _ModuleAction.diary => onDiary, + _ModuleAction.aiChat => onChat, + _ModuleAction.toolLab => onTools, + _ModuleAction.termuxCheck => onTermux, + _ => onOpen2048, + }, + ); + }, + ); + }, + ), + ], + ), + ); + } +} + +class _HeroDemoTile extends StatelessWidget { + const _HeroDemoTile({ + required this.title, + required this.subtitle, + required this.icon, + required this.color, + required this.primaryLabel, + required this.secondaryLabel, + required this.onPrimary, + required this.onSecondary, + }); + + final String title; + final String subtitle; + final IconData icon; + final Color color; + final String primaryLabel; + final String secondaryLabel; + final VoidCallback onPrimary; + final VoidCallback onSecondary; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.40)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 28), + const SizedBox(width: 10), + Expanded( + child: Text(title, style: const TextStyle(color: _text, fontSize: 18, fontWeight: FontWeight.w900)), + ), + ], + ), + const SizedBox(height: 8), + Text(subtitle, style: const TextStyle(color: _muted, fontSize: 12, height: 1.4)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: onPrimary, + icon: const Icon(Icons.open_in_browser_outlined), + label: Text(primaryLabel), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: onSecondary, + icon: const Icon(Icons.hub_outlined), + label: Text(secondaryLabel), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _DemoAction { + const _DemoAction(this.icon, this.title, this.subtitle, this.action, this.color); + + final IconData icon; + final String title; + final String subtitle; + final _ModuleAction action; + final Color color; +} + +class _CommandShortcut { + const _CommandShortcut({ + required this.icon, + required this.title, + required this.subtitle, + required this.color, + required this.action, + }); + + final IconData icon; + final String title; + final String subtitle; + final Color color; + final _ModuleAction action; +} + +class _CommandShortcutTile extends StatelessWidget { + const _CommandShortcutTile({required this.command, required this.onTap}); + + final _CommandShortcut command; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: _Panel( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: command.color.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: command.color.withOpacity(0.28)), + ), + child: Icon(command.icon, color: command.color, size: 21), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(command.title, style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15)), + const SizedBox(height: 4), + Text(command.subtitle, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right_outlined, color: _faint), + ], + ), + ), + ), + ); + } +} + +class _DemoActionTile extends StatelessWidget { + const _DemoActionTile({required this.item, required this.onTap}); + + final _DemoAction item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: _panelSoft, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(item.icon, color: item.color, size: 24), + const Spacer(), + Text(item.title, style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 3), + Text( + item.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 11, height: 1.25), + ), + ], + ), + ), + ), + ); + } +} + +class _MobileCodingLabSheet extends StatefulWidget { + const _MobileCodingLabSheet({ + required this.autoGenerate, + required this.onOpenOnlineDemo, + required this.onOpenGitHub, + required this.onLog, + }); + + final bool autoGenerate; + final VoidCallback onOpenOnlineDemo; + final VoidCallback onOpenGitHub; + final void Function(String title, String detail, IconData icon, Color color) onLog; + + @override + State<_MobileCodingLabSheet> createState() => _MobileCodingLabSheetState(); +} + +class _MobileCodingLabSheetState extends State<_MobileCodingLabSheet> { + String? _projectPath; + String? _transcriptPath; + String? _html; + String _stage = 'Idle'; + int? _lastGenerateMs; + bool _generating = false; + bool _autoStarted = false; + final List<_LocalToolEvent> _localToolEvents = []; + final _localToolEventController = ScrollController(); + + @override + void dispose() { + _localToolEventController.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (widget.autoGenerate && !_autoStarted) { + _autoStarted = true; + WidgetsBinding.instance.addPostFrameCallback((_) => _generate2048()); + } + } + + Future _generate2048() async { + final started = DateTime.now(); + setState(() { + _generating = true; + _stage = 'Starting local tool test'; + _localToolEvents.clear(); + _transcriptPath = null; + }); + try { + final rootDirectory = await _mobileCodeProjectsRootDirectory(); + final projectDirectory = Directory(p.join(rootDirectory.path, 'local_tool_2048')); + + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.system, + title: 'Local tool harness booted', + detail: + 'This is a scripted local tool test, not a model-provider agent call. Loaded phone-safe tools: list_files, write_file, read_file, preview_webview, runtime_probe, github_connect.', + time: DateTime.now(), + ), + ); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.thought, + title: 'Reasoning', + detail: + 'Goal is a real local 2048 project. The local harness will create an app-owned workspace, generate a complete single-file web app, save it atomically, read it back, then make WebView preview available.', + time: DateTime.now(), + ), + ); + + final listStarted = DateTime.now(); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.toolCall, + title: 'tool_call: list_files', + toolName: 'list_files', + path: rootDirectory.path, + detail: jsonEncode({ + 'path': 'mobilecode_projects', + 'maxDepth': 1, + }), + time: DateTime.now(), + ), + ); + await rootDirectory.create(recursive: true); + final existingProjects = rootDirectory + .listSync() + .map((entity) => entity.path.split(Platform.pathSeparator).last) + .where((name) => name.trim().isNotEmpty) + .take(8) + .toList(); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.observation, + title: 'tool_result: list_files', + toolName: 'list_files', + path: rootDirectory.path, + durationMs: DateTime.now().difference(listStarted).inMilliseconds, + detail: existingProjects.isEmpty + ? 'No local MobileCode projects yet.' + : 'Found: ${existingProjects.join(', ')}', + time: DateTime.now(), + ), + ); + + final prepareStarted = DateTime.now(); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.toolCall, + title: 'tool_call: mkdir', + toolName: 'write_file', + path: projectDirectory.path, + detail: jsonEncode({ + 'path': 'mobilecode_projects/local_tool_2048', + 'recursive': true, + }), + time: DateTime.now(), + ), + ); + await projectDirectory.create(recursive: true); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.observation, + title: 'tool_result: mkdir', + toolName: 'write_file', + path: projectDirectory.path, + durationMs: DateTime.now().difference(prepareStarted).inMilliseconds, + detail: 'Workspace ready inside Android app documents.', + time: DateTime.now(), + ), + ); + + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.thought, + title: 'Plan code structure', + detail: + 'Single index.html keeps the demo portable: responsive board, swipe/keyboard input, score, best score, undo, game-over detection, and localStorage persistence.', + time: DateTime.now(), + ), + ); + + final html = _localTool2048Html(); + final chunks = _chunkText(html, 1500); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.toolCall, + title: 'tool_call: write_file', + toolName: 'write_file', + path: '${projectDirectory.path}/index.html', + detail: jsonEncode({ + 'path': 'mobilecode_projects/local_tool_2048/index.html', + 'bytes': utf8.encode(html).length, + 'atomic': true, + }), + time: DateTime.now(), + ), + ); + for (var index = 0; index < chunks.length; index++) { + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.fileWrite, + title: 'Writing code chunk ${index + 1}/${chunks.length}', + toolName: 'write_file', + path: '${projectDirectory.path}/index.html', + detail: _compact(chunks[index], limit: 360), + time: DateTime.now(), + ), + delay: const Duration(milliseconds: 90), + ); + } + + final writeStarted = DateTime.now(); + final tempFile = File('${projectDirectory.path}/index.html.tmp'); + final file = File('${projectDirectory.path}/index.html'); + await tempFile.writeAsString(html, flush: true); + if (await file.exists()) { + await file.delete(); + } + await tempFile.rename(file.path); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.observation, + title: 'tool_result: write_file', + toolName: 'write_file', + path: file.path, + durationMs: DateTime.now().difference(writeStarted).inMilliseconds, + detail: 'Wrote ${html.length} characters to index.html through temp-file rename.', + time: DateTime.now(), + ), + ); + + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.diff, + title: 'Generated diff', + toolName: 'write_file', + path: file.path, + detail: [ + '+ mobilecode_projects/local_tool_2048/index.html', + '+ responsive 4x4 2048 board', + '+ swipe and keyboard controls', + '+ score, best score, undo, game-over state', + '+ offline WebView-ready JavaScript', + ].join('\n'), + time: DateTime.now(), + ), + ); + + final readStarted = DateTime.now(); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.toolCall, + title: 'tool_call: read_file', + toolName: 'read_file', + path: file.path, + detail: jsonEncode({ + 'path': 'mobilecode_projects/local_tool_2048/index.html', + 'purpose': 'verify saved file and prepare preview', + }), + time: DateTime.now(), + ), + ); + final savedHtml = await file.readAsString(); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.observation, + title: 'tool_result: read_file', + toolName: 'read_file', + path: file.path, + durationMs: DateTime.now().difference(readStarted).inMilliseconds, + detail: 'Read back ${savedHtml.length} characters. Preview input is ready.', + time: DateTime.now(), + ), + ); + if (!mounted) return; + setState(() { + _projectPath = file.path; + _html = savedHtml; + _stage = 'Generated and saved'; + _lastGenerateMs = DateTime.now().difference(started).inMilliseconds; + }); + + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.preview, + title: 'tool_call: preview_webview', + toolName: 'preview_webview', + path: file.path, + detail: + 'WebView preview is armed. Tap Preview to run the generated game inside MobileCode without leaving the app.', + durationMs: _lastGenerateMs, + time: DateTime.now(), + ), + ); + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.finalAnswer, + title: 'Local test final', + detail: + '2048 project is complete. The generated code is visible below, stored on-device, and ready for WebView preview or GitHub publishing.', + durationMs: _lastGenerateMs, + time: DateTime.now(), + ), + ); + + final transcript = await _persistRunTranscript(projectDirectory, started); + if (!mounted) return; + setState(() => _transcriptPath = transcript.path); + widget.onLog('Local test generated 2048', '${file.path} - ${_lastGenerateMs}ms', Icons.grid_4x4_outlined, _mint); + } on Object catch (error) { + if (!mounted) return; + await _emitLocalToolEvent( + _LocalToolEvent( + kind: _LocalToolEventKind.error, + title: 'Local test failed', + detail: _compact(error.toString(), limit: 260), + ok: false, + time: DateTime.now(), + ), + delay: Duration.zero, + ); + setState(() => _stage = 'Generation failed'); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Generate failed: $error'))); + widget.onLog('2048 generation failed', _compact(error.toString(), limit: 120), Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _generating = false); + } + } + + Future _emitLocalToolEvent( + _LocalToolEvent event, { + Duration delay = const Duration(milliseconds: 150), + }) async { + if (!mounted) return; + setState(() { + _stage = event.title; + _localToolEvents.add(event); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_localToolEventController.hasClients) return; + _localToolEventController.animateTo( + _localToolEventController.position.maxScrollExtent, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + ); + }); + if (delay > Duration.zero) { + await Future.delayed(delay); + } + } + + Future _persistRunTranscript(Directory projectDirectory, DateTime started) async { + final file = File('${projectDirectory.path}/local_tool_run.json'); + final payload = { + 'agent': 'MobileCode Android Local Tool Harness', + 'inspiredBy': [ + 'tool-harness: model/tool/result loop', + 'workspace-scoped transcript: shell-style tool output', + 'visible tool lifecycle: persistent session events and saved artifacts', + ], + 'startedAt': started.toIso8601String(), + 'finishedAt': DateTime.now().toIso8601String(), + 'projectPath': _projectPath, + 'tools': _localToolSpecs + .map((tool) => { + 'name': tool.name, + 'description': tool.description, + 'surface': tool.surface, + 'risk': tool.risk, + }) + .toList(), + 'events': _localToolEvents.map((event) => event.toJson()).toList(), + }; + await file.writeAsString(const JsonEncoder.withIndent(' ').convert(payload), flush: true); + return file; + } + + void _preview() { + final html = _html; + if (html == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Generate the 2048 project first'))); + return; + } + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _WebPreviewSheet( + title: '2048 local preview', + subtitle: _projectPath ?? 'Generated HTML loaded into WebView', + html: html, + ), + ); + } + + Future _copyCode() async { + final html = _html; + if (html == null) return; + await Clipboard.setData(ClipboardData(text: html)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Generated index.html copied'))); + } + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.code_outlined, + title: 'Mobile Tool Test', + subtitle: + 'A local scripted smoke test with visible tool calls, file writes, diff, and WebView preview.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Local tool harness', + style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 16), + ), + const SizedBox(height: 10), + _InlineStatus( + icon: _generating ? Icons.sync_outlined : Icons.check_circle_outline, + label: _lastGenerateMs == null ? _stage : '$_stage - ${_lastGenerateMs}ms', + color: _generating ? _amber : (_html == null ? _faint : _mint), + ), + const SizedBox(height: 10), + for (final item in const [ + '1. Think: decide the phone-safe workspace and target artifact.', + '2. Act: call list_files, write_file, read_file, and preview_webview.', + '3. Observe: show tool results, latency, generated diff, and saved paths.', + '4. Finish: leave code, transcript, and preview entry visible for inspection.', + ]) + Padding( + padding: const EdgeInsets.only(bottom: 7), + child: Text(item, style: const TextStyle(color: _muted, height: 1.35)), + ), + ], + ), + ), + const SizedBox(height: 12), + const _LocalToolRegistry(tools: _localToolSpecs), + const SizedBox(height: 12), + _LocalToolTranscript( + events: _localToolEvents, + running: _generating, + controller: _localToolEventController, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _generating ? null : _generate2048, + icon: _generating + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.auto_fix_high_outlined), + label: Text(_generating ? 'Running test' : 'Run local tool test'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: _preview, + icon: const Icon(Icons.visibility_outlined), + label: const Text('Preview'), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _html == null ? null : _copyCode, + icon: const Icon(Icons.copy_outlined), + label: const Text('Copy code'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: widget.onOpenGitHub, + icon: const Icon(Icons.hub_outlined), + label: const Text('GitHub test'), + ), + ), + ], + ), + const SizedBox(height: 12), + if (_projectPath != null || _transcriptPath != null) + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_projectPath != null) + Row( + children: [ + const Icon(Icons.description_outlined, color: _mint, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _projectPath!, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ), + ], + ), + if (_transcriptPath != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.receipt_long_outlined, color: _cyan, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _transcriptPath!, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: SelectableText( + _html == null + ? 'No generated code yet. Tap "Run local tool test" to create a real local project.' + : _compact(_html!, limit: 2600), + style: const TextStyle(color: _muted, fontSize: 12, height: 1.4, fontFamily: 'monospace'), + ), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: widget.onOpenOnlineDemo, + icon: const Icon(Icons.public_outlined), + label: const Text('Open already published online 2048 demo'), + ), + ], + ), + ); + } +} + +class _WebPreviewSheet extends StatefulWidget { + const _WebPreviewSheet({ + required this.title, + required this.subtitle, + required this.html, + }); + + final String title; + final String subtitle; + final String html; + + @override + State<_WebPreviewSheet> createState() => _WebPreviewSheetState(); +} + +Future _launchHtmlInExternalBrowser( + String html, { + String browserOpenMode = _browserOpenModeSystem, +}) async { + final dataUri = Uri.dataFromString(html, mimeType: 'text/html', encoding: utf8); + if (await _launchUrlWithBrowserMode(dataUri, browserOpenMode)) { + return true; + } + return launchUrl(dataUri, mode: LaunchMode.platformDefault); +} + +Future _openHtmlInBrowser(BuildContext context, String html) async { + try { + final opened = await _launchHtmlInExternalBrowser(html); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(opened ? 'Opened generated HTML in browser.' : 'No browser accepted this generated HTML.')), + ); + } on Object catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_compact(error.toString(), limit: 140)))); + } +} + +Future _openExternalUrl( + BuildContext context, + String url, { + String label = 'URL', + String browserOpenMode = _browserOpenModeSystem, +}) async { + try { + final uri = Uri.parse(url); + final opened = await _launchUrlWithBrowserMode(uri, browserOpenMode); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(opened ? 'Opened $label with ${_browserOpenModeLabel(browserOpenMode)}.' : 'Could not open $label.')), + ); + } on Object catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_compact(error.toString(), limit: 140)))); + } +} + +Future _copyText(BuildContext context, String value, String label) async { + await Clipboard.setData(ClipboardData(text: value)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$label copied.'))); +} + +class _PagesDeploymentSummary { + const _PagesDeploymentSummary({ + required this.url, + required this.repositoryUrl, + required this.artifactPath, + required this.publishedAt, + required this.readinessSummary, + }); + + final String url; + final String repositoryUrl; + final String artifactPath; + final DateTime publishedAt; + final String readinessSummary; +} + +class _GitHubPagesArtifactDeploySheet extends StatefulWidget { + const _GitHubPagesArtifactDeploySheet({ + required this.artifactPath, + required this.onDeployed, + }); + + final String artifactPath; + final Future Function(_PagesDeploymentSummary summary) onDeployed; + + @override + State<_GitHubPagesArtifactDeploySheet> createState() => _GitHubPagesArtifactDeploySheetState(); +} + +class _GitHubPagesArtifactDeploySheetState extends State<_GitHubPagesArtifactDeploySheet> { + late final GitHubDeepService _github; + late final TextEditingController _repoController; + late final TextEditingController _descriptionController; + final _readinessService = HtmlPublishReadinessService(); + GitHubPagesService? _pages; + bool _loading = true; + bool _checkingReadiness = true; + bool _deploying = false; + bool _privateRepo = false; + bool _tokenValid = false; + bool _allowRemoteAssets = false; + bool _warningsAccepted = false; + String? _user; + GitHubRemoteWorkspaceLink? _remoteLink; + String? _error; + HtmlPublishReadinessReport? _readiness; + DeploymentResult? _result; + final List _steps = []; + + @override + void initState() { + super.initState(); + _github = GitHubDeepService(); + final projectName = p.basename(p.dirname(widget.artifactPath)); + _repoController = TextEditingController(text: _sanitizeRepoName('mobilecode-$projectName')); + _descriptionController = TextEditingController( + text: 'Generated and deployed from MobileCode mobile AI workspace.', + ); + unawaited(_loadRemoteLinkDefault()); + unawaited(_runReadinessCheck()); + unawaited(_initialize()); + } + + @override + void dispose() { + _repoController.dispose(); + _descriptionController.dispose(); + _pages?.dispose(); + _github.dispose(); + super.dispose(); + } + + Future _initialize() async { + setState(() { + _loading = true; + _error = null; + }); + try { + await _github.initialize(); + var valid = false; + if (_github.isAuthenticated) { + valid = await _github.validateToken(); + } + if (!mounted) return; + _pages ??= GitHubPagesService(_github); + setState(() { + _tokenValid = valid; + _user = _github.currentUser; + _loading = false; + if (!valid && _github.isAuthenticated) { + _error = 'GitHub session expired or token scope is insufficient. Please sign in again.'; + } + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _loading = false; + _tokenValid = false; + _error = _compact(error.toString(), limit: 160); + }); + } + } + + Future _loadRemoteLinkDefault() async { + final link = await GitHubRepoHubService.findRemoteLinkForPath(widget.artifactPath); + if (!mounted || link == null) return; + setState(() { + _remoteLink = link; + _repoController.text = _sanitizeRepoName(link.name); + _descriptionController.text = 'Generated and deployed from MobileCode for ${link.fullName}.'; + }); + } + + Future _runReadinessCheck() async { + setState(() { + _checkingReadiness = true; + _warningsAccepted = false; + }); + try { + final report = await _readinessService.checkFile( + widget.artifactPath, + allowRemoteAssets: _allowRemoteAssets, + ); + if (!mounted) return; + setState(() { + _readiness = report; + _checkingReadiness = false; + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _readiness = HtmlPublishReadinessReport( + sourcePath: widget.artifactPath, + checkedAt: DateTime.now(), + allowRemoteAssets: _allowRemoteAssets, + issues: [ + HtmlPublishIssue( + code: 'check_failed', + title: 'Pre-publish check failed', + detail: _compact(error.toString(), limit: 180), + severity: HtmlPublishIssueSeverity.blocking, + ), + ], + ); + _checkingReadiness = false; + }); + } + } + + Future _openGitHubLogin() async { + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => const GitHubScreen())); + if (!mounted) return; + await _initialize(); + } + + Future _deploy() async { + if (_deploying) return; + final readiness = _readiness; + if (_checkingReadiness) { + setState(() => _error = 'Wait for the HTML pre-publish check to finish.'); + return; + } + if (readiness == null) { + await _runReadinessCheck(); + if (!mounted) return; + if (_readiness == null) { + setState(() => _error = 'HTML pre-publish check did not complete.'); + return; + } + } + if (_readiness?.blocked == true) { + setState(() => _error = 'Fix the blocking HTML publish checks before deploying.'); + return; + } + if (_readiness?.hasWarnings == true && !_warningsAccepted) { + final confirmed = await _confirmWarningPublish(_readiness!); + if (!confirmed) return; + _warningsAccepted = true; + } + + final repoName = _sanitizeRepoName(_repoController.text); + if (repoName.isEmpty) { + setState(() => _error = 'Repository name is required.'); + return; + } + final signedInOwner = _github.currentUser; + final owner = _remoteLink?.owner ?? signedInOwner; + final pages = _pages; + if (!_tokenValid || signedInOwner == null || owner == null || pages == null) { + setState(() => _error = 'Please sign in to GitHub before deploying.'); + return; + } + + final artifact = File(widget.artifactPath); + if (!await artifact.exists()) { + setState(() => _error = 'Generated HTML file was not found on this phone.'); + return; + } + + final deploySteps = [ + 'Using GitHub account: $signedInOwner', + 'Project folder: ${p.dirname(widget.artifactPath)}', + if (_remoteLink != null) 'Bound workspace: ${_remoteLink!.fullName}', + 'Target repo: $owner/$repoName', + ]; + + setState(() { + _deploying = true; + _error = null; + _result = null; + _steps + ..clear() + ..addAll(deploySteps); + }); + + try { + var createdRepo = false; + try { + await _github.getRepoDetails(owner, repoName); + _addStep('Repository exists. Reusing $owner/$repoName.'); + } on GitHubDeepException catch (error) { + if (error.statusCode != 404) rethrow; + if (_remoteLink != null && owner != signedInOwner) { + setState(() { + _deploying = false; + _error = + 'Bound repository $owner/$repoName is not visible to this token. Reconnect GitHub with access to that owner or create the repo on GitHub first.'; + }); + return; + } + _addStep('Repository does not exist. Creating $owner/$repoName...'); + await _github.createRepo( + repoName, + description: _descriptionController.text.trim(), + isPrivate: _privateRepo, + autoInit: true, + ); + createdRepo = true; + _addStep('Repository created.'); + } + + _addStep('Uploading static HTML to gh-pages...'); + final result = await pages.deploy( + localProjectPath: p.dirname(widget.artifactPath), + owner: owner, + repo: repoName, + buildType: BuildType.staticHtml, + ); + + if (!mounted) return; + setState(() { + _result = result; + _deploying = false; + _steps + ..addAll(result.steps) + ..add(createdRepo ? 'New repository flow completed.' : 'Existing repository flow completed.'); + if (!result.success) { + final lines = [ + result.error ?? 'GitHub Pages deployment failed.', + if (result.recoveryHint != null) result.recoveryHint!, + if (result.statusCode != null) 'HTTP ${result.statusCode} - ${result.failureKind ?? 'github_api_failed'}', + ]; + _error = lines.join('\n'); + } + }); + + if (result.success && result.url != null) { + await widget.onDeployed(_PagesDeploymentSummary( + url: result.url!, + repositoryUrl: 'https://github.com/$owner/$repoName', + artifactPath: widget.artifactPath, + publishedAt: result.deployedAt, + readinessSummary: (_readiness ?? readiness)?.toAgentSummary(maxIssues: 3) ?? 'HTML publish readiness: not checked', + )); + } + } on Object catch (error) { + final failure = GitHubPagesService.describeFailure(error); + if (!mounted) return; + setState(() { + _deploying = false; + _error = '${failure.message}\n${failure.recoveryHint}'; + }); + } + } + + Future _confirmWarningPublish(HtmlPublishReadinessReport report) async { + final warnings = report.warningIssues.take(5).toList(growable: false); + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Publish with warnings?'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('The HTML can be published, but MobileCode found quality warnings:'), + const SizedBox(height: 12), + for (final issue in warnings) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text('${issue.title}\n${issue.detail}', style: const TextStyle(fontSize: 13, height: 1.3)), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Review first')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Publish anyway')), + ], + ), + ) ?? + false; + } + + void _addStep(String step) { + if (!mounted) return; + setState(() => _steps.add(step)); + } + + Future _openUrl(String url) async { + await _openExternalUrl(context, url, label: 'GitHub Pages URL'); + } + + Future _copy(String value, String label) async { + await _copyText(context, value, label); + } + + Future _openArtifactCode() async { + try { + final file = File(widget.artifactPath); + if (!await file.exists()) { + setState(() => _error = 'Generated HTML file was not found on this phone.'); + return; + } + final code = await file.readAsString(); + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _CodeFileSheet( + path: widget.artifactPath, + code: code, + onOpenEditor: () => unawaited(_openArtifactInEditor(widget.artifactPath, initialContent: code)), + ), + ); + } on Object catch (error) { + if (!mounted) return; + setState(() => _error = _compact(error.toString(), limit: 160)); + } + } + + Future _openArtifactInEditor(String path, {String? initialContent}) async { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditorScreen( + initialFilePath: path, + initialContent: initialContent, + fileName: p.basename(path), + ), + ), + ); + } + + Future _openProjectFolder() async { + try { + final workspaceRoot = await _mobileCodeProjectsRootDirectory(); + final folder = Directory(p.dirname(widget.artifactPath)); + if (!await folder.exists()) { + setState(() => _error = 'Project folder was not found on this phone.'); + return; + } + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _ProjectFolderSheet( + initialPath: folder.path, + workspaceRoot: workspaceRoot.path, + onOpenFile: (filePath) => unawaited(_copy(filePath, 'File path')), + ), + ); + } on Object catch (error) { + if (!mounted) return; + setState(() => _error = _compact(error.toString(), limit: 160)); + } + } + + @override + Widget build(BuildContext context) { + final projectDir = p.dirname(widget.artifactPath); + final repoName = _sanitizeRepoName(_repoController.text); + final owner = _remoteLink?.owner ?? _user; + final previewUrl = owner == null || repoName.isEmpty ? null : 'https://$owner.github.io/$repoName'; + final repositoryUrl = owner == null || repoName.isEmpty ? null : 'https://github.com/$owner/$repoName'; + + return _SheetScaffold( + icon: Icons.rocket_launch_outlined, + title: 'Deploy to GitHub Pages', + subtitle: widget.artifactPath, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PublishReadinessPanel( + report: _readiness, + checking: _checkingReadiness, + allowRemoteAssets: _allowRemoteAssets, + onAllowRemoteAssetsChanged: (value) { + setState(() => _allowRemoteAssets = value); + unawaited(_runReadinessCheck()); + }, + onRefresh: () => unawaited(_runReadinessCheck()), + ), + const SizedBox(height: 12), + if (_loading) + const _Panel( + padding: EdgeInsets.all(18), + child: Row( + children: [ + SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), + SizedBox(width: 10), + Expanded(child: Text('Checking GitHub session...', style: TextStyle(color: _muted))), + ], + ), + ) + else if (!_tokenValid) + _Panel( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.lock_outline, color: _amber, size: 18), + SizedBox(width: 8), + Expanded( + child: Text('GitHub login required', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Sign in once with a GitHub token that can create repositories, write contents, and configure Pages. Fine-grained tokens need Repository contents read/write, Pages read/write, and Administration read/write. Classic PATs need the repo scope.', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 12), + _RuntimeActionButton( + icon: Icons.login_outlined, + label: 'Open GitHub login', + disabled: false, + onTap: () => unawaited(_openGitHubLogin()), + ), + ], + ), + ) + else ...[ + _Panel( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.verified_outlined, color: _mint, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Ready as ${owner ?? 'GitHub user'}', + style: const TextStyle(color: _text, fontWeight: FontWeight.w900), + ), + ), + ], + ), + const SizedBox(height: 10), + TextField( + controller: _repoController, + enabled: !_deploying, + decoration: const InputDecoration( + labelText: 'Repository name', + prefixIcon: Icon(Icons.source_outlined), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 10), + TextField( + controller: _descriptionController, + enabled: !_deploying, + minLines: 1, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Repository description', + prefixIcon: Icon(Icons.notes_outlined), + ), + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: _privateRepo, + onChanged: _deploying ? null : (value) => setState(() => _privateRepo = value), + contentPadding: EdgeInsets.zero, + title: const Text('Private repository', style: TextStyle(color: _text, fontSize: 13, fontWeight: FontWeight.w800)), + subtitle: const Text( + 'Public is recommended for Pages demos. Private Pages may depend on your GitHub plan.', + style: TextStyle(color: _muted, fontSize: 11, height: 1.25), + ), + ), + const SizedBox(height: 8), + _DeployPreviewLine(label: 'Local folder', value: projectDir), + if (repositoryUrl != null) _DeployPreviewLine(label: 'Repository', value: repositoryUrl), + if (previewUrl != null) _DeployPreviewLine(label: 'Pages URL', value: previewUrl), + if (_remoteLink != null) _DeployPreviewLine(label: 'Bound repo', value: _remoteLink!.fullName), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: _blue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _deploying || _checkingReadiness || (_readiness?.blocked ?? false) ? null : _deploy, + icon: _deploying + ? const SizedBox(width: 17, height: 17, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.rocket_launch_outlined), + label: Text(_deploying ? 'Deploying...' : 'One-click deploy'), + ), + ), + ], + ), + ), + ], + if (_steps.isNotEmpty) ...[ + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Deployment log', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + for (final step in _steps.take(14)) + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.check_circle_outline, color: _mint, size: 14), + const SizedBox(width: 6), + Expanded(child: Text(step, style: const TextStyle(color: _muted, fontSize: 11, height: 1.25))), + ], + ), + ), + ], + ), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, color: _rose, size: 18), + const SizedBox(width: 8), + Expanded(child: Text(_error!, style: const TextStyle(color: _rose, fontSize: 12, height: 1.35))), + ], + ), + ), + ], + if (_result?.success == true && _result?.url != null) ...[ + const SizedBox(height: 12), + _PublishedWorkCard( + info: _PublishedArtifactInfo( + pagesUrl: _result!.url!, + repositoryUrl: repositoryUrl ?? '', + artifactPath: widget.artifactPath, + publishedAt: _result!.deployedAt, + readinessSummary: _readiness?.toAgentSummary(maxIssues: 3), + ), + onOpenPages: () => unawaited(_openUrl(_result!.url!)), + onOpenRepo: repositoryUrl == null ? null : () => unawaited(_openUrl(repositoryUrl)), + onOpenCode: () => unawaited(_openArtifactCode()), + onRedeploy: _deploying ? null : () => unawaited(_deploy()), + onCopyPath: () => unawaited(_copy(widget.artifactPath, 'Phone file path')), + onOpenFolder: () => unawaited(_openProjectFolder()), + ), + ], + ], + ), + ); + } +} + +class _DeployPreviewLine extends StatelessWidget { + const _DeployPreviewLine({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 86, + child: Text(label, style: const TextStyle(color: _faint, fontSize: 11)), + ), + Expanded( + child: SelectableText( + value, + maxLines: 2, + style: const TextStyle(color: _muted, fontSize: 11, height: 1.25), + ), + ), + ], + ), + ); + } +} + +class _PublishReadinessPanel extends StatelessWidget { + const _PublishReadinessPanel({ + required this.report, + required this.checking, + required this.allowRemoteAssets, + required this.onAllowRemoteAssetsChanged, + required this.onRefresh, + }); + + final HtmlPublishReadinessReport? report; + final bool checking; + final bool allowRemoteAssets; + final ValueChanged onAllowRemoteAssetsChanged; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + final status = checking ? 'Checking' : (report?.statusLabel ?? 'Not checked'); + final blocked = report?.blocked ?? false; + final warnings = report?.hasWarnings ?? false; + final color = checking + ? _cyan + : blocked + ? _rose + : warnings + ? _amber + : _mint; + final icon = checking + ? Icons.sync_outlined + : blocked + ? Icons.block_outlined + : warnings + ? Icons.warning_amber_outlined + : Icons.verified_outlined; + final issues = report?.issues.take(5).toList(growable: false) ?? const []; + + return _Panel( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Pre-publish check - $status', + style: const TextStyle(color: _text, fontWeight: FontWeight.w900), + ), + ), + TextButton.icon( + onPressed: checking ? null : onRefresh, + icon: const Icon(Icons.refresh_outlined, size: 16), + label: const Text('Recheck'), + ), + ], + ), + const SizedBox(height: 8), + Text( + checking + ? 'Checking title, viewport, private paths, external assets, mobile touch targets, and basic accessibility.' + : report == null + ? 'Run the readiness check before publishing.' + : '${report!.blockingIssues.length} blockers, ${report!.warningIssues.length} warnings. Blockers must be fixed before GitHub Pages deploy.', + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + dense: true, + contentPadding: EdgeInsets.zero, + value: allowRemoteAssets, + onChanged: checking ? null : onAllowRemoteAssetsChanged, + title: const Text('Allow remote assets', style: TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w800)), + subtitle: const Text( + 'Keep this off for fully self-contained HTML. Turn on only when external links/CDNs are intentional.', + style: TextStyle(color: _muted, fontSize: 11, height: 1.25), + ), + ), + if (issues.isNotEmpty) ...[ + const SizedBox(height: 8), + for (final issue in issues) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + issue.severity == HtmlPublishIssueSeverity.blocking ? Icons.error_outline : Icons.info_outline, + color: issue.severity == HtmlPublishIssueSeverity.blocking ? _rose : _amber, + size: 15, + ), + const SizedBox(width: 7), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(issue.title, style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w800)), + const SizedBox(height: 2), + Text(issue.detail, style: const TextStyle(color: _muted, fontSize: 11, height: 1.25)), + ], + ), + ), + ], + ), + ), + if ((report?.issues.length ?? 0) > issues.length) + Text('${report!.issues.length - issues.length} more checks hidden.', style: const TextStyle(color: _faint, fontSize: 11)), + ], + ], + ), + ); + } +} + +class _PublishedWorkCard extends StatelessWidget { + const _PublishedWorkCard({ + required this.info, + required this.onOpenPages, + required this.onOpenRepo, + required this.onOpenCode, + required this.onRedeploy, + required this.onCopyPath, + required this.onOpenFolder, + }); + + final _PublishedArtifactInfo info; + final VoidCallback onOpenPages; + final VoidCallback? onOpenRepo; + final VoidCallback? onOpenCode; + final VoidCallback? onRedeploy; + final VoidCallback onCopyPath; + final VoidCallback onOpenFolder; + + @override + Widget build(BuildContext context) { + final shareText = [ + 'MobileCode published web page', + info.pagesUrl, + if (info.repositoryUrl.isNotEmpty) info.repositoryUrl, + ].join('\n'); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _panel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _mint.withOpacity(0.32)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _mint.withOpacity(0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _mint.withOpacity(0.22)), + ), + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: _panel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _mint.withOpacity(0.24)), + ), + child: const Icon(Icons.public_outlined, color: _mint, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('作品已发布', style: const TextStyle(color: _text, fontSize: 15, fontWeight: FontWeight.w900)), + const SizedBox(height: 3), + Text( + '${info.title} - ${_timeLabel(info.publishedAt)}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.3), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 10), + _PublishedPagesThumbnail(url: info.pagesUrl), + const SizedBox(height: 10), + _PublishedLinkLine(label: 'Pages', value: info.pagesUrl, color: _blue), + if (info.repositoryUrl.isNotEmpty) _PublishedLinkLine(label: 'Repo', value: info.repositoryUrl, color: _violet), + _PublishedLinkLine(label: 'File', value: info.artifactPath, color: _muted), + if (info.readinessSummary != null && info.readinessSummary!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text(info.readinessSummary!, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _faint, fontSize: 11, height: 1.3)), + ], + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MiniArtifactButton(icon: Icons.open_in_browser_outlined, label: '打开网页', onTap: onOpenPages, color: _blue), + if (onOpenRepo != null) + _MiniArtifactButton( + icon: Icons.code_outlined, + leading: const _GitHubMarkIcon(size: 15, color: _violet), + label: '打开仓库', + onTap: onOpenRepo!, + color: _violet, + ), + if (onOpenCode != null) _MiniArtifactButton(icon: Icons.description_outlined, label: '代码文件', onTap: onOpenCode!, color: _mint), + if (onRedeploy != null) _MiniArtifactButton(icon: Icons.rocket_launch_outlined, label: '重新发布', onTap: onRedeploy!, color: _amber), + _MiniArtifactButton(icon: Icons.folder_open_outlined, label: '工程文件夹', onTap: onOpenFolder, color: _mint), + _MiniArtifactButton( + icon: Icons.ios_share_outlined, + label: '复制分享', + onTap: () async { + await Clipboard.setData(ClipboardData(text: info.pagesUrl)); + await Share.share(shareText, subject: 'MobileCode published page'); + }, + color: _cyan, + ), + _MiniArtifactButton(icon: Icons.folder_copy_outlined, label: '复制路径', onTap: onCopyPath, color: _faint), + ], + ), + ], + ), + ); + } +} + +class _PublishedPagesThumbnail extends StatefulWidget { + const _PublishedPagesThumbnail({required this.url}); + + final String url; + + @override + State<_PublishedPagesThumbnail> createState() => _PublishedPagesThumbnailState(); +} + +class _PublishedPagesThumbnailState extends State<_PublishedPagesThumbnail> { + late final WebViewController _controller; + int _progress = 0; + String? _error; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(_panel) + ..setNavigationDelegate( + NavigationDelegate( + onProgress: (progress) { + if (mounted) setState(() => _progress = progress); + }, + onWebResourceError: (error) { + if (mounted) { + setState(() => _error = _compact(error.description, limit: 90)); + } + }, + ), + ) + ..loadRequest(Uri.parse(widget.url)); + } + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + height: 138, + decoration: BoxDecoration( + color: _panelSoft, + border: Border.all(color: _line), + ), + child: Stack( + children: [ + Positioned.fill( + child: Transform.scale( + scale: 0.72, + alignment: Alignment.topLeft, + child: SizedBox( + width: MediaQuery.of(context).size.width / 0.72, + height: 190, + child: IgnorePointer(child: WebViewWidget(controller: _controller)), + ), + ), + ), + Positioned( + left: 0, + right: 0, + top: 0, + child: AnimatedOpacity( + opacity: _progress < 100 ? 1 : 0, + duration: const Duration(milliseconds: 180), + child: LinearProgressIndicator(value: _progress / 100), + ), + ), + if (_error != null) + Positioned.fill( + child: Container( + color: _panelSoft, + padding: const EdgeInsets.all(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.image_not_supported_outlined, color: _amber), + const SizedBox(height: 6), + Text( + 'Live Pages thumbnail unavailable: $_error', + textAlign: TextAlign.center, + style: const TextStyle(color: _muted, fontSize: 11, height: 1.25), + ), + ], + ), + ), + ), + Positioned( + left: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _text.withOpacity(0.72), + borderRadius: BorderRadius.circular(6), + ), + child: const Text('GitHub Pages live preview', style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w800)), + ), + ), + ], + ), + ), + ); + } +} + +class _PublishedLinkLine extends StatelessWidget { + const _PublishedLinkLine({ + required this.label, + required this.value, + required this.color, + }); + + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 44, child: Text(label, style: const TextStyle(color: _faint, fontSize: 11, fontWeight: FontWeight.w800))), + Expanded( + child: SelectableText( + value, + maxLines: 2, + style: TextStyle(color: color, fontSize: 11, height: 1.25), + ), + ), + ], + ), + ); + } +} + +String _sanitizeRepoName(String value) { + final normalized = value + .trim() + .replaceAll(RegExp(r'\s+'), '-') + .replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '-') + .replaceAll(RegExp(r'-{2,}'), '-') + .replaceAll(RegExp(r'^[-.]+|[-.]+$'), ''); + if (normalized.isEmpty) return 'mobilecode-site'; + return normalized.length <= 80 ? normalized : normalized.substring(0, 80); +} + +class _WebPreviewSheetState extends State<_WebPreviewSheet> { + late final WebViewController _controller; + int _progress = 0; + String? _error; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(_bg) + ..setNavigationDelegate( + NavigationDelegate( + onProgress: (progress) { + if (mounted) setState(() => _progress = progress); + }, + onWebResourceError: (error) { + if (mounted) { + setState(() => _error = '${error.errorCode}: ${error.description}'); + } + }, + ), + ) + ..loadHtmlString(widget.html); + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.preview_outlined, + title: widget.title, + subtitle: widget.subtitle, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Panel( + padding: EdgeInsets.zero, + child: Stack( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.72, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: WebViewWidget(controller: _controller), + ), + ), + if (_progress < 100) + Positioned( + left: 0, + right: 0, + top: 0, + child: LinearProgressIndicator(value: _progress / 100), + ), + ], + ), + ), + if (_error != null) ...[ + const SizedBox(height: 10), + _Panel( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.error_outline, color: _rose, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(_error!, style: const TextStyle(color: _rose, fontSize: 12, height: 1.35)), + ), + ], + ), + ), + ], + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _RuntimeActionButton( + icon: Icons.open_in_browser_outlined, + label: '浏览器打开', + disabled: false, + onTap: () => unawaited(_openHtmlInBrowser(context, widget.html)), + ), + _RuntimeActionButton( + icon: Icons.copy_outlined, + label: 'Copy HTML', + disabled: false, + onTap: () { + Clipboard.setData(ClipboardData(text: widget.html)); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Generated HTML copied.'))); + }, + ), + ], + ), + const SizedBox(height: 10), + const Text( + 'Preview mode runs generated HTML inside the app through Android WebView. Browser open uses a generated data URL because Android app-private files are not directly readable by other apps.', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ], + ), + ); + } +} + +class _CodeFileSheet extends StatefulWidget { + const _CodeFileSheet({ + required this.path, + required this.code, + required this.onOpenEditor, + }); + + final String path; + final String code; + final VoidCallback onOpenEditor; + + @override + State<_CodeFileSheet> createState() => _CodeFileSheetState(); +} + +class _CodeFileSheetState extends State<_CodeFileSheet> { + static const _previewLineCount = 110; + + final _codeScrollController = ScrollController(); + bool _expanded = false; + + @override + void dispose() { + _codeScrollController.dispose(); + super.dispose(); + } + + List get _lines => widget.code.split('\n'); + + Map _htmlSections(List lines) { + final markers = { + 'head': RegExp(r'{}; + for (final entry in markers.entries) { + for (var i = 0; i < lines.length; i++) { + if (entry.value.hasMatch(lines[i])) { + sections[entry.key] = i; + break; + } + } + } + return sections; + } + + void _jumpToLine(int line) { + setState(() => _expanded = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_codeScrollController.hasClients) return; + final offset = ((line - 2).clamp(0, line) * 15.8).toDouble(); + _codeScrollController.animateTo( + offset.clamp(0.0, _codeScrollController.position.maxScrollExtent).toDouble(), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + ); + }); + } + + @override + Widget build(BuildContext context) { + final lines = _lines; + final isHtml = _isWebArtifactPath(widget.path) || widget.code.trimLeft().toLowerCase().startsWith('{}; + final maxCodeHeight = MediaQuery.of(context).size.height * 0.52; + return _SheetScaffold( + icon: Icons.code_outlined, + title: 'Generated Code', + subtitle: widget.path, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _RuntimeActionButton( + icon: Icons.edit_note_outlined, + label: '用编辑器打开', + disabled: false, + onTap: () { + final openEditor = widget.onOpenEditor; + Navigator.of(context).pop(); + WidgetsBinding.instance.addPostFrameCallback((_) => openEditor()); + }, + ), + _RuntimeActionButton( + icon: Icons.copy_outlined, + label: 'Copy code', + disabled: false, + onTap: () { + Clipboard.setData(ClipboardData(text: widget.code)); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Generated code copied.'))); + }, + ), + _RuntimeActionButton( + icon: Icons.folder_copy_outlined, + label: 'Copy path', + disabled: false, + onTap: () { + Clipboard.setData(ClipboardData(text: widget.path)); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Phone file path copied.'))); + }, + ), + _RuntimeActionButton( + icon: _expanded ? Icons.unfold_less_outlined : Icons.unfold_more_outlined, + label: _expanded ? 'Collapse' : 'Expand all', + disabled: lines.length <= _previewLineCount, + onTap: () { + setState(() => _expanded = !_expanded); + if (!_expanded && _codeScrollController.hasClients) { + _codeScrollController.jumpTo(0); + } + }, + ), + ], + ), + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + widget.path, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _TaskDetailChip(label: '${lines.length} lines', color: _cyan), + _TaskDetailChip(label: _formatBytes(utf8.encode(widget.code).length), color: _violet), + if (!_expanded && hiddenLines > 0) _TaskDetailChip(label: '$hiddenLines hidden', color: _amber), + ], + ), + ], + ), + ), + if (sections.isNotEmpty) ...[ + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('HTML quick jump', style: TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final entry in sections.entries) + _CodeSectionChip( + label: entry.key, + line: entry.value + 1, + onTap: () => _jumpToLine(entry.value), + ), + ], + ), + ], + ), + ), + ], + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_expanded ? Icons.subject_outlined : Icons.short_text_outlined, color: _mint, size: 17), + const SizedBox(width: 8), + Expanded( + child: Text( + _expanded ? 'Full file' : 'Preview first $_previewLineCount lines', + style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900), + ), + ), + ], + ), + const SizedBox(height: 10), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxCodeHeight.clamp(260.0, 560.0).toDouble()), + child: Scrollbar( + controller: _codeScrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _codeScrollController, + padding: const EdgeInsets.only(right: 8), + child: SelectableText( + visibleLines.join('\n'), + style: const TextStyle( + color: _text, + fontFamily: 'monospace', + fontSize: 11, + height: 1.35, + ), + ), + ), + ), + ), + if (hiddenLines > 0) ...[ + const SizedBox(height: 10), + Text( + '$hiddenLines more lines are folded to keep this sheet readable.', + style: const TextStyle(color: _amber, fontSize: 11, height: 1.3), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _CodeSectionChip extends StatelessWidget { + const _CodeSectionChip({ + required this.label, + required this.line, + required this.onTap, + }); + + final String label; + final int line; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ActionChip( + avatar: const Icon(Icons.tag_outlined, color: _cyan, size: 15), + label: Text('$label · L$line'), + side: BorderSide(color: _cyan.withOpacity(0.35)), + backgroundColor: _cyan.withOpacity(0.08), + labelStyle: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w800), + onPressed: onTap, + ); + } +} + +String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + final kb = bytes / 1024; + if (kb < 1024) return '${kb.toStringAsFixed(kb >= 100 ? 0 : 1)} KB'; + final mb = kb / 1024; + return '${mb.toStringAsFixed(mb >= 100 ? 0 : 1)} MB'; +} + +class _QuickActionGrid extends StatelessWidget { + const _QuickActionGrid({required this.onAction}); + + final ValueChanged<_ModuleAction> onAction; + + @override + Widget build(BuildContext context) { + final actions = const [ + _QuickAction(Icons.forum_outlined, 'AI Chat', _ModuleAction.aiChat, _mint), + _QuickAction(Icons.hub_outlined, 'GitHub', _ModuleAction.githubTest, _cyan), + _QuickAction(Icons.handyman_outlined, 'Tools', _ModuleAction.toolLab, _amber), + _QuickAction(Icons.terminal_outlined, 'Runtime', _ModuleAction.termuxCheck, _lime), + _QuickAction(Icons.psychology_alt_outlined, 'Deep Dive', _ModuleAction.deepDive, _violet), + _QuickAction(Icons.rocket_launch_outlined, 'Build', _ModuleAction.build, _amber), + _QuickAction(Icons.note_add_outlined, 'New File', _ModuleAction.newFile, _cyan), + _QuickAction(Icons.edit_note_outlined, 'Diary', _ModuleAction.diary, _blue), + _QuickAction(Icons.health_and_safety_outlined, 'Guard', _ModuleAction.healthCheck, _rose), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 680 ? 4 : 2; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: actions.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + childAspectRatio: columns == 2 ? 2.65 : 2.2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemBuilder: (context, index) { + final action = actions[index]; + return _QuickActionTile(action: action, onTap: () => onAction(action.action)); + }, + ); + }, + ); + } +} + +class _QuickAction { + const _QuickAction(this.icon, this.label, this.action, this.color); + + final IconData icon; + final String label; + final _ModuleAction action; + final Color color; +} + +class _QuickActionTile extends StatelessWidget { + const _QuickActionTile({required this.action, required this.onTap}); + + final _QuickAction action; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: _panel, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _line), + ), + child: Row( + children: [ + Icon(action.icon, color: action.color, size: 22), + const SizedBox(width: 10), + Expanded( + child: Text( + action.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontWeight: FontWeight.w700), + ), + ), + const Icon(Icons.chevron_right_outlined, color: _faint, size: 20), + ], + ), + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title, required this.subtitle}); + + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(color: _text, fontSize: 20, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ], + ); + } +} + +class _LayerSelector extends StatelessWidget { + const _LayerSelector({ + required this.layers, + required this.selectedIndex, + required this.onSelected, + }); + + final List<_CapabilityLayer> layers; + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 46, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: layers.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final layer = layers[index]; + final selected = index == selectedIndex; + return Tooltip( + message: layer.subtitle, + child: ChoiceChip( + selected: selected, + onSelected: (_) => onSelected(index), + avatar: Icon(layer.icon, size: 18, color: selected ? _bg : layer.color), + label: Text(layer.name), + labelStyle: TextStyle( + color: selected ? _bg : _text, + fontWeight: FontWeight.w700, + ), + selectedColor: layer.color, + backgroundColor: _panel, + side: BorderSide(color: selected ? layer.color : _line), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ); + }, + ), + ); + } +} + +class _LayerHeader extends StatelessWidget { + const _LayerHeader({required this.layer}); + + final _CapabilityLayer layer; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _panelSoft, + borderRadius: BorderRadius.circular(8), + border: Border(left: BorderSide(color: layer.color, width: 4)), + ), + child: Row( + children: [ + Icon(layer.icon, color: layer.color), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + layer.name, + style: const TextStyle(color: _text, fontSize: 17, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 3), + Text( + layer.subtitle, + style: const TextStyle(color: _muted, fontSize: 12), + ), + ], + ), + ), + _Pill( + label: '${layer.serviceCount} services', + icon: Icons.storage_outlined, + color: layer.color, + ), + ], + ), + ); + } +} + +class _CapabilityCard extends StatelessWidget { + const _CapabilityCard({ + required this.capability, + required this.layerColor, + required this.onRun, + required this.onInspect, + }); + + final _Capability capability; + final Color layerColor; + final VoidCallback onRun; + final VoidCallback onInspect; + + @override + Widget build(BuildContext context) { + final statusColor = _statusColor(capability.status); + return _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: layerColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: layerColor.withOpacity(0.4)), + ), + child: Icon(capability.icon, color: layerColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + capability.title, + style: const TextStyle(color: _text, fontSize: 16, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 3), + Text( + capability.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ], + ), + ), + _StatusPill(status: capability.status, color: statusColor), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final service in capability.services.take(3)) + _MiniChip(label: service, color: layerColor), + if (capability.services.length > 3) + _MiniChip(label: '+${capability.services.length - 3}', color: _faint), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: onRun, + icon: const Icon(Icons.play_arrow_outlined), + label: const Text('Open'), + ), + ), + const SizedBox(width: 10), + IconButton.outlined( + tooltip: 'Inspect services', + onPressed: onInspect, + icon: const Icon(Icons.manage_search_outlined), + ), + ], + ), + ], + ), + ); + } +} + +class _OperationsBoard extends StatelessWidget { + const _OperationsBoard({ + required this.activity, + required this.drafts, + required this.snippets, + required this.healthState, + required this.layerCount, + required this.serviceCount, + }); + + final List<_ActivityLog> activity; + final List<_DraftFile> drafts; + final List<_SnippetDraft> snippets; + final _HealthState healthState; + final int layerCount; + final int serviceCount; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionHeader( + title: 'Operations Board', + subtitle: 'The working surface keeps local drafts, snippets, health, and recent module actions visible.', + ), + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 680 ? 4 : 2; + return GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: columns, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 1.85, + children: [ + _MetricCard(label: 'Layers', value: '$layerCount', icon: Icons.account_tree_outlined, color: _mint), + _MetricCard(label: 'Services', value: '$serviceCount', icon: Icons.storage_outlined, color: _cyan), + _MetricCard(label: 'Drafts', value: '${drafts.length}', icon: Icons.note_add_outlined, color: _amber), + _MetricCard(label: 'Snippets', value: '${snippets.length}', icon: Icons.data_object_outlined, color: _lime), + ], + ); + }, + ), + const SizedBox(height: 12), + _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.history_outlined, color: _mint), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Recent Activity', + style: TextStyle(color: _text, fontWeight: FontWeight.w800, fontSize: 16), + ), + ), + _StatusPill(status: _healthToStatus(healthState), color: _healthColor(healthState)), + ], + ), + const SizedBox(height: 12), + for (final item in activity.take(6)) _ActivityRow(item: item), + ], + ), + ), + if (drafts.isNotEmpty || snippets.isNotEmpty) ...[ + const SizedBox(height: 12), + _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Local Work Items', + style: TextStyle(color: _text, fontWeight: FontWeight.w800, fontSize: 16), + ), + const SizedBox(height: 10), + for (final draft in drafts.take(3)) + _WorkItemRow(icon: Icons.description_outlined, title: draft.name, detail: draft.language, color: _cyan), + for (final snippet in snippets.take(3)) + _WorkItemRow(icon: Icons.data_object_outlined, title: snippet.title, detail: snippet.language, color: _lime), + ], + ), + ), + ], + ], + ); + } +} + +class _MetricCard extends StatelessWidget { + const _MetricCard({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + final String label; + final String value; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 22), + const Spacer(), + Text(value, style: const TextStyle(color: _text, fontSize: 22, fontWeight: FontWeight.w800)), + Text(label, style: const TextStyle(color: _muted, fontSize: 12)), + ], + ), + ); + } +} + +class _ActivityRow extends StatelessWidget { + const _ActivityRow({required this.item}); + + final _ActivityLog item; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(item.icon, color: item.color, size: 20), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title, style: const TextStyle(color: _text, fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + Text( + item.detail, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 12), + ), + ], + ), + ), + Text(_timeLabel(item.time), style: const TextStyle(color: _faint, fontSize: 11)), + ], + ), + ); + } +} + +class _WorkItemRow extends StatelessWidget { + const _WorkItemRow({ + required this.icon, + required this.title, + required this.detail, + required this.color, + }); + + final IconData icon; + final String title; + final String detail; + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 9), + child: Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontWeight: FontWeight.w700), + ), + ), + Text(detail, style: const TextStyle(color: _muted, fontSize: 12)), + ], + ), + ); + } +} + +class _BottomNav extends StatelessWidget { + const _BottomNav({required this.tab, required this.onChanged}); + + final _HomeTab tab; + final ValueChanged<_HomeTab> onChanged; + + @override + Widget build(BuildContext context) { + final selectedIndex = switch (tab) { + _HomeTab.control => 0, + _HomeTab.ai => 0, + _HomeTab.ship => 1, + _HomeTab.guard => 2, + _HomeTab.insight => 2, + }; + return Container( + decoration: const BoxDecoration( + color: _panel, + border: Border(top: BorderSide(color: _line)), + ), + child: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: (index) => onChanged(switch (index) { + 0 => _HomeTab.control, + 1 => _HomeTab.ship, + _ => _HomeTab.guard, + }), + backgroundColor: _panel, + indicatorColor: _mint.withOpacity(0.16), + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + destinations: const [ + NavigationDestination(icon: Icon(Icons.forum_outlined), selectedIcon: Icon(Icons.forum), label: 'Chat'), + NavigationDestination(icon: Icon(Icons.handyman_outlined), selectedIcon: Icon(Icons.handyman), label: 'Tools'), + NavigationDestination(icon: Icon(Icons.tune_outlined), selectedIcon: Icon(Icons.tune), label: 'Settings'), + ], + ), + ); + } +} + +class _GitHubTestSheet extends StatefulWidget { + const _GitHubTestSheet({ + required this.onOpenWeb, + required this.onLog, + }); + + final VoidCallback onOpenWeb; + final void Function(String title, String detail, IconData icon, Color color) onLog; + + @override + State<_GitHubTestSheet> createState() => _GitHubTestSheetState(); +} + +class _GitHubTestSheetState extends State<_GitHubTestSheet> { + final _token = TextEditingController(); + final _repo = TextEditingController(text: 'Harzva/mobilecode'); + bool _testing = false; + final List _lines = ['Not tested yet.']; + + @override + void dispose() { + _token.dispose(); + _repo.dispose(); + super.dispose(); + } + + Future _run() async { + setState(() { + _testing = true; + _lines + ..clear() + ..add('Testing GitHub...'); + }); + final token = _token.text.trim(); + final repo = _repo.text.trim().isEmpty ? 'Harzva/mobilecode' : _repo.text.trim(); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final user = await _get(client, 'https://api.github.com/user', token); + final repoRes = await _get(client, 'https://api.github.com/repos/$repo', token); + final pages = await _get(client, 'https://api.github.com/repos/$repo/pages', token); + if (!mounted) return; + setState(() { + _lines + ..clear() + ..add('${user.statusCode == 200 ? 'OK' : 'FAIL'} /user HTTP ${user.statusCode}') + ..add('${repoRes.statusCode == 200 ? 'OK' : 'FAIL'} repo HTTP ${repoRes.statusCode}') + ..add('${pages.statusCode == 200 ? 'OK' : 'WARN'} pages HTTP ${pages.statusCode}'); + if (user.body.contains('"login"')) _lines.add('Identity response received.'); + if (repoRes.statusCode != 200) _lines.add('Repo test failed: missing token scope or repo access.'); + if (pages.statusCode == 403) _lines.add('Pages permission missing: fine-grained tokens need Pages write + Administration write; classic PATs need repo.'); + if (pages.statusCode == 404) _lines.add('Pages not enabled yet, repo is private/invisible, or token cannot read Pages settings.'); + if (pages.statusCode == 422) _lines.add('GitHub rejected the Pages settings. Check repo name, branch, and visibility.'); + }); + widget.onLog( + repoRes.statusCode == 200 ? 'GitHub connected' : 'GitHub test failed', + 'repo HTTP ${repoRes.statusCode}', + repoRes.statusCode == 200 ? Icons.hub_outlined : Icons.error_outline, + repoRes.statusCode == 200 ? _mint : _rose, + ); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _lines + ..clear() + ..add('Network error: ${_compact(error.toString(), limit: 180)}'); + }); + widget.onLog('GitHub network error', _compact(error.toString(), limit: 120), Icons.error_outline, _rose); + } finally { + client.close(force: true); + if (mounted) setState(() => _testing = false); + } + } + + Future<({int statusCode, String body})> _get(HttpClient client, String url, String token) async { + final request = await client.getUrl(Uri.parse(url)).timeout(const Duration(seconds: 10)); + request.headers.set(HttpHeaders.acceptHeader, 'application/vnd.github+json'); + request.headers.set('X-GitHub-Api-Version', '2022-11-28'); + if (token.isNotEmpty) { + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token'); + } + final response = await request.close().timeout(const Duration(seconds: 20)); + final body = await utf8.decodeStream(response); + return (statusCode: response.statusCode, body: body); + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.hub_outlined, + title: 'GitHub Connectivity', + subtitle: 'Test token identity, repository access, and Pages readiness.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _token, + obscureText: true, + decoration: const InputDecoration(labelText: 'GitHub token', prefixIcon: Icon(Icons.key_outlined)), + ), + const SizedBox(height: 10), + TextField( + controller: _repo, + decoration: const InputDecoration(labelText: 'Owner/repo', prefixIcon: Icon(Icons.account_tree_outlined)), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _testing ? null : _run, + icon: _testing + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.network_check_outlined), + label: Text(_testing ? 'Testing' : 'Test in APK'), + ), + ), + const SizedBox(width: 10), + IconButton.outlined( + tooltip: 'Open web test page', + onPressed: widget.onOpenWeb, + icon: const Icon(Icons.open_in_browser_outlined), + ), + ], + ), + const SizedBox(height: 14), + _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in _lines) _GitHubConnectivityLine(line: line), + ], + ), + ), + ], + ), + ); + } +} + +class _GitHubConnectivityLine extends StatelessWidget { + const _GitHubConnectivityLine({required this.line}); + + final String line; + + @override + Widget build(BuildContext context) { + final color = _githubConnectivityColor(line); + return Padding( + padding: const EdgeInsets.only(bottom: 7), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 9, + height: 9, + margin: const EdgeInsets.only(top: 6), + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 9), + Expanded( + child: Text( + line, + style: TextStyle(color: color == _faint ? _muted : color, height: 1.35), + ), + ), + ], + ), + ); + } +} + +Color _githubConnectivityColor(String line) { + final lower = line.toLowerCase(); + if (lower.startsWith('ok') || lower.contains('identity response')) return _mint; + if (lower.startsWith('fail') || lower.startsWith('network error') || lower.contains('failed')) return _rose; + if (lower.startsWith('warn') || lower.contains('permission missing') || lower.contains('not enabled') || lower.contains('rejected')) return _amber; + return _faint; +} + +class _LarkCliDiagnosticsSheet extends StatefulWidget { + const _LarkCliDiagnosticsSheet({ + required this.runtimeManager, + required this.onOpenDocs, + required this.onLog, + }); + + final RuntimeManager runtimeManager; + final VoidCallback onOpenDocs; + final void Function(String title, String detail, IconData icon, Color color) onLog; + + @override + State<_LarkCliDiagnosticsSheet> createState() => _LarkCliDiagnosticsSheetState(); +} + +class _LarkCliDiagnosticsSheetState extends State<_LarkCliDiagnosticsSheet> { + final _query = TextEditingController(text: 'MobileCode'); + final _title = TextEditingController(text: 'MobileCode draft'); + final _content = TextEditingController(text: 'Generated from MobileCode. Review before publishing.'); + final _target = TextEditingController(); + String _selectedAction = 'docs_search'; + bool _checking = false; + bool _runningAction = false; + final List _lines = [ + 'Lark CLI connector is opt-in. MobileCode only runs fixed diagnostic commands through RuntimeProvider.', + 'Install official larksuite/cli, then run config init and auth login --recommend in your runtime.', + ]; + + @override + void dispose() { + _query.dispose(); + _title.dispose(); + _content.dispose(); + _target.dispose(); + super.dispose(); + } + + Future _runDiagnostics() async { + setState(() { + _checking = true; + _lines + ..clear() + ..add('Checking active RuntimeProvider...'); + }); + + try { + await widget.runtimeManager.initialize(); + final capabilities = await widget.runtimeManager.capabilities(); + if (!capabilities.shell) { + _finish(false, 'Active runtime has no shell capability. Start MobileCode Helper or External Termux before using lark-cli.'); + return; + } + + final version = await widget.runtimeManager.execute( + 'lark-cli --version || lark --version', + timeout: const Duration(seconds: 10), + ); + if (!version.success) { + _finish( + false, + 'lark-cli is not available in the active runtime.\nInstall: https://github.com/larksuite/cli\nThen run: lark-cli config init && lark-cli auth login --recommend', + ); + return; + } + + final auth = await widget.runtimeManager.execute( + 'lark-cli auth status --output json || lark-cli auth status || lark auth status --output json || lark auth status', + timeout: const Duration(seconds: 12), + ); + final authOutput = (auth.stdout.trim().isNotEmpty ? auth.stdout : auth.stderr).trim(); + _finish( + auth.success, + [ + 'lark-cli detected: ${(version.stdout.trim().isEmpty ? version.stderr : version.stdout).trim()}', + if (authOutput.isNotEmpty) 'auth status: ${_compact(authOutput, limit: 320)}' else 'auth status returned no output.', + 'Next structured actions will stay opt-in: docs search, task creation, wiki draft, and dry-run message compose.', + ].join('\n'), + ); + } on Object catch (error) { + _finish(false, _compact(error.toString(), limit: 360)); + } + } + + void _finish(bool ok, String message) { + if (!mounted) return; + setState(() { + _checking = false; + _lines + ..clear() + ..add(message); + }); + widget.onLog( + ok ? 'Lark CLI ready' : 'Lark CLI needs setup', + _compact(message, limit: 120), + ok ? Icons.business_center_outlined : Icons.info_outline, + ok ? _mint : _amber, + ); + } + + Future _runStructuredAction() async { + final command = _buildStructuredCommand(); + if (command == null) { + setState(() { + _lines + ..clear() + ..add('Fill the required fields before running this structured action.'); + }); + return; + } + + setState(() { + _runningAction = true; + _lines + ..clear() + ..add('Running structured Lark action:') + ..add(command); + }); + + try { + final result = await widget.runtimeManager.execute(command, timeout: const Duration(seconds: 30)); + final output = (result.stdout.trim().isNotEmpty ? result.stdout : result.stderr).trim(); + if (!mounted) return; + setState(() { + _runningAction = false; + _lines + ..clear() + ..add(result.success ? 'Structured action completed.' : 'Structured action failed.') + ..add('Command: $command') + ..add(output.isEmpty ? 'No output returned.' : _compact(output, limit: 1200)); + }); + widget.onLog( + result.success ? 'Lark action completed' : 'Lark action failed', + _compact(output.isEmpty ? command : output, limit: 120), + result.success ? Icons.business_center_outlined : Icons.error_outline, + result.success ? _mint : _rose, + ); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _runningAction = false; + _lines + ..clear() + ..add('Structured action failed.') + ..add(_compact(error.toString(), limit: 800)); + }); + } + } + + String? _buildStructuredCommand() { + final title = _title.text.trim(); + final content = _content.text.trim(); + final query = _query.text.trim(); + final target = _target.text.trim(); + return switch (_selectedAction) { + 'docs_search' when query.isNotEmpty => + 'lark-cli docs +search --query ${_quoteCommandArg(query)} --format json || lark-cli drive +search --query ${_quoteCommandArg(query)} --format json', + 'task_create' when title.isNotEmpty => + 'lark-cli task +create --title ${_quoteCommandArg(title)} --notes ${_quoteCommandArg(content)} --dry-run --format json', + 'wiki_draft' when title.isNotEmpty && content.isNotEmpty => + 'lark-cli docs +create --api-version v2 --doc-format markdown --title ${_quoteCommandArg(title)} --content ${_quoteCommandArg('# $title\n\n$content')} --dry-run --format json', + 'message_dry_run' when target.isNotEmpty && content.isNotEmpty => + 'lark-cli im +messages-send --chat-id ${_quoteCommandArg(target)} --text ${_quoteCommandArg(content)} --dry-run --format json', + _ => null, + }; + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.business_center_outlined, + title: 'Lark CLI Connector', + subtitle: 'Opt-in first-party connector: diagnostics and auth guidance only in v1.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Panel( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Controlled connector boundary', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + SizedBox(height: 8), + Text( + 'MobileCode does not expose arbitrary Lark shell execution. This connector first checks the official CLI, auth status, and missing setup. Write actions will stay structured and confirm-before-send.', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ], + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _RuntimeActionButton( + icon: Icons.health_and_safety_outlined, + label: _checking ? 'Checking' : 'Run diagnostics', + disabled: _checking, + onTap: _runDiagnostics, + ), + _RuntimeActionButton( + icon: Icons.open_in_browser_outlined, + label: 'Official CLI', + disabled: false, + onTap: widget.onOpenDocs, + ), + ], + ), + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Structured actions', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + const Text( + 'These are fixed Lark CLI flows. Task, wiki, and message actions default to --dry-run so the user can review before any write.', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + value: _selectedAction, + decoration: const InputDecoration(labelText: 'Action', prefixIcon: Icon(Icons.route_outlined)), + items: const [ + DropdownMenuItem(value: 'docs_search', child: Text('Document search')), + DropdownMenuItem(value: 'task_create', child: Text('Create task dry-run')), + DropdownMenuItem(value: 'wiki_draft', child: Text('Wiki/doc draft dry-run')), + DropdownMenuItem(value: 'message_dry_run', child: Text('Message dry-run')), + ], + onChanged: _runningAction ? null : (value) => setState(() => _selectedAction = value ?? 'docs_search'), + ), + const SizedBox(height: 10), + if (_selectedAction == 'docs_search') + TextField( + controller: _query, + enabled: !_runningAction, + decoration: const InputDecoration(labelText: 'Search query', prefixIcon: Icon(Icons.search_outlined)), + ) + else ...[ + TextField( + controller: _title, + enabled: !_runningAction, + decoration: const InputDecoration(labelText: 'Title', prefixIcon: Icon(Icons.title_outlined)), + ), + const SizedBox(height: 8), + TextField( + controller: _content, + enabled: !_runningAction, + minLines: 3, + maxLines: 6, + decoration: const InputDecoration(labelText: 'Content', alignLabelWithHint: true, prefixIcon: Icon(Icons.notes_outlined)), + ), + if (_selectedAction == 'message_dry_run') ...[ + const SizedBox(height: 8), + TextField( + controller: _target, + enabled: !_runningAction, + decoration: const InputDecoration(labelText: 'Chat ID', prefixIcon: Icon(Icons.alternate_email_outlined)), + ), + ], + ], + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _runningAction ? null : _runStructuredAction, + icon: _runningAction + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.play_arrow_outlined), + label: Text(_runningAction ? 'Running action' : 'Run structured action'), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: SelectableText( + _lines.join('\n\n'), + style: const TextStyle(color: _muted, fontSize: 12, height: 1.4), + ), + ), + const SizedBox(height: 12), + const Text( + 'Setup commands inside the active runtime: lark-cli config init, then lark-cli auth login --recommend. MobileCode will show missing scopes before any future write action.', + style: TextStyle(color: _faint, fontSize: 11, height: 1.35), + ), + ], + ), + ); + } +} + +class _DiarySheet extends StatefulWidget { + const _DiarySheet({required this.onLog}); + + final void Function(String title, String detail, IconData icon, Color color) onLog; + + @override + State<_DiarySheet> createState() => _DiarySheetState(); +} + +class _DiarySheetState extends State<_DiarySheet> { + static const _key = 'mobilecode.diary.entries'; + final _title = TextEditingController(); + final _body = TextEditingController(); + final List<_DiaryEntry> _entries = []; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _title.dispose(); + _body.dispose(); + super.dispose(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) return; + final decoded = jsonDecode(raw); + if (decoded is List && mounted) { + setState(() { + _entries + ..clear() + ..addAll(decoded.whereType().map((item) => _DiaryEntry.fromJson(Map.from(item)))); + }); + } + } + + Future _save() async { + final title = _title.text.trim().isEmpty ? 'Daily note' : _title.text.trim(); + final body = _body.text.trim(); + if (body.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Write diary content first'))); + return; + } + setState(() { + _entries.insert(0, _DiaryEntry(title: title, body: body, time: DateTime.now())); + _title.clear(); + _body.clear(); + }); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, jsonEncode(_entries.map((entry) => entry.toJson()).toList())); + widget.onLog('Diary entry saved', title, Icons.edit_note_outlined, _amber); + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.edit_note_outlined, + title: 'Diary APK Demo', + subtitle: 'A tiny local app inside MobileCode. It proves forms, storage, list rendering, and APK runtime.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _title, + decoration: const InputDecoration(labelText: 'Title', prefixIcon: Icon(Icons.title_outlined)), + ), + const SizedBox(height: 10), + TextField( + controller: _body, + minLines: 4, + maxLines: 8, + decoration: const InputDecoration(labelText: 'Today I...', alignLabelWithHint: true), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _save, + icon: const Icon(Icons.save_outlined), + label: const Text('Save diary entry'), + ), + ), + const SizedBox(height: 16), + Text('Entries (${_entries.length})', style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + if (_entries.isEmpty) + const Text('No entries yet.', style: TextStyle(color: _muted)) + else + for (final entry in _entries.take(5)) + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.title, style: const TextStyle(color: _text, fontWeight: FontWeight.w800)), + const SizedBox(height: 4), + Text(_timeLabel(entry.time), style: const TextStyle(color: _faint, fontSize: 11)), + const SizedBox(height: 6), + Text(entry.body, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _muted, height: 1.35)), + ], + ), + ), + ], + ), + ); + } +} + +class _ToolLabSheet extends StatefulWidget { + const _ToolLabSheet({ + required this.baseUrl, + required this.apiKey, + required this.model, + required this.onOpen2048, + required this.onOpenGitHubWeb, + required this.onLog, + }); + + final String baseUrl; + final String apiKey; + final String model; + final VoidCallback onOpen2048; + final VoidCallback onOpenGitHubWeb; + final void Function(String title, String detail, IconData icon, Color color) onLog; + + @override + State<_ToolLabSheet> createState() => _ToolLabSheetState(); +} + +class _ToolLabSheetState extends State<_ToolLabSheet> { + final List<_ToolProbeResult> _results = []; + bool _running = false; + + static const _tools = [ + _ToolProbe(name: 'AI provider health', detail: 'Uses configured Base URL and model.', icon: Icons.monitor_heart_outlined, action: 'health'), + _ToolProbe(name: 'GitHub web tester', detail: 'Opens a Pages test page for token and repo checks.', icon: Icons.hub_outlined, action: 'github_web'), + _ToolProbe(name: 'Code 2048 project', detail: 'Runs the local coding lab and WebView preview flow.', icon: Icons.grid_4x4_outlined, action: 'demo_2048'), + _ToolProbe(name: 'Local storage', detail: 'Writes and reads SharedPreferences.', icon: Icons.save_outlined, action: 'storage'), + _ToolProbe(name: 'Runtime providers', detail: 'Checks MobileCode Helper and External Termux fallback.', icon: Icons.terminal_outlined, action: 'runtime'), + _ToolProbe(name: 'Root permission', detail: 'Detects whether a su binary is visible for backend keepalive.', icon: Icons.admin_panel_settings_outlined, action: 'root'), + ]; + + Future _runAll() async { + setState(() { + _running = true; + _results.clear(); + }); + await _run('storage'); + await _run('runtime'); + await _run('root'); + await _run('health'); + if (mounted) setState(() => _running = false); + } + + Future _run(String action) async { + if (action == 'github_web') { + widget.onOpenGitHubWeb(); + _addResult('GitHub web tester', true, 'Opened external browser page.'); + return; + } + if (action == 'demo_2048') { + widget.onOpen2048(); + _addResult('Code 2048 project', true, 'Opened Mobile Coding Lab.'); + return; + } + if (action == 'storage') { + final prefs = await SharedPreferences.getInstance(); + final stamp = DateTime.now().toIso8601String(); + await prefs.setString('mobilecode.tool.storageProbe', stamp); + final ok = prefs.getString('mobilecode.tool.storageProbe') == stamp; + _addResult('Local storage', ok, ok ? 'Write/read succeeded.' : 'Readback mismatch.'); + return; + } + if (action == 'runtime') { + final helper = await _probeHelperDaemon(); + final installed = await _isAndroidPackageInstalled('com.termux'); + final apiInstalled = await _isAndroidPackageInstalled('com.termux.api'); + if (helper.ready) { + _addResult('Runtime providers', true, helper.detail); + } else if (installed == true) { + final termuxDetail = apiInstalled == true ? 'External Termux + External Termux:API fallback detected.' : 'External Termux fallback detected; External Termux:API not detected.'; + _addResult('Runtime providers', true, '${helper.detail} $termuxDetail'); + } else if (installed == false) { + _addResult('Runtime providers', false, '${helper.detail} External Termux is not installed or not visible.'); + } else { + final urlVisible = await canLaunchUrl(Uri.parse('termux://')); + _addResult( + 'Runtime providers', + urlVisible, + urlVisible + ? '${helper.detail} termux:// handler is visible; package channel unavailable.' + : '${helper.detail} No Helper daemon and package channel is unavailable.', + ); + } + return; + } + if (action == 'root') { + final probe = await _probeRootAvailability(); + if (probe == null) { + _addResult('Root permission', false, 'Root probe channel is unavailable in this build.'); + } else { + _addResult('Root permission', probe.available, probe.detail); + } + return; + } + if (action == 'health') { + if (widget.baseUrl.isEmpty) { + _addResult('AI provider health', false, 'Base URL is empty.'); + return; + } + final flavor = _detectApiFlavor(widget.baseUrl, widget.model); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 8); + try { + final uri = flavor == _ApiFlavor.anthropic ? _anthropicMessagesUri(widget.baseUrl) : _openAiChatUri(widget.baseUrl); + _parseBaseUrl(widget.baseUrl); + final request = flavor == _ApiFlavor.anthropic + ? await client.postUrl(uri).timeout(const Duration(seconds: 8)) + : await client.getUrl(Uri.parse('${_normalizedBaseUrl(widget.baseUrl)}/models')).timeout(const Duration(seconds: 8)); + if (widget.apiKey.isNotEmpty) { + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer ${widget.apiKey}'); + if (flavor == _ApiFlavor.anthropic) request.headers.set('x-api-key', widget.apiKey); + } + if (flavor == _ApiFlavor.anthropic) { + request.headers.contentType = ContentType.json; + request.headers.set('anthropic-version', '2023-06-01'); + request.write(jsonEncode({ + 'model': widget.model.isEmpty ? 'claude-3-5-haiku-latest' : widget.model, + 'max_tokens': 1, + 'messages': [ + {'role': 'user', 'content': 'ping'}, + ], + })); + } + final response = await request.close().timeout(const Duration(seconds: 30)); + await response.drain(); + _addResult('AI provider health', response.statusCode >= 200 && response.statusCode < 300, 'HTTP ${response.statusCode} via ${_flavorLabel(flavor)}'); + } on Object catch (error) { + _addResult('AI provider health', false, _compact(error.toString(), limit: 120)); + } finally { + client.close(force: true); + } + } + } + + Future<_HelperDaemonProbeResult> _probeHelperDaemon() async { + final first = await _probeHelperDaemonOnce(); + if (first.ready) return first; + + final started = await _startMobileCodeHelperService(); + if (started != true) return first; + await Future.delayed(const Duration(milliseconds: 400)); + final second = await _probeHelperDaemonOnce(); + if (second.ready) return second; + return _HelperDaemonProbeResult( + ready: false, + detail: '${first.detail} Native Helper service start was requested, but localhost is still not ready.', + ); + } + + Future<_HelperDaemonProbeResult> _probeHelperDaemonOnce() async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 2); + try { + final uri = Uri.parse('http://127.0.0.1:8765/v1/health'); + final request = await client.getUrl(uri).timeout(const Duration(seconds: 2)); + final response = await request.close().timeout(const Duration(seconds: 3)); + final body = await utf8.decodeStream(response); + final decoded = jsonDecode(body); + final name = decoded is Map ? decoded['name'] as String? ?? 'MobileCode Helper' : 'MobileCode Helper'; + final status = decoded is Map ? decoded['status'] as String? ?? 'responded' : 'responded'; + final ready = response.statusCode >= 200 && response.statusCode < 300 && decoded is Map && decoded['ready'] == true; + return _HelperDaemonProbeResult( + ready: ready, + detail: ready ? '$name daemon ready: $status.' : '$name daemon responded but is not ready: $status.', + ); + } on Object catch (error) { + return _HelperDaemonProbeResult( + ready: false, + detail: 'MobileCode Helper daemon not reachable on 127.0.0.1:8765 (${_compact(error.toString(), limit: 80)}).', + ); + } finally { + client.close(force: true); + } + } + + void _addResult(String name, bool ok, String message) { + if (!mounted) return; + setState(() { + _results.insert(0, _ToolProbeResult(name: name, ok: ok, message: message)); + }); + widget.onLog(ok ? '$name OK' : '$name failed', message, ok ? Icons.check_circle_outline : Icons.error_outline, ok ? _mint : _rose); + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.handyman_outlined, + title: 'Mobile Tool Tests', + subtitle: 'Run small probes for phone-optimized tools and see what fails.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Panel( + padding: const EdgeInsets.all(12), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tool capability map', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + SizedBox(height: 8), + _ToolScopeLine(icon: Icons.phone_android_outlined, color: _mint, title: 'Direct Android/Flutter', detail: 'storage, network, WebView preview, clipboard, sensors, camera, microphone, notifications, secure storage, GitHub HTTP APIs'), + _ToolScopeLine(icon: Icons.terminal_outlined, color: _amber, title: 'Needs runtime', detail: 'Helper daemon, External Termux fallback, git/ssh binaries, npm/python package managers, local build scripts, long-running command sessions'), + _ToolScopeLine(icon: Icons.cloud_outlined, color: _cyan, title: 'Better remote', detail: 'heavy builds, concurrent agent runs, private repo automation, CI release signing, team sync'), + ], + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _running ? null : _runAll, + icon: _running + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.play_arrow_outlined), + label: Text(_running ? 'Running probes' : 'Run core probes'), + ), + ), + const SizedBox(height: 12), + for (final tool in _tools) + _Panel( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(tool.icon, color: _cyan), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tool.name, style: const TextStyle(color: _text, fontWeight: FontWeight.w800)), + const SizedBox(height: 2), + Text(tool.detail, style: const TextStyle(color: _muted, fontSize: 12)), + ], + ), + ), + IconButton.outlined( + onPressed: _running ? null : () => _run(tool.action), + icon: const Icon(Icons.play_arrow_outlined), + ), + ], + ), + ), + if (_results.isNotEmpty) ...[ + const SizedBox(height: 12), + _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Results', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + for (final result in _results) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(result.ok ? Icons.check_circle_outline : Icons.error_outline, color: result.ok ? _mint : _rose, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text('${result.name}: ${result.message}', style: const TextStyle(color: _muted, height: 1.35)), + ), + ], + ), + ), + ], + ), + ), + ], + ], + ), + ); + } +} + +class _ToolScopeLine extends StatelessWidget { + const _ToolScopeLine({ + required this.icon, + required this.color, + required this.title, + required this.detail, + }); + + final IconData icon; + final Color color; + final String title; + final String detail; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: RichText( + text: TextSpan( + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + children: [ + TextSpan(text: '$title: ', style: TextStyle(color: color, fontWeight: FontWeight.w900)), + TextSpan(text: detail), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _RuntimeDiagnosticsSheet extends StatefulWidget { + const _RuntimeDiagnosticsSheet({ + required this.runtimeManager, + required this.initialHealth, + required this.initialCapabilities, + required this.termuxInstalled, + required this.termuxApiInstalled, + required this.rootAvailable, + required this.onOpenInstall, + required this.onRefreshParent, + required this.onLog, + }); + + final RuntimeManager runtimeManager; + final List initialHealth; + final RuntimeCapabilities initialCapabilities; + final bool? termuxInstalled; + final bool? termuxApiInstalled; + final bool? rootAvailable; + final VoidCallback onOpenInstall; + final Future Function() onRefreshParent; + final void Function(String title, String detail, IconData icon, Color color) onLog; + + @override + State<_RuntimeDiagnosticsSheet> createState() => _RuntimeDiagnosticsSheetState(); +} + +class _RuntimeDiagnosticsSheetState extends State<_RuntimeDiagnosticsSheet> { + bool _checking = false; + late List _health; + late RuntimeCapabilities _capabilities; + bool? _termuxInstalled; + bool? _termuxApiInstalled; + bool? _rootAvailable; + RuntimeTaskSnapshot? _task; + String _status = 'Runtime diagnostics are ready to refresh.'; + + @override + void initState() { + super.initState(); + _health = widget.initialHealth; + _capabilities = widget.initialCapabilities; + _termuxInstalled = widget.termuxInstalled; + _termuxApiInstalled = widget.termuxApiInstalled; + _rootAvailable = widget.rootAvailable; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_refresh()); + }); + } + + Future _refresh() async { + setState(() { + _checking = true; + _status = 'Checking Helper, External Termux, root, and active task state...'; + }); + try { + await _startMobileCodeHelperService(); + await widget.runtimeManager.initialize(); + final health = await widget.runtimeManager.refresh(); + final capabilities = await widget.runtimeManager.capabilities(); + final termux = await _isAndroidPackageInstalled('com.termux'); + final termuxApi = await _isAndroidPackageInstalled('com.termux.api'); + final rootProbe = await _probeRootAvailability(); + final task = await widget.runtimeManager.currentTaskSnapshot(); + if (!mounted) return; + final active = widget.runtimeManager.activeHealth; + setState(() { + _health = health; + _capabilities = capabilities; + _termuxInstalled = termux; + _termuxApiInstalled = termuxApi; + _rootAvailable = rootProbe?.available; + _task = task; + _status = '${active?.name ?? 'No runtime'}: ${active?.status ?? 'No provider responded.'}'; + }); + widget.onLog( + active?.ready == true ? 'Runtime diagnostics ready' : 'Runtime diagnostics need setup', + _status, + active?.ready == true ? Icons.verified_outlined : Icons.warning_amber_outlined, + active?.ready == true ? _mint : _amber, + ); + unawaited(widget.onRefreshParent()); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _status = _compact(error.toString(), limit: 180); + }); + widget.onLog('Runtime diagnostics failed', _status, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _checking = false); + } + } + + Future _launchTermux() async { + final opened = await _launchAndroidPackage('com.termux'); + if (!mounted) return; + final message = opened + ? 'External Termux launch intent sent.' + : 'Could not launch com.termux. It may be missing, disabled, or hidden.'; + setState(() => _status = message); + widget.onLog( + opened ? 'External Termux launched' : 'External Termux launch failed', + message, + opened ? Icons.open_in_new_outlined : Icons.error_outline, + opened ? _mint : _rose, + ); + } + + Future _stopTask(String taskId) async { + setState(() { + _checking = true; + _status = 'Stopping runtime task $taskId...'; + }); + try { + await widget.runtimeManager.stopTask(taskId); + final task = await widget.runtimeManager.currentTaskSnapshot(); + if (!mounted) return; + setState(() { + _task = task; + _status = task == null ? 'No active runtime task after stop request.' : 'Task ${task.taskId} is ${task.status.name}.'; + }); + widget.onLog('Runtime task stop requested', _status, Icons.stop_circle_outlined, _amber); + unawaited(widget.onRefreshParent()); + } on Object catch (error) { + if (!mounted) return; + setState(() => _status = _compact(error.toString(), limit: 180)); + widget.onLog('Runtime task stop failed', _status, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _checking = false); + } + } + + @override + Widget build(BuildContext context) { + final active = widget.runtimeManager.activeHealth ?? (_health.isNotEmpty ? _health.first : null); + final healthItems = [..._health] + ..sort((a, b) { + int rank(RuntimeHealth item) { + if (item.ready) return 0; + if (item.available) return 1; + if (item.type == RuntimeProviderType.embeddedLite || item.type == RuntimeProviderType.cloud) return 3; + return 2; + } + + return rank(a).compareTo(rank(b)); + }); + return _SheetScaffold( + icon: Icons.monitor_heart_outlined, + title: 'Runtime Diagnostics', + subtitle: 'Helper, External Termux fallback, task recovery, and capability status in one place.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(active?.name ?? 'Runtime discovery pending', style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 6), + Text(_status, style: const TextStyle(color: _muted, height: 1.4)), + const SizedBox(height: 8), + Text('Capabilities: ${_runtimeCapabilitiesText(_capabilities)}', style: const TextStyle(color: _faint, fontSize: 12, height: 1.35)), + ], + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _checking ? null : _refresh, + icon: _checking + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.refresh_outlined), + label: Text(_checking ? 'Refreshing' : 'Refresh'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: _launchTermux, + icon: const Icon(Icons.open_in_new_outlined), + label: const Text('Open External Termux'), + ), + ), + ], + ), + const SizedBox(height: 12), + if (healthItems.isEmpty) + const Text('No runtime health records yet.', style: TextStyle(color: _muted)) + else + for (final health in healthItems) ...[ + _RuntimeHealthTile(health: health), + const SizedBox(height: 8), + ], + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Fallback visibility', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + _DiagnosticLine(label: 'External Termux', value: _boolStatus(_termuxInstalled), good: _termuxInstalled == true), + _DiagnosticLine(label: 'External Termux:API (optional)', value: _boolStatus(_termuxApiInstalled), good: true), + _DiagnosticLine(label: 'Root keepalive', value: _boolStatus(_rootAvailable), good: _rootAvailable == true), + ], + ), + ), + const SizedBox(height: 12), + _TaskSnapshotPanel( + task: _task, + onStop: _task?.canCancel == true && !_checking ? () => _stopTask(_task!.taskId) : null, + onOpenDetails: _task == null + ? null + : () => _showRuntimeTaskDetailsSheet( + context: context, + runtimeManager: widget.runtimeManager, + task: _task!, + onLog: widget.onLog, + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: widget.onOpenInstall, + icon: const Icon(Icons.download_outlined), + label: const Text('External Termux install guide'), + ), + const SizedBox(height: 12), + const Text( + 'External Termux remains a fallback. MobileCode should prefer Helper, Embedded Lite, or Cloud providers through RuntimeManager whenever possible.', + style: TextStyle(color: _muted, fontSize: 12, height: 1.4), + ), + ], + ), + ); + } +} + +class _RuntimeActionsSheet extends StatefulWidget { + const _RuntimeActionsSheet({ + required this.icon, + required this.title, + required this.subtitle, + required this.runtimeManager, + required this.defaultPackageManager, + required this.onLog, + }); + + final IconData icon; + final String title; + final String subtitle; + final RuntimeManager runtimeManager; + final String defaultPackageManager; + final void Function(String title, String detail, IconData icon, Color color) onLog; + + @override + State<_RuntimeActionsSheet> createState() => _RuntimeActionsSheetState(); +} + +class _RuntimeActionsSheetState extends State<_RuntimeActionsSheet> { + final _projectPath = TextEditingController(text: '/data/data/com.mobilecode.mobile_agent/files/mobilecode_runtime'); + final _message = TextEditingController(text: 'mobile runtime update'); + final List _lines = ['No runtime action has run yet.']; + bool _running = false; + bool _cancelling = false; + late String _packageManager; + RuntimeActionType? _lastFailedAction; + RuntimeProjectProfile? _lastProjectProfile; + RuntimeTaskSnapshot? _lastTask; + + @override + void initState() { + super.initState(); + _packageManager = widget.defaultPackageManager; + } + + @override + void dispose() { + _projectPath.dispose(); + _message.dispose(); + super.dispose(); + } + + Future _run(RuntimeActionType type) async { + final projectPath = _projectPath.text.trim(); + if (projectPath.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); + return; + } + setState(() { + _running = true; + _lines.insert(0, 'Running ${type.name}...'); + }); + try { + final result = await widget.runtimeManager.runAction(_requestFor(type, projectPath)); + if (!mounted) return; + final tail = result.lastResult; + final detail = [ + result.summary, + if (result.skippedReason != null) result.skippedReason!, + if (result.recoveryHint != null) 'Recovery: ${result.recoveryHint!}', + if (tail != null && tail.stdout.trim().isNotEmpty) _compact(tail.stdout.trim(), limit: 160), + if (tail != null && tail.stderr.trim().isNotEmpty) _compact(tail.stderr.trim(), limit: 160), + ].join('\n'); + setState(() { + _lastFailedAction = result.success ? null : result.action; + _lines.insert(0, '${result.success ? 'OK' : 'FAILED'} ${type.name}: $detail'); + }); + widget.onLog( + result.success ? 'Runtime action completed' : 'Runtime action failed', + '${type.name}: ${result.summary}', + result.success ? Icons.check_circle_outline : Icons.error_outline, + result.success ? _mint : _rose, + ); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() { + _lastFailedAction = type; + _lines.insert(0, 'ERROR ${type.name}: $message'); + }); + widget.onLog('Runtime action error', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _running = false); + } + } + + RuntimeActionRequest _requestFor(RuntimeActionType type, String projectPath) { + return RuntimeActionRequest( + type: type, + projectPath: projectPath, + packageManager: _selectedPackageManager, + message: _message.text.trim(), + ); + } + + String? get _selectedPackageManager => _packageManager == 'auto' ? null : _packageManager; + + Future _preflightProject() async { + final projectPath = _projectPath.text.trim(); + if (projectPath.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); + return; + } + setState(() { + _running = true; + _lines.insert(0, 'Running project preflight...'); + }); + try { + final profile = await widget.runtimeManager.preflightProject( + projectPath, + packageManager: _selectedPackageManager, + ); + if (!mounted) return; + setState(() { + _lastProjectProfile = profile; + _lines.insert( + 0, + [ + 'PREFLIGHT: ${profile.summary}', + if (profile.recoveryHint != null) 'Recovery: ${profile.recoveryHint!}', + ].join('\n'), + ); + }); + widget.onLog( + profile.recognized ? 'Runtime project detected' : 'Runtime project needs setup', + profile.summary, + profile.recognized ? Icons.search_outlined : Icons.warning_amber_outlined, + profile.recognized ? _mint : _amber, + ); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'PREFLIGHT ERROR: $message')); + widget.onLog('Runtime project preflight error', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _runValidationLoop() async { + final projectPath = _projectPath.text.trim(); + if (projectPath.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); + return; + } + setState(() { + _running = true; + _lines.insert(0, 'Running validate loop...'); + }); + try { + final result = await widget.runtimeManager.validateProject( + projectPath: projectPath, + packageManager: _selectedPackageManager, + message: _message.text.trim(), + ); + if (!mounted) return; + final failed = result.failedStep; + final stepLines = result.steps.map((step) => '${step.success ? 'OK' : 'FAILED'} ${step.action.name}: ${step.summary}'); + setState(() { + _lastProjectProfile = result.profile; + _lastFailedAction = failed?.action; + _lines.insert( + 0, + [ + result.success ? 'VALIDATED: ${result.summary}' : 'VALIDATION STOPPED: ${result.summary}', + ...stepLines, + if (result.recoveryHint != null) 'Recovery: ${result.recoveryHint!}', + ].join('\n'), + ); + }); + widget.onLog( + result.success ? 'Runtime validate loop completed' : 'Runtime validate loop stopped', + result.summary, + result.success ? Icons.verified_outlined : Icons.error_outline, + result.success ? _mint : _rose, + ); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'VALIDATION ERROR: $message')); + widget.onLog('Runtime validate loop error', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _retryLastFailure() async { + final failedAction = _lastFailedAction; + if (failedAction == null) { + setState(() => _lines.insert(0, 'No failed runtime action to retry.')); + return; + } + await widget.runtimeManager.refresh(); + await _run(failedAction); + } + + Future _cancelTask([String? taskId]) async { + final id = taskId ?? (_lastTask?.canCancel == true ? _lastTask!.taskId : null); + setState(() { + _cancelling = true; + _lines.insert(0, id == null ? 'Stopping active runtime task...' : 'Stopping runtime task $id...'); + }); + try { + if (id == null) { + await widget.runtimeManager.stopCurrentTask(); + } else { + await widget.runtimeManager.stopTask(id); + } + final task = await widget.runtimeManager.currentTaskSnapshot(); + if (!mounted) return; + final summary = task == null ? 'No recoverable runtime task after stop request.' : _taskSummary(task); + setState(() { + _lastTask = task; + _lines.insert(0, 'STOP REQUESTED: $summary'); + }); + widget.onLog('Runtime task stop requested', summary, Icons.stop_circle_outlined, _amber); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'STOP ERROR: $message')); + widget.onLog('Runtime task stop failed', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _cancelling = false); + } + } + + Future _inspectTask() async { + setState(() => _running = true); + try { + final task = await widget.runtimeManager.currentTaskSnapshot(); + if (!mounted) return; + setState(() { + _lastTask = task; + _lines.insert(0, task == null ? 'No recoverable runtime task.' : _taskSummary(task)); + }); + } on Object catch (error) { + if (!mounted) return; + setState(() => _lines.insert(0, 'Task recovery failed: ${_compact(error.toString(), limit: 160)}')); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _useLastTaskPath() async { + setState(() => _running = true); + try { + final tasks = await widget.runtimeManager.taskHistory(limit: 5); + final task = tasks.firstWhere( + (item) => (item.workingDir ?? '').isNotEmpty, + orElse: () => const RuntimeTaskSnapshot( + taskId: '', + status: RuntimeTaskStatus.unknown, + command: '', + providerType: RuntimeProviderType.webViewOnly, + ), + ); + final path = task.workingDir; + if (!mounted) return; + setState(() { + if (path == null || path.isEmpty) { + _lines.insert(0, 'No recent runtime task with a project path.'); + } else { + _projectPath.text = path; + _lines.insert(0, 'Project path set from ${task.taskId}: $path'); + } + }); + } on Object catch (error) { + if (!mounted) return; + setState(() => _lines.insert(0, 'Project path recovery failed: ${_compact(error.toString(), limit: 160)}')); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _inspectHistory() async { + setState(() => _running = true); + try { + final tasks = await widget.runtimeManager.taskHistory(limit: 5); + if (!mounted) return; + RuntimeTaskSnapshot? selectedTask; + for (final task in tasks) { + if (task.running) { + selectedTask = task; + break; + } + } + selectedTask ??= tasks.isEmpty ? null : tasks.first; + setState(() { + _lastTask = selectedTask; + _lines.insert( + 0, + tasks.isEmpty + ? 'No recoverable runtime task history.' + : tasks.map((task) => _taskSummary(task)).join('\n\n'), + ); + }); + } on Object catch (error) { + if (!mounted) return; + setState(() => _lines.insert(0, 'Task history failed: ${_compact(error.toString(), limit: 160)}')); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _openTaskDetails() async { + var task = _lastTask; + if (task == null) { + setState(() => _running = true); + try { + final tasks = await widget.runtimeManager.taskHistory(limit: 12); + if (!mounted) return; + for (final item in tasks) { + if (item.running) { + task = item; + break; + } + } + task ??= tasks.isEmpty ? null : tasks.first; + final selectedTask = task; + setState(() { + _lastTask = selectedTask; + _lines.insert(0, selectedTask == null ? 'No runtime task available for detail view.' : 'Opening details for ${selectedTask.taskId}.'); + }); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 160); + setState(() => _lines.insert(0, 'Task detail load failed: $message')); + return; + } finally { + if (mounted) setState(() => _running = false); + } + } + if (!mounted) return; + if (task == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No runtime task to inspect.'))); + return; + } + _showRuntimeTaskDetailsSheet( + context: context, + runtimeManager: widget.runtimeManager, + task: task, + onLog: widget.onLog, + ); + } + + String _taskSummary(RuntimeTaskSnapshot task) { + final logs = _recentLogLines(task.logs, limit: 4).join('\n'); + final failure = task.failureKind == RuntimeTaskFailureKind.none ? '' : ' (${task.failureKind.name})'; + return 'Task ${task.taskId} is ${task.status.name}$failure: ${task.command}${logs.isEmpty ? '' : '\n$logs'}'; + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: widget.icon, + title: widget.title, + subtitle: widget.subtitle, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _projectPath, + decoration: const InputDecoration(labelText: 'Runtime project path', prefixIcon: Icon(Icons.folder_outlined)), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _RuntimeActionButton( + icon: Icons.home_work_outlined, + label: 'Default path', + disabled: _running, + onTap: () { + _projectPath.text = '/data/data/com.mobilecode.mobile_agent/files/mobilecode_runtime'; + setState(() => _lines.insert(0, 'Project path reset to helper workspace default.')); + }, + ), + _RuntimeActionButton( + icon: Icons.restore_outlined, + label: 'Last cwd', + disabled: _running, + onTap: _useLastTaskPath, + ), + ], + ), + const SizedBox(height: 10), + DropdownButtonFormField( + value: _packageManager, + decoration: const InputDecoration(labelText: 'Action profile', prefixIcon: Icon(Icons.tune_outlined)), + items: const [ + DropdownMenuItem(value: 'auto', child: Text('Auto from capabilities')), + DropdownMenuItem(value: 'flutter', child: Text('Flutter')), + DropdownMenuItem(value: 'npm', child: Text('Node / npm')), + DropdownMenuItem(value: 'python', child: Text('Python')), + ], + onChanged: _running ? null : (value) => setState(() => _packageManager = value ?? 'auto'), + ), + const SizedBox(height: 10), + TextField( + controller: _message, + decoration: const InputDecoration(labelText: 'Commit message', prefixIcon: Icon(Icons.edit_note_outlined)), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _RuntimeActionButton(icon: Icons.inventory_2_outlined, label: 'Install', disabled: _running, onTap: () => _run(RuntimeActionType.installDependencies)), + _RuntimeActionButton(icon: Icons.fact_check_outlined, label: 'Test', disabled: _running, onTap: () => _run(RuntimeActionType.runTests)), + _RuntimeActionButton(icon: Icons.web_asset_outlined, label: 'Preview', disabled: _running, onTap: () => _run(RuntimeActionType.buildPreview)), + _RuntimeActionButton(icon: Icons.search_outlined, label: 'Preflight', disabled: _running, onTap: _preflightProject), + _RuntimeActionButton(icon: Icons.verified_outlined, label: 'Validate', disabled: _running, onTap: _runValidationLoop), + _RuntimeActionButton(icon: Icons.stop_circle_outlined, label: 'Stop', disabled: _cancelling, onTap: _cancelTask), + _RuntimeActionButton(icon: Icons.replay_outlined, label: 'Retry', disabled: _running || _lastFailedAction == null, onTap: _retryLastFailure), + _RuntimeActionButton(icon: Icons.account_tree_outlined, label: 'Commit', disabled: _running, onTap: () => _run(RuntimeActionType.gitCommit)), + _RuntimeActionButton(icon: Icons.publish_outlined, label: 'Publish', disabled: _running, onTap: () => _run(RuntimeActionType.publishPages)), + _RuntimeActionButton(icon: Icons.history_outlined, label: 'Recover', disabled: _running, onTap: _inspectTask), + _RuntimeActionButton(icon: Icons.manage_history_outlined, label: 'History', disabled: _running, onTap: _inspectHistory), + _RuntimeActionButton(icon: Icons.subject_outlined, label: 'Task detail', disabled: _running, onTap: _openTaskDetails), + ], + ), + const SizedBox(height: 12), + if (_lastProjectProfile != null) ...[ + _RuntimeProjectProfilePanel(profile: _lastProjectProfile!), + const SizedBox(height: 12), + ], + if (_lastTask != null) ...[ + _TaskSnapshotPanel( + task: _lastTask, + onStop: _lastTask!.canCancel ? () => _cancelTask(_lastTask!.taskId) : null, + onOpenDetails: () => _showRuntimeTaskDetailsSheet( + context: context, + runtimeManager: widget.runtimeManager, + task: _lastTask!, + onLog: widget.onLog, + ), + ), + const SizedBox(height: 12), + ], + _Panel( + child: Text( + _lines.take(8).join('\n\n'), + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ), + ], + ), + ); + } +} + +class _RuntimeProjectProfilePanel extends StatelessWidget { + const _RuntimeProjectProfilePanel({required this.profile}); + + final RuntimeProjectProfile profile; + + @override + Widget build(BuildContext context) { + final color = profile.recognized ? _mint : _amber; + final markers = profile.detectedFiles.take(6).join(', '); + return _Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(profile.recognized ? Icons.folder_open_outlined : Icons.folder_outlined, color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + profile.packageManager ?? 'No project profile', + style: const TextStyle(color: _text, fontWeight: FontWeight.w900), + ), + ), + Text(profile.kind.name, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w800)), + ], + ), + const SizedBox(height: 6), + Text(profile.summary, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), + if (markers.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(markers, style: const TextStyle(color: _muted, fontSize: 11, height: 1.25)), + ], + if (profile.recoveryHint != null) ...[ + const SizedBox(height: 6), + Text(profile.recoveryHint!, style: TextStyle(color: color, fontSize: 11, height: 1.25)), + ], + ], + ), + ); + } +} + +class _RuntimeHealthTile extends StatelessWidget { + const _RuntimeHealthTile({required this.health}); + + final RuntimeHealth health; + + @override + Widget build(BuildContext context) { + final planned = health.type == RuntimeProviderType.embeddedLite || + health.status.toLowerCase().contains('planned'); + final configuredLater = health.type == RuntimeProviderType.cloud && !health.ready; + final color = health.ready + ? _mint + : planned || configuredLater + ? _faint + : health.available + ? _amber + : _rose; + final label = health.ready + ? 'ready' + : planned + ? 'planned' + : configuredLater + ? 'setup' + : health.available + ? 'available' + : 'offline'; + final icon = health.ready + ? Icons.check_circle_outline + : planned || configuredLater + ? Icons.event_note_outlined + : Icons.info_outline; + return _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(health.name, style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), + ), + Text(label, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w800)), + ], + ), + const SizedBox(height: 6), + Text(health.status, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), + const SizedBox(height: 6), + Text(_runtimeCapabilitiesText(health.capabilities), style: const TextStyle(color: _faint, fontSize: 11, height: 1.3)), + if (health.missingDependencies.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + planned ? 'Planned: ${health.missingDependencies.join(', ')}' : 'Missing: ${health.missingDependencies.join(', ')}', + style: TextStyle(color: planned ? _faint : _amber, fontSize: 11, height: 1.3), + ), + ], + if (health.recoveryActions.isNotEmpty) ...[ + const SizedBox(height: 6), + Text('Recover: ${health.recoveryActions.join(' / ')}', style: const TextStyle(color: _muted, fontSize: 11, height: 1.3)), + ], + ], + ), + ); + } +} + +class _DiagnosticLine extends StatelessWidget { + const _DiagnosticLine({ + required this.label, + required this.value, + required this.good, + }); + + final String label; + final String value; + final bool good; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Icon(good ? Icons.check_circle_outline : Icons.radio_button_unchecked_outlined, size: 16, color: good ? _mint : _faint), + const SizedBox(width: 8), + Expanded(child: Text(label, style: const TextStyle(color: _muted, fontSize: 12))), + Text(value, style: TextStyle(color: good ? _mint : _faint, fontSize: 12, fontWeight: FontWeight.w800)), + ], + ), + ); + } +} + +class _TaskSnapshotPanel extends StatelessWidget { + const _TaskSnapshotPanel({required this.task, this.onStop, this.onOpenDetails}); + + final RuntimeTaskSnapshot? task; + final VoidCallback? onStop; + final VoidCallback? onOpenDetails; + + @override + Widget build(BuildContext context) { + final snapshot = task; + if (snapshot == null) { + return const _Panel( + padding: EdgeInsets.all(12), + child: Text('No recoverable runtime task snapshot yet.', style: TextStyle(color: _muted, fontSize: 12, height: 1.35)), + ); + } + final color = snapshot.running + ? _amber + : snapshot.status == RuntimeTaskStatus.succeeded + ? _mint + : _rose; + final details = [ + if (snapshot.startedAt != null) 'Started ${_timeLabel(snapshot.startedAt!)} ago', + if (snapshot.duration != null) 'Duration ${_durationLabel(snapshot.duration!)}', + if (snapshot.exitCode != null) 'Exit ${snapshot.exitCode}', + if (snapshot.failureKind != RuntimeTaskFailureKind.none) 'Failure ${snapshot.failureKind.name}', + ]; + return _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.history_outlined, color: color, size: 18), + const SizedBox(width: 8), + Expanded(child: Text('Task ${snapshot.taskId}', style: const TextStyle(color: _text, fontWeight: FontWeight.w900))), + Text(snapshot.status.name, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w800)), + if (onOpenDetails != null) ...[ + const SizedBox(width: 4), + IconButton( + tooltip: 'Open task details', + visualDensity: VisualDensity.compact, + onPressed: onOpenDetails, + icon: const Icon(Icons.subject_outlined, size: 18), + color: _cyan, + ), + ], + if (snapshot.canCancel && onStop != null) ...[ + const SizedBox(width: 4), + IconButton( + tooltip: 'Stop task', + visualDensity: VisualDensity.compact, + onPressed: onStop, + icon: const Icon(Icons.stop_circle_outlined, size: 18), + color: _amber, + ), + ], + ], + ), + const SizedBox(height: 6), + Text(snapshot.command.isEmpty ? 'No command recorded.' : snapshot.command, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), + if (details.isNotEmpty) ...[ + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final detail in details) _TaskDetailChip(label: detail, color: color), + ], + ), + ], + if (snapshot.workingDir != null && snapshot.workingDir!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(snapshot.workingDir!, style: const TextStyle(color: _faint, fontSize: 11, height: 1.3)), + ], + if (snapshot.error != null && snapshot.error!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(snapshot.error!, style: const TextStyle(color: _rose, fontSize: 11, height: 1.3)), + ], + if (runtimeFailureKindHint(snapshot.failureKind) != null) ...[ + const SizedBox(height: 6), + Text('Recovery: ${runtimeFailureKindHint(snapshot.failureKind)!}', style: const TextStyle(color: _amber, fontSize: 11, height: 1.3)), + ], + if (snapshot.logs.isNotEmpty) ...[ + const SizedBox(height: 8), + Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + initiallyExpanded: snapshot.running || snapshot.status != RuntimeTaskStatus.succeeded, + title: const Text('Recent logs', style: TextStyle(color: _muted, fontSize: 12, fontWeight: FontWeight.w800)), + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text(_recentLogLines(snapshot.logs, limit: 8).join('\n'), style: const TextStyle(color: _faint, fontSize: 11, height: 1.3)), + ), + ], + ), + ), + ], + ], + ), + ); + } +} + +class _TaskDetailChip extends StatelessWidget { + const _TaskDetailChip({required this.label, required this.color}); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.22)), + ), + child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800)), + ); + } +} + +void _showRuntimeTaskDetailsSheet({ + required BuildContext context, + required RuntimeManager runtimeManager, + required RuntimeTaskSnapshot task, + required void Function(String title, String detail, IconData icon, Color color) onLog, +}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(10)), + ), + builder: (context) => _RuntimeTaskDetailSheet( + runtimeManager: runtimeManager, + initialTask: task, + onLog: onLog, + ), + ); +} + +class _RuntimeTaskDetailSheet extends StatefulWidget { + const _RuntimeTaskDetailSheet({ + required this.runtimeManager, + required this.initialTask, + required this.onLog, + }); + + final RuntimeManager runtimeManager; + final RuntimeTaskSnapshot initialTask; + final void Function(String title, String detail, IconData icon, Color color) onLog; + + @override + State<_RuntimeTaskDetailSheet> createState() => _RuntimeTaskDetailSheetState(); +} + +class _RuntimeTaskDetailSheetState extends State<_RuntimeTaskDetailSheet> { + late RuntimeTaskSnapshot _task; + List _tasks = const []; + bool _loading = false; + bool _retrying = false; + bool _stopping = false; + String _status = 'Task detail is ready.'; + DateTime? _lastRefresh; + Timer? _poller; + + @override + void initState() { + super.initState(); + _task = widget.initialTask; + _tasks = [_task]; + unawaited(_refresh()); + _poller = Timer.periodic(const Duration(seconds: 2), (_) { + if (!mounted) return; + if (_task.running || _tasks.any((task) => task.running)) { + unawaited(_refresh(silent: true)); + } + }); + } + + @override + void dispose() { + _poller?.cancel(); + super.dispose(); + } + + Future _refresh({bool silent = false}) async { + if (!silent && mounted) { + setState(() { + _loading = true; + _status = 'Refreshing task ${_task.taskId}...'; + }); + } + try { + final tasks = await widget.runtimeManager.taskHistory(limit: 24); + var selected = _task; + for (final candidate in tasks) { + if (candidate.taskId == _task.taskId) { + selected = candidate; + break; + } + } + var logs = selected.logs; + if (selected.taskId.trim().isNotEmpty) { + final recoveredLogs = await widget.runtimeManager.taskLogs(selected.taskId, limit: 300); + if (recoveredLogs.isNotEmpty) logs = recoveredLogs; + } + if (!mounted) return; + setState(() { + _tasks = tasks; + _task = selected.copyWith(logs: logs); + _lastRefresh = DateTime.now(); + _status = 'Task ${_task.taskId} is ${_task.status.name}.'; + }); + } on Object catch (error) { + if (!mounted) return; + setState(() => _status = 'Task refresh failed: ${_compact(error.toString(), limit: 160)}'); + } finally { + if (!silent && mounted) setState(() => _loading = false); + } + } + + Future _stopTask() async { + setState(() { + _stopping = true; + _status = 'Stopping task ${_task.taskId}...'; + }); + try { + await widget.runtimeManager.stopTask(_task.taskId); + await _refresh(silent: true); + widget.onLog('Runtime task stop requested', 'Task ${_task.taskId}', Icons.stop_circle_outlined, _amber); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 160); + setState(() => _status = 'Stop failed: $message'); + widget.onLog('Runtime task stop failed', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _stopping = false); + } + } + + Future _retryTask() async { + final command = _task.command.trim(); + if (command.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Task has no command to retry.'))); + return; + } + setState(() { + _retrying = true; + _status = 'Retrying task ${_task.taskId}...'; + }); + try { + final result = await widget.runtimeManager.execute( + command, + workingDir: _task.workingDir, + timeout: const Duration(minutes: 10), + ); + final tasks = await widget.runtimeManager.taskHistory(limit: 24); + RuntimeTaskSnapshot? nextTask; + final nextTaskId = result.taskId; + if (nextTaskId != null && nextTaskId.isNotEmpty) { + for (final candidate in tasks) { + if (candidate.taskId == nextTaskId) { + nextTask = candidate; + break; + } + } + } + nextTask ??= tasks.isEmpty ? null : tasks.first; + final fallbackLogs = [ + if (result.stdout.trim().isNotEmpty) ...result.stdout.trim().split('\n'), + if (result.stderr.trim().isNotEmpty) ...result.stderr.trim().split('\n'), + ]; + if (!mounted) return; + setState(() { + _tasks = tasks; + _task = nextTask ?? + _task.copyWith( + status: result.success ? RuntimeTaskStatus.succeeded : RuntimeTaskStatus.failed, + exitCode: result.exitCode, + duration: result.duration, + logs: fallbackLogs, + failureKind: result.failureKind, + ); + _status = result.success ? 'Retry completed successfully.' : 'Retry failed with exit ${result.exitCode}.'; + }); + widget.onLog( + result.success ? 'Runtime task retry completed' : 'Runtime task retry failed', + 'Original ${widget.initialTask.taskId} -> ${result.taskId ?? 'no task id'}', + result.success ? Icons.replay_circle_filled_outlined : Icons.error_outline, + result.success ? _mint : _rose, + ); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 160); + setState(() => _status = 'Retry failed: $message'); + widget.onLog('Runtime task retry error', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _retrying = false); + } + } + + Future _copyFailureSummary() async { + await Clipboard.setData(ClipboardData(text: _failureSummary())); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Task summary copied.'))); + widget.onLog('Runtime failure summary copied', 'Task ${_task.taskId}', Icons.copy_outlined, _cyan); + } + + String _failureSummary() { + final lines = [ + 'Task: ${_task.taskId}', + 'Status: ${_task.status.name}', + 'Command: ${_task.command.isEmpty ? '(empty)' : _task.command}', + if (_task.workingDir != null && _task.workingDir!.isNotEmpty) 'cwd: ${_task.workingDir}', + if (_task.startedAt != null) 'Started: ${_task.startedAt!.toIso8601String()}', + if (_task.duration != null) 'Duration: ${_durationLabel(_task.duration!)}', + if (_task.exitCode != null) 'Exit code: ${_task.exitCode}', + if (_task.failureKind != RuntimeTaskFailureKind.none) 'Failure kind: ${_task.failureKind.name}', + if (_task.error != null && _task.error!.isNotEmpty) 'Error: ${_task.error}', + if (runtimeFailureKindHint(_task.failureKind) != null) 'Recovery: ${runtimeFailureKindHint(_task.failureKind)!}', + ]; + final logs = _recentLogLines(_task.logs, limit: 24); + if (logs.isNotEmpty) { + lines + ..add('Recent logs:') + ..addAll(logs); + } + return lines.join('\n'); + } + + Color _statusColor(RuntimeTaskSnapshot task) { + if (task.running) return _amber; + return task.status == RuntimeTaskStatus.succeeded ? _mint : _rose; + } + + void _selectTask(RuntimeTaskSnapshot task) { + setState(() { + _task = task; + _status = 'Selected task ${task.taskId}.'; + }); + unawaited(_refresh(silent: true)); + } + + @override + Widget build(BuildContext context) { + final color = _statusColor(_task); + final queuedTasks = _tasks.where((task) => task.status == RuntimeTaskStatus.queued).toList(); + final logs = _recentLogLines(_task.logs, limit: 120); + final detailChips = [ + _task.status.name, + if (_task.startedAt != null) 'started ${_timeLabel(_task.startedAt!)} ago', + if (_task.duration != null) 'duration ${_durationLabel(_task.duration!)}', + if (_task.exitCode != null) 'exit ${_task.exitCode}', + if (_task.failureKind != RuntimeTaskFailureKind.none) _task.failureKind.name, + ]; + return _SheetScaffold( + icon: Icons.subject_outlined, + title: 'Runtime Task Detail', + subtitle: 'Live logs, task retry, failure summary, and queue visibility.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.bolt_outlined, color: color, size: 18), + const SizedBox(width: 8), + Expanded(child: Text('Task ${_task.taskId}', style: const TextStyle(color: _text, fontWeight: FontWeight.w900))), + Text(_lastRefresh == null ? 'not synced' : 'synced ${_timeLabel(_lastRefresh!)} ago', style: const TextStyle(color: _faint, fontSize: 11)), + ], + ), + const SizedBox(height: 6), + Text(_status, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final detail in detailChips) _TaskDetailChip(label: detail, color: color), + ], + ), + const SizedBox(height: 8), + SelectableText(_task.command.isEmpty ? 'No command recorded.' : _task.command, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), + if (_task.workingDir != null && _task.workingDir!.isNotEmpty) ...[ + const SizedBox(height: 6), + SelectableText(_task.workingDir!, style: const TextStyle(color: _faint, fontSize: 11, height: 1.3)), + ], + if (_task.error != null && _task.error!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(_task.error!, style: const TextStyle(color: _rose, fontSize: 11, height: 1.3)), + ], + if (runtimeFailureKindHint(_task.failureKind) != null) ...[ + const SizedBox(height: 6), + Text('Recovery: ${runtimeFailureKindHint(_task.failureKind)!}', style: const TextStyle(color: _amber, fontSize: 11, height: 1.3)), + ], + ], + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _RuntimeActionButton(icon: Icons.refresh_outlined, label: _loading ? 'Refreshing' : 'Refresh', disabled: _loading, onTap: () => unawaited(_refresh())), + _RuntimeActionButton(icon: Icons.stop_circle_outlined, label: _stopping ? 'Stopping' : 'Stop', disabled: _stopping || !_task.canCancel, onTap: () => unawaited(_stopTask())), + _RuntimeActionButton(icon: Icons.replay_outlined, label: _retrying ? 'Retrying' : 'Retry taskId', disabled: _retrying || _task.command.trim().isEmpty, onTap: () => unawaited(_retryTask())), + _RuntimeActionButton(icon: Icons.copy_outlined, label: 'Copy failure', disabled: false, onTap: () => unawaited(_copyFailureSummary())), + ], + ), + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Queue', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + if (queuedTasks.isEmpty) + const Text('No queued runtime tasks.', style: TextStyle(color: _muted, fontSize: 12, height: 1.35)) + else + for (final task in queuedTasks) ...[ + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.pending_actions_outlined, color: _amber), + title: Text('Task ${task.taskId}', style: const TextStyle(color: _text, fontWeight: FontWeight.w800)), + subtitle: Text(_compact(task.command, limit: 96), style: const TextStyle(color: _muted, fontSize: 12)), + trailing: IconButton( + tooltip: 'Inspect queued task', + onPressed: () => _selectTask(task), + icon: const Icon(Icons.open_in_new_outlined, size: 18), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Live logs', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 260), + child: SingleChildScrollView( + child: SelectableText( + logs.isEmpty ? 'No logs recovered for this task yet.' : logs.join('\n'), + style: const TextStyle(color: _faint, fontSize: 11, height: 1.3), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ProjectConsoleSheet extends StatefulWidget { + const _ProjectConsoleSheet({ + required this.runtimeManager, + required this.defaultProjectPath, + required this.onLog, + }); + + final RuntimeManager runtimeManager; + final String defaultProjectPath; + final void Function(String title, String detail, IconData icon, Color color) onLog; + + @override + State<_ProjectConsoleSheet> createState() => _ProjectConsoleSheetState(); +} + +class _ProjectConsoleSheetState extends State<_ProjectConsoleSheet> { + final _projectName = TextEditingController(); + final _projectPath = TextEditingController(); + final _gitUrl = TextEditingController(); + final List _lines = ['No project action has run yet.']; + List _recentProjectPaths = const []; + bool _running = false; + RuntimeProjectProfile? _profile; + + @override + void initState() { + super.initState(); + _projectPath.text = widget.defaultProjectPath; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_loadRecentProjects()); + }); + } + + @override + void dispose() { + _projectName.dispose(); + _projectPath.dispose(); + _gitUrl.dispose(); + super.dispose(); + } + + Future _loadRecentProjects() async { + setState(() { + _running = true; + _lines.insert(0, 'Loading recent runtime project paths...'); + }); + try { + final tasks = await widget.runtimeManager.taskHistory(limit: 12); + final paths = {}; + for (final task in tasks) { + final path = task.workingDir?.trim(); + if (path != null && path.isNotEmpty) paths.add(path); + } + if (_profile?.projectPath.trim().isNotEmpty == true) { + paths.add(_profile!.projectPath.trim()); + } + if (!mounted) return; + setState(() { + _recentProjectPaths = paths.take(6).toList(); + _lines.insert(0, paths.isEmpty ? 'No recent runtime project paths found.' : 'Loaded ${paths.length} recent project path(s).'); + }); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'RECENT PROJECTS ERROR: $message')); + widget.onLog('Recent project load failed', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _cloneRepository() async { + final url = _gitUrl.text.trim(); + final validationError = _gitUrlError(url); + if (validationError != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(validationError))); + return; + } + final targetName = _safeProjectDirectoryName(_projectName.text.trim().isEmpty ? _projectNameFromGitUrl(url) : _projectName.text.trim()); + final targetPath = '${widget.defaultProjectPath}/$targetName'; + setState(() { + _running = true; + _lines.insert(0, 'Cloning repository into $targetPath...'); + }); + try { + final result = await widget.runtimeManager.execute( + 'git clone ${_quoteCommandArg(url)} ${_quoteCommandArg(targetName)}', + workingDir: widget.defaultProjectPath, + timeout: const Duration(minutes: 10), + ); + if (!mounted) return; + setState(() { + _projectPath.text = targetPath; + _recentProjectPaths = [targetPath, ..._recentProjectPaths.where((path) => path != targetPath)].take(6).toList(); + _lines.insert( + 0, + [ + result.success ? 'CLONED: $targetPath' : 'CLONE FAILED: $targetPath', + if (result.stdout.trim().isNotEmpty) _compact(result.stdout.trim(), limit: 220), + if (result.stderr.trim().isNotEmpty) _compact(result.stderr.trim(), limit: 220), + ].join('\n'), + ); + }); + widget.onLog( + result.success ? 'Git repository cloned' : 'Git clone failed', + targetPath, + result.success ? Icons.download_done_outlined : Icons.error_outline, + result.success ? _mint : _rose, + ); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'CLONE ERROR: $message')); + widget.onLog('Git clone error', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _runPreflight() async { + final path = _projectPath.text.trim(); + if (path.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); + return; + } + setState(() { + _running = true; + _lines.insert(0, 'Running project preflight...'); + }); + try { + final profile = await widget.runtimeManager.preflightProject(path); + if (!mounted) return; + setState(() { + _profile = profile; + _lines.insert( + 0, + [ + 'PREFLIGHT: ${profile.summary}', + if (profile.recoveryHint != null) 'Recovery: ${profile.recoveryHint!}', + ].join('\n'), + ); + }); + widget.onLog( + profile.recognized ? 'Project detected' : 'Project needs setup', + profile.summary, + profile.recognized ? Icons.search_outlined : Icons.warning_amber_outlined, + profile.recognized ? _mint : _amber, + ); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'PREFLIGHT ERROR: $message')); + widget.onLog('Project preflight error', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _runValidation() async { + final path = _projectPath.text.trim(); + if (path.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); + return; + } + setState(() { + _running = true; + _lines.insert(0, 'Running project validation...'); + }); + try { + final result = await widget.runtimeManager.validateProject( + projectPath: path, + message: _projectName.text.trim().isEmpty ? null : _projectName.text.trim(), + ); + if (!mounted) return; + final stepLines = result.steps.map((s) => '${s.success ? 'OK' : 'FAILED'} ${s.action.name}: ${s.summary}'); + setState(() { + _profile = result.profile; + _lines.insert( + 0, + [ + result.success ? 'VALIDATED: ${result.summary}' : 'VALIDATION STOPPED: ${result.summary}', + ...stepLines, + if (result.recoveryHint != null) 'Recovery: ${result.recoveryHint!}', + ].join('\n'), + ); + }); + widget.onLog( + result.success ? 'Project validated' : 'Project validation stopped', + result.summary, + result.success ? Icons.verified_outlined : Icons.error_outline, + result.success ? _mint : _rose, + ); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'VALIDATION ERROR: $message')); + widget.onLog('Project validation error', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _running = false); + } + } + + void _fillDefaultPath() { + setState(() { + _projectPath.text = widget.defaultProjectPath; + _lines.insert(0, 'Project path set to runtime workspace default.'); + }); + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.folder_open_outlined, + title: 'Project Console', + subtitle: 'Configure project name and path, then run preflight or validation through the active runtime.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _projectName, + decoration: const InputDecoration(labelText: 'Project name (optional)', prefixIcon: Icon(Icons.badge_outlined)), + ), + const SizedBox(height: 8), + TextField( + controller: _projectPath, + decoration: const InputDecoration(labelText: 'Project path / cwd', prefixIcon: Icon(Icons.folder_outlined)), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _RuntimeActionButton( + icon: Icons.home_work_outlined, + label: 'Default cwd', + disabled: _running, + onTap: _fillDefaultPath, + ), + _RuntimeActionButton( + icon: Icons.manage_history_outlined, + label: 'Recent paths', + disabled: _running, + onTap: _loadRecentProjects, + ), + _RuntimeActionButton( + icon: Icons.search_outlined, + label: 'Preflight', + disabled: _running, + onTap: _runPreflight, + ), + _RuntimeActionButton( + icon: Icons.verified_outlined, + label: 'Validate', + disabled: _running, + onTap: _runValidation, + ), + ], + ), + if (_recentProjectPaths.isNotEmpty) ...[ + const SizedBox(height: 10), + _Panel( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final path in _recentProjectPaths) + ActionChip( + avatar: const Icon(Icons.folder_open_outlined, size: 16), + label: Text(_compact(path, limit: 34)), + onPressed: _running ? null : () => setState(() => _projectPath.text = path), + ), + ], + ), + ), + ], + const SizedBox(height: 10), + TextField( + controller: _gitUrl, + decoration: const InputDecoration(labelText: 'Git repository URL', prefixIcon: Icon(Icons.cloud_download_outlined)), + ), + const SizedBox(height: 8), + _RuntimeActionButton( + icon: Icons.download_outlined, + label: 'Clone / import Git', + disabled: _running, + onTap: _cloneRepository, + ), + const SizedBox(height: 12), + if (_profile != null) ...[ + _RuntimeProjectProfilePanel(profile: _profile!), + const SizedBox(height: 12), + ], + _Panel( + child: Text( + _lines.take(8).join('\n\n'), + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ), + ], + ), + ); + } +} + +class _RuntimeActionButton extends StatelessWidget { + const _RuntimeActionButton({ + required this.icon, + required this.label, + required this.disabled, + required this.onTap, + }); + + final IconData icon; + final String label; + final bool disabled; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: disabled ? null : onTap, + icon: Icon(icon, size: 17), + label: Text(label), + ); + } +} + +String _runtimeCapabilitiesText(RuntimeCapabilities capabilities) { + final labels = [ + if (capabilities.shell) 'shell', + if (capabilities.git) 'git', + if (capabilities.node) 'node', + if (capabilities.python) 'python', + if (capabilities.flutter) 'flutter', + if (capabilities.androidBuild) 'apk', + if (capabilities.pty) 'pty', + if (capabilities.backgroundService) 'background', + if (capabilities.cloudBuild) 'cloud', + if (capabilities.webViewPreview) 'webview', + ]; + return labels.isEmpty ? 'webview-only' : labels.join(', '); +} + +String _boolStatus(bool? value) { + if (value == true) return 'yes'; + if (value == false) return 'no'; + return 'unknown'; +} + +class _ChatPanel extends StatefulWidget { + const _ChatPanel({ + super.key, + required this.baseUrl, + required this.apiKey, + required this.model, + required this.browserOpenMode, + required this.onLog, + required this.onAgentPrompt, + this.onSessionsChanged, + this.embedded = false, + }); + + final String baseUrl; + final String apiKey; + final String model; + final String browserOpenMode; + final void Function(String title, String detail, IconData icon, Color color) onLog; + final Future Function(String prompt) onAgentPrompt; + final void Function(List<_ChatSession> sessions, String? activeSessionId)? onSessionsChanged; + final bool embedded; + + @override + State<_ChatPanel> createState() => _ChatPanelState(); +} + +class _ChatPanelState extends State<_ChatPanel> { + static const _sessionsKey = 'mobilecode.chat.sessions.v1'; + static const _activeSessionKey = 'mobilecode.chat.activeSession.v1'; + + final _promptController = TextEditingController(); + final _chatScrollController = ScrollController(); + final _voiceService = VoiceService(); + final List<_ChatSession> _sessions = []; + Future? _sessionLoadFuture; + String? _activeSessionId; + bool _loading = true; + bool _sending = false; + bool _agentRunning = false; + bool _agentStopping = false; + bool _agentCancelRequested = false; + bool _agentModeEnabled = false; + bool _followChatBottom = true; + bool _showJumpToBottom = false; + bool _autoScrollScheduled = false; + HttpClient? _agentProviderClient; + bool _voiceAvailable = false; + VoiceState _voiceState = VoiceState.idle; + StreamSubscription? _voiceTranscriptSub; + StreamSubscription? _voiceStateSub; + String? _error; + GitHubRepoChatRequest? _repoBinding; + List _roleRecruitRoles = RoleLibraryService.instance.recruitmentRoles; + List? _activeRunRoles; + RoleProposal? _activeRunProposal; + final List<_AgentTraceStep> _agentTrace = []; + final Map _turnKeys = {}; + Timer? _navPreviewTimer; + _ChatNavPreview? _navPreview; + int? _lastNavActiveIndex; + + _ChatSession? get _activeSession { + if (_sessions.isEmpty) return null; + final index = _sessions.indexWhere((session) => session.id == _activeSessionId); + return index == -1 ? _sessions.first : _sessions[index]; + } + + List get _displayRecruitRoles => _activeRunRoles ?? _roleRecruitRoles; + + @override + void initState() { + super.initState(); + _chatScrollController.addListener(_handleChatScroll); + RoleLibraryService.instance.addListener(_handleRoleLibraryChanged); + unawaited(RoleLibraryService.instance.initialize().then((_) { + if (!mounted) return; + setState(() => _roleRecruitRoles = RoleLibraryService.instance.recruitmentRoles); + })); + unawaited(TokenUsageService.instance.initialize()); + _sessionLoadFuture = _loadSessions(); + _initVoiceInput(); + } + + @override + void dispose() { + _agentProviderClient?.close(force: true); + RoleLibraryService.instance.removeListener(_handleRoleLibraryChanged); + _voiceTranscriptSub?.cancel(); + _voiceStateSub?.cancel(); + _navPreviewTimer?.cancel(); + _voiceService.dispose(); + _chatScrollController.dispose(); + _promptController.dispose(); + super.dispose(); + } + + void _handleRoleLibraryChanged() { + if (!mounted) return; + setState(() => _roleRecruitRoles = RoleLibraryService.instance.recruitmentRoles); + } + + Future _initVoiceInput() async { + _voiceTranscriptSub = _voiceService.onTranscriptUpdate.listen((text) { + if (!mounted || text.trim().isEmpty) return; + _promptController.text = text; + _promptController.selection = TextSelection.collapsed(offset: _promptController.text.length); + setState(() {}); + }); + _voiceStateSub = _voiceService.onStateChange.listen((state) { + if (!mounted) return; + setState(() => _voiceState = state); + }); + final available = await _voiceService.initialize(); + if (!mounted) return; + setState(() { + _voiceAvailable = available; + _voiceState = _voiceService.currentState; + }); + } + + Future _ensureSessionsLoaded() async { + final pendingLoad = _sessionLoadFuture; + if (!_loading || pendingLoad == null) return; + await pendingLoad; + } + + Future _loadSessions() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_sessionsKey); + final savedActiveId = prefs.getString(_activeSessionKey); + final loaded = <_ChatSession>[]; + + if (raw != null && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + loaded.addAll( + decoded + .whereType() + .map((item) => _ChatSession.fromJson(Map.from(item))) + .where((session) { + if (session.turns.isNotEmpty) return true; + if (session.id == savedActiveId) return true; + return session.title.trim().isNotEmpty && session.title.trim() != 'New chat'; + }), + ); + } + } catch (_) { + // Corrupt chat storage should not block the chat surface. + } + } + + if (loaded.isEmpty) loaded.add(_newSessionObject()); + loaded.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + if (!mounted) return; + setState(() { + _sessions + ..clear() + ..addAll(loaded.take(20)); + _activeSessionId = savedActiveId; + if (_sessions.every((session) => session.id != _activeSessionId)) { + _activeSessionId = _sessions.first.id; + } + _loading = false; + }); + _notifySessionsChanged(); + } + + Future _persist() async { + final prefs = await SharedPreferences.getInstance(); + final activeId = _activeSessionId; + final persistedSessions = _sessions.where((session) { + if (session.turns.isNotEmpty) return true; + if (session.id == activeId) return true; + return session.title.trim().isNotEmpty && session.title.trim() != 'New chat'; + }).take(20).toList(); + await prefs.setString(_sessionsKey, jsonEncode(persistedSessions.map((session) => session.toJson()).toList())); + if (activeId != null) { + await prefs.setString(_activeSessionKey, activeId); + } + _notifySessionsChanged(); + } + + void _notifySessionsChanged() { + widget.onSessionsChanged?.call(List<_ChatSession>.unmodifiable(_sessions), _activeSessionId); + } + + Future createSessionFromShell() => _createSession(); + + Future selectSessionFromShell(String id) => _selectSession(id); + + Future bindRepoFromShell(GitHubRepoChatRequest request) async { + await _ensureSessionsLoaded(); + if (!mounted) return; + setState(() => _repoBinding = request); + _scrollConversationToEnd(force: true); + } + + Future setPromptFromShell(String prompt, {bool runAgent = false}) async { + await _ensureSessionsLoaded(); + if (!mounted) return; + _promptController.text = prompt; + _promptController.selection = TextSelection.collapsed(offset: prompt.length); + setState(() {}); + if (runAgent) { + unawaited(_runAgentWithTrace()); + } + } + + _ChatSession _newSessionObject() { + final now = DateTime.now(); + return _ChatSession( + id: now.microsecondsSinceEpoch.toString(), + title: 'New chat', + createdAt: now, + updatedAt: now, + turns: const [], + ); + } + + void _storeSession(_ChatSession session) { + final index = _sessions.indexWhere((item) => item.id == session.id); + if (index == -1) { + _sessions.insert(0, session); + } else { + _sessions[index] = session; + _sessions.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + } + } + + Future _createSession() async { + await _ensureSessionsLoaded(); + if (!mounted) return; + final active = _activeSession; + if (active != null && active.turns.isEmpty && active.title == 'New chat') { + setState(() { + _activeSessionId = active.id; + _error = null; + _repoBinding = null; + _promptController.clear(); + }); + _notifySessionsChanged(); + await _persist(); + return; + } + final session = _newSessionObject(); + setState(() { + _sessions.insert(0, session); + _activeSessionId = session.id; + _error = null; + _repoBinding = null; + _promptController.clear(); + }); + _notifySessionsChanged(); + await _persist(); + } + + Future _selectSession(String id) async { + await _ensureSessionsLoaded(); + if (!mounted) return; + setState(() { + _activeSessionId = id; + _error = null; + _repoBinding = null; + }); + _notifySessionsChanged(); + await _persist(); + } + + Future _deleteActiveSession() async { + final active = _activeSession; + if (active == null) return; + setState(() { + _sessions.removeWhere((session) => session.id == active.id); + if (_sessions.isEmpty) { + final replacement = _newSessionObject(); + _sessions.add(replacement); + _activeSessionId = replacement.id; + } else { + _activeSessionId = _sessions.first.id; + } + _error = null; + }); + await _persist(); + } + + Future _send() async { + await _ensureSessionsLoaded(); + if (!mounted) return; + final prompt = _promptController.text.trim(); + if (prompt.isEmpty) { + _showMessage('Enter a prompt first'); + return; + } + final active = _activeSession; + if (active == null) { + _showMessage('Chat is still loading'); + return; + } + + final now = DateTime.now(); + final userTurn = _ChatTurn(role: 'user', content: prompt, time: now); + final history = [...active.turns, userTurn]; + final pending = active.copyWith( + title: active.title == 'New chat' ? _chatTitle(prompt) : active.title, + updatedAt: now, + turns: history, + ); + + setState(() { + _sending = true; + _error = null; + _promptController.clear(); + _storeSession(pending); + }); + _scrollConversationToEnd(force: true); + await _persist(); + + final flavor = _detectApiFlavor(widget.baseUrl, widget.model); + final repoContext = _repoBindingContext(); + final systemPrompt = [ + 'You are MobileCode, a mobile AI development assistant. Use the saved multi-turn chat context, answer concisely, and prefer executable mobile development steps.', + if (repoContext.isNotEmpty) ...[ + '', + repoContext, + ], + ].join('\n'); + final runId = 'chat_${DateTime.now().microsecondsSinceEpoch}'; + final usageAccumulator = TokenUsageAccumulator(providerKind: _usageProviderKind(flavor)); + final usageStarted = DateTime.now(); + final answerBuffer = StringBuffer(); + try { + final assistantStarted = DateTime.now(); + var lastPaintAt = DateTime.fromMillisecondsSinceEpoch(0); + var lastPaintLength = 0; + await for (final chunk in _streamProvider( + history, + systemPrompt: systemPrompt, + responseTimeout: const Duration(minutes: 2), + onUsageChunk: usageAccumulator.addChunk, + )) { + answerBuffer.write(chunk); + final answer = answerBuffer.toString(); + final now = DateTime.now(); + if (now.difference(lastPaintAt).inMilliseconds <= 220 && + answer.length - lastPaintLength < 120) { + continue; + } + lastPaintAt = now; + lastPaintLength = answer.length; + _replaceAssistantTurn( + sessionId: pending.id, + assistantIndex: history.length, + content: answer, + time: assistantStarted, + ); + } + + final answer = answerBuffer.toString().trim(); + if (answer.isEmpty) { + throw Exception('Provider stream completed without text.'); + } + _replaceAssistantTurn( + sessionId: pending.id, + assistantIndex: history.length, + content: answer, + time: assistantStarted, + ); + if (!mounted) return; + _scrollConversationToEnd(); + await _persist(); + await TokenUsageService.instance.recordCompleted( + provider: _flavorLabel(flavor), + model: widget.model, + endpoint: 'chat', + durationMs: DateTime.now().difference(usageStarted).inMilliseconds, + success: true, + sessionId: pending.id, + runId: runId, + usage: usageAccumulator.snapshot( + inputChars: _tokenInputChars(history, systemPrompt), + outputChars: answer.length, + ), + ); + widget.onLog('AI response streamed', '${_flavorLabel(flavor)} - ${widget.model} - ${answer.length} chars', Icons.forum_outlined, _mint); + } on Object catch (error) { + if (!mounted) return; + final message = error.toString().replaceFirst('Exception: ', ''); + setState(() => _error = message); + await TokenUsageService.instance.recordCompleted( + provider: _flavorLabel(flavor), + model: widget.model, + endpoint: 'chat', + durationMs: DateTime.now().difference(usageStarted).inMilliseconds, + success: false, + sessionId: pending.id, + runId: runId, + usage: usageAccumulator.snapshot( + inputChars: _tokenInputChars(history, systemPrompt), + outputChars: answerBuffer.length, + ), + ); + widget.onLog('AI request error', _compact(message, limit: 140), Icons.error_outline, _rose); + } finally { + if (mounted) { + setState(() => _sending = false); + } + } + } + + void _replaceAssistantTurn({ + required String sessionId, + required int assistantIndex, + required String content, + required DateTime time, + }) { + if (!mounted) return; + final sessionIndex = _sessions.indexWhere((item) => item.id == sessionId); + if (sessionIndex == -1) return; + final session = _sessions[sessionIndex]; + final turns = [...session.turns]; + final replacement = _ChatTurn(role: 'assistant', content: content, time: time); + if (assistantIndex >= 0 && + assistantIndex < turns.length && + turns[assistantIndex].role == 'assistant') { + turns[assistantIndex] = replacement; + } else { + turns.add(replacement); + } + setState(() { + _storeSession(session.copyWith(updatedAt: DateTime.now(), turns: turns)); + }); + _scrollConversationToEnd(); + } + + Future _toggleVoiceInput() async { + if (!_voiceAvailable) { + final available = await _voiceService.initialize(); + if (!mounted) return; + setState(() => _voiceAvailable = available); + if (!available) { + _showMessage(_voiceService.lastError.isEmpty ? 'Voice input is not available' : _voiceService.lastError); + return; + } + } + + final voiceActive = _voiceService.isListening || _voiceState == VoiceState.listening; + if (voiceActive) { + setState(() => _voiceState = VoiceState.processing); + String transcript; + try { + transcript = await _voiceService.stopListening().timeout(const Duration(seconds: 2)); + } on TimeoutException { + await _voiceService.cancel(); + transcript = _promptController.text; + } + if (!mounted) return; + if (transcript.trim().isNotEmpty) { + _promptController.text = transcript.trim(); + _promptController.selection = TextSelection.collapsed(offset: _promptController.text.length); + } + setState(() => _voiceState = _voiceService.currentState); + return; + } + + try { + await _voiceService.startListening(); + if (mounted) setState(() => _voiceState = VoiceState.listening); + } on Object catch (error) { + if (!mounted) return; + _showMessage(_compact(error.toString(), limit: 120)); + setState(() => _voiceState = VoiceState.error); + } + } + + String _usageProviderKind(_ApiFlavor flavor) { + return flavor == _ApiFlavor.anthropic ? 'anthropic' : 'openai'; + } + + int _tokenInputChars(List<_ChatTurn> turns, String systemPrompt) { + return systemPrompt.length + turns.fold(0, (total, turn) => total + turn.content.length + turn.role.length + 8); + } + + Future _callProvider( + List<_ChatTurn> history, { + required String systemPrompt, + int maxTokens = 1024, + Duration responseTimeout = const Duration(seconds: 120), + bool trackAgentRequest = false, + bool Function()? isCancelled, + }) async { + if (widget.baseUrl.trim().isEmpty) { + throw Exception('Provider is not configured: Base URL is empty.'); + } + if (widget.apiKey.trim().isEmpty) { + throw Exception('Provider is not configured: API key is empty.'); + } + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + + final flavor = _detectApiFlavor(widget.baseUrl, widget.model); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 12); + if (trackAgentRequest) { + _agentProviderClient = client; + } + try { + final request = await client + .postUrl(flavor == _ApiFlavor.anthropic + ? _anthropicMessagesUri(widget.baseUrl) + : _openAiChatUri(widget.baseUrl)) + .timeout(const Duration(seconds: 12)); + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + request.headers.contentType = ContentType.json; + if (flavor == _ApiFlavor.anthropic) { + request.headers.set('anthropic-version', '2023-06-01'); + } + if (flavor == _ApiFlavor.anthropic) { + request.headers.set('x-api-key', widget.apiKey); + } + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer ${widget.apiKey}'); + request.write(jsonEncode(_requestBody( + flavor, + history, + systemPrompt: systemPrompt, + maxTokens: maxTokens, + stream: false, + ))); + + final response = await request.close().timeout(responseTimeout); + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + final body = await utf8.decodeStream(response); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('Provider HTTP ${response.statusCode}: ${_compact(body)}'); + } + final answer = _extractAssistantText(body); + if (answer.trim().isEmpty) { + throw Exception('Provider returned an empty response.'); + } + return answer; + } on SocketException catch (error) { + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + throw Exception('Provider network error: ${_friendlySocketError(error)}'); + } on HttpException catch (error) { + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + throw Exception('Provider HTTP error: ${error.message}'); + } on TimeoutException { + throw Exception('Provider timed out after ${responseTimeout.inSeconds}s while waiting for a model response. Try a shorter prompt/model output or stop and retry.'); + } finally { + if (identical(_agentProviderClient, client)) { + _agentProviderClient = null; + } + client.close(force: true); + } + } + + Stream _streamProvider( + List<_ChatTurn> history, { + required String systemPrompt, + int maxTokens = 1024, + Duration responseTimeout = const Duration(seconds: 180), + bool trackAgentRequest = false, + bool Function()? isCancelled, + void Function(Map chunk)? onUsageChunk, + }) async* { + if (widget.baseUrl.trim().isEmpty) { + throw Exception('Provider is not configured: Base URL is empty.'); + } + if (widget.apiKey.trim().isEmpty) { + throw Exception('Provider is not configured: API key is empty.'); + } + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + + final flavor = _detectApiFlavor(widget.baseUrl, widget.model); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 12); + if (trackAgentRequest) { + _agentProviderClient = client; + } + + try { + final request = await client + .postUrl(flavor == _ApiFlavor.anthropic + ? _anthropicMessagesUri(widget.baseUrl) + : _openAiChatUri(widget.baseUrl)) + .timeout(const Duration(seconds: 12)); + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + + request.headers.contentType = ContentType.json; + request.headers.set(HttpHeaders.acceptHeader, 'text/event-stream'); + if (flavor == _ApiFlavor.anthropic) { + request.headers.set('anthropic-version', '2023-06-01'); + request.headers.set('x-api-key', widget.apiKey); + } + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer ${widget.apiKey}'); + request.write(jsonEncode(_requestBody( + flavor, + history, + systemPrompt: systemPrompt, + maxTokens: maxTokens, + stream: true, + ))); + + final response = await request.close().timeout(responseTimeout); + if (response.statusCode < 200 || response.statusCode >= 300) { + final body = await utf8.decodeStream(response); + throw Exception('Provider HTTP ${response.statusCode}: ${_compact(body)}'); + } + + await for (final line in response + .transform(utf8.decoder) + .transform(const LineSplitter()) + .timeout(responseTimeout)) { + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('event:')) continue; + if (trimmed == 'data: [DONE]') break; + + final payload = trimmed.startsWith('data: ') ? trimmed.substring(6).trim() : trimmed; + if (payload.isEmpty || payload == '[DONE]') break; + + try { + final decoded = jsonDecode(payload); + if (decoded is Map) { + onUsageChunk?.call(decoded); + if (decoded['type'] == 'message_stop') break; + final delta = _extractStreamDelta(decoded, flavor); + if (delta != null && delta.isNotEmpty) { + yield delta; + } + continue; + } + } catch (_) { + if (payload.startsWith('{')) { + final fallback = _extractAssistantText(payload); + if (fallback.trim().isNotEmpty) yield fallback.trim(); + break; + } + } + } + } on SocketException catch (error) { + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + throw Exception('Provider network error: ${_friendlySocketError(error)}'); + } on HttpException catch (error) { + if (isCancelled?.call() == true) { + throw Exception('Agent run stopped by user.'); + } + throw Exception('Provider HTTP error: ${error.message}'); + } on TimeoutException { + throw Exception('Provider stream timed out after ${responseTimeout.inSeconds}s. Use Pause to stop long runs or reduce requested output.'); + } finally { + if (identical(_agentProviderClient, client)) { + _agentProviderClient = null; + } + client.close(force: true); + } + } + + Future _runAgentWithTrace() async { + await _ensureSessionsLoaded(); + if (!mounted) return; + if (_agentRunning) { + _showMessage('Agent is still running'); + return; + } + final prompt = _promptController.text.trim(); + if (prompt.isEmpty) { + _showMessage('Describe what the agent should build or test'); + return; + } + final active = _activeSession; + if (active == null) { + _showMessage('Chat is still loading'); + return; + } + + final toolName = _agentToolNameForPrompt(prompt); + final runId = 'agent_${DateTime.now().microsecondsSinceEpoch}'; + RoleProposal? proposedRole; + List? activeRoles; + if (_agentModeEnabled) { + await RoleLibraryService.instance.initialize(); + final enabledRoles = RoleLibraryService.instance.recruitmentRoles; + proposedRole = await RoleLibraryService.instance.createProposalFromPrompt(prompt, runId, enabledRoles); + if (proposedRole != null) { + final sourceRoleId = proposedRole.sourceRoleId; + activeRoles = [ + proposedRole.role, + ...enabledRoles.where((role) => role.id != sourceRoleId).take(4), + ]; + } else { + activeRoles = enabledRoles; + } + } + final now = DateTime.now(); + final pending = active.copyWith( + title: active.title == 'New chat' ? _chatTitle(prompt) : active.title, + updatedAt: now, + turns: [ + ...active.turns, + _ChatTurn(role: 'user', content: prompt, time: now), + ], + ); + + setState(() { + _agentRunning = true; + _agentStopping = false; + _agentCancelRequested = false; + _error = null; + _promptController.clear(); + _agentTrace + ..clear() + ..addAll(_agentRunTraceTemplate(prompt)); + _activeRunRoles = activeRoles; + _activeRunProposal = proposedRole; + _storeSession(pending); + }); + _scrollConversationToEnd(force: true); + await _persist(); + + String? failure; + String? modelAnswer; + String? generatedPath; + final flavor = _detectApiFlavor(widget.baseUrl, widget.model); + final usageAccumulator = TokenUsageAccumulator(providerKind: _usageProviderKind(flavor)); + final agentStarted = DateTime.now(); + DateTime? providerStartedAt; + try { + await _completeAgentRunStep(0); + if (_agentCancelRequested) throw Exception('Agent run stopped by user.'); + await _completeAgentRunStep(1); + if (_agentCancelRequested) throw Exception('Agent run stopped by user.'); + final skillContext = toolName.startsWith('mobile_coding.generate_') + ? await SkillManagerService.instance.buildHtmlGenerationSkillContext() + : ''; + if (skillContext.isNotEmpty) { + _setAgentRunStep( + 1, + _AgentStepState.done, + detail: 'Selected $toolName and injected enabled HTML/UI skills into the provider prompt.', + ); + } + final providerStarted = DateTime.now(); + providerStartedAt = providerStarted; + _setAgentRunStep( + 2, + _AgentStepState.running, + detail: 'Streaming ${_flavorLabel(flavor)} provider response. Use Pause to stop this run.', + ); + final streamBuffer = StringBuffer(); + var lastPreviewAt = DateTime.fromMillisecondsSinceEpoch(0); + var lastPreviewLength = 0; + await for (final chunk in _streamProvider( + pending.turns, + systemPrompt: _agentSystemPrompt( + toolName, + skillContext: skillContext, + roleContext: _agentModeEnabled ? _roleRecruitmentContext() : '', + ), + maxTokens: 4096, + responseTimeout: const Duration(minutes: 3), + trackAgentRequest: true, + isCancelled: () => _agentCancelRequested, + onUsageChunk: usageAccumulator.addChunk, + )) { + if (_agentCancelRequested) throw Exception('Agent run stopped by user.'); + streamBuffer.write(chunk); + final currentText = streamBuffer.toString(); + final now = DateTime.now(); + if (now.difference(lastPreviewAt).inMilliseconds > 350 || + currentText.length - lastPreviewLength >= 240) { + lastPreviewAt = now; + lastPreviewLength = currentText.length; + final tailStart = currentText.length > 240 ? currentText.length - 240 : 0; + final previewTail = currentText.substring(tailStart); + final elapsed = now.difference(providerStarted).inSeconds; + _setAgentRunStep( + 2, + _AgentStepState.running, + detail: 'Streaming ${currentText.length} chars in ${elapsed}s...\n${_compact(previewTail, limit: 240)}', + ); + } + } + final streamedAnswer = streamBuffer.toString(); + if (streamedAnswer.trim().isEmpty) { + throw Exception('Provider stream completed without text.'); + } + modelAnswer = streamedAnswer; + if (_agentCancelRequested) throw Exception('Agent run stopped by user.'); + final elapsed = DateTime.now().difference(providerStarted).inSeconds; + _setAgentRunStep(2, _AgentStepState.done, detail: 'Streamed ${streamedAnswer.length} chars from ${_flavorLabel(flavor)} in ${elapsed}s.'); + generatedPath = await _persistAgentGeneratedArtifact(toolName, modelAnswer); + if (_agentCancelRequested) throw Exception('Agent run stopped by user.'); + await _completeAgentRunStep( + 3, + detail: generatedPath == null ? 'No file artifact required for this tool.' : 'Saved generated artifact to $generatedPath', + ); + if (_agentCancelRequested) throw Exception('Agent run stopped by user.'); + await _completeAgentRunStep(4); + } on Object catch (error) { + failure = error.toString(); + _failAgentRunStep(_compact(failure, limit: 140)); + } + + if (!mounted) return; + final current = _sessions.firstWhere((session) => session.id == pending.id, orElse: () => pending); + final stopped = failure != null && failure!.toLowerCase().contains('stopped by user'); + final assistantText = failure == null + ? _agentProviderCompletionMessage(toolName, modelAnswer ?? '', generatedPath) + : stopped + ? 'Agent run stopped before writing more output for `$toolName`.' + : 'Agent run failed while using `$toolName`.\n\n${_compact(failure!, limit: 300)}'; + final next = current.copyWith( + updatedAt: DateTime.now(), + turns: [ + ...current.turns, + _ChatTurn(role: 'assistant', content: assistantText, time: DateTime.now()), + ], + ); + + setState(() { + _agentRunning = false; + _agentStopping = false; + _agentCancelRequested = false; + _storeSession(next); + if (failure != null && !stopped) _error = failure; + }); + final roleId = _displayRecruitRoles.isEmpty ? null : _displayRecruitRoles.first.id; + final durationBase = providerStartedAt ?? agentStarted; + if (stopped) { + await TokenUsageService.instance.recordCancelled( + provider: _flavorLabel(flavor), + model: widget.model, + endpoint: toolName, + durationMs: DateTime.now().difference(durationBase).inMilliseconds, + sessionId: pending.id, + runId: runId, + roleId: roleId, + inputChars: _tokenInputChars(pending.turns, _agentSystemPrompt(toolName, roleContext: _agentModeEnabled ? _roleRecruitmentContext() : '')), + outputChars: modelAnswer?.length ?? 0, + ); + } else { + await TokenUsageService.instance.recordCompleted( + provider: _flavorLabel(flavor), + model: widget.model, + endpoint: toolName, + durationMs: DateTime.now().difference(durationBase).inMilliseconds, + success: failure == null, + sessionId: pending.id, + runId: runId, + roleId: roleId, + usage: usageAccumulator.snapshot( + inputChars: _tokenInputChars(pending.turns, _agentSystemPrompt(toolName, roleContext: _agentModeEnabled ? _roleRecruitmentContext() : '')), + outputChars: modelAnswer?.length ?? 0, + ), + ); + } + _scrollConversationToEnd(); + await _persist(); + + if (failure == null) { + widget.onLog('Agent run completed', toolName, Icons.psychology_alt_outlined, _violet); + await widget.onAgentPrompt(prompt); + } else if (stopped) { + widget.onLog('Agent run stopped', toolName, Icons.pause_circle_outline, _amber); + } else { + widget.onLog('Agent run failed', _compact(failure, limit: 140), Icons.error_outline, _rose); + } + } + + void _cancelAgentRun() { + if (!_agentRunning || _agentStopping) return; + setState(() { + _agentCancelRequested = true; + _agentStopping = true; + }); + _agentProviderClient?.close(force: true); + _failAgentRunStep('Stopped by user.'); + widget.onLog('Agent pause requested', 'Current provider request will be closed and no more files will be written.', Icons.pause_circle_outline, _amber); + } + + void _setAgentRunStep(int index, _AgentStepState state, {String? detail}) { + if (!mounted || index < 0 || index >= _agentTrace.length) return; + setState(() { + _agentTrace[index] = _agentTrace[index].copyWith( + state: state, + detail: detail ?? _agentTrace[index].detail, + finishedAt: state == _AgentStepState.done || state == _AgentStepState.failed ? DateTime.now() : null, + ); + }); + _scrollConversationToEnd(); + } + + Future _completeAgentRunStep(int index, {String? detail}) async { + if (!mounted || index < 0 || index >= _agentTrace.length) return; + if (_agentCancelRequested) return; + _setAgentRunStep(index, _AgentStepState.running); + await Future.delayed(const Duration(milliseconds: 240)); + if (!mounted || index < 0 || index >= _agentTrace.length) return; + if (_agentCancelRequested) return; + _setAgentRunStep(index, _AgentStepState.done, detail: detail); + } + + void _failAgentRunStep(String detail) { + if (!mounted || _agentTrace.isEmpty) return; + final runningIndex = _agentTrace.indexWhere((step) => step.state == _AgentStepState.running); + final index = runningIndex == -1 ? _agentTrace.indexWhere((step) => step.state == _AgentStepState.queued) : runningIndex; + if (index == -1) return; + setState(() { + _agentTrace[index] = _agentTrace[index].copyWith( + detail: detail, + state: _AgentStepState.failed, + finishedAt: DateTime.now(), + ); + }); + _scrollConversationToEnd(); + } + + void _openAgentRoleView(MobileCodeRole role, int index) { + final step = index >= 0 && index < _agentTrace.length ? _agentTrace[index] : null; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _AgentRoleViewSheet( + role: role, + index: index, + step: step, + ), + ); + } + + Future _acceptRoleProposal(RoleProposal proposal, {MobileCodeRole? editedRole}) async { + await RoleLibraryService.instance.acceptProposal(proposal.proposalId, editedRole: editedRole); + if (!mounted) return; + setState(() { + _activeRunProposal = null; + _roleRecruitRoles = RoleLibraryService.instance.recruitmentRoles; + }); + _showMessage('Role saved to local library'); + } + + Future _dismissRoleProposal(RoleProposal proposal) async { + await RoleLibraryService.instance.dismissProposal(proposal.proposalId); + if (!mounted) return; + setState(() => _activeRunProposal = null); + _showMessage('Role proposal dismissed'); + } + + Future _editRoleProposal(RoleProposal proposal) async { + final edited = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _RoleProposalEditSheet(role: proposal.role), + ); + if (edited == null) return; + await _acceptRoleProposal(proposal, editedRole: edited); + } + + String _roleRecruitmentContext() { + final roles = _displayRecruitRoles; + if (roles.isEmpty) return ''; + final lines = [ + 'RR mode is enabled. Use these role cards as sequential role personalities inside one execution lane. Do not claim parallel execution.', + ]; + for (var index = 0; index < roles.length; index++) { + final role = roles[index]; + lines.add([ + '${index + 1}. ${role.name}', + 'Mission: ${role.mission}', + if (role.personality.trim().isNotEmpty) 'Personality: ${role.personality}', + if (role.responsibilities.isNotEmpty) 'Responsibilities: ${role.responsibilities.take(4).join('; ')}', + if (role.guardrails.isNotEmpty) 'Guardrails: ${role.guardrails.take(3).join('; ')}', + if (role.successCriteria.isNotEmpty) 'Success: ${role.successCriteria.take(3).join('; ')}', + if (role.promptTemplate.trim().isNotEmpty) 'Prompt: ${role.promptTemplate}', + ].join('\n')); + } + return lines.join('\n\n'); + } + + String _repoBindingContext() { + final binding = _repoBinding; + if (binding == null) return ''; + return [ + 'Active GitHub repository binding:', + '- Repository: ${binding.repoFullName}', + '- Repository URL: ${binding.repoUrl}', + if (binding.pagesUrl != null) '- GitHub Pages URL: ${binding.pagesUrl}', + if (binding.actionsUrl != null) '- GitHub Actions URL: ${binding.actionsUrl}', + '- Workspace mode: ${binding.workspaceMode}', + if (binding.workspacePath != null) '- Phone workspace path: ${binding.workspacePath}', + '- Remote-linked means GitHub API workspace, not a git clone. Use git commands only when workspace mode is Git clone.', + ].join('\n'); + } + + String _agentSystemPrompt(String toolName, {String skillContext = '', String roleContext = ''}) { + final repoContext = _repoBindingContext(); + return [ + 'You are MobileCode Android tool-aware coding assistant.', + 'You are running inside a mobile app, so be honest about what has actually happened.', + 'The selected tool is `$toolName`.', + if (repoContext.isNotEmpty) ...[ + '', + repoContext, + ], + 'You must generate original code from the user request. Do not use or mention a built-in demo fallback.', + 'Do not claim a file was written, previewed, pushed, or executed unless the app reports that after your response.', + if (roleContext.isNotEmpty) ...[ + '', + 'Role Recruit / RR mode context:', + roleContext, + ], + if (skillContext.isNotEmpty) ...[ + '', + 'Enabled HTML/UI skill context:', + skillContext, + ], + if (toolName.startsWith('mobile_coding.generate_')) + 'For web/html requests, return one complete self-contained HTML document inside a single ```html fenced block. It must be mobile-first, touch-friendly, accessible, visually intentional, GitHub Pages deployable, and not depend on network assets. Use relative links only; never reference app-private local paths inside the HTML.', + if (toolName == 'mobile_coding.build_diary_demo') + 'For the diary app request, return the minimal implementable UI/data model plan and code snippets needed for a local APK diary experience.', + if (toolName == 'mobile_tools.termux_probe') + 'For Termux/root requests, explain the Android permission/runtime boundary and list concrete checks without pretending shell execution happened.', + if (toolName == 'github.connectivity_test') + 'For GitHub requests, describe the exact token/repo API checks and failure modes without inventing successful connectivity.', + 'Keep the answer concise but include enough code for the next local tool step.', + ].join('\n'); + } + + String _agentProviderCompletionMessage(String toolName, String modelAnswer, String? generatedPath) { + final isWebArtifact = generatedPath != null && _isWebArtifactPath(generatedPath); + return [ + 'Agent run completed via provider: `$toolName`', + if (generatedPath != null) ...[ + 'Saved generated artifact: `$generatedPath`', + 'Phone file path: `$generatedPath`', + 'Code file: `$generatedPath`', + if (isWebArtifact) 'Web preview: tap “网页预览” for in-app WebView or “浏览器打开” for the external browser.', + ], + '', + modelAnswer.trim(), + ].join('\n'); + } + + Future _persistAgentGeneratedArtifact(String toolName, String modelAnswer) async { + final isWebArtifact = toolName == 'mobile_coding.generate_snake_preview' || + toolName == 'mobile_coding.generate_2048_preview' || + toolName == 'mobile_coding.generate_web_preview'; + final slug = switch (toolName) { + 'mobile_coding.generate_snake_preview' => 'agent_snake', + 'mobile_coding.generate_2048_preview' => 'local_tool_2048_from_model', + 'mobile_coding.build_diary_demo' => 'agent_diary', + _ => 'agent_run', + }; + final rootDirectory = await _mobileCodeProjectsRootDirectory(); + final projectDirectory = Directory(p.join(rootDirectory.path, slug)); + await projectDirectory.create(recursive: true); + + if (isWebArtifact) { + final html = _extractHtmlDocument(modelAnswer); + if (html == null) { + throw Exception('Provider responded, but did not return a complete ```html block. No game file was written.'); + } + final tempFile = File('${projectDirectory.path}/index.html.tmp'); + final file = File('${projectDirectory.path}/index.html'); + await tempFile.writeAsString(html, flush: true); + if (await file.exists()) { + await file.delete(); + } + await tempFile.rename(file.path); + return file.path; + } + + if (toolName.startsWith('mobile_coding.')) { + final file = File('${projectDirectory.path}/agent_response.md'); + await file.writeAsString(modelAnswer, flush: true); + return file.path; + } + return null; + } + + String? _extractHtmlDocument(String modelAnswer) { + final fenced = RegExp(r'```(?:html|HTML)\s*([\s\S]*?)```').firstMatch(modelAnswer)?.group(1)?.trim(); + if (fenced != null && fenced.contains(' _runAgent() async { + await _runAgentWithTrace(); + } + + + Map _requestBody( + _ApiFlavor flavor, + List<_ChatTurn> turns, { + required String systemPrompt, + int maxTokens = 1024, + bool stream = false, + }) { + final model = widget.model.isEmpty + ? (flavor == _ApiFlavor.anthropic ? _defaultModel : 'gpt-4o-mini') + : widget.model; + final messages = _providerMessages(turns); + if (flavor == _ApiFlavor.anthropic) { + return { + 'model': model, + 'system': systemPrompt, + 'max_tokens': maxTokens, + 'stream': stream, + 'messages': messages, + }; + } + return { + 'model': model, + 'messages': [ + {'role': 'system', 'content': systemPrompt}, + ...messages, + ], + 'stream': stream, + if (stream) 'stream_options': {'include_usage': true}, + }; + } + + List> _providerMessages(List<_ChatTurn> turns) { + final usable = turns + .where((turn) => (turn.role == 'user' || turn.role == 'assistant') && turn.content.trim().isNotEmpty) + .toList(); + final recent = usable.length > 16 ? usable.sublist(usable.length - 16) : usable; + final messages = >[]; + + for (final turn in recent) { + var role = turn.role; + if (messages.isEmpty && role == 'assistant') role = 'user'; + if (messages.isNotEmpty && messages.last['role'] == role) { + messages.last['content'] = '${messages.last['content']}\n\n${turn.content.trim()}'; + } else { + messages.add({'role': role, 'content': turn.content.trim()}); + } + } + + return messages.isEmpty + ? [ + {'role': 'user', 'content': 'Hello'}, + ] + : messages; + } + + String _extractAssistantText(String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + final choices = decoded['choices']; + if (choices is List && choices.isNotEmpty) { + final first = choices.first; + if (first is Map) { + final message = first['message']; + if (message is Map) { + final content = message['content']; + if (content is String && content.trim().isNotEmpty) return content.trim(); + } + final text = first['text']; + if (text is String && text.trim().isNotEmpty) return text.trim(); + } + } + final content = decoded['content']; + if (content is List && content.isNotEmpty) { + final parts = []; + for (final item in content) { + if (item is Map) { + final text = item['text']; + if (text is String && text.trim().isNotEmpty) parts.add(text.trim()); + } + } + if (parts.isNotEmpty) return parts.join('\n\n'); + } + } + } catch (_) { + // Show raw body when the provider returns a non-standard response. + } + return _compact(body); + } + + String? _extractStreamDelta(Map decoded, _ApiFlavor flavor) { + if (flavor == _ApiFlavor.anthropic) { + final delta = decoded['delta']; + if (delta is Map) { + final text = delta['text']; + if (text is String && text.isNotEmpty) return text; + } + final contentBlock = decoded['content_block']; + if (contentBlock is Map) { + final text = contentBlock['text']; + if (text is String && text.isNotEmpty) return text; + } + final content = decoded['content']; + if (content is List && content.isNotEmpty) { + final parts = []; + for (final item in content) { + if (item is Map) { + final text = item['text']; + if (text is String && text.isNotEmpty) parts.add(text); + } + } + if (parts.isNotEmpty) return parts.join('\n\n'); + } + return null; + } + + final choices = decoded['choices']; + if (choices is List && choices.isNotEmpty) { + final first = choices.first; + if (first is Map) { + final delta = first['delta']; + if (delta is Map) { + final content = delta['content']; + if (content is String && content.isNotEmpty) return content; + } + final message = first['message']; + if (message is Map) { + final content = message['content']; + if (content is String && content.isNotEmpty) return content; + } + } + } + return null; + } + + String _chatTitle(String prompt) { + final compact = _compact(prompt, limit: 36); + return compact.isEmpty ? 'New chat' : compact; + } + + void _showMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + bool _isNearChatBottom([double threshold = 180]) { + if (!_chatScrollController.hasClients) return true; + final position = _chatScrollController.position; + return (position.maxScrollExtent - position.pixels) <= threshold; + } + + void _handleChatScroll() { + if (!_chatScrollController.hasClients) return; + final nearBottom = _isNearChatBottom(); + final navCount = _conversationNavEntries(_activeSession).length; + final navIndex = _activeNavIndex(navCount); + if (nearBottom == _followChatBottom && + _showJumpToBottom == !nearBottom && + navIndex == _lastNavActiveIndex) { + return; + } + setState(() { + _followChatBottom = nearBottom; + _showJumpToBottom = !nearBottom; + _lastNavActiveIndex = navIndex; + }); + } + + void _scrollConversationToEnd({bool force = false}) { + if (!force && _autoScrollScheduled) return; + if (!force) _autoScrollScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!force) _autoScrollScheduled = false; + if (!_chatScrollController.hasClients) return; + if (!force && !_followChatBottom && !_isNearChatBottom()) return; + final position = _chatScrollController.position; + final target = position.maxScrollExtent; + if ((target - position.pixels).abs() < 2) { + if (force && mounted) { + setState(() { + _followChatBottom = true; + _showJumpToBottom = false; + }); + } + return; + } + if (force) { + _chatScrollController.animateTo( + target, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + ); + } else { + // Streaming output can update dozens of times per second. Jumping once per + // frame avoids stacked animations fighting each other while still keeping + // the tail visible when the user has not scrolled away. + _chatScrollController.jumpTo(target); + } + if (force && mounted) { + setState(() { + _followChatBottom = true; + _showJumpToBottom = false; + }); + } + }); + } + + String _turnNavId(_ChatTurn turn, int index) { + return '${turn.role}_${turn.time.microsecondsSinceEpoch}_$index'; + } + + GlobalKey _keyForTurn(_ChatTurn turn, int index) { + return _turnKeys.putIfAbsent(_turnNavId(turn, index), () => GlobalKey()); + } + + List<_ChatNavEntry> _conversationNavEntries(_ChatSession? active) { + final turns = active?.turns ?? const <_ChatTurn>[]; + final entries = <_ChatNavEntry>[]; + for (var index = 0; index < turns.length; index++) { + final turn = turns[index]; + final isUser = turn.role == 'user'; + final isKeyResult = turn.role == 'assistant' && _isFinalResultTurn(turn.content); + final isPublishResult = turn.role == 'assistant' && _isPublishResultTurn(turn.content); + final isCodeResult = turn.role == 'assistant' && _isCodeResultTurn(turn.content); + final isBookmark = turn.bookmarked || _isBookmarkedTurn(turn.content); + if (!isUser && !isKeyResult && !isCodeResult && !isBookmark) continue; + final summary = _compact(turn.content.replaceAll(RegExp(r'\s+'), ' ').trim(), limit: 72); + final kind = isBookmark + ? _ChatNavKind.bookmark + : isPublishResult + ? _ChatNavKind.publish + : isCodeResult + ? _ChatNavKind.code + : isKeyResult + ? _ChatNavKind.result + : _ChatNavKind.prompt; + entries.add(_ChatNavEntry( + id: _turnNavId(turn, index), + number: entries.length + 1, + kind: kind, + preview: summary.isEmpty + ? isBookmark + ? '书签' + : isPublishResult + ? '发布成功' + : isCodeResult + ? '代码结果' + : isKeyResult + ? '关键结果' + : '空输入' + : summary, + )); + } + return entries; + } + + int? _activeNavIndex(int count) { + if (count == 0 || !_chatScrollController.hasClients) return null; + final position = _chatScrollController.position; + final max = position.maxScrollExtent; + if (max <= 0) return 0; + final ratio = (position.pixels / max).clamp(0.0, 1.0); + final index = (ratio * (count - 1)).round(); + if (index < 0) return 0; + if (index >= count) return count - 1; + return index; + } + + void _showNavPreview(_ChatNavEntry entry) { + _navPreviewTimer?.cancel(); + setState(() => _navPreview = _ChatNavPreview(entry)); + _navPreviewTimer = Timer(const Duration(seconds: 2), () { + if (!mounted) return; + setState(() => _navPreview = null); + }); + } + + void _jumpToNavEntry(_ChatNavEntry entry) { + _showNavPreview(entry); + final turnContext = _turnKeys[entry.id]?.currentContext; + if (turnContext != null) { + Scrollable.ensureVisible( + turnContext, + duration: const Duration(milliseconds: 240), + curve: Curves.easeOut, + alignment: 0.12, + ); + return; + } + if (!_chatScrollController.hasClients) return; + final entries = _conversationNavEntries(_activeSession); + final index = entries.indexWhere((item) => item.id == entry.id); + if (index == -1 || entries.length <= 1) return; + final target = _chatScrollController.position.maxScrollExtent * (index / (entries.length - 1)); + _chatScrollController.animateTo( + target, + duration: const Duration(milliseconds: 240), + curve: Curves.easeOut, + ); + } + + Widget _buildChatHeader() { + final repoBinding = _repoBinding; + if (repoBinding == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: _RepoChatBindingBar( + binding: repoBinding, + onOpenRepo: () => unawaited(_openExternalUrl(context, repoBinding.repoUrl, label: 'repository', browserOpenMode: widget.browserOpenMode)), + onOpenPages: repoBinding.pagesUrl == null + ? null + : () => unawaited(_openExternalUrl(context, repoBinding.pagesUrl!, label: 'GitHub Pages', browserOpenMode: widget.browserOpenMode)), + onOpenActions: repoBinding.actionsUrl == null + ? null + : () => unawaited(_openExternalUrl(context, repoBinding.actionsUrl!, label: 'GitHub Actions', browserOpenMode: widget.browserOpenMode)), + onClear: () => setState(() => _repoBinding = null), + ), + ); + } + + Future _openArtifactCode(String path) async { + try { + final file = File(path); + if (!await file.exists()) { + _showMessage('Generated code file was not found on this phone.'); + return; + } + final code = await file.readAsString(); + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _CodeFileSheet( + path: path, + code: code, + onOpenEditor: () => unawaited(_openArtifactInEditor(path, initialContent: code)), + ), + ); + } on Object catch (error) { + if (!mounted) return; + _showMessage(_compact(error.toString(), limit: 140)); + } + } + + Future _openArtifactInEditor(String path, {String? initialContent}) async { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditorScreen( + initialFilePath: path, + initialContent: initialContent, + fileName: p.basename(path), + ), + ), + ); + } + + Future _previewArtifact(String path) async { + try { + final file = File(path); + if (!await file.exists()) { + _showMessage('Generated web file was not found on this phone.'); + return; + } + final html = await file.readAsString(); + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _WebPreviewSheet( + title: 'Generated web preview', + subtitle: path, + html: html, + ), + ); + } on Object catch (error) { + if (!mounted) return; + _showMessage(_compact(error.toString(), limit: 140)); + } + } + + Future _openArtifactInBrowser(String path) async { + try { + final file = File(path); + if (!await file.exists()) { + _showMessage('Generated web file was not found on this phone.'); + return; + } + final html = await file.readAsString(); + final opened = await _launchHtmlInExternalBrowser(html, browserOpenMode: widget.browserOpenMode); + if (!mounted) return; + _showMessage(opened ? 'Opened generated HTML with ${_browserOpenModeLabel(widget.browserOpenMode)}.' : 'No browser accepted this generated HTML.'); + } on Object catch (error) { + if (!mounted) return; + _showMessage(_compact(error.toString(), limit: 140)); + } + } + + Future _copyArtifactPath(String path) async { + await Clipboard.setData(ClipboardData(text: path)); + if (!mounted) return; + _showMessage('Phone file path copied.'); + } + + Future _openArtifactFolder(String path) async { + try { + final folderPath = _projectDirectoryForArtifact(path); + final folder = Directory(folderPath); + if (!await folder.exists()) { + _showMessage('Project folder was not found on this phone.'); + return; + } + final workspaceRoot = await _mobileCodeProjectsRootDirectory(); + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _ProjectFolderSheet( + initialPath: folder.path, + workspaceRoot: workspaceRoot.path, + onOpenFile: (filePath) => unawaited(_openArtifactCode(filePath)), + ), + ); + } on Object catch (error) { + if (!mounted) return; + _showMessage(_compact(error.toString(), limit: 140)); + } + } + + Future _deployArtifactToGitHubPages(String path) async { + try { + final flags = FeatureFlagsService(); + await flags.initialize(); + if (!await flags.isEnabled('github_pages_deploy')) { + _showMessage('GitHub Pages publishing is disabled in feature flags.'); + return; + } + final file = File(path); + if (!await file.exists()) { + _showMessage('Generated web file was not found on this phone.'); + return; + } + if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _GitHubPagesArtifactDeploySheet( + artifactPath: path, + onDeployed: _recordPagesDeployment, + ), + ); + } on Object catch (error) { + if (!mounted) return; + _showMessage(_compact(error.toString(), limit: 140)); + } + } + + Future _recordPagesDeployment(_PagesDeploymentSummary summary) async { + if (!mounted) return; + final active = _activeSession; + if (active == null) return; + final now = DateTime.now(); + final turn = _ChatTurn( + role: 'assistant', + content: [ + 'GitHub Pages deployment completed.', + 'Web URL: `${summary.url}`', + 'Repository: `${summary.repositoryUrl}`', + 'Code file: `${summary.artifactPath}`', + 'Published at: `${summary.publishedAt.toIso8601String()}`', + 'Pre-publish check: `${summary.readinessSummary}`', + 'Screenshot: `pending`', + ].join('\n'), + time: now, + ); + setState(() { + _storeSession(active.copyWith( + updatedAt: now, + turns: [...active.turns, turn], + )); + }); + await _persist(); + _scrollConversationToEnd(); + } + + Future _toggleTurnBookmark(int turnIndex) async { + final active = _activeSession; + if (active == null || turnIndex < 0 || turnIndex >= active.turns.length) return; + final turns = List<_ChatTurn>.of(active.turns); + final nextTurn = turns[turnIndex].copyWith(bookmarked: !turns[turnIndex].bookmarked); + turns[turnIndex] = nextTurn; + final now = DateTime.now(); + setState(() { + _storeSession(active.copyWith(updatedAt: now, turns: turns)); + }); + _notifySessionsChanged(); + await _persist(); + if (!mounted) return; + _showMessage(nextTurn.bookmarked ? '已设为书签,可在右侧导航条快速跳转。' : '已取消书签。'); + } + + Widget _buildConversationBody(_ChatSession? active) { + final allTurns = active?.turns ?? const <_ChatTurn>[]; + final finalResultTurn = allTurns.isNotEmpty && allTurns.last.role == 'assistant' && _isFinalResultTurn(allTurns.last.content) + ? allTurns.last + : null; + final conversationTurns = finalResultTurn == null ? allTurns : allTurns.sublist(0, allTurns.length - 1); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Panel( + padding: const EdgeInsets.all(12), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 220), + child: active == null || conversationTurns.isEmpty + ? const _EmptyChatState() + : Column( + children: [ + for (var index = 0; index < conversationTurns.length; index++) ...[ + KeyedSubtree( + key: _keyForTurn(conversationTurns[index], index), + child: _ChatBubble( + turn: conversationTurns[index], + onOpenArtifactCode: _openArtifactCode, + onPreviewArtifact: _previewArtifact, + onOpenArtifactBrowser: _openArtifactInBrowser, + onDeployArtifactPages: _deployArtifactToGitHubPages, + onCopyArtifactPath: _copyArtifactPath, + onOpenArtifactFolder: _openArtifactFolder, + onToggleBookmark: () => unawaited(_toggleTurnBookmark(index)), + browserOpenMode: widget.browserOpenMode, + ), + ), + if (index != conversationTurns.length - 1) const SizedBox(height: 10), + ], + ], + ), + ), + ), + if (_agentTrace.isNotEmpty) ...[ + const SizedBox(height: 12), + if (_agentModeEnabled) ...[ + _AgentRecruitmentPanel( + steps: _agentTrace, + running: _agentRunning, + roles: _displayRecruitRoles, + onOpenRole: _openAgentRoleView, + ), + const SizedBox(height: 12), + ], + _AgentTracePanel( + title: _agentRunning ? 'Agent is writing code' : 'Last agent process', + steps: _agentTrace, + ), + ], + if (!_agentRunning && _activeRunProposal != null && _activeRunProposal!.status == RoleProposalStatus.pending) ...[ + const SizedBox(height: 12), + _RoleProposalApprovalCard( + proposal: _activeRunProposal!, + onSave: () => unawaited(_acceptRoleProposal(_activeRunProposal!)), + onDismiss: () => unawaited(_dismissRoleProposal(_activeRunProposal!)), + onEdit: () => unawaited(_editRoleProposal(_activeRunProposal!)), + ), + ], + if (finalResultTurn != null) ...[ + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.flag_outlined, color: _mint, size: 18), + SizedBox(width: 8), + Expanded( + child: Text('Agent result', style: TextStyle(color: _text, fontSize: 15, fontWeight: FontWeight.w900)), + ), + ], + ), + const SizedBox(height: 12), + KeyedSubtree( + key: _keyForTurn(finalResultTurn, allTurns.length - 1), + child: _ChatBubble( + turn: finalResultTurn, + onOpenArtifactCode: _openArtifactCode, + onPreviewArtifact: _previewArtifact, + onOpenArtifactBrowser: _openArtifactInBrowser, + onDeployArtifactPages: _deployArtifactToGitHubPages, + onCopyArtifactPath: _copyArtifactPath, + onOpenArtifactFolder: _openArtifactFolder, + onToggleBookmark: () => unawaited(_toggleTurnBookmark(allTurns.length - 1)), + browserOpenMode: widget.browserOpenMode, + ), + ), + ], + ), + ), + ], + ], + ); + } + + Widget _buildComposer() { + final voiceActive = _voiceState == VoiceState.listening || _voiceService.isListening; + return Container( + decoration: BoxDecoration( + color: _panel, + border: const Border(top: BorderSide(color: _line)), + boxShadow: [ + BoxShadow( + color: _blue.withOpacity(0.06), + blurRadius: 18, + offset: const Offset(0, -8), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(12, 7, 12, 10), + child: SafeArea( + top: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: _ChatModeStrip(onPrompt: (prompt, {runAgent = false}) => unawaited(setPromptFromShell(prompt, runAgent: runAgent))), + ), + const SizedBox(width: 8), + _CompactAgentModeToggle( + enabled: _agentModeEnabled, + running: _agentRunning, + onChanged: (value) => setState(() => _agentModeEnabled = value), + ), + ], + ), + const SizedBox(height: 7), + Container( + padding: const EdgeInsets.fromLTRB(12, 4, 6, 4), + decoration: BoxDecoration( + color: _panelSoft, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _line), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: TextField( + controller: _promptController, + minLines: 1, + maxLines: 3, + textInputAction: TextInputAction.newline, + style: const TextStyle(color: _text, fontSize: 14.5, height: 1.35), + decoration: const InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + const SizedBox(width: 6), + _VoiceInputButton( + enabled: !_sending && (!_agentRunning || voiceActive), + available: _voiceAvailable, + state: _voiceState, + onTap: _toggleVoiceInput, + ), + const SizedBox(width: 4), + IconButton.filled( + tooltip: _sending ? 'Sending' : 'Send chat', + style: IconButton.styleFrom( + minimumSize: const Size(42, 42), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: _sending || _agentRunning ? null : _send, + icon: _sending + ? const SizedBox(width: 15, height: 15, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.send_outlined, size: 20), + ), + if (_agentRunning) ...[ + const SizedBox(width: 4), + IconButton.outlined( + tooltip: _agentStopping ? 'Stopping agent' : 'Pause agent run', + style: IconButton.styleFrom( + minimumSize: const Size(42, 42), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: _agentStopping ? null : _cancelAgentRun, + icon: _agentStopping + ? const SizedBox(width: 15, height: 15, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.pause_circle_outline, size: 20), + ), + ], + ], + ), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle(color: _rose, fontSize: 12, height: 1.35), + ), + ], + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final flavor = _detectApiFlavor(widget.baseUrl, widget.model); + final active = _activeSession; + final navEntries = _conversationNavEntries(active); + if (_loading) { + return const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator())); + } + if (widget.embedded) { + return Column( + children: [ + Expanded( + child: Stack( + children: [ + SingleChildScrollView( + controller: _chatScrollController, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(0, 2, 0, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildChatHeader(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _buildConversationBody(active), + ), + ], + ), + ), + if (_showJumpToBottom) + Positioned( + right: 18, + bottom: 14, + child: _JumpToBottomButton( + onTap: () => _scrollConversationToEnd(force: true), + ), + ), + if (navEntries.length >= 2) + Positioned( + right: 4, + top: 36, + bottom: 76, + child: _ConversationMinimapRail( + entries: navEntries, + activeIndex: _activeNavIndex(navEntries.length), + onTap: _jumpToNavEntry, + onPreview: _showNavPreview, + ), + ), + if (_navPreview != null) + Positioned( + right: 34, + top: 70, + child: _ConversationMinimapPreview(entry: _navPreview!.entry), + ), + ], + ), + ), + _buildComposer(), + ], + ); + } + return _SheetScaffold( + icon: Icons.forum_outlined, + title: 'AI Chat', + subtitle: _chatEndpointLabel(widget.baseUrl, flavor), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildChatHeader(), + const SizedBox(height: 12), + _buildConversationBody(active), + const SizedBox(height: 12), + _buildComposer(), + ], + ), + ); + } +} + +class _JumpToBottomButton extends StatelessWidget { + const _JumpToBottomButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: _panel, + elevation: 10, + shadowColor: _blue.withOpacity(0.25), + borderRadius: BorderRadius.circular(999), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + border: Border.all(color: _blue.withOpacity(0.35)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.keyboard_arrow_down_outlined, color: _blue, size: 18), + SizedBox(width: 4), + Text('到底部', style: TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900)), + ], + ), + ), + ), + ); + } +} + +enum _ChatNavKind { prompt, result, code, publish, bookmark } + +class _ChatNavEntry { + const _ChatNavEntry({ + required this.id, + required this.number, + required this.kind, + required this.preview, + }); + + final String id; + final int number; + final _ChatNavKind kind; + final String preview; + + bool get isKeyResult => + kind == _ChatNavKind.result || kind == _ChatNavKind.code || kind == _ChatNavKind.publish; +} + +class _ChatNavPreview { + const _ChatNavPreview(this.entry); + + final _ChatNavEntry entry; +} + +class _ConversationMinimapRail extends StatelessWidget { + const _ConversationMinimapRail({ + required this.entries, + required this.activeIndex, + required this.onTap, + required this.onPreview, + }); + + final List<_ChatNavEntry> entries; + final int? activeIndex; + final ValueChanged<_ChatNavEntry> onTap; + final ValueChanged<_ChatNavEntry> onPreview; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : 260.0; + final itemHeight = entries.isEmpty ? 8.0 : (maxHeight / entries.length).clamp(4.0, 12.0).toDouble(); + return Container( + width: 24, + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var index = 0; index < entries.length; index++) + _ConversationMinimapItem( + entry: entries[index], + active: index == activeIndex, + height: itemHeight, + compact: itemHeight < 7, + onTap: onTap, + onPreview: onPreview, + ), + ], + ), + ); + }, + ), + ); + } +} + +class _ConversationMinimapItem extends StatelessWidget { + const _ConversationMinimapItem({ + required this.entry, + required this.active, + required this.height, + required this.compact, + required this.onTap, + required this.onPreview, + }); + + final _ChatNavEntry entry; + final bool active; + final double height; + final bool compact; + final ValueChanged<_ChatNavEntry> onTap; + final ValueChanged<_ChatNavEntry> onPreview; + + @override + Widget build(BuildContext context) { + final baseColor = switch (entry.kind) { + _ChatNavKind.result => _mint, + _ChatNavKind.code => _violet, + _ChatNavKind.publish => _cyan, + _ChatNavKind.bookmark => _amber, + _ChatNavKind.prompt => _line, + }; + final color = active ? _blue : baseColor; + return MouseRegion( + onEnter: (_) => onPreview(entry), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => onTap(entry), + onLongPress: () => onPreview(entry), + child: SizedBox( + width: 24, + height: height, + child: Align( + alignment: Alignment.centerRight, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: active ? 21 : entry.isKeyResult ? 17 : (compact ? 8 : 12), + height: active ? 3.5 : entry.isKeyResult ? 3 : (compact ? 2 : 2.5), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(999), + boxShadow: active + ? [ + BoxShadow( + color: _blue.withOpacity(0.24), + blurRadius: 8, + ), + ] + : null, + ), + ), + ), + ), + ), + ); + } +} + +class _ConversationMinimapPreview extends StatelessWidget { + const _ConversationMinimapPreview({required this.entry}); + + final _ChatNavEntry entry; + + @override + Widget build(BuildContext context) { + final markerColor = switch (entry.kind) { + _ChatNavKind.publish => _cyan, + _ChatNavKind.code => _violet, + _ChatNavKind.result => _mint, + _ChatNavKind.bookmark => _amber, + _ChatNavKind.prompt => _blue, + }; + final markerLabel = switch (entry.kind) { + _ChatNavKind.publish => '发布 ${entry.number}', + _ChatNavKind.code => '代码 ${entry.number}', + _ChatNavKind.result => '结果 ${entry.number}', + _ChatNavKind.bookmark => '书签 ${entry.number}', + _ChatNavKind.prompt => '#${entry.number}', + }; + return Material( + color: Colors.transparent, + child: Container( + width: 230, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _panel, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _line), + boxShadow: [ + BoxShadow( + color: _blue.withOpacity(0.12), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: markerColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + markerLabel, + style: TextStyle(color: markerColor, fontSize: 12, fontWeight: FontWeight.w900), + ), + ), + const SizedBox(height: 8), + Text( + entry.preview, + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 12.5, height: 1.35), + ), + ], + ), + ), + ); + } +} + +class _ChatSessionChip extends StatelessWidget { + const _ChatSessionChip({ + required this.session, + required this.selected, + required this.onTap, + }); + + final _ChatSession session; + final bool selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final color = selected ? _mint : _line; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 150, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: selected ? _mint.withOpacity(0.12) : _panelSoft, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(selected ? 0.70 : 1)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + session.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: selected ? _text : _muted, fontWeight: FontWeight.w800, fontSize: 12), + ), + const SizedBox(height: 4), + Text( + _sessionTurnLabel(session), + style: TextStyle(color: selected ? _mint : _faint, fontSize: 11), + ), + ], + ), + ), + ); + } +} + +class _RepoChatBindingBar extends StatelessWidget { + const _RepoChatBindingBar({ + required this.binding, + required this.onOpenRepo, + required this.onOpenPages, + required this.onOpenActions, + required this.onClear, + }); + + final GitHubRepoChatRequest binding; + final VoidCallback onOpenRepo; + final VoidCallback? onOpenPages; + final VoidCallback? onOpenActions; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final workspacePath = binding.workspacePath; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _blue.withOpacity(0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _blue.withOpacity(0.18)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.account_tree_outlined, color: _blue, size: 17), + const SizedBox(width: 7), + Expanded( + child: Text( + binding.repoFullName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900), + ), + ), + IconButton( + tooltip: 'Clear repo binding', + visualDensity: VisualDensity.compact, + onPressed: onClear, + icon: const Icon(Icons.close_outlined, size: 16), + ), + ], + ), + const SizedBox(height: 5), + Wrap( + spacing: 7, + runSpacing: 7, + children: [ + _RepoBindingChip(icon: Icons.folder_open_outlined, label: binding.workspaceMode, color: _mint), + if (workspacePath != null) _RepoBindingChip(icon: Icons.phone_android_outlined, label: 'On phone', color: _cyan), + _RepoBindingAction(icon: Icons.open_in_new_outlined, label: 'Repo', onTap: onOpenRepo, color: _violet), + _RepoBindingAction(icon: Icons.web_outlined, label: 'Pages', onTap: onOpenPages, color: _mint), + _RepoBindingAction(icon: Icons.play_circle_outline, label: 'Actions', onTap: onOpenActions, color: _blue), + ], + ), + if (workspacePath != null) ...[ + const SizedBox(height: 6), + Text( + workspacePath, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _faint, fontSize: 10), + ), + ], + ], + ), + ); + } +} + +class _RepoBindingChip extends StatelessWidget { + const _RepoBindingChip({ + required this.icon, + required this.label, + required this.color, + }); + + final IconData icon; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.25)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 13), + const SizedBox(width: 5), + Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800)), + ], + ), + ); + } +} + +class _RepoBindingAction extends StatelessWidget { + const _RepoBindingAction({ + required this.icon, + required this.label, + required this.onTap, + required this.color, + }); + + final IconData icon; + final String label; + final VoidCallback? onTap; + final Color color; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Opacity( + opacity: onTap == null ? 0.45 : 1, + child: _RepoBindingChip(icon: icon, label: label, color: color), + ), + ); + } +} + +class _ChatBubble extends StatelessWidget { + const _ChatBubble({ + required this.turn, + required this.onOpenArtifactCode, + required this.onPreviewArtifact, + required this.onOpenArtifactBrowser, + required this.onDeployArtifactPages, + required this.onCopyArtifactPath, + required this.onOpenArtifactFolder, + required this.onToggleBookmark, + required this.browserOpenMode, + }); + + final _ChatTurn turn; + final ValueChanged onOpenArtifactCode; + final ValueChanged onPreviewArtifact; + final ValueChanged onOpenArtifactBrowser; + final ValueChanged onDeployArtifactPages; + final ValueChanged onCopyArtifactPath; + final ValueChanged onOpenArtifactFolder; + final VoidCallback onToggleBookmark; + final String browserOpenMode; + + @override + Widget build(BuildContext context) { + final isUser = turn.role == 'user'; + final color = isUser ? _cyan : _mint; + final published = isUser ? null : _pagesDeploymentFromContent(turn.content, turn.time); + final artifactPath = isUser ? null : _artifactPathFromContent(turn.content); + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: onToggleBookmark, + child: Container( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * (isUser ? 0.78 : 0.92)), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isUser ? _blue.withOpacity(0.11) : _mint.withOpacity(0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.30)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isUser ? 'You' : 'MobileCode', + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w900), + ), + if (turn.bookmarked) ...[ + const SizedBox(width: 6), + const Icon(Icons.bookmark_rounded, color: _amber, size: 14), + ], + ], + ), + const SizedBox(height: 6), + if (published != null) + _PublishedWorkCard( + info: published, + onOpenPages: () => unawaited(_openExternalUrl(context, published.pagesUrl, label: 'Pages URL', browserOpenMode: browserOpenMode)), + onOpenRepo: published.repositoryUrl.isEmpty ? null : () => unawaited(_openExternalUrl(context, published.repositoryUrl, label: 'repository', browserOpenMode: browserOpenMode)), + onOpenCode: () => onOpenArtifactCode(published.artifactPath), + onRedeploy: () => onDeployArtifactPages(published.artifactPath), + onCopyPath: () => onCopyArtifactPath(published.artifactPath), + onOpenFolder: () => onOpenArtifactFolder(published.artifactPath), + ) + else if (artifactPath != null) ...[ + _GeneratedArtifactActions( + path: artifactPath, + onOpenCode: () => onOpenArtifactCode(artifactPath), + onPreview: _isWebArtifactPath(artifactPath) ? () => onPreviewArtifact(artifactPath) : null, + onOpenBrowser: _isWebArtifactPath(artifactPath) ? () => onOpenArtifactBrowser(artifactPath) : null, + onDeployPages: _isWebArtifactPath(artifactPath) ? () => onDeployArtifactPages(artifactPath) : null, + onCopyPath: () => onCopyArtifactPath(artifactPath), + onOpenFolder: () => onOpenArtifactFolder(artifactPath), + ), + const SizedBox(height: 10), + _AssistantContentView(content: turn.content, isUser: isUser), + ] else + _AssistantContentView(content: turn.content, isUser: isUser), + ], + ), + ), + ), + ); + } +} + +class _AssistantContentView extends StatefulWidget { + const _AssistantContentView({ + required this.content, + required this.isUser, + }); + + final String content; + final bool isUser; + + @override + State<_AssistantContentView> createState() => _AssistantContentViewState(); +} + +class _AssistantContentViewState extends State<_AssistantContentView> { + bool _expanded = false; + final ScrollController _codeScrollController = ScrollController(); + + @override + void dispose() { + _codeScrollController.dispose(); + super.dispose(); + } + + bool get _shouldCollapse { + if (widget.isUser) return false; + return widget.content.length > 1800 || + widget.content.contains('```') || + widget.content.contains(' setState(() => _expanded = !_expanded), + icon: Icon(_expanded ? Icons.unfold_less_outlined : Icons.unfold_more_outlined, size: 16), + label: Text(_expanded ? '折叠代码/全文' : '展开代码/全文'), + style: OutlinedButton.styleFrom( + foregroundColor: _blue, + side: BorderSide(color: _blue.withOpacity(0.35)), + visualDensity: VisualDensity.compact, + textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w800), + ), + ), + OutlinedButton.icon( + onPressed: () { + unawaited(Clipboard.setData(ClipboardData(text: widget.content))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已复制完整代码/全文')), + ); + }, + icon: const Icon(Icons.copy_all_outlined, size: 16), + label: const Text('复制全文'), + style: OutlinedButton.styleFrom( + foregroundColor: _mint, + side: BorderSide(color: _mint.withOpacity(0.35)), + visualDensity: VisualDensity.compact, + textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w800), + ), + ), + if (_expanded) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text( + '完整查看请使用上方“代码文件 / 网页预览 / 浏览器打开”。', + style: TextStyle(color: _faint, fontSize: 11.5, height: 1.3), + ), + ), + ], + ), + ], + ], + ); + } +} + +class _GeneratedArtifactActions extends StatelessWidget { + const _GeneratedArtifactActions({ + required this.path, + required this.onOpenCode, + required this.onPreview, + required this.onOpenBrowser, + required this.onDeployPages, + required this.onCopyPath, + required this.onOpenFolder, + }); + + final String path; + final VoidCallback onOpenCode; + final VoidCallback? onPreview; + final VoidCallback? onOpenBrowser; + final VoidCallback? onDeployPages; + final VoidCallback onCopyPath; + final VoidCallback onOpenFolder; + + @override + Widget build(BuildContext context) { + final projectPath = _projectDirectoryForArtifact(path); + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _panel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _line), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(Icons.folder_open_outlined, color: _mint, size: 16), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Generated artifact on this phone', + style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900), + overflow: TextOverflow.ellipsis, + ), + ), + FutureBuilder( + future: _findGitRootForPath(projectPath), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)); + } + if (snapshot.data == null) return const SizedBox.shrink(); + return const _Pill(label: 'Git', icon: Icons.account_tree_outlined, color: _mint); + }, + ), + ], + ), + const SizedBox(height: 6), + SelectableText( + path, + maxLines: 2, + style: const TextStyle(color: _muted, fontSize: 11, height: 1.25), + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.folder_copy_outlined, color: _faint, size: 14), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Project folder: ${p.basename(projectPath)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _faint, fontSize: 11, height: 1.25), + ), + ), + ], + ), + if (onDeployPages != null) ...[ + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: _blue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: onDeployPages, + icon: const _GitHubMarkIcon(size: 17, color: Colors.white), + label: const Text( + '发布 GitHub Pages', + style: TextStyle(fontWeight: FontWeight.w900), + ), + ), + ), + ], + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MiniArtifactButton(icon: Icons.code_outlined, label: '代码文件', onTap: onOpenCode, color: _blue), + if (onPreview != null) _MiniArtifactButton(icon: Icons.preview_outlined, label: '网页预览', onTap: onPreview!, color: _violet), + if (onOpenBrowser != null) _MiniArtifactButton(icon: Icons.open_in_browser_outlined, label: '浏览器打开', onTap: onOpenBrowser!, color: _amber), + _MiniArtifactButton(icon: Icons.folder_open_outlined, label: '工程文件夹', onTap: onOpenFolder, color: _mint), + _MiniArtifactButton(icon: Icons.copy_outlined, label: '复制路径', onTap: onCopyPath, color: _cyan), + ], + ), + ], + ), + ); + } +} + +class _MiniArtifactButton extends StatelessWidget { + const _MiniArtifactButton({ + required this.icon, + required this.label, + required this.onTap, + required this.color, + this.leading, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final Color color; + final Widget? leading; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: color, + side: BorderSide(color: color.withValues(alpha: 0.35)), + visualDensity: VisualDensity.compact, + ), + onPressed: onTap, + icon: leading ?? Icon(icon, size: 15), + label: Text(label), + ); + } +} + +class _GitHubMarkIcon extends StatelessWidget { + const _GitHubMarkIcon({required this.size, required this.color}); + + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + return SvgPicture.asset( + 'assets/icons/github-mark-24.svg', + width: size, + height: size, + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + semanticsLabel: 'GitHub', + ); + } +} + +class _ProjectFolderSheet extends StatefulWidget { + const _ProjectFolderSheet({ + required this.initialPath, + required this.workspaceRoot, + required this.onOpenFile, + }); + + final String initialPath; + final String workspaceRoot; + final ValueChanged onOpenFile; + + @override + State<_ProjectFolderSheet> createState() => _ProjectFolderSheetState(); +} + +class _ProjectFolderSheetState extends State<_ProjectFolderSheet> { + late String _currentPath; + late Future<_ProjectFolderSnapshot> _snapshot; + + @override + void initState() { + super.initState(); + _currentPath = widget.initialPath; + _snapshot = _readFolder(_currentPath); + } + + Future<_ProjectFolderSnapshot> _readFolder(String path) async { + final directory = Directory(path); + if (!await directory.exists()) { + throw Exception('Folder does not exist: $path'); + } + final entries = await directory.list().toList(); + entries.sort((a, b) { + final aDir = FileSystemEntity.isDirectorySync(a.path); + final bDir = FileSystemEntity.isDirectorySync(b.path); + if (aDir != bDir) return aDir ? -1 : 1; + return p.basename(a.path).toLowerCase().compareTo(p.basename(b.path).toLowerCase()); + }); + return _ProjectFolderSnapshot( + path: directory.path, + gitRoot: await _findGitRootForPath(directory.path), + entries: entries.take(120).toList(growable: false), + ); + } + + void _openFolder(String path) { + setState(() { + _currentPath = path; + _snapshot = _readFolder(path); + }); + } + + void _openParent() { + final parent = Directory(_currentPath).parent.path; + if (!_canOpenParent(_currentPath, parent)) return; + _openFolder(parent); + } + + bool _canOpenParent(String currentPath, String parentPath) { + if (p.equals(parentPath, currentPath)) return false; + return p.equals(parentPath, widget.workspaceRoot) || p.isWithin(widget.workspaceRoot, parentPath); + } + + Future _copyPath(String path, String label) async { + await Clipboard.setData(ClipboardData(text: path)); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$label copied.'))); + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.folder_open_outlined, + title: 'Project files', + subtitle: _currentPath, + child: FutureBuilder<_ProjectFolderSnapshot>( + future: _snapshot, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snapshot.hasError) { + return _Panel( + child: Text( + _compact(snapshot.error.toString(), limit: 220), + style: const TextStyle(color: _rose, height: 1.35), + ), + ); + } + final data = snapshot.requireData; + final parentPath = Directory(data.path).parent.path; + final canGoUp = _canOpenParent(data.path, parentPath); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.home_work_outlined, color: _blue, size: 18), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'MobileCode workspace', + style: TextStyle(color: _text, fontWeight: FontWeight.w900), + ), + ), + if (data.gitRoot != null) const _Pill(label: 'Git repository', icon: Icons.account_tree_outlined, color: _mint), + ], + ), + const SizedBox(height: 8), + SelectableText( + data.path, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 8), + Text( + 'Workspace relative: ${_workspaceRelativePath(data.path, widget.workspaceRoot)}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _faint, fontSize: 11, height: 1.3), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: canGoUp ? _openParent : null, + icon: const Icon(Icons.arrow_upward_outlined, size: 16), + label: const Text('上一级'), + ), + OutlinedButton.icon( + onPressed: () => unawaited(_copyPath(data.path, 'Folder path')), + icon: const Icon(Icons.copy_outlined, size: 16), + label: const Text('复制文件夹路径'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + if (data.entries.isEmpty) + const _Panel( + child: Text('This project folder is empty.', style: TextStyle(color: _muted)), + ) + else + _Panel( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (var index = 0; index < data.entries.length; index++) ...[ + _ProjectFileRow( + entity: data.entries[index], + onOpenFolder: _openFolder, + onOpenFile: (path) { + Navigator.of(context).pop(); + widget.onOpenFile(path); + }, + ), + if (index != data.entries.length - 1) const Divider(height: 1, color: _line), + ], + ], + ), + ), + ], + ); + }, + ), + ); + } +} + +class _ProjectFolderSnapshot { + const _ProjectFolderSnapshot({ + required this.path, + required this.gitRoot, + required this.entries, + }); + + final String path; + final String? gitRoot; + final List entries; +} + +class _ProjectFileRow extends StatelessWidget { + const _ProjectFileRow({ + required this.entity, + required this.onOpenFolder, + required this.onOpenFile, + }); + + final FileSystemEntity entity; + final ValueChanged onOpenFolder; + final ValueChanged onOpenFile; + + @override + Widget build(BuildContext context) { + final isDirectory = FileSystemEntity.isDirectorySync(entity.path); + final name = p.basename(entity.path); + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: Icon( + isDirectory ? Icons.folder_outlined : Icons.description_outlined, + color: isDirectory ? _amber : _cyan, + ), + title: Text( + name.isEmpty ? entity.path : name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontWeight: FontWeight.w800), + ), + subtitle: Text( + entity.path, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _faint, fontSize: 11), + ), + trailing: Icon( + isDirectory ? Icons.chevron_right_outlined : Icons.open_in_new_outlined, + color: _faint, + size: 18, + ), + onTap: () => isDirectory ? onOpenFolder(entity.path) : onOpenFile(entity.path), + ); + } +} + +class _EmptyChatState extends StatelessWidget { + const _EmptyChatState(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 26), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, color: _faint, size: 36), + SizedBox(height: 10), + Text('No messages yet', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), + SizedBox(height: 4), + Text( + 'Use Send Chat for normal memory, or Run Agent to show the live coding/tool process.', + textAlign: TextAlign.center, + style: TextStyle(color: _muted, fontSize: 12), + ), + ], + ), + ), + ); + } +} + +class _AgentModeToggle extends StatelessWidget { + const _AgentModeToggle({ + required this.enabled, + required this.running, + required this.onChanged, + }); + + final bool enabled; + final bool running; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final color = enabled ? _violet : _faint; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withOpacity(enabled ? 0.10 : 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(enabled ? 0.34 : 0.18)), + ), + child: Row( + children: [ + SizedBox( + width: 28, + height: 28, + child: enabled + ? SvgPicture.asset( + _rrModeAvatarAsset, + fit: BoxFit.contain, + placeholderBuilder: (_) => Icon(Icons.psychology_alt_outlined, color: color, size: 18), + ) + : Icon(Icons.groups_2_outlined, color: color, size: 20), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + enabled ? 'RR mode on' : 'RR mode off', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900), + ), + Text( + enabled ? 'One run, multiple role personalities' : 'Plain chat surface', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 10.5), + ), + ], + ), + ), + const SizedBox(width: 8), + Switch.adaptive( + value: enabled, + onChanged: running ? null : onChanged, + activeColor: _violet, + ), + ], + ), + ); + } +} + +class _CompactAgentModeToggle extends StatelessWidget { + const _CompactAgentModeToggle({ + required this.enabled, + required this.running, + required this.onChanged, + }); + + final bool enabled; + final bool running; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final color = enabled ? _violet : _faint; + return Tooltip( + message: enabled ? 'RR mode on: role personalities guide the run' : 'Plain chat mode', + child: InkWell( + onTap: running ? null : () => onChanged(!enabled), + borderRadius: BorderRadius.circular(999), + child: Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: color.withOpacity(enabled ? 0.12 : 0.06), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withOpacity(enabled ? 0.42 : 0.20)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (enabled) ...[ + Icon(Icons.groups_2_outlined, color: color, size: 16), + const SizedBox(width: 5), + ], + Text( + enabled ? 'RR' : 'Chat', + style: TextStyle(color: enabled ? _text : _muted, fontSize: 11.5, fontWeight: FontWeight.w900), + ), + const SizedBox(width: 5), + AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 8, + height: 8, + decoration: BoxDecoration( + color: running + ? _amber + : enabled + ? _violet + : _line, + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ChatModeStrip extends StatelessWidget { + const _ChatModeStrip({required this.onPrompt}); + + final void Function(String prompt, {bool runAgent}) onPrompt; + + @override + Widget build(BuildContext context) { + const prompts = [ + _PromptShortcutData( + label: '贪吃蛇', + icon: Icons.videogame_asset_outlined, + prompt: '帮我在手机端创建一个可运行的贪吃蛇网页小游戏,生成 index.html、展示写代码过程,并用 WebView 预览。', + color: _mint, + ), + _PromptShortcutData( + label: '2048', + icon: Icons.grid_4x4_outlined, + prompt: '帮我创建一个 2048 网页小游戏,保存为 index.html,并打开本地 WebView 预览。', + color: _cyan, + ), + _PromptShortcutData( + label: 'GitHub', + icon: Icons.hub_outlined, + prompt: '测试 GitHub token 与 Harzva/mobilecode 仓库是否联通,并说明失败原因。', + color: _violet, + ), + ]; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final item in prompts) ...[ + _PromptShortcutChip(item: item, onTap: () => onPrompt(item.prompt, runAgent: true)), + const SizedBox(width: 8), + ], + ], + ), + ); + } +} + +class _PromptLaunchPanel extends StatelessWidget { + const _PromptLaunchPanel({required this.onPrompt}); + + final Future Function(String prompt, {bool runAgent}) onPrompt; + + @override + Widget build(BuildContext context) { + return _Panel( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.bolt_outlined, color: _mint, size: 19), + SizedBox(width: 8), + Expanded( + child: Text( + 'One-tap coding prompts', + style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15), + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + '这些按钮会回到聊天页并填入任务,让 agent 以“思考 -> 工具调用 -> 写文件 -> 预览”的方式执行。', + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _ActionChipButton( + icon: Icons.videogame_asset_outlined, + label: '贪吃蛇游戏', + color: _mint, + onTap: () => onPrompt('帮我在手机端创建一个可运行的贪吃蛇网页小游戏,生成 index.html、展示写代码过程,并用 WebView 预览。', runAgent: true), + ), + _ActionChipButton( + icon: Icons.grid_4x4_outlined, + label: '2048 Demo', + color: _cyan, + onTap: () => onPrompt('帮我创建一个 2048 网页小游戏,保存为 index.html,并打开本地 WebView 预览。', runAgent: true), + ), + _ActionChipButton( + icon: Icons.edit_note_outlined, + label: '日记 App', + color: _amber, + onTap: () => onPrompt('帮我做一个最小日记 App:本地保存、列表、编辑、删除和空状态都要能在 APK 里体验。'), + ), + ], + ), + ], + ), + ); + } +} + +class _ManagementSurfacePanel extends StatelessWidget { + const _ManagementSurfacePanel({ + required this.onOpenAgent, + required this.onOpenRoles, + required this.onOpenSkills, + required this.onOpenMcp, + required this.onOpenMemory, + required this.onOpenHooks, + required this.onOpenUsage, + required this.onOpenDevice, + }); + + final VoidCallback onOpenAgent; + final VoidCallback onOpenRoles; + final VoidCallback onOpenSkills; + final VoidCallback onOpenMcp; + final VoidCallback onOpenMemory; + final VoidCallback onOpenHooks; + final VoidCallback onOpenUsage; + final VoidCallback onOpenDevice; + + @override + Widget build(BuildContext context) { + return _Panel( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.dashboard_customize_outlined, color: _cyan, size: 19), + SizedBox(width: 8), + Expanded( + child: Text('MobileCode control center', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15)), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MiniArtifactButton(icon: Icons.psychology_alt_outlined, label: 'Agent', onTap: onOpenAgent, color: _violet), + _MiniArtifactButton(icon: Icons.badge_outlined, label: 'Roles', onTap: onOpenRoles, color: _blue), + _MiniArtifactButton(icon: Icons.extension_outlined, label: 'Skills', onTap: onOpenSkills, color: _mint), + _MiniArtifactButton(icon: Icons.account_tree_outlined, label: 'MCP', onTap: onOpenMcp, color: _cyan), + _MiniArtifactButton(icon: Icons.memory_outlined, label: 'Memory', onTap: onOpenMemory, color: _amber), + _MiniArtifactButton(icon: Icons.cable_outlined, label: 'Hooks', onTap: onOpenHooks, color: _violet), + _MiniArtifactButton(icon: Icons.token_outlined, label: 'Usage', onTap: onOpenUsage, color: _rose), + _MiniArtifactButton(icon: Icons.speed_outlined, label: 'Device', onTap: onOpenDevice, color: _lime), + ], + ), + ], + ), + ); + } +} + +class _PromptShortcutData { + const _PromptShortcutData({ + required this.label, + required this.icon, + required this.prompt, + required this.color, + }); + + final String label; + final IconData icon; + final String prompt; + final Color color; +} + +class _PromptShortcutChip extends StatelessWidget { + const _PromptShortcutChip({ + required this.item, + required this.onTap, + }); + + final _PromptShortcutData item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), + decoration: BoxDecoration( + color: item.color.withOpacity(0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: item.color.withOpacity(0.34)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(item.icon, color: item.color, size: 15), + const SizedBox(width: 5), + Text(item.label, style: const TextStyle(color: _text, fontSize: 11.5, fontWeight: FontWeight.w800)), + ], + ), + ), + ); } +} - String _runtimeCapabilityLabel(RuntimeCapabilities capabilities) { - final labels = [ - if (capabilities.shell) 'shell', - if (capabilities.git) 'git', - if (capabilities.node) 'node', - if (capabilities.python) 'python', - if (capabilities.flutter) 'flutter', - if (capabilities.androidBuild) 'apk', - if (capabilities.pty) 'pty', - if (capabilities.backgroundService) 'bg', - if (capabilities.cloudBuild) 'cloud', - if (capabilities.webViewPreview) 'webview', - ]; - return labels.isEmpty ? 'webview-only' : labels.join(', '); +class _ActionChipButton extends StatelessWidget { + const _ActionChipButton({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ActionChip( + avatar: Icon(icon, color: color, size: 17), + label: Text(label), + side: BorderSide(color: color.withOpacity(0.35)), + backgroundColor: color.withOpacity(0.10), + labelStyle: const TextStyle(color: _text, fontWeight: FontWeight.w800), + onPressed: onTap, + ); } - - void _setTab(_HomeTab tab) { - setState(() { - _tab = tab; - _selectedLayerIndex = switch (tab) { - _HomeTab.control => 0, - _HomeTab.ai => 2, - _HomeTab.ship => 3, - _HomeTab.guard => 4, - _HomeTab.insight => 5, - }; - }); - } - - void _runAction(_ModuleAction action, [_Capability? capability]) { - switch (action) { - case _ModuleAction.aiChat: - _openChatSheet(); - break; - case _ModuleAction.apiConfig: - _showMessage('API configuration is at the top of this screen'); - break; - case _ModuleAction.healthCheck: - _checkHealth(); - break; - case _ModuleAction.webDemo: - _openMobileCodingLabSheet(autoGenerate: true); - break; - case _ModuleAction.githubTest: - _openGitHubTestSheet(); - break; - case _ModuleAction.diary: - _openDiarySheet(); - break; - case _ModuleAction.toolLab: - _openToolLabSheet(); - break; - case _ModuleAction.termuxCheck: - _openTermuxSheet(); - break; - case _ModuleAction.newFile: - _openDraftSheet(); - break; - case _ModuleAction.snippet: - _openSnippetSheet(); - break; - case _ModuleAction.project: - _openProjectSheet(); - break; - case _ModuleAction.terminal: - _openCommandSheet(); - break; - case _ModuleAction.deepDive: - _openDeepDiveSheet(); - break; - case _ModuleAction.build: - _openBuildSheet(); - break; - case _ModuleAction.inspect: - if (capability != null) _openCapabilitySheet(capability); - break; - } - } - - void _openCapabilitySheet(_Capability capability) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _CapabilitySheet( - capability: capability, - onRun: () { - Navigator.pop(context); - _runAction(capability.primaryAction, capability); - }, - onCopy: () { - Clipboard.setData(ClipboardData(text: capability.services.join('\n'))); - _showMessage('Service list copied'); - }, - ), - ); - } - - void _openChatSheet() { - if (_effectiveBaseUrl.isEmpty) { - _showMessage('Configure Base URL first'); - return; - } - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _ChatPanel( - baseUrl: _effectiveBaseUrl, - apiKey: _effectiveApiKey, - model: _effectiveModel, - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), - onAgentPrompt: _handleAgentPrompt, - ), - ); - } - - Future _handleAgentPrompt(String prompt) async { - final toolName = _agentToolNameForPrompt(prompt); - _addLog('Agent process stayed in chat', toolName, Icons.psychology_alt_outlined, _violet); - } - - void _openMobileCodingLabSheet({bool autoGenerate = false}) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _MobileCodingLabSheet( - autoGenerate: autoGenerate, - onOpenOnlineDemo: () => _openUrl(_demo2048Url, 'published 2048 demo'), - onOpenGitHub: () => _openUrl(_githubTestUrl, 'GitHub test page'), - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), - ), - ); - } - - Future _openUrl(String url, String label) async { - try { - final uri = Uri.parse(url); - final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); - _addLog( - opened ? 'Opened $label' : 'Failed to open $label', - url, - opened ? Icons.open_in_browser_outlined : Icons.error_outline, - opened ? _mint : _rose, - ); - if (!opened) { - _showMessage('Could not open $label'); - } - } on Object catch (error) { - _addLog('Open URL failed', _compact(error.toString(), limit: 120), Icons.error_outline, _rose); - _showMessage('Could not open $label'); - } - } - - void _openGitHubTestSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _GitHubTestSheet( - onOpenWeb: () => _openUrl(_githubTestUrl, 'GitHub test page'), - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), - ), - ); - } - - void _openDiarySheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _DiarySheet( - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), - ), - ); - } - - void _openToolLabSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _ToolLabSheet( - baseUrl: _effectiveBaseUrl, - apiKey: _effectiveApiKey, - model: _effectiveModel, - onOpen2048: () => _openMobileCodingLabSheet(autoGenerate: true), - onOpenGitHubWeb: () => _openUrl(_githubTestUrl, 'GitHub test page'), - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), - ), - ); - } - - void _openTermuxSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _RuntimeDiagnosticsSheet( - runtimeManager: _runtimeManager, - initialHealth: _runtimeHealth, - initialCapabilities: _runtimeCapabilities, - termuxInstalled: _termuxInstalled, - termuxApiInstalled: _termuxApiInstalled, - rootAvailable: _rootAvailable, - onOpenInstall: () => _openUrl('https://f-droid.org/packages/com.termux/', 'Termux install page'), - onRefreshParent: () => _checkRuntime(), - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), +} + +class _VoiceInputButton extends StatelessWidget { + const _VoiceInputButton({ + required this.enabled, + required this.available, + required this.state, + required this.onTap, + }); + + final bool enabled; + final bool available; + final VoiceState state; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final listening = state == VoiceState.listening; + final color = listening + ? _rose + : available + ? _mint + : _amber; + return Tooltip( + message: listening ? 'Stop voice input' : 'Voice input', + child: SizedBox( + width: 42, + height: 42, + child: FilledButton( + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: color.withOpacity(listening ? 0.92 : 0.16), + foregroundColor: listening ? _bg : color, + minimumSize: const Size(42, 42), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + onPressed: enabled ? onTap : null, + child: Icon(listening ? Icons.stop_rounded : Icons.mic_none_outlined, size: 20), + ), ), ); } - - void _openDraftSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _DraftSheet( - onCreate: (name, language) { - setState(() { - _drafts.insert(0, _DraftFile(name: name, language: language, createdAt: DateTime.now())); - }); - _addLog('File draft created', '$language - $name', Icons.note_add_outlined, _cyan); - }, - ), - ); - } - - void _openSnippetSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _SnippetSheet( - onCreate: (title, language) { - setState(() { - _snippets.insert(0, _SnippetDraft(title: title, language: language, createdAt: DateTime.now())); - }); - _addLog('Snippet captured', '$language - $title', Icons.data_object_outlined, _lime); - }, - ), - ); - } - - void _openProjectSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _ProjectConsoleSheet( - runtimeManager: _runtimeManager, - defaultProjectPath: '/data/data/com.mobilecode.mobile_agent/files/mobilecode_runtime', - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), - ), - ); - } - - void _openCommandSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _RuntimeActionsSheet( - icon: Icons.terminal_outlined, - title: 'Runtime Actions', - subtitle: 'Run structured mobile coding actions through the active RuntimeProvider.', - runtimeManager: _runtimeManager, - defaultPackageManager: 'npm', - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), +} + +class _DraftSheet extends StatefulWidget { + const _DraftSheet({required this.onCreate}); + + final void Function(String name, String language) onCreate; + + @override + State<_DraftSheet> createState() => _DraftSheetState(); +} + +class _DraftSheetState extends State<_DraftSheet> { + final _name = TextEditingController(text: 'lib/screens/new_feature.dart'); + final _language = TextEditingController(text: 'Dart'); + final _content = TextEditingController(); + + @override + void dispose() { + _name.dispose(); + _language.dispose(); + _content.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.note_add_outlined, + title: 'New File Draft', + subtitle: 'Create a local draft from the editor controller surface.', + child: Column( + children: [ + TextField( + controller: _name, + decoration: const InputDecoration(labelText: 'File path', prefixIcon: Icon(Icons.description_outlined)), + ), + const SizedBox(height: 10), + TextField( + controller: _language, + decoration: const InputDecoration(labelText: 'Language', prefixIcon: Icon(Icons.code_outlined)), + ), + const SizedBox(height: 10), + TextField( + controller: _content, + minLines: 5, + maxLines: 8, + decoration: const InputDecoration(labelText: 'Initial content', alignLabelWithHint: true), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + final name = _name.text.trim().isEmpty ? 'untitled.dart' : _name.text.trim(); + final language = _language.text.trim().isEmpty ? 'Text' : _language.text.trim(); + widget.onCreate(name, language); + Navigator.pop(context); + }, + icon: const Icon(Icons.add_outlined), + label: const Text('Create draft'), + ), + ), + ], ), ); } - - void _openDeepDiveSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (_) => _DeepDiveConsoleSheet( - runtimeManager: _runtimeManager, - defaultProjectPath: '/data/data/com.mobilecode.mobile_agent/files/mobilecode_runtime', - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), - onStartInChat: (prompt) { - _addLog('Deep Dive started', 'Prompt sent to Chat Agent with RuntimeManager context', Icons.psychology_alt_outlined, _violet); - unawaited(_usePromptShortcut(prompt, runAgent: true)); - }, +} + +class _SnippetSheet extends StatefulWidget { + const _SnippetSheet({required this.onCreate}); + + final void Function(String title, String language) onCreate; + + @override + State<_SnippetSheet> createState() => _SnippetSheetState(); +} + +class _SnippetSheetState extends State<_SnippetSheet> { + final _title = TextEditingController(text: 'API client helper'); + final _language = TextEditingController(text: 'Dart'); + final _code = TextEditingController(); + + @override + void dispose() { + _title.dispose(); + _language.dispose(); + _code.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _SheetScaffold( + icon: Icons.data_object_outlined, + title: 'Snippet Capture', + subtitle: 'Save reusable code into the snippet surface.', + child: Column( + children: [ + TextField( + controller: _title, + decoration: const InputDecoration(labelText: 'Title', prefixIcon: Icon(Icons.label_outline)), + ), + const SizedBox(height: 10), + TextField( + controller: _language, + decoration: const InputDecoration(labelText: 'Language', prefixIcon: Icon(Icons.code_outlined)), + ), + const SizedBox(height: 10), + TextField( + controller: _code, + minLines: 5, + maxLines: 8, + decoration: const InputDecoration(labelText: 'Code', alignLabelWithHint: true), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + final title = _title.text.trim().isEmpty ? 'Untitled snippet' : _title.text.trim(); + final language = _language.text.trim().isEmpty ? 'Text' : _language.text.trim(); + widget.onCreate(title, language); + Navigator.pop(context); + }, + icon: const Icon(Icons.save_outlined), + label: const Text('Save snippet'), + ), + ), + ], ), ); } - - void _openBuildSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _RuntimeActionsSheet( - icon: Icons.rocket_launch_outlined, - title: 'Build and Release Actions', - subtitle: 'Install, test, build preview, commit, and publish through RuntimeManager.', - runtimeManager: _runtimeManager, - defaultPackageManager: 'flutter', - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), - ), - ); +} + +class _DeepDiveConsoleSheet extends StatefulWidget { + const _DeepDiveConsoleSheet({ + required this.runtimeManager, + required this.defaultProjectPath, + required this.onLog, + required this.onStartInChat, + }); + + final RuntimeManager runtimeManager; + final String defaultProjectPath; + final void Function(String title, String detail, IconData icon, Color color) onLog; + final void Function(String prompt) onStartInChat; + + @override + State<_DeepDiveConsoleSheet> createState() => _DeepDiveConsoleSheetState(); +} + +class _DeepDiveConsoleSheetState extends State<_DeepDiveConsoleSheet> { + final _promptController = TextEditingController(); + final _projectPath = TextEditingController(); + final List _lines = ['No deep dive action has run yet.']; + bool _running = false; + bool _cancelling = false; + List _recentTasks = const []; + + @override + void initState() { + super.initState(); + _promptController.text = + 'Inspect the selected project, run runtime preflight and validation, identify the next highest-value fix, implement it, and explain verification.'; + _projectPath.text = widget.defaultProjectPath; + } + + @override + void dispose() { + _promptController.dispose(); + _projectPath.dispose(); + super.dispose(); + } + + void _startInChat() { + final taskPrompt = _promptController.text.trim(); + if (taskPrompt.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enter a task prompt first.')), + ); + return; + } + final projectPath = _projectPath.text.trim(); + final prompt = [ + 'Deep Dive task:', + taskPrompt, + if (projectPath.isNotEmpty) 'Project path / cwd: $projectPath', + 'Use RuntimeManager actions for preflight, validation, build, and recovery before making risky changes.', + ].join('\n'); + Navigator.pop(context); + widget.onStartInChat(prompt); + } + + Future _validateProject() async { + final projectPath = _projectPath.text.trim(); + if (projectPath.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Project path is required.')), + ); + return; + } + setState(() { + _running = true; + _lines.insert(0, 'Validating project...'); + }); + try { + final result = await widget.runtimeManager.validateProject( + projectPath: projectPath, + ); + if (!mounted) return; + final stepLines = result.steps.map((s) => '${s.success ? 'OK' : 'FAILED'} ${s.action.name}: ${s.summary}'); + setState(() { + _lines.insert( + 0, + [ + result.success ? 'VALIDATED: ${result.summary}' : 'VALIDATION STOPPED: ${result.summary}', + ...stepLines, + if (result.recoveryHint != null) 'Recovery: ${result.recoveryHint!}', + ].join('\n'), + ); + }); + widget.onLog( + result.success ? 'Deep Dive validate completed' : 'Deep Dive validate stopped', + result.summary, + result.success ? Icons.verified_outlined : Icons.error_outline, + result.success ? _mint : _rose, + ); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'VALIDATION ERROR: $message')); + widget.onLog('Deep Dive validate error', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _recoverHistory() async { + setState(() { + _running = true; + _lines.insert(0, 'Loading task history...'); + }); + try { + final tasks = await widget.runtimeManager.taskHistory(limit: 5); + if (!mounted) return; + setState(() { + _recentTasks = tasks; + _lines.insert( + 0, + tasks.isEmpty + ? 'No recoverable runtime task history.' + : tasks.map(_taskSummary).join('\n\n'), + ); + }); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'Task history failed: $message')); + } finally { + if (mounted) setState(() => _running = false); + } + } + + Future _cancelTask([String? taskId]) async { + String? id = taskId; + if (id == null) { + for (final task in _recentTasks) { + if (task.running) { + id = task.taskId; + break; + } + } + } + setState(() { + _cancelling = true; + _lines.insert(0, id == null ? 'Stopping active runtime task...' : 'Stopping runtime task $id...'); + }); + try { + if (id == null) { + await widget.runtimeManager.stopCurrentTask(); + } else { + await widget.runtimeManager.stopTask(id); + } + final task = await widget.runtimeManager.currentTaskSnapshot(); + if (!mounted) return; + setState(() { + if (task != null) { + _recentTasks = [task, ..._recentTasks.where((item) => item.taskId != task.taskId)].take(5).toList(); + } + _lines.insert(0, task == null ? 'STOP REQUESTED: no recoverable runtime task.' : 'STOP REQUESTED: ${_taskSummary(task)}'); + }); + widget.onLog( + 'Deep Dive stop requested', + task == null ? 'No recoverable runtime task after stop request.' : _taskSummary(task), + Icons.stop_circle_outlined, + _amber, + ); + } on Object catch (error) { + if (!mounted) return; + final message = _compact(error.toString(), limit: 180); + setState(() => _lines.insert(0, 'STOP ERROR: $message')); + widget.onLog('Deep Dive stop failed', message, Icons.error_outline, _rose); + } finally { + if (mounted) setState(() => _cancelling = false); + } + } + + String _taskSummary(RuntimeTaskSnapshot task) { + final logs = _recentLogLines(task.logs, limit: 4).join('\n'); + final failure = task.failureKind == RuntimeTaskFailureKind.none ? '' : ' (${task.failureKind.name})'; + return 'Task ${task.taskId} is ${task.status.name}$failure: ${task.command}${logs.isEmpty ? '' : '\n$logs'}'; } - - void _addLog(String title, String detail, IconData icon, Color color) { - setState(() { - _activity.insert( - 0, - _ActivityLog( - title: title, - detail: detail, - icon: icon, - color: color, - time: DateTime.now(), - ), - ); - if (_activity.length > 8) { - _activity.removeLast(); - } - }); - } - - void _showMessage(String message) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); - } - - void _syncDrawerSessions(List<_ChatSession> sessions, String? activeSessionId) { - if (!mounted) return; - setState(() { - _drawerSessions = List<_ChatSession>.unmodifiable(sessions); - _drawerActiveSessionId = activeSessionId; - }); - } - - void _openDrawer() { - _scaffoldKey.currentState?.openDrawer(); - } - - Future _closeDrawerIfOpen() async { - if (Navigator.of(context).canPop()) { - Navigator.of(context).pop(); - await Future.delayed(const Duration(milliseconds: 80)); - } - } - - Future _newChatFromDrawer() async { - await _closeDrawerIfOpen(); - _setTab(_HomeTab.control); - await _chatPanelKey.currentState?.createSessionFromShell(); - } - - Future _selectChatFromDrawer(String id) async { - await _closeDrawerIfOpen(); - _setTab(_HomeTab.control); - await _chatPanelKey.currentState?.selectSessionFromShell(id); - } - - Future _usePromptShortcut(String prompt, {bool runAgent = false}) async { - await _closeDrawerIfOpen(); - _setTab(_HomeTab.control); - WidgetsBinding.instance.addPostFrameCallback((_) { - _chatPanelKey.currentState?.setPromptFromShell(prompt, runAgent: runAgent); - }); - } - - int get _simpleTabIndex { - return switch (_tab) { - _HomeTab.control => 0, - _HomeTab.ai => 0, - _HomeTab.ship => 1, - _HomeTab.guard => 2, - _HomeTab.insight => 2, - }; - } - - Widget _buildChatTab() { - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 6), - child: _SimpleHeader( - title: 'MobileCode', - subtitle: 'Ask, build, preview on phone', - leading: IconButton.filledTonal( - tooltip: 'Open conversations', - onPressed: _openDrawer, - icon: const Icon(Icons.menu_rounded), - ), - trailing: _Pill( - label: _managedProviderActive ? 'Managed' : _flavorLabel(_flavor), - icon: Icons.auto_awesome_outlined, - color: _managedProviderActive ? _mint : (_flavor == _ApiFlavor.anthropic ? _amber : _cyan), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 6), - child: _RuntimePermissionBanner( - activeRuntimeName: _activeRuntimeName, - ready: _runtimeReady, - capabilitiesLabel: _runtimeCapabilityLabel(_runtimeCapabilities), - checking: _runtimeChecking, - message: _runtimeMessage, - onCheck: () => _checkRuntime(), - onOpenRuntime: _openTermuxSheet, - ), - ), - Expanded( - child: _ChatPanel( - key: _chatPanelKey, - baseUrl: _effectiveBaseUrl, - apiKey: _effectiveApiKey, - model: _effectiveModel, - embedded: true, - onLog: (title, detail, icon, color) => _addLog(title, detail, icon, color), - onAgentPrompt: _handleAgentPrompt, - onSessionsChanged: _syncDrawerSessions, - ), - ), - ], - ); - } - - Widget _buildCommandsTab() { - final commands = [ - _CommandShortcut( - icon: Icons.videogame_asset_outlined, - title: '帮我做一个贪吃蛇游戏', - subtitle: '填入提示词,Run Agent 后展示写代码、写文件、预览流程。', - color: _mint, - action: _ModuleAction.aiChat, - ), - _CommandShortcut( - icon: Icons.grid_4x4_outlined, - title: '做 2048 网页小游戏', - subtitle: '生成本地 HTML/CSS/JS,并一键进入 Android WebView 预览。', - color: _cyan, - action: _ModuleAction.webDemo, - ), - _CommandShortcut( - icon: Icons.edit_note_outlined, - title: '做一个最小日记 App', - subtitle: '验证 APK 内本地写入、读取、列表和空状态体验。', - color: _amber, - action: _ModuleAction.diary, - ), - _CommandShortcut( - icon: Icons.note_add_outlined, - title: '新建代码文件', - subtitle: '为移动端工作区创建文件草稿,后续交给 agent 修改。', - color: _violet, - action: _ModuleAction.newFile, - ), - _CommandShortcut( - icon: Icons.data_object_outlined, - title: '保存代码片段', - subtitle: '把常用片段存入本地 snippet 面板。', - color: _lime, - action: _ModuleAction.snippet, - ), - _CommandShortcut( - icon: Icons.psychology_alt_outlined, - title: '深潜一个任务', - subtitle: '显示 agent 的计划、工具调用、观察和完成状态。', - color: _rose, - action: _ModuleAction.deepDive, - ), - ]; - - return ListView( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 20), - cacheExtent: 700, - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - children: [ - _SimpleHeader( - title: 'Create', - subtitle: 'Prompt shortcuts for mobile coding tasks.', - leading: IconButton.filledTonal( - tooltip: 'Open conversations', - onPressed: _openDrawer, - icon: const Icon(Icons.menu_rounded), - ), - ), - const SizedBox(height: 12), - _PromptLaunchPanel(onPrompt: _usePromptShortcut), - const SizedBox(height: 12), - for (final command in commands) ...[ - _CommandShortcutTile( - command: command, - onTap: () { - if (command.title.contains('贪吃蛇')) { - _usePromptShortcut('帮我在手机端创建一个可运行的贪吃蛇网页小游戏,生成 index.html、展示写代码过程,并用 WebView 预览。', runAgent: true); - } else if (command.title.contains('2048')) { - _usePromptShortcut('帮我创建一个 2048 网页小游戏,保存为 index.html,并打开本地 WebView 预览。', runAgent: true); - } else { - _runAction(command.action); - } - }, - ), - const SizedBox(height: 10), - ], - ], - ); - } - - Widget _buildToolsTab() { - final tools = [ - _CommandShortcut( - icon: Icons.handyman_outlined, - title: 'Tool tests', - subtitle: '测试 provider、GitHub、WebView、storage、runtime、root。', - color: _cyan, - action: _ModuleAction.toolLab, - ), - _CommandShortcut( - icon: Icons.terminal_outlined, - title: 'Runtime providers', - subtitle: '检查 Helper、Termux fallback、root keepalive 和后端状态。', - color: _amber, - action: _ModuleAction.termuxCheck, - ), - _CommandShortcut( - icon: Icons.hub_outlined, - title: 'GitHub test', - subtitle: '填写 GitHub token 后验证 /user、repo、Pages 能否联通。', - color: _violet, - action: _ModuleAction.githubTest, - ), - _CommandShortcut( - icon: Icons.rocket_launch_outlined, - title: 'Build / release', - subtitle: '查看 GitHub Release、APK、iOS simulator 和 smoke report。', - color: _rose, - action: _ModuleAction.build, - ), - ]; - - return ListView( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 20), - cacheExtent: 700, - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - children: [ - _SimpleHeader( - title: 'Tools', - subtitle: 'Phone runtime, backend bridge, GitHub, and release checks.', - leading: IconButton.filledTonal( - tooltip: 'Open conversations', - onPressed: _openDrawer, - icon: const Icon(Icons.menu_rounded), - ), - ), - const SizedBox(height: 12), - _RuntimePermissionBanner( - activeRuntimeName: _activeRuntimeName, - ready: _runtimeReady, - capabilitiesLabel: _runtimeCapabilityLabel(_runtimeCapabilities), - checking: _runtimeChecking, - message: _runtimeMessage, - onCheck: () => _checkRuntime(), - onOpenRuntime: _openTermuxSheet, - ), - const SizedBox(height: 12), - for (final tool in tools) ...[ - _CommandShortcutTile( - command: tool, - onTap: () => _runAction(tool.action), - ), - const SizedBox(height: 10), - ], - ], - ); - } - - Widget _buildSettingsTab() { - return ListView( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 20), - cacheExtent: 700, - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - children: [ - _SimpleHeader( - title: 'Settings', - subtitle: 'Runtime, model, release, and advanced capability surfaces.', - leading: IconButton.filledTonal( - tooltip: 'Open conversations', - onPressed: _openDrawer, - icon: const Icon(Icons.menu_rounded), - ), - ), - const SizedBox(height: 12), - _ApiConfigCard( - baseUrlController: _baseUrlController, - apiKeyController: _apiKeyController, - modelController: _modelController, - saving: _saving, - flavor: _flavor, - managedProviderActive: _managedProviderActive, - onPreset: _applyDefaultProvider, - onSave: _saveConfig, - onHealth: _checkHealth, - ), - const SizedBox(height: 12), - _HealthCard( - state: _healthState, - message: _healthMessage, - flavor: _flavor, - onCheck: _checkHealth, - ), - const SizedBox(height: 12), - _SideloadStatusPanel( - managedProviderActive: _managedProviderActive, - onOpenRelease: () => _openUrl(_releaseUrl, 'GitHub Release'), - onOpenAndroidReport: () => _openUrl(_androidSmokeRunUrl, 'Android smoke report'), - onOpenIosReport: () => _openUrl(_iosSimulatorRunUrl, 'iOS simulator report'), - ), - const SizedBox(height: 12), - _Panel( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - const Icon(Icons.account_tree_outlined, color: _cyan), - const SizedBox(width: 10), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Advanced backend map', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), - SizedBox(height: 2), - Text('Hidden by default. Keep the product chat-first.', style: TextStyle(color: _muted, fontSize: 12)), - ], - ), - ), - TextButton.icon( - onPressed: () => setState(() => _showCapabilityMap = !_showCapabilityMap), - icon: Icon(_showCapabilityMap ? Icons.expand_less_outlined : Icons.expand_more_outlined), - label: Text(_showCapabilityMap ? 'Hide' : 'Show'), - ), - ], - ), - ), - if (_showCapabilityMap) ...[ - const SizedBox(height: 14), - _LayerSelector( - layers: _layers, - selectedIndex: _safeLayerIndex, - onSelected: (index) => setState(() => _selectedLayerIndex = index), - ), - const SizedBox(height: 12), - _LayerHeader(layer: _activeLayer), - const SizedBox(height: 10), - for (final capability in _activeLayer.capabilities) ...[ - _CapabilityCard( - capability: capability, - layerColor: _activeLayer.color, - onRun: () => _runAction(capability.primaryAction, capability), - onInspect: () => _openCapabilitySheet(capability), - ), - const SizedBox(height: 10), - ], - ], - const SizedBox(height: 14), - _OperationsBoard( - activity: _activity, - drafts: _drafts, - snippets: _snippets, - healthState: _healthState, - layerCount: _layers.length, - serviceCount: _layers.fold(0, (sum, layer) => sum + layer.serviceCount), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0; - return Scaffold( - key: _scaffoldKey, - resizeToAvoidBottomInset: true, - backgroundColor: _bg, - drawer: _MobileCodeDrawer( - sessions: _drawerSessions, - activeSessionId: _drawerActiveSessionId, - runtimeReady: _runtimeReady, - runtimeLabel: _runtimeDrawerLabel, - onNewChat: _newChatFromDrawer, - onSelectSession: _selectChatFromDrawer, - onPrompt: _usePromptShortcut, - onOpenSettings: () { - Navigator.of(context).pop(); - _setTab(_HomeTab.guard); - }, - onOpenTools: () { - Navigator.of(context).pop(); - _setTab(_HomeTab.ship); - }, - ), - body: SafeArea( - child: Column( - children: [ - Expanded( - child: IndexedStack( - index: _simpleTabIndex, - children: [ - _buildChatTab(), - _buildToolsTab(), - _buildSettingsTab(), - ], - ), - ), - if (!keyboardOpen) _BottomNav(tab: _tab, onChanged: _setTab), - ], - ), - ), - ); - } -} - -class _TopBar extends StatelessWidget { - const _TopBar({ - required this.healthState, - required this.flavor, - required this.onChat, - }); - - final _HealthState healthState; - final _ApiFlavor flavor; - final VoidCallback onChat; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: _panel, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _line), - ), - child: const Icon(Icons.code_rounded, color: _mint), - ), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'MobileCode', - style: TextStyle(color: _text, fontSize: 28, fontWeight: FontWeight.w700), - ), - SizedBox(height: 2), - Text( - 'Mobile AI development console', - style: TextStyle(color: _muted, fontSize: 13), - ), - ], - ), - ), - Tooltip( - message: 'Open AI Chat', - child: IconButton.filledTonal( - onPressed: onChat, - icon: const Icon(Icons.forum_outlined), - ), - ), - ], - ); - } -} - -class _SimpleHeader extends StatelessWidget { - const _SimpleHeader({ - required this.title, - required this.subtitle, - this.leading, - this.trailing, - }); - - final String title; - final String subtitle; - final Widget? leading; - final Widget? trailing; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - leading ?? - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: _panel, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _line), - ), - child: const Icon(Icons.code_rounded, color: _mint), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(color: _text, fontSize: 24, fontWeight: FontWeight.w900)), - const SizedBox(height: 2), - Text(subtitle, style: const TextStyle(color: _muted, fontSize: 12)), - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: 10), - trailing!, - ], - ], - ); - } -} - -class _MobileCodeDrawer extends StatelessWidget { - const _MobileCodeDrawer({ - required this.sessions, - required this.activeSessionId, - required this.runtimeReady, - required this.runtimeLabel, - required this.onNewChat, - required this.onSelectSession, - required this.onPrompt, - required this.onOpenSettings, - required this.onOpenTools, - }); - final List<_ChatSession> sessions; - final String? activeSessionId; - final bool runtimeReady; - final String runtimeLabel; - final VoidCallback onNewChat; - final ValueChanged onSelectSession; - final Future Function(String prompt, {bool runAgent}) onPrompt; - final VoidCallback onOpenSettings; - final VoidCallback onOpenTools; - @override Widget build(BuildContext context) { - final runtimeColor = runtimeReady ? _mint : _amber; + return _SheetScaffold( + icon: Icons.psychology_alt_outlined, + title: 'Deep Dive', + subtitle: 'Launch a multi-step coding session using the existing Chat Agent and RuntimeManager loop.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _promptController, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Task prompt', + hintText: 'Describe the coding task for the agent...', + prefixIcon: Icon(Icons.edit_note_outlined), + ), + ), + const SizedBox(height: 8), + TextField( + controller: _projectPath, + decoration: const InputDecoration(labelText: 'Project path / cwd', prefixIcon: Icon(Icons.folder_outlined)), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _RuntimeActionButton( + icon: Icons.home_work_outlined, + label: 'Default path', + disabled: _running, + onTap: () { + _projectPath.text = widget.defaultProjectPath; + setState(() => _lines.insert(0, 'Project path reset to default.')); + }, + ), + ], + ), + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + const Icon(Icons.info_outline, color: _faint, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Uses the existing Chat Agent and RuntimeManager. No background task queue.', + style: const TextStyle(color: _faint, fontSize: 11, height: 1.3), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _RuntimeActionButton( + icon: Icons.chat_outlined, + label: 'Start in Chat Agent', + disabled: _running, + onTap: _startInChat, + ), + _RuntimeActionButton( + icon: Icons.verified_outlined, + label: 'Validate project', + disabled: _running, + onTap: _validateProject, + ), + _RuntimeActionButton( + icon: Icons.stop_circle_outlined, + label: 'Stop runtime task', + disabled: _cancelling, + onTap: _cancelTask, + ), + _RuntimeActionButton( + icon: Icons.history_outlined, + label: 'Recover history', + disabled: _running, + onTap: _recoverHistory, + ), + ], + ), + const SizedBox(height: 12), + if (_recentTasks.isNotEmpty) ...[ + for (final task in _recentTasks.take(3)) ...[ + _TaskSnapshotPanel( + task: task, + onStop: task.canCancel ? () => _cancelTask(task.taskId) : null, + onOpenDetails: () => _showRuntimeTaskDetailsSheet( + context: context, + runtimeManager: widget.runtimeManager, + task: task, + onLog: widget.onLog, + ), + ), + const SizedBox(height: 8), + ], + ], + _Panel( + child: Text( + _lines.take(8).join('\n\n'), + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ), + ], + ), + ); + } +} - return Drawer( - width: MediaQuery.of(context).size.width.clamp(280, 360).toDouble(), - backgroundColor: _bg, - child: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(18, 14, 14, 10), - child: Row( - children: [ - const Expanded( - child: Text('MobileCode', style: TextStyle(color: _text, fontSize: 24, fontWeight: FontWeight.w900)), - ), - IconButton.filledTonal( - tooltip: 'New chat', - onPressed: onNewChat, - icon: const Icon(Icons.edit_square), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _Panel( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon(Icons.admin_panel_settings_outlined, color: runtimeColor, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text(runtimeLabel, style: const TextStyle(color: _muted, fontSize: 12, height: 1.3)), - ), - ], - ), - ), - ), - const SizedBox(height: 12), - _DrawerAction( - icon: Icons.add_comment_outlined, - label: '新会话', - onTap: onNewChat, - ), - _DrawerAction( - icon: Icons.videogame_asset_outlined, - label: '帮我做贪吃蛇游戏', - onTap: () => onPrompt('帮我在手机端创建一个可运行的贪吃蛇网页小游戏,生成 index.html、展示写代码过程,并用 WebView 预览。', runAgent: true), - ), - _DrawerAction( - icon: Icons.handyman_outlined, - label: '工具与权限', - onTap: onOpenTools, - ), - _DrawerAction( - icon: Icons.tune_outlined, - label: '模型与设置', - onTap: onOpenSettings, - ), - const Padding( - padding: EdgeInsets.fromLTRB(18, 18, 18, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text('Recent chats', style: TextStyle(color: _faint, fontSize: 12, fontWeight: FontWeight.w900)), - ), - ), - Expanded( - child: sessions.isEmpty - ? const Padding( - padding: EdgeInsets.all(18), - child: Text('No chat history yet', style: TextStyle(color: _muted)), - ) - : ListView.builder( - padding: const EdgeInsets.fromLTRB(10, 0, 10, 12), - itemCount: sessions.length, - itemBuilder: (context, index) { - final session = sessions[index]; - return _DrawerSessionTile( - session: session, - selected: session.id == activeSessionId, - onTap: () => onSelectSession(session.id), - ); - }, - ), - ), - Container( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - decoration: const BoxDecoration( - border: Border(top: BorderSide(color: _line)), - ), - child: Row( - children: [ - const CircleAvatar( - radius: 16, - backgroundColor: _panelSoft, - child: Icon(Icons.person_outline, color: _mint, size: 18), - ), - const SizedBox(width: 10), - const Expanded( - child: Text('Local user', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), - ), - IconButton( - tooltip: 'Settings', - onPressed: onOpenSettings, - icon: const Icon(Icons.settings_outlined, color: _muted), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _DrawerAction extends StatelessWidget { - const _DrawerAction({ - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon(icon, color: _text), - title: Text(label, style: const TextStyle(color: _text, fontWeight: FontWeight.w800)), - onTap: onTap, - minLeadingWidth: 26, - ); - } -} - -class _DrawerSessionTile extends StatelessWidget { - const _DrawerSessionTile({ - required this.session, - required this.selected, - required this.onTap, - }); - - final _ChatSession session; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return ListTile( - selected: selected, - selectedTileColor: _mint.withOpacity(0.10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - leading: CircleAvatar( - radius: 15, - backgroundColor: selected ? _mint.withOpacity(0.18) : _panelSoft, - child: Icon(Icons.chat_bubble_outline, color: selected ? _mint : _muted, size: 16), - ), - title: Text( - session.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: selected ? _text : _muted, fontWeight: FontWeight.w800), - ), - subtitle: Text('${session.turns.length} turns', style: const TextStyle(color: _faint, fontSize: 11)), - onTap: onTap, - ); - } -} - -class _RuntimePermissionBanner extends StatelessWidget { - const _RuntimePermissionBanner({ - required this.activeRuntimeName, - required this.ready, - required this.capabilitiesLabel, - required this.checking, - required this.message, - required this.onCheck, - required this.onOpenRuntime, +class _CapabilitySheet extends StatelessWidget { + const _CapabilitySheet({ + required this.capability, + required this.onRun, + required this.onCopy, }); - final String activeRuntimeName; - final bool ready; - final String capabilitiesLabel; - final bool checking; - final String message; - final VoidCallback onCheck; - final VoidCallback onOpenRuntime; + final _Capability capability; + final VoidCallback onRun; + final VoidCallback onCopy; @override Widget build(BuildContext context) { - final color = ready ? _mint : _amber; - final title = ready ? 'Runtime ready' : 'Runtime setup needed'; - final statusLine = '$title · $activeRuntimeName · $capabilitiesLabel'; - return _Panel( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( + return _SheetScaffold( + icon: capability.icon, + title: capability.title, + subtitle: capability.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(ready ? Icons.verified_outlined : Icons.hub_outlined, color: color, size: 20), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - statusLine, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 13), - ), - const SizedBox(height: 2), - Text( - message, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 11, height: 1.2), - ), - ], - ), - ), - const SizedBox(width: 6), - IconButton( - tooltip: 'Open runtime diagnostics', - visualDensity: VisualDensity.compact, - onPressed: onOpenRuntime, - icon: Icon(Icons.monitor_heart_outlined, color: _violet, size: 18), + Text(capability.subtitle, style: const TextStyle(color: _muted, height: 1.4)), + const SizedBox(height: 16), + const Text('Backend services', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final service in capability.services) _MiniChip(label: service, color: _cyan), + ], ), - IconButton( - tooltip: 'Check runtime', - visualDensity: VisualDensity.compact, - onPressed: checking ? null : onCheck, - icon: checking - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.refresh_outlined, size: 18), - ), - ], - ), - ); - } -} - -class _SideloadStatusPanel extends StatelessWidget { - const _SideloadStatusPanel({ - required this.managedProviderActive, - required this.onOpenRelease, - required this.onOpenAndroidReport, - required this.onOpenIosReport, - }); - - final bool managedProviderActive; - final VoidCallback onOpenRelease; - final VoidCallback onOpenAndroidReport; - final VoidCallback onOpenIosReport; - - @override - Widget build(BuildContext context) { - return _Panel( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - Row( - children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: _mint.withOpacity(0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _mint.withOpacity(0.34)), - ), - child: const Icon(Icons.verified_outlined, color: _mint, size: 19), - ), - const SizedBox(width: 10), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('GitHub install build', style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15)), - SizedBox(height: 2), - Text(_releaseBuildLabel, style: TextStyle(color: _muted, fontSize: 12)), - ], - ), - ), - FilledButton.icon( - onPressed: onOpenRelease, - icon: const Icon(Icons.download_outlined, size: 18), - label: const Text('Release'), - ), - ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _StatusActionChip( - label: 'Android smoke passed', - icon: Icons.android_outlined, - color: _mint, - onTap: onOpenAndroidReport, - ), - _StatusActionChip( - label: 'iOS simulator passed', - icon: Icons.phone_iphone_outlined, - color: _cyan, - onTap: onOpenIosReport, - ), - _StatusActionChip( - label: managedProviderActive ? 'Managed model active' : 'Bring your key', - icon: managedProviderActive ? Icons.lock_outline : Icons.key_outlined, - color: managedProviderActive ? _amber : _faint, - onTap: onOpenRelease, - ), - ], - ), - ], - ), - ); - } -} - -class _StatusActionChip extends StatelessWidget { - const _StatusActionChip({ - required this.label, - required this.icon, - required this.color, - required this.onTap, - }); - - final String label; - final IconData icon; - final Color color; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: color.withOpacity(0.09), - borderRadius: BorderRadius.circular(8), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 7), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.28)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: color, size: 15), - const SizedBox(width: 6), - Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800)), - ], - ), - ), - ), - ); - } -} - -class _FocusPanel extends StatelessWidget { - const _FocusPanel({ - required this.tab, - required this.healthState, - required this.onPrimary, - required this.onSecondary, - }); - - final _HomeTab tab; - final _HealthState healthState; - final VoidCallback onPrimary; - final VoidCallback onSecondary; - - @override - Widget build(BuildContext context) { - final accent = _focusColor(tab); - return Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: accent.withOpacity(0.10), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: accent.withOpacity(0.30)), - ), - child: Row( - children: [ - Icon(_focusIcon(tab), color: accent, size: 26), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - _focusTitle(tab), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _text, fontSize: 16, fontWeight: FontWeight.w900), - ), - ), - _MiniChip(label: _focusHealthLabel(healthState), color: _healthColor(healthState)), - ], - ), - const SizedBox(height: 4), - Text( - _focusSubtitle(tab), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 12, height: 1.32), - ), - ], - ), - ), - const SizedBox(width: 10), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton.filledTonal( - tooltip: _focusPrimaryLabel(tab), - onPressed: onPrimary, - icon: Icon(_focusPrimaryIcon(tab), size: 18), - ), - const SizedBox(height: 6), - IconButton.outlined( - tooltip: _focusSecondaryLabel(tab), - onPressed: onSecondary, - icon: Icon(_focusSecondaryIcon(tab), size: 18), - ), - ], - ), - ], - ), - ); - } -} - -class _ApiConfigCard extends StatelessWidget { - const _ApiConfigCard({ - required this.baseUrlController, - required this.apiKeyController, - required this.modelController, - required this.saving, - required this.flavor, - required this.managedProviderActive, - required this.onPreset, - required this.onSave, - required this.onHealth, - }); - - final TextEditingController baseUrlController; - final TextEditingController apiKeyController; - final TextEditingController modelController; - final bool saving; - final _ApiFlavor flavor; - final bool managedProviderActive; - final VoidCallback onPreset; - final VoidCallback onSave; - final VoidCallback onHealth; - - @override - Widget build(BuildContext context) { - return _Panel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.tune_outlined, color: _mint), - const SizedBox(width: 10), - const Expanded( - child: Text( - 'API Configuration', - style: TextStyle(color: _text, fontSize: 18, fontWeight: FontWeight.w700), - ), - ), - if (!managedProviderActive) ...[ - Tooltip( - message: 'Use Mimo Anthropic preset', - child: IconButton.filledTonal( - onPressed: onPreset, - icon: const Icon(Icons.auto_fix_high_outlined, size: 18), - ), - ), - const SizedBox(width: 8), - ], - _Pill( - label: _flavorLabel(flavor), - icon: flavor == _ApiFlavor.anthropic ? Icons.hub_outlined : Icons.api_outlined, - color: flavor == _ApiFlavor.anthropic ? _amber : _cyan, - ), - ], - ), - const SizedBox(height: 14), - if (managedProviderActive) ...[ - const _InlineStatus( - icon: Icons.admin_panel_settings_outlined, - label: 'Managed debug provider active - credentials are hidden in the UI.', - color: _mint, - ), - const SizedBox(height: 10), - const Text( - 'MobileCode will call the configured provider with bundled debug credentials. Base URL, API key, and model are intentionally not shown on this screen.', - style: TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - const SizedBox(height: 14), - OutlinedButton.icon( - onPressed: onHealth, - icon: const Icon(Icons.monitor_heart_outlined), - label: const Text('Check Managed Provider'), - ), - ] else ...[ - TextField( - controller: baseUrlController, - keyboardType: TextInputType.url, - textInputAction: TextInputAction.next, - decoration: const InputDecoration( - labelText: 'Base URL', - hintText: _defaultBaseUrl, - prefixIcon: Icon(Icons.link_outlined), - ), - ), - const SizedBox(height: 10), - TextField( - controller: apiKeyController, - obscureText: true, - textInputAction: TextInputAction.next, - decoration: const InputDecoration( - labelText: 'API Key', - hintText: 'sk-... or provider token', - prefixIcon: Icon(Icons.key_outlined), - ), - ), - const SizedBox(height: 10), - TextField( - controller: modelController, - textInputAction: TextInputAction.done, - decoration: const InputDecoration( - labelText: 'Model', - hintText: _defaultModel, - prefixIcon: Icon(Icons.memory_outlined), - ), - ), - const SizedBox(height: 8), - const Text( - 'Default provider fills Base URL and model only. Paste your API key locally; it is never shipped inside the APK.', - style: TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: saving ? null : onSave, - icon: saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save_outlined), - label: Text(saving ? 'Saving' : 'Save'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - onPressed: onHealth, - icon: const Icon(Icons.monitor_heart_outlined), - label: const Text('Check'), - ), - ), - ], - ), - ], - ], - ), - ); - } -} - -class _HealthCard extends StatelessWidget { - const _HealthCard({ - required this.state, - required this.message, - required this.flavor, - required this.onCheck, - }); - - final _HealthState state; - final String message; - final _ApiFlavor flavor; - final VoidCallback onCheck; - - @override - Widget build(BuildContext context) { - final color = switch (state) { - _HealthState.healthy => _mint, - _HealthState.failed => _rose, - _HealthState.checking => _amber, - _HealthState.unknown => _faint, - }; - final label = switch (state) { - _HealthState.healthy => 'Healthy', - _HealthState.failed => 'Unhealthy', - _HealthState.checking => 'Checking', - _HealthState.unknown => 'Unknown', - }; - return _Panel( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - width: 14, - height: 14, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - boxShadow: [BoxShadow(color: color.withOpacity(0.35), blurRadius: 12)], - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Provider Health - $label', - style: const TextStyle(color: _text, fontWeight: FontWeight.w700, fontSize: 15), - ), - const SizedBox(height: 4), - Text( - message, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 12), - ), - ], - ), - ), - const SizedBox(width: 10), - _Pill( - label: _flavorLabel(flavor), - icon: Icons.route_outlined, - color: flavor == _ApiFlavor.anthropic ? _amber : _cyan, - ), - const SizedBox(width: 4), - Tooltip( - message: 'Run health check', - child: IconButton( - onPressed: state == _HealthState.checking ? null : onCheck, - icon: const Icon(Icons.refresh_outlined), - ), - ), - ], - ), - ); - } -} - -class _DemoLabPanel extends StatelessWidget { - const _DemoLabPanel({ - required this.onOpen2048, - required this.onGitHub, - required this.onDiary, - required this.onChat, - required this.onTools, - required this.onTermux, - }); - - final VoidCallback onOpen2048; - final VoidCallback onGitHub; - final VoidCallback onDiary; - final VoidCallback onChat; - final VoidCallback onTools; - final VoidCallback onTermux; - - @override - Widget build(BuildContext context) { - return _Panel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.science_outlined, color: _mint), - const SizedBox(width: 10), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Demo Lab', style: TextStyle(color: _text, fontSize: 20, fontWeight: FontWeight.w900)), - SizedBox(height: 2), - Text('The focused path: play, connect GitHub, build diary, chat with memory, test tools, check runtime.', style: TextStyle(color: _muted, fontSize: 12, height: 1.35)), - ], - ), - ), - _Pill(label: 'Priority', icon: Icons.flag_outlined, color: _amber), - ], - ), - const SizedBox(height: 14), - _HeroDemoTile( - title: 'Agent codes 2048', - subtitle: 'Generate a real local HTML/CSS/JS project on the phone, save it, then preview it inside MobileCode WebView.', - icon: Icons.grid_4x4_outlined, - color: _mint, - primaryLabel: 'Code + preview', - secondaryLabel: 'GitHub test', - onPrimary: onOpen2048, - onSecondary: onGitHub, - ), - const SizedBox(height: 10), - LayoutBuilder( - builder: (context, constraints) { - final columns = constraints.maxWidth >= 680 ? 4 : 2; - final items = [ - _DemoAction(Icons.edit_note_outlined, 'Diary APK', 'Local diary demo inside this APK', _ModuleAction.diary, _amber), - _DemoAction(Icons.forum_outlined, 'Chat Memory', 'Conversation list and context', _ModuleAction.aiChat, _mint), - _DemoAction(Icons.handyman_outlined, 'Tool Tests', 'Run mobile tool probes', _ModuleAction.toolLab, _cyan), - _DemoAction(Icons.terminal_outlined, 'Runtime', 'Check Helper and fallback setup', _ModuleAction.termuxCheck, _lime), - ]; - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: items.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: columns == 2 ? 1.55 : 1.25, - ), - itemBuilder: (context, index) { - final item = items[index]; - return _DemoActionTile( - item: item, - onTap: switch (item.action) { - _ModuleAction.diary => onDiary, - _ModuleAction.aiChat => onChat, - _ModuleAction.toolLab => onTools, - _ModuleAction.termuxCheck => onTermux, - _ => onOpen2048, - }, - ); - }, - ); - }, - ), - ], - ), - ); - } -} - -class _HeroDemoTile extends StatelessWidget { - const _HeroDemoTile({ - required this.title, - required this.subtitle, - required this.icon, - required this.color, - required this.primaryLabel, - required this.secondaryLabel, - required this.onPrimary, - required this.onSecondary, - }); - - final String title; - final String subtitle; - final IconData icon; - final Color color; - final String primaryLabel; - final String secondaryLabel; - final VoidCallback onPrimary; - final VoidCallback onSecondary; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: color.withOpacity(0.10), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.40)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, color: color, size: 28), - const SizedBox(width: 10), - Expanded( - child: Text(title, style: const TextStyle(color: _text, fontSize: 18, fontWeight: FontWeight.w900)), - ), - ], - ), - const SizedBox(height: 8), - Text(subtitle, style: const TextStyle(color: _muted, fontSize: 12, height: 1.4)), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: onPrimary, - icon: const Icon(Icons.open_in_browser_outlined), - label: Text(primaryLabel), - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - onPressed: onSecondary, - icon: const Icon(Icons.hub_outlined), - label: Text(secondaryLabel), - ), - ), - ], - ), - ], - ), - ); - } -} - -class _DemoAction { - const _DemoAction(this.icon, this.title, this.subtitle, this.action, this.color); - - final IconData icon; - final String title; - final String subtitle; - final _ModuleAction action; - final Color color; -} - -class _CommandShortcut { - const _CommandShortcut({ - required this.icon, - required this.title, - required this.subtitle, - required this.color, - required this.action, - }); - - final IconData icon; - final String title; - final String subtitle; - final Color color; - final _ModuleAction action; -} - -class _CommandShortcutTile extends StatelessWidget { - const _CommandShortcutTile({required this.command, required this.onTap}); - - final _CommandShortcut command; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: _Panel( - padding: const EdgeInsets.all(14), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: command.color.withOpacity(0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: command.color.withOpacity(0.28)), - ), - child: Icon(command.icon, color: command.color, size: 21), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(command.title, style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15)), - const SizedBox(height: 4), - Text(command.subtitle, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), - ], - ), - ), - const SizedBox(width: 8), - const Icon(Icons.chevron_right_outlined, color: _faint), - ], - ), - ), - ), - ); - } -} - -class _DemoActionTile extends StatelessWidget { - const _DemoActionTile({required this.item, required this.onTap}); - - final _DemoAction item; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: _panelSoft, - borderRadius: BorderRadius.circular(8), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _line), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(item.icon, color: item.color, size: 24), - const Spacer(), - Text(item.title, style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), - const SizedBox(height: 3), - Text( - item.subtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 11, height: 1.25), - ), - ], - ), - ), - ), - ); - } -} - -class _MobileCodingLabSheet extends StatefulWidget { - const _MobileCodingLabSheet({ - required this.autoGenerate, - required this.onOpenOnlineDemo, - required this.onOpenGitHub, - required this.onLog, - }); - - final bool autoGenerate; - final VoidCallback onOpenOnlineDemo; - final VoidCallback onOpenGitHub; - final void Function(String title, String detail, IconData icon, Color color) onLog; - - @override - State<_MobileCodingLabSheet> createState() => _MobileCodingLabSheetState(); -} - -class _MobileCodingLabSheetState extends State<_MobileCodingLabSheet> { - String? _projectPath; - String? _transcriptPath; - String? _html; - String _stage = 'Idle'; - int? _lastGenerateMs; - bool _generating = false; - bool _autoStarted = false; - final List<_MiniAgentEvent> _agentEvents = []; - final _agentEventController = ScrollController(); - - @override - void dispose() { - _agentEventController.dispose(); - super.dispose(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (widget.autoGenerate && !_autoStarted) { - _autoStarted = true; - WidgetsBinding.instance.addPostFrameCallback((_) => _generate2048()); - } - } - - Future _generate2048() async { - final started = DateTime.now(); - setState(() { - _generating = true; - _stage = 'Starting mini agent'; - _agentEvents.clear(); - _transcriptPath = null; - }); - try { - final directory = await getApplicationDocumentsDirectory(); - final rootDirectory = Directory('${directory.path}/mobilecode_projects'); - final projectDirectory = Directory('${rootDirectory.path}/agent_2048'); - - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.system, - title: 'Mini harness booted', - detail: - 'Loaded phone-safe tools: list_files, write_file, read_file, preview_webview, termux_probe, github_connect.', - time: DateTime.now(), - ), - ); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.thought, - title: 'Reasoning', - detail: - 'Goal is a real local 2048 project. The agent will create an app-owned workspace, generate a complete single-file web app, save it atomically, read it back, then make WebView preview available.', - time: DateTime.now(), - ), - ); - - final listStarted = DateTime.now(); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.toolCall, - title: 'tool_call: list_files', - toolName: 'list_files', - path: rootDirectory.path, - detail: jsonEncode({ - 'path': 'mobilecode_projects', - 'maxDepth': 1, - }), - time: DateTime.now(), - ), - ); - await rootDirectory.create(recursive: true); - final existingProjects = rootDirectory - .listSync() - .map((entity) => entity.path.split(Platform.pathSeparator).last) - .where((name) => name.trim().isNotEmpty) - .take(8) - .toList(); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.observation, - title: 'tool_result: list_files', - toolName: 'list_files', - path: rootDirectory.path, - durationMs: DateTime.now().difference(listStarted).inMilliseconds, - detail: existingProjects.isEmpty - ? 'No local MobileCode projects yet.' - : 'Found: ${existingProjects.join(', ')}', - time: DateTime.now(), - ), - ); - - final prepareStarted = DateTime.now(); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.toolCall, - title: 'tool_call: mkdir', - toolName: 'write_file', - path: projectDirectory.path, - detail: jsonEncode({ - 'path': 'mobilecode_projects/agent_2048', - 'recursive': true, - }), - time: DateTime.now(), - ), - ); - await projectDirectory.create(recursive: true); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.observation, - title: 'tool_result: mkdir', - toolName: 'write_file', - path: projectDirectory.path, - durationMs: DateTime.now().difference(prepareStarted).inMilliseconds, - detail: 'Workspace ready inside Android app documents.', - time: DateTime.now(), - ), - ); - - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.thought, - title: 'Plan code structure', - detail: - 'Single index.html keeps the demo portable: responsive board, swipe/keyboard input, score, best score, undo, game-over detection, and localStorage persistence.', - time: DateTime.now(), - ), - ); - - final html = _agent2048Html(); - final chunks = _chunkText(html, 1500); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.toolCall, - title: 'tool_call: write_file', - toolName: 'write_file', - path: '${projectDirectory.path}/index.html', - detail: jsonEncode({ - 'path': 'mobilecode_projects/agent_2048/index.html', - 'bytes': utf8.encode(html).length, - 'atomic': true, - }), - time: DateTime.now(), - ), - ); - for (var index = 0; index < chunks.length; index++) { - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.fileWrite, - title: 'Writing code chunk ${index + 1}/${chunks.length}', - toolName: 'write_file', - path: '${projectDirectory.path}/index.html', - detail: _compact(chunks[index], limit: 360), - time: DateTime.now(), - ), - delay: const Duration(milliseconds: 90), - ); - } - - final writeStarted = DateTime.now(); - final tempFile = File('${projectDirectory.path}/index.html.tmp'); - final file = File('${projectDirectory.path}/index.html'); - await tempFile.writeAsString(html, flush: true); - if (await file.exists()) { - await file.delete(); - } - await tempFile.rename(file.path); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.observation, - title: 'tool_result: write_file', - toolName: 'write_file', - path: file.path, - durationMs: DateTime.now().difference(writeStarted).inMilliseconds, - detail: 'Wrote ${html.length} characters to index.html through temp-file rename.', - time: DateTime.now(), - ), - ); - - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.diff, - title: 'Generated diff', - toolName: 'write_file', - path: file.path, - detail: [ - '+ mobilecode_projects/agent_2048/index.html', - '+ responsive 4x4 2048 board', - '+ swipe and keyboard controls', - '+ score, best score, undo, game-over state', - '+ offline WebView-ready JavaScript', - ].join('\n'), - time: DateTime.now(), - ), - ); - - final readStarted = DateTime.now(); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.toolCall, - title: 'tool_call: read_file', - toolName: 'read_file', - path: file.path, - detail: jsonEncode({ - 'path': 'mobilecode_projects/agent_2048/index.html', - 'purpose': 'verify saved file and prepare preview', - }), - time: DateTime.now(), - ), - ); - final savedHtml = await file.readAsString(); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.observation, - title: 'tool_result: read_file', - toolName: 'read_file', - path: file.path, - durationMs: DateTime.now().difference(readStarted).inMilliseconds, - detail: 'Read back ${savedHtml.length} characters. Preview input is ready.', - time: DateTime.now(), - ), - ); - if (!mounted) return; - setState(() { - _projectPath = file.path; - _html = savedHtml; - _stage = 'Generated and saved'; - _lastGenerateMs = DateTime.now().difference(started).inMilliseconds; - }); - - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.preview, - title: 'tool_call: preview_webview', - toolName: 'preview_webview', - path: file.path, - detail: - 'WebView preview is armed. Tap Preview to run the generated game inside MobileCode without leaving the app.', - durationMs: _lastGenerateMs, - time: DateTime.now(), - ), - ); - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.finalAnswer, - title: 'Agent final', - detail: - '2048 project is complete. The generated code is visible below, stored on-device, and ready for WebView preview or GitHub publishing.', - durationMs: _lastGenerateMs, - time: DateTime.now(), - ), - ); - - final transcript = await _persistRunTranscript(projectDirectory, started); - if (!mounted) return; - setState(() => _transcriptPath = transcript.path); - widget.onLog('Agent generated 2048', '${file.path} - ${_lastGenerateMs}ms', Icons.grid_4x4_outlined, _mint); - } on Object catch (error) { - if (!mounted) return; - await _emitAgentEvent( - _MiniAgentEvent( - kind: _MiniAgentEventKind.error, - title: 'Agent failed', - detail: _compact(error.toString(), limit: 260), - ok: false, - time: DateTime.now(), - ), - delay: Duration.zero, - ); - setState(() => _stage = 'Generation failed'); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Generate failed: $error'))); - widget.onLog('2048 generation failed', _compact(error.toString(), limit: 120), Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _generating = false); - } - } - - Future _emitAgentEvent( - _MiniAgentEvent event, { - Duration delay = const Duration(milliseconds: 150), - }) async { - if (!mounted) return; - setState(() { - _stage = event.title; - _agentEvents.add(event); - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_agentEventController.hasClients) return; - _agentEventController.animateTo( - _agentEventController.position.maxScrollExtent, - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - ); - }); - if (delay > Duration.zero) { - await Future.delayed(delay); - } - } - - Future _persistRunTranscript(Directory projectDirectory, DateTime started) async { - final file = File('${projectDirectory.path}/agent_run.json'); - final payload = { - 'agent': 'MobileCode Android Mini Agent', - 'inspiredBy': [ - 'mini-harness: model/tool/result loop', - 'mini-codex: workspace-scoped actions and shell-style tool output', - 'mini-claude-code: persistent session and tool transcript', - 'MiniClaude: visible tool-use lifecycle and file diff surfaces', - ], - 'startedAt': started.toIso8601String(), - 'finishedAt': DateTime.now().toIso8601String(), - 'projectPath': _projectPath, - 'tools': _miniAgentTools - .map((tool) => { - 'name': tool.name, - 'description': tool.description, - 'surface': tool.surface, - 'risk': tool.risk, - }) - .toList(), - 'events': _agentEvents.map((event) => event.toJson()).toList(), - }; - await file.writeAsString(const JsonEncoder.withIndent(' ').convert(payload), flush: true); - return file; - } - - void _preview() { - final html = _html; - if (html == null) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Generate the 2048 project first'))); - return; - } - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(10)), - ), - builder: (context) => _WebPreviewSheet( - title: '2048 local preview', - subtitle: _projectPath ?? 'Generated HTML loaded into WebView', - html: html, - ), - ); - } - - Future _copyCode() async { - final html = _html; - if (html == null) return; - await Clipboard.setData(ClipboardData(text: html)); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Generated index.html copied'))); - } - } - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: Icons.code_outlined, - title: 'Mobile Mini Agent', - subtitle: 'A phone-first coding loop with visible tool calls, file writes, diff, and WebView preview.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Panel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Mini agent harness', - style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 16), - ), - const SizedBox(height: 10), - _InlineStatus( - icon: _generating ? Icons.sync_outlined : Icons.check_circle_outline, - label: _lastGenerateMs == null ? _stage : '$_stage - ${_lastGenerateMs}ms', - color: _generating ? _amber : (_html == null ? _faint : _mint), - ), - const SizedBox(height: 10), - for (final item in const [ - '1. Think: decide the phone-safe workspace and target artifact.', - '2. Act: call list_files, write_file, read_file, and preview_webview.', - '3. Observe: show tool results, latency, generated diff, and saved paths.', - '4. Finish: leave code, transcript, and preview entry visible for inspection.', - ]) - Padding( - padding: const EdgeInsets.only(bottom: 7), - child: Text(item, style: const TextStyle(color: _muted, height: 1.35)), - ), - ], - ), - ), - const SizedBox(height: 12), - const _MiniAgentToolRegistry(tools: _miniAgentTools), - const SizedBox(height: 12), - _MiniAgentConsole( - events: _agentEvents, - running: _generating, - controller: _agentEventController, - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: _generating ? null : _generate2048, - icon: _generating - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.auto_fix_high_outlined), - label: Text(_generating ? 'Agent running' : 'Run mini agent'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - onPressed: _preview, - icon: const Icon(Icons.visibility_outlined), - label: const Text('Preview'), - ), - ), - ], - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _html == null ? null : _copyCode, - icon: const Icon(Icons.copy_outlined), - label: const Text('Copy code'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - onPressed: widget.onOpenGitHub, - icon: const Icon(Icons.hub_outlined), - label: const Text('GitHub test'), - ), - ), - ], - ), - const SizedBox(height: 12), - if (_projectPath != null || _transcriptPath != null) - _Panel( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_projectPath != null) - Row( - children: [ - const Icon(Icons.description_outlined, color: _mint, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - _projectPath!, - style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - ), - ], - ), - if (_transcriptPath != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - const Icon(Icons.receipt_long_outlined, color: _cyan, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - _transcriptPath!, - style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - ), - ], - ), - ], - ], - ), - ), - const SizedBox(height: 12), - _Panel( - padding: const EdgeInsets.all(12), - child: SelectableText( - _html == null - ? 'No generated code yet. Tap "Run mini agent" to create a real local project.' - : _compact(_html!, limit: 2600), - style: const TextStyle(color: _muted, fontSize: 12, height: 1.4, fontFamily: 'monospace'), - ), - ), - const SizedBox(height: 12), - TextButton.icon( - onPressed: widget.onOpenOnlineDemo, - icon: const Icon(Icons.public_outlined), - label: const Text('Open already published online 2048 demo'), - ), - ], - ), - ); - } -} - -class _WebPreviewSheet extends StatefulWidget { - const _WebPreviewSheet({ - required this.title, - required this.subtitle, - required this.html, - }); - - final String title; - final String subtitle; - final String html; - - @override - State<_WebPreviewSheet> createState() => _WebPreviewSheetState(); -} - -class _WebPreviewSheetState extends State<_WebPreviewSheet> { - late final WebViewController _controller; - int _progress = 0; - String? _error; - - @override - void initState() { - super.initState(); - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..setBackgroundColor(_bg) - ..setNavigationDelegate( - NavigationDelegate( - onProgress: (progress) { - if (mounted) setState(() => _progress = progress); - }, - onWebResourceError: (error) { - if (mounted) { - setState(() => _error = '${error.errorCode}: ${error.description}'); - } - }, - ), - ) - ..loadHtmlString(widget.html); - } - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: Icons.preview_outlined, - title: widget.title, - subtitle: widget.subtitle, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Panel( - padding: EdgeInsets.zero, - child: Stack( - children: [ - SizedBox( - height: MediaQuery.of(context).size.height * 0.72, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: WebViewWidget(controller: _controller), - ), - ), - if (_progress < 100) - Positioned( - left: 0, - right: 0, - top: 0, - child: LinearProgressIndicator(value: _progress / 100), - ), - ], - ), - ), - if (_error != null) ...[ - const SizedBox(height: 10), - _Panel( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.error_outline, color: _rose, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text(_error!, style: const TextStyle(color: _rose, fontSize: 12, height: 1.35)), - ), - ], - ), - ), - ], - const SizedBox(height: 10), - const Text( - 'Preview mode runs generated HTML inside the app through Android WebView. JavaScript is enabled for local demos.', - style: TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - ], - ), - ); - } -} - -class _QuickActionGrid extends StatelessWidget { - const _QuickActionGrid({required this.onAction}); - - final ValueChanged<_ModuleAction> onAction; - - @override - Widget build(BuildContext context) { - final actions = const [ - _QuickAction(Icons.forum_outlined, 'AI Chat', _ModuleAction.aiChat, _mint), - _QuickAction(Icons.hub_outlined, 'GitHub', _ModuleAction.githubTest, _cyan), - _QuickAction(Icons.handyman_outlined, 'Tools', _ModuleAction.toolLab, _amber), - _QuickAction(Icons.terminal_outlined, 'Runtime', _ModuleAction.termuxCheck, _lime), - _QuickAction(Icons.psychology_alt_outlined, 'Deep Dive', _ModuleAction.deepDive, _violet), - _QuickAction(Icons.rocket_launch_outlined, 'Build', _ModuleAction.build, _amber), - _QuickAction(Icons.note_add_outlined, 'New File', _ModuleAction.newFile, _cyan), - _QuickAction(Icons.edit_note_outlined, 'Diary', _ModuleAction.diary, _blue), - _QuickAction(Icons.health_and_safety_outlined, 'Guard', _ModuleAction.healthCheck, _rose), - ]; - - return LayoutBuilder( - builder: (context, constraints) { - final columns = constraints.maxWidth >= 680 ? 4 : 2; - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: actions.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - childAspectRatio: columns == 2 ? 2.65 : 2.2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - ), - itemBuilder: (context, index) { - final action = actions[index]; - return _QuickActionTile(action: action, onTap: () => onAction(action.action)); - }, - ); - }, - ); - } -} - -class _QuickAction { - const _QuickAction(this.icon, this.label, this.action, this.color); - - final IconData icon; - final String label; - final _ModuleAction action; - final Color color; -} - -class _QuickActionTile extends StatelessWidget { - const _QuickActionTile({required this.action, required this.onTap}); - - final _QuickAction action; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: _panel, - borderRadius: BorderRadius.circular(8), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _line), - ), - child: Row( - children: [ - Icon(action.icon, color: action.color, size: 22), - const SizedBox(width: 10), - Expanded( - child: Text( - action.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _text, fontWeight: FontWeight.w700), - ), - ), - const Icon(Icons.chevron_right_outlined, color: _faint, size: 20), - ], - ), - ), - ), - ); - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader({required this.title, required this.subtitle}); - - final String title; - final String subtitle; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle(color: _text, fontSize: 20, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - ], - ); - } -} - -class _LayerSelector extends StatelessWidget { - const _LayerSelector({ - required this.layers, - required this.selectedIndex, - required this.onSelected, - }); - - final List<_CapabilityLayer> layers; - final int selectedIndex; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 46, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: layers.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final layer = layers[index]; - final selected = index == selectedIndex; - return Tooltip( - message: layer.subtitle, - child: ChoiceChip( - selected: selected, - onSelected: (_) => onSelected(index), - avatar: Icon(layer.icon, size: 18, color: selected ? _bg : layer.color), - label: Text(layer.name), - labelStyle: TextStyle( - color: selected ? _bg : _text, - fontWeight: FontWeight.w700, - ), - selectedColor: layer.color, - backgroundColor: _panel, - side: BorderSide(color: selected ? layer.color : _line), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - ); - }, - ), - ); - } -} - -class _LayerHeader extends StatelessWidget { - const _LayerHeader({required this.layer}); - - final _CapabilityLayer layer; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: _panelSoft, - borderRadius: BorderRadius.circular(8), - border: Border(left: BorderSide(color: layer.color, width: 4)), - ), - child: Row( - children: [ - Icon(layer.icon, color: layer.color), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - layer.name, - style: const TextStyle(color: _text, fontSize: 17, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 3), - Text( - layer.subtitle, - style: const TextStyle(color: _muted, fontSize: 12), - ), - ], - ), - ), - _Pill( - label: '${layer.serviceCount} services', - icon: Icons.storage_outlined, - color: layer.color, - ), - ], - ), - ); - } -} - -class _CapabilityCard extends StatelessWidget { - const _CapabilityCard({ - required this.capability, - required this.layerColor, - required this.onRun, - required this.onInspect, - }); - - final _Capability capability; - final Color layerColor; - final VoidCallback onRun; - final VoidCallback onInspect; - - @override - Widget build(BuildContext context) { - final statusColor = _statusColor(capability.status); - return _Panel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: layerColor.withOpacity(0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: layerColor.withOpacity(0.4)), - ), - child: Icon(capability.icon, color: layerColor), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - capability.title, - style: const TextStyle(color: _text, fontSize: 16, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 3), - Text( - capability.subtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - ], - ), - ), - _StatusPill(status: capability.status, color: statusColor), - ], - ), - const SizedBox(height: 12), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - for (final service in capability.services.take(3)) - _MiniChip(label: service, color: layerColor), - if (capability.services.length > 3) - _MiniChip(label: '+${capability.services.length - 3}', color: _faint), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: onRun, - icon: const Icon(Icons.play_arrow_outlined), - label: const Text('Open'), - ), - ), - const SizedBox(width: 10), - IconButton.outlined( - tooltip: 'Inspect services', - onPressed: onInspect, - icon: const Icon(Icons.manage_search_outlined), - ), - ], - ), - ], - ), - ); - } -} - -class _OperationsBoard extends StatelessWidget { - const _OperationsBoard({ - required this.activity, - required this.drafts, - required this.snippets, - required this.healthState, - required this.layerCount, - required this.serviceCount, - }); - - final List<_ActivityLog> activity; - final List<_DraftFile> drafts; - final List<_SnippetDraft> snippets; - final _HealthState healthState; - final int layerCount; - final int serviceCount; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _SectionHeader( - title: 'Operations Board', - subtitle: 'The working surface keeps local drafts, snippets, health, and recent module actions visible.', - ), - const SizedBox(height: 12), - LayoutBuilder( - builder: (context, constraints) { - final columns = constraints.maxWidth >= 680 ? 4 : 2; - return GridView.count( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisCount: columns, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 1.85, - children: [ - _MetricCard(label: 'Layers', value: '$layerCount', icon: Icons.account_tree_outlined, color: _mint), - _MetricCard(label: 'Services', value: '$serviceCount', icon: Icons.storage_outlined, color: _cyan), - _MetricCard(label: 'Drafts', value: '${drafts.length}', icon: Icons.note_add_outlined, color: _amber), - _MetricCard(label: 'Snippets', value: '${snippets.length}', icon: Icons.data_object_outlined, color: _lime), - ], - ); - }, - ), - const SizedBox(height: 12), - _Panel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.history_outlined, color: _mint), - const SizedBox(width: 8), - const Expanded( - child: Text( - 'Recent Activity', - style: TextStyle(color: _text, fontWeight: FontWeight.w800, fontSize: 16), - ), - ), - _StatusPill(status: _healthToStatus(healthState), color: _healthColor(healthState)), - ], - ), - const SizedBox(height: 12), - for (final item in activity.take(6)) _ActivityRow(item: item), - ], - ), - ), - if (drafts.isNotEmpty || snippets.isNotEmpty) ...[ - const SizedBox(height: 12), - _Panel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Local Work Items', - style: TextStyle(color: _text, fontWeight: FontWeight.w800, fontSize: 16), - ), - const SizedBox(height: 10), - for (final draft in drafts.take(3)) - _WorkItemRow(icon: Icons.description_outlined, title: draft.name, detail: draft.language, color: _cyan), - for (final snippet in snippets.take(3)) - _WorkItemRow(icon: Icons.data_object_outlined, title: snippet.title, detail: snippet.language, color: _lime), - ], - ), - ), - ], - ], - ); - } -} - -class _MetricCard extends StatelessWidget { - const _MetricCard({ - required this.label, - required this.value, - required this.icon, - required this.color, - }); - - final String label; - final String value; - final IconData icon; - final Color color; - - @override - Widget build(BuildContext context) { - return _Panel( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: color, size: 22), - const Spacer(), - Text(value, style: const TextStyle(color: _text, fontSize: 22, fontWeight: FontWeight.w800)), - Text(label, style: const TextStyle(color: _muted, fontSize: 12)), - ], - ), - ); - } -} - -class _ActivityRow extends StatelessWidget { - const _ActivityRow({required this.item}); - - final _ActivityLog item; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(item.icon, color: item.color, size: 20), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(item.title, style: const TextStyle(color: _text, fontWeight: FontWeight.w700)), - const SizedBox(height: 2), - Text( - item.detail, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 12), - ), - ], - ), - ), - Text(_timeLabel(item.time), style: const TextStyle(color: _faint, fontSize: 11)), - ], - ), - ); - } -} - -class _WorkItemRow extends StatelessWidget { - const _WorkItemRow({ - required this.icon, - required this.title, - required this.detail, - required this.color, - }); - - final IconData icon; - final String title; - final String detail; - final Color color; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 9), - child: Row( - children: [ - Icon(icon, color: color, size: 20), - const SizedBox(width: 10), - Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _text, fontWeight: FontWeight.w700), - ), - ), - Text(detail, style: const TextStyle(color: _muted, fontSize: 12)), - ], - ), - ); - } -} - -class _BottomNav extends StatelessWidget { - const _BottomNav({required this.tab, required this.onChanged}); - - final _HomeTab tab; - final ValueChanged<_HomeTab> onChanged; - - @override - Widget build(BuildContext context) { - final selectedIndex = switch (tab) { - _HomeTab.control => 0, - _HomeTab.ai => 0, - _HomeTab.ship => 1, - _HomeTab.guard => 2, - _HomeTab.insight => 2, - }; - return Container( - decoration: const BoxDecoration( - color: _panel, - border: Border(top: BorderSide(color: _line)), - ), - child: NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: (index) => onChanged(switch (index) { - 0 => _HomeTab.control, - 1 => _HomeTab.ship, - _ => _HomeTab.guard, - }), - backgroundColor: _panel, - indicatorColor: _mint.withOpacity(0.16), - labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, - destinations: const [ - NavigationDestination(icon: Icon(Icons.forum_outlined), selectedIcon: Icon(Icons.forum), label: 'Chat'), - NavigationDestination(icon: Icon(Icons.handyman_outlined), selectedIcon: Icon(Icons.handyman), label: 'Tools'), - NavigationDestination(icon: Icon(Icons.tune_outlined), selectedIcon: Icon(Icons.tune), label: 'Settings'), - ], - ), - ); - } -} - -class _GitHubTestSheet extends StatefulWidget { - const _GitHubTestSheet({ - required this.onOpenWeb, - required this.onLog, - }); - - final VoidCallback onOpenWeb; - final void Function(String title, String detail, IconData icon, Color color) onLog; - - @override - State<_GitHubTestSheet> createState() => _GitHubTestSheetState(); -} - -class _GitHubTestSheetState extends State<_GitHubTestSheet> { - final _token = TextEditingController(); - final _repo = TextEditingController(text: 'Harzva/mobilecode'); - bool _testing = false; - final List _lines = ['Not tested yet.']; - - @override - void dispose() { - _token.dispose(); - _repo.dispose(); - super.dispose(); - } - - Future _run() async { - setState(() { - _testing = true; - _lines - ..clear() - ..add('Testing GitHub...'); - }); - final token = _token.text.trim(); - final repo = _repo.text.trim().isEmpty ? 'Harzva/mobilecode' : _repo.text.trim(); - final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); - try { - final user = await _get(client, 'https://api.github.com/user', token); - final repoRes = await _get(client, 'https://api.github.com/repos/$repo', token); - final pages = await _get(client, 'https://api.github.com/repos/$repo/pages', token); - if (!mounted) return; - setState(() { - _lines - ..clear() - ..add('${user.statusCode == 200 ? 'OK' : 'FAIL'} /user HTTP ${user.statusCode}') - ..add('${repoRes.statusCode == 200 ? 'OK' : 'FAIL'} repo HTTP ${repoRes.statusCode}') - ..add('${pages.statusCode == 200 ? 'OK' : 'WARN'} pages HTTP ${pages.statusCode}'); - if (user.body.contains('"login"')) _lines.add('Identity response received.'); - if (repoRes.statusCode != 200) _lines.add('Repo test failed: missing token scope or repo access.'); - if (pages.statusCode == 404) _lines.add('Pages not enabled yet or token cannot read Pages settings.'); - }); - widget.onLog( - repoRes.statusCode == 200 ? 'GitHub connected' : 'GitHub test failed', - 'repo HTTP ${repoRes.statusCode}', - repoRes.statusCode == 200 ? Icons.hub_outlined : Icons.error_outline, - repoRes.statusCode == 200 ? _mint : _rose, - ); - } on Object catch (error) { - if (!mounted) return; - setState(() { - _lines - ..clear() - ..add('Network error: ${_compact(error.toString(), limit: 180)}'); - }); - widget.onLog('GitHub network error', _compact(error.toString(), limit: 120), Icons.error_outline, _rose); - } finally { - client.close(force: true); - if (mounted) setState(() => _testing = false); - } - } - - Future<({int statusCode, String body})> _get(HttpClient client, String url, String token) async { - final request = await client.getUrl(Uri.parse(url)).timeout(const Duration(seconds: 10)); - request.headers.set(HttpHeaders.acceptHeader, 'application/vnd.github+json'); - request.headers.set('X-GitHub-Api-Version', '2022-11-28'); - if (token.isNotEmpty) { - request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token'); - } - final response = await request.close().timeout(const Duration(seconds: 20)); - final body = await utf8.decodeStream(response); - return (statusCode: response.statusCode, body: body); - } - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: Icons.hub_outlined, - title: 'GitHub Connectivity', - subtitle: 'Test token identity, repository access, and Pages readiness.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _token, - obscureText: true, - decoration: const InputDecoration(labelText: 'GitHub token', prefixIcon: Icon(Icons.key_outlined)), - ), - const SizedBox(height: 10), - TextField( - controller: _repo, - decoration: const InputDecoration(labelText: 'Owner/repo', prefixIcon: Icon(Icons.account_tree_outlined)), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: _testing ? null : _run, - icon: _testing - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.network_check_outlined), - label: Text(_testing ? 'Testing' : 'Test in APK'), - ), - ), - const SizedBox(width: 10), - IconButton.outlined( - tooltip: 'Open web test page', - onPressed: widget.onOpenWeb, - icon: const Icon(Icons.open_in_browser_outlined), - ), - ], - ), - const SizedBox(height: 14), - _Panel( - child: Text( - _lines.join('\n'), - style: const TextStyle(color: _muted, height: 1.45), - ), - ), - ], - ), - ); - } -} - -class _DiarySheet extends StatefulWidget { - const _DiarySheet({required this.onLog}); - - final void Function(String title, String detail, IconData icon, Color color) onLog; - - @override - State<_DiarySheet> createState() => _DiarySheetState(); -} - -class _DiarySheetState extends State<_DiarySheet> { - static const _key = 'mobilecode.diary.entries'; - final _title = TextEditingController(); - final _body = TextEditingController(); - final List<_DiaryEntry> _entries = []; - - @override - void initState() { - super.initState(); - _load(); - } - - @override - void dispose() { - _title.dispose(); - _body.dispose(); - super.dispose(); - } - - Future _load() async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_key); - if (raw == null || raw.isEmpty) return; - final decoded = jsonDecode(raw); - if (decoded is List && mounted) { - setState(() { - _entries - ..clear() - ..addAll(decoded.whereType().map((item) => _DiaryEntry.fromJson(Map.from(item)))); - }); - } - } - - Future _save() async { - final title = _title.text.trim().isEmpty ? 'Daily note' : _title.text.trim(); - final body = _body.text.trim(); - if (body.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Write diary content first'))); - return; - } - setState(() { - _entries.insert(0, _DiaryEntry(title: title, body: body, time: DateTime.now())); - _title.clear(); - _body.clear(); - }); - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_key, jsonEncode(_entries.map((entry) => entry.toJson()).toList())); - widget.onLog('Diary entry saved', title, Icons.edit_note_outlined, _amber); - } - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: Icons.edit_note_outlined, - title: 'Diary APK Demo', - subtitle: 'A tiny local app inside MobileCode. It proves forms, storage, list rendering, and APK runtime.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _title, - decoration: const InputDecoration(labelText: 'Title', prefixIcon: Icon(Icons.title_outlined)), - ), - const SizedBox(height: 10), - TextField( - controller: _body, - minLines: 4, - maxLines: 8, - decoration: const InputDecoration(labelText: 'Today I...', alignLabelWithHint: true), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _save, - icon: const Icon(Icons.save_outlined), - label: const Text('Save diary entry'), - ), - ), - const SizedBox(height: 16), - Text('Entries (${_entries.length})', style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), - const SizedBox(height: 8), - if (_entries.isEmpty) - const Text('No entries yet.', style: TextStyle(color: _muted)) - else - for (final entry in _entries.take(5)) - _Panel( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(entry.title, style: const TextStyle(color: _text, fontWeight: FontWeight.w800)), - const SizedBox(height: 4), - Text(_timeLabel(entry.time), style: const TextStyle(color: _faint, fontSize: 11)), - const SizedBox(height: 6), - Text(entry.body, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(color: _muted, height: 1.35)), - ], - ), - ), - ], - ), - ); - } -} - -class _ToolLabSheet extends StatefulWidget { - const _ToolLabSheet({ - required this.baseUrl, - required this.apiKey, - required this.model, - required this.onOpen2048, - required this.onOpenGitHubWeb, - required this.onLog, - }); - - final String baseUrl; - final String apiKey; - final String model; - final VoidCallback onOpen2048; - final VoidCallback onOpenGitHubWeb; - final void Function(String title, String detail, IconData icon, Color color) onLog; - - @override - State<_ToolLabSheet> createState() => _ToolLabSheetState(); -} - -class _ToolLabSheetState extends State<_ToolLabSheet> { - final List<_ToolProbeResult> _results = []; - bool _running = false; - - static const _tools = [ - _ToolProbe(name: 'AI provider health', detail: 'Uses configured Base URL and model.', icon: Icons.monitor_heart_outlined, action: 'health'), - _ToolProbe(name: 'GitHub web tester', detail: 'Opens a Pages test page for token and repo checks.', icon: Icons.hub_outlined, action: 'github_web'), - _ToolProbe(name: 'Code 2048 project', detail: 'Runs the local coding lab and WebView preview flow.', icon: Icons.grid_4x4_outlined, action: 'demo_2048'), - _ToolProbe(name: 'Local storage', detail: 'Writes and reads SharedPreferences.', icon: Icons.save_outlined, action: 'storage'), - _ToolProbe(name: 'Runtime providers', detail: 'Checks MobileCode Helper and External Termux fallback.', icon: Icons.terminal_outlined, action: 'runtime'), - _ToolProbe(name: 'Root permission', detail: 'Detects whether a su binary is visible for backend keepalive.', icon: Icons.admin_panel_settings_outlined, action: 'root'), - ]; - - Future _runAll() async { - setState(() { - _running = true; - _results.clear(); - }); - await _run('storage'); - await _run('runtime'); - await _run('root'); - await _run('health'); - if (mounted) setState(() => _running = false); - } - - Future _run(String action) async { - if (action == 'github_web') { - widget.onOpenGitHubWeb(); - _addResult('GitHub web tester', true, 'Opened external browser page.'); - return; - } - if (action == 'demo_2048') { - widget.onOpen2048(); - _addResult('Code 2048 project', true, 'Opened Mobile Coding Lab.'); - return; - } - if (action == 'storage') { - final prefs = await SharedPreferences.getInstance(); - final stamp = DateTime.now().toIso8601String(); - await prefs.setString('mobilecode.tool.storageProbe', stamp); - final ok = prefs.getString('mobilecode.tool.storageProbe') == stamp; - _addResult('Local storage', ok, ok ? 'Write/read succeeded.' : 'Readback mismatch.'); - return; - } - if (action == 'runtime') { - final helper = await _probeHelperDaemon(); - final installed = await _isAndroidPackageInstalled('com.termux'); - final apiInstalled = await _isAndroidPackageInstalled('com.termux.api'); - if (helper.ready) { - _addResult('Runtime providers', true, helper.detail); - } else if (installed == true) { - final termuxDetail = apiInstalled == true ? 'External Termux + Termux:API fallback detected.' : 'External Termux fallback detected; Termux:API not detected.'; - _addResult('Runtime providers', true, '${helper.detail} $termuxDetail'); - } else if (installed == false) { - _addResult('Runtime providers', false, '${helper.detail} External Termux is not installed or not visible.'); - } else { - final urlVisible = await canLaunchUrl(Uri.parse('termux://')); - _addResult( - 'Runtime providers', - urlVisible, - urlVisible - ? '${helper.detail} termux:// handler is visible; package channel unavailable.' - : '${helper.detail} No Helper daemon and package channel is unavailable.', - ); - } - return; - } - if (action == 'root') { - final probe = await _probeRootAvailability(); - if (probe == null) { - _addResult('Root permission', false, 'Root probe channel is unavailable in this build.'); - } else { - _addResult('Root permission', probe.available, probe.detail); - } - return; - } - if (action == 'health') { - if (widget.baseUrl.isEmpty) { - _addResult('AI provider health', false, 'Base URL is empty.'); - return; - } - final flavor = _detectApiFlavor(widget.baseUrl, widget.model); - final client = HttpClient()..connectionTimeout = const Duration(seconds: 8); - try { - final uri = flavor == _ApiFlavor.anthropic ? _anthropicMessagesUri(widget.baseUrl) : _openAiChatUri(widget.baseUrl); - _parseBaseUrl(widget.baseUrl); - final request = flavor == _ApiFlavor.anthropic - ? await client.postUrl(uri).timeout(const Duration(seconds: 8)) - : await client.getUrl(Uri.parse('${_normalizedBaseUrl(widget.baseUrl)}/models')).timeout(const Duration(seconds: 8)); - if (widget.apiKey.isNotEmpty) { - request.headers.set(HttpHeaders.authorizationHeader, 'Bearer ${widget.apiKey}'); - if (flavor == _ApiFlavor.anthropic) request.headers.set('x-api-key', widget.apiKey); - } - if (flavor == _ApiFlavor.anthropic) { - request.headers.contentType = ContentType.json; - request.headers.set('anthropic-version', '2023-06-01'); - request.write(jsonEncode({ - 'model': widget.model.isEmpty ? 'claude-3-5-haiku-latest' : widget.model, - 'max_tokens': 1, - 'messages': [ - {'role': 'user', 'content': 'ping'}, - ], - })); - } - final response = await request.close().timeout(const Duration(seconds: 30)); - await response.drain(); - _addResult('AI provider health', response.statusCode >= 200 && response.statusCode < 300, 'HTTP ${response.statusCode} via ${_flavorLabel(flavor)}'); - } on Object catch (error) { - _addResult('AI provider health', false, _compact(error.toString(), limit: 120)); - } finally { - client.close(force: true); - } - } + const SizedBox(height: 16), + const Text('Frontend actions', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), + const SizedBox(height: 8), + for (final action in capability.actions) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.arrow_right_alt_outlined, color: _mint, size: 18), + const SizedBox(width: 8), + Expanded(child: Text(action, style: const TextStyle(color: _muted))), + ], + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: onRun, + icon: const Icon(Icons.play_arrow_outlined), + label: const Text('Open module'), + ), + ), + const SizedBox(width: 10), + IconButton.outlined( + tooltip: 'Copy service list', + onPressed: onCopy, + icon: const Icon(Icons.copy_outlined), + ), + ], + ), + ], + ), + ); } +} - Future<_HelperDaemonProbeResult> _probeHelperDaemon() async { - final first = await _probeHelperDaemonOnce(); - if (first.ready) return first; +class _SheetScaffold extends StatelessWidget { + const _SheetScaffold({ + required this.icon, + required this.title, + required this.subtitle, + required this.child, + }); - final started = await _startMobileCodeHelperService(); - if (started != true) return first; - await Future.delayed(const Duration(milliseconds: 400)); - final second = await _probeHelperDaemonOnce(); - if (second.ready) return second; - return _HelperDaemonProbeResult( - ready: false, - detail: '${first.detail} Native Helper service start was requested, but localhost is still not ready.', - ); - } + final IconData icon; + final String title; + final String subtitle; + final Widget child; - Future<_HelperDaemonProbeResult> _probeHelperDaemonOnce() async { - final client = HttpClient()..connectionTimeout = const Duration(seconds: 2); - try { - final uri = Uri.parse('http://127.0.0.1:8765/v1/health'); - final request = await client.getUrl(uri).timeout(const Duration(seconds: 2)); - final response = await request.close().timeout(const Duration(seconds: 3)); - final body = await utf8.decodeStream(response); - final decoded = jsonDecode(body); - final name = decoded is Map ? decoded['name'] as String? ?? 'MobileCode Helper' : 'MobileCode Helper'; - final status = decoded is Map ? decoded['status'] as String? ?? 'responded' : 'responded'; - final ready = response.statusCode >= 200 && response.statusCode < 300 && decoded is Map && decoded['ready'] == true; - return _HelperDaemonProbeResult( - ready: ready, - detail: ready ? '$name daemon ready: $status.' : '$name daemon responded but is not ready: $status.', - ); - } on Object catch (error) { - return _HelperDaemonProbeResult( - ready: false, - detail: 'MobileCode Helper daemon not reachable on 127.0.0.1:8765 (${_compact(error.toString(), limit: 80)}).', - ); - } finally { - client.close(force: true); - } + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.only( + left: 18, + right: 18, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: _line, + borderRadius: BorderRadius.circular(99), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Icon(icon, color: _mint), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(color: _text, fontSize: 20, fontWeight: FontWeight.w800)), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 12), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + child, + ], + ), + ); } +} - void _addResult(String name, bool ok, String message) { - if (!mounted) return; - setState(() { - _results.insert(0, _ToolProbeResult(name: name, ok: ok, message: message)); - }); - widget.onLog(ok ? '$name OK' : '$name failed', message, ok ? Icons.check_circle_outline : Icons.error_outline, ok ? _mint : _rose); - } - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: Icons.handyman_outlined, - title: 'Mobile Tool Tests', - subtitle: 'Run small probes for phone-optimized tools and see what fails.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Panel( - padding: const EdgeInsets.all(12), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Tool capability map', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), - SizedBox(height: 8), - _ToolScopeLine(icon: Icons.phone_android_outlined, color: _mint, title: 'Direct Android/Flutter', detail: 'storage, network, WebView preview, clipboard, sensors, camera, microphone, notifications, secure storage, GitHub HTTP APIs'), - _ToolScopeLine(icon: Icons.terminal_outlined, color: _amber, title: 'Needs runtime', detail: 'Helper daemon, External Termux fallback, git/ssh binaries, npm/python package managers, local build scripts, long-running command sessions'), - _ToolScopeLine(icon: Icons.cloud_outlined, color: _cyan, title: 'Better remote', detail: 'heavy builds, concurrent agent runs, private repo automation, CI release signing, team sync'), - ], - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _running ? null : _runAll, - icon: _running - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.play_arrow_outlined), - label: Text(_running ? 'Running probes' : 'Run core probes'), - ), - ), - const SizedBox(height: 12), - for (final tool in _tools) - _Panel( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon(tool.icon, color: _cyan), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(tool.name, style: const TextStyle(color: _text, fontWeight: FontWeight.w800)), - const SizedBox(height: 2), - Text(tool.detail, style: const TextStyle(color: _muted, fontSize: 12)), - ], - ), - ), - IconButton.outlined( - onPressed: _running ? null : () => _run(tool.action), - icon: const Icon(Icons.play_arrow_outlined), - ), - ], - ), - ), - if (_results.isNotEmpty) ...[ - const SizedBox(height: 12), - _Panel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Results', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), - const SizedBox(height: 8), - for (final result in _results) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(result.ok ? Icons.check_circle_outline : Icons.error_outline, color: result.ok ? _mint : _rose, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text('${result.name}: ${result.message}', style: const TextStyle(color: _muted, height: 1.35)), - ), - ], - ), - ), - ], - ), - ), - ], - ], - ), - ); - } -} - -class _ToolScopeLine extends StatelessWidget { - const _ToolScopeLine({ - required this.icon, - required this.color, - required this.title, - required this.detail, - }); - - final IconData icon; - final Color color; - final String title; - final String detail; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 8), - Expanded( - child: RichText( - text: TextSpan( - style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), - children: [ - TextSpan(text: '$title: ', style: TextStyle(color: color, fontWeight: FontWeight.w900)), - TextSpan(text: detail), - ], - ), - ), - ), - ], - ), - ); - } -} - -class _RuntimeDiagnosticsSheet extends StatefulWidget { - const _RuntimeDiagnosticsSheet({ - required this.runtimeManager, - required this.initialHealth, - required this.initialCapabilities, - required this.termuxInstalled, - required this.termuxApiInstalled, - required this.rootAvailable, - required this.onOpenInstall, - required this.onRefreshParent, - required this.onLog, +class _Panel extends StatelessWidget { + const _Panel({ + required this.child, + this.padding = const EdgeInsets.all(16), }); - final RuntimeManager runtimeManager; - final List initialHealth; - final RuntimeCapabilities initialCapabilities; - final bool? termuxInstalled; - final bool? termuxApiInstalled; - final bool? rootAvailable; - final VoidCallback onOpenInstall; - final Future Function() onRefreshParent; - final void Function(String title, String detail, IconData icon, Color color) onLog; + final Widget child; + final EdgeInsetsGeometry padding; @override - State<_RuntimeDiagnosticsSheet> createState() => _RuntimeDiagnosticsSheetState(); + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: padding, + decoration: BoxDecoration( + color: _panel, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _line), + boxShadow: const [ + BoxShadow( + color: Color(0x0A2555FF), + blurRadius: 16, + offset: Offset(0, 8), + ), + ], + ), + child: child, + ); + } } -class _RuntimeDiagnosticsSheetState extends State<_RuntimeDiagnosticsSheet> { - bool _checking = false; - late List _health; - late RuntimeCapabilities _capabilities; - bool? _termuxInstalled; - bool? _termuxApiInstalled; - bool? _rootAvailable; - RuntimeTaskSnapshot? _task; - String _status = 'Runtime diagnostics are ready to refresh.'; +class _Pill extends StatelessWidget { + const _Pill({ + required this.label, + required this.icon, + required this.color, + }); + + final String label; + final IconData icon; + final Color color; @override - void initState() { - super.initState(); - _health = widget.initialHealth; - _capabilities = widget.initialCapabilities; - _termuxInstalled = widget.termuxInstalled; - _termuxApiInstalled = widget.termuxApiInstalled; - _rootAvailable = widget.rootAvailable; + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.45)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 14), + const SizedBox(width: 5), + Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800)), + ], + ), + ); } +} - Future _refresh() async { - setState(() { - _checking = true; - _status = 'Checking Helper, External Termux, root, and active task state...'; - }); - try { - await _startMobileCodeHelperService(); - await widget.runtimeManager.initialize(); - final health = await widget.runtimeManager.refresh(); - final capabilities = await widget.runtimeManager.capabilities(); - final termux = await _isAndroidPackageInstalled('com.termux'); - final termuxApi = await _isAndroidPackageInstalled('com.termux.api'); - final rootProbe = await _probeRootAvailability(); - final task = await widget.runtimeManager.currentTaskSnapshot(); - if (!mounted) return; - final active = widget.runtimeManager.activeHealth; - setState(() { - _health = health; - _capabilities = capabilities; - _termuxInstalled = termux; - _termuxApiInstalled = termuxApi; - _rootAvailable = rootProbe?.available; - _task = task; - _status = '${active?.name ?? 'No runtime'}: ${active?.status ?? 'No provider responded.'}'; - }); - widget.onLog( - active?.ready == true ? 'Runtime diagnostics ready' : 'Runtime diagnostics need setup', - _status, - active?.ready == true ? Icons.verified_outlined : Icons.warning_amber_outlined, - active?.ready == true ? _mint : _amber, - ); - unawaited(widget.onRefreshParent()); - } on Object catch (error) { - if (!mounted) return; - setState(() { - _status = _compact(error.toString(), limit: 180); - }); - widget.onLog('Runtime diagnostics failed', _status, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _checking = false); - } - } +class _MiniChip extends StatelessWidget { + const _MiniChip({required this.label, required this.color}); - Future _launchTermux() async { - final opened = await _launchAndroidPackage('com.termux'); - if (!mounted) return; - final message = opened - ? 'External Termux launch intent sent.' - : 'Could not launch com.termux. It may be missing, disabled, or hidden.'; - setState(() => _status = message); - widget.onLog( - opened ? 'External Termux launched' : 'External Termux launch failed', - message, - opened ? Icons.open_in_new_outlined : Icons.error_outline, - opened ? _mint : _rose, + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.26)), + ), + child: Text( + label, + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w700), + ), ); } +} - Future _stopTask(String taskId) async { - setState(() { - _checking = true; - _status = 'Stopping runtime task $taskId...'; - }); - try { - await widget.runtimeManager.stopTask(taskId); - final task = await widget.runtimeManager.currentTaskSnapshot(); - if (!mounted) return; - setState(() { - _task = task; - _status = task == null ? 'No active runtime task after stop request.' : 'Task ${task.taskId} is ${task.status.name}.'; - }); - widget.onLog('Runtime task stop requested', _status, Icons.stop_circle_outlined, _amber); - unawaited(widget.onRefreshParent()); - } on Object catch (error) { - if (!mounted) return; - setState(() => _status = _compact(error.toString(), limit: 180)); - widget.onLog('Runtime task stop failed', _status, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _checking = false); - } +class _InlineStatus extends StatelessWidget { + const _InlineStatus({ + required this.icon, + required this.label, + required this.color, + }); + + final IconData icon; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.09), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.24)), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w800), + ), + ), + ], + ), + ); } +} + +class _LocalToolRegistry extends StatelessWidget { + const _LocalToolRegistry({required this.tools}); + + final List<_LocalToolSpec> tools; @override Widget build(BuildContext context) { - final active = widget.runtimeManager.activeHealth ?? (_health.isNotEmpty ? _health.first : null); - return _SheetScaffold( - icon: Icons.monitor_heart_outlined, - title: 'Runtime Diagnostics', - subtitle: 'Helper, Termux fallback, task recovery, and capability status in one place.', + return _Panel( + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _Panel( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(active?.name ?? 'Runtime discovery pending', style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), - const SizedBox(height: 6), - Text(_status, style: const TextStyle(color: _muted, height: 1.4)), - const SizedBox(height: 8), - Text('Capabilities: ${_runtimeCapabilitiesText(_capabilities)}', style: const TextStyle(color: _faint, fontSize: 12, height: 1.35)), - ], - ), - ), - const SizedBox(height: 12), Row( children: [ - Expanded( - child: FilledButton.icon( - onPressed: _checking ? null : _refresh, - icon: _checking - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.refresh_outlined), - label: Text(_checking ? 'Refreshing' : 'Refresh'), + const Icon(Icons.handyman_outlined, color: _cyan, size: 18), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Phone tool registry', + style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15), ), ), - const SizedBox(width: 10), + _Pill(label: '${tools.length} tools', icon: Icons.schema_outlined, color: _cyan), + ], + ), + const SizedBox(height: 10), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 620 ? 3 : 2; + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: tools.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + childAspectRatio: columns == 2 ? 1.12 : 1.34, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) => _LocalToolTile(tool: tools[index]), + ); + }, + ), + ], + ), + ); + } +} + +class _LocalToolTile extends StatelessWidget { + const _LocalToolTile({required this.tool}); + + final _LocalToolSpec tool; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _panelSoft, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: tool.color.withOpacity(0.28)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(tool.icon, color: tool.color, size: 18), + const SizedBox(width: 7), Expanded( - child: OutlinedButton.icon( - onPressed: _launchTermux, - icon: const Icon(Icons.open_in_new_outlined), - label: const Text('Open Termux'), + child: Text( + tool.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900), ), ), ], ), - const SizedBox(height: 12), - if (_health.isEmpty) - const Text('No runtime health records yet.', style: TextStyle(color: _muted)) - else - for (final health in _health) ...[ - _RuntimeHealthTile(health: health), - const SizedBox(height: 8), - ], - _Panel( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Fallback visibility', style: TextStyle(color: _text, fontWeight: FontWeight.w900)), - const SizedBox(height: 8), - _DiagnosticLine(label: 'External Termux', value: _boolStatus(_termuxInstalled), good: _termuxInstalled == true), - _DiagnosticLine(label: 'Termux:API', value: _boolStatus(_termuxApiInstalled), good: _termuxApiInstalled == true), - _DiagnosticLine(label: 'Root keepalive', value: _boolStatus(_rootAvailable), good: _rootAvailable == true), - ], + const SizedBox(height: 6), + Expanded( + child: Text( + tool.description, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 11, height: 1.25), ), ), - const SizedBox(height: 12), - _TaskSnapshotPanel( - task: _task, - onStop: _task?.canCancel == true && !_checking ? () => _stopTask(_task!.taskId) : null, - ), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: widget.onOpenInstall, - icon: const Icon(Icons.download_outlined), - label: const Text('External Termux install guide'), - ), - const SizedBox(height: 12), - const Text( - 'External Termux remains a fallback. MobileCode should prefer Helper, Embedded Lite, or Cloud providers through RuntimeManager whenever possible.', - style: TextStyle(color: _muted, fontSize: 12, height: 1.4), + const SizedBox(height: 6), + Wrap( + spacing: 5, + runSpacing: 5, + children: [ + _MiniChip(label: tool.surface, color: tool.color), + _MiniChip(label: tool.risk, color: _faint), + ], ), ], ), @@ -4950,3468 +13622,1396 @@ class _RuntimeDiagnosticsSheetState extends State<_RuntimeDiagnosticsSheet> { } } -class _RuntimeActionsSheet extends StatefulWidget { - const _RuntimeActionsSheet({ - required this.icon, - required this.title, - required this.subtitle, - required this.runtimeManager, - required this.defaultPackageManager, - required this.onLog, +class _LocalToolTranscript extends StatelessWidget { + const _LocalToolTranscript({ + required this.events, + required this.running, + required this.controller, }); - final IconData icon; - final String title; - final String subtitle; - final RuntimeManager runtimeManager; - final String defaultPackageManager; - final void Function(String title, String detail, IconData icon, Color color) onLog; + final List<_LocalToolEvent> events; + final bool running; + final ScrollController controller; @override - State<_RuntimeActionsSheet> createState() => _RuntimeActionsSheetState(); + Widget build(BuildContext context) { + final toolCalls = events.where((event) => event.kind == _LocalToolEventKind.toolCall).length; + final observations = events.where((event) => event.kind == _LocalToolEventKind.observation).length; + return _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(running ? Icons.sync_outlined : Icons.timeline_outlined, color: running ? _amber : _violet, size: 18), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Live code-writing transcript', + style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15), + ), + ), + _Pill( + label: running ? 'Live' : '${events.length} events', + icon: running ? Icons.bolt_outlined : Icons.receipt_long_outlined, + color: running ? _amber : _violet, + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MiniChip(label: '$toolCalls tool calls', color: _cyan), + _MiniChip(label: '$observations results', color: _mint), + _MiniChip(label: 'workspace-scoped', color: _amber), + ], + ), + const SizedBox(height: 12), + SizedBox( + height: events.isEmpty ? 178 : 390, + child: events.isEmpty + ? const _LocalToolEmptyTranscript() + : ListView.separated( + controller: controller, + itemCount: events.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) => _LocalToolEventCard(event: events[index]), + ), + ), + ], + ), + ); + } } -class _RuntimeActionsSheetState extends State<_RuntimeActionsSheet> { - final _projectPath = TextEditingController(text: '/data/data/com.mobilecode.mobile_agent/files/mobilecode_runtime'); - final _message = TextEditingController(text: 'mobile runtime update'); - final List _lines = ['No runtime action has run yet.']; - bool _running = false; - bool _cancelling = false; - late String _packageManager; - RuntimeActionType? _lastFailedAction; - RuntimeProjectProfile? _lastProjectProfile; - RuntimeTaskSnapshot? _lastTask; +class _LocalToolEmptyTranscript extends StatelessWidget { + const _LocalToolEmptyTranscript(); @override - void initState() { - super.initState(); - _packageManager = widget.defaultPackageManager; + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: _panelSoft, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _line), + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.play_circle_outline, color: _faint, size: 34), + SizedBox(height: 10), + Text( + 'Run the local tool test to watch tool calls, file writes, diff, and preview setup appear here.', + textAlign: TextAlign.center, + style: TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + ], + ), + ); } +} + +class _LocalToolEventCard extends StatelessWidget { + const _LocalToolEventCard({required this.event}); + + final _LocalToolEvent event; @override - void dispose() { - _projectPath.dispose(); - _message.dispose(); - super.dispose(); + Widget build(BuildContext context) { + final color = _localToolEventColor(event); + final isCodeLike = event.kind == _LocalToolEventKind.fileWrite || + event.kind == _LocalToolEventKind.diff || + event.kind == _LocalToolEventKind.toolCall; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.30)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_localToolEventIcon(event), color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + event.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 13, fontWeight: FontWeight.w900), + ), + ), + Text( + event.durationMs == null ? _clockLabel(event.time) : '${event.durationMs}ms', + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800), + ), + ], + ), + if (event.toolName != null || event.path != null) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + if (event.toolName != null) _MiniChip(label: event.toolName!, color: color), + if (event.path != null) _MiniChip(label: _compact(event.path!, limit: 46), color: _faint), + ], + ), + ], + const SizedBox(height: 8), + SelectableText( + event.detail, + style: TextStyle( + color: event.ok ? _muted : _rose, + fontSize: 12, + height: 1.38, + fontFamily: isCodeLike ? 'monospace' : null, + ), + ), + ], + ), + ); } +} - Future _run(RuntimeActionType type) async { - final projectPath = _projectPath.text.trim(); - if (projectPath.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); - return; - } - setState(() { - _running = true; - _lines.insert(0, 'Running ${type.name}...'); - }); - try { - final result = await widget.runtimeManager.runAction(_requestFor(type, projectPath)); - if (!mounted) return; - final tail = result.lastResult; - final detail = [ - result.summary, - if (result.skippedReason != null) result.skippedReason!, - if (result.recoveryHint != null) 'Recovery: ${result.recoveryHint!}', - if (tail != null && tail.stdout.trim().isNotEmpty) _compact(tail.stdout.trim(), limit: 160), - if (tail != null && tail.stderr.trim().isNotEmpty) _compact(tail.stderr.trim(), limit: 160), - ].join('\n'); - setState(() { - _lastFailedAction = result.success ? null : result.action; - _lines.insert(0, '${result.success ? 'OK' : 'FAILED'} ${type.name}: $detail'); - }); - widget.onLog( - result.success ? 'Runtime action completed' : 'Runtime action failed', - '${type.name}: ${result.summary}', - result.success ? Icons.check_circle_outline : Icons.error_outline, - result.success ? _mint : _rose, - ); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() { - _lastFailedAction = type; - _lines.insert(0, 'ERROR ${type.name}: $message'); - }); - widget.onLog('Runtime action error', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _running = false); - } - } +class _RoleProposalApprovalCard extends StatelessWidget { + const _RoleProposalApprovalCard({ + required this.proposal, + required this.onSave, + required this.onDismiss, + required this.onEdit, + }); - RuntimeActionRequest _requestFor(RuntimeActionType type, String projectPath) { - return RuntimeActionRequest( - type: type, - projectPath: projectPath, - packageManager: _selectedPackageManager, - message: _message.text.trim(), + final RoleProposal proposal; + final VoidCallback onSave; + final VoidCallback onDismiss; + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + final color = Color(proposal.role.colorValue); + return _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.35)), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: SvgPicture.asset( + proposal.role.avatarAsset, + fit: BoxFit.contain, + placeholderBuilder: (_) => Icon(Icons.person_add_alt_1_outlined, color: color, size: 18), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Pending Approval', + style: TextStyle(color: _text, fontSize: 14, fontWeight: FontWeight.w900), + ), + Text( + '${proposal.role.name} · ${proposal.rationale}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 11, height: 1.3), + ), + ], + ), + ), + _Pill(label: 'local only', icon: Icons.lock_outline, color: _amber), + ], + ), + const SizedBox(height: 10), + Text( + proposal.role.mission, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MiniArtifactButton(icon: Icons.library_add_check_outlined, label: '保存到 Roles', onTap: onSave, color: _mint), + _MiniArtifactButton(icon: Icons.edit_note_outlined, label: '编辑后保存', onTap: onEdit, color: _violet), + _MiniArtifactButton(icon: Icons.close_outlined, label: '忽略', onTap: onDismiss, color: _faint), + ], + ), + ], + ), ); } +} - String? get _selectedPackageManager => _packageManager == 'auto' ? null : _packageManager; +class _RoleProposalEditSheet extends StatefulWidget { + const _RoleProposalEditSheet({required this.role}); - Future _preflightProject() async { - final projectPath = _projectPath.text.trim(); - if (projectPath.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); - return; - } - setState(() { - _running = true; - _lines.insert(0, 'Running project preflight...'); - }); - try { - final profile = await widget.runtimeManager.preflightProject( - projectPath, - packageManager: _selectedPackageManager, - ); - if (!mounted) return; - setState(() { - _lastProjectProfile = profile; - _lines.insert( - 0, - [ - 'PREFLIGHT: ${profile.summary}', - if (profile.recoveryHint != null) 'Recovery: ${profile.recoveryHint!}', - ].join('\n'), - ); - }); - widget.onLog( - profile.recognized ? 'Runtime project detected' : 'Runtime project needs setup', - profile.summary, - profile.recognized ? Icons.search_outlined : Icons.warning_amber_outlined, - profile.recognized ? _mint : _amber, - ); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'PREFLIGHT ERROR: $message')); - widget.onLog('Runtime project preflight error', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _running = false); - } - } + final MobileCodeRole role; - Future _runValidationLoop() async { - final projectPath = _projectPath.text.trim(); - if (projectPath.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); - return; - } - setState(() { - _running = true; - _lines.insert(0, 'Running validate loop...'); - }); - try { - final result = await widget.runtimeManager.validateProject( - projectPath: projectPath, - packageManager: _selectedPackageManager, - message: _message.text.trim(), - ); - if (!mounted) return; - final failed = result.failedStep; - final stepLines = result.steps.map((step) => '${step.success ? 'OK' : 'FAILED'} ${step.action.name}: ${step.summary}'); - setState(() { - _lastProjectProfile = result.profile; - _lastFailedAction = failed?.action; - _lines.insert( - 0, - [ - result.success ? 'VALIDATED: ${result.summary}' : 'VALIDATION STOPPED: ${result.summary}', - ...stepLines, - if (result.recoveryHint != null) 'Recovery: ${result.recoveryHint!}', - ].join('\n'), - ); - }); - widget.onLog( - result.success ? 'Runtime validate loop completed' : 'Runtime validate loop stopped', - result.summary, - result.success ? Icons.verified_outlined : Icons.error_outline, - result.success ? _mint : _rose, - ); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'VALIDATION ERROR: $message')); - widget.onLog('Runtime validate loop error', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _running = false); - } - } + @override + State<_RoleProposalEditSheet> createState() => _RoleProposalEditSheetState(); +} - Future _retryLastFailure() async { - final failedAction = _lastFailedAction; - if (failedAction == null) { - setState(() => _lines.insert(0, 'No failed runtime action to retry.')); - return; - } - await widget.runtimeManager.refresh(); - await _run(failedAction); +class _RoleProposalEditSheetState extends State<_RoleProposalEditSheet> { + late final TextEditingController _name; + late final TextEditingController _mission; + late final TextEditingController _personality; + late final TextEditingController _responsibilities; + late final TextEditingController _guardrails; + late final TextEditingController _successCriteria; + late final TextEditingController _promptTemplate; + + @override + void initState() { + super.initState(); + _name = TextEditingController(text: widget.role.name); + _mission = TextEditingController(text: widget.role.mission); + _personality = TextEditingController(text: widget.role.personality); + _responsibilities = TextEditingController(text: widget.role.responsibilities.join('\n')); + _guardrails = TextEditingController(text: widget.role.guardrails.join('\n')); + _successCriteria = TextEditingController(text: widget.role.successCriteria.join('\n')); + _promptTemplate = TextEditingController(text: widget.role.promptTemplate); } - Future _cancelTask([String? taskId]) async { - final id = taskId ?? (_lastTask?.canCancel == true ? _lastTask!.taskId : null); - setState(() { - _cancelling = true; - _lines.insert(0, id == null ? 'Stopping active runtime task...' : 'Stopping runtime task $id...'); - }); - try { - if (id == null) { - await widget.runtimeManager.stopCurrentTask(); - } else { - await widget.runtimeManager.stopTask(id); - } - final task = await widget.runtimeManager.currentTaskSnapshot(); - if (!mounted) return; - final summary = task == null ? 'No recoverable runtime task after stop request.' : _taskSummary(task); - setState(() { - _lastTask = task; - _lines.insert(0, 'STOP REQUESTED: $summary'); - }); - widget.onLog('Runtime task stop requested', summary, Icons.stop_circle_outlined, _amber); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'STOP ERROR: $message')); - widget.onLog('Runtime task stop failed', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _cancelling = false); - } + @override + void dispose() { + _name.dispose(); + _mission.dispose(); + _personality.dispose(); + _responsibilities.dispose(); + _guardrails.dispose(); + _successCriteria.dispose(); + _promptTemplate.dispose(); + super.dispose(); } - Future _inspectTask() async { - setState(() => _running = true); - try { - final task = await widget.runtimeManager.currentTaskSnapshot(); - if (!mounted) return; - setState(() { - _lastTask = task; - _lines.insert(0, task == null ? 'No recoverable runtime task.' : _taskSummary(task)); - }); - } on Object catch (error) { - if (!mounted) return; - setState(() => _lines.insert(0, 'Task recovery failed: ${_compact(error.toString(), limit: 160)}')); - } finally { - if (mounted) setState(() => _running = false); - } + List _lines(TextEditingController controller) { + return controller.text + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .take(6) + .toList(growable: false); } - Future _useLastTaskPath() async { - setState(() => _running = true); - try { - final tasks = await widget.runtimeManager.taskHistory(limit: 5); - final task = tasks.firstWhere( - (item) => (item.workingDir ?? '').isNotEmpty, - orElse: () => const RuntimeTaskSnapshot( - taskId: '', - status: RuntimeTaskStatus.unknown, - command: '', - providerType: RuntimeProviderType.webViewOnly, - ), - ); - final path = task.workingDir; - if (!mounted) return; - setState(() { - if (path == null || path.isEmpty) { - _lines.insert(0, 'No recent runtime task with a project path.'); - } else { - _projectPath.text = path; - _lines.insert(0, 'Project path set from ${task.taskId}: $path'); - } - }); - } on Object catch (error) { - if (!mounted) return; - setState(() => _lines.insert(0, 'Project path recovery failed: ${_compact(error.toString(), limit: 160)}')); - } finally { - if (mounted) setState(() => _running = false); + void _save() { + final name = _name.text.trim(); + final mission = _mission.text.trim(); + if (name.isEmpty || mission.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Name and mission are required.'))); + return; } + final edited = widget.role.copyWith( + name: name, + summary: mission, + mission: mission, + personality: _personality.text.trim(), + responsibilities: _lines(_responsibilities), + guardrails: _lines(_guardrails), + successCriteria: _lines(_successCriteria), + promptTemplate: _promptTemplate.text.trim(), + builtIn: false, + enabled: true, + ); + Navigator.of(context).pop(edited); } - Future _inspectHistory() async { - setState(() => _running = true); - try { - final tasks = await widget.runtimeManager.taskHistory(limit: 5); - if (!mounted) return; - RuntimeTaskSnapshot? selectedTask; - for (final task in tasks) { - if (task.running) { - selectedTask = task; - break; - } - } - selectedTask ??= tasks.isEmpty ? null : tasks.first; - setState(() { - _lastTask = selectedTask; - _lines.insert( - 0, - tasks.isEmpty - ? 'No recoverable runtime task history.' - : tasks.map((task) => _taskSummary(task)).join('\n\n'), - ); - }); - } on Object catch (error) { - if (!mounted) return; - setState(() => _lines.insert(0, 'Task history failed: ${_compact(error.toString(), limit: 160)}')); - } finally { - if (mounted) setState(() => _running = false); - } + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + children: [ + Icon(Icons.edit_note_outlined, color: _violet, size: 20), + SizedBox(width: 8), + Expanded( + child: Text('Edit role before saving', style: TextStyle(color: _text, fontSize: 16, fontWeight: FontWeight.w900)), + ), + ], + ), + const SizedBox(height: 12), + TextField(controller: _name, decoration: const InputDecoration(labelText: 'Role name')), + const SizedBox(height: 8), + TextField(controller: _mission, maxLines: 2, decoration: const InputDecoration(labelText: 'Mission')), + const SizedBox(height: 8), + TextField(controller: _personality, maxLines: 2, decoration: const InputDecoration(labelText: 'Personality')), + const SizedBox(height: 8), + TextField(controller: _responsibilities, maxLines: 3, decoration: const InputDecoration(labelText: 'Responsibilities, one per line')), + const SizedBox(height: 8), + TextField(controller: _guardrails, maxLines: 3, decoration: const InputDecoration(labelText: 'Guardrails, one per line')), + const SizedBox(height: 8), + TextField(controller: _successCriteria, maxLines: 3, decoration: const InputDecoration(labelText: 'Success criteria, one per line')), + const SizedBox(height: 8), + TextField(controller: _promptTemplate, minLines: 3, maxLines: 6, decoration: const InputDecoration(labelText: 'Prompt template')), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: _save, + icon: const Icon(Icons.library_add_check_outlined), + label: const Text('Save role'), + ), + ), + ], + ), + ], + ), + ), + ), + ); } +} - String _taskSummary(RuntimeTaskSnapshot task) { - final logs = _recentLogLines(task.logs, limit: 4).join('\n'); - final failure = task.failureKind == RuntimeTaskFailureKind.none ? '' : ' (${task.failureKind.name})'; - return 'Task ${task.taskId} is ${task.status.name}$failure: ${task.command}${logs.isEmpty ? '' : '\n$logs'}'; +class _AgentRoleViewSheet extends StatelessWidget { + const _AgentRoleViewSheet({ + required this.role, + required this.index, + this.step, + }); + + final MobileCodeRole role; + final int index; + final _AgentTraceStep? step; + + @override + Widget build(BuildContext context) { + final color = Color(role.colorValue); + final state = step?.state ?? _AgentStepState.queued; + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 54, + height: 54, + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.35)), + ), + child: Padding( + padding: const EdgeInsets.all(5), + child: SvgPicture.asset( + role.avatarAsset, + fit: BoxFit.contain, + placeholderBuilder: (_) => Icon(Icons.person_outline, color: color, size: 24), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(role.name, style: const TextStyle(color: _text, fontSize: 18, fontWeight: FontWeight.w900)), + const SizedBox(height: 4), + Text(role.summary.isEmpty ? role.mission : role.summary, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), + ], + ), + ), + _Pill(label: 'Role ${index + 1}', icon: Icons.badge_outlined, color: color), + ], + ), + const SizedBox(height: 14), + _AgentViewStatusCard(step: step, state: state), + const SizedBox(height: 12), + _AgentViewTextBlock(title: 'Mission', text: role.mission), + _AgentViewTextBlock(title: 'Personality', text: role.personality), + _AgentViewListBlock(title: 'Responsibilities', values: role.responsibilities), + _AgentViewListBlock(title: 'Guardrails', values: role.guardrails), + _AgentViewListBlock(title: 'Success Criteria', values: role.successCriteria), + if (role.promptTemplate.trim().isNotEmpty) + _AgentViewTextBlock(title: 'Role Prompt', text: role.promptTemplate), + ], + ), + ), + ); } +} + +class _AgentViewStatusCard extends StatelessWidget { + const _AgentViewStatusCard({required this.step, required this.state}); + + final _AgentTraceStep? step; + final _AgentStepState state; @override Widget build(BuildContext context) { - return _SheetScaffold( - icon: widget.icon, - title: widget.title, - subtitle: widget.subtitle, + final color = _agentStepColor(state); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.28)), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField( - controller: _projectPath, - decoration: const InputDecoration(labelText: 'Runtime project path', prefixIcon: Icon(Icons.folder_outlined)), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, + Row( children: [ - _RuntimeActionButton( - icon: Icons.home_work_outlined, - label: 'Default path', - disabled: _running, - onTap: () { - _projectPath.text = '/data/data/com.mobilecode.mobile_agent/files/mobilecode_runtime'; - setState(() => _lines.insert(0, 'Project path reset to helper workspace default.')); - }, - ), - _RuntimeActionButton( - icon: Icons.restore_outlined, - label: 'Last cwd', - disabled: _running, - onTap: _useLastTaskPath, + Icon(_agentStepStatusIcon(state), color: color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + step?.title ?? 'Awaiting handoff', + style: const TextStyle(color: _text, fontSize: 14, fontWeight: FontWeight.w900), + ), ), + _Pill(label: _agentStepLabel(state), icon: _agentStepStatusIcon(state), color: color), ], ), - const SizedBox(height: 10), - DropdownButtonFormField( - value: _packageManager, - decoration: const InputDecoration(labelText: 'Action profile', prefixIcon: Icon(Icons.tune_outlined)), - items: const [ - DropdownMenuItem(value: 'auto', child: Text('Auto from capabilities')), - DropdownMenuItem(value: 'flutter', child: Text('Flutter')), - DropdownMenuItem(value: 'npm', child: Text('Node / npm')), - DropdownMenuItem(value: 'python', child: Text('Python')), - ], - onChanged: _running ? null : (value) => setState(() => _packageManager = value ?? 'auto'), - ), - const SizedBox(height: 10), - TextField( - controller: _message, - decoration: const InputDecoration(labelText: 'Commit message', prefixIcon: Icon(Icons.edit_note_outlined)), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _RuntimeActionButton(icon: Icons.inventory_2_outlined, label: 'Install', disabled: _running, onTap: () => _run(RuntimeActionType.installDependencies)), - _RuntimeActionButton(icon: Icons.fact_check_outlined, label: 'Test', disabled: _running, onTap: () => _run(RuntimeActionType.runTests)), - _RuntimeActionButton(icon: Icons.web_asset_outlined, label: 'Preview', disabled: _running, onTap: () => _run(RuntimeActionType.buildPreview)), - _RuntimeActionButton(icon: Icons.search_outlined, label: 'Preflight', disabled: _running, onTap: _preflightProject), - _RuntimeActionButton(icon: Icons.verified_outlined, label: 'Validate', disabled: _running, onTap: _runValidationLoop), - _RuntimeActionButton(icon: Icons.stop_circle_outlined, label: 'Stop', disabled: _cancelling, onTap: _cancelTask), - _RuntimeActionButton(icon: Icons.replay_outlined, label: 'Retry', disabled: _running || _lastFailedAction == null, onTap: _retryLastFailure), - _RuntimeActionButton(icon: Icons.account_tree_outlined, label: 'Commit', disabled: _running, onTap: () => _run(RuntimeActionType.gitCommit)), - _RuntimeActionButton(icon: Icons.publish_outlined, label: 'Publish', disabled: _running, onTap: () => _run(RuntimeActionType.publishPages)), - _RuntimeActionButton(icon: Icons.history_outlined, label: 'Recover', disabled: _running, onTap: _inspectTask), - _RuntimeActionButton(icon: Icons.manage_history_outlined, label: 'History', disabled: _running, onTap: _inspectHistory), - ], + const SizedBox(height: 8), + Text( + step?.detail ?? 'This role has not taken a visible step in the current run yet.', + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), ), - const SizedBox(height: 12), - if (_lastProjectProfile != null) ...[ - _RuntimeProjectProfilePanel(profile: _lastProjectProfile!), - const SizedBox(height: 12), + if (step?.toolName != null) ...[ + const SizedBox(height: 8), + _MiniChip(label: step!.toolName!, color: color), ], - _Panel( - child: Text( - _lines.take(8).join('\n\n'), - style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ], + ), + ); + } +} + +class _AgentViewTextBlock extends StatelessWidget { + const _AgentViewTextBlock({required this.title, required this.text}); + + final String title; + final String text; + + @override + Widget build(BuildContext context) { + if (text.trim().isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(color: _text, fontSize: 13, fontWeight: FontWeight.w900)), + const SizedBox(height: 6), + SelectableText(text, style: const TextStyle(color: _muted, fontSize: 12, height: 1.4)), + ], + ), + ); + } +} + +class _AgentViewListBlock extends StatelessWidget { + const _AgentViewListBlock({required this.title, required this.values}); + + final String title; + final List values; + + @override + Widget build(BuildContext context) { + if (values.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(color: _text, fontSize: 13, fontWeight: FontWeight.w900)), + const SizedBox(height: 6), + for (final value in values) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• ', style: TextStyle(color: _faint, fontSize: 12, height: 1.4)), + Expanded(child: Text(value, style: const TextStyle(color: _muted, fontSize: 12, height: 1.4))), + ], + ), ), - ), ], ), ); } } -class _RuntimeProjectProfilePanel extends StatelessWidget { - const _RuntimeProjectProfilePanel({required this.profile}); +class _AgentRecruitmentPanel extends StatelessWidget { + const _AgentRecruitmentPanel({ + required this.steps, + required this.running, + required this.roles, + required this.onOpenRole, + }); - final RuntimeProjectProfile profile; + final List<_AgentTraceStep> steps; + final bool running; + final List roles; + final void Function(MobileCodeRole role, int index) onOpenRole; + + _AgentStepState _roleState(int index) { + if (steps.any((step) => step.state == _AgentStepState.failed)) { + final failedIndex = steps.indexWhere((step) => step.state == _AgentStepState.failed); + if (index > failedIndex) return _AgentStepState.queued; + if (index == failedIndex) return _AgentStepState.failed; + } + final completed = steps.where((step) => step.state == _AgentStepState.done).length; + if (index < completed) return _AgentStepState.done; + if (running && index == completed) return _AgentStepState.running; + return _AgentStepState.queued; + } @override Widget build(BuildContext context) { - final color = profile.recognized ? _mint : _amber; - final markers = profile.detectedFiles.take(6).join(', '); return _Panel( + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(profile.recognized ? Icons.folder_open_outlined : Icons.folder_outlined, color: color, size: 18), + const Icon(Icons.groups_2_outlined, color: _violet, size: 18), const SizedBox(width: 8), - Expanded( + const Expanded( child: Text( - profile.packageManager ?? 'No project profile', - style: const TextStyle(color: _text, fontWeight: FontWeight.w900), + 'Role Recruit · RR mode', + style: TextStyle(color: _text, fontSize: 14, fontWeight: FontWeight.w900), ), ), - Text(profile.kind.name, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w800)), + _Pill( + label: running ? 'working' : 'ready', + icon: running ? Icons.sync_outlined : Icons.verified_outlined, + color: running ? _amber : _mint, + ), ], ), - const SizedBox(height: 6), - Text(profile.summary, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), - if (markers.isNotEmpty) ...[ - const SizedBox(height: 6), - Text(markers, style: const TextStyle(color: _muted, fontSize: 11, height: 1.25)), - ], - if (profile.recoveryHint != null) ...[ - const SizedBox(height: 6), - Text(profile.recoveryHint!, style: TextStyle(color: color, fontSize: 11, height: 1.25)), - ], + const SizedBox(height: 8), + const Text( + 'One execution lane, with different role personalities taking each stage.', + style: TextStyle(color: _muted, fontSize: 11, height: 1.35), + ), + const SizedBox(height: 12), + SizedBox( + height: 134, + child: roles.isEmpty + ? Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: _panelSoft, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _line), + ), + child: const Text( + 'No enabled roles. Open Control Center -> Roles to enable one.', + textAlign: TextAlign.center, + style: TextStyle(color: _muted, fontSize: 11, fontWeight: FontWeight.w700), + ), + ) + : ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: roles.length, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final role = roles[index]; + return _AgentRoleCard( + role: role, + index: index, + state: _roleState(index), + onTap: () => onOpenRole(role, index), + ); + }, + ), + ), ], ), ); } } -class _RuntimeHealthTile extends StatelessWidget { - const _RuntimeHealthTile({required this.health}); +class _AgentRoleCard extends StatelessWidget { + const _AgentRoleCard({ + required this.role, + required this.index, + required this.state, + required this.onTap, + }); - final RuntimeHealth health; + final MobileCodeRole role; + final int index; + final _AgentStepState state; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final color = health.ready ? _mint : health.available ? _amber : _rose; - return _Panel( - padding: const EdgeInsets.all(12), - child: Column( + final stateColor = _agentStepColor(state); + final roleColor = Color(role.colorValue); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 174, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: roleColor.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: stateColor.withOpacity(state == _AgentStepState.queued ? 0.22 : 0.48)), + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(health.ready ? Icons.check_circle_outline : Icons.info_outline, color: color, size: 18), + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: roleColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: roleColor.withOpacity(0.35)), + ), + child: Padding( + padding: const EdgeInsets.all(3), + child: SvgPicture.asset( + role.avatarAsset, + fit: BoxFit.contain, + placeholderBuilder: (_) => Icon(Icons.person_outline, color: roleColor, size: 18), + ), + ), + ), const SizedBox(width: 8), Expanded( - child: Text(health.name, style: const TextStyle(color: _text, fontWeight: FontWeight.w900)), + child: Text( + role.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900), + ), ), - Text(health.ready ? 'ready' : health.available ? 'available' : 'offline', style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w800)), + Text('${index + 1}'.padLeft(2, '0'), style: const TextStyle(color: _faint, fontSize: 10, fontWeight: FontWeight.w900)), + ], + ), + const SizedBox(height: 8), + Text( + role.mission, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 10.5, height: 1.25), + ), + const Spacer(), + Row( + children: [ + Icon(_agentStepStatusIcon(state), color: stateColor, size: 14), + const SizedBox(width: 5), + Text(_agentStepLabel(state), style: TextStyle(color: stateColor, fontSize: 10.5, fontWeight: FontWeight.w900)), ], ), - const SizedBox(height: 6), - Text(health.status, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), - const SizedBox(height: 6), - Text(_runtimeCapabilitiesText(health.capabilities), style: const TextStyle(color: _faint, fontSize: 11, height: 1.3)), - if (health.missingDependencies.isNotEmpty) ...[ - const SizedBox(height: 6), - Text('Missing: ${health.missingDependencies.join(', ')}', style: const TextStyle(color: _amber, fontSize: 11, height: 1.3)), - ], - if (health.recoveryActions.isNotEmpty) ...[ - const SizedBox(height: 6), - Text('Recover: ${health.recoveryActions.join(' / ')}', style: const TextStyle(color: _muted, fontSize: 11, height: 1.3)), - ], ], ), + ), ); } } -class _DiagnosticLine extends StatelessWidget { - const _DiagnosticLine({ - required this.label, - required this.value, - required this.good, +class _AgentTracePanel extends StatelessWidget { + const _AgentTracePanel({ + required this.title, + required this.steps, }); - final String label; - final String value; - final bool good; + final String title; + final List<_AgentTraceStep> steps; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row( + final completed = steps.where((step) => step.state == _AgentStepState.done).length; + final failed = steps.where((step) => step.state == _AgentStepState.failed).length; + final running = steps.where((step) => step.state == _AgentStepState.running).length; + final progress = steps.isEmpty ? 0.0 : (completed + failed) / steps.length; + return _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(good ? Icons.check_circle_outline : Icons.radio_button_unchecked_outlined, size: 16, color: good ? _mint : _faint), - const SizedBox(width: 8), - Expanded(child: Text(label, style: const TextStyle(color: _muted, fontSize: 12))), - Text(value, style: TextStyle(color: good ? _mint : _faint, fontSize: 12, fontWeight: FontWeight.w800)), + Row( + children: [ + const Icon(Icons.timeline_outlined, color: _violet, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: const TextStyle(color: _text, fontSize: 15, fontWeight: FontWeight.w900), + ), + ), + _Pill( + label: '$completed/${steps.length}', + icon: Icons.task_alt_outlined, + color: _violet, + ), + ], + ), + const SizedBox(height: 12), + for (var index = 0; index < steps.length; index++) ...[ + _AgentTraceRow( + step: steps[index], + index: index, + isLast: index == steps.length - 1, + ), + ], + const SizedBox(height: 12), + _AgentTraceProgressFooter( + progress: progress, + completed: completed, + running: running, + failed: failed, + total: steps.length, + ), ], ), ); } } -class _TaskSnapshotPanel extends StatelessWidget { - const _TaskSnapshotPanel({required this.task, this.onStop}); +class _AgentTraceProgressFooter extends StatelessWidget { + const _AgentTraceProgressFooter({ + required this.progress, + required this.completed, + required this.running, + required this.failed, + required this.total, + }); - final RuntimeTaskSnapshot? task; - final VoidCallback? onStop; + final double progress; + final int completed; + final int running; + final int failed; + final int total; @override Widget build(BuildContext context) { - final snapshot = task; - if (snapshot == null) { - return const _Panel( - padding: EdgeInsets.all(12), - child: Text('No recoverable runtime task snapshot yet.', style: TextStyle(color: _muted, fontSize: 12, height: 1.35)), - ); - } - final color = snapshot.running - ? _amber - : snapshot.status == RuntimeTaskStatus.succeeded - ? _mint - : _rose; - final details = [ - if (snapshot.startedAt != null) 'Started ${_timeLabel(snapshot.startedAt!)} ago', - if (snapshot.duration != null) 'Duration ${_durationLabel(snapshot.duration!)}', - if (snapshot.exitCode != null) 'Exit ${snapshot.exitCode}', - if (snapshot.failureKind != RuntimeTaskFailureKind.none) 'Failure ${snapshot.failureKind.name}', - ]; - return _Panel( - padding: const EdgeInsets.all(12), + final color = failed > 0 ? _rose : _mint; + final percent = (progress * 100).round().clamp(0, 100); + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _panelSoft.withOpacity(0.70), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.24)), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.history_outlined, color: color, size: 18), + Icon(Icons.stacked_line_chart_outlined, color: color, size: 16), const SizedBox(width: 8), - Expanded(child: Text('Task ${snapshot.taskId}', style: const TextStyle(color: _text, fontWeight: FontWeight.w900))), - Text(snapshot.status.name, style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w800)), - if (snapshot.canCancel && onStop != null) ...[ - const SizedBox(width: 4), - IconButton( - tooltip: 'Stop task', - visualDensity: VisualDensity.compact, - onPressed: onStop, - icon: const Icon(Icons.stop_circle_outlined, size: 18), - color: _amber, + Expanded( + child: Text( + 'Progress · $percent%', + style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900), ), - ], + ), + Text('$completed/$total steps', style: const TextStyle(color: _muted, fontSize: 11, fontWeight: FontWeight.w800)), ], ), - const SizedBox(height: 6), - Text(snapshot.command.isEmpty ? 'No command recorded.' : snapshot.command, style: const TextStyle(color: _muted, fontSize: 12, height: 1.35)), - if (details.isNotEmpty) ...[ - const SizedBox(height: 6), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - for (final detail in details) _TaskDetailChip(label: detail, color: color), - ], - ), - ], - if (snapshot.workingDir != null && snapshot.workingDir!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text(snapshot.workingDir!, style: const TextStyle(color: _faint, fontSize: 11, height: 1.3)), - ], - if (snapshot.error != null && snapshot.error!.isNotEmpty) ...[ - const SizedBox(height: 6), - Text(snapshot.error!, style: const TextStyle(color: _rose, fontSize: 11, height: 1.3)), - ], - if (snapshot.logs.isNotEmpty) ...[ - const SizedBox(height: 8), - Theme( - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - tilePadding: EdgeInsets.zero, - childrenPadding: EdgeInsets.zero, - initiallyExpanded: snapshot.running || snapshot.status != RuntimeTaskStatus.succeeded, - title: const Text('Recent logs', style: TextStyle(color: _muted, fontSize: 12, fontWeight: FontWeight.w800)), - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text(_recentLogLines(snapshot.logs, limit: 8).join('\n'), style: const TextStyle(color: _faint, fontSize: 11, height: 1.3)), - ), - ], - ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: progress, + minHeight: 6, + backgroundColor: _panel, + valueColor: AlwaysStoppedAnimation(color), ), - ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _Pill(label: '$completed done', icon: Icons.check_circle_outline, color: _mint), + if (running > 0) _Pill(label: '$running running', icon: Icons.sync_outlined, color: _amber), + if (failed > 0) _Pill(label: '$failed failed', icon: Icons.error_outline, color: _rose), + ], + ), ], ), ); } } -class _TaskDetailChip extends StatelessWidget { - const _TaskDetailChip({required this.label, required this.color}); +class _AgentTraceRow extends StatelessWidget { + const _AgentTraceRow({ + required this.step, + required this.index, + required this.isLast, + }); - final String label; - final Color color; + final _AgentTraceStep step; + final int index; + final bool isLast; @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withValues(alpha: 0.22)), + final color = _agentStepColor(step.state); + final icon = _agentStepStatusIcon(step.state); + return Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 10), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => _showAgentTraceStepDetails(context, step), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: step.state == _AgentStepState.running ? color.withOpacity(0.08) : _panelSoft.withOpacity(0.55), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(step.state == _AgentStepState.queued ? 0.18 : 0.35)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.42)), + ), + child: _AgentTraceAvatar( + assetPath: step.avatarAsset, + fallbackIcon: icon, + color: color, + ), + ), + const SizedBox(height: 6), + Text( + (index + 1).toString().padLeft(2, '0'), + style: const TextStyle(color: _faint, fontSize: 10, fontWeight: FontWeight.w900), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(step.icon, color: color, size: 15), + const SizedBox(width: 6), + Expanded( + child: Text( + step.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 13), + ), + ), + const SizedBox(width: 8), + _Pill(label: _agentStepLabel(step.state), icon: icon, color: color), + ], + ), + if (step.toolName != null) ...[ + const SizedBox(height: 6), + Text( + step.toolName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800), + ), + ], + const SizedBox(height: 6), + Text( + step.detail, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), + ), + if (step.details.isNotEmpty) ...[ + const SizedBox(height: 8), + _AgentTraceInlineDetails( + details: step.details, + color: color, + initiallyExpanded: step.title == 'Call model provider', + ), + ], + ], + ), + ), + const SizedBox(width: 6), + Icon(Icons.chevron_right, color: color.withOpacity(0.8), size: 18), + ], + ), + ), ), - child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800)), ); } + } -class _ProjectConsoleSheet extends StatefulWidget { - const _ProjectConsoleSheet({ - required this.runtimeManager, - required this.defaultProjectPath, - required this.onLog, - }); - - final RuntimeManager runtimeManager; - final String defaultProjectPath; - final void Function(String title, String detail, IconData icon, Color color) onLog; - - @override - State<_ProjectConsoleSheet> createState() => _ProjectConsoleSheetState(); -} - -class _ProjectConsoleSheetState extends State<_ProjectConsoleSheet> { - final _projectName = TextEditingController(); - final _projectPath = TextEditingController(); - final _gitUrl = TextEditingController(); - final List _lines = ['No project action has run yet.']; - List _recentProjectPaths = const []; - bool _running = false; - RuntimeProjectProfile? _profile; - +class _AgentTraceInlineDetails extends StatefulWidget { + const _AgentTraceInlineDetails({ + required this.details, + required this.color, + this.initiallyExpanded = false, + }); + + final Map details; + final Color color; + final bool initiallyExpanded; + + @override + State<_AgentTraceInlineDetails> createState() => _AgentTraceInlineDetailsState(); +} + +class _AgentTraceInlineDetailsState extends State<_AgentTraceInlineDetails> { + late bool _expanded; + @override void initState() { super.initState(); - _projectPath.text = widget.defaultProjectPath; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_loadRecentProjects()); - }); - } - - @override - void dispose() { - _projectName.dispose(); - _projectPath.dispose(); - _gitUrl.dispose(); - super.dispose(); + _expanded = widget.initiallyExpanded; } - Future _loadRecentProjects() async { - setState(() { - _running = true; - _lines.insert(0, 'Loading recent runtime project paths...'); - }); - try { - final tasks = await widget.runtimeManager.taskHistory(limit: 12); - final paths = {}; - for (final task in tasks) { - final path = task.workingDir?.trim(); - if (path != null && path.isNotEmpty) paths.add(path); - } - if (_profile?.projectPath.trim().isNotEmpty == true) { - paths.add(_profile!.projectPath.trim()); - } - if (!mounted) return; - setState(() { - _recentProjectPaths = paths.take(6).toList(); - _lines.insert(0, paths.isEmpty ? 'No recent runtime project paths found.' : 'Loaded ${paths.length} recent project path(s).'); - }); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'RECENT PROJECTS ERROR: $message')); - widget.onLog('Recent project load failed', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _running = false); - } - } + @override + Widget build(BuildContext context) { + final visibleEntries = widget.details.entries + .where((entry) => entry.value.trim().isNotEmpty) + .toList(growable: false); + if (visibleEntries.isEmpty) return const SizedBox.shrink(); - Future _cloneRepository() async { - final url = _gitUrl.text.trim(); - final validationError = _gitUrlError(url); - if (validationError != null) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(validationError))); - return; - } - final targetName = _safeProjectDirectoryName(_projectName.text.trim().isEmpty ? _projectNameFromGitUrl(url) : _projectName.text.trim()); - final targetPath = '${widget.defaultProjectPath}/$targetName'; - setState(() { - _running = true; - _lines.insert(0, 'Cloning repository into $targetPath...'); - }); - try { - final result = await widget.runtimeManager.execute( - 'git clone ${_quoteCommandArg(url)} ${_quoteCommandArg(targetName)}', - workingDir: widget.defaultProjectPath, - timeout: const Duration(minutes: 10), - ); - if (!mounted) return; - setState(() { - _projectPath.text = targetPath; - _recentProjectPaths = [targetPath, ..._recentProjectPaths.where((path) => path != targetPath)].take(6).toList(); - _lines.insert( - 0, - [ - result.success ? 'CLONED: $targetPath' : 'CLONE FAILED: $targetPath', - if (result.stdout.trim().isNotEmpty) _compact(result.stdout.trim(), limit: 220), - if (result.stderr.trim().isNotEmpty) _compact(result.stderr.trim(), limit: 220), - ].join('\n'), - ); - }); - widget.onLog( - result.success ? 'Git repository cloned' : 'Git clone failed', - targetPath, - result.success ? Icons.download_done_outlined : Icons.error_outline, - result.success ? _mint : _rose, - ); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'CLONE ERROR: $message')); - widget.onLog('Git clone error', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _running = false); - } - } - - Future _runPreflight() async { - final path = _projectPath.text.trim(); - if (path.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); - return; - } - setState(() { - _running = true; - _lines.insert(0, 'Running project preflight...'); - }); - try { - final profile = await widget.runtimeManager.preflightProject(path); - if (!mounted) return; - setState(() { - _profile = profile; - _lines.insert( - 0, - [ - 'PREFLIGHT: ${profile.summary}', - if (profile.recoveryHint != null) 'Recovery: ${profile.recoveryHint!}', - ].join('\n'), - ); - }); - widget.onLog( - profile.recognized ? 'Project detected' : 'Project needs setup', - profile.summary, - profile.recognized ? Icons.search_outlined : Icons.warning_amber_outlined, - profile.recognized ? _mint : _amber, - ); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'PREFLIGHT ERROR: $message')); - widget.onLog('Project preflight error', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _running = false); - } - } - - Future _runValidation() async { - final path = _projectPath.text.trim(); - if (path.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Project path is required'))); - return; - } - setState(() { - _running = true; - _lines.insert(0, 'Running project validation...'); - }); - try { - final result = await widget.runtimeManager.validateProject( - projectPath: path, - message: _projectName.text.trim().isEmpty ? null : _projectName.text.trim(), - ); - if (!mounted) return; - final stepLines = result.steps.map((s) => '${s.success ? 'OK' : 'FAILED'} ${s.action.name}: ${s.summary}'); - setState(() { - _profile = result.profile; - _lines.insert( - 0, - [ - result.success ? 'VALIDATED: ${result.summary}' : 'VALIDATION STOPPED: ${result.summary}', - ...stepLines, - if (result.recoveryHint != null) 'Recovery: ${result.recoveryHint!}', - ].join('\n'), - ); - }); - widget.onLog( - result.success ? 'Project validated' : 'Project validation stopped', - result.summary, - result.success ? Icons.verified_outlined : Icons.error_outline, - result.success ? _mint : _rose, - ); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'VALIDATION ERROR: $message')); - widget.onLog('Project validation error', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _running = false); - } - } - - void _fillDefaultPath() { - setState(() { - _projectPath.text = widget.defaultProjectPath; - _lines.insert(0, 'Project path set to runtime workspace default.'); - }); - } - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: Icons.folder_open_outlined, - title: 'Project Console', - subtitle: 'Configure project name and path, then run preflight or validation through the active runtime.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _projectName, - decoration: const InputDecoration(labelText: 'Project name (optional)', prefixIcon: Icon(Icons.badge_outlined)), - ), - const SizedBox(height: 8), - TextField( - controller: _projectPath, - decoration: const InputDecoration(labelText: 'Project path / cwd', prefixIcon: Icon(Icons.folder_outlined)), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _RuntimeActionButton( - icon: Icons.home_work_outlined, - label: 'Default cwd', - disabled: _running, - onTap: _fillDefaultPath, - ), - _RuntimeActionButton( - icon: Icons.manage_history_outlined, - label: 'Recent paths', - disabled: _running, - onTap: _loadRecentProjects, - ), - _RuntimeActionButton( - icon: Icons.search_outlined, - label: 'Preflight', - disabled: _running, - onTap: _runPreflight, - ), - _RuntimeActionButton( - icon: Icons.verified_outlined, - label: 'Validate', - disabled: _running, - onTap: _runValidation, - ), - ], - ), - if (_recentProjectPaths.isNotEmpty) ...[ - const SizedBox(height: 10), - _Panel( - padding: const EdgeInsets.all(12), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final path in _recentProjectPaths) - ActionChip( - avatar: const Icon(Icons.folder_open_outlined, size: 16), - label: Text(_compact(path, limit: 34)), - onPressed: _running ? null : () => setState(() => _projectPath.text = path), - ), - ], + return Container( + decoration: BoxDecoration( + color: _panel.withOpacity(0.68), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: widget.color.withOpacity(0.22)), + ), + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: _expanded, + tilePadding: const EdgeInsets.symmetric(horizontal: 10), + childrenPadding: const EdgeInsets.fromLTRB(10, 0, 10, 10), + visualDensity: VisualDensity.compact, + onExpansionChanged: (value) => setState(() => _expanded = value), + leading: Icon(Icons.account_tree_outlined, color: widget.color, size: 16), + title: Text( + _expanded ? 'Hide provider/local tool details' : 'Show provider/local tool details', + style: const TextStyle(color: _text, fontSize: 11.5, fontWeight: FontWeight.w900), + ), + children: [ + for (final entry in visibleEntries) ...[ + Align( + alignment: Alignment.centerLeft, + child: Text( + entry.key, + style: TextStyle(color: widget.color, fontSize: 11, fontWeight: FontWeight.w900), + ), ), - ), + const SizedBox(height: 3), + Align( + alignment: Alignment.centerLeft, + child: SelectableText( + entry.value, + style: const TextStyle(color: _muted, fontSize: 11, height: 1.34), + ), + ), + const SizedBox(height: 8), + ], ], - const SizedBox(height: 10), - TextField( - controller: _gitUrl, - decoration: const InputDecoration(labelText: 'Git repository URL', prefixIcon: Icon(Icons.cloud_download_outlined)), - ), - const SizedBox(height: 8), - _RuntimeActionButton( - icon: Icons.download_outlined, - label: 'Clone / import Git', - disabled: _running, - onTap: _cloneRepository, - ), - const SizedBox(height: 12), - if (_profile != null) ...[ - _RuntimeProjectProfilePanel(profile: _profile!), - const SizedBox(height: 12), - ], - _Panel( - child: Text( - _lines.take(8).join('\n\n'), - style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - ), - ], - ), - ); - } -} - -class _RuntimeActionButton extends StatelessWidget { - const _RuntimeActionButton({ - required this.icon, - required this.label, - required this.disabled, - required this.onTap, + ), + ), + ); + } +} + +class _AgentTraceAvatar extends StatelessWidget { + const _AgentTraceAvatar({ + required this.assetPath, + required this.fallbackIcon, + required this.color, }); - final IconData icon; - final String label; - final bool disabled; - final VoidCallback onTap; + final String? assetPath; + final IconData fallbackIcon; + final Color color; @override Widget build(BuildContext context) { - return OutlinedButton.icon( - onPressed: disabled ? null : onTap, - icon: Icon(icon, size: 17), - label: Text(label), + final path = assetPath; + if (path == null || path.isEmpty) { + return Icon(fallbackIcon, color: color, size: 16); + } + + return Padding( + padding: const EdgeInsets.all(3), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SvgPicture.asset( + path, + fit: BoxFit.contain, + placeholderBuilder: (_) => Icon(fallbackIcon, color: color, size: 16), + ), + ), ); } } -String _runtimeCapabilitiesText(RuntimeCapabilities capabilities) { - final labels = [ - if (capabilities.shell) 'shell', - if (capabilities.git) 'git', - if (capabilities.node) 'node', - if (capabilities.python) 'python', - if (capabilities.flutter) 'flutter', - if (capabilities.androidBuild) 'apk', - if (capabilities.pty) 'pty', - if (capabilities.backgroundService) 'background', - if (capabilities.cloudBuild) 'cloud', - if (capabilities.webViewPreview) 'webview', - ]; - return labels.isEmpty ? 'webview-only' : labels.join(', '); +void _showAgentTraceStepDetails(BuildContext context, _AgentTraceStep step) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: _panel, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), + builder: (context) => _AgentTraceDetailSheet(step: step), + ); } -String _boolStatus(bool? value) { - if (value == true) return 'yes'; - if (value == false) return 'no'; - return 'unknown'; +class _AgentTraceDetailSheet extends StatelessWidget { + const _AgentTraceDetailSheet({required this.step}); + + final _AgentTraceStep step; + + @override + Widget build(BuildContext context) { + final color = _agentStepColor(step.state); + final details = step.details.isEmpty + ? { + 'Detail': step.detail, + } + : step.details; + return _SheetScaffold( + icon: step.icon, + title: step.title, + subtitle: step.toolName ?? _agentStepLabel(step.state), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _Pill(label: _agentStepLabel(step.state), icon: _agentStepStatusIcon(step.state), color: color), + if (step.finishedAt != null) _Pill(label: _clockLabel(step.finishedAt!), icon: Icons.schedule_outlined, color: _cyan), + ], + ), + const SizedBox(height: 12), + _Panel( + padding: const EdgeInsets.all(12), + child: SelectableText( + step.detail, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.38), + ), + ), + const SizedBox(height: 12), + for (final entry in details.entries) ...[ + _TraceDetailItem(label: entry.key, value: entry.value), + const SizedBox(height: 10), + ], + ], + ), + ); + } } -class _ChatPanel extends StatefulWidget { - const _ChatPanel({ - super.key, - required this.baseUrl, - required this.apiKey, - required this.model, - required this.onLog, - required this.onAgentPrompt, - this.onSessionsChanged, - this.embedded = false, - }); - - final String baseUrl; - final String apiKey; - final String model; - final void Function(String title, String detail, IconData icon, Color color) onLog; - final Future Function(String prompt) onAgentPrompt; - final void Function(List<_ChatSession> sessions, String? activeSessionId)? onSessionsChanged; - final bool embedded; - - @override - State<_ChatPanel> createState() => _ChatPanelState(); -} - -class _ChatPanelState extends State<_ChatPanel> { - static const _sessionsKey = 'mobilecode.chat.sessions.v1'; - static const _activeSessionKey = 'mobilecode.chat.activeSession.v1'; - - final _promptController = TextEditingController(); - final _chatScrollController = ScrollController(); - final _voiceService = VoiceService(); - final List<_ChatSession> _sessions = []; - String? _activeSessionId; - bool _loading = true; - bool _sending = false; - bool _agentRunning = false; - bool _voiceAvailable = false; - VoiceState _voiceState = VoiceState.idle; - StreamSubscription? _voiceTranscriptSub; - StreamSubscription? _voiceStateSub; - String? _error; - final List<_AgentTraceStep> _agentTrace = []; - - _ChatSession? get _activeSession { - if (_sessions.isEmpty) return null; - final index = _sessions.indexWhere((session) => session.id == _activeSessionId); - return index == -1 ? _sessions.first : _sessions[index]; - } - - @override - void initState() { - super.initState(); - _loadSessions(); - _initVoiceInput(); - } - - @override - void dispose() { - _voiceTranscriptSub?.cancel(); - _voiceStateSub?.cancel(); - _voiceService.dispose(); - _chatScrollController.dispose(); - _promptController.dispose(); - super.dispose(); - } - - Future _initVoiceInput() async { - _voiceTranscriptSub = _voiceService.onTranscriptUpdate.listen((text) { - if (!mounted || text.trim().isEmpty) return; - _promptController.text = text; - _promptController.selection = TextSelection.collapsed(offset: _promptController.text.length); - setState(() {}); - }); - _voiceStateSub = _voiceService.onStateChange.listen((state) { - if (!mounted) return; - setState(() => _voiceState = state); - }); - final available = await _voiceService.initialize(); - if (!mounted) return; - setState(() { - _voiceAvailable = available; - _voiceState = _voiceService.currentState; - }); - } - - Future _loadSessions() async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_sessionsKey); - final loaded = <_ChatSession>[]; - - if (raw != null && raw.isNotEmpty) { - try { - final decoded = jsonDecode(raw); - if (decoded is List) { - loaded.addAll( - decoded - .whereType() - .map((item) => _ChatSession.fromJson(Map.from(item))) - .where((session) => session.turns.isNotEmpty || session.title.trim().isNotEmpty), - ); - } - } catch (_) { - // Corrupt chat storage should not block the chat surface. - } - } - - if (loaded.isEmpty) loaded.add(_newSessionObject()); - loaded.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - - if (!mounted) return; - setState(() { - _sessions - ..clear() - ..addAll(loaded.take(20)); - _activeSessionId = prefs.getString(_activeSessionKey); - if (_sessions.every((session) => session.id != _activeSessionId)) { - _activeSessionId = _sessions.first.id; - } - _loading = false; - }); - _notifySessionsChanged(); - } - - Future _persist() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_sessionsKey, jsonEncode(_sessions.map((session) => session.toJson()).toList())); - final activeId = _activeSessionId; - if (activeId != null) { - await prefs.setString(_activeSessionKey, activeId); - } - _notifySessionsChanged(); - } - - void _notifySessionsChanged() { - widget.onSessionsChanged?.call(List<_ChatSession>.unmodifiable(_sessions), _activeSessionId); - } - - Future createSessionFromShell() => _createSession(); - - Future selectSessionFromShell(String id) => _selectSession(id); - - void setPromptFromShell(String prompt, {bool runAgent = false}) { - _promptController.text = prompt; - _promptController.selection = TextSelection.collapsed(offset: prompt.length); - if (!mounted) return; - setState(() {}); - if (runAgent) { - unawaited(_runAgentWithTrace()); - } - } - - _ChatSession _newSessionObject() { - final now = DateTime.now(); - return _ChatSession( - id: now.microsecondsSinceEpoch.toString(), - title: 'New chat', - createdAt: now, - updatedAt: now, - turns: const [], - ); - } - - void _storeSession(_ChatSession session) { - final index = _sessions.indexWhere((item) => item.id == session.id); - if (index == -1) { - _sessions.insert(0, session); - } else { - _sessions[index] = session; - _sessions.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); - } - } - - Future _createSession() async { - final session = _newSessionObject(); - setState(() { - _sessions.insert(0, session); - _activeSessionId = session.id; - _error = null; - }); - await _persist(); - } - - Future _selectSession(String id) async { - setState(() { - _activeSessionId = id; - _error = null; - }); - await _persist(); - } - - Future _deleteActiveSession() async { - final active = _activeSession; - if (active == null) return; - setState(() { - _sessions.removeWhere((session) => session.id == active.id); - if (_sessions.isEmpty) { - final replacement = _newSessionObject(); - _sessions.add(replacement); - _activeSessionId = replacement.id; - } else { - _activeSessionId = _sessions.first.id; - } - _error = null; - }); - await _persist(); - } - - Future _send() async { - final prompt = _promptController.text.trim(); - if (prompt.isEmpty) { - _showMessage('Enter a prompt first'); - return; - } - final active = _activeSession; - if (active == null) { - _showMessage('Chat is still loading'); - return; - } - - final now = DateTime.now(); - final userTurn = _ChatTurn(role: 'user', content: prompt, time: now); - final history = [...active.turns, userTurn]; - final pending = active.copyWith( - title: active.title == 'New chat' ? _chatTitle(prompt) : active.title, - updatedAt: now, - turns: history, - ); - - setState(() { - _sending = true; - _error = null; - _promptController.clear(); - _storeSession(pending); - }); - _scrollConversationToEnd(); - await _persist(); - - try { - final flavor = _detectApiFlavor(widget.baseUrl, widget.model); - final answer = await _callProvider( - history, - systemPrompt: - 'You are MobileCode, a mobile AI development assistant. Use the saved multi-turn chat context, answer concisely, and prefer executable mobile development steps.', - ); - if (!mounted) return; - final current = _sessions.firstWhere((session) => session.id == pending.id, orElse: () => pending); - final next = current.copyWith( - updatedAt: DateTime.now(), - turns: [ - ...current.turns, - _ChatTurn(role: 'assistant', content: answer, time: DateTime.now()), - ], - ); - setState(() => _storeSession(next)); - _scrollConversationToEnd(); - await _persist(); - widget.onLog('AI response received', '${_flavorLabel(flavor)} - ${widget.model}', Icons.forum_outlined, _mint); - } on Object catch (error) { - if (!mounted) return; - final message = error.toString().replaceFirst('Exception: ', ''); - setState(() => _error = message); - widget.onLog('AI request error', _compact(message, limit: 140), Icons.error_outline, _rose); - } finally { - if (mounted) { - setState(() => _sending = false); - } - } - } - - Future _toggleVoiceInput() async { - if (!_voiceAvailable) { - final available = await _voiceService.initialize(); - if (!mounted) return; - setState(() => _voiceAvailable = available); - if (!available) { - _showMessage(_voiceService.lastError.isEmpty ? 'Voice input is not available' : _voiceService.lastError); - return; - } - } - - if (_voiceService.isListening) { - final transcript = await _voiceService.stopListening(); - if (!mounted) return; - if (transcript.trim().isNotEmpty) { - _promptController.text = transcript.trim(); - _promptController.selection = TextSelection.collapsed(offset: _promptController.text.length); - } - setState(() => _voiceState = _voiceService.currentState); - return; - } - - try { - await _voiceService.startListening(); - if (mounted) setState(() => _voiceState = VoiceState.listening); - } on Object catch (error) { - if (!mounted) return; - _showMessage(_compact(error.toString(), limit: 120)); - setState(() => _voiceState = VoiceState.error); - } - } - - Future _callProvider( - List<_ChatTurn> history, { - required String systemPrompt, - int maxTokens = 1024, - }) async { - if (widget.baseUrl.trim().isEmpty) { - throw Exception('Provider is not configured: Base URL is empty.'); - } - if (widget.apiKey.trim().isEmpty) { - throw Exception('Provider is not configured: API key is empty.'); - } - - final flavor = _detectApiFlavor(widget.baseUrl, widget.model); - final client = HttpClient()..connectionTimeout = const Duration(seconds: 12); - try { - final request = await client - .postUrl(flavor == _ApiFlavor.anthropic - ? _anthropicMessagesUri(widget.baseUrl) - : _openAiChatUri(widget.baseUrl)) - .timeout(const Duration(seconds: 12)); - request.headers.contentType = ContentType.json; - if (flavor == _ApiFlavor.anthropic) { - request.headers.set('anthropic-version', '2023-06-01'); - } - if (flavor == _ApiFlavor.anthropic) { - request.headers.set('x-api-key', widget.apiKey); - } - request.headers.set(HttpHeaders.authorizationHeader, 'Bearer ${widget.apiKey}'); - request.write(jsonEncode(_requestBody( - flavor, - history, - systemPrompt: systemPrompt, - maxTokens: maxTokens, - ))); - - final response = await request.close().timeout(const Duration(seconds: 60)); - final body = await utf8.decodeStream(response); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw Exception('Provider HTTP ${response.statusCode}: ${_compact(body)}'); - } - final answer = _extractAssistantText(body); - if (answer.trim().isEmpty) { - throw Exception('Provider returned an empty response.'); - } - return answer; - } on SocketException catch (error) { - throw Exception('Provider network error: ${_friendlySocketError(error)}'); - } on TimeoutException { - throw Exception('Provider timed out while waiting for a model response.'); - } finally { - client.close(force: true); - } - } - - Future _runAgentWithTrace() async { - if (_agentRunning) { - _showMessage('Agent is still running'); - return; - } - final prompt = _promptController.text.trim(); - if (prompt.isEmpty) { - _showMessage('Describe what the agent should build or test'); - return; - } - final active = _activeSession; - if (active == null) { - _showMessage('Chat is still loading'); - return; - } - - final toolName = _agentToolNameForPrompt(prompt); - final now = DateTime.now(); - final pending = active.copyWith( - title: active.title == 'New chat' ? _chatTitle(prompt) : active.title, - updatedAt: now, - turns: [ - ...active.turns, - _ChatTurn(role: 'user', content: prompt, time: now), - ], - ); - - setState(() { - _agentRunning = true; - _error = null; - _promptController.clear(); - _agentTrace - ..clear() - ..addAll(_agentRunTraceTemplate(prompt)); - _storeSession(pending); - }); - _scrollConversationToEnd(); - await _persist(); - - String? failure; - String? modelAnswer; - String? generatedPath; - try { - await _completeAgentRunStep(0); - await _completeAgentRunStep(1); - _setAgentRunStep(2, _AgentStepState.running, detail: 'Calling ${_flavorLabel(_detectApiFlavor(widget.baseUrl, widget.model))} provider...'); - modelAnswer = await _callProvider( - pending.turns, - systemPrompt: _agentSystemPrompt(toolName), - maxTokens: 4096, - ); - _setAgentRunStep(2, _AgentStepState.done, detail: 'Model response received from ${_flavorLabel(_detectApiFlavor(widget.baseUrl, widget.model))}.'); - generatedPath = await _persistAgentGeneratedArtifact(toolName, modelAnswer); - await _completeAgentRunStep( - 3, - detail: generatedPath == null ? 'No file artifact required for this tool.' : 'Saved generated artifact to $generatedPath', - ); - await _completeAgentRunStep(4); - } on Object catch (error) { - failure = error.toString(); - _failAgentRunStep(_compact(failure, limit: 140)); - } - - if (!mounted) return; - final current = _sessions.firstWhere((session) => session.id == pending.id, orElse: () => pending); - final assistantText = failure == null - ? _agentProviderCompletionMessage(toolName, modelAnswer ?? '', generatedPath) - : 'Agent run failed while using `$toolName`.\n\n${_compact(failure, limit: 300)}'; - final next = current.copyWith( - updatedAt: DateTime.now(), - turns: [ - ...current.turns, - _ChatTurn(role: 'assistant', content: assistantText, time: DateTime.now()), - ], - ); - - setState(() { - _agentRunning = false; - _storeSession(next); - if (failure != null) _error = failure; - }); - _scrollConversationToEnd(); - await _persist(); - - if (failure == null) { - widget.onLog('Agent run completed', toolName, Icons.psychology_alt_outlined, _violet); - await widget.onAgentPrompt(prompt); - } else { - widget.onLog('Agent run failed', _compact(failure, limit: 140), Icons.error_outline, _rose); - } - } - - void _setAgentRunStep(int index, _AgentStepState state, {String? detail}) { - if (!mounted || index < 0 || index >= _agentTrace.length) return; - setState(() { - _agentTrace[index] = _agentTrace[index].copyWith( - state: state, - detail: detail ?? _agentTrace[index].detail, - finishedAt: state == _AgentStepState.done || state == _AgentStepState.failed ? DateTime.now() : null, - ); - }); - _scrollConversationToEnd(); - } - - Future _completeAgentRunStep(int index, {String? detail}) async { - if (!mounted || index < 0 || index >= _agentTrace.length) return; - _setAgentRunStep(index, _AgentStepState.running); - await Future.delayed(const Duration(milliseconds: 240)); - if (!mounted || index < 0 || index >= _agentTrace.length) return; - _setAgentRunStep(index, _AgentStepState.done, detail: detail); - } - - void _failAgentRunStep(String detail) { - if (!mounted || _agentTrace.isEmpty) return; - final runningIndex = _agentTrace.indexWhere((step) => step.state == _AgentStepState.running); - final index = runningIndex == -1 ? _agentTrace.indexWhere((step) => step.state == _AgentStepState.queued) : runningIndex; - if (index == -1) return; - setState(() { - _agentTrace[index] = _agentTrace[index].copyWith( - detail: detail, - state: _AgentStepState.failed, - finishedAt: DateTime.now(), - ); - }); - _scrollConversationToEnd(); - } - - String _agentSystemPrompt(String toolName) { - return [ - 'You are MobileCode Android Mini Agent.', - 'You are running inside a mobile app, so be honest about what has actually happened.', - 'The selected tool is `$toolName`.', - 'You must generate original code from the user request. Do not use or mention a built-in demo fallback.', - 'Do not claim a file was written, previewed, pushed, or executed unless the app reports that after your response.', - if (toolName.startsWith('mobile_coding.generate_')) - 'For web game requests, return one complete self-contained HTML document inside a single ```html fenced block. It must be mobile-first, playable by touch, and not depend on network assets.', - if (toolName == 'mobile_coding.build_diary_demo') - 'For the diary app request, return the minimal implementable UI/data model plan and code snippets needed for a local APK diary experience.', - if (toolName == 'mobile_tools.termux_probe') - 'For Termux/root requests, explain the Android permission/runtime boundary and list concrete checks without pretending shell execution happened.', - if (toolName == 'github.connectivity_test') - 'For GitHub requests, describe the exact token/repo API checks and failure modes without inventing successful connectivity.', - 'Keep the answer concise but include enough code for the next local tool step.', - ].join('\n'); - } - - String _agentProviderCompletionMessage(String toolName, String modelAnswer, String? generatedPath) { - return [ - 'Agent run completed via provider: `$toolName`', - if (generatedPath != null) 'Saved generated artifact: `$generatedPath`', - '', - modelAnswer.trim(), - ].join('\n'); - } - - Future _persistAgentGeneratedArtifact(String toolName, String modelAnswer) async { - final isWebArtifact = toolName == 'mobile_coding.generate_snake_preview' || - toolName == 'mobile_coding.generate_2048_preview' || - toolName == 'mobile_coding.generate_web_preview'; - final directory = await getApplicationDocumentsDirectory(); - final slug = switch (toolName) { - 'mobile_coding.generate_snake_preview' => 'agent_snake', - 'mobile_coding.generate_2048_preview' => 'agent_2048_from_model', - 'mobile_coding.build_diary_demo' => 'agent_diary', - _ => 'agent_run', - }; - final projectDirectory = Directory('${directory.path}/mobilecode_projects/$slug'); - await projectDirectory.create(recursive: true); - - if (isWebArtifact) { - final html = _extractHtmlDocument(modelAnswer); - if (html == null) { - throw Exception('Provider responded, but did not return a complete ```html block. No game file was written.'); - } - final tempFile = File('${projectDirectory.path}/index.html.tmp'); - final file = File('${projectDirectory.path}/index.html'); - await tempFile.writeAsString(html, flush: true); - if (await file.exists()) { - await file.delete(); - } - await tempFile.rename(file.path); - return file.path; - } - - if (toolName.startsWith('mobile_coding.')) { - final file = File('${projectDirectory.path}/agent_response.md'); - await file.writeAsString(modelAnswer, flush: true); - return file.path; - } - return null; - } - - String? _extractHtmlDocument(String modelAnswer) { - final fenced = RegExp(r'```(?:html|HTML)\s*([\s\S]*?)```').firstMatch(modelAnswer)?.group(1)?.trim(); - if (fenced != null && fenced.contains(' _runAgent() async { - await _runAgentWithTrace(); - } - - - Map _requestBody( - _ApiFlavor flavor, - List<_ChatTurn> turns, { - required String systemPrompt, - int maxTokens = 1024, - }) { - final model = widget.model.isEmpty - ? (flavor == _ApiFlavor.anthropic ? _defaultModel : 'gpt-4o-mini') - : widget.model; - final messages = _providerMessages(turns); - if (flavor == _ApiFlavor.anthropic) { - return { - 'model': model, - 'system': systemPrompt, - 'max_tokens': maxTokens, - 'messages': messages, - }; - } - return { - 'model': model, - 'messages': [ - {'role': 'system', 'content': systemPrompt}, - ...messages, - ], - 'stream': false, - }; - } - - List> _providerMessages(List<_ChatTurn> turns) { - final usable = turns - .where((turn) => (turn.role == 'user' || turn.role == 'assistant') && turn.content.trim().isNotEmpty) - .toList(); - final recent = usable.length > 16 ? usable.sublist(usable.length - 16) : usable; - final messages = >[]; - - for (final turn in recent) { - var role = turn.role; - if (messages.isEmpty && role == 'assistant') role = 'user'; - if (messages.isNotEmpty && messages.last['role'] == role) { - messages.last['content'] = '${messages.last['content']}\n\n${turn.content.trim()}'; - } else { - messages.add({'role': role, 'content': turn.content.trim()}); - } - } - - return messages.isEmpty - ? [ - {'role': 'user', 'content': 'Hello'}, - ] - : messages; - } - - String _extractAssistantText(String body) { - try { - final decoded = jsonDecode(body); - if (decoded is Map) { - final choices = decoded['choices']; - if (choices is List && choices.isNotEmpty) { - final first = choices.first; - if (first is Map) { - final message = first['message']; - if (message is Map) { - final content = message['content']; - if (content is String && content.trim().isNotEmpty) return content.trim(); - } - final text = first['text']; - if (text is String && text.trim().isNotEmpty) return text.trim(); - } - } - final content = decoded['content']; - if (content is List && content.isNotEmpty) { - final parts = []; - for (final item in content) { - if (item is Map) { - final text = item['text']; - if (text is String && text.trim().isNotEmpty) parts.add(text.trim()); - } - } - if (parts.isNotEmpty) return parts.join('\n\n'); - } - } - } catch (_) { - // Show raw body when the provider returns a non-standard response. - } - return _compact(body); - } - - String _chatTitle(String prompt) { - final compact = _compact(prompt, limit: 36); - return compact.isEmpty ? 'New chat' : compact; - } - - void _showMessage(String message) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); - } - - void _scrollConversationToEnd() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!_chatScrollController.hasClients) return; - _chatScrollController.animateTo( - _chatScrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOut, - ); - }); - } - - Widget _buildChatHeader(_ChatSession? active) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: _Panel( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), - child: Row( - children: [ - const Icon(Icons.memory_outlined, color: _blue, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - '${active?.turns.length ?? 0} saved turns · context sent with each request', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 12, height: 1.3), - ), - ), - const SizedBox(width: 8), - IconButton.filledTonal( - tooltip: 'New chat', - visualDensity: VisualDensity.compact, - onPressed: _sending || _agentRunning ? null : _createSession, - icon: const Icon(Icons.add_comment_outlined, size: 18), - ), - ], - ), - ), - ); - } - - Widget _buildConversationBody(_ChatSession? active) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Panel( - padding: const EdgeInsets.all(12), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 220), - child: active == null || active.turns.isEmpty - ? const _EmptyChatState() - : Column( - children: [ - for (var index = 0; index < active.turns.length; index++) ...[ - _ChatBubble(turn: active.turns[index]), - if (index != active.turns.length - 1) const SizedBox(height: 10), - ], - ], - ), - ), - ), - if (_agentTrace.isNotEmpty) ...[ - const SizedBox(height: 12), - _AgentTracePanel( - title: _agentRunning ? 'Agent is writing code' : 'Last agent process', - steps: _agentTrace, - ), - ], - ], - ); - } - - Widget _buildComposer(_ApiFlavor flavor) { - final status = _voiceHelperText(flavor, widget.model, _voiceState); - return Container( - decoration: const BoxDecoration( - color: _bg, - border: Border(top: BorderSide(color: _line)), - ), - padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), - child: _Panel( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _ChatModeStrip(onPrompt: (prompt, {runAgent = false}) => setPromptFromShell(prompt, runAgent: runAgent)), - const SizedBox(height: 6), - Text( - status, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 11), - ), - const SizedBox(height: 6), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: _promptController, - minLines: 1, - maxLines: 3, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - labelText: _voiceService.isListening ? 'Listening...' : 'Message', - hintText: 'Ask MobileCode, or tap a task shortcut.', - alignLabelWithHint: true, - ), - ), - ), - const SizedBox(width: 8), - _VoiceInputButton( - enabled: !_sending && !_agentRunning, - available: _voiceAvailable, - state: _voiceState, - onTap: _toggleVoiceInput, - ), - const SizedBox(width: 6), - IconButton.filled( - tooltip: _sending ? 'Sending' : 'Send chat', - onPressed: _sending || _agentRunning ? null : _send, - icon: _sending - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.send_outlined), - ), - const SizedBox(width: 6), - IconButton.outlined( - tooltip: _agentRunning ? 'Agent running' : 'Run agent', - onPressed: _sending || _agentRunning ? null : _runAgent, - icon: _agentRunning - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.psychology_alt_outlined), - ), - ], - ), - if (_error != null) ...[ - const SizedBox(height: 10), - Text( - _error!, - style: const TextStyle(color: _rose, fontSize: 12, height: 1.35), - ), - ], - ], - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final flavor = _detectApiFlavor(widget.baseUrl, widget.model); - final active = _activeSession; - if (_loading) { - return const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator())); - } - if (widget.embedded) { - return Column( - children: [ - Expanded( - child: SingleChildScrollView( - controller: _chatScrollController, - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.fromLTRB(0, 2, 0, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildChatHeader(active), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _buildConversationBody(active), - ), - ], - ), - ), - ), - _buildComposer(flavor), - ], - ); - } - return _SheetScaffold( - icon: Icons.forum_outlined, - title: 'AI Chat', - subtitle: _chatEndpointLabel(widget.baseUrl, flavor), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildChatHeader(active), - const SizedBox(height: 12), - _buildConversationBody(active), - const SizedBox(height: 12), - _buildComposer(flavor), - ], - ), - ); - } -} - -class _ChatSessionChip extends StatelessWidget { - const _ChatSessionChip({ - required this.session, - required this.selected, - required this.onTap, - }); - - final _ChatSession session; - final bool selected; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final color = selected ? _mint : _line; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Container( - width: 150, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: selected ? _mint.withOpacity(0.12) : _panelSoft, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(selected ? 0.70 : 1)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - session.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: selected ? _text : _muted, fontWeight: FontWeight.w800, fontSize: 12), - ), - const SizedBox(height: 4), - Text( - '${session.turns.length} turns', - style: TextStyle(color: selected ? _mint : _faint, fontSize: 11), - ), - ], - ), - ), - ); - } -} - -class _ChatBubble extends StatelessWidget { - const _ChatBubble({required this.turn}); - - final _ChatTurn turn; - - @override - Widget build(BuildContext context) { - final isUser = turn.role == 'user'; - final color = isUser ? _cyan : _mint; - return Align( - alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.76), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isUser ? _blue.withOpacity(0.11) : _mint.withOpacity(0.10), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.30)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - isUser ? 'You' : 'MobileCode', - style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w900), - ), - const SizedBox(height: 6), - SelectableText( - turn.content, - style: const TextStyle(color: _text, height: 1.42, fontSize: 13), - ), - ], - ), - ), - ); - } -} - -class _EmptyChatState extends StatelessWidget { - const _EmptyChatState(); - - @override - Widget build(BuildContext context) { - return const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 26), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.forum_outlined, color: _faint, size: 36), - SizedBox(height: 10), - Text('No messages yet', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), - SizedBox(height: 4), - Text( - 'Use Send Chat for normal memory, or Run Agent to show the live coding/tool process.', - textAlign: TextAlign.center, - style: TextStyle(color: _muted, fontSize: 12), - ), - ], - ), - ), - ); - } -} - -class _ChatModeStrip extends StatelessWidget { - const _ChatModeStrip({required this.onPrompt}); - - final void Function(String prompt, {bool runAgent}) onPrompt; - - @override - Widget build(BuildContext context) { - const prompts = [ - _PromptShortcutData( - label: '贪吃蛇', - icon: Icons.videogame_asset_outlined, - prompt: '帮我在手机端创建一个可运行的贪吃蛇网页小游戏,生成 index.html、展示写代码过程,并用 WebView 预览。', - color: _mint, - ), - _PromptShortcutData( - label: '2048', - icon: Icons.grid_4x4_outlined, - prompt: '帮我创建一个 2048 网页小游戏,保存为 index.html,并打开本地 WebView 预览。', - color: _cyan, - ), - _PromptShortcutData( - label: 'GitHub', - icon: Icons.hub_outlined, - prompt: '测试 GitHub token 与 Harzva/mobilecode 仓库是否联通,并说明失败原因。', - color: _violet, - ), - ]; - - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (final item in prompts) ...[ - _PromptShortcutChip(item: item, onTap: () => onPrompt(item.prompt, runAgent: true)), - const SizedBox(width: 8), - ], - ], - ), - ); - } -} - -class _PromptLaunchPanel extends StatelessWidget { - const _PromptLaunchPanel({required this.onPrompt}); - - final Future Function(String prompt, {bool runAgent}) onPrompt; - - @override - Widget build(BuildContext context) { - return _Panel( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(Icons.bolt_outlined, color: _mint, size: 19), - SizedBox(width: 8), - Expanded( - child: Text( - 'One-tap coding prompts', - style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15), - ), - ), - ], - ), - const SizedBox(height: 8), - const Text( - '这些按钮会回到聊天页并填入任务,让 agent 以“思考 -> 工具调用 -> 写文件 -> 预览”的方式执行。', - style: TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _ActionChipButton( - icon: Icons.videogame_asset_outlined, - label: '贪吃蛇游戏', - color: _mint, - onTap: () => onPrompt('帮我在手机端创建一个可运行的贪吃蛇网页小游戏,生成 index.html、展示写代码过程,并用 WebView 预览。', runAgent: true), - ), - _ActionChipButton( - icon: Icons.grid_4x4_outlined, - label: '2048 Demo', - color: _cyan, - onTap: () => onPrompt('帮我创建一个 2048 网页小游戏,保存为 index.html,并打开本地 WebView 预览。', runAgent: true), - ), - _ActionChipButton( - icon: Icons.edit_note_outlined, - label: '日记 App', - color: _amber, - onTap: () => onPrompt('帮我做一个最小日记 App:本地保存、列表、编辑、删除和空状态都要能在 APK 里体验。'), - ), - ], - ), - ], - ), - ); - } -} - -class _PromptShortcutData { - const _PromptShortcutData({ - required this.label, - required this.icon, - required this.prompt, - required this.color, - }); - - final String label; - final IconData icon; - final String prompt; - final Color color; -} - -class _PromptShortcutChip extends StatelessWidget { - const _PromptShortcutChip({ - required this.item, - required this.onTap, - }); - - final _PromptShortcutData item; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(999), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: item.color.withOpacity(0.10), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: item.color.withOpacity(0.34)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(item.icon, color: item.color, size: 16), - const SizedBox(width: 6), - Text(item.label, style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w800)), - ], - ), - ), - ); - } -} - -class _ActionChipButton extends StatelessWidget { - const _ActionChipButton({ - required this.icon, - required this.label, - required this.color, - required this.onTap, - }); - - final IconData icon; - final String label; - final Color color; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return ActionChip( - avatar: Icon(icon, color: color, size: 17), - label: Text(label), - side: BorderSide(color: color.withOpacity(0.35)), - backgroundColor: color.withOpacity(0.10), - labelStyle: const TextStyle(color: _text, fontWeight: FontWeight.w800), - onPressed: onTap, - ); - } -} - -class _VoiceInputButton extends StatelessWidget { - const _VoiceInputButton({ - required this.enabled, - required this.available, - required this.state, - required this.onTap, - }); - - final bool enabled; - final bool available; - final VoiceState state; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final listening = state == VoiceState.listening; - final color = listening - ? _rose - : available - ? _mint - : _amber; - return Tooltip( - message: listening ? 'Stop voice input' : 'Voice input', - child: SizedBox( - width: 54, - height: 54, - child: FilledButton( - style: FilledButton.styleFrom( - padding: EdgeInsets.zero, - backgroundColor: color.withOpacity(listening ? 0.92 : 0.16), - foregroundColor: listening ? _bg : color, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), - ), - onPressed: enabled ? onTap : null, - child: Icon(listening ? Icons.stop_rounded : Icons.mic_none_outlined), - ), - ), - ); - } -} - -String _voiceHelperText(_ApiFlavor flavor, String model, VoiceState state) { - final modelLabel = model.isEmpty ? 'default model' : model; - final voiceLabel = switch (state) { - VoiceState.listening => 'voice listening', - VoiceState.processing => 'voice processing', - VoiceState.done => 'voice ready', - VoiceState.error => 'voice unavailable', - VoiceState.idle => 'voice ready', - }; - return '${_flavorLabel(flavor)} - $modelLabel - $voiceLabel'; -} - -class _DraftSheet extends StatefulWidget { - const _DraftSheet({required this.onCreate}); - - final void Function(String name, String language) onCreate; - - @override - State<_DraftSheet> createState() => _DraftSheetState(); -} - -class _DraftSheetState extends State<_DraftSheet> { - final _name = TextEditingController(text: 'lib/screens/new_feature.dart'); - final _language = TextEditingController(text: 'Dart'); - final _content = TextEditingController(); - - @override - void dispose() { - _name.dispose(); - _language.dispose(); - _content.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: Icons.note_add_outlined, - title: 'New File Draft', - subtitle: 'Create a local draft from the editor controller surface.', - child: Column( - children: [ - TextField( - controller: _name, - decoration: const InputDecoration(labelText: 'File path', prefixIcon: Icon(Icons.description_outlined)), - ), - const SizedBox(height: 10), - TextField( - controller: _language, - decoration: const InputDecoration(labelText: 'Language', prefixIcon: Icon(Icons.code_outlined)), - ), - const SizedBox(height: 10), - TextField( - controller: _content, - minLines: 5, - maxLines: 8, - decoration: const InputDecoration(labelText: 'Initial content', alignLabelWithHint: true), - ), - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () { - final name = _name.text.trim().isEmpty ? 'untitled.dart' : _name.text.trim(); - final language = _language.text.trim().isEmpty ? 'Text' : _language.text.trim(); - widget.onCreate(name, language); - Navigator.pop(context); - }, - icon: const Icon(Icons.add_outlined), - label: const Text('Create draft'), - ), - ), - ], - ), - ); - } -} - -class _SnippetSheet extends StatefulWidget { - const _SnippetSheet({required this.onCreate}); - - final void Function(String title, String language) onCreate; - - @override - State<_SnippetSheet> createState() => _SnippetSheetState(); -} - -class _SnippetSheetState extends State<_SnippetSheet> { - final _title = TextEditingController(text: 'API client helper'); - final _language = TextEditingController(text: 'Dart'); - final _code = TextEditingController(); - - @override - void dispose() { - _title.dispose(); - _language.dispose(); - _code.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: Icons.data_object_outlined, - title: 'Snippet Capture', - subtitle: 'Save reusable code into the snippet surface.', - child: Column( - children: [ - TextField( - controller: _title, - decoration: const InputDecoration(labelText: 'Title', prefixIcon: Icon(Icons.label_outline)), - ), - const SizedBox(height: 10), - TextField( - controller: _language, - decoration: const InputDecoration(labelText: 'Language', prefixIcon: Icon(Icons.code_outlined)), - ), - const SizedBox(height: 10), - TextField( - controller: _code, - minLines: 5, - maxLines: 8, - decoration: const InputDecoration(labelText: 'Code', alignLabelWithHint: true), - ), - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () { - final title = _title.text.trim().isEmpty ? 'Untitled snippet' : _title.text.trim(); - final language = _language.text.trim().isEmpty ? 'Text' : _language.text.trim(); - widget.onCreate(title, language); - Navigator.pop(context); - }, - icon: const Icon(Icons.save_outlined), - label: const Text('Save snippet'), - ), - ), - ], - ), - ); - } -} - -class _DeepDiveConsoleSheet extends StatefulWidget { - const _DeepDiveConsoleSheet({ - required this.runtimeManager, - required this.defaultProjectPath, - required this.onLog, - required this.onStartInChat, - }); - - final RuntimeManager runtimeManager; - final String defaultProjectPath; - final void Function(String title, String detail, IconData icon, Color color) onLog; - final void Function(String prompt) onStartInChat; - - @override - State<_DeepDiveConsoleSheet> createState() => _DeepDiveConsoleSheetState(); -} - -class _DeepDiveConsoleSheetState extends State<_DeepDiveConsoleSheet> { - final _promptController = TextEditingController(); - final _projectPath = TextEditingController(); - final List _lines = ['No deep dive action has run yet.']; - bool _running = false; - bool _cancelling = false; - List _recentTasks = const []; - +class _TraceDetailItem extends StatelessWidget { + const _TraceDetailItem({required this.label, required this.value}); + + final String label; + final String value; + @override - void initState() { - super.initState(); - _promptController.text = - 'Inspect the selected project, run runtime preflight and validation, identify the next highest-value fix, implement it, and explain verification.'; - _projectPath.text = widget.defaultProjectPath; + Widget build(BuildContext context) { + return _Panel( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900)), + const SizedBox(height: 6), + SelectableText( + value, + style: const TextStyle(color: _muted, fontSize: 12, height: 1.38), + ), + ], + ), + ); } - - @override - void dispose() { - _promptController.dispose(); - _projectPath.dispose(); - super.dispose(); - } - - void _startInChat() { - final taskPrompt = _promptController.text.trim(); - if (taskPrompt.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Enter a task prompt first.')), - ); - return; - } - final projectPath = _projectPath.text.trim(); - final prompt = [ - 'Deep Dive task:', - taskPrompt, - if (projectPath.isNotEmpty) 'Project path / cwd: $projectPath', - 'Use RuntimeManager actions for preflight, validation, build, and recovery before making risky changes.', - ].join('\n'); - Navigator.pop(context); - widget.onStartInChat(prompt); +} + +class _StatusPill extends StatelessWidget { + const _StatusPill({required this.status, required this.color}); + + final _CapabilityStatus status; + final Color color; + + @override + Widget build(BuildContext context) { + return _Pill(label: _statusLabel(status), icon: _statusIcon(status), color: color); } - - Future _validateProject() async { - final projectPath = _projectPath.text.trim(); - if (projectPath.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Project path is required.')), - ); - return; - } - setState(() { - _running = true; - _lines.insert(0, 'Validating project...'); - }); - try { - final result = await widget.runtimeManager.validateProject( - projectPath: projectPath, - ); - if (!mounted) return; - final stepLines = result.steps.map((s) => '${s.success ? 'OK' : 'FAILED'} ${s.action.name}: ${s.summary}'); - setState(() { - _lines.insert( - 0, - [ - result.success ? 'VALIDATED: ${result.summary}' : 'VALIDATION STOPPED: ${result.summary}', - ...stepLines, - if (result.recoveryHint != null) 'Recovery: ${result.recoveryHint!}', - ].join('\n'), - ); - }); - widget.onLog( - result.success ? 'Deep Dive validate completed' : 'Deep Dive validate stopped', - result.summary, - result.success ? Icons.verified_outlined : Icons.error_outline, - result.success ? _mint : _rose, - ); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'VALIDATION ERROR: $message')); - widget.onLog('Deep Dive validate error', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _running = false); - } - } - - Future _recoverHistory() async { - setState(() { - _running = true; - _lines.insert(0, 'Loading task history...'); - }); - try { - final tasks = await widget.runtimeManager.taskHistory(limit: 5); - if (!mounted) return; - setState(() { - _recentTasks = tasks; - _lines.insert( - 0, - tasks.isEmpty - ? 'No recoverable runtime task history.' - : tasks.map(_taskSummary).join('\n\n'), - ); - }); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'Task history failed: $message')); - } finally { - if (mounted) setState(() => _running = false); - } - } - - Future _cancelTask([String? taskId]) async { - String? id = taskId; - if (id == null) { - for (final task in _recentTasks) { - if (task.running) { - id = task.taskId; - break; - } - } - } - setState(() { - _cancelling = true; - _lines.insert(0, id == null ? 'Stopping active runtime task...' : 'Stopping runtime task $id...'); - }); - try { - if (id == null) { - await widget.runtimeManager.stopCurrentTask(); - } else { - await widget.runtimeManager.stopTask(id); - } - final task = await widget.runtimeManager.currentTaskSnapshot(); - if (!mounted) return; - setState(() { - if (task != null) { - _recentTasks = [task, ..._recentTasks.where((item) => item.taskId != task.taskId)].take(5).toList(); - } - _lines.insert(0, task == null ? 'STOP REQUESTED: no recoverable runtime task.' : 'STOP REQUESTED: ${_taskSummary(task)}'); - }); - widget.onLog( - 'Deep Dive stop requested', - task == null ? 'No recoverable runtime task after stop request.' : _taskSummary(task), - Icons.stop_circle_outlined, - _amber, - ); - } on Object catch (error) { - if (!mounted) return; - final message = _compact(error.toString(), limit: 180); - setState(() => _lines.insert(0, 'STOP ERROR: $message')); - widget.onLog('Deep Dive stop failed', message, Icons.error_outline, _rose); - } finally { - if (mounted) setState(() => _cancelling = false); - } - } - - String _taskSummary(RuntimeTaskSnapshot task) { - final logs = _recentLogLines(task.logs, limit: 4).join('\n'); - final failure = task.failureKind == RuntimeTaskFailureKind.none ? '' : ' (${task.failureKind.name})'; - return 'Task ${task.taskId} is ${task.status.name}$failure: ${task.command}${logs.isEmpty ? '' : '\n$logs'}'; - } - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: Icons.psychology_alt_outlined, - title: 'Deep Dive', - subtitle: 'Launch a multi-step coding session using the existing Chat Agent and RuntimeManager loop.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: _promptController, - maxLines: 3, - decoration: const InputDecoration( - labelText: 'Task prompt', - hintText: 'Describe the coding task for the agent...', - prefixIcon: Icon(Icons.edit_note_outlined), - ), - ), - const SizedBox(height: 8), - TextField( - controller: _projectPath, - decoration: const InputDecoration(labelText: 'Project path / cwd', prefixIcon: Icon(Icons.folder_outlined)), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _RuntimeActionButton( - icon: Icons.home_work_outlined, - label: 'Default path', - disabled: _running, - onTap: () { - _projectPath.text = widget.defaultProjectPath; - setState(() => _lines.insert(0, 'Project path reset to default.')); - }, - ), - ], - ), - const SizedBox(height: 12), - _Panel( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - const Icon(Icons.info_outline, color: _faint, size: 16), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Uses the existing Chat Agent and RuntimeManager. No background task queue.', - style: const TextStyle(color: _faint, fontSize: 11, height: 1.3), - ), - ), - ], - ), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _RuntimeActionButton( - icon: Icons.chat_outlined, - label: 'Start in Chat Agent', - disabled: _running, - onTap: _startInChat, - ), - _RuntimeActionButton( - icon: Icons.verified_outlined, - label: 'Validate project', - disabled: _running, - onTap: _validateProject, - ), - _RuntimeActionButton( - icon: Icons.stop_circle_outlined, - label: 'Stop runtime task', - disabled: _cancelling, - onTap: _cancelTask, - ), - _RuntimeActionButton( - icon: Icons.history_outlined, - label: 'Recover history', - disabled: _running, - onTap: _recoverHistory, - ), - ], - ), - const SizedBox(height: 12), - if (_recentTasks.isNotEmpty) ...[ - for (final task in _recentTasks.take(3)) ...[ - _TaskSnapshotPanel( - task: task, - onStop: task.canCancel ? () => _cancelTask(task.taskId) : null, - ), - const SizedBox(height: 8), - ], - ], - _Panel( - child: Text( - _lines.take(8).join('\n\n'), - style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - ), - ], - ), - ); - } -} - -class _CapabilitySheet extends StatelessWidget { - const _CapabilitySheet({ - required this.capability, - required this.onRun, - required this.onCopy, - }); - - final _Capability capability; - final VoidCallback onRun; - final VoidCallback onCopy; - - @override - Widget build(BuildContext context) { - return _SheetScaffold( - icon: capability.icon, - title: capability.title, - subtitle: capability.surface, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(capability.subtitle, style: const TextStyle(color: _muted, height: 1.4)), - const SizedBox(height: 16), - const Text('Backend services', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - for (final service in capability.services) _MiniChip(label: service, color: _cyan), - ], - ), - const SizedBox(height: 16), - const Text('Frontend actions', style: TextStyle(color: _text, fontWeight: FontWeight.w800)), - const SizedBox(height: 8), - for (final action in capability.actions) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.arrow_right_alt_outlined, color: _mint, size: 18), - const SizedBox(width: 8), - Expanded(child: Text(action, style: const TextStyle(color: _muted))), - ], - ), - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: onRun, - icon: const Icon(Icons.play_arrow_outlined), - label: const Text('Open module'), - ), - ), - const SizedBox(width: 10), - IconButton.outlined( - tooltip: 'Copy service list', - onPressed: onCopy, - icon: const Icon(Icons.copy_outlined), - ), - ], - ), - ], - ), - ); - } -} - -class _SheetScaffold extends StatelessWidget { - const _SheetScaffold({ - required this.icon, - required this.title, - required this.subtitle, - required this.child, - }); - - final IconData icon; - final String title; - final String subtitle; - final Widget child; - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: EdgeInsets.only( - left: 18, - right: 18, - top: 16, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 44, - height: 4, - decoration: BoxDecoration( - color: _line, - borderRadius: BorderRadius.circular(99), - ), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Icon(icon, color: _mint), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(color: _text, fontSize: 20, fontWeight: FontWeight.w800)), - const SizedBox(height: 2), - Text( - subtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 12), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - child, - ], - ), - ); - } -} - -class _Panel extends StatelessWidget { - const _Panel({ - required this.child, - this.padding = const EdgeInsets.all(16), - }); - - final Widget child; - final EdgeInsetsGeometry padding; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: padding, - decoration: BoxDecoration( - color: _panel, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _line), - boxShadow: const [ - BoxShadow( - color: Color(0x0A2555FF), - blurRadius: 16, - offset: Offset(0, 8), - ), - ], - ), - child: child, - ); - } -} - -class _Pill extends StatelessWidget { - const _Pill({ - required this.label, - required this.icon, - required this.color, - }); - - final String label; - final IconData icon; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), - decoration: BoxDecoration( - color: color.withOpacity(0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.45)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: color, size: 14), - const SizedBox(width: 5), - Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800)), - ], - ), - ); - } -} - -class _MiniChip extends StatelessWidget { - const _MiniChip({required this.label, required this.color}); - - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), - decoration: BoxDecoration( - color: color.withOpacity(0.10), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: color.withOpacity(0.26)), - ), - child: Text( - label, - style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w700), - ), - ); - } -} - -class _InlineStatus extends StatelessWidget { - const _InlineStatus({ - required this.icon, - required this.label, - required this.color, - }); - - final IconData icon; - final String label; - final Color color; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: color.withOpacity(0.09), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.24)), - ), - child: Row( - children: [ - Icon(icon, color: color, size: 16), - const SizedBox(width: 8), - Expanded( - child: Text( - label, - style: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w800), - ), - ), - ], - ), - ); - } -} - -class _MiniAgentToolRegistry extends StatelessWidget { - const _MiniAgentToolRegistry({required this.tools}); - - final List<_MiniAgentToolSpec> tools; - - @override - Widget build(BuildContext context) { - return _Panel( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.handyman_outlined, color: _cyan, size: 18), - const SizedBox(width: 8), - const Expanded( - child: Text( - 'Phone tool registry', - style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15), - ), - ), - _Pill(label: '${tools.length} tools', icon: Icons.schema_outlined, color: _cyan), - ], - ), - const SizedBox(height: 10), - LayoutBuilder( - builder: (context, constraints) { - final columns = constraints.maxWidth >= 620 ? 3 : 2; - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: tools.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: columns, - childAspectRatio: columns == 2 ? 1.12 : 1.34, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemBuilder: (context, index) => _MiniAgentToolTile(tool: tools[index]), - ); - }, - ), - ], - ), - ); - } -} - -class _MiniAgentToolTile extends StatelessWidget { - const _MiniAgentToolTile({required this.tool}); - - final _MiniAgentToolSpec tool; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: _panelSoft, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: tool.color.withOpacity(0.28)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(tool.icon, color: tool.color, size: 18), - const SizedBox(width: 7), - Expanded( - child: Text( - tool.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _text, fontSize: 12, fontWeight: FontWeight.w900), - ), - ), - ], - ), - const SizedBox(height: 6), - Expanded( - child: Text( - tool.description, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _muted, fontSize: 11, height: 1.25), - ), - ), - const SizedBox(height: 6), - Wrap( - spacing: 5, - runSpacing: 5, - children: [ - _MiniChip(label: tool.surface, color: tool.color), - _MiniChip(label: tool.risk, color: _faint), - ], - ), - ], - ), - ); - } -} - -class _MiniAgentConsole extends StatelessWidget { - const _MiniAgentConsole({ - required this.events, - required this.running, - required this.controller, - }); - - final List<_MiniAgentEvent> events; - final bool running; - final ScrollController controller; - - @override - Widget build(BuildContext context) { - final toolCalls = events.where((event) => event.kind == _MiniAgentEventKind.toolCall).length; - final observations = events.where((event) => event.kind == _MiniAgentEventKind.observation).length; - return _Panel( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(running ? Icons.sync_outlined : Icons.timeline_outlined, color: running ? _amber : _violet, size: 18), - const SizedBox(width: 8), - const Expanded( - child: Text( - 'Live code-writing transcript', - style: TextStyle(color: _text, fontWeight: FontWeight.w900, fontSize: 15), - ), - ), - _Pill( - label: running ? 'Live' : '${events.length} events', - icon: running ? Icons.bolt_outlined : Icons.receipt_long_outlined, - color: running ? _amber : _violet, - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _MiniChip(label: '$toolCalls tool calls', color: _cyan), - _MiniChip(label: '$observations results', color: _mint), - _MiniChip(label: 'workspace-scoped', color: _amber), - ], - ), - const SizedBox(height: 12), - SizedBox( - height: events.isEmpty ? 178 : 390, - child: events.isEmpty - ? const _MiniAgentEmptyConsole() - : ListView.separated( - controller: controller, - itemCount: events.length, - separatorBuilder: (context, index) => const SizedBox(height: 8), - itemBuilder: (context, index) => _MiniAgentEventCard(event: events[index]), - ), - ), - ], - ), - ); - } -} - -class _MiniAgentEmptyConsole extends StatelessWidget { - const _MiniAgentEmptyConsole(); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: _panelSoft, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _line), - ), - child: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.play_circle_outline, color: _faint, size: 34), - SizedBox(height: 10), - Text( - 'Run the mini agent to watch thinking, tool calls, file writes, diff, and preview setup appear here.', - textAlign: TextAlign.center, - style: TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - ], - ), - ); - } -} - -class _MiniAgentEventCard extends StatelessWidget { - const _MiniAgentEventCard({required this.event}); - - final _MiniAgentEvent event; - - @override - Widget build(BuildContext context) { - final color = _miniAgentEventColor(event); - final isCodeLike = event.kind == _MiniAgentEventKind.fileWrite || - event.kind == _MiniAgentEventKind.diff || - event.kind == _MiniAgentEventKind.toolCall; - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withOpacity(0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.30)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(_miniAgentEventIcon(event), color: color, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - event.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: _text, fontSize: 13, fontWeight: FontWeight.w900), - ), - ), - Text( - event.durationMs == null ? _clockLabel(event.time) : '${event.durationMs}ms', - style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800), - ), - ], - ), - if (event.toolName != null || event.path != null) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - if (event.toolName != null) _MiniChip(label: event.toolName!, color: color), - if (event.path != null) _MiniChip(label: _compact(event.path!, limit: 46), color: _faint), - ], - ), - ], - const SizedBox(height: 8), - SelectableText( - event.detail, - style: TextStyle( - color: event.ok ? _muted : _rose, - fontSize: 12, - height: 1.38, - fontFamily: isCodeLike ? 'monospace' : null, - ), - ), - ], - ), - ); - } -} - -class _AgentTracePanel extends StatelessWidget { - const _AgentTracePanel({ - required this.title, - required this.steps, - }); - - final String title; - final List<_AgentTraceStep> steps; - - @override - Widget build(BuildContext context) { - return _Panel( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.timeline_outlined, color: _violet, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: const TextStyle(color: _text, fontSize: 15, fontWeight: FontWeight.w900), - ), - ), - _Pill( - label: '${steps.where((step) => step.state == _AgentStepState.done).length}/${steps.length}', - icon: Icons.task_alt_outlined, - color: _violet, - ), - ], - ), - const SizedBox(height: 12), - for (var index = 0; index < steps.length; index++) ...[ - _AgentTraceRow(step: steps[index], isLast: index == steps.length - 1), - ], - ], - ), - ); - } -} - -class _AgentTraceRow extends StatelessWidget { - const _AgentTraceRow({ - required this.step, - required this.isLast, - }); - - final _AgentTraceStep step; - final bool isLast; - - @override - Widget build(BuildContext context) { - final color = _agentStepColor(step.state); - final icon = _agentStepStatusIcon(step.state); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: color.withOpacity(0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.42)), - ), - child: Icon(icon, color: color, size: 16), - ), - if (!isLast) - Container( - width: 1, - height: 42, - margin: const EdgeInsets.symmetric(vertical: 4), - color: _line, - ), - ], - ), - const SizedBox(width: 10), - Expanded( - child: Padding( - padding: EdgeInsets.only(bottom: isLast ? 0 : 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(step.icon, color: color, size: 15), - const SizedBox(width: 6), - Expanded( - child: Text( - step.title, - style: const TextStyle(color: _text, fontWeight: FontWeight.w800, fontSize: 13), - ), - ), - Text( - _agentStepLabel(step.state), - style: TextStyle(color: color, fontWeight: FontWeight.w800, fontSize: 11), - ), - ], - ), - const SizedBox(height: 4), - Text( - step.detail, - style: const TextStyle(color: _muted, fontSize: 12, height: 1.35), - ), - ], - ), - ), - ), - ], - ); - } -} - -class _StatusPill extends StatelessWidget { - const _StatusPill({required this.status, required this.color}); - - final _CapabilityStatus status; - final Color color; - - @override - Widget build(BuildContext context) { - return _Pill(label: _statusLabel(status), icon: _statusIcon(status), color: color); - } -} - -String _agentStepLabel(_AgentStepState state) { - return switch (state) { - _AgentStepState.queued => 'Queued', - _AgentStepState.running => 'Running', - _AgentStepState.done => 'Done', - _AgentStepState.failed => 'Failed', - }; -} - -IconData _agentStepStatusIcon(_AgentStepState state) { - return switch (state) { - _AgentStepState.queued => Icons.radio_button_unchecked_outlined, - _AgentStepState.running => Icons.sync_outlined, - _AgentStepState.done => Icons.check_circle_outline, - _AgentStepState.failed => Icons.error_outline, - }; -} - -Color _agentStepColor(_AgentStepState state) { - return switch (state) { - _AgentStepState.queued => _faint, - _AgentStepState.running => _amber, - _AgentStepState.done => _mint, - _AgentStepState.failed => _rose, - }; -} - -Color _miniAgentEventColor(_MiniAgentEvent event) { - if (!event.ok || event.kind == _MiniAgentEventKind.error) return _rose; - return switch (event.kind) { - _MiniAgentEventKind.system => _cyan, - _MiniAgentEventKind.thought => _violet, - _MiniAgentEventKind.toolCall => _amber, - _MiniAgentEventKind.observation => _mint, - _MiniAgentEventKind.fileWrite => _blue, - _MiniAgentEventKind.diff => _lime, - _MiniAgentEventKind.preview => _violet, - _MiniAgentEventKind.finalAnswer => _mint, - _MiniAgentEventKind.error => _rose, - }; -} - -IconData _miniAgentEventIcon(_MiniAgentEvent event) { - if (!event.ok || event.kind == _MiniAgentEventKind.error) return Icons.error_outline; - return switch (event.kind) { - _MiniAgentEventKind.system => Icons.memory_outlined, - _MiniAgentEventKind.thought => Icons.psychology_alt_outlined, - _MiniAgentEventKind.toolCall => Icons.play_circle_outline, - _MiniAgentEventKind.observation => Icons.check_circle_outline, - _MiniAgentEventKind.fileWrite => Icons.edit_note_outlined, - _MiniAgentEventKind.diff => Icons.compare_arrows_outlined, - _MiniAgentEventKind.preview => Icons.preview_outlined, - _MiniAgentEventKind.finalAnswer => Icons.task_alt_outlined, - _MiniAgentEventKind.error => Icons.error_outline, - }; -} - -String _flavorLabel(_ApiFlavor flavor) { - return switch (flavor) { - _ApiFlavor.anthropic => 'Anthropic', - _ApiFlavor.openAi => 'OpenAI', - }; -} - -String _statusLabel(_CapabilityStatus status) { - return switch (status) { - _CapabilityStatus.ready => 'Ready', - _CapabilityStatus.needsConfig => 'Config', - _CapabilityStatus.local => 'Local', - _CapabilityStatus.preview => 'Preview', - }; -} - -IconData _statusIcon(_CapabilityStatus status) { - return switch (status) { - _CapabilityStatus.ready => Icons.check_circle_outline, - _CapabilityStatus.needsConfig => Icons.tune_outlined, - _CapabilityStatus.local => Icons.offline_bolt_outlined, - _CapabilityStatus.preview => Icons.visibility_outlined, - }; -} - -Color _statusColor(_CapabilityStatus status) { - return switch (status) { - _CapabilityStatus.ready => _mint, - _CapabilityStatus.needsConfig => _amber, - _CapabilityStatus.local => _lime, - _CapabilityStatus.preview => _cyan, - }; -} - -String _focusTitle(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => 'Ready workspace', - _HomeTab.ai => 'Agent conversation', - _HomeTab.ship => 'Build and release', - _HomeTab.guard => 'Runtime checks', - _HomeTab.insight => 'Usage signal', - }; -} - -String _focusSubtitle(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => 'Provider, mini agent, GitHub, Termux, and local demo surfaces stay one tap away.', - _HomeTab.ai => 'Persistent chat plus visible tool traces for phone-first coding.', - _HomeTab.ship => 'GitHub Release, Android APK, iOS simulator build, Pages, and preview paths.', - _HomeTab.guard => 'Provider health, tool probes, install checks, and local storage checks.', - _HomeTab.insight => 'Recent activity, saved drafts, snippets, and build confidence signals.', - }; -} - -IconData _focusIcon(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => Icons.dashboard_customize_outlined, - _HomeTab.ai => Icons.psychology_alt_outlined, - _HomeTab.ship => Icons.rocket_launch_outlined, - _HomeTab.guard => Icons.health_and_safety_outlined, - _HomeTab.insight => Icons.insights_outlined, - }; -} - -Color _focusColor(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => _mint, - _HomeTab.ai => _violet, - _HomeTab.ship => _amber, - _HomeTab.guard => _rose, - _HomeTab.insight => _cyan, - }; -} - -_ModuleAction _focusPrimaryAction(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => _ModuleAction.webDemo, - _HomeTab.ai => _ModuleAction.aiChat, - _HomeTab.ship => _ModuleAction.build, - _HomeTab.guard => _ModuleAction.healthCheck, - _HomeTab.insight => _ModuleAction.toolLab, - }; -} - -_ModuleAction _focusSecondaryAction(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => _ModuleAction.githubTest, - _HomeTab.ai => _ModuleAction.toolLab, - _HomeTab.ship => _ModuleAction.githubTest, - _HomeTab.guard => _ModuleAction.termuxCheck, - _HomeTab.insight => _ModuleAction.project, - }; -} - -String _focusPrimaryLabel(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => 'Run mini agent', - _HomeTab.ai => 'Open AI chat', - _HomeTab.ship => 'Open release tools', - _HomeTab.guard => 'Check provider health', - _HomeTab.insight => 'Open tool lab', - }; -} - -String _focusSecondaryLabel(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => 'Open GitHub test', - _HomeTab.ai => 'Open tool probes', - _HomeTab.ship => 'Open GitHub test', +} + +String _agentStepLabel(_AgentStepState state) { + return switch (state) { + _AgentStepState.queued => 'Queued', + _AgentStepState.running => 'Running', + _AgentStepState.done => 'Done', + _AgentStepState.failed => 'Failed', + }; +} + +IconData _agentStepStatusIcon(_AgentStepState state) { + return switch (state) { + _AgentStepState.queued => Icons.radio_button_unchecked_outlined, + _AgentStepState.running => Icons.sync_outlined, + _AgentStepState.done => Icons.check_circle_outline, + _AgentStepState.failed => Icons.error_outline, + }; +} + +Color _agentStepColor(_AgentStepState state) { + return switch (state) { + _AgentStepState.queued => _faint, + _AgentStepState.running => _amber, + _AgentStepState.done => _mint, + _AgentStepState.failed => _rose, + }; +} + +Color _localToolEventColor(_LocalToolEvent event) { + if (!event.ok || event.kind == _LocalToolEventKind.error) return _rose; + return switch (event.kind) { + _LocalToolEventKind.system => _cyan, + _LocalToolEventKind.thought => _violet, + _LocalToolEventKind.toolCall => _amber, + _LocalToolEventKind.observation => _mint, + _LocalToolEventKind.fileWrite => _blue, + _LocalToolEventKind.diff => _lime, + _LocalToolEventKind.preview => _violet, + _LocalToolEventKind.finalAnswer => _mint, + _LocalToolEventKind.error => _rose, + }; +} + +IconData _localToolEventIcon(_LocalToolEvent event) { + if (!event.ok || event.kind == _LocalToolEventKind.error) return Icons.error_outline; + return switch (event.kind) { + _LocalToolEventKind.system => Icons.memory_outlined, + _LocalToolEventKind.thought => Icons.psychology_alt_outlined, + _LocalToolEventKind.toolCall => Icons.play_circle_outline, + _LocalToolEventKind.observation => Icons.check_circle_outline, + _LocalToolEventKind.fileWrite => Icons.edit_note_outlined, + _LocalToolEventKind.diff => Icons.compare_arrows_outlined, + _LocalToolEventKind.preview => Icons.preview_outlined, + _LocalToolEventKind.finalAnswer => Icons.task_alt_outlined, + _LocalToolEventKind.error => Icons.error_outline, + }; +} + +String _flavorLabel(_ApiFlavor flavor) { + return switch (flavor) { + _ApiFlavor.anthropic => 'Anthropic', + _ApiFlavor.openAi => 'OpenAI', + }; +} + +String _statusLabel(_CapabilityStatus status) { + return switch (status) { + _CapabilityStatus.ready => 'Ready', + _CapabilityStatus.needsConfig => 'Config', + _CapabilityStatus.local => 'Local', + _CapabilityStatus.preview => 'Preview', + }; +} + +IconData _statusIcon(_CapabilityStatus status) { + return switch (status) { + _CapabilityStatus.ready => Icons.check_circle_outline, + _CapabilityStatus.needsConfig => Icons.tune_outlined, + _CapabilityStatus.local => Icons.offline_bolt_outlined, + _CapabilityStatus.preview => Icons.visibility_outlined, + }; +} + +Color _statusColor(_CapabilityStatus status) { + return switch (status) { + _CapabilityStatus.ready => _mint, + _CapabilityStatus.needsConfig => _amber, + _CapabilityStatus.local => _lime, + _CapabilityStatus.preview => _cyan, + }; +} + +String _focusTitle(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => 'Ready workspace', + _HomeTab.ai => 'Agent conversation', + _HomeTab.ship => 'Build and release', + _HomeTab.guard => 'Runtime checks', + _HomeTab.insight => 'Usage signal', + }; +} + +String _focusSubtitle(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => 'Provider, local tool test, GitHub, Runtime, and demo surfaces stay one tap away.', + _HomeTab.ai => 'Persistent chat plus visible tool traces for phone-first coding.', + _HomeTab.ship => 'GitHub Release, Android APK, iOS simulator build, Pages, and preview paths.', + _HomeTab.guard => 'Provider health, tool probes, install checks, and local storage checks.', + _HomeTab.insight => 'Recent activity, saved drafts, snippets, and build confidence signals.', + }; +} + +IconData _focusIcon(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => Icons.dashboard_customize_outlined, + _HomeTab.ai => Icons.psychology_alt_outlined, + _HomeTab.ship => Icons.rocket_launch_outlined, + _HomeTab.guard => Icons.health_and_safety_outlined, + _HomeTab.insight => Icons.insights_outlined, + }; +} + +Color _focusColor(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => _mint, + _HomeTab.ai => _violet, + _HomeTab.ship => _amber, + _HomeTab.guard => _rose, + _HomeTab.insight => _cyan, + }; +} + +_ModuleAction _focusPrimaryAction(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => _ModuleAction.webDemo, + _HomeTab.ai => _ModuleAction.aiChat, + _HomeTab.ship => _ModuleAction.build, + _HomeTab.guard => _ModuleAction.healthCheck, + _HomeTab.insight => _ModuleAction.toolLab, + }; +} + +_ModuleAction _focusSecondaryAction(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => _ModuleAction.githubTest, + _HomeTab.ai => _ModuleAction.toolLab, + _HomeTab.ship => _ModuleAction.githubTest, + _HomeTab.guard => _ModuleAction.termuxCheck, + _HomeTab.insight => _ModuleAction.project, + }; +} + +String _focusPrimaryLabel(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => 'Run local test', + _HomeTab.ai => 'Open AI chat', + _HomeTab.ship => 'Open release tools', + _HomeTab.guard => 'Check provider health', + _HomeTab.insight => 'Open tool lab', + }; +} + +String _focusSecondaryLabel(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => 'Open GitHub test', + _HomeTab.ai => 'Open tool probes', + _HomeTab.ship => 'Open GitHub test', _HomeTab.guard => 'Check runtime', - _HomeTab.insight => 'Open project console', - }; -} - -IconData _focusPrimaryIcon(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => Icons.play_arrow_outlined, - _HomeTab.ai => Icons.forum_outlined, - _HomeTab.ship => Icons.rocket_launch_outlined, - _HomeTab.guard => Icons.monitor_heart_outlined, - _HomeTab.insight => Icons.handyman_outlined, - }; -} - -IconData _focusSecondaryIcon(_HomeTab tab) { - return switch (tab) { - _HomeTab.control => Icons.hub_outlined, - _HomeTab.ai => Icons.schema_outlined, - _HomeTab.ship => Icons.hub_outlined, - _HomeTab.guard => Icons.terminal_outlined, - _HomeTab.insight => Icons.folder_open_outlined, - }; -} - -String _focusHealthLabel(_HealthState health) { - return switch (health) { - _HealthState.healthy => 'healthy', - _HealthState.failed => 'needs check', - _HealthState.checking => 'checking', - _HealthState.unknown => 'not checked', - }; -} - -_CapabilityStatus _healthToStatus(_HealthState health) { - return switch (health) { - _HealthState.healthy => _CapabilityStatus.ready, - _HealthState.failed => _CapabilityStatus.needsConfig, - _HealthState.checking => _CapabilityStatus.preview, - _HealthState.unknown => _CapabilityStatus.preview, - }; -} - -Color _healthColor(_HealthState health) { - return switch (health) { - _HealthState.healthy => _mint, - _HealthState.failed => _rose, - _HealthState.checking => _amber, - _HealthState.unknown => _cyan, - }; -} - + _HomeTab.insight => 'Open project console', + }; +} + +IconData _focusPrimaryIcon(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => Icons.play_arrow_outlined, + _HomeTab.ai => Icons.forum_outlined, + _HomeTab.ship => Icons.rocket_launch_outlined, + _HomeTab.guard => Icons.monitor_heart_outlined, + _HomeTab.insight => Icons.handyman_outlined, + }; +} + +IconData _focusSecondaryIcon(_HomeTab tab) { + return switch (tab) { + _HomeTab.control => Icons.hub_outlined, + _HomeTab.ai => Icons.schema_outlined, + _HomeTab.ship => Icons.hub_outlined, + _HomeTab.guard => Icons.terminal_outlined, + _HomeTab.insight => Icons.folder_open_outlined, + }; +} + +String _focusHealthLabel(_HealthState health) { + return switch (health) { + _HealthState.healthy => 'healthy', + _HealthState.failed => 'needs check', + _HealthState.checking => 'checking', + _HealthState.unknown => 'not checked', + }; +} + +_CapabilityStatus _healthToStatus(_HealthState health) { + return switch (health) { + _HealthState.healthy => _CapabilityStatus.ready, + _HealthState.failed => _CapabilityStatus.needsConfig, + _HealthState.checking => _CapabilityStatus.preview, + _HealthState.unknown => _CapabilityStatus.preview, + }; +} + +Color _healthColor(_HealthState health) { + return switch (health) { + _HealthState.healthy => _mint, + _HealthState.failed => _rose, + _HealthState.checking => _amber, + _HealthState.unknown => _cyan, + }; +} + String _timeLabel(DateTime time) { final diff = DateTime.now().difference(time); if (diff.inSeconds < 60) return '${diff.inSeconds}s'; @@ -8462,329 +15062,329 @@ String _quoteCommandArg(String value) { } final List<_CapabilityLayer> _capabilityLayers = [ - _CapabilityLayer( - name: 'AI Core', - subtitle: 'Model gateway, multimodal input, local AI, API operations, and prompt templates.', - icon: Icons.auto_awesome_outlined, - color: _mint, - capabilities: [ - _Capability( - title: 'LLM Gateway', - subtitle: 'Chat, complete, explain, fix, streaming responses, and context-aware prompts.', - icon: Icons.forum_outlined, - status: _CapabilityStatus.needsConfig, - services: ['llm_service.dart', 'api_service.dart', 'coding_prompts.dart', 'context_injector.dart'], - actions: ['Send real chat request', 'Switch OpenAI or Anthropic-compatible routes', 'Use coding prompt modes'], - primaryAction: _ModuleAction.aiChat, - surface: 'AI Chat sheet and provider health panel', - ), - _Capability( - title: 'Multimodal Studio', - subtitle: 'Screenshot, image, voice, and text input grouped for mobile coding tasks.', - icon: Icons.image_search_outlined, - status: _CapabilityStatus.preview, - services: ['multimodal_llm_service.dart', 'screenshot_to_code_service.dart', 'voice_service.dart'], - actions: ['Capture screenshot to code', 'Attach voice command', 'Inspect generated Flutter output'], - primaryAction: _ModuleAction.inspect, - ), - _Capability( - title: 'API Operations', - subtitle: 'Multiple keys, provider priority, usage, quota alerts, and fallback planning.', - icon: Icons.route_outlined, - status: _CapabilityStatus.ready, - services: ['api_manager_service.dart', 'api_usage_service.dart', 'secure_storage_service.dart'], - actions: ['Save provider profile', 'Run health probe', 'Track token usage and budgets'], - primaryAction: _ModuleAction.apiConfig, - ), - _Capability( - title: 'Local AI', - subtitle: 'On-device inference surface for offline or privacy-sensitive coding workflows.', - icon: Icons.memory_outlined, - status: _CapabilityStatus.local, - services: ['local_ai_service.dart', 'offline_manager.dart', 'device_perf_service.dart'], - actions: ['Inspect device readiness', 'Prefer local model when offline', 'Monitor memory and heat limits'], - primaryAction: _ModuleAction.inspect, - ), - ], - ), - _CapabilityLayer( - name: 'Agents', - subtitle: 'Supervisor-worker orchestration, ReAct actions, Deep Dive Solo, and self-use automation.', - icon: Icons.psychology_alt_outlined, - color: _violet, - capabilities: [ - _Capability( - title: 'Supervisor Agents', - subtitle: 'Dynamic expert routing for coding, planning, debugging, review, and release tasks.', - icon: Icons.account_tree_outlined, - status: _CapabilityStatus.preview, - services: ['agent_orchestrator.dart', 'agent_action_system.dart', 'coding_prompts.dart'], - actions: ['Plan task decomposition', 'Track action and observation loop', 'Route to expert worker'], - primaryAction: _ModuleAction.deepDive, - ), - _Capability( - title: 'Deep Dive Solo', - subtitle: 'Background task queue with progress, isolate execution, and resumable task history.', - icon: Icons.all_inclusive_outlined, - status: _CapabilityStatus.ready, - services: ['deep_dive_service.dart', 'deep_dive_task_manager.dart', 'foreground_service.dart'], - actions: ['Queue long-running coding task', 'Watch progress', 'Resume task history'], - primaryAction: _ModuleAction.deepDive, - ), - _Capability( - title: 'Self-Use Actions', - subtitle: 'App-level action registry for navigation, file, editor, terminal, and workflow automation.', - icon: Icons.touch_app_outlined, - status: _CapabilityStatus.preview, - services: ['self_invocation_service.dart', 'self_action_registry.dart', 'navigation_controller.dart'], - actions: ['Inspect 52 registered actions', 'Trigger UI-aware workflow', 'Audit action metadata'], - primaryAction: _ModuleAction.inspect, - ), - ], - ), - _CapabilityLayer( - name: 'Code', - subtitle: 'Editor, LSP-like controls, code index, context injection, similarity, snippets, and terminal.', - icon: Icons.code_outlined, - color: _cyan, - capabilities: [ - _Capability( - title: 'Mobile Editor', - subtitle: 'Tabs, syntax styling, search, replace, AI assists, and file draft entry points.', - icon: Icons.edit_note_outlined, - status: _CapabilityStatus.ready, - services: ['editor_controller.dart', 'storage_service.dart', 'file_item.dart'], - actions: ['Create file draft', 'Open code editing surface', 'Prepare AI context for current file'], - primaryAction: _ModuleAction.newFile, - ), - _Capability( - title: 'Code Intelligence', - subtitle: 'SQLite FTS index, keyword extraction, context injection, and similarity analysis.', - icon: Icons.manage_search_outlined, - status: _CapabilityStatus.local, - services: ['code_index_service.dart', 'context_injector.dart', 'code_similarity_service.dart'], - actions: ['Index project files', 'Retrieve relevant context', 'Compare snippets by AST features'], - primaryAction: _ModuleAction.project, - ), - _Capability( - title: 'Snippets', - subtitle: 'Capture reusable fragments and surface them beside projects, editor, and AI prompts.', - icon: Icons.data_object_outlined, - status: _CapabilityStatus.ready, - services: ['snippet_provider.dart', 'storage_service.dart', 'sync_queue_service.dart'], - actions: ['Save snippet', 'Prepare quick paste', 'Queue offline sync'], - primaryAction: _ModuleAction.snippet, - ), - _Capability( - title: 'Terminal', - subtitle: 'Local shell state, command history, autocomplete, and streaming output surfaces.', - icon: Icons.terminal_outlined, - status: _CapabilityStatus.preview, - services: ['terminal_service.dart', 'terminal_controller.dart', 'terminal_provider.dart'], - actions: ['Prepare command session', 'Inspect output stream', 'Bridge to SSH or Termux'], - primaryAction: _ModuleAction.terminal, - ), - ], - ), - _CapabilityLayer( - name: 'Remote', - subtitle: 'SSH, Termux, build orchestration, previews, GitHub, Gist, Pages, and WeChat publishing.', - icon: Icons.cloud_sync_outlined, - color: _amber, - capabilities: [ - _Capability( - title: 'Remote Dev', - subtitle: 'SSH, SFTP, port forwarding, Termux commands, and mobile Linux workflows.', - icon: Icons.dns_outlined, - status: _CapabilityStatus.needsConfig, - services: ['ssh_service.dart', 'termux_service.dart', 'ssh_provider.dart'], - actions: ['Attach host', 'Run remote command', 'Sync files through SFTP'], - primaryAction: _ModuleAction.terminal, - ), - _Capability( - title: 'Build Orchestrator', - subtitle: 'APK, preview, static deploy, and mobile packaging command center.', - icon: Icons.rocket_launch_outlined, - status: _CapabilityStatus.ready, - services: ['build_orchestrator.dart', 'preview_service.dart', 'termux_service.dart'], - actions: ['Stage APK build', 'Inspect preview surface', 'Track release progress'], - primaryAction: _ModuleAction.build, - ), - _Capability( - title: 'GitHub Deep Work', - subtitle: 'Repository browsing, issue/PR surfaces, Gists, Pages deploy, cache, and analytics.', - icon: Icons.account_tree_outlined, - status: _CapabilityStatus.preview, - services: ['github_service.dart', 'github_deep_service.dart', 'github_gist_service.dart', 'github_pages_service.dart', 'github_cache_service.dart'], - actions: ['Browse repo', 'Analyze PR or issue', 'Publish Gist or Pages site'], - primaryAction: _ModuleAction.project, - ), - _Capability( - title: 'WeChat Publish', - subtitle: 'Mini-program upload and release pipeline surfaced beside GitHub and build flows.', - icon: Icons.send_to_mobile_outlined, - status: _CapabilityStatus.preview, - services: ['wechat_publish_service.dart', 'build_orchestrator.dart', 'logger_service.dart'], - actions: ['Prepare upload', 'Validate project metadata', 'Track publish log'], - primaryAction: _ModuleAction.build, - ), - ], - ), - _CapabilityLayer( - name: 'Guard', - subtitle: 'Secure storage, biometrics, local database, offline sync, crash recovery, and HTTP policies.', - icon: Icons.security_outlined, - color: _rose, - capabilities: [ - _Capability( - title: 'Credential Vault', - subtitle: 'AES storage, Android Keystore or iOS Keychain, biometrics, and key masking.', - icon: Icons.lock_outline, - status: _CapabilityStatus.ready, - services: ['secure_storage_service.dart', 'biometric_service.dart', 'api_manager_service.dart'], - actions: ['Protect API keys', 'Enable biometric unlock', 'Audit provider secrets'], - primaryAction: _ModuleAction.apiConfig, - ), - _Capability( - title: 'Offline First', - subtitle: 'SQLite database, file storage, sync queue, conflict handling, and offline mode.', - icon: Icons.offline_bolt_outlined, - status: _CapabilityStatus.local, - services: ['local_database_service.dart', 'storage_service.dart', 'sync_queue_service.dart', 'offline_manager.dart'], - actions: ['Queue offline changes', 'Inspect conflict policy', 'Resume sync when network returns'], - primaryAction: _ModuleAction.inspect, - ), - _Capability( - title: 'Recovery and Scan', - subtitle: 'Crash recovery, structured logs, binary analysis, and security scan surfaces.', - icon: Icons.health_and_safety_outlined, - status: _CapabilityStatus.preview, - services: ['crash_recovery_service.dart', 'logger_service.dart', 'binary_analysis_service.dart'], - actions: ['Inspect crash snapshot', 'Analyze APK or IPA', 'View structured logs'], - primaryAction: _ModuleAction.inspect, - ), - ], - ), - _CapabilityLayer( - name: 'Analytics', - subtitle: 'Projects, memory, habits, feedback learning, API usage, and binary analysis.', - icon: Icons.insights_outlined, - color: _blue, - capabilities: [ - _Capability( - title: 'Project Brain', - subtitle: 'Project creation, import, export, learning, knowledge generation, and stats.', - icon: Icons.folder_special_outlined, - status: _CapabilityStatus.ready, - services: ['project_manager.dart', 'project_learning_service.dart', 'memory_service.dart'], - actions: ['Create or import project', 'Learn project knowledge', 'Feed memory into prompts'], - primaryAction: _ModuleAction.project, - ), - _Capability( - title: 'Usage and Cost', - subtitle: 'Token usage, quota, costs, projections, and provider alerts.', - icon: Icons.query_stats_outlined, - status: _CapabilityStatus.preview, - services: ['api_usage_service.dart', 'api_manager_service.dart', 'feedback_learning_service.dart'], - actions: ['Inspect usage projection', 'Set quota warning', 'Learn from answer feedback'], - primaryAction: _ModuleAction.inspect, - ), - _Capability( - title: 'Coding Habits', - subtitle: 'Activity tracking, habits, achievements, and personal rhythm analytics.', - icon: Icons.timeline_outlined, - status: _CapabilityStatus.local, - services: ['habit_service.dart', 'vibing_activity_service.dart', 'memory_service.dart'], - actions: ['Track coding time', 'View habit summary', 'Generate improvement suggestions'], - primaryAction: _ModuleAction.inspect, - ), - ], - ), - _CapabilityLayer( - name: 'Tools', - subtitle: 'Voice, screenshot-to-code, skill manager, remote skills, feature flags, logs, and notifications.', - icon: Icons.construction_outlined, - color: _lime, - capabilities: [ - _Capability( - title: 'Capture Tools', - subtitle: 'Voice commands and screenshot-to-code flows for mobile-first AI coding.', - icon: Icons.center_focus_strong_outlined, - status: _CapabilityStatus.preview, - services: ['voice_service.dart', 'screenshot_to_code_service.dart', 'multimodal_llm_service.dart'], - actions: ['Capture voice task', 'Convert screenshot to Flutter', 'Send multimodal prompt'], - primaryAction: _ModuleAction.inspect, - ), - _Capability( - title: 'MCP and Skills', - subtitle: 'Local and remote skill packages with import, metadata, and execution surfaces.', - icon: Icons.extension_outlined, - status: _CapabilityStatus.preview, - services: ['skill_manager_service.dart', 'remote_skill_service.dart', 'self_action_registry.dart'], - actions: ['Import skill from GitHub', 'Inspect tool metadata', 'Bind skill to agent action'], - primaryAction: _ModuleAction.inspect, - ), - _Capability( - title: 'Runtime Controls', - subtitle: 'Feature flags, structured logger, notifications, and navigation state.', - icon: Icons.tune_outlined, - status: _CapabilityStatus.ready, - services: ['feature_flags_service.dart', 'notification_manager.dart', 'navigation_controller.dart', 'logger_service.dart'], - actions: ['Toggle rollout flag', 'Inspect notification queue', 'Audit navigation events'], - primaryAction: _ModuleAction.inspect, - ), - ], - ), - _CapabilityLayer( - name: 'Performance', - subtitle: 'FPS, haptics, device performance, performance modes, memory, and activity stats.', - icon: Icons.speed_outlined, - color: _cyan, - capabilities: [ - _Capability( - title: 'Device Performance', - subtitle: 'FPS tracking, CPU, memory, temperature, and adaptive performance modes.', - icon: Icons.speed_outlined, - status: _CapabilityStatus.local, - services: ['fps_tracker.dart', 'device_perf_service.dart', 'performance_mode_service.dart'], - actions: ['Watch frame budget', 'Switch performance mode', 'Inspect device pressure'], - primaryAction: _ModuleAction.inspect, - ), - _Capability( - title: 'Interaction Engine', - subtitle: 'Haptics, animation timing, and activity feedback for mobile coding flows.', - icon: Icons.vibration_outlined, - status: _CapabilityStatus.ready, - services: ['haptic_feedback_service.dart', 'vibing_activity_service.dart', 'performance_provider.dart'], - actions: ['Trigger haptic profile', 'Track activity streak', 'Tune interaction density'], - primaryAction: _ModuleAction.inspect, - ), - ], - ), - _CapabilityLayer( - name: 'Team', - subtitle: 'Team members, shared knowledge, collaboration surfaces, and foreground execution.', - icon: Icons.groups_outlined, - color: _amber, - capabilities: [ - _Capability( - title: 'Team Hub', - subtitle: 'Members, permissions, shared projects, knowledge, and collaborative task views.', - icon: Icons.groups_outlined, - status: _CapabilityStatus.preview, - services: ['team_service.dart', 'team_provider.dart', 'team_knowledge_screen.dart'], - actions: ['Inspect members', 'Share project knowledge', 'Prepare collaboration backend endpoint'], - primaryAction: _ModuleAction.inspect, - ), - _Capability( - title: 'Foreground Runs', - subtitle: 'Keep Deep Dive and long-running build tasks visible during background execution.', - icon: Icons.notifications_active_outlined, - status: _CapabilityStatus.preview, - services: ['foreground_service.dart', 'notification_manager.dart', 'deep_dive_task_manager.dart'], - actions: ['Publish progress notification', 'Keep background task alive', 'Resume from notification tap'], - primaryAction: _ModuleAction.deepDive, - ), - ], - ), -]; + _CapabilityLayer( + name: 'AI Core', + subtitle: 'Model gateway, multimodal input, local AI, API operations, and prompt templates.', + icon: Icons.auto_awesome_outlined, + color: _mint, + capabilities: [ + _Capability( + title: 'LLM Gateway', + subtitle: 'Chat, complete, explain, fix, streaming responses, and context-aware prompts.', + icon: Icons.forum_outlined, + status: _CapabilityStatus.needsConfig, + services: ['llm_service.dart', 'api_service.dart', 'coding_prompts.dart', 'context_injector.dart'], + actions: ['Send real chat request', 'Switch OpenAI or Anthropic-compatible routes', 'Use coding prompt modes'], + primaryAction: _ModuleAction.aiChat, + surface: 'AI Chat sheet and provider health panel', + ), + _Capability( + title: 'Multimodal Studio', + subtitle: 'Screenshot, image, voice, and text input grouped for mobile coding tasks.', + icon: Icons.image_search_outlined, + status: _CapabilityStatus.preview, + services: ['multimodal_llm_service.dart', 'screenshot_to_code_service.dart', 'voice_service.dart'], + actions: ['Capture screenshot to code', 'Attach voice command', 'Inspect generated Flutter output'], + primaryAction: _ModuleAction.inspect, + ), + _Capability( + title: 'API Operations', + subtitle: 'Multiple keys, provider priority, usage, quota alerts, and fallback planning.', + icon: Icons.route_outlined, + status: _CapabilityStatus.ready, + services: ['api_manager_service.dart', 'api_usage_service.dart', 'secure_storage_service.dart'], + actions: ['Save provider profile', 'Run health probe', 'Track token usage and budgets'], + primaryAction: _ModuleAction.apiConfig, + ), + _Capability( + title: 'Local AI', + subtitle: 'On-device inference surface for offline or privacy-sensitive coding workflows.', + icon: Icons.memory_outlined, + status: _CapabilityStatus.local, + services: ['local_ai_service.dart', 'offline_manager.dart', 'device_perf_service.dart'], + actions: ['Inspect device readiness', 'Prefer local model when offline', 'Monitor memory and heat limits'], + primaryAction: _ModuleAction.inspect, + ), + ], + ), + _CapabilityLayer( + name: 'Agents', + subtitle: 'Supervisor-worker orchestration, ReAct actions, Deep Dive Solo, and self-use automation.', + icon: Icons.psychology_alt_outlined, + color: _violet, + capabilities: [ + _Capability( + title: 'Supervisor Agents', + subtitle: 'Dynamic expert routing for coding, planning, debugging, review, and release tasks.', + icon: Icons.account_tree_outlined, + status: _CapabilityStatus.preview, + services: ['agent_orchestrator.dart', 'agent_action_system.dart', 'coding_prompts.dart'], + actions: ['Plan task decomposition', 'Track action and observation loop', 'Route to expert worker'], + primaryAction: _ModuleAction.deepDive, + ), + _Capability( + title: 'Deep Dive Solo', + subtitle: 'Background task queue with progress, isolate execution, and resumable task history.', + icon: Icons.all_inclusive_outlined, + status: _CapabilityStatus.ready, + services: ['deep_dive_service.dart', 'deep_dive_task_manager.dart', 'foreground_service.dart'], + actions: ['Queue long-running coding task', 'Watch progress', 'Resume task history'], + primaryAction: _ModuleAction.deepDive, + ), + _Capability( + title: 'Self-Use Actions', + subtitle: 'App-level action registry for navigation, file, editor, terminal, and workflow automation.', + icon: Icons.touch_app_outlined, + status: _CapabilityStatus.preview, + services: ['self_invocation_service.dart', 'self_action_registry.dart', 'navigation_controller.dart'], + actions: ['Inspect 52 registered actions', 'Trigger UI-aware workflow', 'Audit action metadata'], + primaryAction: _ModuleAction.inspect, + ), + ], + ), + _CapabilityLayer( + name: 'Code', + subtitle: 'Editor, LSP-like controls, code index, context injection, similarity, snippets, and terminal.', + icon: Icons.code_outlined, + color: _cyan, + capabilities: [ + _Capability( + title: 'Mobile Editor', + subtitle: 'Tabs, syntax styling, search, replace, AI assists, and file draft entry points.', + icon: Icons.edit_note_outlined, + status: _CapabilityStatus.ready, + services: ['editor_controller.dart', 'storage_service.dart', 'file_item.dart'], + actions: ['Create file draft', 'Open code editing surface', 'Prepare AI context for current file'], + primaryAction: _ModuleAction.newFile, + ), + _Capability( + title: 'Code Intelligence', + subtitle: 'SQLite FTS index, keyword extraction, context injection, and similarity analysis.', + icon: Icons.manage_search_outlined, + status: _CapabilityStatus.local, + services: ['code_index_service.dart', 'context_injector.dart', 'code_similarity_service.dart'], + actions: ['Index project files', 'Retrieve relevant context', 'Compare snippets by AST features'], + primaryAction: _ModuleAction.project, + ), + _Capability( + title: 'Snippets', + subtitle: 'Capture reusable fragments and surface them beside projects, editor, and AI prompts.', + icon: Icons.data_object_outlined, + status: _CapabilityStatus.ready, + services: ['snippet_provider.dart', 'storage_service.dart', 'sync_queue_service.dart'], + actions: ['Save snippet', 'Prepare quick paste', 'Queue offline sync'], + primaryAction: _ModuleAction.snippet, + ), + _Capability( + title: 'Terminal', + subtitle: 'Local shell state, command history, autocomplete, and streaming output surfaces.', + icon: Icons.terminal_outlined, + status: _CapabilityStatus.preview, + services: ['terminal_service.dart', 'terminal_controller.dart', 'terminal_provider.dart'], + actions: ['Prepare command session', 'Inspect output stream', 'Bridge to SSH or Runtime'], + primaryAction: _ModuleAction.terminal, + ), + ], + ), + _CapabilityLayer( + name: 'Remote', + subtitle: 'SSH, Runtime, build orchestration, previews, GitHub, Gist, Pages, and WeChat publishing.', + icon: Icons.cloud_sync_outlined, + color: _amber, + capabilities: [ + _Capability( + title: 'Remote Dev', + subtitle: 'SSH, SFTP, port forwarding, Runtime commands, and mobile Linux workflows.', + icon: Icons.dns_outlined, + status: _CapabilityStatus.needsConfig, + services: ['ssh_service.dart', 'termux_service.dart', 'ssh_provider.dart'], + actions: ['Attach host', 'Run remote command', 'Sync files through SFTP'], + primaryAction: _ModuleAction.terminal, + ), + _Capability( + title: 'Build Orchestrator', + subtitle: 'APK, preview, static deploy, and mobile packaging command center.', + icon: Icons.rocket_launch_outlined, + status: _CapabilityStatus.ready, + services: ['build_orchestrator.dart', 'preview_service.dart', 'termux_service.dart'], + actions: ['Stage APK build', 'Inspect preview surface', 'Track release progress'], + primaryAction: _ModuleAction.build, + ), + _Capability( + title: 'GitHub Deep Work', + subtitle: 'Repository browsing, issue/PR surfaces, Gists, Pages deploy, cache, and analytics.', + icon: Icons.account_tree_outlined, + status: _CapabilityStatus.preview, + services: ['github_service.dart', 'github_deep_service.dart', 'github_gist_service.dart', 'github_pages_service.dart', 'github_cache_service.dart'], + actions: ['Browse repo', 'Analyze PR or issue', 'Publish Gist or Pages site'], + primaryAction: _ModuleAction.project, + ), + _Capability( + title: 'WeChat Publish', + subtitle: 'Mini-program upload and release pipeline surfaced beside GitHub and build flows.', + icon: Icons.send_to_mobile_outlined, + status: _CapabilityStatus.preview, + services: ['wechat_publish_service.dart', 'build_orchestrator.dart', 'logger_service.dart'], + actions: ['Prepare upload', 'Validate project metadata', 'Track publish log'], + primaryAction: _ModuleAction.build, + ), + ], + ), + _CapabilityLayer( + name: 'Guard', + subtitle: 'Secure storage, biometrics, local database, offline sync, crash recovery, and HTTP policies.', + icon: Icons.security_outlined, + color: _rose, + capabilities: [ + _Capability( + title: 'Credential Vault', + subtitle: 'AES storage, Android Keystore or iOS Keychain, biometrics, and key masking.', + icon: Icons.lock_outline, + status: _CapabilityStatus.ready, + services: ['secure_storage_service.dart', 'biometric_service.dart', 'api_manager_service.dart'], + actions: ['Protect API keys', 'Enable biometric unlock', 'Audit provider secrets'], + primaryAction: _ModuleAction.apiConfig, + ), + _Capability( + title: 'Offline First', + subtitle: 'SQLite database, file storage, sync queue, conflict handling, and offline mode.', + icon: Icons.offline_bolt_outlined, + status: _CapabilityStatus.local, + services: ['local_database_service.dart', 'storage_service.dart', 'sync_queue_service.dart', 'offline_manager.dart'], + actions: ['Queue offline changes', 'Inspect conflict policy', 'Resume sync when network returns'], + primaryAction: _ModuleAction.inspect, + ), + _Capability( + title: 'Recovery and Scan', + subtitle: 'Crash recovery, structured logs, binary analysis, and security scan surfaces.', + icon: Icons.health_and_safety_outlined, + status: _CapabilityStatus.preview, + services: ['crash_recovery_service.dart', 'logger_service.dart', 'binary_analysis_service.dart'], + actions: ['Inspect crash snapshot', 'Analyze APK or IPA', 'View structured logs'], + primaryAction: _ModuleAction.inspect, + ), + ], + ), + _CapabilityLayer( + name: 'Analytics', + subtitle: 'Projects, memory, habits, feedback learning, API usage, and binary analysis.', + icon: Icons.insights_outlined, + color: _blue, + capabilities: [ + _Capability( + title: 'Project Brain', + subtitle: 'Project creation, import, export, learning, knowledge generation, and stats.', + icon: Icons.folder_special_outlined, + status: _CapabilityStatus.ready, + services: ['project_manager.dart', 'project_learning_service.dart', 'memory_service.dart'], + actions: ['Create or import project', 'Learn project knowledge', 'Feed memory into prompts'], + primaryAction: _ModuleAction.project, + ), + _Capability( + title: 'Usage and Cost', + subtitle: 'Token usage, quota, costs, projections, and provider alerts.', + icon: Icons.query_stats_outlined, + status: _CapabilityStatus.preview, + services: ['api_usage_service.dart', 'api_manager_service.dart', 'feedback_learning_service.dart'], + actions: ['Inspect usage projection', 'Set quota warning', 'Learn from answer feedback'], + primaryAction: _ModuleAction.inspect, + ), + _Capability( + title: 'Coding Habits', + subtitle: 'Activity tracking, habits, achievements, and personal rhythm analytics.', + icon: Icons.timeline_outlined, + status: _CapabilityStatus.local, + services: ['habit_service.dart', 'vibing_activity_service.dart', 'memory_service.dart'], + actions: ['Track coding time', 'View habit summary', 'Generate improvement suggestions'], + primaryAction: _ModuleAction.inspect, + ), + ], + ), + _CapabilityLayer( + name: 'Tools', + subtitle: 'Voice, screenshot-to-code, skill manager, remote skills, feature flags, logs, and notifications.', + icon: Icons.construction_outlined, + color: _lime, + capabilities: [ + _Capability( + title: 'Capture Tools', + subtitle: 'Voice commands and screenshot-to-code flows for mobile-first AI coding.', + icon: Icons.center_focus_strong_outlined, + status: _CapabilityStatus.preview, + services: ['voice_service.dart', 'screenshot_to_code_service.dart', 'multimodal_llm_service.dart'], + actions: ['Capture voice task', 'Convert screenshot to Flutter', 'Send multimodal prompt'], + primaryAction: _ModuleAction.inspect, + ), + _Capability( + title: 'MCP and Skills', + subtitle: 'Local and remote skill packages with import, metadata, and execution surfaces.', + icon: Icons.extension_outlined, + status: _CapabilityStatus.preview, + services: ['skill_manager_service.dart', 'remote_skill_service.dart', 'self_action_registry.dart'], + actions: ['Import skill from GitHub', 'Inspect tool metadata', 'Bind skill to agent action'], + primaryAction: _ModuleAction.inspect, + ), + _Capability( + title: 'Runtime Controls', + subtitle: 'Feature flags, structured logger, notifications, and navigation state.', + icon: Icons.tune_outlined, + status: _CapabilityStatus.ready, + services: ['feature_flags_service.dart', 'notification_manager.dart', 'navigation_controller.dart', 'logger_service.dart'], + actions: ['Toggle rollout flag', 'Inspect notification queue', 'Audit navigation events'], + primaryAction: _ModuleAction.inspect, + ), + ], + ), + _CapabilityLayer( + name: 'Performance', + subtitle: 'FPS, haptics, device performance, performance modes, memory, and activity stats.', + icon: Icons.speed_outlined, + color: _cyan, + capabilities: [ + _Capability( + title: 'Device Performance', + subtitle: 'FPS tracking, CPU, memory, temperature, and adaptive performance modes.', + icon: Icons.speed_outlined, + status: _CapabilityStatus.local, + services: ['fps_tracker.dart', 'device_perf_service.dart', 'performance_mode_service.dart'], + actions: ['Watch frame budget', 'Switch performance mode', 'Inspect device pressure'], + primaryAction: _ModuleAction.inspect, + ), + _Capability( + title: 'Interaction Engine', + subtitle: 'Haptics, animation timing, and activity feedback for mobile coding flows.', + icon: Icons.vibration_outlined, + status: _CapabilityStatus.ready, + services: ['haptic_feedback_service.dart', 'vibing_activity_service.dart', 'performance_provider.dart'], + actions: ['Trigger haptic profile', 'Track activity streak', 'Tune interaction density'], + primaryAction: _ModuleAction.inspect, + ), + ], + ), + _CapabilityLayer( + name: 'Team', + subtitle: 'Team members, shared knowledge, collaboration surfaces, and foreground execution.', + icon: Icons.groups_outlined, + color: _amber, + capabilities: [ + _Capability( + title: 'Team Hub', + subtitle: 'Members, permissions, shared projects, knowledge, and collaborative task views.', + icon: Icons.groups_outlined, + status: _CapabilityStatus.preview, + services: ['team_service.dart', 'team_provider.dart', 'team_knowledge_screen.dart'], + actions: ['Inspect members', 'Share project knowledge', 'Prepare collaboration backend endpoint'], + primaryAction: _ModuleAction.inspect, + ), + _Capability( + title: 'Foreground Runs', + subtitle: 'Keep Deep Dive and long-running build tasks visible during background execution.', + icon: Icons.notifications_active_outlined, + status: _CapabilityStatus.preview, + services: ['foreground_service.dart', 'notification_manager.dart', 'deep_dive_task_manager.dart'], + actions: ['Publish progress notification', 'Keep background task alive', 'Resume from notification tap'], + primaryAction: _ModuleAction.deepDive, + ), + ], + ), +]; diff --git a/mobile_agent/lib/screens/hook_registry_screen.dart b/mobile_agent/lib/screens/hook_registry_screen.dart new file mode 100644 index 0000000..99f2a53 --- /dev/null +++ b/mobile_agent/lib/screens/hook_registry_screen.dart @@ -0,0 +1,358 @@ +// lib/screens/hook_registry_screen.dart +// Read-only Hook Registry screen. This intentionally does not execute scripts. + +import 'package:flutter/material.dart'; + +import '../core/theme.dart'; +import '../models/hook_registry_model.dart'; + +class HookRegistryScreen extends StatelessWidget { + const HookRegistryScreen({super.key}); + + @override + Widget build(BuildContext context) { + const snapshot = HookRegistrySnapshot.v1; + + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: AppTheme.background.withOpacity(0.8), + elevation: 0, + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 20, color: AppTheme.textSecondary), + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text( + 'Hook Registry', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + _SummaryCard(snapshot: snapshot), + const SizedBox(height: 12), + for (final entry in snapshot.entries) ...[ + _HookEntryCard(entry: entry), + const SizedBox(height: 10), + ], + ], + ), + ); + } +} + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({required this.snapshot}); + + final HookRegistrySnapshot snapshot; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.link_outlined, color: AppTheme.primary, size: 20), + SizedBox(width: 8), + Expanded( + child: Text( + 'Read-only extension hooks', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'V1 only exposes lifecycle points and status. User scripts, remote code execution, and background hook automation stay deferred.', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + height: 1.35, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _Pill(label: '${snapshot.enabledCount} enabled', color: AppTheme.success), + _Pill(label: '${snapshot.deferredCount} deferred', color: AppTheme.warning), + const _Pill(label: 'No script runtime', color: AppTheme.primary), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: () => _showHookDraftDialog(context), + icon: const Icon(Icons.add, size: 16), + label: const Text('新增 Hook 草案'), + ), + OutlinedButton.icon( + onPressed: () => _showHookPolishDialog(context), + icon: const Icon(Icons.auto_awesome_outlined, size: 16), + label: const Text('AI 润色规范'), + ), + ], + ), + ], + ), + ); + } +} + +void _showHookDraftDialog(BuildContext context) { + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.62), + builder: (context) => _HookInfoDialog( + icon: Icons.add_link_outlined, + title: 'Hook 草案', + body: + 'V1 允许登记 hook 点和启用状态,但不执行任意脚本。下一步会把这里升级成“草案 -> 审核 -> 只读注册”的安全流程。', + ), + ); +} + +void _showHookPolishDialog(BuildContext context) { + showDialog( + context: context, + barrierColor: Colors.black.withOpacity(0.62), + builder: (context) => _HookInfoDialog( + icon: Icons.auto_awesome_outlined, + title: 'AI 润色 Hook 规范', + body: + 'Hook 的 AI 润色会把用户意图标准化为 phase、trigger、scope、guardrails、confirmation policy。V1 只保存规范草案,不启动脚本。', + ), + ); +} + +class _HookInfoDialog extends StatelessWidget { + const _HookInfoDialog({ + required this.icon, + required this.title, + required this.body, + }); + + final IconData icon; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: AppTheme.surface, + surfaceTintColor: AppTheme.surface, + insetPadding: const EdgeInsets.symmetric(horizontal: 22, vertical: 24), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide(color: AppTheme.primary.withOpacity(0.28)), + ), + titlePadding: const EdgeInsets.fromLTRB(22, 20, 22, 0), + contentPadding: const EdgeInsets.fromLTRB(22, 12, 22, 8), + actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 14), + title: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.14), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: AppTheme.primary, size: 19), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppTheme.textPrimary, + ), + ), + ), + ], + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Text( + body, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + height: 1.55, + color: AppTheme.textSecondary, + ), + ), + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + foregroundColor: AppTheme.textOnPrimary, + backgroundColor: AppTheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + onPressed: () => Navigator.of(context).pop(), + child: const Text( + '知道了', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + } +} + +class _HookEntryCard extends StatelessWidget { + const _HookEntryCard({required this.entry}); + + final HookRegistryEntry entry; + + @override + Widget build(BuildContext context) { + final statusColor = entry.enabled ? AppTheme.success : AppTheme.textTertiary; + final safetyColor = switch (entry.safetyLevel) { + HookSafetyLevel.readOnly => AppTheme.primary, + HookSafetyLevel.gated => AppTheme.warning, + HookSafetyLevel.deferred => AppTheme.textTertiary, + }; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: entry.enabled ? AppTheme.primary.withOpacity(0.24) : AppTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: statusColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(9), + ), + child: Icon( + entry.enabled ? Icons.check_circle_outline : Icons.pause_circle_outline, + color: statusColor, + size: 19, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.name, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + entry.id, + style: const TextStyle( + fontFamily: AppTheme.fontCode, + fontSize: 11, + color: AppTheme.textTertiary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + entry.description, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + height: 1.35, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _Pill(label: entry.phase.label, color: AppTheme.accent), + _Pill(label: entry.enabled ? 'enabled' : 'disabled', color: statusColor), + _Pill(label: entry.safetyLevel.label, color: safetyColor), + _Pill(label: entry.owner, color: AppTheme.textSecondary), + ], + ), + ], + ), + ); + } +} + +class _Pill extends StatelessWidget { + const _Pill({required this.label, required this.color}); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.24)), + ), + child: Text( + label, + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ); + } +} diff --git a/mobile_agent/lib/screens/mcp_manager_screen.dart b/mobile_agent/lib/screens/mcp_manager_screen.dart index 6f2fafa..e9bf724 100644 --- a/mobile_agent/lib/screens/mcp_manager_screen.dart +++ b/mobile_agent/lib/screens/mcp_manager_screen.dart @@ -120,6 +120,26 @@ class _McpManagerScreenState extends ConsumerState { color: AppTheme.success, ), const Spacer(), + OutlinedButton.icon( + onPressed: _showMcpHubRegistrySheet, + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.primary, + side: const BorderSide(color: AppTheme.border), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: const Size(0, 36), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: const Icon(Icons.travel_explore_outlined, size: 16), + label: const Text( + 'Registry', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), // Add button ElevatedButton.icon( onPressed: _showAddServerDialog, @@ -135,7 +155,7 @@ class _McpManagerScreenState extends ConsumerState { ), icon: const Icon(Icons.add, size: 16), label: const Text( - '添加', + '登记', style: TextStyle( fontFamily: AppTheme.fontBody, fontSize: 12, @@ -155,22 +175,22 @@ class _McpManagerScreenState extends ConsumerState { children: [ _StatusLegend( label: '运行中', - color: const Color(McpServerStatus.running.colorHex), + color: Color(McpServerStatus.running.colorHex), ), const SizedBox(width: 12), _StatusLegend( label: '已停止', - color: const Color(McpServerStatus.stopped.colorHex), + color: Color(McpServerStatus.stopped.colorHex), ), const SizedBox(width: 12), _StatusLegend( label: '启动中', - color: const Color(McpServerStatus.starting.colorHex), + color: Color(McpServerStatus.starting.colorHex), ), const SizedBox(width: 12), _StatusLegend( label: '错误', - color: const Color(McpServerStatus.error.colorHex), + color: Color(McpServerStatus.error.colorHex), ), ], ), @@ -215,7 +235,7 @@ class _McpManagerScreenState extends ConsumerState { ), const SizedBox(height: 8), Text( - '安装带有 MCP 服务器的技能,或手动添加自定义服务器', + '装载带有 MCP 服务器的 Skill,或手动登记自定义服务器', style: TextStyle( fontFamily: AppTheme.fontBody, fontSize: 13, @@ -237,7 +257,7 @@ class _McpManagerScreenState extends ConsumerState { ), icon: const Icon(Icons.add, size: 18), label: const Text( - '添加 MCP 服务器', + '登记 MCP 服务器', style: TextStyle( fontFamily: AppTheme.fontBody, fontSize: 14, @@ -254,6 +274,172 @@ class _McpManagerScreenState extends ConsumerState { // Add Server Dialog // ═══════════════════════════════════════════════════════════════════════ + void _showMcpHubRegistrySheet() { + showModalBottomSheet( + context: context, + backgroundColor: AppTheme.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.74, + minChildSize: 0.45, + maxChildSize: 0.92, + builder: (context, scrollController) { + return FutureBuilder>( + future: ref.read(skillManagerServiceProvider).searchMcpRegistryServers(limit: 10), + builder: (context, snapshot) { + final servers = snapshot.data ?? const []; + return ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textTertiary.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + const Text( + 'MCP Registry', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 6), + const Text( + 'Only public GitHub metadata is imported. No registry account is required. Servers are registered disabled until you review command, env, and permissions.', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + height: 1.35, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 16), + if (snapshot.connectionState != ConnectionState.done) + const Center(child: CircularProgressIndicator(color: AppTheme.primary)) + else if (servers.isEmpty) + const Text( + 'No MCP candidates found.', + style: TextStyle(color: AppTheme.textTertiary), + ) + else + for (final server in servers) ...[ + _RegistryMcpTile( + server: server, + onTap: () => _showMcpRegistryPreview(server), + ), + const SizedBox(height: 10), + ], + ], + ); + }, + ); + }, + ); + }, + ); + } + + void _showMcpRegistryPreview(McpServer server) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: AppTheme.border), + ), + title: Text(server.name, style: const TextStyle(color: AppTheme.textPrimary)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + server.description ?? 'No description provided.', + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + height: 1.4, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 12), + _PreviewLine(label: 'Type', value: server.type), + _PreviewLine(label: 'Command', value: server.command.isEmpty ? 'Review upstream docs before running.' : server.command), + if (server.env.isNotEmpty) _PreviewLine(label: 'Env', value: server.env.keys.join(', ')), + const SizedBox(height: 10), + const Text( + 'Safety: registering does not start this MCP server. Enable it later only after reviewing secrets and workspace scope.', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + height: 1.35, + color: AppTheme.warning, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton.icon( + onPressed: () async { + Navigator.of(context).pop(); + await _registerMcpRegistryCandidate(server); + }, + icon: const Icon(Icons.add_link_outlined, size: 16), + label: const Text('Register disabled'), + ), + ], + ), + ); + } + + Future _registerMcpRegistryCandidate(McpServer server) async { + final candidate = server.copyWith( + isEnabled: false, + status: McpServerStatus.stopped, + registeredAt: DateTime.now(), + ); + try { + await ref.read(skillManagerServiceProvider).addCustomMcpServer(candidate); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('已注册但未启用: ${candidate.name}'), + backgroundColor: AppTheme.success, + behavior: SnackBarBehavior.floating, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('注册失败: $e'), + backgroundColor: AppTheme.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + void _showAddServerDialog() { _nameController.clear(); _commandController.clear(); @@ -270,7 +456,7 @@ class _McpManagerScreenState extends ConsumerState { side: const BorderSide(color: AppTheme.border), ), title: const Text( - '添加 MCP 服务器', + '登记 MCP 服务器', style: TextStyle( fontFamily: AppTheme.fontBody, fontSize: 18, @@ -282,6 +468,26 @@ class _McpManagerScreenState extends ConsumerState { child: Column( mainAxisSize: MainAxisSize.min, children: [ + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => _polishMcpDraft(setState), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.primary, + side: const BorderSide(color: AppTheme.border), + ), + icon: const Icon(Icons.auto_awesome_outlined, size: 16), + label: const Text( + 'AI 润色草案', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 12), // Server name TextField( controller: _nameController, @@ -422,7 +628,7 @@ class _McpManagerScreenState extends ConsumerState { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: const Text( - '添加', + '登记', style: TextStyle(fontFamily: AppTheme.fontBody, fontWeight: FontWeight.w600), ), ), @@ -432,6 +638,72 @@ class _McpManagerScreenState extends ConsumerState { ); } + void _polishMcpDraft(StateSetter dialogSetState) { + final endpoint = _newServerType == 'stdio' + ? _commandController.text.trim() + : _urlController.text.trim(); + if (endpoint.isEmpty && _nameController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('先输入命令或 URL,再润色 MCP 草案'), + backgroundColor: AppTheme.warning, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + dialogSetState(() { + if (_nameController.text.trim().isEmpty) { + _nameController.text = _inferMcpName(endpoint, _newServerType); + } + if (_newServerType == 'stdio') { + _commandController.text = _commandController.text.trim().replaceAll(RegExp(r'\s+'), ' '); + } else { + _urlController.text = _urlController.text.trim(); + } + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已按 MCP 模板整理名称和入口;登记前仍需人工审核命令、密钥和权限范围。'), + backgroundColor: AppTheme.success, + behavior: SnackBarBehavior.floating, + ), + ); + } + + String _inferMcpName(String endpoint, String type) { + final text = endpoint.trim(); + if (text.isEmpty) return type == 'sse' ? 'Remote MCP Server' : 'Custom MCP Server'; + if (type == 'sse') { + final uri = Uri.tryParse(text); + final host = uri?.host; + if (host != null && host.isNotEmpty) { + final name = host.split('.').where((part) => part.isNotEmpty && part != 'www').take(2).join(' '); + return name.isEmpty ? 'Remote MCP Server' : '${_titleCase(name)} MCP'; + } + return 'Remote MCP Server'; + } + final packageMatch = RegExp(r'(@[\w.-]+/[\w.-]+|[\w.-]*mcp[\w.-]*|server-[\w.-]+)', caseSensitive: false) + .firstMatch(text); + final raw = packageMatch?.group(0) ?? text.split(RegExp(r'\s+')).last; + final clean = raw + .replaceAll('@modelcontextprotocol/', '') + .replaceAll('@', '') + .replaceAll(RegExp(r'[-_/]+'), ' ') + .trim(); + return clean.isEmpty ? 'Custom MCP Server' : '${_titleCase(clean)} MCP'; + } + + String _titleCase(String value) { + return value + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .map((part) => part[0].toUpperCase() + (part.length > 1 ? part.substring(1) : '')) + .join(' '); + } + Future _addServer(BuildContext dialogContext) async { final name = _nameController.text.trim(); final command = _commandController.text.trim(); @@ -487,7 +759,7 @@ class _McpManagerScreenState extends ConsumerState { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('MCP 服务器已添加'), + content: Text('MCP 服务器已登记,默认不会自动启动'), backgroundColor: AppTheme.success, behavior: SnackBarBehavior.floating, ), @@ -496,7 +768,7 @@ class _McpManagerScreenState extends ConsumerState { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('添加失败: $e'), + content: Text('登记失败: $e'), backgroundColor: AppTheme.error, behavior: SnackBarBehavior.floating, ), @@ -1076,6 +1348,154 @@ class _StatusLegend extends StatelessWidget { } } +class _RegistryMcpTile extends StatelessWidget { + const _RegistryMcpTile({ + required this.server, + required this.onTap, + }); + + final McpServer server; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.backgroundElevated, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.12), + borderRadius: BorderRadius.circular(9), + ), + child: const Icon(Icons.account_tree_outlined, color: AppTheme.primary, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + server.name, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + server.description ?? server.command, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + height: 1.35, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _TypeBadge(type: server.type), + const _RegistryPill(label: 'disabled preview'), + if (server.env.isNotEmpty) const _RegistryPill(label: 'env required'), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: AppTheme.textTertiary), + ], + ), + ), + ); + } +} + +class _RegistryPill extends StatelessWidget { + const _RegistryPill({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: AppTheme.warning.withOpacity(0.10), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: AppTheme.warning.withOpacity(0.22)), + ), + child: Text( + label, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppTheme.warning, + ), + ), + ); + } +} + +class _PreviewLine extends StatelessWidget { + const _PreviewLine({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 76, + child: Text( + label, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + color: AppTheme.textTertiary, + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + fontFamily: AppTheme.fontCode, + fontSize: 12, + color: AppTheme.textSecondary, + ), + ), + ), + ], + ), + ); + } +} + /// Type badge showing "stdio" or "sse". class _TypeBadge extends StatelessWidget { final String type; diff --git a/mobile_agent/lib/screens/memory_manager_screen.dart b/mobile_agent/lib/screens/memory_manager_screen.dart index 0a06a78..995b088 100644 --- a/mobile_agent/lib/screens/memory_manager_screen.dart +++ b/mobile_agent/lib/screens/memory_manager_screen.dart @@ -13,6 +13,7 @@ // Each item supports swipe-to-delete and tap-to-view details. import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../core/theme.dart'; import '../services/memory_service.dart'; @@ -41,7 +42,7 @@ class _MemoryManagerScreenState extends State @override void initState() { super.initState(); - _tabController = TabController(length: 7, vsync: this); + _tabController = TabController(length: 8, vsync: this); _initialize(); } @@ -96,6 +97,7 @@ class _MemoryManagerScreenState extends State Tab(text: '错误模式'), Tab(text: '常用片段'), Tab(text: '用户修正'), + Tab(text: 'Rules'), Tab(text: '记忆统计'), Tab(text: '代码偏好'), ], @@ -116,6 +118,7 @@ class _MemoryManagerScreenState extends State _ErrorPatternsTab(memory: _memory), _FrequentSnippetsTab(memory: _memory), _UserCorrectionsTab(memory: _memory), + _MemoryRulesTab(memory: _memory), _MemoryStatsTab(memory: _memory), _CodePreferencesTab(memory: _memory), ], @@ -875,7 +878,442 @@ class _UserCorrectionsTab extends StatelessWidget { } // ═══════════════════════════════════════════════════════════════════════════ -// Tab 6: Memory Statistics +// Tab 6: Memory Rules +// ═══════════════════════════════════════════════════════════════════════════ + +class _MemoryRulesTab extends StatefulWidget { + final MemoryService memory; + + const _MemoryRulesTab({required this.memory}); + + @override + State<_MemoryRulesTab> createState() => _MemoryRulesTabState(); +} + +class _MemoryRulesTabState extends State<_MemoryRulesTab> { + late Future> _rules; + final TextEditingController _ruleTitleController = TextEditingController(); + final TextEditingController _ruleBodyController = TextEditingController(); + final TextEditingController _ruleCategoryController = TextEditingController(text: 'user-rule'); + + @override + void initState() { + super.initState(); + _rules = widget.memory.getMemoryRules(); + } + + @override + void dispose() { + _ruleTitleController.dispose(); + _ruleBodyController.dispose(); + _ruleCategoryController.dispose(); + super.dispose(); + } + + void _reload() { + setState(() { + _rules = widget.memory.getMemoryRules(); + }); + } + + Future _copyRulesFile() async { + final markdown = await widget.memory.buildRulesMarkdown(); + await Clipboard.setData(ClipboardData(text: markdown)); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('MOBILECODE_RULES.md 内容已复制'), + backgroundColor: AppTheme.success, + behavior: SnackBarBehavior.floating, + ), + ); + } + + Future _showAddRuleDialog() async { + _ruleTitleController.clear(); + _ruleBodyController.clear(); + _ruleCategoryController.text = 'user-rule'; + + await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.surface, + surfaceTintColor: AppTheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: AppTheme.border), + ), + title: const Text( + '新增 Rule', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _RuleInputField( + controller: _ruleTitleController, + label: '标题', + hint: '例如:HTML 默认走 GitHub Pages 发布', + ), + const SizedBox(height: 10), + _RuleInputField( + controller: _ruleCategoryController, + label: '分类', + hint: 'user-rule / repo-insight / workflow', + ), + const SizedBox(height: 10), + _RuleInputField( + controller: _ruleBodyController, + label: '规则内容', + hint: '写成短句,明确 MobileCode 以后应该如何执行。', + maxLines: 4, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消', style: TextStyle(color: AppTheme.textSecondary)), + ), + ElevatedButton.icon( + onPressed: () async { + final title = _ruleTitleController.text.trim(); + final body = _ruleBodyController.text.trim(); + if (title.isEmpty || body.isEmpty) return; + await widget.memory.upsertMemoryRule( + MemoryRule( + id: 'manual_rule_${DateTime.now().microsecondsSinceEpoch}', + title: title, + category: _ruleCategoryController.text.trim().isEmpty + ? 'user-rule' + : _ruleCategoryController.text.trim(), + rule: body, + source: 'manual', + evidenceRepos: const [], + createdAt: DateTime.now(), + enabled: true, + ), + ); + if (context.mounted) Navigator.of(context).pop(); + _reload(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primary, + foregroundColor: AppTheme.textOnPrimary, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)), + ), + icon: const Icon(Icons.save_outlined, size: 16), + label: const Text('保存'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _rules, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator(color: AppTheme.primary)); + } + final rules = snapshot.data!; + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: rules.isEmpty ? 2 : rules.length + 1, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + if (index == 0) { + return _RulesFileCard( + activeRuleCount: rules.where((rule) => rule.enabled).length, + onAddRule: _showAddRuleDialog, + onCopyRulesFile: _copyRulesFile, + ); + } + if (rules.isEmpty) { + return const _RulesEmptyCard(); + } + final ruleIndex = index - 1; + if (ruleIndex >= rules.length) { + return const SizedBox.shrink(); + } + final rule = rules[ruleIndex]; + return _GlassCard( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppTheme.accent.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.psychology_alt_outlined, color: AppTheme.accent, size: 19), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rule.title, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + '${rule.category} · ${rule.source}', + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + color: AppTheme.textTertiary, + ), + ), + ], + ), + ), + IconButton( + tooltip: '删除规则', + onPressed: () async { + await widget.memory.removeMemoryRule(rule.id); + _reload(); + }, + icon: const Icon(Icons.delete_outline, color: AppTheme.error, size: 20), + ), + ], + ), + const SizedBox(height: 10), + Text( + rule.rule, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + color: AppTheme.textSecondary, + height: 1.35, + ), + ), + if (rule.evidenceRepos.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final repo in rule.evidenceRepos.take(4)) + _StatChip(label: repo), + ], + ), + ], + ], + ), + ), + ); + }, + ); + }, + ); + } +} + +class _RulesFileCard extends StatelessWidget { + const _RulesFileCard({ + required this.activeRuleCount, + required this.onAddRule, + required this.onCopyRulesFile, + }); + + final int activeRuleCount; + final VoidCallback onAddRule; + final VoidCallback onCopyRulesFile; + + @override + Widget build(BuildContext context) { + return _GlassCard( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.12), + borderRadius: BorderRadius.circular(11), + ), + child: const Icon(Icons.rule_outlined, color: AppTheme.primary, size: 20), + ), + const SizedBox(width: 10), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'MOBILECODE_RULES.md', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppTheme.textPrimary, + ), + ), + SizedBox(height: 2), + Text( + 'Rules 是用户批准后的行为准则;Memory 是证据、偏好和可提案的经验。', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + height: 1.35, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _StatChip(label: '$activeRuleCount active rules'), + const _StatChip(label: 'user approved'), + const _StatChip(label: 'prompt injected later'), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: onAddRule, + icon: const Icon(Icons.add, size: 16), + label: const Text('新增 Rule'), + ), + OutlinedButton.icon( + onPressed: onCopyRulesFile, + icon: const Icon(Icons.copy_all_outlined, size: 16), + label: const Text('复制 RULES.md'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _RulesEmptyCard extends StatelessWidget { + const _RulesEmptyCard(); + + @override + Widget build(BuildContext context) { + return _GlassCard( + child: const Padding( + padding: EdgeInsets.all(18), + child: Column( + children: [ + Icon(Icons.rule_outlined, color: AppTheme.textTertiary, size: 34), + SizedBox(height: 10), + Text( + '还没有批准的 Rules', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + SizedBox(height: 6), + Text( + '你可以手动新增,也可以从仓库分析、Role/Memory proposal 中接受规则。Memory 不会自动变成 Rule。', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + height: 1.35, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ); + } +} + +class _RuleInputField extends StatelessWidget { + const _RuleInputField({ + required this.controller, + required this.label, + required this.hint, + this.maxLines = 1, + }); + + final TextEditingController controller; + final String label; + final String hint; + final int maxLines; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + color: AppTheme.textPrimary, + ), + decoration: InputDecoration( + labelText: label, + hintText: hint, + labelStyle: const TextStyle(color: AppTheme.textSecondary), + hintStyle: const TextStyle(color: AppTheme.textTertiary, fontSize: 12), + filled: true, + fillColor: AppTheme.surfaceInput, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppTheme.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppTheme.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppTheme.primary, width: 1.5), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tab 7: Memory Statistics // ═══════════════════════════════════════════════════════════════════════════ class _MemoryStatsTab extends StatelessWidget { @@ -920,6 +1358,12 @@ class _MemoryStatsTab extends StatelessWidget { icon: Icons.code, color: AppTheme.warning, ), + _StatCard( + title: '规则洞察', + value: '${stats.totalRules}', + icon: Icons.psychology_alt, + color: AppTheme.accent, + ), const SizedBox(height: 16), _GlassCard( child: Padding( @@ -940,6 +1384,7 @@ class _MemoryStatsTab extends StatelessWidget { _MemoryBar(label: '对话', value: stats.totalConversations, max: stats.totalItems, color: AppTheme.accent), _MemoryBar(label: '错误', value: stats.totalErrorPatterns, max: stats.totalItems, color: AppTheme.error), _MemoryBar(label: '片段', value: stats.totalSnippets, max: stats.totalItems, color: AppTheme.warning), + _MemoryBar(label: '规则', value: stats.totalRules, max: stats.totalItems, color: AppTheme.accent), const SizedBox(height: 12), Text( '总占用: ${stats.memorySizeKB} KB', diff --git a/mobile_agent/lib/screens/role_manager_screen.dart b/mobile_agent/lib/screens/role_manager_screen.dart new file mode 100644 index 0000000..10dbdba --- /dev/null +++ b/mobile_agent/lib/screens/role_manager_screen.dart @@ -0,0 +1,724 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../core/theme.dart'; +import '../services/role_library_service.dart'; + +const _roleAvatarChoices = [ + 'assets/role_avatars/avatar-batch2-01-mist-studio.svg', + 'assets/role_avatars/avatar-batch2-02-office-glasses.svg', + 'assets/role_avatars/avatar-batch2-09-blue-cap.svg', + 'assets/role_avatars/avatar-batch2-15-tech.svg', + 'assets/role_avatars/avatar-batch2-18-pencil-wash.svg', + 'assets/role_avatars/avatar-batch2-21-navy.svg', + 'assets/role_avatars/avatar-batch2-23-mono.svg', + 'assets/role_avatars/avatar-batch2-24-rounded-icon.svg', + 'assets/role_avatars/avatar-batch2-35-yellow-bucket.svg', +]; + +const _roleColorChoices = [ + 0xFF7557E8, + 0xFF2555FF, + 0xFF16B9C7, + 0xFF0B9B7E, + 0xFFB7791F, + 0xFFE0526E, + 0xFF4F8F2D, + 0xFF0B1020, +]; + +class RoleManagerScreen extends StatefulWidget { + const RoleManagerScreen({ + super.key, + this.onPolishRoleIntent, + }); + + final Future Function(String intent)? onPolishRoleIntent; + + @override + State createState() => _RoleManagerScreenState(); +} + +class _RoleManagerScreenState extends State { + final _service = RoleLibraryService.instance; + bool _loading = true; + + @override + void initState() { + super.initState(); + _service.addListener(_handleRoleLibraryChanged); + _load(); + } + + @override + void dispose() { + _service.removeListener(_handleRoleLibraryChanged); + super.dispose(); + } + + Future _load() async { + await _service.initialize(); + if (!mounted) return; + setState(() => _loading = false); + } + + void _handleRoleLibraryChanged() { + if (mounted) setState(() {}); + } + + Future _openEditor([MobileCodeRole? role]) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18))), + builder: (context) => _RoleEditorSheet( + initialRole: role, + onPolishRoleIntent: widget.onPolishRoleIntent, + onSave: (nextRole) async { + await _service.upsertCustomRole(nextRole); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar(const SnackBar(content: Text('Role saved'))); + } + }, + ), + ); + } + + Future _removeRole(MobileCodeRole role) async { + await _service.removeCustomRole(role.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${role.name} removed'))); + } + + @override + Widget build(BuildContext context) { + final roles = _service.allRoles; + final enabled = _service.enabledRoles.length; + + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: AppTheme.background.withOpacity(0.92), + elevation: 0, + centerTitle: true, + title: const Text( + 'Roles', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 20, color: AppTheme.textSecondary), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + IconButton( + tooltip: 'Add custom role', + icon: const Icon(Icons.add_circle_outline, color: AppTheme.primary), + onPressed: () => _openEditor(), + ), + const SizedBox(width: 6), + ], + ), + floatingActionButton: FloatingActionButton.extended( + backgroundColor: AppTheme.primary, + foregroundColor: AppTheme.textOnPrimary, + onPressed: () => _openEditor(), + icon: const Icon(Icons.add), + label: const Text('自定义角色'), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 96), + children: [ + _RoleLibraryHeader(total: roles.length, enabled: enabled), + const SizedBox(height: 12), + for (final role in roles) ...[ + _RoleCard( + role: role, + onToggle: (value) => _service.setRoleEnabled(role.id, value), + onEdit: role.builtIn ? null : () => _openEditor(role), + onDelete: role.builtIn ? null : () => _removeRole(role), + ), + const SizedBox(height: 10), + ], + ], + ), + ); + } +} + +class _RoleLibraryHeader extends StatelessWidget { + const _RoleLibraryHeader({ + required this.total, + required this.enabled, + }); + + final int total; + final int enabled; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.groups_2_outlined, color: AppTheme.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Role Recruit library', + style: TextStyle( + fontFamily: AppTheme.fontBody, + color: AppTheme.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + Text( + '$enabled enabled / $total roles', + style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'RR mode uses these roles as personalities and responsibilities inside one execution lane. It is not a parallel multi-agent scheduler.', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 13, height: 1.35), + ), + ], + ), + ); + } +} + +class _RoleCard extends StatelessWidget { + const _RoleCard({ + required this.role, + required this.onToggle, + required this.onEdit, + required this.onDelete, + }); + + final MobileCodeRole role; + final ValueChanged onToggle; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final color = Color(role.colorValue); + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: role.enabled ? color.withOpacity(0.35) : AppTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _RoleAvatar(asset: role.avatarAsset, color: color, size: 48), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + role.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + color: AppTheme.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + role.builtIn ? 'built-in' : 'custom', + style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w800), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + role.summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12, height: 1.3), + ), + ], + ), + ), + Switch.adaptive(value: role.enabled, onChanged: onToggle), + ], + ), + const SizedBox(height: 12), + Text( + role.mission, + style: const TextStyle(color: AppTheme.textPrimary, fontSize: 13, height: 1.35), + ), + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final item in role.responsibilities.take(3)) + _RoleChip(label: item, color: color), + ], + ), + if (onEdit != null || onDelete != null) ...[ + const SizedBox(height: 10), + Row( + children: [ + if (onEdit != null) + OutlinedButton.icon( + onPressed: onEdit, + icon: const Icon(Icons.edit_outlined, size: 16), + label: const Text('编辑'), + ), + if (onEdit != null && onDelete != null) const SizedBox(width: 8), + if (onDelete != null) + OutlinedButton.icon( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline, size: 16), + label: const Text('删除'), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _RoleChip extends StatelessWidget { + const _RoleChip({required this.label, required this.color}); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withOpacity(0.18)), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w700), + ), + ); + } +} + +class _RoleAvatar extends StatelessWidget { + const _RoleAvatar({ + required this.asset, + required this.color, + required this.size, + }); + + final String asset; + final Color color; + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: color.withOpacity(0.24)), + ), + child: SvgPicture.asset( + asset, + fit: BoxFit.contain, + placeholderBuilder: (_) => Icon(Icons.person_outline, color: color), + ), + ); + } +} + +class _RoleEditorSheet extends StatefulWidget { + const _RoleEditorSheet({ + required this.initialRole, + required this.onSave, + required this.onPolishRoleIntent, + }); + + final MobileCodeRole? initialRole; + final ValueChanged onSave; + final Future Function(String intent)? onPolishRoleIntent; + + @override + State<_RoleEditorSheet> createState() => _RoleEditorSheetState(); +} + +class _RoleEditorSheetState extends State<_RoleEditorSheet> { + final _intent = TextEditingController(); + final _name = TextEditingController(); + final _summary = TextEditingController(); + final _mission = TextEditingController(); + final _personality = TextEditingController(); + final _responsibilities = TextEditingController(); + final _guardrails = TextEditingController(); + final _successCriteria = TextEditingController(); + final _promptTemplate = TextEditingController(); + bool _polishing = false; + String _avatarAsset = RoleLibraryService.defaultCustomAvatarAsset; + int _colorValue = 0xFF7557E8; + + @override + void initState() { + super.initState(); + final role = widget.initialRole; + if (role != null) _applyRole(role); + } + + @override + void dispose() { + _intent.dispose(); + _name.dispose(); + _summary.dispose(); + _mission.dispose(); + _personality.dispose(); + _responsibilities.dispose(); + _guardrails.dispose(); + _successCriteria.dispose(); + _promptTemplate.dispose(); + super.dispose(); + } + + void _applyRole(MobileCodeRole role) { + _name.text = role.name; + _summary.text = role.summary; + _mission.text = role.mission; + _personality.text = role.personality; + _responsibilities.text = role.responsibilities.join('\n'); + _guardrails.text = role.guardrails.join('\n'); + _successCriteria.text = role.successCriteria.join('\n'); + _promptTemplate.text = role.promptTemplate; + _avatarAsset = role.avatarAsset; + _colorValue = role.colorValue; + } + + Future _polish() async { + final rawIntent = _intent.text.trim().isEmpty ? _mission.text.trim() : _intent.text.trim(); + if (rawIntent.isEmpty) { + _snack('先写一句你想要的角色意图'); + return; + } + + setState(() => _polishing = true); + try { + final output = widget.onPolishRoleIntent == null + ? '' + : await widget.onPolishRoleIntent!(rawIntent); + final polished = output.trim().isEmpty + ? RoleLibraryService.instance.standardizeLocalIntent(rawIntent) + : RoleLibraryService.instance.parsePolishedOutput(output, fallbackIntent: rawIntent); + final role = polished.copyWith(avatarAsset: _avatarAsset, colorValue: _colorValue); + _applyRole(role); + _snack(output.trim().isEmpty ? '已用本地模板标准化' : 'AI 已润色为标准角色卡'); + } catch (error) { + final role = RoleLibraryService.instance.standardizeLocalIntent(rawIntent).copyWith( + avatarAsset: _avatarAsset, + colorValue: _colorValue, + ); + _applyRole(role); + _snack('AI 润色不可用,已用本地模板标准化'); + } finally { + if (mounted) setState(() => _polishing = false); + } + } + + void _save() { + if (_name.text.trim().isEmpty || _mission.text.trim().isEmpty) { + _snack('角色名称和使命不能为空'); + return; + } + final previous = widget.initialRole; + final role = MobileCodeRole( + id: previous?.id ?? '', + name: _name.text.trim(), + summary: _summary.text.trim(), + mission: _mission.text.trim(), + personality: _personality.text.trim(), + responsibilities: _lines(_responsibilities.text), + guardrails: _lines(_guardrails.text), + successCriteria: _lines(_successCriteria.text), + promptTemplate: _promptTemplate.text.trim(), + avatarAsset: _avatarAsset, + colorValue: _colorValue, + builtIn: false, + ); + widget.onSave(role); + Navigator.of(context).pop(); + } + + void _snack(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return Padding( + padding: EdgeInsets.fromLTRB(16, 14, 16, bottomInset + 16), + child: SafeArea( + top: false, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(Icons.badge_outlined, color: AppTheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.initialRole == null ? 'Create custom role' : 'Edit custom role', + style: const TextStyle( + fontFamily: AppTheme.fontBody, + color: AppTheme.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + tooltip: 'AI 润色角色定义', + onPressed: _polishing ? null : _polish, + icon: _polishing + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.auto_fix_high_outlined, color: AppTheme.primary), + ), + ], + ), + const SizedBox(height: 8), + const Text( + '先用自然语言说清楚你想要什么角色,再点 AI 润色。MobileCode 会用标准角色模板生成可维护的 role card。', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 12, height: 1.35), + ), + const SizedBox(height: 12), + _field(_intent, 'Role intent', '例如:帮我定义一个专门检查 GitHub Pages 发布错误的角色', maxLines: 3), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton.icon( + onPressed: _polishing ? null : _polish, + icon: _polishing + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.auto_fix_high_outlined, size: 17), + label: const Text('AI 润色角色卡'), + ), + ), + const SizedBox(height: 10), + _buildAvatarPicker(), + const SizedBox(height: 10), + Row( + children: [ + Expanded(child: _field(_name, 'Name', 'GitHub Publisher')), + const SizedBox(width: 10), + Expanded(child: _field(_summary, 'Summary', 'One short sentence')), + ], + ), + _field(_mission, 'Mission', 'This role owns...', maxLines: 2), + _field(_personality, 'Personality', 'Calm, exact, mobile-first...', maxLines: 2), + _field(_responsibilities, 'Responsibilities', 'One per line', maxLines: 4), + _field(_guardrails, 'Guardrails', 'One per line', maxLines: 3), + _field(_successCriteria, 'Success criteria', 'One per line', maxLines: 3), + _field(_promptTemplate, 'Prompt template', 'You are...', maxLines: 5), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + label: const Text('取消'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: _save, + icon: const Icon(Icons.save_outlined), + label: const Text('保存角色'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _field(TextEditingController controller, String label, String hint, {int maxLines = 1}) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: controller, + minLines: 1, + maxLines: maxLines, + style: const TextStyle(color: AppTheme.textPrimary, fontFamily: AppTheme.fontBody), + decoration: InputDecoration( + labelText: label, + hintText: hint, + alignLabelWithHint: maxLines > 1, + labelStyle: const TextStyle(color: AppTheme.textSecondary), + hintStyle: const TextStyle(color: AppTheme.textTertiary), + filled: true, + fillColor: AppTheme.surfaceInput, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppTheme.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppTheme.primary, width: 1.5), + ), + ), + ), + ); + } + + Widget _buildAvatarPicker() { + final selectedColor = Color(_colorValue); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.backgroundElevated, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Role avatar', + style: TextStyle(color: AppTheme.textPrimary, fontSize: 12, fontWeight: FontWeight.w800), + ), + const SizedBox(height: 10), + SizedBox( + height: 54, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _roleAvatarChoices.length, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final asset = _roleAvatarChoices[index]; + final selected = asset == _avatarAsset; + return InkWell( + onTap: () => setState(() => _avatarAsset = asset), + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + width: 50, + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: selected ? selectedColor.withOpacity(0.16) : AppTheme.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: selected ? selectedColor : AppTheme.border), + ), + child: SvgPicture.asset( + asset, + fit: BoxFit.contain, + placeholderBuilder: (_) => Icon(Icons.person_outline, color: selectedColor, size: 18), + ), + ), + ); + }, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final colorValue in _roleColorChoices) + InkWell( + onTap: () => setState(() => _colorValue = colorValue), + borderRadius: BorderRadius.circular(999), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Color(colorValue), + shape: BoxShape.circle, + border: Border.all( + color: colorValue == _colorValue ? AppTheme.textPrimary : AppTheme.border, + width: colorValue == _colorValue ? 2 : 1, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +List _lines(String value) { + return value + .split(RegExp(r'[\n;]')) + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); +} diff --git a/mobile_agent/lib/screens/screens.dart b/mobile_agent/lib/screens/screens.dart index b8e4360..7e4f63a 100644 --- a/mobile_agent/lib/screens/screens.dart +++ b/mobile_agent/lib/screens/screens.dart @@ -7,9 +7,12 @@ library; export 'api_config_screen.dart'; +export 'api_usage_screen.dart'; +export 'device_telemetry_screen.dart'; export 'editor_screen.dart'; export 'github_screen.dart'; export 'home_screen.dart'; export 'projects_screen.dart'; +export 'role_manager_screen.dart'; export 'settings_screen.dart'; -export 'snippets_screen.dart'; \ No newline at end of file +export 'snippets_screen.dart'; diff --git a/mobile_agent/lib/screens/settings_screen.dart b/mobile_agent/lib/screens/settings_screen.dart index 4b252c5..e1edd6a 100644 --- a/mobile_agent/lib/screens/settings_screen.dart +++ b/mobile_agent/lib/screens/settings_screen.dart @@ -24,7 +24,7 @@ class _SettingsScreenState extends State { String _themeMode = 'dark'; // dark, light, system // AI settings - String _defaultApi = 'OpenAI GPT-4'; + String _defaultApi = 'Custom Provider / Base URL'; double _temperature = 0.7; // GitHub diff --git a/mobile_agent/lib/screens/skill_manager_screen.dart b/mobile_agent/lib/screens/skill_manager_screen.dart index 6d2a8c3..136589b 100644 --- a/mobile_agent/lib/screens/skill_manager_screen.dart +++ b/mobile_agent/lib/screens/skill_manager_screen.dart @@ -8,8 +8,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/theme.dart'; import '../models/skill_model.dart'; import '../providers/skill_provider.dart'; +import '../services/model_provider_polish_service.dart'; import '../services/skill_manager_service.dart'; -import 'mcp_manager_screen.dart'; + +enum _SkillDiscoverySource { github, curated } // ═══════════════════════════════════════════════════════════════════════════ // Skill Manager Screen @@ -18,9 +20,8 @@ import 'mcp_manager_screen.dart'; /// Skill Manager Screen /// /// Tabbed interface: -/// - 已安装: Installed skills list with enable/disable toggle +/// - 已装载: Loaded skills list with enable/disable toggle /// - 发现: Skill marketplace (GitHub skills browser) -/// - MCP: MCP server management overview /// /// Each skill card: /// - Icon + Name + Version @@ -41,12 +42,18 @@ class _SkillManagerScreenState extends ConsumerState late TabController _tabController; final TextEditingController _searchController = TextEditingController(); final TextEditingController _githubUrlController = TextEditingController(); + final TextEditingController _customSkillNameController = TextEditingController(); + final TextEditingController _customSkillDescriptionController = TextEditingController(); + final TextEditingController _customSkillTagsController = TextEditingController(); + final TextEditingController _customSkillActionsController = TextEditingController(); + final TextEditingController _customSkillPromptsController = TextEditingController(); + _SkillDiscoverySource _discoverySource = _SkillDiscoverySource.github; bool _isSearching = false; @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); + _tabController = TabController(length: 2, vsync: this); _tabController.addListener(() { ref.read(skillTabIndexProvider.notifier).state = _tabController.index; setState(() {}); @@ -67,6 +74,11 @@ class _SkillManagerScreenState extends ConsumerState _tabController.dispose(); _searchController.dispose(); _githubUrlController.dispose(); + _customSkillNameController.dispose(); + _customSkillDescriptionController.dispose(); + _customSkillTagsController.dispose(); + _customSkillActionsController.dispose(); + _customSkillPromptsController.dispose(); super.dispose(); } @@ -94,6 +106,11 @@ class _SkillManagerScreenState extends ConsumerState onPressed: () => Navigator.of(context).pop(), ), actions: [ + IconButton( + tooltip: '自定义技能', + icon: const Icon(Icons.add_circle_outline, color: AppTheme.primary, size: 21), + onPressed: _showCustomSkillSheet, + ), // Stats badge Center( child: _buildStatsBadge(), @@ -125,9 +142,8 @@ class _SkillManagerScreenState extends ConsumerState fontWeight: FontWeight.w500, ), tabs: const [ - Tab(text: '已安装'), + Tab(text: '已装载'), Tab(text: '发现'), - Tab(text: 'MCP'), ], ), ), @@ -140,26 +156,67 @@ class _SkillManagerScreenState extends ConsumerState _buildInstalledTab(), // Tab 2: Discover (marketplace) _buildDiscoverTab(), - // Tab 3: MCP overview - const McpManagerScreen(), ], ), - floatingActionButton: tabIndex == 1 - ? FloatingActionButton.extended( - onPressed: _showGitHubImportSheet, - backgroundColor: AppTheme.primary, - icon: const Icon(Icons.code, color: AppTheme.textOnPrimary, size: 20), - label: const Text( - 'GitHub 导入', - style: TextStyle( - fontFamily: AppTheme.fontBody, - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.textOnPrimary, - ), + floatingActionButton: _buildSkillFab(tabIndex), + ); + } + + Widget? _buildSkillFab(int tabIndex) { + if (tabIndex == 1) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + FloatingActionButton.extended( + heroTag: 'custom-skill-fab', + onPressed: _showCustomSkillSheet, + backgroundColor: AppTheme.accent, + icon: const Icon(Icons.add, color: AppTheme.background, size: 20), + label: const Text( + '自定义技能', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppTheme.background, + ), + ), + ), + const SizedBox(height: 10), + FloatingActionButton.extended( + heroTag: 'github-skill-fab', + onPressed: _showGitHubImportSheet, + backgroundColor: AppTheme.primary, + icon: const Icon(Icons.code, color: AppTheme.textOnPrimary, size: 20), + label: const Text( + 'GitHub 导入', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.textOnPrimary, ), - ) - : null, + ), + ), + ], + ); + } + + return FloatingActionButton.extended( + heroTag: 'custom-skill-installed-fab', + onPressed: _showCustomSkillSheet, + backgroundColor: AppTheme.accent, + icon: const Icon(Icons.add, color: AppTheme.background, size: 20), + label: const Text( + '自定义技能', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppTheme.background, + ), + ), ); } @@ -196,8 +253,8 @@ class _SkillManagerScreenState extends ConsumerState if (installed.isEmpty) { return _buildEmptyState( icon: Icons.extension_off_outlined, - title: '暂无已安装技能', - subtitle: '前往「发现」标签浏览并安装技能', + title: '暂无已装载技能', + subtitle: '前往「发现」标签浏览并装载技能', ); } @@ -225,9 +282,28 @@ class _SkillManagerScreenState extends ConsumerState Widget _buildDiscoverTab() { final searchQuery = ref.watch(skillSearchQueryProvider); + final sourceLabel = _discoverySource == _SkillDiscoverySource.github ? 'GitHub' : 'Curated GitHub'; return Column( children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + children: [ + _SourceChip( + label: 'GitHub', + selected: _discoverySource == _SkillDiscoverySource.github, + onTap: () => setState(() => _discoverySource = _SkillDiscoverySource.github), + ), + const SizedBox(width: 8), + _SourceChip( + label: 'Curated', + selected: _discoverySource == _SkillDiscoverySource.curated, + onTap: () => setState(() => _discoverySource = _SkillDiscoverySource.curated), + ), + ], + ), + ), // Search bar Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), @@ -242,7 +318,7 @@ class _SkillManagerScreenState extends ConsumerState color: AppTheme.textPrimary, ), decoration: InputDecoration( - hintText: '搜索 GitHub 技能...', + hintText: '搜索 $sourceLabel 技能...', hintStyle: const TextStyle( fontFamily: AppTheme.fontBody, fontSize: 14, @@ -288,7 +364,9 @@ class _SkillManagerScreenState extends ConsumerState } Widget _buildTrendingSkills() { - final trendingAsync = ref.watch(trendingSkillsProvider); + final AsyncValue> trendingAsync = _discoverySource == _SkillDiscoverySource.curated + ? ref.watch(curatedSkillSearchProvider((query: null, limit: 12))) + : ref.watch(trendingSkillsProvider); return trendingAsync.when( data: (skills) { @@ -311,7 +389,7 @@ class _SkillManagerScreenState extends ConsumerState skill: skill, showActions: true, isInstalled: isInstalled, - onInstall: () => _installSkill(skill), + onInstall: () => _previewOrInstallDiscoveredSkill(skill), ), ); }, @@ -325,7 +403,9 @@ class _SkillManagerScreenState extends ConsumerState } Widget _buildSearchResults(String query) { - final searchAsync = ref.watch(skillSearchProvider(query)); + final AsyncValue> searchAsync = _discoverySource == _SkillDiscoverySource.curated + ? ref.watch(curatedSkillSearchProvider((query: query, limit: 12))) + : ref.watch(githubSkillSearchProvider((query: query, language: ''))); return searchAsync.when( data: (skills) { @@ -346,7 +426,7 @@ class _SkillManagerScreenState extends ConsumerState child: SkillCard( skill: skill, showActions: true, - onInstall: () => _installSkill(skill), + onInstall: () => _previewOrInstallDiscoveredSkill(skill), ), ); }, @@ -363,6 +443,277 @@ class _SkillManagerScreenState extends ConsumerState // GitHub Import Flow // ═══════════════════════════════════════════════════════════════════════ + void _showCustomSkillSheet() { + _customSkillNameController.clear(); + _customSkillDescriptionController.clear(); + _customSkillTagsController.text = 'custom, mobilecode'; + _customSkillActionsController.clear(); + _customSkillPromptsController.clear(); + + showModalBottomSheet( + context: context, + backgroundColor: AppTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + isScrollControlled: true, + builder: (context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + left: 20, + right: 20, + top: 20, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textTertiary.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 18), + const Row( + children: [ + Icon(Icons.add_circle_outline, color: AppTheme.accent, size: 20), + SizedBox(width: 8), + Expanded( + child: Text( + '自定义技能', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + const Text( + '把常用 prompt、动作入口或工作习惯注册成轻量 Skill。保存前不会执行任何代码。', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + height: 1.35, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 16), + _CustomSkillField( + controller: _customSkillNameController, + label: '技能名称', + hint: '例如:移动端 HTML 打磨助手', + icon: Icons.badge_outlined, + ), + const SizedBox(height: 10), + _CustomSkillField( + controller: _customSkillDescriptionController, + label: '一句话描述 / 用户意图', + hint: '描述这个技能应该帮你做什么', + icon: Icons.notes_outlined, + maxLines: 3, + ), + const SizedBox(height: 10), + _CustomSkillField( + controller: _customSkillTagsController, + label: '标签', + hint: 'custom, html, ui', + icon: Icons.sell_outlined, + ), + const SizedBox(height: 10), + _CustomSkillField( + controller: _customSkillActionsController, + label: '动作 ID(可选,逗号分隔)', + hint: 'html.polish, github.pages.publish', + icon: Icons.bolt_outlined, + ), + const SizedBox(height: 10), + _CustomSkillField( + controller: _customSkillPromptsController, + label: 'Prompt ID(可选,逗号分隔)', + hint: 'mobile_html_review', + icon: Icons.chat_bubble_outline, + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _polishCustomSkillDraft(), + icon: const Icon(Icons.auto_awesome_outlined, size: 17), + label: const Text('AI 润色草案'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _saveCustomSkill(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accent, + foregroundColor: AppTheme.background, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + icon: const Icon(Icons.save_outlined, size: 17), + label: const Text( + '装载 Skill', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 20), + ], + ), + ), + ); + }, + ); + } + + Future _polishCustomSkillDraft() async { + final draft = SkillPolishDraft( + name: _customSkillNameController.text.trim(), + description: _customSkillDescriptionController.text.trim(), + tags: _parseCsv(_customSkillTagsController.text), + actions: _parseCsv(_customSkillActionsController.text), + prompts: _parseCsv(_customSkillPromptsController.text), + ); + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ), + ); + + final result = await ModelProviderPolishService.instance.polishSkillDraft(draft); + if (!mounted) return; + Navigator.of(context, rootNavigator: true).pop(); + + _applyPolishedSkillDraft(result.draft); + final usedProvider = result.usedProvider; + final reason = result.fallbackReason; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + usedProvider + ? '已调用模型 provider 标准化 Skill 草案' + : 'Provider 不可用,已使用离线 fallback${reason == null ? '' : ': $reason'}', + ), + backgroundColor: usedProvider ? AppTheme.success : AppTheme.warning, + behavior: SnackBarBehavior.floating, + ), + ); + } + + void _applyPolishedSkillDraft(SkillPolishDraft draft) { + _customSkillNameController.text = draft.name; + _customSkillDescriptionController.text = draft.description; + _customSkillTagsController.text = draft.tags.join(', '); + _customSkillActionsController.text = draft.actions.join(', '); + _customSkillPromptsController.text = draft.prompts.join(', '); + } + + Future _saveCustomSkill(BuildContext sheetContext) async { + final name = _customSkillNameController.text.trim(); + final description = _customSkillDescriptionController.text.trim(); + if (name.isEmpty || description.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('请填写技能名称和描述'), + backgroundColor: AppTheme.warning, + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + final service = ref.read(skillManagerServiceProvider); + var id = 'custom_${_slugify(name)}'; + if (id == 'custom_') { + id = 'custom_skill_${DateTime.now().millisecondsSinceEpoch}'; + } + if (service.getSkill(id) != null) { + id = '${id}_${DateTime.now().millisecondsSinceEpoch}'; + } + + final skill = Skill( + id: id, + name: name, + version: '1.0.0', + description: description, + author: 'local-user', + tags: _parseCsv(_customSkillTagsController.text), + actions: _parseCsv(_customSkillActionsController.text), + prompts: _parseCsv(_customSkillPromptsController.text), + mcpServers: const [], + source: SkillSource.userCreated, + isEnabled: true, + isInstalled: true, + installedAt: DateTime.now(), + readme: + '# $name\n\n$description\n\nSource: MobileCode custom skill. It is local metadata only and does not execute scripts by itself.', + ); + + try { + await service.install(skill); + if (!mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('已装载自定义 Skill: $name'), + backgroundColor: AppTheme.success, + behavior: SnackBarBehavior.floating, + ), + ); + _tabController.animateTo(0); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('创建失败: $e'), + backgroundColor: AppTheme.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + List _parseCsv(String value) { + return value + .split(RegExp(r'[,,\n]')) + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toSet() + .toList(); + } + + String _slugify(String value) { + final slug = value + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'_+'), '_') + .replaceAll(RegExp(r'^_|_$'), ''); + return slug; + } + void _showGitHubImportSheet() { _githubUrlController.clear(); showModalBottomSheet( @@ -646,6 +997,9 @@ class _SkillManagerScreenState extends ConsumerState ), const SizedBox(height: 16), + _buildManifestAuditSection(skill), + const SizedBox(height: 16), + // Actions preview if (skill.hasActions) ...[ _buildPreviewSection('Actions', skill.actions, Icons.bolt), @@ -685,7 +1039,7 @@ class _SkillManagerScreenState extends ConsumerState ), icon: const Icon(Icons.download, size: 16), label: const Text( - '安装', + '装载', style: TextStyle(fontFamily: AppTheme.fontBody, fontWeight: FontWeight.w600), ), ), @@ -694,6 +1048,72 @@ class _SkillManagerScreenState extends ConsumerState ); } + Widget _buildManifestAuditSection(Skill skill) { + final risks = _skillRiskLabels(skill); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.backgroundElevated, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.fact_check_outlined, size: 15, color: AppTheme.primary), + SizedBox(width: 6), + Text( + 'Manifest review', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + skill.githubUrl ?? 'No GitHub provenance URL recorded.', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 11, + color: AppTheme.textSecondary, + height: 1.35, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _SkillRiskChip(label: '${skill.actions.length} actions', color: AppTheme.info, icon: Icons.bolt_outlined), + _SkillRiskChip(label: '${skill.prompts.length} prompts', color: AppTheme.primary, icon: Icons.chat_bubble_outline), + _SkillRiskChip(label: '${skill.mcpServers.length} MCP', color: skill.hasMcpServers ? AppTheme.warning : AppTheme.textTertiary, icon: Icons.dns_outlined), + for (final risk in risks) _SkillRiskChip(label: risk.label, color: risk.color, icon: risk.icon), + ], + ), + const SizedBox(height: 8), + const Text( + 'Load only after source, manifest, MCP commands, and permissions match what you expect.', + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 11, + color: AppTheme.textTertiary, + height: 1.35, + ), + ), + ], + ), + ); + } + Widget _buildPreviewSection(String title, List items, IconData icon) { return Container( width: double.infinity, @@ -769,18 +1189,18 @@ class _SkillManagerScreenState extends ConsumerState if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('已安装: ${skill.name}'), + content: Text('已装载: ${skill.name}'), backgroundColor: AppTheme.success, behavior: SnackBarBehavior.floating, ), ); - // Switch to installed tab + // Switch to loaded tab _tabController.animateTo(0); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('安装失败: $e'), + content: Text('装载失败: $e'), backgroundColor: AppTheme.error, behavior: SnackBarBehavior.floating, ), @@ -788,6 +1208,41 @@ class _SkillManagerScreenState extends ConsumerState } } + Future _previewOrInstallDiscoveredSkill(Skill skill) async { + final shouldPreviewManifest = + skill.githubUrl != null && (skill.actions.isEmpty && skill.prompts.isEmpty && skill.mcpServers.isEmpty); + if (!shouldPreviewManifest) { + await _installSkill(skill); + return; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ), + ); + + try { + final preview = await ref.read(skillManagerServiceProvider).importFromGitHub(skill.githubUrl!); + if (!mounted) return; + Navigator.of(context, rootNavigator: true).pop(); + _showSkillPreviewDialog(preview); + } catch (e) { + if (!mounted) return; + Navigator.of(context, rootNavigator: true).pop(); + _showSkillPreviewDialog(skill); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('未找到 skill.yaml,已改为仓库元数据预览: $e'), + backgroundColor: AppTheme.warning, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + void _confirmUninstall(Skill skill) { showDialog( context: context, @@ -953,10 +1408,143 @@ class _SkillManagerScreenState extends ConsumerState } } +class _CustomSkillField extends StatelessWidget { + const _CustomSkillField({ + required this.controller, + required this.label, + required this.hint, + required this.icon, + this.maxLines = 1, + }); + + final TextEditingController controller; + final String label; + final String hint; + final IconData icon; + final int maxLines; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLines: maxLines, + minLines: maxLines, + style: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 14, + color: AppTheme.textPrimary, + ), + decoration: InputDecoration( + labelText: label, + labelStyle: const TextStyle( + fontFamily: AppTheme.fontBody, + color: AppTheme.textSecondary, + ), + hintText: hint, + hintStyle: const TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 13, + color: AppTheme.textTertiary, + ), + prefixIcon: Icon(icon, size: 19, color: AppTheme.textTertiary), + filled: true, + fillColor: AppTheme.surfaceInput, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppTheme.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppTheme.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppTheme.accent, width: 1.5), + ), + ), + ); + } +} + // ═══════════════════════════════════════════════════════════════════════════ // Skill Card Widget // ═══════════════════════════════════════════════════════════════════════════ +class _SkillRisk { + const _SkillRisk({ + required this.label, + required this.icon, + required this.color, + }); + + final String label; + final IconData icon; + final Color color; +} + +List<_SkillRisk> _skillRiskLabels(Skill skill) { + final risks = <_SkillRisk>[]; + if (skill.githubUrl == null || skill.githubUrl!.trim().isEmpty) { + risks.add(const _SkillRisk(label: 'No provenance URL', icon: Icons.link_off_outlined, color: AppTheme.warning)); + } else { + risks.add(const _SkillRisk(label: 'GitHub provenance', icon: Icons.verified_outlined, color: AppTheme.success)); + } + if (!skill.hasActions && !skill.hasPrompts && !skill.hasMcpServers) { + risks.add(const _SkillRisk(label: 'Metadata-only manifest', icon: Icons.info_outline, color: AppTheme.textTertiary)); + } + if (skill.hasMcpServers) { + risks.add(const _SkillRisk(label: 'MCP command review', icon: Icons.security_outlined, color: AppTheme.warning)); + } + if (skill.tags.any((tag) => tag.toLowerCase().contains('external'))) { + risks.add(const _SkillRisk(label: 'External registry source', icon: Icons.travel_explore_outlined, color: AppTheme.warning)); + } + if (skill.source == SkillSource.github && skill.readme == null) { + risks.add(const _SkillRisk(label: 'README not fetched', icon: Icons.article_outlined, color: AppTheme.info)); + } + return risks; +} + +class _SkillRiskChip extends StatelessWidget { + const _SkillRiskChip({ + required this.label, + required this.color, + required this.icon, + }); + + final String label; + final Color color; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(7), + border: Border.all(color: color.withOpacity(0.26)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 12), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 10.5, + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + /// A card widget displaying a single skill with actions. class SkillCard extends StatelessWidget { final Skill skill; @@ -978,6 +1566,7 @@ class SkillCard extends StatelessWidget { @override Widget build(BuildContext context) { + final riskLabels = _skillRiskLabels(skill); return Container( decoration: BoxDecoration( color: AppTheme.surface, @@ -1097,8 +1686,8 @@ class SkillCard extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), ), - child: const Text( - '安装', + child: Text( + skill.source == SkillSource.github ? '审核装载' : '装载', style: TextStyle( fontFamily: AppTheme.fontBody, fontSize: 12, @@ -1135,6 +1724,17 @@ class SkillCard extends StatelessWidget { return _TagChip(label: tag); }).toList(), ), + if (riskLabels.isNotEmpty && !skill.isInstalled) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final risk in riskLabels.take(3)) + _SkillRiskChip(label: risk.label, color: risk.color, icon: risk.icon), + ], + ), + ], ], ), ), @@ -1242,6 +1842,41 @@ class SkillCard extends StatelessWidget { // ── Tag Chip ───────────────────────────────────── +class _SourceChip extends StatelessWidget { + const _SourceChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ActionChip( + onPressed: onTap, + backgroundColor: selected ? AppTheme.primary.withOpacity(0.16) : AppTheme.surfaceHover, + side: BorderSide(color: selected ? AppTheme.primary.withOpacity(0.45) : AppTheme.border), + avatar: Icon( + selected ? Icons.check_circle_outline : Icons.travel_explore_outlined, + size: 16, + color: selected ? AppTheme.primary : AppTheme.textTertiary, + ), + label: Text( + label, + style: TextStyle( + fontFamily: AppTheme.fontBody, + fontSize: 12, + fontWeight: FontWeight.w600, + color: selected ? AppTheme.primary : AppTheme.textSecondary, + ), + ), + ); + } +} + class _TagChip extends StatelessWidget { final String label; diff --git a/mobile_agent/lib/screens/splash_screen.dart b/mobile_agent/lib/screens/splash_screen.dart index 853190c..adf82cb 100644 --- a/mobile_agent/lib/screens/splash_screen.dart +++ b/mobile_agent/lib/screens/splash_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../themes/app_theme.dart'; import 'home_screen.dart'; @@ -181,15 +182,11 @@ class _SplashScreenState extends State angle: _logoRotate.value, child: Transform.scale( scale: _logoScale.value, - child: Container( - decoration: BoxDecoration( - gradient: AppTheme.auroraGradient, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.bolt, - size: 64, - color: Colors.white, + child: ClipRRect( + borderRadius: BorderRadius.circular(30), + child: SvgPicture.asset( + 'assets/icons/mobilecode-logo.svg', + fit: BoxFit.cover, ), ), ), diff --git a/mobile_agent/lib/screens/theme_settings_screen.dart b/mobile_agent/lib/screens/theme_settings_screen.dart index 9426709..eadc24f 100644 --- a/mobile_agent/lib/screens/theme_settings_screen.dart +++ b/mobile_agent/lib/screens/theme_settings_screen.dart @@ -1,463 +1,463 @@ -// ============================================================ -// theme_settings_screen.dart — MobileCode Theme Settings -// ============================================================ -// Complete theme selection UI with: -// - 5 theme cards with live preview miniatures -// - Animated transitions between theme selections -// - Color swatches and personality descriptions -// - Quick toggle in app bar -// - Persistent selection with visual feedback -// - Glassmorphism cards adapted to each theme -// ============================================================ - -import 'dart:math' show Random, sin, cos, pi; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../core/theme_manager.dart'; -import '../core/animations.dart'; -import '../widgets/animated_background.dart'; -import '../widgets/custom_icons.dart'; - -// ============================================================ -// SECTION 1: Main Settings Screen -// ============================================================ - -class ThemeSettingsScreen extends StatefulWidget { - const ThemeSettingsScreen({Key? key}) : super(key: key); - - @override - State createState() => _ThemeSettingsScreenState(); -} - -class _ThemeSettingsScreenState extends State - with SingleTickerProviderStateMixin { - late ThemeManager _themeManager; - late AnimationController _transitionController; - late Animation _pageFadeAnim; - late Animation _pageSlideAnim; - - @override - void initState() { - super.initState(); - _transitionController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 600), - ); - _pageFadeAnim = CurvedAnimation( - parent: _transitionController, - curve: Curves.easeOutCubic, - ); - _pageSlideAnim = Tween( - begin: const Offset(0, 0.08), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _transitionController, - curve: Curves.easeOutCubic, - )); - _transitionController.forward(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _themeManager = ThemeProvider.of(context); - } - - @override - void dispose() { - _transitionController.dispose(); - super.dispose(); - } - - void _onThemeChanged(AppTheme newTheme) { - if (_themeManager.activeThemeId == newTheme) return; - HapticFeedback.mediumImpact(); - _themeManager.setTheme(newTheme); - _transitionController - .reverse() - .then((_) => _transitionController.forward()); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final mobileTheme = _themeManager.activeTheme; - - return Scaffold( - backgroundColor: theme.scaffoldBackgroundColor, - body: AnimatedBuilder( - animation: _themeManager, - builder: (context, child) { - return AnimatedBackground( - theme: _themeManager.activeThemeId, - child: FadeTransition( - opacity: _pageFadeAnim, - child: SlideTransition( - position: _pageSlideAnim, - child: SafeArea( - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - // App Bar - _buildAppBar(theme, mobileTheme), - // Current theme indicator - _buildCurrentThemeHeader(theme, mobileTheme), - // Theme cards grid - _buildThemeGrid(theme), - // Theme detail section - _buildThemeDetail(theme, mobileTheme), - // Settings - _buildSettingsSection(theme, mobileTheme), - const SliverToBoxAdapter(child: SizedBox(height: 40)), - ], - ), - ), - ), - ), - ); - }, - ), - ); - } - - // -- App Bar -- - Widget _buildAppBar(ThemeData theme, MobileTheme mobileTheme) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - // Back button - _GlassIconButton( - icon: CustomIcons.arrowLeft(size: 20), - onTap: () => Navigator.of(context).pop(), - mobileTheme: mobileTheme, - ), - const SizedBox(width: 16), - // Title - Expanded( - child: SlideInAnimation( - direction: AxisDirection.right, - duration: const Duration(milliseconds: 400), - child: Text( - 'Appearance', - style: theme.textTheme.headlineMedium, - ), - ), - ), - // Quick theme toggle - _GlassIconButton( - icon: CustomIcons.magicWand(size: 20), - onTap: () { - HapticFeedback.mediumImpact(); - _themeManager.quickToggle(); - }, - mobileTheme: mobileTheme, - tooltip: 'Quick cycle themes', - ), - ], - ), - ), - ); - } - - // -- Current theme header -- - Widget _buildCurrentThemeHeader(ThemeData theme, MobileTheme mobileTheme) { - final meta = _themeManager.activeThemeId; - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), - child: BounceInAnimation( - duration: const Duration(milliseconds: 500), - child: Container( - padding: const EdgeInsets.all(20), - decoration: Glassmorphism.card(mobileTheme), - child: Row( - children: [ - // Theme emoji icon - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - meta.previewPrimary.withOpacity(0.3), - meta.previewSecondary.withOpacity(0.2), - ], - ), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: meta.previewPrimary.withOpacity(0.3), - width: 1, - ), - ), - child: Center( - child: Text( - meta.emoji, - style: const TextStyle(fontSize: 28), - ), - ), - ), - const SizedBox(width: 16), - // Theme info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - meta.label, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - meta.description, - style: theme.textTheme.bodySmall, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - // Live indicator - PulseAnimation( - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: meta.previewPrimary, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: meta.previewPrimary.withOpacity(0.5), - blurRadius: 8, - spreadRadius: 2, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ); - } - - // -- Theme selection grid -- - Widget _buildThemeGrid(ThemeData theme) { - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 20), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 16, - crossAxisSpacing: 16, - childAspectRatio: 0.78, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final appTheme = AppTheme.values[index]; - final isActive = _themeManager.activeThemeId == appTheme; - final meta = appTheme; - - return StaggeredItem( - index: index, - delay: Duration(milliseconds: 80 * index), - duration: const Duration(milliseconds: 400), - child: _ThemeCard( - appTheme: appTheme, - isActive: isActive, - onTap: () => _onThemeChanged(appTheme), - ), - ); - }, - childCount: AppTheme.values.length, - ), - ), - ); - } - - // -- Theme detail section -- - Widget _buildThemeDetail(ThemeData theme, MobileTheme mobileTheme) { - final meta = _themeManager.activeThemeId; - final colors = [ - meta.previewPrimary, - meta.previewSecondary, - ..._getThemeAccentColors(meta), - ]; - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 32, 20, 0), - child: StaggeredItem( - index: 0, - delay: const Duration(milliseconds: 300), - duration: const Duration(milliseconds: 400), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Color Palette', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 16), - // Color swatches - Wrap( - spacing: 12, - runSpacing: 12, - children: colors.map((color) { - return _ColorSwatch( - color: color, - hex: '#${color.value.toRadixString(16).substring(2).toUpperCase()}', - ); - }).toList(), - ), - const SizedBox(height: 24), - // Personality traits - Text( - 'Theme Personality', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 12), - _buildPersonalityChips(theme, meta), - ], - ), - ), - ), - ); - } - - // -- Personality chips -- - Widget _buildPersonalityChips(ThemeData theme, AppTheme meta) { - final traits = _getPersonalityTraits(meta); - return Wrap( - spacing: 8, - runSpacing: 8, - children: traits.map((trait) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: theme.colorScheme.outline.withOpacity(0.2), - ), - ), - child: Text( - trait, - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ); - }).toList(), - ); - } - - // -- Settings section -- - Widget _buildSettingsSection(ThemeData theme, MobileTheme mobileTheme) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 32, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Preferences', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 16), - // Dark mode toggle - _SettingTile( - icon: CustomIcons.moon(size: 20, color: theme.colorScheme.primary), - title: 'Dark Mode', - subtitle: 'Always use dark interface', - trailing: Switch( - value: true, - onChanged: (v) {}, - ), - mobileTheme: mobileTheme, - ), - const SizedBox(height: 8), - // Animated backgrounds toggle - _SettingTile( - icon: CustomIcons.sparkles(size: 20, color: theme.colorScheme.primary), - title: 'Animated Backgrounds', - subtitle: 'Show live animated backdrops', - trailing: Switch( - value: true, - onChanged: (v) {}, - ), - mobileTheme: mobileTheme, - ), - const SizedBox(height: 8), - // Follow system toggle - _SettingTile( - icon: CustomIcons.system(size: 20, color: theme.colorScheme.primary), - title: 'Follow System', - subtitle: 'Match device theme settings', - trailing: Switch( - value: _themeManager.followSystem, - onChanged: (v) => _themeManager.setFollowSystem(v), - ), - mobileTheme: mobileTheme, - ), - ], - ), - ), - ); - } - - // -- Helpers -- - List _getThemeAccentColors(AppTheme theme) { - switch (theme) { - case AppTheme.deepSpace: - return [ - const Color(0xFF7B2FF7), - const Color(0xFF00D4AA), - const Color(0xFF9D5CFF), - const Color(0xFF33E5C0), - const Color(0xFF0A0E17), - const Color(0xFF0D1117), - ]; - case AppTheme.aurora: - return [ - const Color(0xFF00FF88), - const Color(0xFFFF6B9D), - const Color(0xFF33FFA0), - const Color(0xFFFF8FB0), - const Color(0xFF0E2236), - const Color(0xFF0E1D2E), - ]; - case AppTheme.midnightForest: - return [ - const Color(0xFF2ECC71), - const Color(0xFFF39C12), - const Color(0xFF52D687), - const Color(0xFFF5B041), - const Color(0xFF0F240F), - const Color(0xFF0D1F0D), - ]; - case AppTheme.cyberSunset: - return [ - const Color(0xFFFF7B54), - const Color(0xFF9B59B6), - const Color(0xFFFF9D80), - const Color(0xFFB07DC9), - const Color(0xFF12103A), - const Color(0xFF110E34), - ]; +// ============================================================ +// theme_settings_screen.dart — MobileCode Theme Settings +// ============================================================ +// Complete theme selection UI with: +// - 5 theme cards with live preview miniatures +// - Animated transitions between theme selections +// - Color swatches and personality descriptions +// - Quick toggle in app bar +// - Persistent selection with visual feedback +// - Glassmorphism cards adapted to each theme +// ============================================================ + +import 'dart:math' show Random, sin, cos, pi; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../core/theme_manager.dart'; +import '../core/animations.dart'; +import '../widgets/animated_background.dart'; +import '../widgets/custom_icons.dart'; + +// ============================================================ +// SECTION 1: Main Settings Screen +// ============================================================ + +class ThemeSettingsScreen extends StatefulWidget { + const ThemeSettingsScreen({Key? key}) : super(key: key); + + @override + State createState() => _ThemeSettingsScreenState(); +} + +class _ThemeSettingsScreenState extends State + with SingleTickerProviderStateMixin { + late ThemeManager _themeManager; + late AnimationController _transitionController; + late Animation _pageFadeAnim; + late Animation _pageSlideAnim; + + @override + void initState() { + super.initState(); + _transitionController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _pageFadeAnim = CurvedAnimation( + parent: _transitionController, + curve: Curves.easeOutCubic, + ); + _pageSlideAnim = Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _transitionController, + curve: Curves.easeOutCubic, + )); + _transitionController.forward(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _themeManager = ThemeProvider.of(context); + } + + @override + void dispose() { + _transitionController.dispose(); + super.dispose(); + } + + void _onThemeChanged(AppTheme newTheme) { + if (_themeManager.activeThemeId == newTheme) return; + HapticFeedback.mediumImpact(); + _themeManager.setTheme(newTheme); + _transitionController + .reverse() + .then((_) => _transitionController.forward()); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final mobileTheme = _themeManager.activeTheme; + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + body: AnimatedBuilder( + animation: _themeManager, + builder: (context, child) { + return AnimatedBackground( + theme: _themeManager.activeThemeId, + child: FadeTransition( + opacity: _pageFadeAnim, + child: SlideTransition( + position: _pageSlideAnim, + child: SafeArea( + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + // App Bar + _buildAppBar(theme, mobileTheme), + // Current theme indicator + _buildCurrentThemeHeader(theme, mobileTheme), + // Theme cards grid + _buildThemeGrid(theme), + // Theme detail section + _buildThemeDetail(theme, mobileTheme), + // Settings + _buildSettingsSection(theme, mobileTheme), + const SliverToBoxAdapter(child: SizedBox(height: 40)), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } + + // -- App Bar -- + Widget _buildAppBar(ThemeData theme, MobileTheme mobileTheme) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + // Back button + _GlassIconButton( + icon: CustomIcons.arrowLeft(size: 20), + onTap: () => Navigator.of(context).pop(), + mobileTheme: mobileTheme, + ), + const SizedBox(width: 16), + // Title + Expanded( + child: SlideInAnimation( + direction: AxisDirection.right, + duration: const Duration(milliseconds: 400), + child: Text( + 'Appearance', + style: theme.textTheme.headlineMedium, + ), + ), + ), + // Quick theme toggle + _GlassIconButton( + icon: CustomIcons.magicWand(size: 20), + onTap: () { + HapticFeedback.mediumImpact(); + _themeManager.quickToggle(); + }, + mobileTheme: mobileTheme, + tooltip: 'Quick cycle themes', + ), + ], + ), + ), + ); + } + + // -- Current theme header -- + Widget _buildCurrentThemeHeader(ThemeData theme, MobileTheme mobileTheme) { + final meta = _themeManager.activeThemeId; + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: BounceInAnimation( + duration: const Duration(milliseconds: 500), + child: Container( + padding: const EdgeInsets.all(20), + decoration: Glassmorphism.card(mobileTheme), + child: Row( + children: [ + // Theme emoji icon + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + meta.previewPrimary.withOpacity(0.3), + meta.previewSecondary.withOpacity(0.2), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: meta.previewPrimary.withOpacity(0.3), + width: 1, + ), + ), + child: Center( + child: Text( + meta.emoji, + style: const TextStyle(fontSize: 28), + ), + ), + ), + const SizedBox(width: 16), + // Theme info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + meta.label, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + meta.description, + style: theme.textTheme.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Live indicator + PulseAnimation( + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: meta.previewPrimary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: meta.previewPrimary.withOpacity(0.5), + blurRadius: 8, + spreadRadius: 2, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + // -- Theme selection grid -- + Widget _buildThemeGrid(ThemeData theme) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 0.78, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final appTheme = AppTheme.values[index]; + final isActive = _themeManager.activeThemeId == appTheme; + final meta = appTheme; + + return StaggeredItem( + index: index, + delay: Duration(milliseconds: 80 * index), + duration: const Duration(milliseconds: 400), + child: _ThemeCard( + appTheme: appTheme, + isActive: isActive, + onTap: () => _onThemeChanged(appTheme), + ), + ); + }, + childCount: AppTheme.values.length, + ), + ), + ); + } + + // -- Theme detail section -- + Widget _buildThemeDetail(ThemeData theme, MobileTheme mobileTheme) { + final meta = _themeManager.activeThemeId; + final colors = [ + meta.previewPrimary, + meta.previewSecondary, + ..._getThemeAccentColors(meta), + ]; + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 32, 20, 0), + child: StaggeredItem( + index: 0, + delay: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: 400), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Color Palette', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + // Color swatches + Wrap( + spacing: 12, + runSpacing: 12, + children: colors.map((color) { + return _ColorSwatch( + color: color, + hex: '#${color.value.toRadixString(16).substring(2).toUpperCase()}', + ); + }).toList(), + ), + const SizedBox(height: 24), + // Personality traits + Text( + 'Theme Personality', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + _buildPersonalityChips(theme, meta), + ], + ), + ), + ), + ); + } + + // -- Personality chips -- + Widget _buildPersonalityChips(ThemeData theme, AppTheme meta) { + final traits = _getPersonalityTraits(meta); + return Wrap( + spacing: 8, + runSpacing: 8, + children: traits.map((trait) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.colorScheme.outline.withOpacity(0.2), + ), + ), + child: Text( + trait, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + }).toList(), + ); + } + + // -- Settings section -- + Widget _buildSettingsSection(ThemeData theme, MobileTheme mobileTheme) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 32, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Preferences', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 16), + // Dark mode toggle + _SettingTile( + icon: CustomIcons.moon(size: 20, color: theme.colorScheme.primary), + title: 'Dark Mode', + subtitle: 'Always use dark interface', + trailing: Switch( + value: true, + onChanged: (v) {}, + ), + mobileTheme: mobileTheme, + ), + const SizedBox(height: 8), + // Animated backgrounds toggle + _SettingTile( + icon: CustomIcons.sparkles(size: 20, color: theme.colorScheme.primary), + title: 'Animated Backgrounds', + subtitle: 'Show live animated backdrops', + trailing: Switch( + value: true, + onChanged: (v) {}, + ), + mobileTheme: mobileTheme, + ), + const SizedBox(height: 8), + // Follow system toggle + _SettingTile( + icon: CustomIcons.system(size: 20, color: theme.colorScheme.primary), + title: 'Follow System', + subtitle: 'Match device theme settings', + trailing: Switch( + value: _themeManager.followSystem, + onChanged: (v) => _themeManager.setFollowSystem(v), + ), + mobileTheme: mobileTheme, + ), + ], + ), + ), + ); + } + + // -- Helpers -- + List _getThemeAccentColors(AppTheme theme) { + switch (theme) { + case AppTheme.deepSpace: + return [ + const Color(0xFF7B2FF7), + const Color(0xFF00D4AA), + const Color(0xFF9D5CFF), + const Color(0xFF33E5C0), + const Color(0xFF0A0E17), + const Color(0xFF0D1117), + ]; + case AppTheme.aurora: + return [ + const Color(0xFF00FF88), + const Color(0xFFFF6B9D), + const Color(0xFF33FFA0), + const Color(0xFFFF8FB0), + const Color(0xFF0E2236), + const Color(0xFF0E1D2E), + ]; + case AppTheme.midnightForest: + return [ + const Color(0xFF2ECC71), + const Color(0xFFF39C12), + const Color(0xFF52D687), + const Color(0xFFF5B041), + const Color(0xFF0F240F), + const Color(0xFF0D1F0D), + ]; + case AppTheme.cyberSunset: + return [ + const Color(0xFFFF7B54), + const Color(0xFF9B59B6), + const Color(0xFFFF9D80), + const Color(0xFFB07DC9), + const Color(0xFF12103A), + const Color(0xFF110E34), + ]; case AppTheme.monochromeGeek: return [ const Color(0xFFFFFFFF), @@ -467,638 +467,678 @@ class _ThemeSettingsScreenState extends State const Color(0xFF0A0A0A), const Color(0xFF080808), ]; + case AppTheme.claudeYellow: + return [ + const Color(0xFFD97706), + const Color(0xFFEF925B), + const Color(0xFFFFB86B), + const Color(0xFFFFC18C), + const Color(0xFF24170C), + const Color(0xFF3A250F), + ]; + case AppTheme.codexBlue: + return [ + const Color(0xFF2555FF), + const Color(0xFF16B9C7), + const Color(0xFF6EA8FF), + const Color(0xFF6BE4EE), + const Color(0xFF0B1B33), + const Color(0xFF122A4D), + ]; } } List _getPersonalityTraits(AppTheme theme) { - switch (theme) { - case AppTheme.deepSpace: - return ['Cosmic', 'Mysterious', 'Focused', 'Technical', 'Dark']; - case AppTheme.aurora: - return ['Vibrant', 'Flowing', 'Creative', 'Ethereal', 'Dynamic']; - case AppTheme.midnightForest: - return ['Organic', 'Natural', 'Calm', 'Warm', 'Alive']; + switch (theme) { + case AppTheme.deepSpace: + return ['Cosmic', 'Mysterious', 'Focused', 'Technical', 'Dark']; + case AppTheme.aurora: + return ['Vibrant', 'Flowing', 'Creative', 'Ethereal', 'Dynamic']; + case AppTheme.midnightForest: + return ['Organic', 'Natural', 'Calm', 'Warm', 'Alive']; case AppTheme.cyberSunset: return ['Neon', 'Warm', 'Retro-futurism', 'Bold', 'Energetic']; case AppTheme.monochromeGeek: return ['Minimal', 'Pure', 'Distraction-free', 'Clean', 'Precise']; + case AppTheme.claudeYellow: + return ['Warm', 'Readable', 'Reflective', 'Editorial', 'Calm']; + case AppTheme.codexBlue: + return ['Focused', 'Technical', 'Clear', 'Precise', 'Release-ready']; } } } - -// ============================================================ -// SECTION 2: Theme Card Widget -// ============================================================ - -/// An individual theme selection card with live preview miniature, -/// selection indicator, and glassmorphism styling. -class _ThemeCard extends StatefulWidget { - final AppTheme appTheme; - final bool isActive; - final VoidCallback onTap; - - const _ThemeCard({ - Key? key, - required this.appTheme, - required this.isActive, - required this.onTap, - }) : super(key: key); - - @override - State<_ThemeCard> createState() => _ThemeCardState(); -} - -class _ThemeCardState extends State<_ThemeCard> - with SingleTickerProviderStateMixin { - late AnimationController _hoverController; - bool _isHovered = false; - - @override - void initState() { - super.initState(); - _hoverController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - ); - } - - @override - void dispose() { - _hoverController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final meta = widget.appTheme; - final mobileTheme = MobileThemeFactory.get(widget.appTheme); - - return GestureDetector( - onTap: widget.onTap, - child: MouseRegion( - onEnter: (_) { - setState(() => _isHovered = true); - _hoverController.forward(); - }, - onExit: (_) { - setState(() => _isHovered = false); - _hoverController.reverse(); - }, - child: AnimatedBuilder( - animation: _hoverController, - builder: (context, child) { - final scale = 1.0 + _hoverController.value * 0.02; - return Transform.scale( - scale: scale, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - meta.previewPrimary.withOpacity(0.15), - meta.previewSecondary.withOpacity(0.08), - ], - ), - border: Border.all( - color: widget.isActive - ? meta.previewPrimary.withOpacity(0.7) - : _isHovered - ? meta.previewPrimary.withOpacity(0.4) - : meta.previewPrimary.withOpacity(0.15), - width: widget.isActive ? 2.5 : 1, - ), - boxShadow: [ - if (widget.isActive) - BoxShadow( - color: meta.previewPrimary.withOpacity(0.3), - blurRadius: 16, - spreadRadius: 2, - ), - if (_isHovered && !widget.isActive) - BoxShadow( - color: meta.previewPrimary.withOpacity(0.15), - blurRadius: 12, - spreadRadius: 1, - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Stack( - children: [ - // Mini background preview - _MiniBackgroundPreview(theme: widget.appTheme), - // Content overlay - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - mobileTheme.themeData.scaffoldBackgroundColor - .withOpacity(0.85), - ], - stops: const [0.3, 0.75], - ), - ), - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // Mini color bar - Row( - children: [ - _MiniSwatch(color: meta.previewPrimary), - const SizedBox(width: 6), - _MiniSwatch(color: meta.previewSecondary), - const SizedBox(width: 6), - _MiniSwatch( - color: meta.previewPrimary.withOpacity(0.5), - isSmall: true, - ), - ], - ), - const SizedBox(height: 10), - // Theme label - Text( - meta.label, - style: mobileTheme.themeData.textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.w700, - fontSize: 14, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - // Emoji - Text( - meta.description, - style: mobileTheme.themeData.textTheme.bodySmall - ?.copyWith(fontSize: 10), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - // Active checkmark - if (widget.isActive) - Positioned( - top: 10, - right: 10, - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: meta.previewPrimary, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: meta.previewPrimary.withOpacity(0.5), - blurRadius: 8, - spreadRadius: 1, - ), - ], - ), - child: const Icon( - Icons.check, - color: Colors.white, - size: 16, - ), - ), - ), - ], - ), - ), - ), - ); - }, - ), - ), - ); - } -} - -/// Miniature background preview for each theme card. -class _MiniBackgroundPreview extends StatelessWidget { - final AppTheme theme; - - const _MiniBackgroundPreview({Key? key, required this.theme}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - height: 120, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: _getMiniGradientColors(theme), - ), - ), - child: CustomPaint( - painter: _MiniPreviewPainter(theme: theme), - size: Size.infinite, - ), - ); - } - - List _getMiniGradientColors(AppTheme t) { - switch (t) { - case AppTheme.deepSpace: - return [ - const Color(0xFF0A0E2A), - const Color(0xFF030508), - ]; - case AppTheme.aurora: - return [ - const Color(0xFF0E1D2E), - const Color(0xFF0A1628), - ]; - case AppTheme.midnightForest: + +// ============================================================ +// SECTION 2: Theme Card Widget +// ============================================================ + +/// An individual theme selection card with live preview miniature, +/// selection indicator, and glassmorphism styling. +class _ThemeCard extends StatefulWidget { + final AppTheme appTheme; + final bool isActive; + final VoidCallback onTap; + + const _ThemeCard({ + Key? key, + required this.appTheme, + required this.isActive, + required this.onTap, + }) : super(key: key); + + @override + State<_ThemeCard> createState() => _ThemeCardState(); +} + +class _ThemeCardState extends State<_ThemeCard> + with SingleTickerProviderStateMixin { + late AnimationController _hoverController; + bool _isHovered = false; + + @override + void initState() { + super.initState(); + _hoverController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + } + + @override + void dispose() { + _hoverController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final meta = widget.appTheme; + final mobileTheme = MobileThemeFactory.get(widget.appTheme); + + return GestureDetector( + onTap: widget.onTap, + child: MouseRegion( + onEnter: (_) { + setState(() => _isHovered = true); + _hoverController.forward(); + }, + onExit: (_) { + setState(() => _isHovered = false); + _hoverController.reverse(); + }, + child: AnimatedBuilder( + animation: _hoverController, + builder: (context, child) { + final scale = 1.0 + _hoverController.value * 0.02; + return Transform.scale( + scale: scale, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + meta.previewPrimary.withOpacity(0.15), + meta.previewSecondary.withOpacity(0.08), + ], + ), + border: Border.all( + color: widget.isActive + ? meta.previewPrimary.withOpacity(0.7) + : _isHovered + ? meta.previewPrimary.withOpacity(0.4) + : meta.previewPrimary.withOpacity(0.15), + width: widget.isActive ? 2.5 : 1, + ), + boxShadow: [ + if (widget.isActive) + BoxShadow( + color: meta.previewPrimary.withOpacity(0.3), + blurRadius: 16, + spreadRadius: 2, + ), + if (_isHovered && !widget.isActive) + BoxShadow( + color: meta.previewPrimary.withOpacity(0.15), + blurRadius: 12, + spreadRadius: 1, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( + children: [ + // Mini background preview + _MiniBackgroundPreview(theme: widget.appTheme), + // Content overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + mobileTheme.themeData.scaffoldBackgroundColor + .withOpacity(0.85), + ], + stops: const [0.3, 0.75], + ), + ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Mini color bar + Row( + children: [ + _MiniSwatch(color: meta.previewPrimary), + const SizedBox(width: 6), + _MiniSwatch(color: meta.previewSecondary), + const SizedBox(width: 6), + _MiniSwatch( + color: meta.previewPrimary.withOpacity(0.5), + isSmall: true, + ), + ], + ), + const SizedBox(height: 10), + // Theme label + Text( + meta.label, + style: mobileTheme.themeData.textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // Emoji + Text( + meta.description, + style: mobileTheme.themeData.textTheme.bodySmall + ?.copyWith(fontSize: 10), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Active checkmark + if (widget.isActive) + Positioned( + top: 10, + right: 10, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: meta.previewPrimary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: meta.previewPrimary.withOpacity(0.5), + blurRadius: 8, + spreadRadius: 1, + ), + ], + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 16, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} + +/// Miniature background preview for each theme card. +class _MiniBackgroundPreview extends StatelessWidget { + final AppTheme theme; + + const _MiniBackgroundPreview({Key? key, required this.theme}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 120, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _getMiniGradientColors(theme), + ), + ), + child: CustomPaint( + painter: _MiniPreviewPainter(theme: theme), + size: Size.infinite, + ), + ); + } + + List _getMiniGradientColors(AppTheme t) { + switch (t) { + case AppTheme.deepSpace: + return [ + const Color(0xFF0A0E2A), + const Color(0xFF030508), + ]; + case AppTheme.aurora: + return [ + const Color(0xFF0E1D2E), + const Color(0xFF0A1628), + ]; + case AppTheme.midnightForest: + return [ + const Color(0xFF0D1F0D), + const Color(0xFF0A1A0A), + ]; + case AppTheme.cyberSunset: + return [ + const Color(0xFF110E34), + const Color(0xFF0D0B2B), + ]; + case AppTheme.monochromeGeek: return [ - const Color(0xFF0D1F0D), - const Color(0xFF0A1A0A), + const Color(0xFF0A0A0A), + const Color(0xFF000000), ]; - case AppTheme.cyberSunset: + case AppTheme.claudeYellow: return [ - const Color(0xFF110E34), - const Color(0xFF0D0B2B), + const Color(0xFF33200F), + const Color(0xFF19110A), ]; - case AppTheme.monochromeGeek: + case AppTheme.codexBlue: return [ - const Color(0xFF0A0A0A), - const Color(0xFF000000), + const Color(0xFF102544), + const Color(0xFF071326), ]; } } } - -class _MiniPreviewPainter extends CustomPainter { - final AppTheme theme; - final Random _random = Random(7); - - _MiniPreviewPainter({required this.theme}); - - @override - void paint(Canvas canvas, Size size) { - switch (theme) { - case AppTheme.deepSpace: - _drawStars(canvas, size, const Color(0xFF7B2FF7), const Color(0xFF00D4AA)); - break; - case AppTheme.aurora: - _drawAuroraStrip(canvas, size, const Color(0xFF00FF88), const Color(0xFFFF6B9D)); - break; - case AppTheme.midnightForest: - _drawFireflies(canvas, size, const Color(0xFF2ECC71), const Color(0xFFF39C12)); - break; - case AppTheme.cyberSunset: - _drawOrbs(canvas, size, const Color(0xFFFF7B54), const Color(0xFF9B59B6)); - break; + +class _MiniPreviewPainter extends CustomPainter { + final AppTheme theme; + final Random _random = Random(7); + + _MiniPreviewPainter({required this.theme}); + + @override + void paint(Canvas canvas, Size size) { + switch (theme) { + case AppTheme.deepSpace: + _drawStars(canvas, size, const Color(0xFF7B2FF7), const Color(0xFF00D4AA)); + break; + case AppTheme.aurora: + _drawAuroraStrip(canvas, size, const Color(0xFF00FF88), const Color(0xFFFF6B9D)); + break; + case AppTheme.midnightForest: + _drawFireflies(canvas, size, const Color(0xFF2ECC71), const Color(0xFFF39C12)); + break; + case AppTheme.cyberSunset: + _drawOrbs(canvas, size, const Color(0xFFFF7B54), const Color(0xFF9B59B6)); + break; case AppTheme.monochromeGeek: _drawNoise(canvas, size); break; + case AppTheme.claudeYellow: + _drawOrbs(canvas, size, const Color(0xFFD97706), const Color(0xFFFFB86B)); + _drawFireflies(canvas, size, const Color(0xFFFFB86B), const Color(0xFFEF925B)); + break; + case AppTheme.codexBlue: + _drawAuroraStrip(canvas, size, const Color(0xFF2555FF), const Color(0xFF16B9C7)); + _drawStars(canvas, size, const Color(0xFF6EA8FF), const Color(0xFF16B9C7)); + break; } } - - void _drawStars(Canvas canvas, Size size, Color c1, Color c2) { - for (int i = 0; i < 20; i++) { - final x = _random.nextDouble() * size.width; - final y = _random.nextDouble() * size.height; - final s = 0.5 + _random.nextDouble() * 1.5; - final color = [c1, c2, Colors.white][_random.nextInt(3)]; - canvas.drawCircle( - Offset(x, y), - s, - Paint() - ..color = color.withOpacity(0.4 + _random.nextDouble() * 0.4) - ..maskFilter = s > 1 ? const MaskFilter.blur(BlurStyle.normal, 1) : null, - ); - } - } - - void _drawAuroraStrip(Canvas canvas, Size size, Color c1, Color c2) { - final path = Path(); - path.moveTo(0, size.height * 0.6); - for (int i = 0; i <= 20; i++) { - final t = i / 20; - final x = t * size.width; - final y = size.height * 0.5 + sin(t * pi * 2) * size.height * 0.15; - path.lineTo(x, y); - } - path.lineTo(size.width, size.height); - path.lineTo(0, size.height); - path.close(); - canvas.drawPath( - path, - Paint() - ..shader = LinearGradient( - colors: [c1.withOpacity(0.3), c2.withOpacity(0.15)], - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8), - ); - } - - void _drawFireflies(Canvas canvas, Size size, Color c1, Color c2) { - for (int i = 0; i < 8; i++) { - final x = _random.nextDouble() * size.width; - final y = 0.3 + _random.nextDouble() * 0.6; - final color = [c1, c2][_random.nextInt(2)]; - canvas.drawCircle( - Offset(x, y * size.height), - 2 + _random.nextDouble(), - Paint() - ..color = color.withOpacity(0.5) - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), - ); - } - } - - void _drawOrbs(Canvas canvas, Size size, Color c1, Color c2) { - canvas.drawCircle( - Offset(size.width * 0.3, size.height * 0.4), - size.width * 0.25, - Paint() - ..color = c1.withOpacity(0.15) - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12), - ); - canvas.drawCircle( - Offset(size.width * 0.7, size.height * 0.5), - size.width * 0.2, - Paint() - ..color = c2.withOpacity(0.12) - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10), - ); - } - - void _drawNoise(Canvas canvas, Size size) { - final paint = Paint()..strokeWidth = 0.5; - for (int i = 0; i < 30; i++) { - final x = _random.nextDouble() * size.width; - final y = _random.nextDouble() * size.height; - final b = 0.03 + _random.nextDouble() * 0.05; - canvas.drawPoints( - PointMode.points, - [Offset(x, y)], - paint..color = Colors.white.withOpacity(b), - ); - } - } - - @override - bool shouldRepaint(covariant _MiniPreviewPainter old) => false; -} - -// ============================================================ -// SECTION 3: Color Swatch Widget -// ============================================================ - -/// A color swatch with hex label used in the theme detail section. -class _ColorSwatch extends StatefulWidget { - final Color color; - final String hex; - - const _ColorSwatch({Key? key, required this.color, required this.hex}) - : super(key: key); - - @override - State<_ColorSwatch> createState() => _ColorSwatchState(); -} - -class _ColorSwatchState extends State<_ColorSwatch> - with SingleTickerProviderStateMixin { - late AnimationController _tapController; - bool _copied = false; - - @override - void initState() { - super.initState(); - _tapController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 150), - ); - } - - @override - void dispose() { - _tapController.dispose(); - super.dispose(); - } - - void _onTap() { - HapticFeedback.lightImpact(); - _tapController.forward().then((_) => _tapController.reverse()); - setState(() => _copied = true); - Future.delayed(const Duration(seconds: 2), () { - if (mounted) setState(() => _copied = false); - }); - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: _onTap, - child: AnimatedBuilder( - animation: _tapController, - builder: (context, child) { - final scale = 1.0 - _tapController.value * 0.1; - return Transform.scale( - scale: scale, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: widget.color, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: widget.color.computeLuminance() > 0.5 - ? Colors.black.withOpacity(0.1) - : Colors.white.withOpacity(0.15), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: widget.color.withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: _copied - ? const Center( - child: Icon(Icons.check, - color: Colors.white, size: 18)) - : null, - ), - const SizedBox(height: 6), - Text( - _copied ? 'Copied!' : widget.hex, - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - fontFamily: 'monospace', - ), - ), - ], - ), - ); - }, - ), - ); - } -} - -// ============================================================ -// SECTION 4: Glass Icon Button -// ============================================================ - -/// A compact icon button with glassmorphism styling. -class _GlassIconButton extends StatelessWidget { - final Widget icon; - final VoidCallback onTap; - final MobileTheme mobileTheme; - final String? tooltip; - - const _GlassIconButton({ - Key? key, - required this.icon, - required this.onTap, - required this.mobileTheme, - this.tooltip, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip ?? '', - child: GestureDetector( - onTap: onTap, - child: Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: mobileTheme.cardGlassBackground, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: mobileTheme.cardGlassBorder, - width: 1, - ), - ), - child: Center(child: icon), - ), - ), - ); - } -} - -// ============================================================ -// SECTION 5: Setting Tile -// ============================================================ - -/// A settings list tile with glassmorphism background. -class _SettingTile extends StatelessWidget { - final Widget icon; - final String title; - final String subtitle; - final Widget trailing; - final MobileTheme mobileTheme; - - const _SettingTile({ - Key? key, - required this.icon, - required this.title, - required this.subtitle, - required this.trailing, - required this.mobileTheme, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: theme.colorScheme.surface.withOpacity(0.6), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: theme.colorScheme.outline.withOpacity(0.1), - ), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: theme.colorScheme.primary.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Center(child: icon), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - subtitle, - style: theme.textTheme.bodySmall, - ), - ], - ), - ), - trailing, - ], - ), - ); - } -} - -// ============================================================ -// SECTION 6: Mini Swatch Widget -// ============================================================ - -/// A small color dot used inside theme cards. -class _MiniSwatch extends StatelessWidget { - final Color color; - final bool isSmall; - - const _MiniSwatch({Key? key, required this.color, this.isSmall = false}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - width: isSmall ? 12 : 16, - height: isSmall ? 12 : 16, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(isSmall ? 4 : 6), - border: Border.all( - color: color.computeLuminance() > 0.5 - ? Colors.black.withOpacity(0.1) - : Colors.white.withOpacity(0.2), - width: 1, - ), - ), - ); - } -} + + void _drawStars(Canvas canvas, Size size, Color c1, Color c2) { + for (int i = 0; i < 20; i++) { + final x = _random.nextDouble() * size.width; + final y = _random.nextDouble() * size.height; + final s = 0.5 + _random.nextDouble() * 1.5; + final color = [c1, c2, Colors.white][_random.nextInt(3)]; + canvas.drawCircle( + Offset(x, y), + s, + Paint() + ..color = color.withOpacity(0.4 + _random.nextDouble() * 0.4) + ..maskFilter = s > 1 ? const MaskFilter.blur(BlurStyle.normal, 1) : null, + ); + } + } + + void _drawAuroraStrip(Canvas canvas, Size size, Color c1, Color c2) { + final path = Path(); + path.moveTo(0, size.height * 0.6); + for (int i = 0; i <= 20; i++) { + final t = i / 20; + final x = t * size.width; + final y = size.height * 0.5 + sin(t * pi * 2) * size.height * 0.15; + path.lineTo(x, y); + } + path.lineTo(size.width, size.height); + path.lineTo(0, size.height); + path.close(); + canvas.drawPath( + path, + Paint() + ..shader = LinearGradient( + colors: [c1.withOpacity(0.3), c2.withOpacity(0.15)], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8), + ); + } + + void _drawFireflies(Canvas canvas, Size size, Color c1, Color c2) { + for (int i = 0; i < 8; i++) { + final x = _random.nextDouble() * size.width; + final y = 0.3 + _random.nextDouble() * 0.6; + final color = [c1, c2][_random.nextInt(2)]; + canvas.drawCircle( + Offset(x, y * size.height), + 2 + _random.nextDouble(), + Paint() + ..color = color.withOpacity(0.5) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3), + ); + } + } + + void _drawOrbs(Canvas canvas, Size size, Color c1, Color c2) { + canvas.drawCircle( + Offset(size.width * 0.3, size.height * 0.4), + size.width * 0.25, + Paint() + ..color = c1.withOpacity(0.15) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12), + ); + canvas.drawCircle( + Offset(size.width * 0.7, size.height * 0.5), + size.width * 0.2, + Paint() + ..color = c2.withOpacity(0.12) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10), + ); + } + + void _drawNoise(Canvas canvas, Size size) { + final paint = Paint()..strokeWidth = 0.5; + for (int i = 0; i < 30; i++) { + final x = _random.nextDouble() * size.width; + final y = _random.nextDouble() * size.height; + final b = 0.03 + _random.nextDouble() * 0.05; + canvas.drawPoints( + PointMode.points, + [Offset(x, y)], + paint..color = Colors.white.withOpacity(b), + ); + } + } + + @override + bool shouldRepaint(covariant _MiniPreviewPainter old) => false; +} + +// ============================================================ +// SECTION 3: Color Swatch Widget +// ============================================================ + +/// A color swatch with hex label used in the theme detail section. +class _ColorSwatch extends StatefulWidget { + final Color color; + final String hex; + + const _ColorSwatch({Key? key, required this.color, required this.hex}) + : super(key: key); + + @override + State<_ColorSwatch> createState() => _ColorSwatchState(); +} + +class _ColorSwatchState extends State<_ColorSwatch> + with SingleTickerProviderStateMixin { + late AnimationController _tapController; + bool _copied = false; + + @override + void initState() { + super.initState(); + _tapController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 150), + ); + } + + @override + void dispose() { + _tapController.dispose(); + super.dispose(); + } + + void _onTap() { + HapticFeedback.lightImpact(); + _tapController.forward().then((_) => _tapController.reverse()); + setState(() => _copied = true); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _copied = false); + }); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + child: AnimatedBuilder( + animation: _tapController, + builder: (context, child) { + final scale = 1.0 - _tapController.value * 0.1; + return Transform.scale( + scale: scale, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: widget.color, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: widget.color.computeLuminance() > 0.5 + ? Colors.black.withOpacity(0.1) + : Colors.white.withOpacity(0.15), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: widget.color.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: _copied + ? const Center( + child: Icon(Icons.check, + color: Colors.white, size: 18)) + : null, + ), + const SizedBox(height: 6), + Text( + _copied ? 'Copied!' : widget.hex, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontFamily: 'monospace', + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +// ============================================================ +// SECTION 4: Glass Icon Button +// ============================================================ + +/// A compact icon button with glassmorphism styling. +class _GlassIconButton extends StatelessWidget { + final Widget icon; + final VoidCallback onTap; + final MobileTheme mobileTheme; + final String? tooltip; + + const _GlassIconButton({ + Key? key, + required this.icon, + required this.onTap, + required this.mobileTheme, + this.tooltip, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip ?? '', + child: GestureDetector( + onTap: onTap, + child: Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: mobileTheme.cardGlassBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: mobileTheme.cardGlassBorder, + width: 1, + ), + ), + child: Center(child: icon), + ), + ), + ); + } +} + +// ============================================================ +// SECTION 5: Setting Tile +// ============================================================ + +/// A settings list tile with glassmorphism background. +class _SettingTile extends StatelessWidget { + final Widget icon; + final String title; + final String subtitle; + final Widget trailing; + final MobileTheme mobileTheme; + + const _SettingTile({ + Key? key, + required this.icon, + required this.title, + required this.subtitle, + required this.trailing, + required this.mobileTheme, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surface.withOpacity(0.6), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: theme.colorScheme.outline.withOpacity(0.1), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: theme.colorScheme.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Center(child: icon), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + trailing, + ], + ), + ); + } +} + +// ============================================================ +// SECTION 6: Mini Swatch Widget +// ============================================================ + +/// A small color dot used inside theme cards. +class _MiniSwatch extends StatelessWidget { + final Color color; + final bool isSmall; + + const _MiniSwatch({Key? key, required this.color, this.isSmall = false}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: isSmall ? 12 : 16, + height: isSmall ? 12 : 16, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(isSmall ? 4 : 6), + border: Border.all( + color: color.computeLuminance() > 0.5 + ? Colors.black.withOpacity(0.1) + : Colors.white.withOpacity(0.2), + width: 1, + ), + ), + ); + } +} diff --git a/mobile_agent/lib/services/device_telemetry_service.dart b/mobile_agent/lib/services/device_telemetry_service.dart new file mode 100644 index 0000000..eb6fcbf --- /dev/null +++ b/mobile_agent/lib/services/device_telemetry_service.dart @@ -0,0 +1,184 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class DeviceTelemetrySnapshot { + const DeviceTelemetrySnapshot({ + required this.platform, + required this.manufacturer, + required this.model, + required this.androidVersion, + required this.sdkInt, + required this.abis, + required this.cpuCores, + required this.cpuUsagePercent, + required this.totalMemoryMb, + required this.availableMemoryMb, + required this.lowMemory, + required this.appRssMb, + required this.appHeapMb, + required this.storageTotalMb, + required this.storageFreeMb, + required this.batteryLevel, + required this.batteryCharging, + required this.batteryTemperatureC, + required this.thermalStatus, + required this.timestamp, + required this.fallback, + }); + + final String platform; + final String manufacturer; + final String model; + final String androidVersion; + final int sdkInt; + final List abis; + final int cpuCores; + final double cpuUsagePercent; + final int totalMemoryMb; + final int availableMemoryMb; + final bool lowMemory; + final int appRssMb; + final int appHeapMb; + final int storageTotalMb; + final int storageFreeMb; + final int batteryLevel; + final bool batteryCharging; + final double batteryTemperatureC; + final int thermalStatus; + final DateTime timestamp; + final bool fallback; + + double get memoryUsedPercent { + if (totalMemoryMb <= 0) return 0; + return (totalMemoryMb - availableMemoryMb).clamp(0, totalMemoryMb) / totalMemoryMb; + } + + double get storageUsedPercent { + if (storageTotalMb <= 0) return 0; + return (storageTotalMb - storageFreeMb).clamp(0, storageTotalMb) / storageTotalMb; + } + + double get batteryPercent => batteryLevel < 0 ? 0 : batteryLevel / 100; + + factory DeviceTelemetrySnapshot.fromMap(Map map) { + return DeviceTelemetrySnapshot( + platform: map['platform'] as String? ?? 'android', + manufacturer: map['manufacturer'] as String? ?? '', + model: map['model'] as String? ?? 'Unknown device', + androidVersion: map['androidVersion'] as String? ?? '', + sdkInt: _intValue(map['sdkInt']), + abis: _stringList(map['abis']), + cpuCores: _intValue(map['cpuCores'], fallback: Platform.numberOfProcessors), + cpuUsagePercent: _doubleValue(map['cpuUsagePercent']), + totalMemoryMb: _intValue(map['totalMemoryMb']), + availableMemoryMb: _intValue(map['availableMemoryMb']), + lowMemory: map['lowMemory'] as bool? ?? false, + appRssMb: _intValue(map['appRssMb']), + appHeapMb: _intValue(map['appHeapMb']), + storageTotalMb: _intValue(map['storageTotalMb']), + storageFreeMb: _intValue(map['storageFreeMb']), + batteryLevel: _intValue(map['batteryLevel'], fallback: -1), + batteryCharging: map['batteryCharging'] as bool? ?? false, + batteryTemperatureC: _doubleValue(map['batteryTemperatureC']), + thermalStatus: _intValue(map['thermalStatus'], fallback: -1), + timestamp: DateTime.fromMillisecondsSinceEpoch( + _intValue(map['timestamp'], fallback: DateTime.now().millisecondsSinceEpoch), + ), + fallback: map['fallback'] as bool? ?? false, + ); + } + + factory DeviceTelemetrySnapshot.fallback({Object? error}) { + final rssMb = (ProcessInfo.currentRss / (1024 * 1024)).round(); + return DeviceTelemetrySnapshot( + platform: kIsWeb ? 'web' : Platform.operatingSystem, + manufacturer: '', + model: error == null ? 'Flutter fallback' : 'Flutter fallback (${error.runtimeType})', + androidVersion: '', + sdkInt: 0, + abis: const [], + cpuCores: Platform.numberOfProcessors, + cpuUsagePercent: 0, + totalMemoryMb: 0, + availableMemoryMb: 0, + lowMemory: false, + appRssMb: rssMb, + appHeapMb: rssMb, + storageTotalMb: 0, + storageFreeMb: 0, + batteryLevel: -1, + batteryCharging: false, + batteryTemperatureC: 0, + thermalStatus: -1, + timestamp: DateTime.now(), + fallback: true, + ); + } +} + +class DeviceTelemetryService { + DeviceTelemetryService._(); + + static final DeviceTelemetryService instance = DeviceTelemetryService._(); + static const _channel = MethodChannel('mobilecode/system_tools'); + + Future getStaticProfile() => getLatestSnapshot(); + + Future getLatestSnapshot() async { + if (kIsWeb) return DeviceTelemetrySnapshot.fallback(); + try { + final raw = await _channel.invokeMethod>('getDeviceTelemetry'); + if (raw == null) return DeviceTelemetrySnapshot.fallback(); + return DeviceTelemetrySnapshot.fromMap(Map.from(raw)); + } on Object catch (error) { + return DeviceTelemetrySnapshot.fallback(error: error); + } + } + + Stream watchTelemetry({ + Duration interval = const Duration(seconds: 1), + }) { + late final StreamController controller; + Timer? timer; + + Future emit() async { + if (controller.isClosed) return; + controller.add(await getLatestSnapshot()); + } + + controller = StreamController( + onListen: () { + unawaited(emit()); + timer = Timer.periodic(interval, (_) => unawaited(emit())); + }, + onCancel: () { + timer?.cancel(); + timer = null; + }, + ); + return controller.stream; + } +} + +List _stringList(Object? value) { + if (value is List) return value.map((item) => item.toString()).where((item) => item.isNotEmpty).toList(); + if (value is String && value.isNotEmpty) return [value]; + return const []; +} + +int _intValue(Object? value, {int fallback = 0}) { + if (value is int) return value; + if (value is num) return value.round(); + if (value is String) return int.tryParse(value) ?? fallback; + return fallback; +} + +double _doubleValue(Object? value, {double fallback = 0}) { + if (value is double) return value; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value) ?? fallback; + return fallback; +} diff --git a/mobile_agent/lib/services/external_termux_provider.dart b/mobile_agent/lib/services/external_termux_provider.dart index cbe5d13..127a0a5 100644 --- a/mobile_agent/lib/services/external_termux_provider.dart +++ b/mobile_agent/lib/services/external_termux_provider.dart @@ -1,169 +1,168 @@ -// lib/services/external_termux_provider.dart -// RuntimeProvider adapter around the existing TermuxService. - -import 'dart:async'; - -import 'runtime_provider.dart'; -import 'termux_service.dart'; - -class ExternalTermuxProvider implements RuntimeProvider { - final TermuxService _termux; - - ExternalTermuxProvider(this._termux); - - @override - RuntimeProviderType get type => RuntimeProviderType.externalTermux; - - @override - String get name => 'External Termux'; - - @override - Stream get logStream => _termux.buildLogStream; - - @override - Future initialize() => _termux.initialize(); - - @override - Future capabilities() async { - await initialize(); - final installed = await _termux.isTermuxInstalled(); - if (!installed) { - return const RuntimeCapabilities(); - } - - final flutterStatus = await _termux.checkFlutterSdk(); - final flutterReady = flutterStatus == FlutterSdkStatus.installed || - flutterStatus == FlutterSdkStatus.outdated; - - return RuntimeCapabilities( - shell: true, - git: await _hasCommand('git'), - node: await _hasCommand('node'), - python: await _hasCommand('python') || await _hasCommand('python3'), - flutter: flutterReady, - androidBuild: flutterReady, - pty: false, - backgroundService: false, - webViewPreview: true, - ); - } - - @override - Future healthCheck() async { - await initialize(); - - final installed = await _termux.isTermuxInstalled(); - final apiInstalled = await _termux.isTermuxApiInstalled(); - final caps = await capabilities(); - final missing = []; - final actions = []; - +// lib/services/external_termux_provider.dart +// RuntimeProvider adapter around the existing TermuxService. + +import 'dart:async'; + +import 'runtime_provider.dart'; +import 'termux_service.dart'; + +class ExternalTermuxProvider implements RuntimeProvider { + final TermuxService _termux; + + ExternalTermuxProvider(this._termux); + + @override + RuntimeProviderType get type => RuntimeProviderType.externalTermux; + + @override + String get name => 'External Termux'; + + @override + Stream get logStream => _termux.buildLogStream; + + @override + Future initialize() => _termux.initialize(); + + @override + Future capabilities() async { + await initialize(); + final installed = await _termux.isTermuxInstalled(); + if (!installed) { + return const RuntimeCapabilities(); + } + + final flutterStatus = await _termux.checkFlutterSdk(); + final flutterReady = flutterStatus == FlutterSdkStatus.installed || + flutterStatus == FlutterSdkStatus.outdated; + + return RuntimeCapabilities( + shell: true, + git: await _hasCommand('git'), + node: await _hasCommand('node'), + python: await _hasCommand('python') || await _hasCommand('python3'), + flutter: flutterReady, + androidBuild: flutterReady, + pty: false, + backgroundService: false, + webViewPreview: true, + ); + } + + @override + Future healthCheck() async { + await initialize(); + + final installed = await _termux.isTermuxInstalled(); + final apiInstalled = await _termux.isTermuxApiInstalled(); + final caps = await capabilities(); + final missing = []; + final actions = []; + if (!installed) { missing.add('Termux app'); actions.add('Install Termux from F-Droid, then reopen MobileCode.'); } if (installed && !apiInstalled) { - missing.add('Termux:API plugin'); - actions.add('Install Termux:API for richer automation and notifications.'); - } - if (installed && !caps.flutter) { - missing.add('Flutter SDK in Termux'); - actions.add('Run the MobileCode Termux setup wizard.'); - } - - return RuntimeHealth( - type: type, - name: name, - available: installed, - ready: installed && caps.shell, - status: installed - ? (caps.flutter ? 'Termux ready for Flutter builds.' : 'Termux available; Flutter SDK missing.') - : 'Termux is not installed.', - capabilities: caps, - missingDependencies: missing, - recoveryActions: actions, - ); - } - - @override - Future execute( - String command, { - String? workingDir, - Map? environment, - Duration? timeout, - }) async { - final result = await _termux.execute( - command, - workingDir: workingDir, - timeoutSeconds: timeout?.inSeconds ?? 120, - ); - return RuntimeCommandResult( - command: result.command, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - duration: result.duration, - providerType: type, - ); - } - - @override - Stream executeStream( - String command, { - String? workingDir, - Map? environment, - }) { - return _termux.executeStream(command, workingDir: workingDir); - } - - @override - Future syncWorkspace({ - required String sourcePath, - required String targetPath, - }) async { - try { - await _termux.syncToTermux(sourcePath, targetPath); - return RuntimeSyncResult( - success: true, - sourcePath: sourcePath, - targetPath: targetPath, - ); - } catch (e) { - return RuntimeSyncResult( - success: false, - sourcePath: sourcePath, - targetPath: targetPath, - error: e.toString(), - ); - } - } - - @override - Future buildWeb(String projectPath) => _termux.buildWeb(projectPath); - - @override - Future buildApk(String projectPath, {BuildMode mode = BuildMode.debug}) { - return _termux.buildApk(projectPath, mode: mode); - } - - @override - Future installApk(String apkPath) => _termux.installApk(apkPath); - - @override - Future launchApp(String packageName) => _termux.launchApp(packageName); - - @override - Future uninstallApp(String packageName) => _termux.uninstallApp(packageName); - - @override - Future stopCurrentTask() => _termux.stopCurrentBuild(); - - Future _hasCommand(String command) async { - try { - final result = await _termux.execute('which $command'); - return result.success && result.stdout.trim().isNotEmpty; - } catch (_) { - return false; + actions.add('Optional: install Termux:API for notifications, clipboard, and richer Android integration.'); } - } -} + if (installed && !caps.flutter) { + missing.add('Flutter SDK in Termux'); + actions.add('Run the MobileCode Termux setup wizard.'); + } + + return RuntimeHealth( + type: type, + name: name, + available: installed, + ready: installed && caps.shell, + status: installed + ? (caps.flutter ? 'Termux ready for Flutter builds.' : 'Termux available; Flutter SDK missing.') + : 'Termux is not installed.', + capabilities: caps, + missingDependencies: missing, + recoveryActions: actions, + ); + } + + @override + Future execute( + String command, { + String? workingDir, + Map? environment, + Duration? timeout, + }) async { + final result = await _termux.execute( + command, + workingDir: workingDir, + timeoutSeconds: timeout?.inSeconds ?? 120, + ); + return RuntimeCommandResult( + command: result.command, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + duration: result.duration, + providerType: type, + ); + } + + @override + Stream executeStream( + String command, { + String? workingDir, + Map? environment, + }) { + return _termux.executeStream(command, workingDir: workingDir); + } + + @override + Future syncWorkspace({ + required String sourcePath, + required String targetPath, + }) async { + try { + await _termux.syncToTermux(sourcePath, targetPath); + return RuntimeSyncResult( + success: true, + sourcePath: sourcePath, + targetPath: targetPath, + ); + } catch (e) { + return RuntimeSyncResult( + success: false, + sourcePath: sourcePath, + targetPath: targetPath, + error: e.toString(), + ); + } + } + + @override + Future buildWeb(String projectPath) => _termux.buildWeb(projectPath); + + @override + Future buildApk(String projectPath, {BuildMode mode = BuildMode.debug}) { + return _termux.buildApk(projectPath, mode: mode); + } + + @override + Future installApk(String apkPath) => _termux.installApk(apkPath); + + @override + Future launchApp(String packageName) => _termux.launchApp(packageName); + + @override + Future uninstallApp(String packageName) => _termux.uninstallApp(packageName); + + @override + Future stopCurrentTask() => _termux.stopCurrentBuild(); + + Future _hasCommand(String command) async { + try { + final result = await _termux.execute('which $command'); + return result.success && result.stdout.trim().isNotEmpty; + } catch (_) { + return false; + } + } +} diff --git a/mobile_agent/lib/services/feature_flags_service.dart b/mobile_agent/lib/services/feature_flags_service.dart index 6b869a4..ee77b72 100644 --- a/mobile_agent/lib/services/feature_flags_service.dart +++ b/mobile_agent/lib/services/feature_flags_service.dart @@ -1,659 +1,666 @@ -// lib/services/feature_flags_service.dart -// Feature Flags Service - Toggle features on/off -// 功能开关系统 - -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -// ═══════════════════════════════════════════════════════════════════════════ -// Data Models -// ═══════════════════════════════════════════════════════════════════════════ - -/// Categories for organizing feature flags. -enum FeatureCategory { - /// 实验功能 - Experimental features that may be unstable - experimental, - - /// 高级功能 - Advanced features for power users - advanced, - - /// 团队功能 - Team collaboration features - team, - - /// 显示设置 - Display/UI preferences - display, - - /// 核心功能 - Core app features (not toggleable) - core, -} - -/// Extension for display names and icons. -extension FeatureCategoryExt on FeatureCategory { - /// Chinese display name. - String get displayName { - switch (this) { - case FeatureCategory.experimental: - return '实验功能'; - case FeatureCategory.advanced: - return '高级功能'; - case FeatureCategory.team: - return '团队功能'; - case FeatureCategory.display: - return '显示设置'; - case FeatureCategory.core: - return '核心功能'; - } - } - - /// English display name. - String get displayNameEn { - switch (this) { - case FeatureCategory.experimental: - return 'Experimental'; - case FeatureCategory.advanced: - return 'Advanced'; - case FeatureCategory.team: - return 'Team'; - case FeatureCategory.display: - return 'Display'; - case FeatureCategory.core: - return 'Core'; - } - } - - /// Emoji icon for the category. - String get emoji { - switch (this) { - case FeatureCategory.experimental: - return '🧪'; - case FeatureCategory.advanced: - return '⚙️'; - case FeatureCategory.team: - return '👥'; - case FeatureCategory.display: - return '🎨'; - case FeatureCategory.core: - return '🔒'; - } - } - - /// Icon data name for Flutter. - String get iconName { - switch (this) { - case FeatureCategory.experimental: - return 'science'; - case FeatureCategory.advanced: - return 'settings'; - case FeatureCategory.team: - return 'people'; - case FeatureCategory.display: - return 'palette'; - case FeatureCategory.core: - return 'lock'; - } - } - - /// Sort order for display. - int get sortOrder { - switch (this) { - case FeatureCategory.core: - return 0; - case FeatureCategory.experimental: - return 1; - case FeatureCategory.advanced: - return 2; - case FeatureCategory.team: - return 3; - case FeatureCategory.display: - return 4; - } - } -} - -/// A single feature flag with metadata. -/// -/// Features are organized by category and can be toggled on/off. -/// Some features require additional permissions (e.g., microphone, camera). -class FeatureFlag { - final String id; - final String name; - final String description; - final FeatureCategory category; - final bool defaultValue; - final bool requiresPermission; - - /// Current runtime value. - bool _currentValue; - - /// Permission name required (e.g., 'microphone', 'camera', 'storage'). - final String? permissionType; - - /// Whether this feature is only available in certain platforms. - final List? supportedPlatforms; - - FeatureFlag({ - required this.id, - required this.name, - required this.description, - required this.category, - required this.defaultValue, - this.requiresPermission = false, - this.permissionType, - this.supportedPlatforms, - }) : _currentValue = defaultValue; - - /// Current value of the feature flag. - bool get value => _currentValue; - - /// Set the current value (internal use only). - set value(bool v) => _currentValue = v; - - /// Whether this feature is experimental. - bool get isExperimental => category == FeatureCategory.experimental; - - /// Whether this feature is a core feature (not toggleable). - bool get isCore => category == FeatureCategory.core; - - /// Whether this feature requires a permission. - bool get needsPermission => requiresPermission; - - FeatureFlag copyWith({bool? currentValue}) { - final copy = FeatureFlag( - id: id, - name: name, - description: description, - category: category, - defaultValue: defaultValue, - requiresPermission: requiresPermission, - permissionType: permissionType, - supportedPlatforms: supportedPlatforms, - ); - copy._currentValue = currentValue ?? _currentValue; - return copy; - } - - @override - String toString() => 'FeatureFlag($id: $name = $value)'; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Feature Flags Service -// ═══════════════════════════════════════════════════════════════════════════ - -/// Feature Flags Service -/// -/// Allows users to enable/disable features dynamically. -/// Feature states are persisted to SharedPreferences. -/// -/// ## Feature Categories -/// - 🧪 实验功能 (Experimental) -/// - ⚙️ 高级功能 (Advanced) -/// - 👥 团队功能 (Team) -/// - 🎨 显示设置 (Display) -/// - 🔒 核心功能 (Core - not toggleable) -/// -/// ## Usage -/// ```dart -/// final flags = ref.read(featureFlagsServiceProvider); -/// await flags.initialize(); -/// -/// if (await flags.isEnabled('screenshot_to_code')) { -/// // Enable screenshot-to-code UI -/// } -/// -/// await flags.setEnabled('terminal', true); -/// ``` -class FeatureFlagsService extends ChangeNotifier { - // ── Feature Definitions ───────────────────────── - - /// All available features with their metadata. - static final Map allFeatures = { - // ── Experimental Features ───────────────────── - 'voice_to_code': FeatureFlag( - id: 'voice_to_code', - name: '语音转代码', - description: '通过语音输入生成代码,说出你的想法即可生成代码', - category: FeatureCategory.experimental, - defaultValue: true, - requiresPermission: true, - permissionType: 'microphone', - ), - 'screenshot_to_code': FeatureFlag( - id: 'screenshot_to_code', - name: '截图转代码', - description: '通过截图识别UI并生成Flutter代码,支持识别布局和样式', - category: FeatureCategory.experimental, - defaultValue: true, - requiresPermission: true, - permissionType: 'camera', - ), - 'offline_ai': FeatureFlag( - id: 'offline_ai', - name: '离线AI', - description: '使用本地AI模型进行代码生成(需提前下载模型)', - category: FeatureCategory.experimental, - defaultValue: false, - supportedPlatforms: ['android'], - ), - 'agent_multi_step': FeatureFlag( - id: 'agent_multi_step', - name: '多步Agent', - description: 'AI自动分解并执行多步骤任务,如创建完整项目结构', - category: FeatureCategory.experimental, - defaultValue: true, - ), - 'wechat_publish': FeatureFlag( - id: 'wechat_publish', - name: '微信发布', - description: '自动发布文章到微信公众号,支持Markdown转公众号格式', - category: FeatureCategory.experimental, - defaultValue: false, - ), - - // ── Advanced Features ───────────────────────── - 'terminal': FeatureFlag( - id: 'terminal', - name: '终端执行', - description: '在应用内执行终端命令,支持常用shell命令', - category: FeatureCategory.advanced, - defaultValue: false, - requiresPermission: true, - permissionType: 'storage', - ), - 'github_pages_deploy': FeatureFlag( - id: 'github_pages_deploy', - name: 'GitHub Pages 部署', - description: '一键部署静态网页到 GitHub Pages', - category: FeatureCategory.advanced, - defaultValue: true, - ), - 'advanced_ai_settings': FeatureFlag( - id: 'advanced_ai_settings', - name: '高级AI设置', - description: '自定义温度、最大令牌数、系统提示词等高级参数', - category: FeatureCategory.advanced, - defaultValue: false, - ), - - // ── Team Features ───────────────────────────── - 'team_collaboration': FeatureFlag( - id: 'team_collaboration', - name: '团队协作', - description: '团队成员协作功能,包括代码审查和任务分配', - category: FeatureCategory.team, - defaultValue: false, - ), - 'live_collaboration': FeatureFlag( - id: 'live_collaboration', - name: '实时协作', - description: '多人实时编辑同一文件,类似Google Docs', - category: FeatureCategory.team, - defaultValue: false, - ), - - // ── Display Features ────────────────────────── - 'code_minimap': FeatureFlag( - id: 'code_minimap', - name: '代码迷你地图', - description: '编辑器右侧显示代码缩略图,快速导航大文件', - category: FeatureCategory.display, - defaultValue: false, - ), - 'breadcrumbs': FeatureFlag( - id: 'breadcrumbs', - name: '面包屑导航', - description: '编辑器顶部显示文件路径导航', - category: FeatureCategory.display, - defaultValue: true, - ), - 'zen_mode': FeatureFlag( - id: 'zen_mode', - name: '禅模式', - description: '全屏专注编码模式,隐藏所有干扰元素', - category: FeatureCategory.display, - defaultValue: true, - ), - - // ── Core Features (not toggleable) ──────────── - 'ai_chat': FeatureFlag( - id: 'ai_chat', - name: 'AI 对话', - description: '与AI助手对话获取编程帮助', - category: FeatureCategory.core, - defaultValue: true, - ), - 'code_editor': FeatureFlag( - id: 'code_editor', - name: '代码编辑器', - description: '核心代码编辑功能', - category: FeatureCategory.core, - defaultValue: true, - ), - 'file_manager': FeatureFlag( - id: 'file_manager', - name: '文件管理', - description: '项目文件浏览和管理', - category: FeatureCategory.core, - defaultValue: true, - ), - 'github_integration': FeatureFlag( - id: 'github_integration', - name: 'GitHub 集成', - description: 'GitHub仓库管理和代码同步', - category: FeatureCategory.core, - defaultValue: true, - ), - }; - - // ── Internal State ───────────────────────────── - - final Map _featureStates = {}; - SharedPreferences? _prefs; - bool _initialized = false; - - // ── Storage Key ──────────────────────────────── - - static const String _storageKey = 'feature_flags_states'; - - // ── Singleton ────────────────────────────────── - - static FeatureFlagsService? _instance; - - factory FeatureFlagsService() { - _instance ??= FeatureFlagsService._internal(); - return _instance!; - } - - FeatureFlagsService._internal(); - - static void reset() => _instance = null; - - // ── Initialization ───────────────────────────── - - /// Initialize the service and load persisted feature states. - Future initialize() async { - if (_initialized) return; - - debugPrint('[FeatureFlags] Initializing...'); - - _prefs = await SharedPreferences.getInstance(); - - // Load persisted states - await _loadStates(); - - // Initialize any missing features with defaults - for (final entry in allFeatures.entries) { - if (!_featureStates.containsKey(entry.key)) { - _featureStates[entry.key] = entry.value.defaultValue; - } - } - - _initialized = true; - debugPrint('[FeatureFlags] Initialized: ${allFeatures.length} features'); - notifyListeners(); - } - - void _ensureInitialized() { - if (!_initialized) { - throw StateError('FeatureFlagsService not initialized. Call initialize() first.'); - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // Core Methods - // ═══════════════════════════════════════════════════════════════════════ - - /// Check if a feature is enabled. - /// - /// Returns [defaultValue] if the feature ID is unknown. - Future isEnabled(String featureId) async { - _ensureInitialized(); - - // Check if feature exists - final feature = allFeatures[featureId]; - if (feature == null) { - debugPrint('[FeatureFlags] Unknown feature: $featureId'); - return false; - } - - // Core features are always enabled - if (feature.isCore) return true; - - return _featureStates[featureId] ?? feature.defaultValue; - } - - /// Synchronous check (returns cached value). - /// - /// Use [isEnabled] for the most accurate result. - bool isEnabledSync(String featureId) { - if (!_initialized) return allFeatures[featureId]?.defaultValue ?? false; - - final feature = allFeatures[featureId]; - if (feature?.isCore ?? false) return true; - - return _featureStates[featureId] ?? feature?.defaultValue ?? false; - } - - /// Enable or disable a feature. - Future setEnabled(String featureId, bool enabled) async { - _ensureInitialized(); - - final feature = allFeatures[featureId]; - if (feature == null) { - throw ArgumentError('Unknown feature: $featureId'); - } - - // Core features cannot be disabled - if (feature.isCore && !enabled) { - debugPrint('[FeatureFlags] Cannot disable core feature: $featureId'); - return; - } - - _featureStates[featureId] = enabled; - await _persistStates(); - - debugPrint('[FeatureFlags] $featureId = $enabled'); - notifyListeners(); - } - - /// Toggle a feature on/off. - Future toggle(String featureId) async { - _ensureInitialized(); - - final current = await isEnabled(featureId); - await setEnabled(featureId, !current); - return !current; - } - - // ═══════════════════════════════════════════════════════════════════════ - // Query Methods - // ═══════════════════════════════════════════════════════════════════════ - - /// Get all features organized by category. - Map> get featuresByCategory { - _ensureInitialized(); - - final result = >{}; - for (final feature in allFeatures.values) { - result.putIfAbsent(feature.category, () => []).add( - feature.copyWith( - currentValue: _featureStates[feature.id] ?? feature.defaultValue, - ), - ); - } - - // Sort categories and features within each category - final sorted = Map.fromEntries( - result.entries.toList()..sort((a, b) => a.key.sortOrder.compareTo(b.key.sortOrder)), - ); - - for (final list in sorted.values) { - list.sort((a, b) => a.name.compareTo(b.name)); - } - - return sorted; - } - - /// Get features filtered by category. - List getFeaturesByCategory(FeatureCategory category) { - _ensureInitialized(); - - return allFeatures.values - .where((f) => f.category == category) - .map((f) => f.copyWith(currentValue: _featureStates[f.id] ?? f.defaultValue)) - .toList() - ..sort((a, b) => a.name.compareTo(b.name)); - } - - /// Get all features with their current values. - List getAllFeatures() { - _ensureInitialized(); - - return allFeatures.values - .map((f) => f.copyWith(currentValue: _featureStates[f.id] ?? f.defaultValue)) - .toList() - ..sort((a, b) { - final catCompare = a.category.sortOrder.compareTo(b.category.sortOrder); - if (catCompare != 0) return catCompare; - return a.name.compareTo(b.name); - }); - } - - /// Get a single feature by ID. - FeatureFlag? getFeature(String featureId) { - final feature = allFeatures[featureId]; - if (feature == null) return null; - return feature.copyWith( - currentValue: _featureStates[featureId] ?? feature.defaultValue, - ); - } - - /// Check if a feature requires a permission. - bool requiresPermission(String featureId) { - return allFeatures[featureId]?.requiresPermission ?? false; - } - - /// Get the permission type required for a feature. - String? getRequiredPermission(String featureId) { - return allFeatures[featureId]?.permissionType; - } - - // ═══════════════════════════════════════════════════════════════════════ - // Bulk Operations - // ═══════════════════════════════════════════════════════════════════════ - - /// Reset all features to their default values. - Future resetToDefaults() async { - _ensureInitialized(); - - _featureStates.clear(); - for (final entry in allFeatures.entries) { - _featureStates[entry.key] = entry.value.defaultValue; - } - - await _persistStates(); - debugPrint('[FeatureFlags] Reset to defaults'); - notifyListeners(); - } - - /// Reset only experimental features to their defaults. - Future resetExperimentalToDefaults() async { - _ensureInitialized(); - - for (final entry in allFeatures.entries) { - if (entry.value.category == FeatureCategory.experimental) { - _featureStates[entry.key] = entry.value.defaultValue; - } - } - - await _persistStates(); - debugPrint('[FeatureFlags] Experimental features reset'); - notifyListeners(); - } - - /// Enable all features in a category. - Future enableCategory(FeatureCategory category) async { - _ensureInitialized(); - - for (final entry in allFeatures.entries) { - if (entry.value.category == category && !entry.value.isCore) { - _featureStates[entry.key] = true; - } - } - - await _persistStates(); - notifyListeners(); - } - - /// Disable all non-core features in a category. - Future disableCategory(FeatureCategory category) async { - _ensureInitialized(); - - for (final entry in allFeatures.entries) { - if (entry.value.category == category && !entry.value.isCore) { - _featureStates[entry.key] = false; - } - } - - await _persistStates(); - notifyListeners(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Import / Export - // ═══════════════════════════════════════════════════════════════════════ - - /// Export all feature states as JSON. - Map exportStates() { - _ensureInitialized(); - return Map.from(_featureStates); - } - - /// Import feature states from JSON. - Future importStates(Map states) async { - _ensureInitialized(); - - for (final entry in states.entries) { - if (allFeatures.containsKey(entry.key)) { - final feature = allFeatures[entry.key]!; - if (!feature.isCore) { - _featureStates[entry.key] = entry.value as bool; - } - } - } - - await _persistStates(); - notifyListeners(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Private: Persistence - // ═══════════════════════════════════════════════════════════════════════ - - Future _loadStates() async { - try { - final jsonStr = _prefs?.getString(_storageKey); - if (jsonStr == null || jsonStr.isEmpty) return; - - final Map decoded = jsonDecode(jsonStr); - _featureStates.clear(); - for (final entry in decoded.entries) { - _featureStates[entry.key] = entry.value as bool; - } - debugPrint('[FeatureFlags] Loaded ${_featureStates.length} states'); - } catch (e) { - debugPrint('[FeatureFlags] Failed to load states: $e'); - } - } - - Future _persistStates() async { - try { - final jsonStr = jsonEncode(_featureStates); - await _prefs?.setString(_storageKey, jsonStr); - } catch (e) { - debugPrint('[FeatureFlags] Failed to persist states: $e'); - } - } -} +// lib/services/feature_flags_service.dart +// Feature Flags Service - Toggle features on/off +// 功能开关系统 + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Data Models +// ═══════════════════════════════════════════════════════════════════════════ + +/// Categories for organizing feature flags. +enum FeatureCategory { + /// 实验功能 - Experimental features that may be unstable + experimental, + + /// 高级功能 - Advanced features for power users + advanced, + + /// 团队功能 - Team collaboration features + team, + + /// 显示设置 - Display/UI preferences + display, + + /// 核心功能 - Core app features (not toggleable) + core, +} + +/// Extension for display names and icons. +extension FeatureCategoryExt on FeatureCategory { + /// Chinese display name. + String get displayName { + switch (this) { + case FeatureCategory.experimental: + return '实验功能'; + case FeatureCategory.advanced: + return '高级功能'; + case FeatureCategory.team: + return '团队功能'; + case FeatureCategory.display: + return '显示设置'; + case FeatureCategory.core: + return '核心功能'; + } + } + + /// English display name. + String get displayNameEn { + switch (this) { + case FeatureCategory.experimental: + return 'Experimental'; + case FeatureCategory.advanced: + return 'Advanced'; + case FeatureCategory.team: + return 'Team'; + case FeatureCategory.display: + return 'Display'; + case FeatureCategory.core: + return 'Core'; + } + } + + /// Emoji icon for the category. + String get emoji { + switch (this) { + case FeatureCategory.experimental: + return '🧪'; + case FeatureCategory.advanced: + return '⚙️'; + case FeatureCategory.team: + return '👥'; + case FeatureCategory.display: + return '🎨'; + case FeatureCategory.core: + return '🔒'; + } + } + + /// Icon data name for Flutter. + String get iconName { + switch (this) { + case FeatureCategory.experimental: + return 'science'; + case FeatureCategory.advanced: + return 'settings'; + case FeatureCategory.team: + return 'people'; + case FeatureCategory.display: + return 'palette'; + case FeatureCategory.core: + return 'lock'; + } + } + + /// Sort order for display. + int get sortOrder { + switch (this) { + case FeatureCategory.core: + return 0; + case FeatureCategory.experimental: + return 1; + case FeatureCategory.advanced: + return 2; + case FeatureCategory.team: + return 3; + case FeatureCategory.display: + return 4; + } + } +} + +/// A single feature flag with metadata. +/// +/// Features are organized by category and can be toggled on/off. +/// Some features require additional permissions (e.g., microphone, camera). +class FeatureFlag { + final String id; + final String name; + final String description; + final FeatureCategory category; + final bool defaultValue; + final bool requiresPermission; + + /// Current runtime value. + bool _currentValue; + + /// Permission name required (e.g., 'microphone', 'camera', 'storage'). + final String? permissionType; + + /// Whether this feature is only available in certain platforms. + final List? supportedPlatforms; + + FeatureFlag({ + required this.id, + required this.name, + required this.description, + required this.category, + required this.defaultValue, + this.requiresPermission = false, + this.permissionType, + this.supportedPlatforms, + }) : _currentValue = defaultValue; + + /// Current value of the feature flag. + bool get value => _currentValue; + + /// Set the current value (internal use only). + set value(bool v) => _currentValue = v; + + /// Whether this feature is experimental. + bool get isExperimental => category == FeatureCategory.experimental; + + /// Whether this feature is a core feature (not toggleable). + bool get isCore => category == FeatureCategory.core; + + /// Whether this feature requires a permission. + bool get needsPermission => requiresPermission; + + FeatureFlag copyWith({bool? currentValue}) { + final copy = FeatureFlag( + id: id, + name: name, + description: description, + category: category, + defaultValue: defaultValue, + requiresPermission: requiresPermission, + permissionType: permissionType, + supportedPlatforms: supportedPlatforms, + ); + copy._currentValue = currentValue ?? _currentValue; + return copy; + } + + @override + String toString() => 'FeatureFlag($id: $name = $value)'; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Feature Flags Service +// ═══════════════════════════════════════════════════════════════════════════ + +/// Feature Flags Service +/// +/// Allows users to enable/disable features dynamically. +/// Feature states are persisted to SharedPreferences. +/// +/// ## Feature Categories +/// - 🧪 实验功能 (Experimental) +/// - ⚙️ 高级功能 (Advanced) +/// - 👥 团队功能 (Team) +/// - 🎨 显示设置 (Display) +/// - 🔒 核心功能 (Core - not toggleable) +/// +/// ## Usage +/// ```dart +/// final flags = ref.read(featureFlagsServiceProvider); +/// await flags.initialize(); +/// +/// if (await flags.isEnabled('screenshot_to_code')) { +/// // Enable screenshot-to-code UI +/// } +/// +/// await flags.setEnabled('terminal', true); +/// ``` +class FeatureFlagsService extends ChangeNotifier { + // ── Feature Definitions ───────────────────────── + + /// All available features with their metadata. + static final Map allFeatures = { + // ── Experimental Features ───────────────────── + 'voice_to_code': FeatureFlag( + id: 'voice_to_code', + name: '语音转代码', + description: '通过语音输入生成代码,说出你的想法即可生成代码', + category: FeatureCategory.experimental, + defaultValue: true, + requiresPermission: true, + permissionType: 'microphone', + ), + 'screenshot_to_code': FeatureFlag( + id: 'screenshot_to_code', + name: '截图转代码', + description: '通过截图识别UI并生成Flutter代码,支持识别布局和样式', + category: FeatureCategory.experimental, + defaultValue: true, + requiresPermission: true, + permissionType: 'camera', + ), + 'offline_ai': FeatureFlag( + id: 'offline_ai', + name: '离线AI', + description: '使用本地AI模型进行代码生成(需提前下载模型)', + category: FeatureCategory.experimental, + defaultValue: false, + supportedPlatforms: ['android'], + ), + 'agent_multi_step': FeatureFlag( + id: 'agent_multi_step', + name: '多步Agent', + description: 'AI自动分解并执行多步骤任务,如创建完整项目结构', + category: FeatureCategory.experimental, + defaultValue: true, + ), + 'wechat_publish': FeatureFlag( + id: 'wechat_publish', + name: '微信发布', + description: '自动发布文章到微信公众号,支持Markdown转公众号格式', + category: FeatureCategory.experimental, + defaultValue: false, + ), + + // ── Advanced Features ───────────────────────── + 'terminal': FeatureFlag( + id: 'terminal', + name: '终端执行', + description: '在应用内执行终端命令,支持常用shell命令', + category: FeatureCategory.advanced, + defaultValue: false, + requiresPermission: true, + permissionType: 'storage', + ), + 'github_pages_deploy': FeatureFlag( + id: 'github_pages_deploy', + name: 'GitHub Pages 部署', + description: '一键部署静态网页到 GitHub Pages', + category: FeatureCategory.advanced, + defaultValue: true, + ), + 'lark_cli': FeatureFlag( + id: 'lark_cli', + name: 'Lark CLI 连接器', + description: '通过 RuntimeProvider 受控检测 lark-cli、授权状态和后续飞书/Lark结构化动作', + category: FeatureCategory.advanced, + defaultValue: false, + ), + 'advanced_ai_settings': FeatureFlag( + id: 'advanced_ai_settings', + name: '高级AI设置', + description: '自定义温度、最大令牌数、系统提示词等高级参数', + category: FeatureCategory.advanced, + defaultValue: false, + ), + + // ── Team Features ───────────────────────────── + 'team_collaboration': FeatureFlag( + id: 'team_collaboration', + name: '团队协作', + description: '团队成员协作功能,包括代码审查和任务分配', + category: FeatureCategory.team, + defaultValue: false, + ), + 'live_collaboration': FeatureFlag( + id: 'live_collaboration', + name: '实时协作', + description: '多人实时编辑同一文件,类似Google Docs', + category: FeatureCategory.team, + defaultValue: false, + ), + + // ── Display Features ────────────────────────── + 'code_minimap': FeatureFlag( + id: 'code_minimap', + name: '代码迷你地图', + description: '编辑器右侧显示代码缩略图,快速导航大文件', + category: FeatureCategory.display, + defaultValue: false, + ), + 'breadcrumbs': FeatureFlag( + id: 'breadcrumbs', + name: '面包屑导航', + description: '编辑器顶部显示文件路径导航', + category: FeatureCategory.display, + defaultValue: true, + ), + 'zen_mode': FeatureFlag( + id: 'zen_mode', + name: '禅模式', + description: '全屏专注编码模式,隐藏所有干扰元素', + category: FeatureCategory.display, + defaultValue: true, + ), + + // ── Core Features (not toggleable) ──────────── + 'ai_chat': FeatureFlag( + id: 'ai_chat', + name: 'AI 对话', + description: '与AI助手对话获取编程帮助', + category: FeatureCategory.core, + defaultValue: true, + ), + 'code_editor': FeatureFlag( + id: 'code_editor', + name: '代码编辑器', + description: '核心代码编辑功能', + category: FeatureCategory.core, + defaultValue: true, + ), + 'file_manager': FeatureFlag( + id: 'file_manager', + name: '文件管理', + description: '项目文件浏览和管理', + category: FeatureCategory.core, + defaultValue: true, + ), + 'github_integration': FeatureFlag( + id: 'github_integration', + name: 'GitHub 集成', + description: 'GitHub仓库管理和代码同步', + category: FeatureCategory.core, + defaultValue: true, + ), + }; + + // ── Internal State ───────────────────────────── + + final Map _featureStates = {}; + SharedPreferences? _prefs; + bool _initialized = false; + + // ── Storage Key ──────────────────────────────── + + static const String _storageKey = 'feature_flags_states'; + + // ── Singleton ────────────────────────────────── + + static FeatureFlagsService? _instance; + + factory FeatureFlagsService() { + _instance ??= FeatureFlagsService._internal(); + return _instance!; + } + + FeatureFlagsService._internal(); + + static void reset() => _instance = null; + + // ── Initialization ───────────────────────────── + + /// Initialize the service and load persisted feature states. + Future initialize() async { + if (_initialized) return; + + debugPrint('[FeatureFlags] Initializing...'); + + _prefs = await SharedPreferences.getInstance(); + + // Load persisted states + await _loadStates(); + + // Initialize any missing features with defaults + for (final entry in allFeatures.entries) { + if (!_featureStates.containsKey(entry.key)) { + _featureStates[entry.key] = entry.value.defaultValue; + } + } + + _initialized = true; + debugPrint('[FeatureFlags] Initialized: ${allFeatures.length} features'); + notifyListeners(); + } + + void _ensureInitialized() { + if (!_initialized) { + throw StateError('FeatureFlagsService not initialized. Call initialize() first.'); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Core Methods + // ═══════════════════════════════════════════════════════════════════════ + + /// Check if a feature is enabled. + /// + /// Returns [defaultValue] if the feature ID is unknown. + Future isEnabled(String featureId) async { + _ensureInitialized(); + + // Check if feature exists + final feature = allFeatures[featureId]; + if (feature == null) { + debugPrint('[FeatureFlags] Unknown feature: $featureId'); + return false; + } + + // Core features are always enabled + if (feature.isCore) return true; + + return _featureStates[featureId] ?? feature.defaultValue; + } + + /// Synchronous check (returns cached value). + /// + /// Use [isEnabled] for the most accurate result. + bool isEnabledSync(String featureId) { + if (!_initialized) return allFeatures[featureId]?.defaultValue ?? false; + + final feature = allFeatures[featureId]; + if (feature?.isCore ?? false) return true; + + return _featureStates[featureId] ?? feature?.defaultValue ?? false; + } + + /// Enable or disable a feature. + Future setEnabled(String featureId, bool enabled) async { + _ensureInitialized(); + + final feature = allFeatures[featureId]; + if (feature == null) { + throw ArgumentError('Unknown feature: $featureId'); + } + + // Core features cannot be disabled + if (feature.isCore && !enabled) { + debugPrint('[FeatureFlags] Cannot disable core feature: $featureId'); + return; + } + + _featureStates[featureId] = enabled; + await _persistStates(); + + debugPrint('[FeatureFlags] $featureId = $enabled'); + notifyListeners(); + } + + /// Toggle a feature on/off. + Future toggle(String featureId) async { + _ensureInitialized(); + + final current = await isEnabled(featureId); + await setEnabled(featureId, !current); + return !current; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Query Methods + // ═══════════════════════════════════════════════════════════════════════ + + /// Get all features organized by category. + Map> get featuresByCategory { + _ensureInitialized(); + + final result = >{}; + for (final feature in allFeatures.values) { + result.putIfAbsent(feature.category, () => []).add( + feature.copyWith( + currentValue: _featureStates[feature.id] ?? feature.defaultValue, + ), + ); + } + + // Sort categories and features within each category + final sorted = Map.fromEntries( + result.entries.toList()..sort((a, b) => a.key.sortOrder.compareTo(b.key.sortOrder)), + ); + + for (final list in sorted.values) { + list.sort((a, b) => a.name.compareTo(b.name)); + } + + return sorted; + } + + /// Get features filtered by category. + List getFeaturesByCategory(FeatureCategory category) { + _ensureInitialized(); + + return allFeatures.values + .where((f) => f.category == category) + .map((f) => f.copyWith(currentValue: _featureStates[f.id] ?? f.defaultValue)) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + } + + /// Get all features with their current values. + List getAllFeatures() { + _ensureInitialized(); + + return allFeatures.values + .map((f) => f.copyWith(currentValue: _featureStates[f.id] ?? f.defaultValue)) + .toList() + ..sort((a, b) { + final catCompare = a.category.sortOrder.compareTo(b.category.sortOrder); + if (catCompare != 0) return catCompare; + return a.name.compareTo(b.name); + }); + } + + /// Get a single feature by ID. + FeatureFlag? getFeature(String featureId) { + final feature = allFeatures[featureId]; + if (feature == null) return null; + return feature.copyWith( + currentValue: _featureStates[featureId] ?? feature.defaultValue, + ); + } + + /// Check if a feature requires a permission. + bool requiresPermission(String featureId) { + return allFeatures[featureId]?.requiresPermission ?? false; + } + + /// Get the permission type required for a feature. + String? getRequiredPermission(String featureId) { + return allFeatures[featureId]?.permissionType; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Bulk Operations + // ═══════════════════════════════════════════════════════════════════════ + + /// Reset all features to their default values. + Future resetToDefaults() async { + _ensureInitialized(); + + _featureStates.clear(); + for (final entry in allFeatures.entries) { + _featureStates[entry.key] = entry.value.defaultValue; + } + + await _persistStates(); + debugPrint('[FeatureFlags] Reset to defaults'); + notifyListeners(); + } + + /// Reset only experimental features to their defaults. + Future resetExperimentalToDefaults() async { + _ensureInitialized(); + + for (final entry in allFeatures.entries) { + if (entry.value.category == FeatureCategory.experimental) { + _featureStates[entry.key] = entry.value.defaultValue; + } + } + + await _persistStates(); + debugPrint('[FeatureFlags] Experimental features reset'); + notifyListeners(); + } + + /// Enable all features in a category. + Future enableCategory(FeatureCategory category) async { + _ensureInitialized(); + + for (final entry in allFeatures.entries) { + if (entry.value.category == category && !entry.value.isCore) { + _featureStates[entry.key] = true; + } + } + + await _persistStates(); + notifyListeners(); + } + + /// Disable all non-core features in a category. + Future disableCategory(FeatureCategory category) async { + _ensureInitialized(); + + for (final entry in allFeatures.entries) { + if (entry.value.category == category && !entry.value.isCore) { + _featureStates[entry.key] = false; + } + } + + await _persistStates(); + notifyListeners(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Import / Export + // ═══════════════════════════════════════════════════════════════════════ + + /// Export all feature states as JSON. + Map exportStates() { + _ensureInitialized(); + return Map.from(_featureStates); + } + + /// Import feature states from JSON. + Future importStates(Map states) async { + _ensureInitialized(); + + for (final entry in states.entries) { + if (allFeatures.containsKey(entry.key)) { + final feature = allFeatures[entry.key]!; + if (!feature.isCore) { + _featureStates[entry.key] = entry.value as bool; + } + } + } + + await _persistStates(); + notifyListeners(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Private: Persistence + // ═══════════════════════════════════════════════════════════════════════ + + Future _loadStates() async { + try { + final jsonStr = _prefs?.getString(_storageKey); + if (jsonStr == null || jsonStr.isEmpty) return; + + final Map decoded = jsonDecode(jsonStr); + _featureStates.clear(); + for (final entry in decoded.entries) { + _featureStates[entry.key] = entry.value as bool; + } + debugPrint('[FeatureFlags] Loaded ${_featureStates.length} states'); + } catch (e) { + debugPrint('[FeatureFlags] Failed to load states: $e'); + } + } + + Future _persistStates() async { + try { + final jsonStr = jsonEncode(_featureStates); + await _prefs?.setString(_storageKey, jsonStr); + } catch (e) { + debugPrint('[FeatureFlags] Failed to persist states: $e'); + } + } +} diff --git a/mobile_agent/lib/services/github_deep_service.dart b/mobile_agent/lib/services/github_deep_service.dart index d0f3fb9..4c8764c 100644 --- a/mobile_agent/lib/services/github_deep_service.dart +++ b/mobile_agent/lib/services/github_deep_service.dart @@ -1,1319 +1,1516 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../models/github_repo.dart'; -import 'github_cache_service.dart'; - -// ============================================================================= -// EXCEPTIONS -// ============================================================================= - -class GitHubDeepException implements Exception { - final String message; - final String? endpoint; - final int? statusCode; - final dynamic originalError; - - const GitHubDeepException({ - required this.message, - this.endpoint, - this.statusCode, - this.originalError, - }); - - @override - String toString() => 'GitHubDeepException [$endpoint ${statusCode ?? ''}]: $message'; -} - -// ============================================================================= -// PAGINATED RESULT -// ============================================================================= - -/// A paginated result wrapper for GitHub API list endpoints. -/// -/// GitHub uses Link headers for pagination, but this wrapper provides -/// a simpler page-based model with `hasNextPage` / `hasPrevPage` flags. -/// -/// ```dart -/// final result = await service.getReposPaginated(page: 1); -/// if (result.hasNextPage) { -/// final next = await service.getReposPaginated(page: result.page + 1); -/// } -/// ``` -class PaginatedResult { - /// The items on this page. - final List items; - - /// Current page number (1-based). - final int page; - - /// Number of items per page. - final int perPage; - - /// Total number of items, if available from the API. - /// For search endpoints, this is `total_count`. - /// For regular list endpoints, this may be null. - final int? totalCount; - - /// Whether there is a next page. - final bool hasNextPage; - - /// Whether there is a previous page. - final bool hasPrevPage; - - /// Raw Link header data (if needed for cursor-based pagination). - final String? linkHeader; - - const PaginatedResult({ - required this.items, - required this.page, - required this.perPage, - this.totalCount, - required this.hasNextPage, - this.hasPrevPage = false, - this.linkHeader, - }); - - /// Total number of pages (estimated from totalCount / perPage). - /// Returns null if totalCount is not available. - int? get totalPages { - if (totalCount == null) return null; - return (totalCount! + perPage - 1) ~/ perPage; - } - - /// Number of items on the current page. - int get itemCount => items.length; - - /// Whether the result set is empty. - bool get isEmpty => items.isEmpty; - - /// Whether the result set has items. - bool get isNotEmpty => items.isNotEmpty; - - /// Convenience: map over items. - List map(R Function(T) fn) => items.map(fn).toList(); - - /// Convenience: filter items. - List where(bool Function(T) fn) => items.where(fn).toList(); - - @override - String toString() { - return 'PaginatedResult(page=$page, perPage=$perPage, ' - 'items=${items.length}, total=$totalCount, hasNext=$hasNextPage)'; - } -} - -// ============================================================================= -// RATE LIMIT -// ============================================================================= - -/// GitHub API rate limit status from the `/rate_limit` endpoint. -/// -/// Provides information about the current rate limit window, -/// including usage percentage and critical status. -class RateLimit { - /// Maximum requests allowed per hour. - final int limit; - - /// Remaining requests in the current window. - final int remaining; - - /// Requests used in the current window. - final int used; - - /// When the rate limit window resets. - final DateTime resetAt; - - const RateLimit({ - required this.limit, - required this.remaining, - required this.used, - required this.resetAt, - }); - - /// Usage percentage (0.0 to 1.0). - double get usagePercent => used / limit; - - /// Whether remaining requests are critically low (< 100). - bool get isCritical => remaining < 100; - - /// Whether the rate limit has been exceeded. - bool get isExceeded => remaining <= 0; - - /// Time until the rate limit window resets. - Duration get timeUntilReset { - final diff = resetAt.difference(DateTime.now()); - return diff.isNegative ? Duration.zero : diff; - } - - /// Whether the rate limit window has reset. - bool get hasReset => DateTime.now().isAfter(resetAt); - - /// Formatted usage string: "4,200 / 5,000 (84%)" - String get usageString { - final percent = (usagePercent * 100).toStringAsFixed(0); - return '$used / $limit ($percent%)'; - } - - @override - String toString() { - return 'RateLimit(used=$used/$limit, remaining=$remaining, ' - 'resets in ${timeUntilReset.inMinutes}m)'; - } -} - -// ============================================================================= -// AUTH SESSION MODEL -// ============================================================================= - -/// Represents an authenticated GitHub session for a single account. -@immutable -class GitHubSession { - final String token; - final String username; - final String avatarUrl; - final int id; - final DateTime authenticatedAt; - - const GitHubSession({ - required this.token, - required this.username, - required this.avatarUrl, - required this.id, - required this.authenticatedAt, - }); - - factory GitHubSession.fromUserJson(Map json, String token) { - return GitHubSession( - token: token, - username: json['login'] as String, - avatarUrl: json['avatar_url'] as String? ?? '', - id: json['id'] as int? ?? 0, - authenticatedAt: DateTime.now(), - ); - } - - Map toJson() => { - 'token': token, - 'username': username, - 'avatarUrl': avatarUrl, - 'id': id, - 'authenticatedAt': authenticatedAt.toIso8601String(), - }; - - factory GitHubSession.fromJson(Map json) { - return GitHubSession( - token: json['token'] as String, - username: json['username'] as String, - avatarUrl: json['avatarUrl'] as String? ?? '', - id: json['id'] as int? ?? 0, - authenticatedAt: DateTime.parse(json['authenticatedAt'] as String), - ); - } - - /// Whether the session is older than 24 hours and may need refresh. - bool get needsRefresh { - return DateTime.now().difference(authenticatedAt).inHours > 24; - } -} - -// ============================================================================= -// MAIN SERVICE -// ============================================================================= - -/// Deep GitHub integration service with full API coverage. -/// -/// Provides authentication (PAT & OAuth), repository management, -/// file CRUD operations, issue/PR management, notifications, search, -/// and Git operations support. -/// -/// Supports multiple account sessions and secure token storage. -class GitHubDeepService { - static const String _baseUrl = 'https://api.github.com'; - static const String _apiVersion = '2022-11-28'; - static const _storageKey = 'github_sessions'; - static const _activeSessionKey = 'github_active_session_idx'; - - final _secureStorage = const FlutterSecureStorage(); - final _httpClient = http.Client(); - - /// Two-level cache for GitHub API responses (L1 memory + L2 persistent). - final GitHubCacheService _cache = GitHubCacheService(); - - /// Whether the cache has been initialized. - bool _cacheInitialized = false; - - /// All authenticated sessions (multi-account support). - final List _sessions = []; - int _activeSessionIndex = -1; - - /// Currently active session, or null if not logged in. - GitHubSession? get activeSession => - _activeSessionIndex >= 0 && _activeSessionIndex < _sessions.length - ? _sessions[_activeSessionIndex] - : null; - - /// Whether any account is currently authenticated. - bool get isAuthenticated => activeSession != null; - - /// Current user's username, or null. - String? get currentUser => activeSession?.username; - - /// List of all logged-in account usernames. - List get accountList => _sessions.map((s) => s.username).toList(); - - // --------------------------------------------------------------------------- - // INITIALIZATION - // --------------------------------------------------------------------------- - - /// Load saved sessions from secure storage on app startup. - Future initialize() async { - try { - final saved = await _secureStorage.read(key: _storageKey); - if (saved != null && saved.isNotEmpty) { - final List decoded = jsonDecode(saved); - _sessions.clear(); - for (final item in decoded) { - _sessions.add(GitHubSession.fromJson(item as Map)); - } - } - - final prefs = await SharedPreferences.getInstance(); - _activeSessionIndex = prefs.getInt(_activeSessionKey) ?? -1; - - if (_activeSessionIndex >= _sessions.length) { - _activeSessionIndex = _sessions.isNotEmpty ? 0 : -1; - } - - debugPrint('[GitHubDeepService] Loaded ${_sessions.length} sessions'); - } catch (e) { - debugPrint('[GitHubDeepService] Failed to load sessions: $e'); - } - - // Initialize cache service - try { - await _cache.initialize(); - _cacheInitialized = true; - debugPrint('[GitHubDeepService] Cache initialized'); - } catch (e) { - debugPrint('[GitHubDeepService] Cache initialization skipped: $e'); - } - } - - // --------------------------------------------------------------------------- - // AUTHENTICATION - // --------------------------------------------------------------------------- - - /// Authenticate with a Personal Access Token (PAT). - /// - /// Validates the token against the GitHub API, stores it securely, - /// and adds it to the session list. - Future authenticate(String token) async { - try { - final userData = await _getJson('/user', token: token); - if (userData == null || userData['login'] == null) { - return false; - } - - final session = GitHubSession.fromUserJson(userData, token); - - // Remove any existing session with same username. - _sessions.removeWhere((s) => s.username == session.username); - _sessions.add(session); - _activeSessionIndex = _sessions.length - 1; - - await _persistSessions(); - - debugPrint('[GitHubDeepService] Authenticated as ${session.username}'); - return true; - } catch (e) { - debugPrint('[GitHubDeepService] Authentication failed: $e'); - return false; - } - } - - /// Switch to a different account by username. - Future switchAccount(String username) async { - final idx = _sessions.indexWhere((s) => s.username == username); - if (idx < 0) return false; - _activeSessionIndex = idx; - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_activeSessionKey, idx); - debugPrint('[GitHubDeepService] Switched to account: $username'); - return true; - } - - /// Log out the currently active account. - Future logout() async { - if (_activeSessionIndex >= 0 && _activeSessionIndex < _sessions.length) { - final username = _sessions[_activeSessionIndex].username; - _sessions.removeAt(_activeSessionIndex); - _activeSessionIndex = _sessions.isNotEmpty ? 0 : -1; - await _persistSessions(); - debugPrint('[GitHubDeepService] Logged out: $username'); - } - } - - /// Log out all accounts and clear all stored sessions. - Future logoutAll() async { - _sessions.clear(); - _activeSessionIndex = -1; - await _secureStorage.delete(key: _storageKey); - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_activeSessionKey); - debugPrint('[GitHubDeepService] All sessions cleared'); - } - - /// Validate that the current active token is still valid. - Future validateToken() async { - if (activeSession == null) return false; - try { - final result = await _getJson('/user'); - return result != null && result['login'] != null; - } catch (_) { - return false; - } - } - - /// Refresh session info (avatar, etc.) for the active account. - Future refreshSession() async { - if (activeSession == null) return; - try { - final userData = await _getJson('/user'); - if (userData != null) { - final newSession = GitHubSession( - token: activeSession!.token, - username: userData['login'] as String, - avatarUrl: userData['avatar_url'] as String? ?? activeSession!.avatarUrl, - id: userData['id'] as int? ?? activeSession!.id, - authenticatedAt: DateTime.now(), - ); - _sessions[_activeSessionIndex] = newSession; - await _persistSessions(); - } - } catch (e) { - debugPrint('[GitHubDeepService] Refresh session failed: $e'); - } - } - - // --------------------------------------------------------------------------- - // USER PROFILE - // --------------------------------------------------------------------------- - - /// Get the authenticated user's full profile. - Future> getUserProfile() async { - return await _getJson('/user') ?? {}; - } - - /// Get a specific user's profile. - Future> getUser(String username) async { - return await _getJson('/users/$username') ?? {}; - } - - // --------------------------------------------------------------------------- - // REPOSITORIES - // --------------------------------------------------------------------------- - - /// List repositories for the authenticated user. - /// - /// [type] - 'all', 'owner', 'member', 'collaborator' - /// [sort] - 'created', 'updated', 'pushed', 'full_name' - /// [affiliation] - 'owner,collaborator,organization_member' - Future> getRepos({ - String? type, - String? sort, - String? affiliation, - int perPage = 100, - }) async { - final query = { - 'per_page': '$perPage', - if (sort != null) 'sort': sort else 'sort': 'pushed', - if (type != null) 'type': type else 'type': 'all', - if (affiliation != null) 'affiliation': affiliation, - }; - - final List data = await _getJsonList('/user/repos', query: query); - return data.map((item) => GitHubRepo.fromGitHubApi(item)).toList(); - } - - /// Create a new repository. - Future createRepo( - String name, { - String? description, - bool isPrivate = false, - bool autoInit = false, - }) async { - final body = { - 'name': name, - if (description != null && description.isNotEmpty) 'description': description, - 'private': isPrivate, - if (autoInit) 'auto_init': true, - }; - - final data = await _postJson('/user/repos', body: body); - return GitHubRepo.fromGitHubApi(data); - } - - /// Fork a repository to the authenticated user's account. - Future forkRepo(String owner, String repo) async { - return await _postJson('/repos/$owner/$repo/forks'); - } - - /// Star a repository. - Future starRepo(String owner, String repo) async { - await _putJson('/user/starred/$owner/$repo', body: {}); - } - - /// Unstar a repository. - Future unstarRepo(String owner, String repo) async { - await _deleteJson('/user/starred/$owner/$repo'); - } - - /// Check if a repository is starred. - Future isStarred(String owner, String repo) async { - try { - final token = activeSession?.token; - if (token == null) return false; - final response = await _httpClient.get( - Uri.parse('$_baseUrl/user/starred/$owner/$repo'), - headers: _headers(token), - ); - return response.statusCode == 204; - } catch (_) { - return false; - } - } - - /// Watch (subscribe to) a repository. - Future watchRepo(String owner, String repo) async { - await _putJson('/repos/$owner/$repo/subscription', body: {'subscribed': true}); - } - - /// Unwatch a repository. - Future unwatchRepo(String owner, String repo) async { - await _deleteJson('/repos/$owner/$repo/subscription'); - } - - /// Delete a repository (owner only). - Future deleteRepo(String owner, String repo) async { - await _deleteJson('/repos/$owner/$repo'); - } - - /// Get repository details. - Future> getRepoDetails(String owner, String repo) async { - return await _getJson('/repos/$owner/$repo') ?? {}; - } - - // --------------------------------------------------------------------------- - // PAGINATED REPOSITORIES - // --------------------------------------------------------------------------- - - /// Get repositories for the authenticated user (paginated). - /// - /// [page] - Page number (1-based). - /// [perPage] - Items per page (max 100). - /// [type] - 'all', 'owner', 'member', 'collaborator' - /// [sort] - 'created', 'updated', 'pushed', 'full_name' - /// - /// Returns a [PaginatedResult] with items and pagination metadata. - Future> getReposPaginated({ - int page = 1, - int perPage = 30, - String? type, - String? sort, - }) async { - final query = { - 'per_page': '$perPage', - 'page': '$page', - if (sort != null) 'sort': sort else 'sort': 'pushed', - if (type != null) 'type': type else 'type': 'all', - }; - - final List data = await _getJsonListCached('/user/repos', query: query); - final repos = data.map((item) => GitHubRepo.fromGitHubApi(item)).toList(); - - // Determine if there's a next page by checking if we got a full page - final hasNext = repos.length >= perPage; - - return PaginatedResult( - items: repos, - page: page, - perPage: perPage, - hasNextPage: hasNext, - hasPrevPage: page > 1, - ); - } - - // --------------------------------------------------------------------------- - // PAGINATED ISSUES - // --------------------------------------------------------------------------- - - /// Get issues for a repository (paginated). - /// - /// [page] - Page number (1-based). - /// [perPage] - Items per page (max 100). - /// [state] - 'open', 'closed', 'all' - /// [labels] - Comma-separated label names to filter by. - Future> getIssuesPaginated( - String owner, - String repo, { - int page = 1, - int perPage = 30, - String state = 'open', - String? labels, - }) async { - final query = { - 'state': state, - 'per_page': '$perPage', - 'page': '$page', - 'sort': 'updated', - 'direction': 'desc', - if (labels != null && labels.isNotEmpty) 'labels': labels, - }; - - final data = await _getJsonListCached( - '/repos/$owner/$repo/issues', - query: query, - ); - - // Filter out pull requests (GitHub includes PRs in issues endpoint) - final issues = data.where((item) { - if (item is! Map) return false; - return item['pull_request'] == null; - }).toList(); - - final hasNext = data.length >= perPage; - - return PaginatedResult( - items: issues, - page: page, - perPage: perPage, - hasNextPage: hasNext, - hasPrevPage: page > 1, - ); - } - - // --------------------------------------------------------------------------- - // PAGINATED PULL REQUESTS - // --------------------------------------------------------------------------- - - /// Get pull requests for a repository (paginated). - /// - /// [page] - Page number (1-based). - /// [perPage] - Items per page (max 100). - /// [state] - 'open', 'closed', 'all' - Future> getPullRequestsPaginated( - String owner, - String repo, { - int page = 1, - int perPage = 30, - String state = 'open', - }) async { - final query = { - 'state': state, - 'per_page': '$perPage', - 'page': '$page', - 'sort': 'updated', - 'direction': 'desc', - }; - - final data = await _getJsonListCached( - '/repos/$owner/$repo/pulls', - query: query, - ); - - final hasNext = data.length >= perPage; - - return PaginatedResult( - items: data, - page: page, - perPage: perPage, - hasNextPage: hasNext, - hasPrevPage: page > 1, - ); - } - - /// Get repository branches. - Future> getBranches(String owner, String repo) async { - return await _getJsonList('/repos/$owner/$repo/branches'); - } - - /// Get repository contributors. - Future> getContributors(String owner, String repo) async { - return await _getJsonList('/repos/$owner/$repo/contributors'); - } - - /// Get repository releases. - Future> getReleases(String owner, String repo) async { - return await _getJsonList('/repos/$owner/$repo/releases'); - } - - /// Get repository tags. - Future> getTags(String owner, String repo) async { - return await _getJsonList('/repos/$owner/$repo/tags'); - } - - /// Get README content rendered as HTML, or raw markdown. - Future?> getReadme(String owner, String repo) async { - return await _getJson('/repos/$owner/$repo/readme'); - } - - // --------------------------------------------------------------------------- - // FILE CONTENTS - // --------------------------------------------------------------------------- - - /// Get directory contents or single file details. - /// - /// Returns a list of items. For directories, each item is a file or subdir - /// in the GitHub API format. For files, returns a single-item list with - /// the file metadata including base64-encoded content. - Future> getContents( - String owner, - String repo, { - String? path, - String? ref, - }) async { - var url = '/repos/$owner/$repo/contents'; - if (path != null && path.isNotEmpty) url += '/$path'; - - final query = { - if (ref != null && ref.isNotEmpty) 'ref': ref, - }; - - final data = await _getJson(url, query: query.isNotEmpty ? query : null); - if (data == null) return []; - if (data is List) return data; - // Single file. - return [data]; - } - - /// Get decoded file content as a string. - Future getFileContent( - String owner, - String repo, - String path, { - String? ref, - }) async { - final items = await getContents(owner, repo, path: path, ref: ref); - if (items.isEmpty) throw const GitHubDeepException(message: 'File not found'); - - final fileData = items.first as Map; - final content = fileData['content'] as String?; - if (content == null) return ''; - - // Remove newlines that GitHub inserts in base64. - final clean = content.replaceAll('\n', ''); - return utf8.decode(base64Decode(clean)); - } - - /// Create or update a file with a commit. - /// - /// Returns true on success. For updates, the current file SHA is - /// automatically fetched if not provided. - Future createOrUpdateFile( - String owner, - String repo, - String path, - String content, - String message, { - String? branch, - String? sha, - }) async { - // If no SHA provided, try to get it (file must exist for updates). - String? fileSha = sha; - if (fileSha == null) { - try { - final existing = await getContents(owner, repo, path: path, ref: branch); - if (existing.isNotEmpty) { - fileSha = (existing.first as Map)['sha'] as String?; - } - } catch (_) { - // File doesn't exist — creating new. - } - } - - final body = { - 'message': message, - 'content': base64Encode(utf8.encode(content)), - if (branch != null) 'branch': branch, - if (fileSha != null) 'sha': fileSha, - }; - - await _putJson('/repos/$owner/$repo/contents/$path', body: body); - return true; - } - - /// Delete a file with a commit. - Future deleteFile( - String owner, - String repo, - String path, - String message, { - String? branch, - }) async { - // Must provide SHA to delete. - final items = await getContents(owner, repo, path: path, ref: branch); - final fileSha = (items.first as Map)['sha'] as String?; - - final body = { - 'message': message, - 'sha': fileSha, - if (branch != null) 'branch': branch, - }; - - await _deleteJson('/repos/$owner/$repo/contents/$path', body: body); - } - - /// Rename/move a file (copy + delete pattern). - Future renameFile( - String owner, - String repo, - String oldPath, - String newPath, - String message, { - String? branch, - }) async { - // Get content of old file. - final content = await getFileContent(owner, repo, oldPath, ref: branch); - - // Create new file. - await createOrUpdateFile(owner, repo, newPath, content, 'Create $newPath', - branch: branch); - - // Delete old file. - await deleteFile(owner, repo, oldPath, 'Delete $oldPath', branch: branch); - - return true; - } - - /// Get commit history for a file. - Future> getFileHistory( - String owner, - String repo, - String path, { - String? branch, - }) async { - final query = { - 'path': path, - if (branch != null) 'sha': branch, - }; - return await _getJsonList('/repos/$owner/$repo/commits', query: query); - } - - /// Get blame data (line-by-line author info) for a file. - /// Note: GitHub's blame API returns the raw blame data. - Future> getBlame( - String owner, - String repo, - String path, { - String? branch, - }) async { - // GitHub doesn't have a direct blame API; we use the commits API - // with path filter to get relevant commits, then reconstruct. - return await getFileHistory(owner, repo, path, branch: branch); - } - - // --------------------------------------------------------------------------- - // COMMITS & BRANCHES - // --------------------------------------------------------------------------- - - /// Get commit history for a repository. - Future> getCommits( - String owner, - String repo, { - String? sha, - String? path, - int perPage = 30, - }) async { - final query = { - 'per_page': '$perPage', - if (sha != null) 'sha': sha, - if (path != null) 'path': path, - }; - return await _getJsonList('/repos/$owner/$repo/commits', query: query); - } - - /// Get a single commit's details. - Future?> getCommit( - String owner, - String repo, - String sha, - ) async { - return await _getJson('/repos/$owner/$repo/commits/$sha'); - } - - /// Create a new branch. - Future createBranch( - String owner, - String repo, - String branchName, - String fromSha, - ) async { - await _postJson( - '/repos/$owner/$repo/git/refs', - body: { - 'ref': 'refs/heads/$branchName', - 'sha': fromSha, - }, - ); - } - - /// Delete a branch. - Future deleteBranch(String owner, String repo, String branchName) async { - await _deleteJson('/repos/$owner/$repo/git/refs/heads/$branchName'); - } - - // --------------------------------------------------------------------------- - // ISSUES - // --------------------------------------------------------------------------- - - /// List issues for a repository. - /// - /// [state] - 'open', 'closed', 'all' - /// [labels] - comma-separated label names - /// [assignee] - username or 'none', '*' - Future> getIssues( - String owner, - String repo, { - String state = 'open', - String? labels, - String? assignee, - String? sort, - int perPage = 50, - }) async { - final query = { - 'state': state, - 'per_page': '$perPage', - 'sort': sort ?? 'updated', - 'direction': 'desc', - if (labels != null && labels.isNotEmpty) 'labels': labels, - if (assignee != null && assignee.isNotEmpty) 'assignee': assignee, - }; - return await _getJsonList('/repos/$owner/$repo/issues', query: query); - } - - /// Create a new issue. - Future createIssue( - String owner, - String repo, - String title, { - String? body, - List? labels, - List? assignees, - int? milestone, - }) async { - final requestBody = { - 'title': title, - if (body != null && body.isNotEmpty) 'body': body, - if (labels != null && labels.isNotEmpty) 'labels': labels, - if (assignees != null && assignees.isNotEmpty) 'assignees': assignees, - if (milestone != null) 'milestone': milestone, - }; - return await _postJson('/repos/$owner/$repo/issues', body: requestBody); - } - - /// Update an issue (title, body, state, labels, assignees). - Future updateIssue( - String owner, - String repo, - int number, { - String? title, - String? body, - String? state, - List? labels, - List? assignees, - }) async { - final requestBody = { - if (title != null) 'title': title, - if (body != null) 'body': body, - if (state != null) 'state': state, - if (labels != null) 'labels': labels, - if (assignees != null) 'assignees': assignees, - }; - return await _patchJson('/repos/$owner/$repo/issues/$number', body: requestBody); - } - - /// Get a single issue with full details. - Future?> getIssue( - String owner, - String repo, - int number, - ) async { - return await _getJson('/repos/$owner/$repo/issues/$number'); - } - - /// Get issue timeline (events, comments, references). - Future> getIssueTimeline( - String owner, - String repo, - int number, - ) async { - return await _getJsonList( - '/repos/$owner/$repo/issues/$number/timeline', - extraHeaders: {'Accept': 'application/vnd.github.mockingbird-preview+json'}, - ); - } - - /// Get comments on an issue. - Future> getIssueComments( - String owner, - String repo, - int number, - ) async { - return await _getJsonList('/repos/$owner/$repo/issues/$number/comments'); - } - - /// Add a comment to an issue. - Future addIssueComment( - String owner, - String repo, - int number, - String body, - ) async { - return await _postJson( - '/repos/$owner/$repo/issues/$number/comments', - body: {'body': body}, - ); - } - - /// List labels for a repository. - Future> getLabels(String owner, String repo) async { - return await _getJsonList('/repos/$owner/$repo/labels'); - } - - /// List milestones for a repository. - Future> getMilestones(String owner, String repo) async { - return await _getJsonList('/repos/$owner/$repo/milestones'); - } - - // --------------------------------------------------------------------------- - // PULL REQUESTS - // --------------------------------------------------------------------------- - - /// List pull requests for a repository. - Future> getPullRequests( - String owner, - String repo, { - String state = 'open', - String? head, - String? base, - String? sort, - int perPage = 50, - }) async { - final query = { - 'state': state, - 'per_page': '$perPage', - 'sort': sort ?? 'updated', - 'direction': 'desc', - if (head != null && head.isNotEmpty) 'head': head, - if (base != null && base.isNotEmpty) 'base': base, - }; - return await _getJsonList('/repos/$owner/$repo/pulls', query: query); - } - - /// Get a single pull request. - Future?> getPullRequest( - String owner, - String repo, - int number, - ) async { - return await _getJson('/repos/$owner/$repo/pulls/$number'); - } - - /// Create a pull request. - Future createPullRequest( - String owner, - String repo, - String title, - String head, - String base, { - String? body, - bool draft = false, - }) async { - final requestBody = { - 'title': title, - 'head': head, - 'base': base, - if (body != null && body.isNotEmpty) 'body': body, - 'draft': draft, - }; - return await _postJson('/repos/$owner/$repo/pulls', body: requestBody); - } - - /// Merge a pull request. - /// - /// [method] - 'merge', 'squash', or 'rebase'. - Future?> mergePullRequest( - String owner, - String repo, - int number, { - String method = 'merge', - String? commitTitle, - String? commitMessage, - }) async { - final body = { - 'merge_method': method, - if (commitTitle != null) 'commit_title': commitTitle, - if (commitMessage != null) 'commit_message': commitMessage, - }; - return await _putJson('/repos/$owner/$repo/pulls/$number/merge', body: body); - } - - /// Get PR diff as raw text. - Future getPullRequestDiff( - String owner, - String repo, - int number, - ) async { - final token = activeSession?.token; - if (token == null) throw const GitHubDeepException(message: 'Not authenticated'); - - final response = await _httpClient.get( - Uri.parse('$_baseUrl/repos/$owner/$repo/pulls/$number'), - headers: { - ..._headers(token), - 'Accept': 'application/vnd.github.v3.diff', - }, - ); - - if (response.statusCode == 200) { - return response.body; - } - throw GitHubDeepException( - message: 'Failed to fetch PR diff: ${response.statusCode}', - statusCode: response.statusCode, - ); - } - - /// List commits in a PR. - Future> getPullRequestCommits( - String owner, - String repo, - int number, - ) async { - return await _getJsonList('/repos/$owner/$repo/pulls/$number/commits'); - } +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../models/github_repo.dart'; +import 'github_cache_service.dart'; + +// ============================================================================= +// EXCEPTIONS +// ============================================================================= + +class GitHubDeepException implements Exception { + final String message; + final String? endpoint; + final int? statusCode; + final dynamic originalError; + + const GitHubDeepException({ + required this.message, + this.endpoint, + this.statusCode, + this.originalError, + }); + + @override + String toString() => 'GitHubDeepException [$endpoint ${statusCode ?? ''}]: $message'; +} + +// ============================================================================= +// PAGINATED RESULT +// ============================================================================= + +/// A paginated result wrapper for GitHub API list endpoints. +/// +/// GitHub uses Link headers for pagination, but this wrapper provides +/// a simpler page-based model with `hasNextPage` / `hasPrevPage` flags. +/// +/// ```dart +/// final result = await service.getReposPaginated(page: 1); +/// if (result.hasNextPage) { +/// final next = await service.getReposPaginated(page: result.page + 1); +/// } +/// ``` +class PaginatedResult { + /// The items on this page. + final List items; + + /// Current page number (1-based). + final int page; + + /// Number of items per page. + final int perPage; + + /// Total number of items, if available from the API. + /// For search endpoints, this is `total_count`. + /// For regular list endpoints, this may be null. + final int? totalCount; + + /// Whether there is a next page. + final bool hasNextPage; + + /// Whether there is a previous page. + final bool hasPrevPage; + + /// Raw Link header data (if needed for cursor-based pagination). + final String? linkHeader; + + const PaginatedResult({ + required this.items, + required this.page, + required this.perPage, + this.totalCount, + required this.hasNextPage, + this.hasPrevPage = false, + this.linkHeader, + }); + + /// Total number of pages (estimated from totalCount / perPage). + /// Returns null if totalCount is not available. + int? get totalPages { + if (totalCount == null) return null; + return (totalCount! + perPage - 1) ~/ perPage; + } + + /// Number of items on the current page. + int get itemCount => items.length; + + /// Whether the result set is empty. + bool get isEmpty => items.isEmpty; + + /// Whether the result set has items. + bool get isNotEmpty => items.isNotEmpty; + + /// Convenience: map over items. + List map(R Function(T) fn) => items.map(fn).toList(); + + /// Convenience: filter items. + List where(bool Function(T) fn) => items.where(fn).toList(); + + @override + String toString() { + return 'PaginatedResult(page=$page, perPage=$perPage, ' + 'items=${items.length}, total=$totalCount, hasNext=$hasNextPage)'; + } +} + +// ============================================================================= +// RATE LIMIT +// ============================================================================= + +/// GitHub API rate limit status from the `/rate_limit` endpoint. +/// +/// Provides information about the current rate limit window, +/// including usage percentage and critical status. +class RateLimit { + /// Maximum requests allowed per hour. + final int limit; + + /// Remaining requests in the current window. + final int remaining; + + /// Requests used in the current window. + final int used; + + /// When the rate limit window resets. + final DateTime resetAt; + + const RateLimit({ + required this.limit, + required this.remaining, + required this.used, + required this.resetAt, + }); + + /// Usage percentage (0.0 to 1.0). + double get usagePercent => used / limit; + + /// Whether remaining requests are critically low (< 100). + bool get isCritical => remaining < 100; + + /// Whether the rate limit has been exceeded. + bool get isExceeded => remaining <= 0; + + /// Time until the rate limit window resets. + Duration get timeUntilReset { + final diff = resetAt.difference(DateTime.now()); + return diff.isNegative ? Duration.zero : diff; + } + + /// Whether the rate limit window has reset. + bool get hasReset => DateTime.now().isAfter(resetAt); + + /// Formatted usage string: "4,200 / 5,000 (84%)" + String get usageString { + final percent = (usagePercent * 100).toStringAsFixed(0); + return '$used / $limit ($percent%)'; + } + + @override + String toString() { + return 'RateLimit(used=$used/$limit, remaining=$remaining, ' + 'resets in ${timeUntilReset.inMinutes}m)'; + } +} + +// ============================================================================= +// AUTH SESSION MODEL +// ============================================================================= + +/// Represents an authenticated GitHub session for a single account. +@immutable +class GitHubSession { + final String token; + final String username; + final String avatarUrl; + final int id; + final DateTime authenticatedAt; + + const GitHubSession({ + required this.token, + required this.username, + required this.avatarUrl, + required this.id, + required this.authenticatedAt, + }); + + factory GitHubSession.fromUserJson(Map json, String token) { + return GitHubSession( + token: token, + username: json['login'] as String, + avatarUrl: json['avatar_url'] as String? ?? '', + id: json['id'] as int? ?? 0, + authenticatedAt: DateTime.now(), + ); + } + + Map toJson() => { + 'token': token, + 'username': username, + 'avatarUrl': avatarUrl, + 'id': id, + 'authenticatedAt': authenticatedAt.toIso8601String(), + }; + + factory GitHubSession.fromJson(Map json) { + return GitHubSession( + token: json['token'] as String, + username: json['username'] as String, + avatarUrl: json['avatarUrl'] as String? ?? '', + id: json['id'] as int? ?? 0, + authenticatedAt: DateTime.parse(json['authenticatedAt'] as String), + ); + } + + /// Whether the session is older than 24 hours and may need refresh. + bool get needsRefresh { + return DateTime.now().difference(authenticatedAt).inHours > 24; + } +} + +// ============================================================================= +// MAIN SERVICE +// ============================================================================= + +/// Deep GitHub integration service with full API coverage. +/// +/// Provides authentication (PAT & OAuth), repository management, +/// file CRUD operations, issue/PR management, notifications, search, +/// and Git operations support. +/// +/// Supports multiple account sessions and secure token storage. +class GitHubDeepService { + static const String _baseUrl = 'https://api.github.com'; + static const String _apiVersion = '2022-11-28'; + static const _storageKey = 'github_sessions'; + static const _activeSessionKey = 'github_active_session_idx'; + + final _secureStorage = const FlutterSecureStorage(); + final _httpClient = http.Client(); + + /// Two-level cache for GitHub API responses (L1 memory + L2 persistent). + final GitHubCacheService _cache = GitHubCacheService(); + + /// Whether the cache has been initialized. + bool _cacheInitialized = false; + + /// All authenticated sessions (multi-account support). + final List _sessions = []; + int _activeSessionIndex = -1; + + /// Currently active session, or null if not logged in. + GitHubSession? get activeSession => + _activeSessionIndex >= 0 && _activeSessionIndex < _sessions.length + ? _sessions[_activeSessionIndex] + : null; + + /// Whether any account is currently authenticated. + bool get isAuthenticated => activeSession != null; + + /// Current user's username, or null. + String? get currentUser => activeSession?.username; + + /// List of all logged-in account usernames. + List get accountList => _sessions.map((s) => s.username).toList(); - /// List review comments on a PR. - Future> getPullRequestComments( - String owner, - String repo, - int number, - ) async { - return await _getJsonList('/repos/$owner/$repo/pulls/$number/comments'); + DateTime? authenticatedAtFor(String username) { + final index = _sessions.indexWhere((s) => s.username == username); + final session = index < 0 ? null : _sessions[index]; + return session?.authenticatedAt; + } + + String? avatarUrlFor(String username) { + final index = _sessions.indexWhere((s) => s.username == username); + final session = index < 0 ? null : _sessions[index]; + return session?.avatarUrl; + } + + // --------------------------------------------------------------------------- + // INITIALIZATION + // --------------------------------------------------------------------------- + + /// Load saved sessions from secure storage on app startup. + Future initialize() async { + try { + final saved = await _secureStorage.read(key: _storageKey); + if (saved != null && saved.isNotEmpty) { + final decoded = jsonDecode(saved) as List; + _sessions.clear(); + for (final item in decoded) { + _sessions.add(GitHubSession.fromJson(item as Map)); + } + } + + final prefs = await SharedPreferences.getInstance(); + _activeSessionIndex = prefs.getInt(_activeSessionKey) ?? -1; + + if (_activeSessionIndex >= _sessions.length) { + _activeSessionIndex = _sessions.isNotEmpty ? 0 : -1; + } + + debugPrint('[GitHubDeepService] Loaded ${_sessions.length} sessions'); + } catch (e) { + debugPrint('[GitHubDeepService] Failed to load sessions: $e'); + } + + // Initialize cache service + try { + await _cache.initialize(); + _cacheInitialized = true; + debugPrint('[GitHubDeepService] Cache initialized'); + } catch (e) { + debugPrint('[GitHubDeepService] Cache initialization skipped: $e'); + } + } + + // --------------------------------------------------------------------------- + // AUTHENTICATION + // --------------------------------------------------------------------------- + + /// Authenticate with a Personal Access Token (PAT). + /// + /// Validates the token against the GitHub API, stores it securely, + /// and adds it to the session list. + Future authenticate(String token) async { + try { + final userData = await _getJson('/user', token: token); + if (userData == null || userData['login'] == null) { + return false; + } + + final session = GitHubSession.fromUserJson(userData, token); + + // Remove any existing session with same username. + _sessions.removeWhere((s) => s.username == session.username); + _sessions.add(session); + _activeSessionIndex = _sessions.length - 1; + + await _persistSessions(); + + debugPrint('[GitHubDeepService] Authenticated as ${session.username}'); + return true; + } catch (e) { + debugPrint('[GitHubDeepService] Authentication failed: $e'); + return false; + } } - /// Create a review comment on a PR diff (inline). - Future createPullRequestComment( - String owner, - String repo, - int number, { - required String body, - String? commitId, - String? path, - int? position, - int? line, + /// Exchange a GitHub OAuth callback code for an access token, then persist the + /// resulting account just like PAT login. OAuth App token exchange requires + /// a configured client id and client secret; public builds should treat this + /// as a preview convenience because APK secrets can be extracted. + Future authenticateWithOAuthCode({ + required String code, + required String clientId, + String? clientSecret, + String? redirectUri, }) async { - final requestBody = { - 'body': body, - if (commitId != null) 'commit_id': commitId, - if (path != null) 'path': path, - if (position != null) 'position': position, - if (line != null) 'line': line, - }; - return await _postJson( - '/repos/$owner/$repo/pulls/$number/comments', - body: requestBody, - ); - } + if (clientId.trim().isEmpty) { + throw const GitHubDeepException(message: 'GitHub OAuth client id is not configured'); + } + if (clientSecret == null || clientSecret.trim().isEmpty) { + throw const GitHubDeepException(message: 'GitHub OAuth client secret is not configured'); + } + if (code.trim().isEmpty) { + throw const GitHubDeepException(message: 'GitHub OAuth callback did not include a code'); + } - /// Submit a PR review (approve, request changes, or comment). - Future submitPullRequestReview( - String owner, - String repo, - int number, { - required String event, // 'APPROVE', 'REQUEST_CHANGES', 'COMMENT' - String? body, - }) async { - final requestBody = { - 'event': event, - if (body != null && body.isNotEmpty) 'body': body, + final body = { + 'client_id': clientId.trim(), + 'client_secret': clientSecret.trim(), + 'code': code.trim(), + if (redirectUri != null && redirectUri.trim().isNotEmpty) 'redirect_uri': redirectUri.trim(), }; - return await _postJson( - '/repos/$owner/$repo/pulls/$number/reviews', - body: requestBody, + final response = await _httpClient.post( + Uri.parse('https://github.com/login/oauth/access_token'), + headers: { + 'Accept': 'application/json', + 'User-Agent': 'MobileAgent/1.0', + }, + body: body, ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw GitHubDeepException( + message: response.body.isEmpty ? 'OAuth token exchange failed' : response.body, + endpoint: '/login/oauth/access_token', + statusCode: response.statusCode, + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const GitHubDeepException(message: 'OAuth token exchange returned an invalid response'); + } + final error = decoded['error']; + if (error is String && error.isNotEmpty) { + final description = decoded['error_description'] as String? ?? error; + throw GitHubDeepException(message: description, endpoint: '/login/oauth/access_token'); + } + final token = decoded['access_token']; + if (token is! String || token.trim().isEmpty) { + throw const GitHubDeepException(message: 'OAuth token exchange did not return an access token'); + } + return authenticate(token); + } + + /// Switch to a different account by username. + Future switchAccount(String username) async { + final idx = _sessions.indexWhere((s) => s.username == username); + if (idx < 0) return false; + _activeSessionIndex = idx; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_activeSessionKey, idx); + debugPrint('[GitHubDeepService] Switched to account: $username'); + return true; + } + + /// Log out the currently active account. + Future logout() async { + if (_activeSessionIndex >= 0 && _activeSessionIndex < _sessions.length) { + final username = _sessions[_activeSessionIndex].username; + _sessions.removeAt(_activeSessionIndex); + _activeSessionIndex = _sessions.isNotEmpty ? 0 : -1; + await _persistSessions(); + debugPrint('[GitHubDeepService] Logged out: $username'); + } + } + + /// Log out all accounts and clear all stored sessions. + Future logoutAll() async { + _sessions.clear(); + _activeSessionIndex = -1; + await _secureStorage.delete(key: _storageKey); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_activeSessionKey); + debugPrint('[GitHubDeepService] All sessions cleared'); + } + + /// Validate that the current active token is still valid. + Future validateToken() async { + if (activeSession == null) return false; + try { + final result = await _getJson('/user'); + return result != null && result['login'] != null; + } catch (_) { + return false; + } + } + + /// Refresh session info (avatar, etc.) for the active account. + Future refreshSession() async { + if (activeSession == null) return; + try { + final userData = await _getJson('/user'); + if (userData != null) { + final newSession = GitHubSession( + token: activeSession!.token, + username: userData['login'] as String, + avatarUrl: userData['avatar_url'] as String? ?? activeSession!.avatarUrl, + id: userData['id'] as int? ?? activeSession!.id, + authenticatedAt: DateTime.now(), + ); + _sessions[_activeSessionIndex] = newSession; + await _persistSessions(); + } + } catch (e) { + debugPrint('[GitHubDeepService] Refresh session failed: $e'); + } } - /// Get CI status checks for a PR (via the ref). - Future?> getCombinedStatus( - String owner, - String repo, - String ref, - ) async { - return await _getJson('/repos/$owner/$repo/commits/$ref/status'); - } - - /// Get check runs (GitHub Actions) for a ref. - Future> getCheckRuns( - String owner, - String repo, - String ref, - ) async { - final data = await _getJson('/repos/$owner/$repo/commits/$ref/check-runs'); - return (data?['check_runs'] as List?) ?? []; - } - - // --------------------------------------------------------------------------- - // NOTIFICATIONS - // --------------------------------------------------------------------------- - - /// List GitHub notifications. - /// - /// [all] - If true, show read notifications too. - /// [since] - Only notifications after this time. - Future> getNotifications({ - bool all = false, - DateTime? since, - int perPage = 50, + Future> getTokenScopes({String? username}) async { + final index = username == null ? -1 : _sessions.indexWhere((s) => s.username == username); + final session = username == null + ? activeSession + : index < 0 + ? null + : _sessions[index]; + if (session == null) return const []; + final response = await _request('GET', '/user', token: session.token); + final raw = response.headers['x-oauth-scopes'] ?? ''; + return raw + .split(',') + .map((scope) => scope.trim()) + .where((scope) => scope.isNotEmpty) + .toList() + ..sort(); + } + + // --------------------------------------------------------------------------- + // USER PROFILE + // --------------------------------------------------------------------------- + + /// Get the authenticated user's full profile. + Future> getUserProfile() async { + return await _getJson('/user') ?? {}; + } + + /// Get a specific user's profile. + Future> getUser(String username) async { + return await _getJson('/users/$username') ?? {}; + } + + // --------------------------------------------------------------------------- + // REPOSITORIES + // --------------------------------------------------------------------------- + + /// List repositories for the authenticated user. + /// + /// [type] - 'all', 'owner', 'member', 'collaborator' + /// [sort] - 'created', 'updated', 'pushed', 'full_name' + /// [affiliation] - 'owner,collaborator,organization_member' + Future> getRepos({ + String? type, + String? sort, + String? affiliation, + int perPage = 100, + }) async { + final query = { + 'per_page': '$perPage', + if (sort != null) 'sort': sort else 'sort': 'pushed', + if (type != null) 'type': type else 'type': 'all', + if (affiliation != null) 'affiliation': affiliation, + }; + + final List data = await _getJsonList('/user/repos', query: query); + return data + .map((item) => GitHubRepo.fromGitHubApi(item as Map)) + .toList(); + } + + /// List public repositories for a specific GitHub user or organization. + Future> getUserRepos( + String owner, { + String sort = 'pushed', + int perPage = 100, + bool public = false, }) async { final query = { 'per_page': '$perPage', - 'all': all ? 'true' : 'false', - if (since != null) 'since': since.toUtc().toIso8601String(), + 'sort': sort, + 'type': 'all', }; - return await _getJsonList('/notifications', query: query); - } - - /// Mark a notification thread as read. - Future markNotificationRead(String threadId) async { - await _patchJson('/notifications/threads/$threadId', body: {}); - } - - /// Mark all notifications as read. - Future markAllNotificationsRead() async { - await _putJson('/notifications', body: {}); + final List data = await _getJsonList( + '/users/$owner/repos', + query: query, + allowAnonymous: public, + ); + return data + .map((item) => GitHubRepo.fromGitHubApi(item as Map)) + .toList(); + } + + /// Create a new repository. + Future createRepo( + String name, { + String? description, + bool isPrivate = false, + bool autoInit = false, + }) async { + final body = { + 'name': name, + if (description != null && description.isNotEmpty) 'description': description, + 'private': isPrivate, + if (autoInit) 'auto_init': true, + }; + + final data = await _postJson('/user/repos', body: body); + return GitHubRepo.fromGitHubApi(data); + } + + /// Fork a repository to the authenticated user's account. + Future forkRepo(String owner, String repo) async { + return await _postJson('/repos/$owner/$repo/forks'); + } + + /// Star a repository. + Future starRepo(String owner, String repo) async { + await _putJson('/user/starred/$owner/$repo', body: {}); + } + + /// Unstar a repository. + Future unstarRepo(String owner, String repo) async { + await _deleteJson('/user/starred/$owner/$repo'); + } + + /// Check if a repository is starred. + Future isStarred(String owner, String repo) async { + try { + final token = activeSession?.token; + if (token == null) return false; + final response = await _httpClient.get( + Uri.parse('$_baseUrl/user/starred/$owner/$repo'), + headers: _headers(token), + ); + return response.statusCode == 204; + } catch (_) { + return false; + } + } + + /// Watch (subscribe to) a repository. + Future watchRepo(String owner, String repo) async { + await _putJson('/repos/$owner/$repo/subscription', body: {'subscribed': true}); + } + + /// Unwatch a repository. + Future unwatchRepo(String owner, String repo) async { + await _deleteJson('/repos/$owner/$repo/subscription'); + } + + /// Delete a repository (owner only). + Future deleteRepo(String owner, String repo) async { + await _deleteJson('/repos/$owner/$repo'); + } + + /// Get repository details. + Future> getRepoDetails(String owner, String repo, {bool public = false}) async { + return await _getJson('/repos/$owner/$repo', allowAnonymous: public) ?? {}; + } + + /// List GitHub Actions workflows for a repository. + Future> getWorkflows(String owner, String repo, {int perPage = 30, bool public = false}) async { + final data = await _getJson( + '/repos/$owner/$repo/actions/workflows', + query: {'per_page': '$perPage'}, + allowAnonymous: public, + ) ?? + {}; + return (data['workflows'] as List?) ?? const []; } - /// Batch mark notification threads as read. - /// - /// More efficient than marking individually when processing - /// multiple notifications at once. - Future markNotificationsReadBatch(List threadIds) async { - // GitHub doesn't support true batch marking, so we parallelize - final futures = threadIds.map((id) => markNotificationRead(id)); - await Future.wait(futures, eagerError: false); - debugPrint('[GitHubDeepService] Marked ${threadIds.length} notifications as read'); + /// List recent GitHub Actions runs for a repository. + Future> getWorkflowRuns(String owner, String repo, {int perPage = 5, bool public = false}) async { + final data = await _getJson( + '/repos/$owner/$repo/actions/runs', + query: {'per_page': '$perPage'}, + allowAnonymous: public, + ) ?? + {}; + return (data['workflow_runs'] as List?) ?? const []; + } + + /// List artifacts for a workflow run. + Future> getWorkflowRunArtifacts(String owner, String repo, int runId, {bool public = false}) async { + final data = await _getJson('/repos/$owner/$repo/actions/runs/$runId/artifacts', allowAnonymous: public) ?? {}; + return (data['artifacts'] as List?) ?? const []; + } + + /// List jobs and step status for a workflow run. + Future> getWorkflowRunJobs(String owner, String repo, int runId, {bool public = false}) async { + final data = await _getJson('/repos/$owner/$repo/actions/runs/$runId/jobs', allowAnonymous: public) ?? {}; + return (data['jobs'] as List?) ?? const []; + } + + /// Download a workflow artifact as a zip archive. + Future> downloadWorkflowArtifactZip(String owner, String repo, int artifactId) async { + final response = await _request( + 'GET', + '/repos/$owner/$repo/actions/artifacts/$artifactId/zip', + extraHeaders: {'Accept': 'application/vnd.github+json'}, + ); + return response.bodyBytes; + } + + /// Trigger a workflow_dispatch run. The workflow identifier can be an id or file name. + Future dispatchWorkflow( + String owner, + String repo, + String workflowId, { + required String ref, + Map inputs = const {}, + }) async { + await _postJson( + '/repos/$owner/$repo/actions/workflows/$workflowId/dispatches', + body: { + 'ref': ref, + if (inputs.isNotEmpty) 'inputs': inputs, + }, + ); + } + + // --------------------------------------------------------------------------- + // PAGINATED REPOSITORIES + // --------------------------------------------------------------------------- + + /// Get repositories for the authenticated user (paginated). + /// + /// [page] - Page number (1-based). + /// [perPage] - Items per page (max 100). + /// [type] - 'all', 'owner', 'member', 'collaborator' + /// [sort] - 'created', 'updated', 'pushed', 'full_name' + /// + /// Returns a [PaginatedResult] with items and pagination metadata. + Future> getReposPaginated({ + int page = 1, + int perPage = 30, + String? type, + String? sort, + }) async { + final query = { + 'per_page': '$perPage', + 'page': '$page', + if (sort != null) 'sort': sort else 'sort': 'pushed', + if (type != null) 'type': type else 'type': 'all', + }; + + final List data = await _getJsonListCached('/user/repos', query: query); + final repos = data + .map((item) => GitHubRepo.fromGitHubApi(item as Map)) + .toList(); + + // Determine if there's a next page by checking if we got a full page + final hasNext = repos.length >= perPage; + + return PaginatedResult( + items: repos, + page: page, + perPage: perPage, + hasNextPage: hasNext, + hasPrevPage: page > 1, + ); + } + + // --------------------------------------------------------------------------- + // PAGINATED ISSUES + // --------------------------------------------------------------------------- + + /// Get issues for a repository (paginated). + /// + /// [page] - Page number (1-based). + /// [perPage] - Items per page (max 100). + /// [state] - 'open', 'closed', 'all' + /// [labels] - Comma-separated label names to filter by. + Future> getIssuesPaginated( + String owner, + String repo, { + int page = 1, + int perPage = 30, + String state = 'open', + String? labels, + }) async { + final query = { + 'state': state, + 'per_page': '$perPage', + 'page': '$page', + 'sort': 'updated', + 'direction': 'desc', + if (labels != null && labels.isNotEmpty) 'labels': labels, + }; + + final data = await _getJsonListCached( + '/repos/$owner/$repo/issues', + query: query, + ); + + // Filter out pull requests (GitHub includes PRs in issues endpoint) + final issues = data.where((item) { + if (item is! Map) return false; + return item['pull_request'] == null; + }).toList(); + + final hasNext = data.length >= perPage; + + return PaginatedResult( + items: issues, + page: page, + perPage: perPage, + hasNextPage: hasNext, + hasPrevPage: page > 1, + ); + } + + // --------------------------------------------------------------------------- + // PAGINATED PULL REQUESTS + // --------------------------------------------------------------------------- + + /// Get pull requests for a repository (paginated). + /// + /// [page] - Page number (1-based). + /// [perPage] - Items per page (max 100). + /// [state] - 'open', 'closed', 'all' + Future> getPullRequestsPaginated( + String owner, + String repo, { + int page = 1, + int perPage = 30, + String state = 'open', + }) async { + final query = { + 'state': state, + 'per_page': '$perPage', + 'page': '$page', + 'sort': 'updated', + 'direction': 'desc', + }; + + final data = await _getJsonListCached( + '/repos/$owner/$repo/pulls', + query: query, + ); + + final hasNext = data.length >= perPage; + + return PaginatedResult( + items: data, + page: page, + perPage: perPage, + hasNextPage: hasNext, + hasPrevPage: page > 1, + ); + } + + /// Get repository branches. + Future> getBranches(String owner, String repo) async { + return await _getJsonList('/repos/$owner/$repo/branches'); + } + + /// Get repository contributors. + Future> getContributors(String owner, String repo) async { + return await _getJsonList('/repos/$owner/$repo/contributors'); + } + + /// Get repository releases. + Future> getReleases(String owner, String repo, {bool public = false}) async { + return await _getJsonList('/repos/$owner/$repo/releases', allowAnonymous: public); + } + + /// Get repository tags. + Future> getTags(String owner, String repo) async { + return await _getJsonList('/repos/$owner/$repo/tags'); + } + + /// Get README content rendered as HTML, or raw markdown. + Future?> getReadme(String owner, String repo, {bool public = false}) async { + return await _getJson('/repos/$owner/$repo/readme', allowAnonymous: public); } - /// Get unread notification count. - Future getUnreadNotificationCount() async { - try { - final notifications = await getNotifications(all: false, perPage: 100); - return notifications.length; - } catch (_) { - return 0; + /// Get a single repository metadata record. + Future getRepository(String owner, String repo, {bool public = false}) async { + final json = await _getJson('/repos/$owner/$repo', allowAnonymous: public); + if (json == null) { + throw const GitHubDeepException(message: 'Repository not found', statusCode: 404); } + return GitHubRepo.fromGitHubApi(json); } // --------------------------------------------------------------------------- - // SEARCH - // --------------------------------------------------------------------------- - - /// Search repositories on GitHub. + // FILE CONTENTS + // --------------------------------------------------------------------------- + + /// Get directory contents or single file details. + /// + /// Returns a list of items. For directories, each item is a file or subdir + /// in the GitHub API format. For files, returns a single-item list with + /// the file metadata including base64-encoded content. + Future> getContents( + String owner, + String repo, { + String? path, + String? ref, + bool public = false, + }) async { + var url = '/repos/$owner/$repo/contents'; + if (path != null && path.isNotEmpty) url += '/$path'; + + final query = { + if (ref != null && ref.isNotEmpty) 'ref': ref, + }; + + final response = await _request( + 'GET', + url, + query: query.isNotEmpty ? query : null, + allowAnonymous: public, + ); + if (response.body.isEmpty) return []; + final data = jsonDecode(response.body); + if (data == null) return []; + if (data is List) return data; + // Single file. + return [data]; + } + + /// Get decoded file content as a string. + Future getFileContent( + String owner, + String repo, + String path, { + String? ref, + bool public = false, + }) async { + final items = await getContents(owner, repo, path: path, ref: ref, public: public); + if (items.isEmpty) throw const GitHubDeepException(message: 'File not found'); + + final fileData = items.first as Map; + final content = fileData['content'] as String?; + if (content == null) return ''; + + // Remove newlines that GitHub inserts in base64. + final clean = content.replaceAll('\n', ''); + return utf8.decode(base64Decode(clean)); + } + + /// Create or update a file with a commit. + /// + /// Returns true on success. For updates, the current file SHA is + /// automatically fetched if not provided. + Future createOrUpdateFile( + String owner, + String repo, + String path, + String content, + String message, { + String? branch, + String? sha, + }) async { + // If no SHA provided, try to get it (file must exist for updates). + String? fileSha = sha; + if (fileSha == null) { + try { + final existing = await getContents(owner, repo, path: path, ref: branch); + if (existing.isNotEmpty) { + fileSha = (existing.first as Map)['sha'] as String?; + } + } catch (_) { + // File doesn't exist — creating new. + } + } + + final body = { + 'message': message, + 'content': base64Encode(utf8.encode(content)), + if (branch != null) 'branch': branch, + if (fileSha != null) 'sha': fileSha, + }; + + await _putJson('/repos/$owner/$repo/contents/$path', body: body); + return true; + } + + /// Delete a file with a commit. + Future deleteFile( + String owner, + String repo, + String path, + String message, { + String? branch, + }) async { + // Must provide SHA to delete. + final items = await getContents(owner, repo, path: path, ref: branch); + final fileSha = (items.first as Map)['sha'] as String?; + + final body = { + 'message': message, + 'sha': fileSha, + if (branch != null) 'branch': branch, + }; + + await _deleteJson('/repos/$owner/$repo/contents/$path', body: body); + } + + /// Rename/move a file (copy + delete pattern). + Future renameFile( + String owner, + String repo, + String oldPath, + String newPath, + String message, { + String? branch, + }) async { + // Get content of old file. + final content = await getFileContent(owner, repo, oldPath, ref: branch); + + // Create new file. + await createOrUpdateFile(owner, repo, newPath, content, 'Create $newPath', + branch: branch); + + // Delete old file. + await deleteFile(owner, repo, oldPath, 'Delete $oldPath', branch: branch); + + return true; + } + + /// Get commit history for a file. + Future> getFileHistory( + String owner, + String repo, + String path, { + String? branch, + }) async { + final query = { + 'path': path, + if (branch != null) 'sha': branch, + }; + return await _getJsonList('/repos/$owner/$repo/commits', query: query); + } + + /// Get blame data (line-by-line author info) for a file. + /// Note: GitHub's blame API returns the raw blame data. + Future> getBlame( + String owner, + String repo, + String path, { + String? branch, + }) async { + // GitHub doesn't have a direct blame API; we use the commits API + // with path filter to get relevant commits, then reconstruct. + return await getFileHistory(owner, repo, path, branch: branch); + } + + // --------------------------------------------------------------------------- + // COMMITS & BRANCHES + // --------------------------------------------------------------------------- + + /// Get commit history for a repository. + Future> getCommits( + String owner, + String repo, { + String? sha, + String? path, + int perPage = 30, + }) async { + final query = { + 'per_page': '$perPage', + if (sha != null) 'sha': sha, + if (path != null) 'path': path, + }; + return await _getJsonList('/repos/$owner/$repo/commits', query: query); + } + + /// Get a single commit's details. + Future?> getCommit( + String owner, + String repo, + String sha, + ) async { + return await _getJson('/repos/$owner/$repo/commits/$sha'); + } + + /// Create a new branch. + Future createBranch( + String owner, + String repo, + String branchName, + String fromSha, + ) async { + await _postJson( + '/repos/$owner/$repo/git/refs', + body: { + 'ref': 'refs/heads/$branchName', + 'sha': fromSha, + }, + ); + } + + /// Delete a branch. + Future deleteBranch(String owner, String repo, String branchName) async { + await _deleteJson('/repos/$owner/$repo/git/refs/heads/$branchName'); + } + + // --------------------------------------------------------------------------- + // ISSUES + // --------------------------------------------------------------------------- + + /// List issues for a repository. + /// + /// [state] - 'open', 'closed', 'all' + /// [labels] - comma-separated label names + /// [assignee] - username or 'none', '*' + Future> getIssues( + String owner, + String repo, { + String state = 'open', + String? labels, + String? assignee, + String? sort, + int perPage = 50, + }) async { + final query = { + 'state': state, + 'per_page': '$perPage', + 'sort': sort ?? 'updated', + 'direction': 'desc', + if (labels != null && labels.isNotEmpty) 'labels': labels, + if (assignee != null && assignee.isNotEmpty) 'assignee': assignee, + }; + return await _getJsonList('/repos/$owner/$repo/issues', query: query); + } + + /// Create a new issue. + Future createIssue( + String owner, + String repo, + String title, { + String? body, + List? labels, + List? assignees, + int? milestone, + }) async { + final requestBody = { + 'title': title, + if (body != null && body.isNotEmpty) 'body': body, + if (labels != null && labels.isNotEmpty) 'labels': labels, + if (assignees != null && assignees.isNotEmpty) 'assignees': assignees, + if (milestone != null) 'milestone': milestone, + }; + return await _postJson('/repos/$owner/$repo/issues', body: requestBody); + } + + /// Update an issue (title, body, state, labels, assignees). + Future updateIssue( + String owner, + String repo, + int number, { + String? title, + String? body, + String? state, + List? labels, + List? assignees, + }) async { + final requestBody = { + if (title != null) 'title': title, + if (body != null) 'body': body, + if (state != null) 'state': state, + if (labels != null) 'labels': labels, + if (assignees != null) 'assignees': assignees, + }; + return await _patchJson('/repos/$owner/$repo/issues/$number', body: requestBody); + } + + /// Get a single issue with full details. + Future?> getIssue( + String owner, + String repo, + int number, + ) async { + return await _getJson('/repos/$owner/$repo/issues/$number'); + } + + /// Get issue timeline (events, comments, references). + Future> getIssueTimeline( + String owner, + String repo, + int number, + ) async { + return await _getJsonList( + '/repos/$owner/$repo/issues/$number/timeline', + extraHeaders: {'Accept': 'application/vnd.github.mockingbird-preview+json'}, + ); + } + + /// Get comments on an issue. + Future> getIssueComments( + String owner, + String repo, + int number, + ) async { + return await _getJsonList('/repos/$owner/$repo/issues/$number/comments'); + } + + /// Add a comment to an issue. + Future addIssueComment( + String owner, + String repo, + int number, + String body, + ) async { + return await _postJson( + '/repos/$owner/$repo/issues/$number/comments', + body: {'body': body}, + ); + } + + /// List labels for a repository. + Future> getLabels(String owner, String repo) async { + return await _getJsonList('/repos/$owner/$repo/labels'); + } + + /// List milestones for a repository. + Future> getMilestones(String owner, String repo) async { + return await _getJsonList('/repos/$owner/$repo/milestones'); + } + + // --------------------------------------------------------------------------- + // PULL REQUESTS + // --------------------------------------------------------------------------- + + /// List pull requests for a repository. + Future> getPullRequests( + String owner, + String repo, { + String state = 'open', + String? head, + String? base, + String? sort, + int perPage = 50, + }) async { + final query = { + 'state': state, + 'per_page': '$perPage', + 'sort': sort ?? 'updated', + 'direction': 'desc', + if (head != null && head.isNotEmpty) 'head': head, + if (base != null && base.isNotEmpty) 'base': base, + }; + return await _getJsonList('/repos/$owner/$repo/pulls', query: query); + } + + /// Get a single pull request. + Future?> getPullRequest( + String owner, + String repo, + int number, + ) async { + return await _getJson('/repos/$owner/$repo/pulls/$number'); + } + + /// Create a pull request. + Future createPullRequest( + String owner, + String repo, + String title, + String head, + String base, { + String? body, + bool draft = false, + }) async { + final requestBody = { + 'title': title, + 'head': head, + 'base': base, + if (body != null && body.isNotEmpty) 'body': body, + 'draft': draft, + }; + return await _postJson('/repos/$owner/$repo/pulls', body: requestBody); + } + + /// Merge a pull request. + /// + /// [method] - 'merge', 'squash', or 'rebase'. + Future?> mergePullRequest( + String owner, + String repo, + int number, { + String method = 'merge', + String? commitTitle, + String? commitMessage, + }) async { + final body = { + 'merge_method': method, + if (commitTitle != null) 'commit_title': commitTitle, + if (commitMessage != null) 'commit_message': commitMessage, + }; + return await _putJson('/repos/$owner/$repo/pulls/$number/merge', body: body); + } + + /// Get PR diff as raw text. + Future getPullRequestDiff( + String owner, + String repo, + int number, + ) async { + final token = activeSession?.token; + if (token == null) throw const GitHubDeepException(message: 'Not authenticated'); + + final response = await _httpClient.get( + Uri.parse('$_baseUrl/repos/$owner/$repo/pulls/$number'), + headers: { + ..._headers(token), + 'Accept': 'application/vnd.github.v3.diff', + }, + ); + + if (response.statusCode == 200) { + return response.body; + } + throw GitHubDeepException( + message: 'Failed to fetch PR diff: ${response.statusCode}', + statusCode: response.statusCode, + ); + } + + /// List commits in a PR. + Future> getPullRequestCommits( + String owner, + String repo, + int number, + ) async { + return await _getJsonList('/repos/$owner/$repo/pulls/$number/commits'); + } + + /// List review comments on a PR. + Future> getPullRequestComments( + String owner, + String repo, + int number, + ) async { + return await _getJsonList('/repos/$owner/$repo/pulls/$number/comments'); + } + + /// Create a review comment on a PR diff (inline). + Future createPullRequestComment( + String owner, + String repo, + int number, { + required String body, + String? commitId, + String? path, + int? position, + int? line, + }) async { + final requestBody = { + 'body': body, + if (commitId != null) 'commit_id': commitId, + if (path != null) 'path': path, + if (position != null) 'position': position, + if (line != null) 'line': line, + }; + return await _postJson( + '/repos/$owner/$repo/pulls/$number/comments', + body: requestBody, + ); + } + + /// Submit a PR review (approve, request changes, or comment). + Future submitPullRequestReview( + String owner, + String repo, + int number, { + required String event, // 'APPROVE', 'REQUEST_CHANGES', 'COMMENT' + String? body, + }) async { + final requestBody = { + 'event': event, + if (body != null && body.isNotEmpty) 'body': body, + }; + return await _postJson( + '/repos/$owner/$repo/pulls/$number/reviews', + body: requestBody, + ); + } + + /// Get CI status checks for a PR (via the ref). + Future?> getCombinedStatus( + String owner, + String repo, + String ref, + ) async { + return await _getJson('/repos/$owner/$repo/commits/$ref/status'); + } + + /// Get check runs (GitHub Actions) for a ref. + Future> getCheckRuns( + String owner, + String repo, + String ref, + ) async { + final data = await _getJson('/repos/$owner/$repo/commits/$ref/check-runs'); + return (data?['check_runs'] as List?) ?? []; + } + + // --------------------------------------------------------------------------- + // NOTIFICATIONS + // --------------------------------------------------------------------------- + + /// List GitHub notifications. + /// + /// [all] - If true, show read notifications too. + /// [since] - Only notifications after this time. + Future> getNotifications({ + bool all = false, + DateTime? since, + int perPage = 50, + }) async { + final query = { + 'per_page': '$perPage', + 'all': all ? 'true' : 'false', + if (since != null) 'since': since.toUtc().toIso8601String(), + }; + return await _getJsonList('/notifications', query: query); + } + + /// Mark a notification thread as read. + Future markNotificationRead(String threadId) async { + await _patchJson('/notifications/threads/$threadId', body: {}); + } + + /// Mark all notifications as read. + Future markAllNotificationsRead() async { + await _putJson('/notifications', body: {}); + } + + /// Batch mark notification threads as read. + /// + /// More efficient than marking individually when processing + /// multiple notifications at once. + Future markNotificationsReadBatch(List threadIds) async { + // GitHub doesn't support true batch marking, so we parallelize + final futures = threadIds.map((id) => markNotificationRead(id)); + await Future.wait(futures, eagerError: false); + debugPrint('[GitHubDeepService] Marked ${threadIds.length} notifications as read'); + } + + /// Get unread notification count. + Future getUnreadNotificationCount() async { + try { + final notifications = await getNotifications(all: false, perPage: 100); + return notifications.length; + } catch (_) { + return 0; + } + } + + // --------------------------------------------------------------------------- + // SEARCH + // --------------------------------------------------------------------------- + + /// Search repositories on GitHub. Future> searchRepositories( String query, { String? language, String? sort, int perPage = 30, + bool public = false, }) async { - var q = query; - if (language != null && language.isNotEmpty) { - q += ' language:$language'; - } - - final queryParams = { - 'q': q, - 'per_page': '$perPage', - if (sort != null && sort.isNotEmpty) 'sort': sort, - }; - - final data = await _getJson('/search/repositories', query: queryParams); - return (data?['items'] as List?) ?? []; - } - - /// Search issues across GitHub or within a repo. - Future> searchIssues( - String query, { - String? owner, - String? repo, - String? state, - int perPage = 30, - }) async { - var q = query; - if (owner != null && repo != null) { - q += ' repo:$owner/$repo'; - } - if (state != null) { - q += ' state:$state'; - } - - final data = await _getJson('/search/issues', query: { - 'q': q, - 'per_page': '$perPage', - }); - return (data?['items'] as List?) ?? []; - } - - // --------------------------------------------------------------------------- - // WIKI - // --------------------------------------------------------------------------- - - /// Get wiki pages for a repository. - /// Note: GitHub doesn't expose wiki content via the REST API. - /// This returns the wiki pages by scraping or alternative methods. - Future>> getWikiPages(String owner, String repo) async { - // GitHub wikis are Git repos themselves. List via the Pages API. - // Return a placeholder structure for the UI. - return [ - {'title': 'Home', 'url': 'https://github.com/$owner/$repo/wiki/Home'}, - {'title': 'Getting Started', 'url': 'https://github.com/$owner/$repo/wiki/Getting-Started'}, - {'title': 'API Reference', 'url': 'https://github.com/$owner/$repo/wiki/API-Reference'}, - ]; - } - - // --------------------------------------------------------------------------- - // GIT OPERATIONS (Client-Side Support) - // --------------------------------------------------------------------------- - - /// Get a tree of repository files at a given ref. - Future> getGitTree( - String owner, - String repo, { - String? treeSha, - bool recursive = true, - }) async { - final sha = treeSha ?? 'HEAD'; - final query = { - if (recursive) 'recursive': '1', - }; + var q = query; + if (language != null && language.isNotEmpty) { + q += ' language:$language'; + } + + final queryParams = { + 'q': q, + 'per_page': '$perPage', + if (sort != null && sort.isNotEmpty) 'sort': sort, + }; + final data = await _getJson( - '/repos/$owner/$repo/git/trees/$sha', - query: query.isNotEmpty ? query : null, + '/search/repositories', + query: queryParams, + allowAnonymous: public, ); - return (data?['tree'] as List?) ?? []; + return (data?['items'] as List?) ?? []; } - - // --------------------------------------------------------------------------- - // PRIVATE HELPERS - // --------------------------------------------------------------------------- - + + /// Search issues across GitHub or within a repo. + Future> searchIssues( + String query, { + String? owner, + String? repo, + String? state, + int perPage = 30, + }) async { + var q = query; + if (owner != null && repo != null) { + q += ' repo:$owner/$repo'; + } + if (state != null) { + q += ' state:$state'; + } + + final data = await _getJson('/search/issues', query: { + 'q': q, + 'per_page': '$perPage', + }); + return (data?['items'] as List?) ?? []; + } + + // --------------------------------------------------------------------------- + // WIKI + // --------------------------------------------------------------------------- + + /// Get wiki pages for a repository. + /// Note: GitHub doesn't expose wiki content via the REST API. + /// This returns the wiki pages by scraping or alternative methods. + Future>> getWikiPages(String owner, String repo) async { + // GitHub wikis are Git repos themselves. List via the Pages API. + // Return a placeholder structure for the UI. + return [ + {'title': 'Home', 'url': 'https://github.com/$owner/$repo/wiki/Home'}, + {'title': 'Getting Started', 'url': 'https://github.com/$owner/$repo/wiki/Getting-Started'}, + {'title': 'API Reference', 'url': 'https://github.com/$owner/$repo/wiki/API-Reference'}, + ]; + } + + // --------------------------------------------------------------------------- + // GIT OPERATIONS (Client-Side Support) + // --------------------------------------------------------------------------- + + /// Get a tree of repository files at a given ref. + Future> getGitTree( + String owner, + String repo, { + String? treeSha, + bool recursive = true, + }) async { + final sha = treeSha ?? 'HEAD'; + final query = { + if (recursive) 'recursive': '1', + }; + final data = await _getJson( + '/repos/$owner/$repo/git/trees/$sha', + query: query.isNotEmpty ? query : null, + ); + return (data?['tree'] as List?) ?? []; + } + + // --------------------------------------------------------------------------- + // PRIVATE HELPERS + // --------------------------------------------------------------------------- + Map _headers(String token) => { 'Authorization': 'Bearer $token', 'Accept': 'application/vnd.github+json', @@ -1321,406 +1518,415 @@ class GitHubDeepService { 'User-Agent': 'MobileAgent/1.0', }; - Uri _uri(String path, {Map? query}) { - final base = Uri.parse('$_baseUrl$path'); - if (query == null || query.isEmpty) return base; - return base.replace(queryParameters: query); - } - - Future _request( - String method, - String path, { + Map _publicHeaders() => { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': _apiVersion, + 'User-Agent': 'MobileAgent/1.0', + }; + + Uri _uri(String path, {Map? query}) { + final base = Uri.parse('$_baseUrl$path'); + if (query == null || query.isEmpty) return base; + return base.replace(queryParameters: query); + } + + Future _request( + String method, + String path, { Map? body, Map? query, Map? extraHeaders, String? token, + bool allowAnonymous = false, }) async { - final t = token ?? activeSession?.token; - if (t == null) { + final t = allowAnonymous ? token : token ?? activeSession?.token; + if (t == null && !allowAnonymous) { throw const GitHubDeepException(message: 'Not authenticated'); } - final headers = _headers(t); + final headers = t == null ? _publicHeaders() : _headers(t); if (extraHeaders != null) headers.addAll(extraHeaders); - - final uri = _uri(path, query: query); - late http.Response response; - - switch (method.toUpperCase()) { - case 'GET': - response = await _httpClient.get(uri, headers: headers); - case 'POST': - response = await _httpClient.post( - uri, - headers: {...headers, 'Content-Type': 'application/json'}, - body: body != null ? jsonEncode(body) : null, - ); - case 'PUT': - response = await _httpClient.put( - uri, - headers: {...headers, 'Content-Type': 'application/json'}, - body: body != null ? jsonEncode(body) : null, - ); - case 'PATCH': - response = await _httpClient.patch( - uri, - headers: {...headers, 'Content-Type': 'application/json'}, - body: body != null ? jsonEncode(body) : null, - ); - case 'DELETE': - response = await _httpClient.delete( - uri, - headers: {...headers, 'Content-Type': 'application/json'}, - body: body != null ? jsonEncode(body) : null, - ); - default: - throw GitHubDeepException(message: 'Unsupported HTTP method: $method'); - } - - if (response.statusCode >= 200 && response.statusCode < 300) { - return response; - } - - // Handle errors. - String errorMessage = 'Request failed'; - try { - final errorBody = jsonDecode(response.body) as Map; - errorMessage = errorBody['message'] as String? ?? response.body; - } catch (_) { - errorMessage = response.body.isNotEmpty ? response.body : 'HTTP ${response.statusCode}'; - } - - throw GitHubDeepException( - message: errorMessage, - endpoint: path, - statusCode: response.statusCode, - ); - } - - Future?> _getJson( - String path, { + + final uri = _uri(path, query: query); + late http.Response response; + + switch (method.toUpperCase()) { + case 'GET': + response = await _httpClient.get(uri, headers: headers); + case 'POST': + response = await _httpClient.post( + uri, + headers: {...headers, 'Content-Type': 'application/json'}, + body: body != null ? jsonEncode(body) : null, + ); + case 'PUT': + response = await _httpClient.put( + uri, + headers: {...headers, 'Content-Type': 'application/json'}, + body: body != null ? jsonEncode(body) : null, + ); + case 'PATCH': + response = await _httpClient.patch( + uri, + headers: {...headers, 'Content-Type': 'application/json'}, + body: body != null ? jsonEncode(body) : null, + ); + case 'DELETE': + response = await _httpClient.delete( + uri, + headers: {...headers, 'Content-Type': 'application/json'}, + body: body != null ? jsonEncode(body) : null, + ); + default: + throw GitHubDeepException(message: 'Unsupported HTTP method: $method'); + } + + if (response.statusCode >= 200 && response.statusCode < 300) { + return response; + } + + // Handle errors. + String errorMessage = 'Request failed'; + try { + final errorBody = jsonDecode(response.body) as Map; + errorMessage = errorBody['message'] as String? ?? response.body; + } catch (_) { + errorMessage = response.body.isNotEmpty ? response.body : 'HTTP ${response.statusCode}'; + } + + throw GitHubDeepException( + message: errorMessage, + endpoint: path, + statusCode: response.statusCode, + ); + } + + Future?> _getJson( + String path, { Map? query, Map? extraHeaders, String? token, + bool allowAnonymous = false, }) async { final response = await _request('GET', path, - query: query, extraHeaders: extraHeaders, token: token); + query: query, extraHeaders: extraHeaders, token: token, allowAnonymous: allowAnonymous); if (response.body.isEmpty) return null; return jsonDecode(response.body) as Map; } - - Future> _getJsonList( + + Future> _getJsonList( String path, { Map? query, Map? extraHeaders, + bool allowAnonymous = false, }) async { final response = await _request('GET', path, - query: query, extraHeaders: extraHeaders); + query: query, extraHeaders: extraHeaders, allowAnonymous: allowAnonymous); if (response.body.isEmpty) return []; return jsonDecode(response.body) as List; } - - Future> _postJson( - String path, { - Map? body, - }) async { - final response = await _request('POST', path, body: body); - if (response.body.isEmpty) return {}; - return jsonDecode(response.body) as Map; - } - - Future?> _putJson( - String path, { - Map? body, - }) async { - final response = await _request('PUT', path, body: body); - if (response.body.isEmpty) return null; - return jsonDecode(response.body) as Map?; - } - - Future> _patchJson( - String path, { - Map? body, - }) async { - final response = await _request('PATCH', path, body: body); - if (response.body.isEmpty) return {}; - return jsonDecode(response.body) as Map; - } - - Future _deleteJson( - String path, { - Map? body, - }) async { - await _request('DELETE', path, body: body); - } - - // --------------------------------------------------------------------------- - // CACHE HELPERS - // --------------------------------------------------------------------------- - - /// Perform a cached GET request. - /// - /// Checks the cache first, and only makes an HTTP request if - /// the cache entry is missing or expired. - Future?> _getJsonCached( - String path, { - Map? query, - Map? extraHeaders, - String? token, - }) async { - // Build cache key from path + sorted query params - final cacheKey = _buildCacheKey(path, query); - - // Try cache first - if (_cacheInitialized) { - final cached = _cache.get(cacheKey); - if (cached != null && cached is Map) { - return cached; - } - } - - // Cache miss - fetch from API - final response = await _getJson( - path, - query: query, - extraHeaders: extraHeaders, - token: token, - ); - - // Store in cache - if (_cacheInitialized && response != null) { - _cache.set(cacheKey, response); - } - - return response; - } - - /// Perform a cached GET request that returns a list. - Future> _getJsonListCached( - String path, { - Map? query, - Map? extraHeaders, - }) async { - final cacheKey = _buildCacheKey(path, query); - - if (_cacheInitialized) { - final cached = _cache.get(cacheKey); - if (cached != null && cached is List) { - return cached; - } - } - - final response = await _getJsonList( - path, - query: query, - extraHeaders: extraHeaders, - ); - - if (_cacheInitialized) { - _cache.set(cacheKey, response); - } - - return response; - } - - /// Build a cache key from the endpoint path and query parameters. - String _buildCacheKey(String path, Map? query) { - if (query == null || query.isEmpty) return path; - final sorted = query.entries.toList() - ..sort((a, b) => a.key.compareTo(b.key)); - final queryString = sorted.map((e) => '${e.key}=${e.value}').join('&'); - return '$path?$queryString'; - } - - // --------------------------------------------------------------------------- - // SESSION PERSISTENCE - // --------------------------------------------------------------------------- - - Future _persistSessions() async { - final jsonList = _sessions.map((s) => s.toJson()).toList(); - await _secureStorage.write( - key: _storageKey, - value: jsonEncode(jsonList), - ); - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_activeSessionKey, _activeSessionIndex); - } - - // --------------------------------------------------------------------------- - // REPOSITORY LANGUAGES - // --------------------------------------------------------------------------- - - /// Get the language breakdown for a repository. - /// - /// Returns a map of language name to byte count. - /// Example: `{'Dart': 45000, 'Swift': 12000, 'Kotlin': 8000}` - Future> getRepoLanguages(String owner, String repo) async { - final cacheKey = '/repos/$owner/$repo/languages'; - - // Try cache - if (_cacheInitialized) { - final cached = _cache.get(cacheKey); - if (cached != null && cached is Map) { - return cached.map((k, v) => MapEntry(k, v as int)); - } - } - - final data = await _getJson('/repos/$owner/$repo/languages') ?? {}; - final result = data.map((k, v) => MapEntry(k as String, v as int)); - - if (_cacheInitialized) { - _cache.set(cacheKey, data); - } - - return result; - } - - // --------------------------------------------------------------------------- - // README CONTENT - // --------------------------------------------------------------------------- - - /// Get the decoded README content for a repository. - /// - /// Returns the raw markdown text of the README file. - /// Returns an empty string if no README exists. - Future getReadmeContent(String owner, String repo) async { - try { - final readme = await getReadme(owner, repo); - if (readme == null) return ''; - - final content = readme['content'] as String?; - if (content == null) { - // README might be too large - content is truncated - final downloadUrl = readme['download_url'] as String?; - if (downloadUrl != null) { - final response = await _httpClient.get(Uri.parse(downloadUrl)); - if (response.statusCode == 200) return response.body; - } - return ''; - } - - // Decode base64 content (remove newlines that GitHub inserts) - final cleanContent = content.replaceAll('\n', ''); - return utf8.decode(base64Decode(cleanContent)); - } catch (e) { - debugPrint('[GitHubDeepService] Failed to get README: $e'); - return ''; - } - } - - // --------------------------------------------------------------------------- - // PERMISSIONS - // --------------------------------------------------------------------------- - - /// Check if the authenticated user has push access to a repository. - /// - /// Returns true if the user can push to the default branch. - Future canPush(String owner, String repo) async { - try { - final repoData = await _getJsonCached('/repos/$owner/$repo'); - if (repoData == null) return false; - - final permissions = repoData['permissions'] as Map?; - if (permissions == null) return false; - - return permissions['push'] as bool? ?? false; - } catch (e) { - debugPrint('[GitHubDeepService] canPush check failed: $e'); - return false; - } - } - - /// Check if the authenticated user has admin access to a repository. - Future isAdmin(String owner, String repo) async { - try { - final repoData = await _getJsonCached('/repos/$owner/$repo'); - if (repoData == null) return false; - - final permissions = repoData['permissions'] as Map?; - if (permissions == null) return false; - - return permissions['admin'] as bool? ?? false; - } catch (e) { - debugPrint('[GitHubDeepService] isAdmin check failed: $e'); - return false; - } - } - - // --------------------------------------------------------------------------- - // RATE LIMIT - // --------------------------------------------------------------------------- - - /// Get the current GitHub API rate limit status. - /// - /// This endpoint is not subject to rate limiting itself, - /// so it can always be called. - Future getRateLimit() async { + + Future> _postJson( + String path, { + Map? body, + }) async { + final response = await _request('POST', path, body: body); + if (response.body.isEmpty) return {}; + return jsonDecode(response.body) as Map; + } + + Future?> _putJson( + String path, { + Map? body, + }) async { + final response = await _request('PUT', path, body: body); + if (response.body.isEmpty) return null; + return jsonDecode(response.body) as Map?; + } + + Future> _patchJson( + String path, { + Map? body, + }) async { + final response = await _request('PATCH', path, body: body); + if (response.body.isEmpty) return {}; + return jsonDecode(response.body) as Map; + } + + Future _deleteJson( + String path, { + Map? body, + }) async { + await _request('DELETE', path, body: body); + } + + // --------------------------------------------------------------------------- + // CACHE HELPERS + // --------------------------------------------------------------------------- + + /// Perform a cached GET request. + /// + /// Checks the cache first, and only makes an HTTP request if + /// the cache entry is missing or expired. + Future?> _getJsonCached( + String path, { + Map? query, + Map? extraHeaders, + String? token, + }) async { + // Build cache key from path + sorted query params + final cacheKey = _buildCacheKey(path, query); + + // Try cache first + if (_cacheInitialized) { + final cached = _cache.get(cacheKey); + if (cached != null && cached is Map) { + return cached; + } + } + + // Cache miss - fetch from API + final response = await _getJson( + path, + query: query, + extraHeaders: extraHeaders, + token: token, + ); + + // Store in cache + if (_cacheInitialized && response != null) { + _cache.set(cacheKey, response); + } + + return response; + } + + /// Perform a cached GET request that returns a list. + Future> _getJsonListCached( + String path, { + Map? query, + Map? extraHeaders, + }) async { + final cacheKey = _buildCacheKey(path, query); + + if (_cacheInitialized) { + final cached = _cache.get(cacheKey); + if (cached != null && cached is List) { + return cached; + } + } + + final response = await _getJsonList( + path, + query: query, + extraHeaders: extraHeaders, + ); + + if (_cacheInitialized) { + _cache.set(cacheKey, response); + } + + return response; + } + + /// Build a cache key from the endpoint path and query parameters. + String _buildCacheKey(String path, Map? query) { + if (query == null || query.isEmpty) return path; + final sorted = query.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + final queryString = sorted.map((e) => '${e.key}=${e.value}').join('&'); + return '$path?$queryString'; + } + + // --------------------------------------------------------------------------- + // SESSION PERSISTENCE + // --------------------------------------------------------------------------- + + Future _persistSessions() async { + final jsonList = _sessions.map((s) => s.toJson()).toList(); + await _secureStorage.write( + key: _storageKey, + value: jsonEncode(jsonList), + ); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_activeSessionKey, _activeSessionIndex); + } + + // --------------------------------------------------------------------------- + // REPOSITORY LANGUAGES + // --------------------------------------------------------------------------- + + /// Get the language breakdown for a repository. + /// + /// Returns a map of language name to byte count. + /// Example: `{'Dart': 45000, 'Swift': 12000, 'Kotlin': 8000}` + Future> getRepoLanguages(String owner, String repo) async { + final cacheKey = '/repos/$owner/$repo/languages'; + + // Try cache + if (_cacheInitialized) { + final cached = _cache.get(cacheKey); + if (cached != null && cached is Map) { + return cached.map((k, v) => MapEntry(k, v as int)); + } + } + + final data = await _getJson('/repos/$owner/$repo/languages') ?? {}; + final result = data.map((k, v) => MapEntry(k as String, v as int)); + + if (_cacheInitialized) { + _cache.set(cacheKey, data); + } + + return result; + } + + // --------------------------------------------------------------------------- + // README CONTENT + // --------------------------------------------------------------------------- + + /// Get the decoded README content for a repository. + /// + /// Returns the raw markdown text of the README file. + /// Returns an empty string if no README exists. + Future getReadmeContent(String owner, String repo, {bool public = false}) async { try { - final data = await _getJson('/rate_limit'); - final core = (data?['resources'] as Map?) ?? {}; - final coreLimit = core['core'] as Map? ?? {}; - - return RateLimit( - limit: coreLimit['limit'] as int? ?? 5000, - remaining: coreLimit['remaining'] as int? ?? 0, - used: coreLimit['used'] as int? ?? 0, - resetAt: DateTime.fromMillisecondsSinceEpoch( - ((coreLimit['reset'] as int?) ?? 0) * 1000, - ), - ); - } catch (e) { - debugPrint('[GitHubDeepService] Failed to get rate limit: $e'); - // Return a safe default - return RateLimit( - limit: 5000, - remaining: 0, - used: 5000, - resetAt: DateTime.now().add(const Duration(hours: 1)), - ); - } - } - - // --------------------------------------------------------------------------- - // CACHE MANAGEMENT - // --------------------------------------------------------------------------- - - /// Invalidate all cache for a specific repository. - /// - /// Call this after making mutations to a repository to ensure - /// fresh data on next read. - void invalidateRepoCache(String owner, String repo) { - if (!_cacheInitialized) return; - _cache.invalidateRepo(owner, repo); - debugPrint('[GitHubDeepService] Invalidated cache for $owner/$repo'); - } - - /// Invalidate cache by a pattern. - void invalidateCachePattern(RegExp pattern) { - if (!_cacheInitialized) return; - _cache.invalidatePattern(pattern); - } - - /// Clear all API cache entries. - void clearCache() { - if (!_cacheInitialized) return; - _cache.clear(); - } - - /// Get cache statistics for debugging. - CacheStats getCacheStats() { - if (!_cacheInitialized) { - return CacheStats(lastReset: DateTime.now()); - } - return _cache.getStats(); - } - - // --------------------------------------------------------------------------- - // LIFECYCLE - // --------------------------------------------------------------------------- - - /// Dispose resources. - void dispose() { - _httpClient.close(); - _cache.dispose(); - } -} + final readme = await getReadme(owner, repo, public: public); + if (readme == null) return ''; + + final content = readme['content'] as String?; + if (content == null) { + // README might be too large - content is truncated + final downloadUrl = readme['download_url'] as String?; + if (downloadUrl != null) { + final response = await _httpClient.get(Uri.parse(downloadUrl)); + if (response.statusCode == 200) return response.body; + } + return ''; + } + + // Decode base64 content (remove newlines that GitHub inserts) + final cleanContent = content.replaceAll('\n', ''); + return utf8.decode(base64Decode(cleanContent)); + } catch (e) { + debugPrint('[GitHubDeepService] Failed to get README: $e'); + return ''; + } + } + + // --------------------------------------------------------------------------- + // PERMISSIONS + // --------------------------------------------------------------------------- + + /// Check if the authenticated user has push access to a repository. + /// + /// Returns true if the user can push to the default branch. + Future canPush(String owner, String repo) async { + try { + final repoData = await _getJsonCached('/repos/$owner/$repo'); + if (repoData == null) return false; + + final permissions = repoData['permissions'] as Map?; + if (permissions == null) return false; + + return permissions['push'] as bool? ?? false; + } catch (e) { + debugPrint('[GitHubDeepService] canPush check failed: $e'); + return false; + } + } + + /// Check if the authenticated user has admin access to a repository. + Future isAdmin(String owner, String repo) async { + try { + final repoData = await _getJsonCached('/repos/$owner/$repo'); + if (repoData == null) return false; + + final permissions = repoData['permissions'] as Map?; + if (permissions == null) return false; + + return permissions['admin'] as bool? ?? false; + } catch (e) { + debugPrint('[GitHubDeepService] isAdmin check failed: $e'); + return false; + } + } + + // --------------------------------------------------------------------------- + // RATE LIMIT + // --------------------------------------------------------------------------- + + /// Get the current GitHub API rate limit status. + /// + /// This endpoint is not subject to rate limiting itself, + /// so it can always be called. + Future getRateLimit() async { + try { + final data = await _getJson('/rate_limit'); + final core = (data?['resources'] as Map?) ?? {}; + final coreLimit = core['core'] as Map? ?? {}; + + return RateLimit( + limit: coreLimit['limit'] as int? ?? 5000, + remaining: coreLimit['remaining'] as int? ?? 0, + used: coreLimit['used'] as int? ?? 0, + resetAt: DateTime.fromMillisecondsSinceEpoch( + ((coreLimit['reset'] as int?) ?? 0) * 1000, + ), + ); + } catch (e) { + debugPrint('[GitHubDeepService] Failed to get rate limit: $e'); + // Return a safe default + return RateLimit( + limit: 5000, + remaining: 0, + used: 5000, + resetAt: DateTime.now().add(const Duration(hours: 1)), + ); + } + } + + // --------------------------------------------------------------------------- + // CACHE MANAGEMENT + // --------------------------------------------------------------------------- + + /// Invalidate all cache for a specific repository. + /// + /// Call this after making mutations to a repository to ensure + /// fresh data on next read. + void invalidateRepoCache(String owner, String repo) { + if (!_cacheInitialized) return; + _cache.invalidateRepo(owner, repo); + debugPrint('[GitHubDeepService] Invalidated cache for $owner/$repo'); + } + + /// Invalidate cache by a pattern. + void invalidateCachePattern(RegExp pattern) { + if (!_cacheInitialized) return; + _cache.invalidatePattern(pattern); + } + + /// Clear all API cache entries. + void clearCache() { + if (!_cacheInitialized) return; + _cache.clear(); + } + + /// Get cache statistics for debugging. + CacheStats getCacheStats() { + if (!_cacheInitialized) { + return CacheStats(lastReset: DateTime.now()); + } + return _cache.getStats(); + } + + // --------------------------------------------------------------------------- + // LIFECYCLE + // --------------------------------------------------------------------------- + + /// Dispose resources. + void dispose() { + _httpClient.close(); + _cache.dispose(); + } +} diff --git a/mobile_agent/lib/services/github_oauth_flow.dart b/mobile_agent/lib/services/github_oauth_flow.dart new file mode 100644 index 0000000..d0f813c --- /dev/null +++ b/mobile_agent/lib/services/github_oauth_flow.dart @@ -0,0 +1,182 @@ +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'github_deep_service.dart'; + +class GitHubOAuthLaunchResult { + const GitHubOAuthLaunchResult({ + required this.startedOAuth, + required this.message, + }); + + final bool startedOAuth; + final String message; +} + +class GitHubOAuthCallbackResult { + const GitHubOAuthCallbackResult({ + required this.handled, + required this.success, + this.message, + }); + + final bool handled; + final bool success; + final String? message; +} + +class GitHubOAuthFlow { + static const _systemTools = MethodChannel('mobilecode/system_tools'); + static const _stateKey = 'github_oauth_pending_state'; + static const clientId = String.fromEnvironment('MOBILECODE_GITHUB_OAUTH_CLIENT_ID'); + static const clientSecret = String.fromEnvironment('MOBILECODE_GITHUB_OAUTH_CLIENT_SECRET'); + static const redirectUri = String.fromEnvironment( + 'MOBILECODE_GITHUB_OAUTH_REDIRECT_URI', + defaultValue: 'mobilecode://github/oauth', + ); + static const scopes = 'repo user notifications workflow'; + + static bool get canExchange => clientId.isNotEmpty && clientSecret.isNotEmpty; + + static String get authModeLabel => canExchange ? 'OAuth Web Login' : 'Browser token setup'; + + static String get authModeDescription => canExchange + ? 'Sign in through GitHub, then MobileCode will exchange the callback code for a stored token.' + : 'Open GitHub in the browser, create a token, then return and paste it above.'; + + static String get actionLabel => canExchange ? 'Login with GitHub OAuth' : 'Open GitHub token page'; + + static Future launchAuthorization() async { + const tokenSetupUrl = + 'https://github.com/settings/tokens/new?description=MobileCode&scopes=repo,user,notifications,workflow'; + if (!canExchange) { + await launchUrl(Uri.parse(tokenSetupUrl), mode: LaunchMode.externalApplication); + final missing = clientId.isEmpty ? 'client id' : 'client secret'; + return GitHubOAuthLaunchResult( + startedOAuth: false, + message: 'This build has no GitHub OAuth $missing configured. Use a token, or build with OAuth client settings.', + ); + } + + final state = _newState(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_stateKey, state); + final url = Uri.https('github.com', '/login/oauth/authorize', { + 'client_id': clientId, + 'redirect_uri': redirectUri, + 'scope': scopes, + 'state': state, + 'prompt': 'select_account', + }); + await launchUrl(url, mode: LaunchMode.externalApplication); + return const GitHubOAuthLaunchResult( + startedOAuth: true, + message: 'GitHub OAuth opened. Return to MobileCode after authorization.', + ); + } + + static Future consumePendingCallbackUri() async { + String? rawLink; + try { + rawLink = await _systemTools.invokeMethod('consumeInitialDeepLink'); + } catch (_) { + return null; + } + if (rawLink == null || rawLink.trim().isEmpty) return null; + final uri = Uri.tryParse(rawLink); + if (uri == null || uri.scheme != 'mobilecode' || uri.host != 'github') { + return null; + } + return uri; + } + + static Future completeCallbackUri( + Uri uri, + GitHubDeepService github, + ) async { + final error = uri.queryParameters['error']; + if (error != null && error.isNotEmpty) { + final description = uri.queryParameters['error_description'] ?? error; + await _clearState(); + return GitHubOAuthCallbackResult( + handled: true, + success: false, + message: 'GitHub OAuth failed: $description', + ); + } + + final code = uri.queryParameters['code']; + if (code == null || code.isEmpty) { + return const GitHubOAuthCallbackResult(handled: false, success: false); + } + + final prefs = await SharedPreferences.getInstance(); + final expectedState = prefs.getString(_stateKey); + final actualState = uri.queryParameters['state']; + if (expectedState != null && expectedState.isNotEmpty && actualState != expectedState) { + await _clearState(); + return const GitHubOAuthCallbackResult( + handled: true, + success: false, + message: 'GitHub OAuth state mismatch. Please try login again.', + ); + } + + if (!canExchange) { + await _clearState(); + final missing = clientId.isEmpty ? 'client id' : 'client secret'; + return GitHubOAuthCallbackResult( + handled: true, + success: false, + message: 'GitHub OAuth callback arrived, but this APK has no OAuth $missing configured. Use token login or rebuild with OAuth settings.', + ); + } + + try { + final ok = await github.authenticateWithOAuthCode( + code: code, + clientId: clientId, + clientSecret: clientSecret, + redirectUri: redirectUri, + ); + await _clearState(); + return GitHubOAuthCallbackResult( + handled: true, + success: ok, + message: ok ? 'GitHub OAuth login connected.' : 'GitHub OAuth token was received but /user could not be read.', + ); + } catch (error) { + await _clearState(); + return GitHubOAuthCallbackResult( + handled: true, + success: false, + message: 'GitHub OAuth exchange failed: $error', + ); + } + } + + static Future consumeAndComplete( + GitHubDeepService github, + ) async { + final uri = await consumePendingCallbackUri(); + if (uri == null) { + return const GitHubOAuthCallbackResult(handled: false, success: false); + } + return completeCallbackUri(uri, github); + } + + static String _newState() { + final random = Random.secure(); + final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36); + final suffix = List.generate(12, (_) => random.nextInt(36).toRadixString(36)).join(); + return '$now-$suffix'; + } + + static Future _clearState() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_stateKey); + } +} diff --git a/mobile_agent/lib/services/github_pages_service.dart b/mobile_agent/lib/services/github_pages_service.dart index 8d1329b..8d6337a 100644 --- a/mobile_agent/lib/services/github_pages_service.dart +++ b/mobile_agent/lib/services/github_pages_service.dart @@ -1,695 +1,818 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as path; - -import 'github_deep_service.dart'; -import 'terminal_service.dart'; - -// ============================================================================= -// GITHUB PAGES SERVICE -// ============================================================================= - -/// Deploy static sites directly from MobileCode to GitHub Pages. -/// -/// Supports deploying Flutter Web builds, static HTML sites, and Jekyll sites. -/// Orchestrates the build process, branch management, and GitHub Pages -/// configuration through the GitHub API. -/// -/// Example: -/// ```dart -/// final pages = GitHubPagesService(githubService); -/// final result = await pages.deploy( -/// localProjectPath: '/projects/my-app', -/// owner: 'username', -/// repo: 'my-app', -/// buildType: BuildType.flutterWeb, -/// ); -/// if (result.success) print('Deployed to: ${result.url}'); -/// ``` -class GitHubPagesService { - static const String _baseUrl = 'https://api.github.com'; - static const String _apiVersion = '2022-11-28'; - - final GitHubDeepService _github; - final TerminalService _terminal; - final http.Client _httpClient; - - GitHubPagesService( - this._github, { - TerminalService? terminalService, - http.Client? httpClient, - }) : _terminal = terminalService ?? TerminalService(), - _httpClient = httpClient ?? http.Client(); - - // ── Auth / Headers ────────────────────────────────────────────────── - - Map get _headers { - final token = _github.activeSession?.token; - if (token == null) { - throw const GitHubDeepException(message: 'Not authenticated'); - } - return { - 'Authorization': 'Bearer $token', - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': _apiVersion, - 'Content-Type': 'application/json', - 'User-Agent': 'MobileAgent/1.0', - }; - } - - Uri _uri(String path, {Map? query}) { - final base = Uri.parse('$_baseUrl$path'); - if (query == null || query.isEmpty) return base; - return base.replace(queryParameters: query); - } - - // ── Deployment ────────────────────────────────────────────────────── - - /// Deploy a local project to GitHub Pages. - /// - /// Steps performed: - /// 1. Build the project based on [buildType] - /// 2. Get or create the target branch (default: 'gh-pages') - /// 3. Commit build output to the branch - /// 4. Enable GitHub Pages if not already enabled - /// 5. Return the deployment URL - /// - /// [customDomain] - Optional custom domain (creates CNAME file). - Future deploy({ - required String localProjectPath, - required String owner, - required String repo, - required BuildType buildType, - String? branch, - String? customDomain, - }) async { - final targetBranch = branch ?? 'gh-pages'; - final steps = []; - - try { - // Step 1: Build. - steps.add('Building project (${buildType.name})...'); - final String buildOutputPath; - switch (buildType) { - case BuildType.flutterWeb: - buildOutputPath = await _buildFlutterWeb(localProjectPath); - break; - case BuildType.staticHtml: - buildOutputPath = await _buildStatic(localProjectPath); - break; - case BuildType.jekyll: - buildOutputPath = await _buildJekyll(localProjectPath); - break; - } - steps.add('Build complete: $buildOutputPath'); - - // Step 2: Prepare build output (CNAME, etc.). - final preparedPath = await _prepareBuildOutput( - buildOutputPath, - customDomain: customDomain, - ); - if (customDomain != null) { - steps.add('Custom domain configured: $customDomain'); - } - - // Step 3: Get the default branch SHA for orphan branch creation. - steps.add('Creating $targetBranch branch...'); - final repoDetails = await _github.getRepoDetails(owner, repo); - final defaultBranch = repoDetails['default_branch'] as String? ?? 'main'; - - // Step 4: Create/update gh-pages branch via GitHub API (tree + commit + ref). - await _commitBuildOutput( - owner: owner, - repo: repo, - buildPath: preparedPath, - branch: targetBranch, - defaultBranch: defaultBranch, - steps: steps, - ); - steps.add('Build output committed to $targetBranch'); - - // Step 5: Enable GitHub Pages. - steps.add('Enabling GitHub Pages...'); - await enablePages(owner, repo, branch: targetBranch); - steps.add('GitHub Pages enabled'); - - // Step 6: Set custom domain via API if provided. - if (customDomain != null && customDomain.isNotEmpty) { - await setCustomDomain(owner, repo, customDomain); - steps.add('Custom domain set: $customDomain'); - } - - // Determine the deployment URL. - final url = _buildPagesUrl(owner, repo, customDomain); - - return DeploymentResult( - success: true, - url: url, - customDomain: customDomain, - deployedAt: DateTime.now(), - steps: steps, - ); - } on GitHubDeepException catch (e) { - steps.add('Error: ${e.message}'); - return DeploymentResult( - success: false, - url: null, - customDomain: customDomain, - deployedAt: DateTime.now(), - error: e.message, - steps: steps, - ); - } catch (e) { - steps.add('Unexpected error: $e'); - return DeploymentResult( - success: false, - url: null, - customDomain: customDomain, - deployedAt: DateTime.now(), - error: e.toString(), - steps: steps, - ); - } - } - - /// Check the current GitHub Pages deployment status. - Future getDeploymentStatus(String owner, String repo) async { - try { - final response = await _httpClient.get( - _uri('/repos/$owner/$repo/pages'), - headers: _headers, - ); - if (response.statusCode == 404) { - return const DeploymentStatus(isEnabled: false); - } - if (response.statusCode != 200) { - return DeploymentStatus( - isEnabled: false, - error: 'HTTP ${response.statusCode}', - ); - } - final data = jsonDecode(response.body) as Map; - final source = data['source'] as Map?; - return DeploymentStatus( - isEnabled: data['status'] == 'built', - url: data['html_url'] as String?, - status: data['status'] as String?, // building, built, errored - lastDeployedAt: _parseDateTime(data['updated_at']), - ); - } catch (e) { - return DeploymentStatus(isEnabled: false, error: e.toString()); - } - } - - /// Enable GitHub Pages for a repository. - Future enablePages( - String owner, - String repo, { - String branch = 'gh-pages', - String path = '/', - }) async { - final response = await _httpClient.post( - _uri('/repos/$owner/$repo/pages'), - headers: _headers, - body: jsonEncode({ - 'source': { - 'branch': branch, - 'path': path, - }, - }), - ); - if (response.statusCode == 201 || response.statusCode == 204 || response.statusCode == 409) { - // 409 = Pages already enabled. - return; - } - _handleError(response, '/repos/$owner/$repo/pages'); - } - - /// Disable GitHub Pages for a repository. - Future disablePages(String owner, String repo) async { - final response = await _httpClient.delete( - _uri('/repos/$owner/$repo/pages'), - headers: _headers, - ); - if (response.statusCode >= 200 && response.statusCode < 300) return; - _handleError(response, '/repos/$owner/$repo/pages'); - } - - /// Set a custom domain for GitHub Pages. - Future setCustomDomain(String owner, String repo, String domain) async { - final response = await _httpClient.put( - _uri('/repos/$owner/$repo/pages'), - headers: _headers, - body: jsonEncode({ - 'cname': domain, - 'source': { - 'branch': 'gh-pages', - 'path': '/', - }, - }), - ); - if (response.statusCode >= 200 && response.statusCode < 300) return; - _handleError(response, '/repos/$owner/$repo/pages'); - } - - /// Get deployment history for a repository. - Future> getDeployments(String owner, String repo) async { - final data = await _getJsonList('/repos/$owner/$repo/deployments'); - return data - .map((item) => Deployment.fromJson(item as Map)) - .toList(); - } - - /// Get the latest deployment for a repository. - Future getLatestDeployment(String owner, String repo) async { - final deployments = await getDeployments(owner, repo); - if (deployments.isEmpty) return null; - return deployments.first; - } - - // ── Build Automation ──────────────────────────────────────────────── - - /// Build a Flutter Web project. Returns the path to the build/web directory. - Future _buildFlutterWeb(String projectPath) async { - final buildDir = path.join(projectPath, 'build', 'web'); - - // Clean previous build. - final cleanResult = await _terminal.flutterClean(projectPath); - if (!cleanResult.success) { - debugPrint('[PagesService] flutter clean warning: ${cleanResult.stderr}'); - } - - // Build for web. - final result = await _terminal.execute( - 'flutter build web --release', - workingDirectory: projectPath, - timeoutSeconds: 300, - ); - if (!result.success) { - throw GitHubDeepException( - message: 'Flutter web build failed: ${result.stderr}', - ); - } - - if (!Directory(buildDir).existsSync()) { - throw const GitHubDeepException(message: 'Build output directory not found'); - } - return buildDir; - } - - /// Build a static HTML project. Returns the project path (no build needed). - Future _buildStatic(String projectPath) async { - // Static HTML projects don't need a build step. - // Return the project root; we'll deploy all HTML/CSS/JS files. - return projectPath; - } - - /// Build a Jekyll site. Returns the path to the _site directory. - Future _buildJekyll(String projectPath) async { - final result = await _terminal.execute( - 'bundle exec jekyll build', - workingDirectory: projectPath, - timeoutSeconds: 180, - ); - if (!result.success) { - throw GitHubDeepException( - message: 'Jekyll build failed: ${result.stderr}', - ); - } - final siteDir = path.join(projectPath, '_site'); - if (!Directory(siteDir).existsSync()) { - throw const GitHubDeepException(message: 'Jekyll _site directory not found'); - } - return siteDir; - } - - /// Prepare build output for deployment. - /// - /// Creates a CNAME file if [customDomain] is provided. - /// Returns the path to the prepared build directory. - Future _prepareBuildOutput( - String buildPath, { - String? customDomain, - }) async { - if (customDomain != null && customDomain.isNotEmpty) { - final cnameFile = File(path.join(buildPath, 'CNAME')); - await cnameFile.writeAsString(customDomain); - } - return buildPath; - } - - // ── GitHub API: Commit Build Output ───────────────────────────────── - - /// Commit all files from [buildPath] to the [branch] branch using the - /// GitHub Git Data API (trees + commits + refs). - Future _commitBuildOutput({ - required String owner, - required String repo, - required String buildPath, - required String branch, - required String defaultBranch, - required List steps, - }) async { - // 1. Get the current commit SHA of the default branch. - final defaultRef = await _getJson('/repos/$owner/$repo/git/refs/heads/$defaultBranch'); - final baseCommitSha = defaultRef?['object']?['sha'] as String?; - if (baseCommitSha == null) { - throw const GitHubDeepException(message: 'Could not get default branch commit'); - } - - // 2. Collect all files from the build directory. - final buildDir = Directory(buildPath); - if (!buildDir.existsSync()) { - throw GitHubDeepException(message: 'Build directory not found: $buildPath'); - } - - final fileEntries = <_FileEntry>[]; - await for (final entity in buildDir.list(recursive: true, followLinks: false)) { - if (entity is File) { - final relativePath = path.relative(entity.path, from: buildPath); - // Skip hidden files and directories. - if (relativePath.startsWith('.')) continue; - final content = await entity.readAsBytes(); - fileEntries.add(_FileEntry(path: relativePath, content: content)); - } - } - - steps.add('Found ${fileEntries.length} files to deploy'); - - // 3. Create blobs for each file. - final treeItems = >[]; - for (var i = 0; i < fileEntries.length; i++) { - final entry = fileEntries[i]; - final isBinary = _isBinaryContent(entry.content); - - if (isBinary) { - // Create a blob for binary content. - final blobData = await _postJson( - '/repos/$owner/$repo/git/blobs', - body: { - 'content': base64Encode(entry.content), - 'encoding': 'base64', - }, - ); - final blobSha = blobData['sha'] as String; - treeItems.add({ - 'path': entry.path, - 'mode': '100644', - 'type': 'blob', - 'sha': blobSha, - }); - } else { - // For text files, inline the content in the tree. - treeItems.add({ - 'path': entry.path, - 'mode': '100644', - 'type': 'blob', - 'content': utf8.decode(entry.content, allowMalformed: true), - }); - } - - if ((i + 1) % 50 == 0) { - steps.add('Processed ${i + 1}/${fileEntries.length} files...'); - } - } - - // 4. Create a tree. - final treeData = await _postJson( - '/repos/$owner/$repo/git/trees', - body: {'tree': treeItems}, - ); - final treeSha = treeData['sha'] as String; - - // 5. Create a commit. - final commitData = await _postJson( - '/repos/$owner/$repo/git/commits', - body: { - 'message': 'Deploy to GitHub Pages from MobileCode', - 'tree': treeSha, - if (baseCommitSha != null) 'parents': [baseCommitSha], - }, - ); - final newCommitSha = commitData['sha'] as String; - - // 6. Update or create the branch ref. - try { - await _postJson( - '/repos/$owner/$repo/git/refs', - body: { - 'ref': 'refs/heads/$branch', - 'sha': newCommitSha, - }, - ); - } catch (e) { - // Ref might already exist; update it. - await _patchJson( - '/repos/$owner/$repo/git/refs/heads/$branch', - body: {'sha': newCommitSha, 'force': true}, - ); - } - } - - // ── Helpers ───────────────────────────────────────────────────────── - - String _buildPagesUrl(String owner, String repo, String? customDomain) { - if (customDomain != null && customDomain.isNotEmpty) { - return 'https://$customDomain'; - } - return 'https://$owner.github.io/$repo'; - } - - bool _isBinaryContent(List bytes) { - // Check if content contains null bytes (likely binary). - for (var i = 0; i < bytes.length && i < 8000; i++) { - if (bytes[i] == 0) return true; - } - return false; - } - - DateTime? _parseDateTime(dynamic value) { - if (value == null) return null; - if (value is String) { - try { - return DateTime.parse(value); - } catch (_) { - return null; - } - } - return null; - } - - // ── HTTP Helpers ──────────────────────────────────────────────────── - - Future?> _getJson(String path) async { - final response = await _httpClient.get(_uri(path), headers: _headers); - if (response.statusCode >= 200 && response.statusCode < 300) { - if (response.body.isEmpty) return null; - return jsonDecode(response.body) as Map; - } - _handleError(response, path); - return null; - } - - Future> _getJsonList(String path) async { - final response = await _httpClient.get(_uri(path), headers: _headers); - if (response.statusCode >= 200 && response.statusCode < 300) { - if (response.body.isEmpty) return []; - return jsonDecode(response.body) as List; - } - _handleError(response, path); - return []; - } - - Future> _postJson( - String path, { - Map? body, - }) async { - final response = await _httpClient.post( - _uri(path), - headers: _headers, - body: body != null ? jsonEncode(body) : null, - ); - if (response.statusCode >= 200 && response.statusCode < 300) { - if (response.body.isEmpty) return {}; - return jsonDecode(response.body) as Map; - } - _handleError(response, path); - return {}; - } - - Future> _patchJson( - String path, { - Map? body, - }) async { - final response = await _httpClient.patch( - _uri(path), - headers: _headers, - body: body != null ? jsonEncode(body) : null, - ); - if (response.statusCode >= 200 && response.statusCode < 300) { - if (response.body.isEmpty) return {}; - return jsonDecode(response.body) as Map; - } - _handleError(response, path); - return {}; - } - - void _handleError(http.Response response, String path) { - String message = 'Request failed'; - try { - final body = jsonDecode(response.body) as Map; - message = body['message'] as String? ?? response.body; - } catch (_) { - message = response.body.isNotEmpty ? response.body : 'HTTP ${response.statusCode}'; - } - throw GitHubDeepException( - message: message, - endpoint: path, - statusCode: response.statusCode, - ); - } - - /// Dispose resources. - void dispose() { - _httpClient.close(); - } -} - -// ============================================================================= -// DATA MODELS -// ============================================================================= - -/// The type of project to build and deploy. -enum BuildType { - /// Flutter web project (builds with `flutter build web`). - flutterWeb, - - /// Static HTML/CSS/JS project (no build step). - staticHtml, - - /// Jekyll site (builds with `bundle exec jekyll build`). - jekyll, -} - -/// Result of a deployment operation. -@immutable -class DeploymentResult { - final bool success; - final String? url; - final String? customDomain; - final DateTime deployedAt; - final String? error; - final List steps; - - const DeploymentResult({ - required this.success, - this.url, - this.customDomain, - required this.deployedAt, - this.error, - this.steps = const [], - }); - - @override - String toString() => - 'DeploymentResult[success=$success, url=$url, steps=${steps.length}]'; -} - -/// Current status of GitHub Pages for a repository. -@immutable -class DeploymentStatus { - final bool isEnabled; - final String? url; - final String? status; - final DateTime? lastDeployedAt; - final String? error; - - const DeploymentStatus({ - required this.isEnabled, - this.url, - this.status, - this.lastDeployedAt, - this.error, - }); - - @override - String toString() => - 'DeploymentStatus[enabled=$isEnabled, status=$status, url=$url]'; -} - -/// A single deployment entry from the GitHub deployments API. -@immutable -class Deployment { - final int id; - final String sha; - final String ref; - final String? description; - final DateTime createdAt; - final DateTime updatedAt; - final String? creator; - final String environment; - final String state; - - const Deployment({ - required this.id, - required this.sha, - required this.ref, - this.description, - required this.createdAt, - required this.updatedAt, - this.creator, - required this.environment, - required this.state, - }); - - factory Deployment.fromJson(Map json) { - final creatorData = json['creator'] as Map?; - return Deployment( - id: json['id'] as int? ?? 0, - sha: json['sha'] as String? ?? '', - ref: json['ref'] as String? ?? '', - description: json['description'] as String?, - createdAt: _parseDate(json['created_at']), - updatedAt: _parseDate(json['updated_at']), - creator: creatorData?['login'] as String?, - environment: json['environment'] as String? ?? 'github-pages', - state: json['state'] as String? ?? 'unknown', - ); - } - - /// Whether this deployment was successful. - bool get isSuccess => state == 'success'; - - /// Whether this deployment failed. - bool get isFailure => state == 'failure' || state == 'error'; - - /// Whether this deployment is still pending. - bool get isPending => state == 'pending'; - - static DateTime _parseDate(dynamic value) { - if (value == null) return DateTime.now(); - if (value is String) { - try { - return DateTime.parse(value); - } catch (_) { - return DateTime.now(); - } - } - return DateTime.now(); - } - - @override - String toString() => 'Deployment[$id, $ref, $state]'; -} - -// Internal helper for file entries during deployment. -class _FileEntry { - final String path; - final List content; - - _FileEntry({required this.path, required this.content}); -} +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; + +import 'github_deep_service.dart'; +import 'terminal_service.dart'; + +// ============================================================================= +// GITHUB PAGES SERVICE +// ============================================================================= + +/// Deploy static sites directly from MobileCode to GitHub Pages. +/// +/// Supports deploying Flutter Web builds, static HTML sites, and Jekyll sites. +/// Orchestrates the build process, branch management, and GitHub Pages +/// configuration through the GitHub API. +/// +/// Example: +/// ```dart +/// final pages = GitHubPagesService(githubService); +/// final result = await pages.deploy( +/// localProjectPath: '/projects/my-app', +/// owner: 'username', +/// repo: 'my-app', +/// buildType: BuildType.flutterWeb, +/// ); +/// if (result.success) print('Deployed to: ${result.url}'); +/// ``` +class GitHubPagesService { + static const String _baseUrl = 'https://api.github.com'; + static const String _apiVersion = '2022-11-28'; + + final GitHubDeepService _github; + final TerminalService _terminal; + final http.Client _httpClient; + + GitHubPagesService( + this._github, { + TerminalService? terminalService, + http.Client? httpClient, + }) : _terminal = terminalService ?? TerminalService(), + _httpClient = httpClient ?? http.Client(); + + // ── Auth / Headers ────────────────────────────────────────────────── + + Map get _headers { + final token = _github.activeSession?.token; + if (token == null) { + throw const GitHubDeepException(message: 'Not authenticated'); + } + return { + 'Authorization': 'Bearer $token', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': _apiVersion, + 'Content-Type': 'application/json', + 'User-Agent': 'MobileAgent/1.0', + }; + } + + Uri _uri(String path, {Map? query}) { + final base = Uri.parse('$_baseUrl$path'); + if (query == null || query.isEmpty) return base; + return base.replace(queryParameters: query); + } + + static GitHubPagesFailureDetails describeFailure(Object error) { + if (error is GitHubDeepException) { + final status = error.statusCode; + final lower = error.message.toLowerCase(); + if (status == 401) { + return GitHubPagesFailureDetails( + message: 'GitHub token is invalid or expired.', + recoveryHint: 'Open GitHub login and save a fresh token before publishing again.', + failureKind: 'auth_invalid', + statusCode: status, + endpoint: error.endpoint, + ); + } + if (status == 403) { + final rateLimited = lower.contains('rate limit') || lower.contains('secondary rate'); + return GitHubPagesFailureDetails( + message: rateLimited + ? 'GitHub rejected the request because this token or device is rate limited.' + : 'GitHub token does not have enough repository or Pages permission.', + recoveryHint: rateLimited + ? 'Wait for the rate limit window to reset, then retry. If this keeps happening, use an authenticated token.' + : 'Fine-grained tokens need Repository contents read/write, Pages read/write, and Administration read/write. Classic PATs need the repo scope.', + failureKind: rateLimited ? 'rate_limited' : 'permission_denied', + statusCode: status, + endpoint: error.endpoint, + ); + } + if (status == 404) { + return GitHubPagesFailureDetails( + message: 'GitHub repository or Pages endpoint is not visible to this token.', + recoveryHint: 'Confirm the owner/repo name, repository visibility, and token access. Private repos require a token that can read and write that repo.', + failureKind: 'not_found_or_not_visible', + statusCode: status, + endpoint: error.endpoint, + ); + } + if (status == 409) { + return GitHubPagesFailureDetails( + message: 'GitHub Pages is already configured but the Pages source could not be updated.', + recoveryHint: 'Retry after a moment. If it still fails, check that the gh-pages branch exists and the token can administer Pages.', + failureKind: 'pages_conflict', + statusCode: status, + endpoint: error.endpoint, + ); + } + if (status == 422) { + return GitHubPagesFailureDetails( + message: 'GitHub rejected the repository or Pages configuration.', + recoveryHint: 'Use a simpler repository name, publish to a public repo when possible, and make sure the Pages source branch/path is valid.', + failureKind: 'validation_failed', + statusCode: status, + endpoint: error.endpoint, + ); + } + return GitHubPagesFailureDetails( + message: error.message, + recoveryHint: 'Review the GitHub response and retry after correcting the repository, token, or Pages settings.', + failureKind: 'github_api_failed', + statusCode: status, + endpoint: error.endpoint, + ); + } + + return GitHubPagesFailureDetails( + message: error.toString(), + recoveryHint: 'Check network connectivity and retry. If this repeats, run the GitHub connectivity test from Tools.', + failureKind: 'unexpected_failure', + ); + } + + // ── Deployment ────────────────────────────────────────────────────── + + /// Deploy a local project to GitHub Pages. + /// + /// Steps performed: + /// 1. Build the project based on [buildType] + /// 2. Get or create the target branch (default: 'gh-pages') + /// 3. Commit build output to the branch + /// 4. Enable GitHub Pages if not already enabled + /// 5. Return the deployment URL + /// + /// [customDomain] - Optional custom domain (creates CNAME file). + Future deploy({ + required String localProjectPath, + required String owner, + required String repo, + required BuildType buildType, + String? branch, + String? customDomain, + }) async { + final targetBranch = branch ?? 'gh-pages'; + final steps = []; + + try { + // Step 1: Build. + steps.add('Building project (${buildType.name})...'); + final String buildOutputPath; + switch (buildType) { + case BuildType.flutterWeb: + buildOutputPath = await _buildFlutterWeb(localProjectPath); + break; + case BuildType.staticHtml: + buildOutputPath = await _buildStatic(localProjectPath); + break; + case BuildType.jekyll: + buildOutputPath = await _buildJekyll(localProjectPath); + break; + } + steps.add('Build complete: $buildOutputPath'); + + // Step 2: Prepare build output (CNAME, etc.). + final preparedPath = await _prepareBuildOutput( + buildOutputPath, + customDomain: customDomain, + ); + if (customDomain != null) { + steps.add('Custom domain configured: $customDomain'); + } + + // Step 3: Get the default branch SHA for orphan branch creation. + steps.add('Creating $targetBranch branch...'); + final repoDetails = await _github.getRepoDetails(owner, repo); + final defaultBranch = repoDetails['default_branch'] as String? ?? 'main'; + + // Step 4: Create/update gh-pages branch via GitHub API (tree + commit + ref). + await _commitBuildOutput( + owner: owner, + repo: repo, + buildPath: preparedPath, + branch: targetBranch, + defaultBranch: defaultBranch, + steps: steps, + ); + steps.add('Build output committed to $targetBranch'); + + // Step 5: Enable GitHub Pages. + steps.add('Enabling GitHub Pages...'); + await enablePages(owner, repo, branch: targetBranch); + steps.add('GitHub Pages enabled'); + + // Step 6: Set custom domain via API if provided. + if (customDomain != null && customDomain.isNotEmpty) { + await setCustomDomain(owner, repo, customDomain); + steps.add('Custom domain set: $customDomain'); + } + + // Determine the deployment URL. + final url = _buildPagesUrl(owner, repo, customDomain); + + return DeploymentResult( + success: true, + url: url, + customDomain: customDomain, + deployedAt: DateTime.now(), + steps: steps, + ); + } on GitHubDeepException catch (e) { + final failure = describeFailure(e); + steps.add('Error: ${failure.message}'); + return DeploymentResult( + success: false, + url: null, + customDomain: customDomain, + deployedAt: DateTime.now(), + error: failure.message, + recoveryHint: failure.recoveryHint, + failureKind: failure.failureKind, + statusCode: failure.statusCode, + steps: steps, + ); + } catch (e) { + final failure = describeFailure(e); + steps.add('Unexpected error: ${failure.message}'); + return DeploymentResult( + success: false, + url: null, + customDomain: customDomain, + deployedAt: DateTime.now(), + error: failure.message, + recoveryHint: failure.recoveryHint, + failureKind: failure.failureKind, + statusCode: failure.statusCode, + steps: steps, + ); + } + } + + /// Check the current GitHub Pages deployment status. + Future getDeploymentStatus(String owner, String repo) async { + try { + final response = await _httpClient.get( + _uri('/repos/$owner/$repo/pages'), + headers: _headers, + ); + if (response.statusCode == 404) { + return const DeploymentStatus(isEnabled: false); + } + if (response.statusCode != 200) { + return DeploymentStatus( + isEnabled: false, + error: 'HTTP ${response.statusCode}', + ); + } + final data = jsonDecode(response.body) as Map; + final source = data['source'] as Map?; + return DeploymentStatus( + isEnabled: data['status'] == 'built', + url: data['html_url'] as String?, + status: data['status'] as String?, // building, built, errored + lastDeployedAt: _parseDateTime(data['updated_at']), + ); + } catch (e) { + return DeploymentStatus(isEnabled: false, error: e.toString()); + } + } + + /// Enable GitHub Pages for a repository. + Future enablePages( + String owner, + String repo, { + String branch = 'gh-pages', + String path = '/', + }) async { + final response = await _httpClient.post( + _uri('/repos/$owner/$repo/pages'), + headers: _headers, + body: jsonEncode({ + 'source': { + 'branch': branch, + 'path': path, + }, + }), + ); + if (response.statusCode == 201 || response.statusCode == 204 || response.statusCode == 409) { + if (response.statusCode == 409) { + await _updatePagesSource(owner, repo, branch: branch, path: path); + } + return; + } + _handleError(response, '/repos/$owner/$repo/pages'); + } + + Future _updatePagesSource( + String owner, + String repo, { + required String branch, + required String path, + }) async { + final response = await _httpClient.put( + _uri('/repos/$owner/$repo/pages'), + headers: _headers, + body: jsonEncode({ + 'source': { + 'branch': branch, + 'path': path, + }, + }), + ); + if (response.statusCode >= 200 && response.statusCode < 300) return; + _handleError(response, '/repos/$owner/$repo/pages'); + } + + /// Disable GitHub Pages for a repository. + Future disablePages(String owner, String repo) async { + final response = await _httpClient.delete( + _uri('/repos/$owner/$repo/pages'), + headers: _headers, + ); + if (response.statusCode >= 200 && response.statusCode < 300) return; + _handleError(response, '/repos/$owner/$repo/pages'); + } + + /// Set a custom domain for GitHub Pages. + Future setCustomDomain(String owner, String repo, String domain) async { + final response = await _httpClient.put( + _uri('/repos/$owner/$repo/pages'), + headers: _headers, + body: jsonEncode({ + 'cname': domain, + 'source': { + 'branch': 'gh-pages', + 'path': '/', + }, + }), + ); + if (response.statusCode >= 200 && response.statusCode < 300) return; + _handleError(response, '/repos/$owner/$repo/pages'); + } + + /// Get deployment history for a repository. + Future> getDeployments(String owner, String repo) async { + final data = await _getJsonList('/repos/$owner/$repo/deployments'); + return data + .map((item) => Deployment.fromJson(item as Map)) + .toList(); + } + + /// Get the latest deployment for a repository. + Future getLatestDeployment(String owner, String repo) async { + final deployments = await getDeployments(owner, repo); + if (deployments.isEmpty) return null; + return deployments.first; + } + + // ── Build Automation ──────────────────────────────────────────────── + + /// Build a Flutter Web project. Returns the path to the build/web directory. + Future _buildFlutterWeb(String projectPath) async { + final buildDir = path.join(projectPath, 'build', 'web'); + + // Clean previous build. + final cleanResult = await _terminal.flutterClean(projectPath); + if (!cleanResult.success) { + debugPrint('[PagesService] flutter clean warning: ${cleanResult.stderr}'); + } + + // Build for web. + final result = await _terminal.execute( + 'flutter build web --release', + workingDirectory: projectPath, + timeoutSeconds: 300, + ); + if (!result.success) { + throw GitHubDeepException( + message: 'Flutter web build failed: ${result.stderr}', + ); + } + + if (!Directory(buildDir).existsSync()) { + throw const GitHubDeepException(message: 'Build output directory not found'); + } + return buildDir; + } + + /// Build a static HTML project. Returns the project path (no build needed). + Future _buildStatic(String projectPath) async { + // Static HTML projects don't need a build step. + // Return the project root; we'll deploy all HTML/CSS/JS files. + return projectPath; + } + + /// Build a Jekyll site. Returns the path to the _site directory. + Future _buildJekyll(String projectPath) async { + final result = await _terminal.execute( + 'bundle exec jekyll build', + workingDirectory: projectPath, + timeoutSeconds: 180, + ); + if (!result.success) { + throw GitHubDeepException( + message: 'Jekyll build failed: ${result.stderr}', + ); + } + final siteDir = path.join(projectPath, '_site'); + if (!Directory(siteDir).existsSync()) { + throw const GitHubDeepException(message: 'Jekyll _site directory not found'); + } + return siteDir; + } + + /// Prepare build output for deployment. + /// + /// Creates a CNAME file if [customDomain] is provided. + /// Returns the path to the prepared build directory. + Future _prepareBuildOutput( + String buildPath, { + String? customDomain, + }) async { + if (customDomain != null && customDomain.isNotEmpty) { + final cnameFile = File(path.join(buildPath, 'CNAME')); + await cnameFile.writeAsString(customDomain); + } + return buildPath; + } + + // ── GitHub API: Commit Build Output ───────────────────────────────── + + /// Commit all files from [buildPath] to the [branch] branch using the + /// GitHub Git Data API (trees + commits + refs). + Future _commitBuildOutput({ + required String owner, + required String repo, + required String buildPath, + required String branch, + required String defaultBranch, + required List steps, + }) async { + // 1. Get the current commit SHA of the default branch. + final defaultRef = await _getJson('/repos/$owner/$repo/git/refs/heads/$defaultBranch'); + final baseCommitSha = defaultRef?['object']?['sha'] as String?; + if (baseCommitSha == null) { + throw const GitHubDeepException(message: 'Could not get default branch commit'); + } + + // 2. Collect all files from the build directory. + final buildDir = Directory(buildPath); + if (!buildDir.existsSync()) { + throw GitHubDeepException(message: 'Build directory not found: $buildPath'); + } + + final fileEntries = <_FileEntry>[]; + await for (final entity in buildDir.list(recursive: true, followLinks: false)) { + if (entity is File) { + final relativePath = path.relative(entity.path, from: buildPath); + // Skip hidden files and directories. + if (relativePath.startsWith('.')) continue; + final content = await entity.readAsBytes(); + fileEntries.add(_FileEntry(path: relativePath, content: content)); + } + } + + steps.add('Found ${fileEntries.length} files to deploy'); + + // 3. Create blobs for each file. + final treeItems = >[]; + for (var i = 0; i < fileEntries.length; i++) { + final entry = fileEntries[i]; + final isBinary = _isBinaryContent(entry.content); + + if (isBinary) { + // Create a blob for binary content. + final blobData = await _postJson( + '/repos/$owner/$repo/git/blobs', + body: { + 'content': base64Encode(entry.content), + 'encoding': 'base64', + }, + ); + final blobSha = blobData['sha'] as String; + treeItems.add({ + 'path': entry.path, + 'mode': '100644', + 'type': 'blob', + 'sha': blobSha, + }); + } else { + // For text files, inline the content in the tree. + treeItems.add({ + 'path': entry.path, + 'mode': '100644', + 'type': 'blob', + 'content': utf8.decode(entry.content, allowMalformed: true), + }); + } + + if ((i + 1) % 50 == 0) { + steps.add('Processed ${i + 1}/${fileEntries.length} files...'); + } + } + + // 4. Create a tree. + final treeData = await _postJson( + '/repos/$owner/$repo/git/trees', + body: {'tree': treeItems}, + ); + final treeSha = treeData['sha'] as String; + + // 5. Create a commit. + final commitData = await _postJson( + '/repos/$owner/$repo/git/commits', + body: { + 'message': 'Deploy to GitHub Pages from MobileCode', + 'tree': treeSha, + if (baseCommitSha != null) 'parents': [baseCommitSha], + }, + ); + final newCommitSha = commitData['sha'] as String; + + // 6. Update or create the branch ref. + try { + await _postJson( + '/repos/$owner/$repo/git/refs', + body: { + 'ref': 'refs/heads/$branch', + 'sha': newCommitSha, + }, + ); + } catch (e) { + // Ref might already exist; update it. + await _patchJson( + '/repos/$owner/$repo/git/refs/heads/$branch', + body: {'sha': newCommitSha, 'force': true}, + ); + } + } + + // ── Helpers ───────────────────────────────────────────────────────── + + String _buildPagesUrl(String owner, String repo, String? customDomain) { + if (customDomain != null && customDomain.isNotEmpty) { + return 'https://$customDomain'; + } + return 'https://$owner.github.io/$repo'; + } + + bool _isBinaryContent(List bytes) { + // Check if content contains null bytes (likely binary). + for (var i = 0; i < bytes.length && i < 8000; i++) { + if (bytes[i] == 0) return true; + } + return false; + } + + DateTime? _parseDateTime(dynamic value) { + if (value == null) return null; + if (value is String) { + try { + return DateTime.parse(value); + } catch (_) { + return null; + } + } + return null; + } + + // ── HTTP Helpers ──────────────────────────────────────────────────── + + Future?> _getJson(String path) async { + final response = await _httpClient.get(_uri(path), headers: _headers); + if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.body.isEmpty) return null; + return jsonDecode(response.body) as Map; + } + _handleError(response, path); + return null; + } + + Future> _getJsonList(String path) async { + final response = await _httpClient.get(_uri(path), headers: _headers); + if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.body.isEmpty) return []; + return jsonDecode(response.body) as List; + } + _handleError(response, path); + return []; + } + + Future> _postJson( + String path, { + Map? body, + }) async { + final response = await _httpClient.post( + _uri(path), + headers: _headers, + body: body != null ? jsonEncode(body) : null, + ); + if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.body.isEmpty) return {}; + return jsonDecode(response.body) as Map; + } + _handleError(response, path); + return {}; + } + + Future> _patchJson( + String path, { + Map? body, + }) async { + final response = await _httpClient.patch( + _uri(path), + headers: _headers, + body: body != null ? jsonEncode(body) : null, + ); + if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.body.isEmpty) return {}; + return jsonDecode(response.body) as Map; + } + _handleError(response, path); + return {}; + } + + void _handleError(http.Response response, String path) { + String message = 'Request failed'; + try { + final body = jsonDecode(response.body) as Map; + message = body['message'] as String? ?? response.body; + } catch (_) { + message = response.body.isNotEmpty ? response.body : 'HTTP ${response.statusCode}'; + } + throw GitHubDeepException( + message: message, + endpoint: path, + statusCode: response.statusCode, + ); + } + + /// Dispose resources. + void dispose() { + _httpClient.close(); + } +} + +// ============================================================================= +// DATA MODELS +// ============================================================================= + +/// The type of project to build and deploy. +enum BuildType { + /// Flutter web project (builds with `flutter build web`). + flutterWeb, + + /// Static HTML/CSS/JS project (no build step). + staticHtml, + + /// Jekyll site (builds with `bundle exec jekyll build`). + jekyll, +} + +/// Result of a deployment operation. +@immutable +class DeploymentResult { + final bool success; + final String? url; + final String? customDomain; + final DateTime deployedAt; + final String? error; + final String? recoveryHint; + final String? failureKind; + final int? statusCode; + final List steps; + + const DeploymentResult({ + required this.success, + this.url, + this.customDomain, + required this.deployedAt, + this.error, + this.recoveryHint, + this.failureKind, + this.statusCode, + this.steps = const [], + }); + + @override + String toString() => + 'DeploymentResult[success=$success, url=$url, steps=${steps.length}]'; +} + +/// User-facing explanation for GitHub Pages failures. +class GitHubPagesFailureDetails { + const GitHubPagesFailureDetails({ + required this.message, + required this.recoveryHint, + required this.failureKind, + this.statusCode, + this.endpoint, + }); + + final String message; + final String recoveryHint; + final String failureKind; + final int? statusCode; + final String? endpoint; +} + +/// Current status of GitHub Pages for a repository. +@immutable +class DeploymentStatus { + final bool isEnabled; + final String? url; + final String? status; + final DateTime? lastDeployedAt; + final String? error; + + const DeploymentStatus({ + required this.isEnabled, + this.url, + this.status, + this.lastDeployedAt, + this.error, + }); + + @override + String toString() => + 'DeploymentStatus[enabled=$isEnabled, status=$status, url=$url]'; +} + +/// A single deployment entry from the GitHub deployments API. +@immutable +class Deployment { + final int id; + final String sha; + final String ref; + final String? description; + final DateTime createdAt; + final DateTime updatedAt; + final String? creator; + final String environment; + final String state; + + const Deployment({ + required this.id, + required this.sha, + required this.ref, + this.description, + required this.createdAt, + required this.updatedAt, + this.creator, + required this.environment, + required this.state, + }); + + factory Deployment.fromJson(Map json) { + final creatorData = json['creator'] as Map?; + return Deployment( + id: json['id'] as int? ?? 0, + sha: json['sha'] as String? ?? '', + ref: json['ref'] as String? ?? '', + description: json['description'] as String?, + createdAt: _parseDate(json['created_at']), + updatedAt: _parseDate(json['updated_at']), + creator: creatorData?['login'] as String?, + environment: json['environment'] as String? ?? 'github-pages', + state: json['state'] as String? ?? 'unknown', + ); + } + + /// Whether this deployment was successful. + bool get isSuccess => state == 'success'; + + /// Whether this deployment failed. + bool get isFailure => state == 'failure' || state == 'error'; + + /// Whether this deployment is still pending. + bool get isPending => state == 'pending'; + + static DateTime _parseDate(dynamic value) { + if (value == null) return DateTime.now(); + if (value is String) { + try { + return DateTime.parse(value); + } catch (_) { + return DateTime.now(); + } + } + return DateTime.now(); + } + + @override + String toString() => 'Deployment[$id, $ref, $state]'; +} + +// Internal helper for file entries during deployment. +class _FileEntry { + final String path; + final List content; + + _FileEntry({required this.path, required this.content}); +} diff --git a/mobile_agent/lib/services/github_repo_hub_service.dart b/mobile_agent/lib/services/github_repo_hub_service.dart new file mode 100644 index 0000000..bfd5ab8 --- /dev/null +++ b/mobile_agent/lib/services/github_repo_hub_service.dart @@ -0,0 +1,814 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/github_repo.dart'; +import 'github_deep_service.dart'; + +const mobileCodeProjectsFolderName = 'mobilecode_projects'; +const githubWorkspaceFolderName = 'github'; + +class GitHubRepoLocalState { + const GitHubRepoLocalState({ + required this.path, + required this.exists, + required this.hasGit, + required this.remoteLinked, + this.runtimeGit = false, + }); + + final String path; + final bool exists; + final bool hasGit; + final bool remoteLinked; + final bool runtimeGit; + + String get statusLabel { + if (runtimeGit) return 'Termux git clone'; + if (hasGit) return 'Git clone'; + if (remoteLinked) return 'Remote-linked'; + if (exists) return 'Phone folder'; + return 'Not on phone'; + } + + String get modeDescription { + if (runtimeGit) return 'Real git clone inside the active runtime workspace.'; + if (hasGit) return 'Real git clone with a .git folder on this phone.'; + if (remoteLinked) return 'GitHub API workspace marker; files are remote-linked, not cloned.'; + if (exists) return 'Phone folder exists, but it is not linked to GitHub yet.'; + return 'No phone workspace has been created for this repo yet.'; + } +} + +class GitHubRemoteWorkspaceLink { + const GitHubRemoteWorkspaceLink({ + required this.owner, + required this.name, + required this.workspacePath, + this.htmlUrl, + this.defaultBranch = 'main', + }); + + final String owner; + final String name; + final String workspacePath; + final String? htmlUrl; + final String defaultBranch; + + String get fullName => '$owner/$name'; +} + +class GitHubRepoCloneTarget { + const GitHubRepoCloneTarget({ + required this.finalPath, + required this.clonePath, + required this.usesTemporaryPath, + }); + + final String finalPath; + final String clonePath; + final bool usesTemporaryPath; +} + +class GitHubRepoHubItem { + const GitHubRepoHubItem({ + required this.repo, + required this.localState, + required this.watched, + }); + + final GitHubRepo repo; + final GitHubRepoLocalState localState; + final bool watched; + + String get key => GitHubRepoHubService.repoKey(repo); +} + +class GitHubActionsSnapshot { + const GitHubActionsSnapshot({ + required this.workflows, + required this.runs, + required this.artifacts, + required this.jobs, + }); + + final List workflows; + final List runs; + final List artifacts; + final List jobs; + + Map? get latestRun { + final first = runs.isEmpty ? null : runs.first; + return first is Map ? first : null; + } +} + +class GitHubReleaseAsset { + const GitHubReleaseAsset({ + required this.name, + required this.downloadUrl, + this.contentType, + this.sizeBytes, + this.downloadCount, + }); + + final String name; + final String downloadUrl; + final String? contentType; + final int? sizeBytes; + final int? downloadCount; + + bool get isBuildArtifact { + final lower = name.toLowerCase(); + return lower.endsWith('.apk') || + lower.endsWith('.aab') || + lower.endsWith('.zip') || + lower.endsWith('.ipa') || + lower.endsWith('.tar.gz'); + } + + factory GitHubReleaseAsset.fromJson(Map json) { + return GitHubReleaseAsset( + name: json['name']?.toString() ?? 'asset', + downloadUrl: json['browser_download_url']?.toString() ?? '', + contentType: json['content_type']?.toString(), + sizeBytes: json['size'] is int ? json['size'] as int : null, + downloadCount: json['download_count'] is int ? json['download_count'] as int : null, + ); + } +} + +class GitHubReleaseSummary { + const GitHubReleaseSummary({ + required this.tagName, + required this.title, + required this.releaseUrl, + required this.publishedAt, + required this.assets, + required this.prerelease, + required this.draft, + }); + + final String tagName; + final String title; + final String releaseUrl; + final DateTime? publishedAt; + final List assets; + final bool prerelease; + final bool draft; + + List get buildAssets => + assets.where((asset) => asset.isBuildArtifact && asset.downloadUrl.isNotEmpty).toList(); + + bool get hasBuildAssets => buildAssets.isNotEmpty; + + factory GitHubReleaseSummary.fromJson(Map json) { + final rawAssets = (json['assets'] as List?) ?? const []; + final rawTitle = json['name']?.toString().trim(); + final rawTag = json['tag_name']?.toString() ?? 'untagged'; + return GitHubReleaseSummary( + tagName: rawTag, + title: rawTitle != null && rawTitle.isNotEmpty ? rawTitle : rawTag, + releaseUrl: json['html_url']?.toString() ?? '', + publishedAt: DateTime.tryParse(json['published_at']?.toString() ?? ''), + assets: rawAssets + .whereType>() + .map(GitHubReleaseAsset.fromJson) + .where((asset) => asset.name.isNotEmpty) + .toList(), + prerelease: json['prerelease'] == true, + draft: json['draft'] == true, + ); + } +} + +class GitHubWorkspaceEntry { + const GitHubWorkspaceEntry({ + required this.name, + required this.path, + required this.type, + this.sha, + this.size, + this.downloadUrl, + }); + + final String name; + final String path; + final String type; + final String? sha; + final int? size; + final String? downloadUrl; + + bool get isDirectory => type == 'dir'; + + bool get isFile => type == 'file'; + + factory GitHubWorkspaceEntry.fromJson(Map json) { + return GitHubWorkspaceEntry( + name: json['name']?.toString() ?? '', + path: json['path']?.toString() ?? '', + type: json['type']?.toString() ?? 'file', + sha: json['sha']?.toString(), + size: json['size'] is int ? json['size'] as int : null, + downloadUrl: json['download_url']?.toString(), + ); + } +} + +class GitHubRemoteFile { + const GitHubRemoteFile({ + required this.path, + required this.content, + this.sha, + }); + + final String path; + final String content; + final String? sha; +} + +class GitHubArtifactDownloadRecord { + const GitHubArtifactDownloadRecord({ + required this.repoFullName, + required this.artifactName, + required this.path, + required this.downloadedAt, + this.sizeBytes, + }); + + final String repoFullName; + final String artifactName; + final String path; + final DateTime downloadedAt; + final int? sizeBytes; + + Map toJson() => { + 'repoFullName': repoFullName, + 'artifactName': artifactName, + 'path': path, + 'downloadedAt': downloadedAt.toIso8601String(), + if (sizeBytes != null) 'sizeBytes': sizeBytes, + }; + + factory GitHubArtifactDownloadRecord.fromJson(Map json) { + return GitHubArtifactDownloadRecord( + repoFullName: json['repoFullName']?.toString() ?? '', + artifactName: json['artifactName']?.toString() ?? 'artifact', + path: json['path']?.toString() ?? '', + downloadedAt: DateTime.tryParse(json['downloadedAt']?.toString() ?? '') ?? DateTime.now(), + sizeBytes: json['sizeBytes'] is int ? json['sizeBytes'] as int : null, + ); + } +} + +class GitHubRuntimeWorkspaceSyncRecord { + const GitHubRuntimeWorkspaceSyncRecord({ + required this.repoFullName, + required this.runtimePath, + required this.sharedPath, + required this.syncedAt, + }); + + final String repoFullName; + final String runtimePath; + final String sharedPath; + final DateTime syncedAt; + + Map toJson() => { + 'repoFullName': repoFullName, + 'runtimePath': runtimePath, + 'sharedPath': sharedPath, + 'syncedAt': syncedAt.toIso8601String(), + }; + + factory GitHubRuntimeWorkspaceSyncRecord.fromJson(Map json) { + return GitHubRuntimeWorkspaceSyncRecord( + repoFullName: json['repoFullName']?.toString() ?? '', + runtimePath: json['runtimePath']?.toString() ?? '', + sharedPath: json['sharedPath']?.toString() ?? '', + syncedAt: DateTime.tryParse(json['syncedAt']?.toString() ?? '') ?? DateTime.now(), + ); + } +} + +class GitHubRepoHubService { + GitHubRepoHubService(this.github); + + static const _watchlistKey = 'mobilecode.github.repoWatchlist.v1'; + static const _artifactDownloadsKey = 'mobilecode.github.artifactDownloads.v1'; + static const _runtimeWorkspaceSyncsKey = 'mobilecode.github.runtimeWorkspaceSyncs.v1'; + static const _markerName = '.mobilecode-remote.json'; + + final GitHubDeepService github; + + static String repoKey(GitHubRepo repo) => '${repo.owner}/${repo.name}'; + + Future initialize() => github.initialize(); + + bool get isAuthenticated => github.isAuthenticated; + + String? get currentUser => github.currentUser; + + List get accountList => github.accountList; + + Future switchAccount(String username) => github.switchAccount(username); + + DateTime? authenticatedAtFor(String username) => github.authenticatedAtFor(username); + + String? avatarUrlFor(String username) => github.avatarUrlFor(username); + + Future> loadTokenScopes({String? username}) => github.getTokenScopes(username: username); + + Future> loadHubItems({ + String? owner, + String sort = 'pushed', + }) async { + await initialize(); + final normalizedOwner = owner?.trim(); + final watchlist = await loadWatchlist(); + final repos = normalizedOwner == null || normalizedOwner.isEmpty || normalizedOwner == currentUser + ? await github.getRepos(sort: sort) + : await github.getUserRepos(normalizedOwner, sort: sort, public: true); + final items = []; + for (final repo in repos) { + items.add(GitHubRepoHubItem( + repo: repo, + localState: await localStateFor(repo), + watched: watchlist.contains(repoKey(repo)), + )); + } + return items; + } + + Future> searchHubItems({ + required String query, + required String source, + String sort = 'updated', + }) async { + await initialize(); + final trimmed = query.trim(); + final effectiveQuery = _searchQueryForSource(trimmed, source); + final watchlist = await loadWatchlist(); + final rawRepos = await github.searchRepositories( + effectiveQuery, + sort: _searchSort(sort), + perPage: 50, + public: true, + ); + final items = []; + final seen = {}; + for (final raw in rawRepos) { + if (raw is! Map) continue; + final repo = GitHubRepo.fromGitHubApi(raw); + if (!seen.add(repoKey(repo))) continue; + items.add(GitHubRepoHubItem( + repo: repo, + localState: await localStateFor(repo), + watched: watchlist.contains(repoKey(repo)), + )); + } + return items; + } + + Future> loadWatchlist() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_watchlistKey) ?? const []; + return raw.where((item) => item.trim().isNotEmpty).toSet(); + } + + String _searchQueryForSource(String query, String source) { + final userQuery = query.isEmpty ? '' : '$query '; + return switch (source) { + 'skill' => '${userQuery}(SKILL.md OR "agent skill" OR "codex skill" OR "claude skill") in:readme,description archived:false', + 'mcp' => '${userQuery}("mcp server" OR "model context protocol") in:name,description,readme archived:false', + 'release' => '${userQuery}(release OR apk OR android OR flutter) in:name,description,readme archived:false', + _ => '${query.isEmpty ? 'stars:>10' : query} archived:false', + }; + } + + String _searchSort(String sort) { + if (sort == 'pushed' || sort == 'updated' || sort == 'created') return 'updated'; + if (sort == 'full_name') return ''; + return sort; + } + + Future setWatched(GitHubRepo repo, bool watched) async { + final prefs = await SharedPreferences.getInstance(); + final values = await loadWatchlist(); + final key = repoKey(repo); + if (watched) { + values.add(key); + } else { + values.remove(key); + } + await prefs.setStringList(_watchlistKey, values.toList()..sort()); + return watched; + } + + Future workspaceRoot() async { + final directory = await getApplicationDocumentsDirectory(); + final root = Directory(p.join(directory.path, mobileCodeProjectsFolderName, githubWorkspaceFolderName)); + await root.create(recursive: true); + return root; + } + + Future workspacePathFor(GitHubRepo repo) async { + final root = await workspaceRoot(); + return p.join(root.path, _safeSegment(repo.owner), _safeSegment(repo.name)); + } + + Future localStateFor(GitHubRepo repo) async { + final path = await workspacePathFor(repo); + final directory = Directory(path); + final exists = await directory.exists(); + final hasGit = await Directory(p.join(path, '.git')).exists(); + final marker = await File(p.join(path, _markerName)).exists(); + if (marker && !hasGit) { + try { + final decoded = jsonDecode(await File(p.join(path, _markerName)).readAsString()); + if (decoded is Map && decoded['mode'] == 'termux_git_workspace') { + final runtimePath = decoded['runtimePath']?.toString().trim(); + if (runtimePath != null && runtimePath.isNotEmpty) { + return GitHubRepoLocalState( + path: runtimePath, + exists: true, + hasGit: true, + remoteLinked: false, + runtimeGit: true, + ); + } + } + } catch (_) { + // Fall back to the normal marker state below. + } + } + return GitHubRepoLocalState( + path: path, + exists: exists, + hasGit: hasGit, + remoteLinked: marker && !hasGit, + ); + } + + Future ensureRemoteLinkedWorkspace(GitHubRepo repo) async { + final path = await workspacePathFor(repo); + final directory = Directory(path); + await directory.create(recursive: true); + final marker = File(p.join(path, _markerName)); + await marker.writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'mode': 'github_api_workspace', + 'repo': repoKey(repo), + 'owner': repo.owner, + 'name': repo.name, + 'htmlUrl': repo.htmlUrl, + 'defaultBranch': repo.defaultBranch, + 'createdAt': DateTime.now().toIso8601String(), + }), + flush: true, + ); + return localStateFor(repo); + } + + Future prepareCloneTarget(GitHubRepo repo) async { + final path = await workspacePathFor(repo); + final directory = Directory(path); + final parent = Directory(p.dirname(path)); + await parent.create(recursive: true); + + if (!await directory.exists()) { + return GitHubRepoCloneTarget( + finalPath: path, + clonePath: await _uniqueCloneTempPath(path), + usesTemporaryPath: true, + ); + } + if (await Directory(p.join(path, '.git')).exists()) { + return GitHubRepoCloneTarget( + finalPath: path, + clonePath: path, + usesTemporaryPath: false, + ); + } + + final entities = await directory.list(followLinks: false).toList(); + final blockingEntries = entities + .where((entity) => p.basename(entity.path) != _markerName) + .toList(); + if (blockingEntries.isNotEmpty) { + throw StateError( + 'Phone workspace already contains files but no .git folder. ' + 'Move or remove that folder before cloning.', + ); + } + + return GitHubRepoCloneTarget( + finalPath: path, + clonePath: await _uniqueCloneTempPath(path), + usesTemporaryPath: true, + ); + } + + String runtimeClonePathFor(GitHubRepo repo, String runtimeWorkspaceRoot) { + final root = runtimeWorkspaceRoot.trim().isEmpty + ? '~/mobilecode_projects' + : runtimeWorkspaceRoot.trim(); + return p.posix.join( + root, + githubWorkspaceFolderName, + _safeSegment(repo.owner), + _safeSegment(repo.name), + ); + } + + Future ensureRuntimeGitWorkspace( + GitHubRepo repo, { + required String runtimePath, + }) async { + final markerPath = await workspacePathFor(repo); + final directory = Directory(markerPath); + await directory.create(recursive: true); + final marker = File(p.join(markerPath, _markerName)); + await marker.writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'mode': 'termux_git_workspace', + 'repo': repoKey(repo), + 'owner': repo.owner, + 'name': repo.name, + 'htmlUrl': repo.htmlUrl, + 'defaultBranch': repo.defaultBranch, + 'runtimePath': runtimePath, + 'createdAt': DateTime.now().toIso8601String(), + }), + flush: true, + ); + return localStateFor(repo); + } + + Future completeCloneTarget( + GitHubRepo repo, + GitHubRepoCloneTarget target, + ) async { + if (target.usesTemporaryPath) { + final finalDirectory = Directory(target.finalPath); + if (await finalDirectory.exists()) { + final marker = File(p.join(target.finalPath, _markerName)); + if (await marker.exists()) { + await marker.delete(); + } + await finalDirectory.delete(); + } + await Directory(target.clonePath).rename(target.finalPath); + } + return localStateFor(repo); + } + + Future cleanupCloneTarget(GitHubRepoCloneTarget target) async { + if (!target.usesTemporaryPath) return; + final directory = Directory(target.clonePath); + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } + + Future _uniqueCloneTempPath(String finalPath) async { + for (var attempt = 0; attempt < 10; attempt += 1) { + final suffix = '${DateTime.now().millisecondsSinceEpoch}-$attempt'; + final candidate = '$finalPath.clone-tmp-$suffix'; + if (!await Directory(candidate).exists() && + !await File(candidate).exists()) { + return candidate; + } + } + throw StateError('Could not allocate a temporary clone path.'); + } + + static Future findRemoteLinkForPath(String path) async { + var current = FileSystemEntity.isDirectorySync(path) ? Directory(path) : Directory(p.dirname(path)); + for (var depth = 0; depth < 16; depth += 1) { + final marker = File(p.join(current.path, _markerName)); + if (await marker.exists()) { + try { + final data = jsonDecode(await marker.readAsString()); + if (data is Map) { + final owner = data['owner']?.toString(); + final name = data['name']?.toString(); + if (owner != null && owner.isNotEmpty && name != null && name.isNotEmpty) { + return GitHubRemoteWorkspaceLink( + owner: owner, + name: name, + workspacePath: current.path, + htmlUrl: data['htmlUrl']?.toString(), + defaultBranch: data['defaultBranch']?.toString() ?? 'main', + ); + } + } + } on Object { + return null; + } + } + + final parentPath = p.dirname(current.path); + if (parentPath == current.path) break; + current = Directory(parentPath); + } + return null; + } + + Future loadActionsSnapshot(GitHubRepo repo) async { + final usePublicRead = !repo.isPrivate; + final workflows = await github.getWorkflows(repo.owner, repo.name, public: usePublicRead); + final runs = await github.getWorkflowRuns(repo.owner, repo.name, perPage: 5, public: usePublicRead); + var artifacts = const []; + var jobs = const []; + final latestRun = runs.isEmpty ? null : runs.first; + if (latestRun is Map) { + final id = latestRun['id']; + if (id is int) { + artifacts = await github.getWorkflowRunArtifacts(repo.owner, repo.name, id, public: usePublicRead); + jobs = await github.getWorkflowRunJobs(repo.owner, repo.name, id, public: usePublicRead); + } + } + return GitHubActionsSnapshot( + workflows: workflows, + runs: runs, + artifacts: artifacts, + jobs: jobs, + ); + } + + Future> loadReleaseSummaries(GitHubRepo repo) async { + final releases = await github.getReleases(repo.owner, repo.name, public: !repo.isPrivate); + return releases + .whereType>() + .map(GitHubReleaseSummary.fromJson) + .where((release) => release.tagName.isNotEmpty || release.releaseUrl.isNotEmpty) + .toList(); + } + + Future loadLatestReleaseSummary(GitHubRepo repo) async { + final releases = await loadReleaseSummaries(repo); + if (releases.isEmpty) return null; + return releases.first; + } + + Future dispatchWorkflow(GitHubRepo repo, String workflowId) { + return github.dispatchWorkflow( + repo.owner, + repo.name, + workflowId, + ref: repo.defaultBranch, + ); + } + + Future downloadArtifactZip(GitHubRepo repo, Map artifact) async { + final id = artifact['id']; + if (id is! int) { + throw const GitHubDeepException(message: 'Artifact id is missing.'); + } + final bytes = await github.downloadWorkflowArtifactZip(repo.owner, repo.name, id); + final root = await workspaceRoot(); + final artifactDir = Directory(p.join(root.path, '_actions_artifacts', _safeSegment(repo.owner), _safeSegment(repo.name))); + await artifactDir.create(recursive: true); + final rawName = artifact['name']?.toString() ?? 'artifact-$id'; + final file = File(p.join(artifactDir.path, '${_safeSegment(rawName)}.zip')); + await file.writeAsBytes(bytes, flush: true); + await _recordArtifactDownload(repo, artifact, file.path); + return file.path; + } + + Future> loadArtifactDownloads({GitHubRepo? repo}) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_artifactDownloadsKey) ?? const []; + final records = []; + for (final item in raw) { + try { + final data = jsonDecode(item); + if (data is Map) { + final record = GitHubArtifactDownloadRecord.fromJson(data); + if (record.path.isEmpty) continue; + if (repo != null && record.repoFullName != repoKey(repo)) continue; + records.add(record); + } + } on Object { + continue; + } + } + records.sort((a, b) => b.downloadedAt.compareTo(a.downloadedAt)); + return records; + } + + Future _recordArtifactDownload(GitHubRepo repo, Map artifact, String path) async { + final prefs = await SharedPreferences.getInstance(); + final records = await loadArtifactDownloads(); + final next = [ + GitHubArtifactDownloadRecord( + repoFullName: repoKey(repo), + artifactName: artifact['name']?.toString() ?? 'artifact', + path: path, + downloadedAt: DateTime.now(), + sizeBytes: artifact['size_in_bytes'] is int ? artifact['size_in_bytes'] as int : null, + ), + ...records.where((record) => record.path != path), + ].take(24).map((record) => jsonEncode(record.toJson())).toList(); + await prefs.setStringList(_artifactDownloadsKey, next); + } + + Future> loadRuntimeWorkspaceSyncs({GitHubRepo? repo}) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList(_runtimeWorkspaceSyncsKey) ?? const []; + final records = []; + for (final item in raw) { + try { + final data = jsonDecode(item); + if (data is Map) { + final record = GitHubRuntimeWorkspaceSyncRecord.fromJson(data); + if (record.sharedPath.isEmpty) continue; + if (repo != null && record.repoFullName != repoKey(repo)) continue; + records.add(record); + } + } on Object { + continue; + } + } + records.sort((a, b) => b.syncedAt.compareTo(a.syncedAt)); + return records; + } + + Future recordRuntimeWorkspaceSync( + GitHubRepo repo, { + required String runtimePath, + required String sharedPath, + }) async { + final prefs = await SharedPreferences.getInstance(); + final records = await loadRuntimeWorkspaceSyncs(); + final next = [ + GitHubRuntimeWorkspaceSyncRecord( + repoFullName: repoKey(repo), + runtimePath: runtimePath, + sharedPath: sharedPath, + syncedAt: DateTime.now(), + ), + ...records.where((record) => record.sharedPath != sharedPath), + ].take(24).map((record) => jsonEncode(record.toJson())).toList(); + await prefs.setStringList(_runtimeWorkspaceSyncsKey, next); + } + + Future> loadRemoteTree(GitHubRepo repo, {String path = ''}) async { + final usePublicRead = !repo.isPrivate; + final items = await github.getContents( + repo.owner, + repo.name, + path: path.isEmpty ? null : path, + ref: repo.defaultBranch, + public: usePublicRead, + ); + return items + .whereType>() + .map(GitHubWorkspaceEntry.fromJson) + .where((entry) => entry.name.isNotEmpty) + .toList() + ..sort((a, b) { + if (a.isDirectory != b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + } + + Future readRemoteFile(GitHubRepo repo, String path) async { + final usePublicRead = !repo.isPrivate; + final items = await github.getContents(repo.owner, repo.name, path: path, ref: repo.defaultBranch, public: usePublicRead); + final metadata = items.isNotEmpty && items.first is Map ? items.first as Map : null; + final content = await github.getFileContent(repo.owner, repo.name, path, ref: repo.defaultBranch, public: usePublicRead); + return GitHubRemoteFile( + path: path, + content: content, + sha: metadata?['sha']?.toString(), + ); + } + + Future commitRemoteFile( + GitHubRepo repo, { + required String path, + required String content, + required String message, + String? sha, + }) async { + await github.createOrUpdateFile( + repo.owner, + repo.name, + path, + content, + message, + branch: repo.defaultBranch, + sha: sha, + ); + } + + static String _safeSegment(String value) { + final cleaned = value.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); + return cleaned.isEmpty ? 'repo' : cleaned; + } +} diff --git a/mobile_agent/lib/services/html_publish_readiness_service.dart b/mobile_agent/lib/services/html_publish_readiness_service.dart new file mode 100644 index 0000000..2b6fcc1 --- /dev/null +++ b/mobile_agent/lib/services/html_publish_readiness_service.dart @@ -0,0 +1,305 @@ +import 'dart:io'; + +enum HtmlPublishIssueSeverity { + blocking, + warning, + info, +} + +class HtmlPublishIssue { + const HtmlPublishIssue({ + required this.code, + required this.title, + required this.detail, + required this.severity, + }); + + final String code; + final String title; + final String detail; + final HtmlPublishIssueSeverity severity; +} + +class HtmlPublishReadinessReport { + const HtmlPublishReadinessReport({ + required this.sourcePath, + required this.checkedAt, + required this.issues, + this.allowRemoteAssets = false, + }); + + final String? sourcePath; + final DateTime checkedAt; + final List issues; + final bool allowRemoteAssets; + + List get blockingIssues => + issues.where((issue) => issue.severity == HtmlPublishIssueSeverity.blocking).toList(growable: false); + + List get warningIssues => + issues.where((issue) => issue.severity == HtmlPublishIssueSeverity.warning).toList(growable: false); + + bool get blocked => blockingIssues.isNotEmpty; + bool get hasWarnings => warningIssues.isNotEmpty; + bool get ready => !blocked; + + String get statusLabel { + if (blocked) return 'Blocked'; + if (hasWarnings) return 'Warnings'; + return 'Ready'; + } + + String toAgentSummary({int maxIssues = 4}) { + final buffer = StringBuffer() + ..write('HTML publish readiness: $statusLabel') + ..write(' (${blockingIssues.length} blockers, ${warningIssues.length} warnings)'); + if (sourcePath != null && sourcePath!.isNotEmpty) { + buffer.write(' for $sourcePath'); + } + final visible = issues.take(maxIssues).toList(growable: false); + if (visible.isNotEmpty) { + buffer.writeln(); + for (final issue in visible) { + final label = switch (issue.severity) { + HtmlPublishIssueSeverity.blocking => 'BLOCK', + HtmlPublishIssueSeverity.warning => 'WARN', + HtmlPublishIssueSeverity.info => 'INFO', + }; + buffer.writeln('- $label ${issue.code}: ${issue.title}'); + } + } + if (issues.length > maxIssues) { + buffer.writeln('- ${issues.length - maxIssues} more checks omitted from chat summary.'); + } + return buffer.toString().trim(); + } +} + +class HtmlPublishReadinessService { + Future checkFile( + String path, { + bool allowRemoteAssets = false, + }) async { + final file = File(path); + if (!await file.exists()) { + return HtmlPublishReadinessReport( + sourcePath: path, + checkedAt: DateTime.now(), + allowRemoteAssets: allowRemoteAssets, + issues: const [ + HtmlPublishIssue( + code: 'file_missing', + title: 'Generated HTML file is missing', + detail: 'MobileCode could not find the generated index.html on this phone.', + severity: HtmlPublishIssueSeverity.blocking, + ), + ], + ); + } + + final html = await file.readAsString(); + return checkHtml( + html, + sourcePath: path, + allowRemoteAssets: allowRemoteAssets, + ); + } + + HtmlPublishReadinessReport checkHtml( + String html, { + String? sourcePath, + bool allowRemoteAssets = false, + }) { + final issues = []; + final lower = html.toLowerCase(); + + if (html.trim().isEmpty) { + issues.add(const HtmlPublishIssue( + code: 'empty_html', + title: 'HTML is empty', + detail: 'The generated artifact has no HTML content to publish.', + severity: HtmlPublishIssueSeverity.blocking, + )); + } + + if (!_looksLikeHtml(lower)) { + issues.add(const HtmlPublishIssue( + code: 'not_html', + title: 'Artifact does not look like HTML', + detail: 'Publish expects a complete HTML document with recognizable html, body, or doctype markers.', + severity: HtmlPublishIssueSeverity.blocking, + )); + } + + if (!_hasNonEmptyTitle(html)) { + issues.add(const HtmlPublishIssue( + code: 'missing_title', + title: 'Missing document title', + detail: 'Add a non-empty so the published GitHub Pages tab has a readable name.', + severity: HtmlPublishIssueSeverity.blocking, + )); + } + + if (!_hasViewportMeta(html)) { + issues.add(const HtmlPublishIssue( + code: 'missing_viewport', + title: 'Missing mobile viewport', + detail: 'Add <meta name="viewport" content="width=device-width, initial-scale=1.0"> for phone-sized screens.', + severity: HtmlPublishIssueSeverity.blocking, + )); + } + + if (_containsPrivateDevicePath(html)) { + issues.add(const HtmlPublishIssue( + code: 'private_path_leak', + title: 'App-private phone path leaked into HTML', + detail: 'Remove /data/user/0, file:///data/user/0, or Android app-private paths before publishing.', + severity: HtmlPublishIssueSeverity.blocking, + )); + } + + final externalRefs = _externalReferences(html); + if (externalRefs.isNotEmpty && !allowRemoteAssets) { + issues.add(HtmlPublishIssue( + code: 'remote_references', + title: 'Remote links or assets detected', + detail: + 'Found ${externalRefs.length} external reference(s). Keep generated demos self-contained, or explicitly allow remote assets before publishing.', + severity: HtmlPublishIssueSeverity.warning, + )); + } + + if (_hasImagesWithoutAlt(html)) { + issues.add(const HtmlPublishIssue( + code: 'missing_image_alt', + title: 'Image alt text is missing', + detail: 'Every <img> should include alt text so the page has basic accessibility.', + severity: HtmlPublishIssueSeverity.warning, + )); + } + + if (_hasUnnamedInteractiveControls(html)) { + issues.add(const HtmlPublishIssue( + code: 'unnamed_controls', + title: 'Interactive controls need labels', + detail: 'Buttons and links should have readable text, aria-label, or title for assistive technology.', + severity: HtmlPublishIssueSeverity.warning, + )); + } + + if (_hasInteractiveControls(html) && !_hasTouchTargetHints(lower)) { + issues.add(const HtmlPublishIssue( + code: 'touch_targets_unclear', + title: 'Touch target sizing is unclear', + detail: 'Add button/link padding or min-height around 44px so controls are comfortable on phones.', + severity: HtmlPublishIssueSeverity.warning, + )); + } + + if (!_hasSemanticStructure(lower)) { + issues.add(const HtmlPublishIssue( + code: 'semantic_structure', + title: 'Semantic page structure is thin', + detail: 'Use main, section, header, nav, or footer to make the generated page easier to inspect and navigate.', + severity: HtmlPublishIssueSeverity.warning, + )); + } + + return HtmlPublishReadinessReport( + sourcePath: sourcePath, + checkedAt: DateTime.now(), + allowRemoteAssets: allowRemoteAssets, + issues: List.unmodifiable(issues), + ); + } + + bool _looksLikeHtml(String lower) { + return lower.contains('<!doctype html') || + lower.contains('<html') || + lower.contains('<body') || + lower.contains('<main') || + lower.contains('<section'); + } + + bool _hasNonEmptyTitle(String html) { + final match = RegExp(r'<title\b[^>]*>([\s\S]*?)<\/title>', caseSensitive: false).firstMatch(html); + final value = match?.group(1)?.replaceAll(RegExp(r'<[^>]+>'), '').trim(); + return value != null && value.isNotEmpty; + } + + bool _hasViewportMeta(String html) { + return RegExp( + r'''<meta\b(?=[^>]*\bname\s*=\s*["']viewport["'])(?=[^>]*\bcontent\s*=)[^>]*>''', + caseSensitive: false, + ).hasMatch(html); + } + + bool _containsPrivateDevicePath(String html) { + return RegExp( + r'(file:\/\/\/data\/user\/0\/|\/data\/user\/0\/|\/storage\/emulated\/0\/Android\/data\/)', + caseSensitive: false, + ).hasMatch(html); + } + + List<String> _externalReferences(String html) { + final refs = <String>[]; + final attrRegex = RegExp( + r'''(?:src|href)\s*=\s*["']([^"']+)["']''', + caseSensitive: false, + ); + for (final match in attrRegex.allMatches(html)) { + final value = match.group(1)?.trim() ?? ''; + if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('//')) { + refs.add(value); + } + } + return refs; + } + + bool _hasImagesWithoutAlt(String html) { + final imgRegex = RegExp(r'<img\b[^>]*>', caseSensitive: false); + for (final match in imgRegex.allMatches(html)) { + final tag = match.group(0) ?? ''; + if (!RegExp(r'\balt\s*=', caseSensitive: false).hasMatch(tag)) return true; + } + return false; + } + + bool _hasUnnamedInteractiveControls(String html) { + final buttonRegex = RegExp(r'<button\b([^>]*)>([\s\S]*?)<\/button>', caseSensitive: false); + for (final match in buttonRegex.allMatches(html)) { + final attrs = match.group(1) ?? ''; + final text = _stripTags(match.group(2) ?? '').trim(); + if (text.isEmpty && !_hasAccessibleName(attrs)) return true; + } + + final linkRegex = RegExp(r'<a\b([^>]*)>([\s\S]*?)<\/a>', caseSensitive: false); + for (final match in linkRegex.allMatches(html)) { + final attrs = match.group(1) ?? ''; + final text = _stripTags(match.group(2) ?? '').trim(); + if (text.isEmpty && !_hasAccessibleName(attrs)) return true; + } + + return false; + } + + bool _hasAccessibleName(String attrs) { + return RegExp(r'''\b(aria-label|title)\s*=\s*["'][^"']+["']''', caseSensitive: false).hasMatch(attrs); + } + + bool _hasInteractiveControls(String html) { + return RegExp(r'''<(button|a)\b|\brole\s*=\s*["']button["']''', caseSensitive: false).hasMatch(html); + } + + bool _hasTouchTargetHints(String lower) { + return RegExp(r'min-(height|width)\s*:\s*(4[4-9]|[5-9]\d)px').hasMatch(lower) || + RegExp(r'padding\s*:\s*([^;]*(1[0-9]|[2-9]\d)px)').hasMatch(lower) || + lower.contains('touch-action'); + } + + bool _hasSemanticStructure(String lower) { + return RegExp(r'<(main|section|header|nav|footer|article)\b').hasMatch(lower); + } + + String _stripTags(String value) => value.replaceAll(RegExp(r'<[^>]+>'), '').replaceAll(' ', ' '); +} diff --git a/mobile_agent/lib/services/memory_service.dart b/mobile_agent/lib/services/memory_service.dart index e92d0a7..0477e52 100644 --- a/mobile_agent/lib/services/memory_service.dart +++ b/mobile_agent/lib/services/memory_service.dart @@ -362,6 +362,138 @@ class UserCorrection { ); } +enum MemoryRuleProposalStatus { pending, accepted, dismissed } + +/// A user-approved operating rule learned from repository patterns. +@immutable +class MemoryRule { + final String id; + final String title; + final String category; + final String rule; + final String source; + final List<String> evidenceRepos; + final DateTime createdAt; + bool enabled; + + MemoryRule({ + required this.id, + required this.title, + required this.category, + required this.rule, + required this.source, + required this.evidenceRepos, + required this.createdAt, + this.enabled = true, + }); + + MemoryRule copyWith({ + String? id, + String? title, + String? category, + String? rule, + String? source, + List<String>? evidenceRepos, + DateTime? createdAt, + bool? enabled, + }) { + return MemoryRule( + id: id ?? this.id, + title: title ?? this.title, + category: category ?? this.category, + rule: rule ?? this.rule, + source: source ?? this.source, + evidenceRepos: evidenceRepos ?? this.evidenceRepos, + createdAt: createdAt ?? this.createdAt, + enabled: enabled ?? this.enabled, + ); + } + + Map<String, dynamic> toJson() => { + 'id': id, + 'title': title, + 'category': category, + 'rule': rule, + 'source': source, + 'evidenceRepos': evidenceRepos, + 'createdAt': createdAt.toIso8601String(), + 'enabled': enabled, + }; + + factory MemoryRule.fromJson(Map<String, dynamic> json) => MemoryRule( + id: json['id'] as String? ?? _newMemoryRuleId(), + title: json['title'] as String? ?? 'Memory rule', + category: json['category'] as String? ?? 'repo-insight', + rule: json['rule'] as String? ?? '', + source: json['source'] as String? ?? 'manual', + evidenceRepos: _jsonStringList(json['evidenceRepos']), + createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now(), + enabled: json['enabled'] as bool? ?? true, + ); +} + +/// A proposed memory rule that needs user approval before persistence. +@immutable +class MemoryRuleProposal { + final String proposalId; + final MemoryRule rule; + final String rationale; + final List<String> evidenceRepos; + final MemoryRuleProposalStatus status; + final DateTime createdAt; + + const MemoryRuleProposal({ + required this.proposalId, + required this.rule, + required this.rationale, + required this.evidenceRepos, + required this.status, + required this.createdAt, + }); + + MemoryRuleProposal copyWith({ + String? proposalId, + MemoryRule? rule, + String? rationale, + List<String>? evidenceRepos, + MemoryRuleProposalStatus? status, + DateTime? createdAt, + }) { + return MemoryRuleProposal( + proposalId: proposalId ?? this.proposalId, + rule: rule ?? this.rule, + rationale: rationale ?? this.rationale, + evidenceRepos: evidenceRepos ?? this.evidenceRepos, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + ); + } + + Map<String, dynamic> toJson() => { + 'proposalId': proposalId, + 'rule': rule.toJson(), + 'rationale': rationale, + 'evidenceRepos': evidenceRepos, + 'status': status.name, + 'createdAt': createdAt.toIso8601String(), + }; + + factory MemoryRuleProposal.fromJson(Map<String, dynamic> json) { + final statusName = json['status'] as String? ?? MemoryRuleProposalStatus.pending.name; + return MemoryRuleProposal( + proposalId: json['proposalId'] as String? ?? _newMemoryRuleProposalId(), + rule: MemoryRule.fromJson(Map<String, dynamic>.from(json['rule'] as Map? ?? const {})), + rationale: json['rationale'] as String? ?? '', + evidenceRepos: _jsonStringList(json['evidenceRepos']), + status: MemoryRuleProposalStatus.values.firstWhere( + (item) => item.name == statusName, + orElse: () => MemoryRuleProposalStatus.pending, + ), + createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now(), + ); + } +} + /// Aggregate memory statistics. @immutable class MemoryStats { @@ -369,6 +501,7 @@ class MemoryStats { final int totalConversations; final int totalErrorPatterns; final int totalSnippets; + final int totalRules; final int memorySizeKB; final DateTime lastSync; @@ -377,23 +510,34 @@ class MemoryStats { required this.totalConversations, required this.totalErrorPatterns, required this.totalSnippets, + this.totalRules = 0, required this.memorySizeKB, required this.lastSync, }); int get totalItems => - totalProjects + totalConversations + totalErrorPatterns + totalSnippets; + totalProjects + totalConversations + totalErrorPatterns + totalSnippets + totalRules; Map<String, dynamic> toJson() => { 'totalProjects': totalProjects, 'totalConversations': totalConversations, 'totalErrorPatterns': totalErrorPatterns, 'totalSnippets': totalSnippets, + 'totalRules': totalRules, 'memorySizeKB': memorySizeKB, 'lastSync': lastSync.toIso8601String(), }; } +List<String> _jsonStringList(dynamic value) { + if (value is! List) return const []; + return value.map((item) => item.toString()).where((item) => item.trim().isNotEmpty).toList(); +} + +String _newMemoryRuleId() => 'memory_rule_${DateTime.now().microsecondsSinceEpoch}'; + +String _newMemoryRuleProposalId() => 'memory_rule_proposal_${DateTime.now().microsecondsSinceEpoch}'; + // ═══════════════════════════════════════════════════════════════════════════ // Memory Service // ═══════════════════════════════════════════════════════════════════════════ @@ -416,6 +560,8 @@ class MemoryService extends ChangeNotifier { final List<ErrorPattern> _errorCache = []; final List<FrequentSnippet> _snippetCache = []; final List<UserCorrection> _correctionCache = []; + final List<MemoryRule> _ruleCache = []; + final List<MemoryRuleProposal> _ruleProposalCache = []; // Storage keys static const String _keyProjects = 'memory_projects'; @@ -424,6 +570,8 @@ class MemoryService extends ChangeNotifier { static const String _keyErrors = 'memory_errors'; static const String _keySnippets = 'memory_snippets'; static const String _keyCorrections = 'memory_corrections'; + static const String _keyRules = 'memory_rules'; + static const String _keyRuleProposals = 'memory_rule_proposals'; static const String _keyLastSync = 'memory_last_sync'; // Singleton @@ -709,6 +857,148 @@ class MemoryService extends ChangeNotifier { notifyListeners(); } + // ── Memory Rules ────────────────────────────────────────────────── + + /// Get user-approved memory rules learned from repo insights. + Future<List<MemoryRule>> getMemoryRules() async { + _ensureInit(); + return List.unmodifiable(_ruleCache); + } + + /// Build a portable rules file, similar to CLAUDE.md / AGENTS.md. + /// + /// Rules are the explicit, user-approved instructions. Memory remains the + /// evidence layer that can propose new rules, but is not injected as a hard + /// instruction until the user accepts it here. + Future<String> buildRulesMarkdown() async { + _ensureInit(); + final enabledRules = _ruleCache.where((rule) => rule.enabled).toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + final buffer = StringBuffer() + ..writeln('# MobileCode Rules') + ..writeln() + ..writeln('This file contains explicit, user-approved operating rules for MobileCode.') + ..writeln('Memory stores evidence and preferences; Rules are the durable instructions the app should follow.') + ..writeln() + ..writeln('Generated: ${DateTime.now().toIso8601String()}') + ..writeln(); + + if (enabledRules.isEmpty) { + buffer + ..writeln('## Active Rules') + ..writeln() + ..writeln('- No approved rules yet.') + ..writeln(); + } else { + buffer + ..writeln('## Active Rules') + ..writeln(); + for (final rule in enabledRules) { + buffer + ..writeln('### ${rule.title}') + ..writeln() + ..writeln('- Category: ${rule.category}') + ..writeln('- Source: ${rule.source}') + ..writeln('- Rule: ${rule.rule}'); + if (rule.evidenceRepos.isNotEmpty) { + buffer.writeln('- Evidence repos: ${rule.evidenceRepos.join(', ')}'); + } + buffer.writeln(); + } + } + + buffer + ..writeln('## Boundary') + ..writeln() + ..writeln('- Do not execute Hook or MCP scripts unless the user explicitly enables and confirms them.') + ..writeln('- Treat Memory as suggestions and evidence, not as hard instructions.') + ..writeln('- Keep mobile workflows lightweight; prefer GitHub Pages and GitHub Actions for heavy remote work.') + ..writeln(); + + return buffer.toString(); + } + + /// Get pending memory rule proposals that still require approval. + Future<List<MemoryRuleProposal>> pendingMemoryRuleProposals() async { + _ensureInit(); + return List.unmodifiable( + _ruleProposalCache.where((p) => p.status == MemoryRuleProposalStatus.pending), + ); + } + + /// Add or update an approved memory rule. + Future<void> upsertMemoryRule(MemoryRule rule) async { + _ensureInit(); + final index = _ruleCache.indexWhere((item) => item.id == rule.id); + if (index >= 0) { + _ruleCache[index] = rule; + } else { + _ruleCache.add(rule); + } + await _persistRules(); + notifyListeners(); + } + + /// Delete an approved memory rule. + Future<void> removeMemoryRule(String id) async { + _ensureInit(); + _ruleCache.removeWhere((rule) => rule.id == id); + await _persistRules(); + notifyListeners(); + } + + /// Store a proposal without accepting it. + Future<void> addMemoryRuleProposal(MemoryRuleProposal proposal) async { + _ensureInit(); + final index = _ruleProposalCache.indexWhere((item) => item.proposalId == proposal.proposalId); + if (index >= 0) { + _ruleProposalCache[index] = proposal; + } else { + _ruleProposalCache.add(proposal); + } + await _persistRuleProposals(); + notifyListeners(); + } + + /// Store multiple proposals without accepting them. + Future<void> addMemoryRuleProposals(List<MemoryRuleProposal> proposals) async { + _ensureInit(); + for (final proposal in proposals) { + final index = _ruleProposalCache.indexWhere((item) => item.proposalId == proposal.proposalId); + if (index >= 0) { + _ruleProposalCache[index] = proposal; + } else { + _ruleProposalCache.add(proposal); + } + } + await _persistRuleProposals(); + notifyListeners(); + } + + /// Accept a proposed rule and persist it to the approved Memory list. + Future<void> acceptMemoryRuleProposal(String proposalId, {MemoryRule? editedRule}) async { + _ensureInit(); + final index = _ruleProposalCache.indexWhere((item) => item.proposalId == proposalId); + if (index == -1) return; + final proposal = _ruleProposalCache[index]; + final rule = editedRule ?? proposal.rule; + await upsertMemoryRule(rule.copyWith(enabled: true)); + _ruleProposalCache[index] = proposal.copyWith(status: MemoryRuleProposalStatus.accepted); + await _persistRuleProposals(); + notifyListeners(); + } + + /// Dismiss a proposed memory rule without persisting it. + Future<void> dismissMemoryRuleProposal(String proposalId) async { + _ensureInit(); + final index = _ruleProposalCache.indexWhere((item) => item.proposalId == proposalId); + if (index == -1) return; + _ruleProposalCache[index] = + _ruleProposalCache[index].copyWith(status: MemoryRuleProposalStatus.dismissed); + await _persistRuleProposals(); + notifyListeners(); + } + // ── Memory Statistics ────────────────────────────────────────────── /// Get aggregate statistics about all stored memories. @@ -719,12 +1009,14 @@ class MemoryService extends ChangeNotifier { size += _conversationCache.length * 2; // ~2KB per conversation size += _snippetCache.length; // ~1KB per snippet size += _correctionCache.length; // ~1KB per correction + size += _ruleCache.length; // ~1KB per approved rule return MemoryStats( totalProjects: _projectCache.length, totalConversations: _conversationCache.length, totalErrorPatterns: _errorCache.length, totalSnippets: _snippetCache.length, + totalRules: _ruleCache.length, memorySizeKB: size, lastSync: DateTime.now(), ); @@ -744,6 +1036,8 @@ class MemoryService extends ChangeNotifier { 'errorPatterns': _errorCache.map((e) => e.toJson()).toList(), 'snippets': _snippetCache.map((s) => s.toJson()).toList(), 'corrections': _correctionCache.map((c) => c.toJson()).toList(), + 'rules': _ruleCache.map((rule) => rule.toJson()).toList(), + 'ruleProposals': _ruleProposalCache.map((proposal) => proposal.toJson()).toList(), }; return jsonEncode(export); } @@ -790,6 +1084,19 @@ class MemoryService extends ChangeNotifier { .addAll(list.map((j) => UserCorrection.fromJson(j as Map<String, dynamic>))); } + if (decoded['rules'] != null) { + final list = decoded['rules'] as List<dynamic>; + _ruleCache.clear(); + _ruleCache.addAll(list.map((j) => MemoryRule.fromJson(j as Map<String, dynamic>))); + } + + if (decoded['ruleProposals'] != null) { + final list = decoded['ruleProposals'] as List<dynamic>; + _ruleProposalCache.clear(); + _ruleProposalCache + .addAll(list.map((j) => MemoryRuleProposal.fromJson(j as Map<String, dynamic>))); + } + await _persistAll(); notifyListeners(); debugPrint('[MemoryService] Memories imported successfully'); @@ -808,6 +1115,8 @@ class MemoryService extends ChangeNotifier { _errorCache.clear(); _snippetCache.clear(); _correctionCache.clear(); + _ruleCache.clear(); + _ruleProposalCache.clear(); await _persistAll(); notifyListeners(); debugPrint('[MemoryService] All memories cleared'); @@ -822,6 +1131,8 @@ class MemoryService extends ChangeNotifier { await _loadErrors(); await _loadSnippets(); await _loadCorrections(); + await _loadRules(); + await _loadRuleProposals(); } Future<void> _persistAll() async { @@ -831,6 +1142,8 @@ class MemoryService extends ChangeNotifier { await _persistErrors(); await _persistSnippets(); await _persistCorrections(); + await _persistRules(); + await _persistRuleProposals(); } Future<void> _loadProjects() async { @@ -958,4 +1271,47 @@ class MemoryService extends ChangeNotifier { debugPrint('[MemoryService] Failed to persist corrections: $e'); } } + + Future<void> _loadRules() async { + try { + final jsonStr = _prefs?.getString(_keyRules); + if (jsonStr == null) return; + final list = jsonDecode(jsonStr) as List<dynamic>; + _ruleCache.clear(); + _ruleCache.addAll(list.map((j) => MemoryRule.fromJson(j as Map<String, dynamic>))); + } catch (e) { + debugPrint('[MemoryService] Failed to load memory rules: $e'); + } + } + + Future<void> _persistRules() async { + try { + final data = _ruleCache.map((rule) => rule.toJson()).toList(); + await _prefs?.setString(_keyRules, jsonEncode(data)); + } catch (e) { + debugPrint('[MemoryService] Failed to persist memory rules: $e'); + } + } + + Future<void> _loadRuleProposals() async { + try { + final jsonStr = _prefs?.getString(_keyRuleProposals); + if (jsonStr == null) return; + final list = jsonDecode(jsonStr) as List<dynamic>; + _ruleProposalCache.clear(); + _ruleProposalCache + .addAll(list.map((j) => MemoryRuleProposal.fromJson(j as Map<String, dynamic>))); + } catch (e) { + debugPrint('[MemoryService] Failed to load memory rule proposals: $e'); + } + } + + Future<void> _persistRuleProposals() async { + try { + final data = _ruleProposalCache.map((proposal) => proposal.toJson()).toList(); + await _prefs?.setString(_keyRuleProposals, jsonEncode(data)); + } catch (e) { + debugPrint('[MemoryService] Failed to persist memory rule proposals: $e'); + } + } } diff --git a/mobile_agent/lib/services/mobile_code_helper_provider.dart b/mobile_agent/lib/services/mobile_code_helper_provider.dart index c3f50b8..6013d23 100644 --- a/mobile_agent/lib/services/mobile_code_helper_provider.dart +++ b/mobile_agent/lib/services/mobile_code_helper_provider.dart @@ -1,448 +1,548 @@ -// lib/services/mobile_code_helper_provider.dart -// RuntimeProvider client for the future MobileCode Helper daemon. - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'runtime_actions.dart'; -import 'runtime_provider.dart'; -import 'termux_service.dart'; - -/// HTTP/NDJSON client for a local MobileCode Helper daemon. -/// -/// Protocol v1: -/// - GET /v1/health -/// - POST /v1/execute -/// - POST /v1/execute/stream (NDJSON lines) -/// - POST /v1/sync -/// - POST /v1/build/web -/// - POST /v1/build/apk -/// - POST /v1/apk/install -/// - POST /v1/app/launch -/// - POST /v1/app/uninstall -/// - POST /v1/task/stop -/// - POST /v1/tasks/:id/stop -/// - GET /v1/tasks/current -/// - GET /v1/tasks -/// - GET /v1/tasks/:id/logs -/// - POST /v1/project/preflight +// lib/services/mobile_code_helper_provider.dart +// RuntimeProvider client for the future MobileCode Helper daemon. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'runtime_actions.dart'; +import 'runtime_provider.dart'; +import 'termux_service.dart'; + +/// HTTP/NDJSON client for a local MobileCode Helper daemon. +/// +/// Protocol v1: +/// - GET /v1/health +/// - POST /v1/execute +/// - POST /v1/execute/stream (NDJSON lines) +/// - POST /v1/sync +/// - POST /v1/build/web +/// - POST /v1/build/apk +/// - POST /v1/apk/install +/// - POST /v1/app/launch +/// - POST /v1/app/uninstall +/// - POST /v1/task/stop +/// - POST /v1/tasks/:id/stop +/// - GET /v1/tasks/current +/// - GET /v1/tasks +/// - GET /v1/tasks/:id/logs +/// - POST /v1/project/preflight class MobileCodeHelperProvider implements RuntimeProvider, RuntimeTaskMonitor, RuntimeTaskController, RuntimeProjectInspector { - final Uri baseUri; - final Duration probeTimeout; - final String? authToken; - final HttpClient _client; - final StreamController<String> _logController = StreamController<String>.broadcast(); - - RuntimeHealth? _lastHealth; - - MobileCodeHelperProvider({ - Uri? baseUri, - this.probeTimeout = const Duration(milliseconds: 700), - this.authToken, - HttpClient? client, - }) : baseUri = baseUri ?? Uri.parse('http://127.0.0.1:8765'), - _client = client ?? HttpClient(); - - @override - RuntimeProviderType get type => RuntimeProviderType.mobileCodeHelper; + final Uri baseUri; + final Duration probeTimeout; + final String? authToken; + final HttpClient _client; + final StreamController<String> _logController = StreamController<String>.broadcast(); + + RuntimeHealth? _lastHealth; + + MobileCodeHelperProvider({ + Uri? baseUri, + this.probeTimeout = const Duration(milliseconds: 700), + this.authToken, + HttpClient? client, + }) : baseUri = baseUri ?? Uri.parse('http://127.0.0.1:8765'), + _client = client ?? HttpClient(); + + @override + RuntimeProviderType get type => RuntimeProviderType.mobileCodeHelper; + + @override + String get name => 'MobileCode Helper'; + + @override + Stream<String> get logStream => _logController.stream; + + @override + Future<void> initialize() async { + _client.connectionTimeout = probeTimeout; + } + + @override + Future<RuntimeCapabilities> capabilities() async { + final health = _lastHealth ?? await healthCheck(); + return health.capabilities; + } + + @override + Future<RuntimeHealth> healthCheck() async { + try { + final payload = await _getJson('/v1/health', timeout: probeTimeout); + final capabilities = _capabilitiesFromJson(_mapValue(payload['capabilities'])); + final health = RuntimeHealth( + type: type, + name: (payload['name'] as String?) ?? name, + available: payload['available'] as bool? ?? true, + ready: payload['ready'] as bool? ?? false, + status: (payload['status'] as String?) ?? 'MobileCode Helper responded.', + capabilities: capabilities, + missingDependencies: _stringList(payload['missingDependencies']), + recoveryActions: _stringList(payload['recoveryActions']), + ); + _lastHealth = health; + return health; + } catch (e) { + final health = RuntimeHealth( + type: type, + name: name, + available: false, + ready: false, + status: 'MobileCode Helper daemon is not reachable at $baseUri.', + capabilities: RuntimeCapabilities.none, + missingDependencies: const ['MobileCode Helper daemon'], + recoveryActions: const [ + 'Install or start the MobileCode Helper APK.', + 'For the prototype, run mobile_agent/tooling/run_mobilecode_helper_daemon.sh in Termux.', + 'Keep the helper foreground service or daemon running before retrying.', + ], + ); + _lastHealth = health; + return health; + } + } + + @override + Future<RuntimeCommandResult> execute( + String command, { + String? workingDir, + Map<String, String>? environment, + Duration? timeout, + }) async { + final stopwatch = Stopwatch()..start(); + final payload = await _postJson('/v1/execute', { + 'command': command, + if (workingDir != null) 'cwd': workingDir, + if (environment != null) 'env': environment, + if (timeout != null) 'timeoutMs': timeout.inMilliseconds, + }, timeout: timeout); + stopwatch.stop(); + + return RuntimeCommandResult( + command: (payload['command'] as String?) ?? command, + stdout: (payload['stdout'] as String?) ?? '', + stderr: (payload['stderr'] as String?) ?? '', + exitCode: (payload['exitCode'] as num?)?.toInt() ?? 1, + duration: Duration( + milliseconds: (payload['durationMs'] as num?)?.toInt() ?? + stopwatch.elapsedMilliseconds, + ), + providerType: type, + taskId: payload['taskId'] as String?, + failureKind: _taskFailureKindFromString(payload['failureKind']?.toString()), + ); + } + + @override + Future<RuntimeTaskSnapshot?> currentTask() async { + final payload = await _getJson('/v1/tasks/current', timeout: const Duration(seconds: 3)); + final taskPayload = payload['task']; + if (taskPayload is Map) { + return _taskSnapshotFromJson(Map<String, dynamic>.from(taskPayload)); + } + + final taskId = payload['taskId']?.toString() ?? ''; + final command = payload['command']?.toString() ?? ''; + final logs = _stringList(payload['logs']); + if (taskId.isEmpty && command.isEmpty && logs.isEmpty) return null; + return RuntimeTaskSnapshot( + taskId: taskId, + status: payload['running'] == true ? RuntimeTaskStatus.running : RuntimeTaskStatus.unknown, + command: command, + logs: logs, + providerType: type, + ); + } + + @override + Future<List<RuntimeTaskSnapshot>> listTasks({int limit = 20}) async { + final payload = await _getJson('/v1/tasks?limit=$limit', timeout: const Duration(seconds: 3)); + final tasks = payload['tasks']; + if (tasks is! List) return const []; + return tasks + .whereType<Map>() + .map((task) => _taskSnapshotFromJson(Map<String, dynamic>.from(task))) + .toList(); + } + + @override + Future<List<String>> taskLogs(String taskId, {int limit = 200}) async { + final safeTaskId = Uri.encodeComponent(taskId); + final payload = await _getJson('/v1/tasks/$safeTaskId/logs?limit=$limit', timeout: const Duration(seconds: 3)); + return _stringList(payload['logs']); + } + + @override + Future<RuntimeProjectProfile> preflightProject( + String projectPath, { + String? packageManager, + }) async { + final payload = await _postJson('/v1/project/preflight', { + 'cwd': projectPath, + if (packageManager != null) 'packageManager': packageManager, + }, timeout: const Duration(seconds: 8)); + final detectedFiles = _stringList(payload['detectedFiles']); + final caps = await capabilities(); + return profileRuntimeProject( + projectPath: payload['cwd']?.toString() ?? projectPath, + probeOutput: detectedFiles.join('\n'), + capabilities: caps, + packageManagerOverride: packageManager, + ); + } + + @override + Stream<String> executeStream( + String command, { + String? workingDir, + Map<String, String>? environment, + }) async* { + final request = await _openPost('/v1/execute/stream', { + 'command': command, + if (workingDir != null) 'cwd': workingDir, + if (environment != null) 'env': environment, + }, accept: 'application/x-ndjson'); + + final response = await request.close(); + if (response.statusCode < 200 || response.statusCode >= 300) { + final body = await response.transform(utf8.decoder).join(); + yield '[error] Helper stream failed: HTTP ${response.statusCode} $body'; + return; + } + + await for (final line in response + .transform(utf8.decoder) + .transform(const LineSplitter())) { + if (line.trim().isEmpty) continue; + final decoded = jsonDecode(line); + if (decoded is! Map<String, dynamic>) { + yield line; + continue; + } + final type = decoded['type'] as String? ?? 'stdout'; + final data = decoded['data']?.toString() ?? ''; + if (type == 'exit') { + final exitCode = decoded['exitCode']?.toString() ?? 'unknown'; + final message = '[exit] Helper command exited with code $exitCode'; + _logController.add(message); + yield message; + } else { + final message = type == 'stderr' ? '[stderr] $data' : data; + _logController.add(message); + yield message; + } + } + } + + @override + Future<RuntimeSyncResult> syncWorkspace({ + required String sourcePath, + required String targetPath, + }) async { + final payload = await _postJson('/v1/sync', { + 'sourcePath': sourcePath, + 'targetPath': targetPath, + }); + return RuntimeSyncResult( + success: payload['success'] as bool? ?? false, + sourcePath: (payload['sourcePath'] as String?) ?? sourcePath, + targetPath: (payload['targetPath'] as String?) ?? targetPath, + error: payload['error'] as String?, + ); + } + + @override + Future<BuildResult> buildWeb(String projectPath) async { + final payload = await _postJson('/v1/build/web', {'projectPath': projectPath}); + return _buildResultFromJson(payload); + } + + @override + Future<BuildResult> buildApk(String projectPath, {BuildMode mode = BuildMode.debug}) async { + final payload = await _postJson('/v1/build/apk', { + 'projectPath': projectPath, + 'mode': mode.name, + }); + return _buildResultFromJson(payload); + } + + @override + Future<InstallResult> installApk(String apkPath) async { + final payload = await _postJson('/v1/apk/install', {'apkPath': apkPath}); + return InstallResult( + success: payload['success'] as bool? ?? false, + packageName: (payload['packageName'] as String?) ?? '', + error: payload['error'] as String?, + ); + } + + @override + Future<void> launchApp(String packageName) async { + await _postJson('/v1/app/launch', {'packageName': packageName}); + } + + @override + Future<void> uninstallApp(String packageName) async { + await _postJson('/v1/app/uninstall', {'packageName': packageName}); + } + + @override + Future<void> stopCurrentTask() async { + await _postJson('/v1/task/stop', const {}); + } + + @override + Future<void> stopTask(String taskId) async { + final safeTaskId = Uri.encodeComponent(taskId.trim()); + if (safeTaskId.isEmpty) { + throw const MobileCodeHelperException('Task ID is required to stop a helper task.'); + } + await _postJson('/v1/tasks/$safeTaskId/stop', const {}); + } + + Uri _resolve(String path) => baseUri.resolve(path); + + Future<Map<String, dynamic>> _getJson(String path, {Duration? timeout}) async { + final request = await _client.getUrl(_resolve(path)).timeout(timeout ?? probeTimeout); + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + _attachAuth(request); + return _decodeJsonResponse(await request.close().timeout(timeout ?? probeTimeout)); + } + + Future<Map<String, dynamic>> _postJson( + String path, + Map<String, dynamic> body, { + Duration? timeout, + }) async { + final request = await _openPost(path, body).timeout(timeout ?? const Duration(seconds: 120)); + return _decodeJsonResponse(await request.close().timeout(timeout ?? const Duration(seconds: 120))); + } + + Future<HttpClientRequest> _openPost( + String path, + Map<String, dynamic> body, { + String accept = 'application/json', + }) async { + final request = await _client.postUrl(_resolve(path)); + request.headers.set(HttpHeaders.acceptHeader, accept); + request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); + _attachAuth(request); + request.write(jsonEncode(body)); + return request; + } + + void _attachAuth(HttpClientRequest request) { + final token = authToken?.trim(); + if (token == null || token.isEmpty) return; + request.headers.set('X-MobileCode-Token', token); + } + + Future<Map<String, dynamic>> _decodeJsonResponse(HttpClientResponse response) async { + final text = await response.transform(utf8.decoder).join(); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw MobileCodeHelperException('HTTP ${response.statusCode}: $text'); + } + if (text.trim().isEmpty) return <String, dynamic>{}; + final decoded = jsonDecode(text); + if (decoded is Map<String, dynamic>) return decoded; + throw const MobileCodeHelperException('Helper response was not a JSON object.'); + } + + RuntimeCapabilities _capabilitiesFromJson(Map<String, dynamic> json) { + return RuntimeCapabilities( + shell: json['shell'] as bool? ?? false, + git: json['git'] as bool? ?? false, + node: json['node'] as bool? ?? false, + python: json['python'] as bool? ?? false, + flutter: json['flutter'] as bool? ?? false, + androidBuild: json['androidBuild'] as bool? ?? false, + pty: json['pty'] as bool? ?? false, + backgroundService: json['backgroundService'] as bool? ?? false, + webViewPreview: json['webViewPreview'] as bool? ?? false, + cloudBuild: json['cloudBuild'] as bool? ?? false, + ); + } + + BuildResult _buildResultFromJson(Map<String, dynamic> json) { + return BuildResult( + success: json['success'] as bool? ?? false, + outputPath: json['outputPath'] as String?, + error: json['error'] as String?, + buildTime: Duration(milliseconds: (json['buildTimeMs'] as num?)?.toInt() ?? 0), + fileSize: (json['fileSize'] as num?)?.toInt() ?? 0, + ); + } + + RuntimeTaskSnapshot _taskSnapshotFromJson(Map<String, dynamic> json) { + final durationMs = (json['durationMs'] as num?)?.toInt(); + return RuntimeTaskSnapshot( + taskId: json['id']?.toString() ?? json['taskId']?.toString() ?? '', + status: _taskStatusFromString(json['status']?.toString()), + command: json['command']?.toString() ?? '', + workingDir: json['cwd']?.toString() ?? json['workingDir']?.toString(), + startedAt: _dateFromEpochMs(json['startedAtMs']), + finishedAt: _dateFromEpochMs(json['finishedAtMs']), + exitCode: (json['exitCode'] as num?)?.toInt(), + duration: durationMs == null ? null : Duration(milliseconds: durationMs), + logs: _stringList(json['logs']), + providerType: type, + error: json['error']?.toString(), + failureKind: _taskFailureKindFromString(json['failureKind']?.toString()), + ); + } + + RuntimeTaskStatus _taskStatusFromString(String? value) { + return switch (value) { + 'queued' => RuntimeTaskStatus.queued, + 'running' => RuntimeTaskStatus.running, + 'succeeded' => RuntimeTaskStatus.succeeded, + 'failed' => RuntimeTaskStatus.failed, + 'cancelled' => RuntimeTaskStatus.cancelled, + 'timedOut' || 'timed_out' || 'timeout' => RuntimeTaskStatus.timedOut, + 'lost' => RuntimeTaskStatus.lost, + _ => RuntimeTaskStatus.unknown, + }; + } + + RuntimeTaskFailureKind _taskFailureKindFromString(String? value) { + if (value == null || value == 'none') return RuntimeTaskFailureKind.none; + return switch (value) { + 'timeout' || 'timedOut' || 'timed_out' => RuntimeTaskFailureKind.timeout, + 'cancelled' => RuntimeTaskFailureKind.cancelled, + 'dependencyMissing' || 'dependency_missing' => RuntimeTaskFailureKind.dependencyMissing, + 'commandBlocked' || 'command_blocked' => RuntimeTaskFailureKind.commandBlocked, + 'cwdOutsideWorkspace' || 'cwd_outside_workspace' => RuntimeTaskFailureKind.cwdOutsideWorkspace, + 'authFailed' || 'auth_failed' => RuntimeTaskFailureKind.authFailed, + 'processFailed' || 'process_failed' => RuntimeTaskFailureKind.processFailed, + 'runtimeLost' || 'runtime_lost' || 'lost' => RuntimeTaskFailureKind.runtimeLost, + _ => RuntimeTaskFailureKind.unknown, + }; + } + + DateTime? _dateFromEpochMs(Object? value) { + final millis = (value as num?)?.toInt(); + if (millis == null || millis <= 0) return null; + return DateTime.fromMillisecondsSinceEpoch(millis); + } + + Map<String, dynamic> _mapValue(Object? value) { + if (value is Map<String, dynamic>) return value; + if (value is Map) { + return value.map((key, value) => MapEntry(key.toString(), value)); + } + return <String, dynamic>{}; + } + + List<String> _stringList(Object? value) { + if (value is List) return value.map((item) => item.toString()).toList(); + return const []; + } +} - @override - String get name => 'MobileCode Helper'; +/// RuntimeProvider facade for the Termux-hosted MobileCode daemon prototype. +/// +/// The daemon speaks the same protocol as MobileCode Helper, but it runs inside +/// Termux so commands like `git clone` execute with Termux binaries and PATH. +class TermuxDaemonProvider extends MobileCodeHelperProvider { + String? _workspaceRoot; + + TermuxDaemonProvider({ + super.baseUri, + super.probeTimeout, + super.authToken, + super.client, + }); @override - Stream<String> get logStream => _logController.stream; + RuntimeProviderType get type => RuntimeProviderType.externalTermux; @override - Future<void> initialize() async { - _client.connectionTimeout = probeTimeout; - } + String get name => 'External Termux daemon'; - @override - Future<RuntimeCapabilities> capabilities() async { - final health = _lastHealth ?? await healthCheck(); - return health.capabilities; - } + String? get workspaceRoot => _workspaceRoot; @override Future<RuntimeHealth> healthCheck() async { try { final payload = await _getJson('/v1/health', timeout: probeTimeout); + final payloadName = payload['name']?.toString() ?? ''; + final statusText = payload['status']?.toString() ?? ''; + final runtimeKind = payload['runtimeKind']?.toString() ?? ''; + _workspaceRoot = payload['workspaceRoot']?.toString(); + final legacyPrototypeDaemon = runtimeKind.isEmpty && + payloadName.toLowerCase().contains('prototype'); + final isTermuxDaemon = runtimeKind == 'termuxDaemon' || + payload['termux'] == true || + legacyPrototypeDaemon || + statusText.toLowerCase().contains('termux'); + + if (!isTermuxDaemon) { + final health = RuntimeHealth( + type: type, + name: name, + available: false, + ready: false, + status: payloadName.isEmpty + ? 'External Termux daemon is not the service at $baseUri.' + : 'Detected $payloadName at $baseUri; this is not the Termux daemon.', + capabilities: RuntimeCapabilities.none, + missingDependencies: const ['Termux daemon'], + recoveryActions: const [ + 'Start mobile_agent/tooling/run_mobilecode_helper_daemon.sh inside Termux.', + 'Use MobileCode Helper APK for foreground-service execution instead.', + ], + ); + _lastHealth = health; + return health; + } + final capabilities = _capabilitiesFromJson(_mapValue(payload['capabilities'])); + final missing = [..._stringList(payload['missingDependencies'])]; + final recovery = [..._stringList(payload['recoveryActions'])]; + if (!capabilities.git) { + missing.add('git in Termux'); + recovery.add('Run pkg install git in Termux, then restart or refresh the MobileCode daemon.'); + } + final health = RuntimeHealth( type: type, - name: (payload['name'] as String?) ?? name, + name: name, available: payload['available'] as bool? ?? true, - ready: payload['ready'] as bool? ?? false, - status: (payload['status'] as String?) ?? 'MobileCode Helper responded.', + ready: (payload['ready'] as bool? ?? false) && capabilities.shell && capabilities.git, + status: capabilities.git + ? 'Termux daemon is running; git clone can execute through Termux.' + : 'Termux daemon is running, but git is missing from Termux.', capabilities: capabilities, - missingDependencies: _stringList(payload['missingDependencies']), - recoveryActions: _stringList(payload['recoveryActions']), + missingDependencies: missing, + recoveryActions: recovery, ); _lastHealth = health; return health; - } catch (e) { + } catch (_) { final health = RuntimeHealth( type: type, name: name, available: false, ready: false, - status: 'MobileCode Helper daemon is not reachable at $baseUri.', + status: 'External Termux daemon is not reachable at $baseUri.', capabilities: RuntimeCapabilities.none, - missingDependencies: const ['MobileCode Helper daemon'], + missingDependencies: const ['Termux daemon'], recoveryActions: const [ - 'Install or start the MobileCode Helper APK.', - 'For the prototype, run mobile_agent/tooling/run_mobilecode_helper_daemon.sh in Termux.', - 'Keep the helper foreground service or daemon running before retrying.', + 'Open Termux and run mobile_agent/tooling/run_mobilecode_helper_daemon.sh.', + 'Install git with pkg install git if /v1/health reports git=false.', + 'Keep Termux running in the foreground while cloning.', ], ); _lastHealth = health; return health; } } - - @override - Future<RuntimeCommandResult> execute( - String command, { - String? workingDir, - Map<String, String>? environment, - Duration? timeout, - }) async { - final stopwatch = Stopwatch()..start(); - final payload = await _postJson('/v1/execute', { - 'command': command, - if (workingDir != null) 'cwd': workingDir, - if (environment != null) 'env': environment, - if (timeout != null) 'timeoutMs': timeout.inMilliseconds, - }, timeout: timeout); - stopwatch.stop(); - - return RuntimeCommandResult( - command: (payload['command'] as String?) ?? command, - stdout: (payload['stdout'] as String?) ?? '', - stderr: (payload['stderr'] as String?) ?? '', - exitCode: (payload['exitCode'] as num?)?.toInt() ?? 1, - duration: Duration( - milliseconds: (payload['durationMs'] as num?)?.toInt() ?? - stopwatch.elapsedMilliseconds, - ), - providerType: type, - taskId: payload['taskId'] as String?, - failureKind: _taskFailureKindFromString(payload['failureKind']?.toString()), - ); - } - - @override - Future<RuntimeTaskSnapshot?> currentTask() async { - final payload = await _getJson('/v1/tasks/current', timeout: const Duration(seconds: 3)); - final taskPayload = payload['task']; - if (taskPayload is Map) { - return _taskSnapshotFromJson(Map<String, dynamic>.from(taskPayload)); - } - - final taskId = payload['taskId']?.toString() ?? ''; - final command = payload['command']?.toString() ?? ''; - final logs = _stringList(payload['logs']); - if (taskId.isEmpty && command.isEmpty && logs.isEmpty) return null; - return RuntimeTaskSnapshot( - taskId: taskId, - status: payload['running'] == true ? RuntimeTaskStatus.running : RuntimeTaskStatus.unknown, - command: command, - logs: logs, - providerType: type, - ); - } - - @override - Future<List<RuntimeTaskSnapshot>> listTasks({int limit = 20}) async { - final payload = await _getJson('/v1/tasks?limit=$limit', timeout: const Duration(seconds: 3)); - final tasks = payload['tasks']; - if (tasks is! List) return const []; - return tasks - .whereType<Map>() - .map((task) => _taskSnapshotFromJson(Map<String, dynamic>.from(task))) - .toList(); - } - - @override - Future<List<String>> taskLogs(String taskId, {int limit = 200}) async { - final safeTaskId = Uri.encodeComponent(taskId); - final payload = await _getJson('/v1/tasks/$safeTaskId/logs?limit=$limit', timeout: const Duration(seconds: 3)); - return _stringList(payload['logs']); - } - - @override - Future<RuntimeProjectProfile> preflightProject( - String projectPath, { - String? packageManager, - }) async { - final payload = await _postJson('/v1/project/preflight', { - 'cwd': projectPath, - if (packageManager != null) 'packageManager': packageManager, - }, timeout: const Duration(seconds: 8)); - final detectedFiles = _stringList(payload['detectedFiles']); - final caps = await capabilities(); - return profileRuntimeProject( - projectPath: payload['cwd']?.toString() ?? projectPath, - probeOutput: detectedFiles.join('\n'), - capabilities: caps, - packageManagerOverride: packageManager, - ); - } - - @override - Stream<String> executeStream( - String command, { - String? workingDir, - Map<String, String>? environment, - }) async* { - final request = await _openPost('/v1/execute/stream', { - 'command': command, - if (workingDir != null) 'cwd': workingDir, - if (environment != null) 'env': environment, - }, accept: 'application/x-ndjson'); - - final response = await request.close(); - if (response.statusCode < 200 || response.statusCode >= 300) { - final body = await response.transform(utf8.decoder).join(); - yield '[error] Helper stream failed: HTTP ${response.statusCode} $body'; - return; - } - - await for (final line in response - .transform(utf8.decoder) - .transform(const LineSplitter())) { - if (line.trim().isEmpty) continue; - final decoded = jsonDecode(line); - if (decoded is! Map<String, dynamic>) { - yield line; - continue; - } - final type = decoded['type'] as String? ?? 'stdout'; - final data = decoded['data']?.toString() ?? ''; - if (type == 'exit') { - final exitCode = decoded['exitCode']?.toString() ?? 'unknown'; - final message = '[exit] Helper command exited with code $exitCode'; - _logController.add(message); - yield message; - } else { - final message = type == 'stderr' ? '[stderr] $data' : data; - _logController.add(message); - yield message; - } - } - } - - @override - Future<RuntimeSyncResult> syncWorkspace({ - required String sourcePath, - required String targetPath, - }) async { - final payload = await _postJson('/v1/sync', { - 'sourcePath': sourcePath, - 'targetPath': targetPath, - }); - return RuntimeSyncResult( - success: payload['success'] as bool? ?? false, - sourcePath: (payload['sourcePath'] as String?) ?? sourcePath, - targetPath: (payload['targetPath'] as String?) ?? targetPath, - error: payload['error'] as String?, - ); - } - - @override - Future<BuildResult> buildWeb(String projectPath) async { - final payload = await _postJson('/v1/build/web', {'projectPath': projectPath}); - return _buildResultFromJson(payload); - } - - @override - Future<BuildResult> buildApk(String projectPath, {BuildMode mode = BuildMode.debug}) async { - final payload = await _postJson('/v1/build/apk', { - 'projectPath': projectPath, - 'mode': mode.name, - }); - return _buildResultFromJson(payload); - } - - @override - Future<InstallResult> installApk(String apkPath) async { - final payload = await _postJson('/v1/apk/install', {'apkPath': apkPath}); - return InstallResult( - success: payload['success'] as bool? ?? false, - packageName: (payload['packageName'] as String?) ?? '', - error: payload['error'] as String?, - ); - } - - @override - Future<void> launchApp(String packageName) async { - await _postJson('/v1/app/launch', {'packageName': packageName}); - } - - @override - Future<void> uninstallApp(String packageName) async { - await _postJson('/v1/app/uninstall', {'packageName': packageName}); - } - - @override - Future<void> stopCurrentTask() async { - await _postJson('/v1/task/stop', const {}); - } - - @override - Future<void> stopTask(String taskId) async { - final safeTaskId = Uri.encodeComponent(taskId.trim()); - if (safeTaskId.isEmpty) { - throw const MobileCodeHelperException('Task ID is required to stop a helper task.'); - } - await _postJson('/v1/tasks/$safeTaskId/stop', const {}); - } - - Uri _resolve(String path) => baseUri.resolve(path); - - Future<Map<String, dynamic>> _getJson(String path, {Duration? timeout}) async { - final request = await _client.getUrl(_resolve(path)).timeout(timeout ?? probeTimeout); - request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - _attachAuth(request); - return _decodeJsonResponse(await request.close().timeout(timeout ?? probeTimeout)); - } - - Future<Map<String, dynamic>> _postJson( - String path, - Map<String, dynamic> body, { - Duration? timeout, - }) async { - final request = await _openPost(path, body).timeout(timeout ?? const Duration(seconds: 120)); - return _decodeJsonResponse(await request.close().timeout(timeout ?? const Duration(seconds: 120))); - } - - Future<HttpClientRequest> _openPost( - String path, - Map<String, dynamic> body, { - String accept = 'application/json', - }) async { - final request = await _client.postUrl(_resolve(path)); - request.headers.set(HttpHeaders.acceptHeader, accept); - request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); - _attachAuth(request); - request.write(jsonEncode(body)); - return request; - } - - void _attachAuth(HttpClientRequest request) { - final token = authToken?.trim(); - if (token == null || token.isEmpty) return; - request.headers.set('X-MobileCode-Token', token); - } - - Future<Map<String, dynamic>> _decodeJsonResponse(HttpClientResponse response) async { - final text = await response.transform(utf8.decoder).join(); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw MobileCodeHelperException('HTTP ${response.statusCode}: $text'); - } - if (text.trim().isEmpty) return <String, dynamic>{}; - final decoded = jsonDecode(text); - if (decoded is Map<String, dynamic>) return decoded; - throw const MobileCodeHelperException('Helper response was not a JSON object.'); - } - - RuntimeCapabilities _capabilitiesFromJson(Map<String, dynamic> json) { - return RuntimeCapabilities( - shell: json['shell'] as bool? ?? false, - git: json['git'] as bool? ?? false, - node: json['node'] as bool? ?? false, - python: json['python'] as bool? ?? false, - flutter: json['flutter'] as bool? ?? false, - androidBuild: json['androidBuild'] as bool? ?? false, - pty: json['pty'] as bool? ?? false, - backgroundService: json['backgroundService'] as bool? ?? false, - webViewPreview: json['webViewPreview'] as bool? ?? false, - cloudBuild: json['cloudBuild'] as bool? ?? false, - ); - } - - BuildResult _buildResultFromJson(Map<String, dynamic> json) { - return BuildResult( - success: json['success'] as bool? ?? false, - outputPath: json['outputPath'] as String?, - error: json['error'] as String?, - buildTime: Duration(milliseconds: (json['buildTimeMs'] as num?)?.toInt() ?? 0), - fileSize: (json['fileSize'] as num?)?.toInt() ?? 0, - ); - } - - RuntimeTaskSnapshot _taskSnapshotFromJson(Map<String, dynamic> json) { - final durationMs = (json['durationMs'] as num?)?.toInt(); - return RuntimeTaskSnapshot( - taskId: json['id']?.toString() ?? json['taskId']?.toString() ?? '', - status: _taskStatusFromString(json['status']?.toString()), - command: json['command']?.toString() ?? '', - workingDir: json['cwd']?.toString() ?? json['workingDir']?.toString(), - startedAt: _dateFromEpochMs(json['startedAtMs']), - finishedAt: _dateFromEpochMs(json['finishedAtMs']), - exitCode: (json['exitCode'] as num?)?.toInt(), - duration: durationMs == null ? null : Duration(milliseconds: durationMs), - logs: _stringList(json['logs']), - providerType: type, - error: json['error']?.toString(), - failureKind: _taskFailureKindFromString(json['failureKind']?.toString()), - ); - } - - RuntimeTaskStatus _taskStatusFromString(String? value) { - return switch (value) { - 'queued' => RuntimeTaskStatus.queued, - 'running' => RuntimeTaskStatus.running, - 'succeeded' => RuntimeTaskStatus.succeeded, - 'failed' => RuntimeTaskStatus.failed, - 'cancelled' => RuntimeTaskStatus.cancelled, - 'timedOut' || 'timed_out' || 'timeout' => RuntimeTaskStatus.timedOut, - 'lost' => RuntimeTaskStatus.lost, - _ => RuntimeTaskStatus.unknown, - }; - } - - RuntimeTaskFailureKind _taskFailureKindFromString(String? value) { - if (value == null || value == 'none') return RuntimeTaskFailureKind.none; - return switch (value) { - 'timeout' || 'timedOut' || 'timed_out' => RuntimeTaskFailureKind.timeout, - 'cancelled' => RuntimeTaskFailureKind.cancelled, - 'dependencyMissing' || 'dependency_missing' => RuntimeTaskFailureKind.dependencyMissing, - 'commandBlocked' || 'command_blocked' => RuntimeTaskFailureKind.commandBlocked, - 'cwdOutsideWorkspace' || 'cwd_outside_workspace' => RuntimeTaskFailureKind.cwdOutsideWorkspace, - 'authFailed' || 'auth_failed' => RuntimeTaskFailureKind.authFailed, - 'processFailed' || 'process_failed' => RuntimeTaskFailureKind.processFailed, - 'runtimeLost' || 'runtime_lost' || 'lost' => RuntimeTaskFailureKind.runtimeLost, - _ => RuntimeTaskFailureKind.unknown, - }; - } - - DateTime? _dateFromEpochMs(Object? value) { - final millis = (value as num?)?.toInt(); - if (millis == null || millis <= 0) return null; - return DateTime.fromMillisecondsSinceEpoch(millis); - } - - Map<String, dynamic> _mapValue(Object? value) { - if (value is Map<String, dynamic>) return value; - if (value is Map) { - return value.map((key, value) => MapEntry(key.toString(), value)); - } - return <String, dynamic>{}; - } - - List<String> _stringList(Object? value) { - if (value is List) return value.map((item) => item.toString()).toList(); - return const []; - } } class MobileCodeHelperException implements Exception { final String message; - - const MobileCodeHelperException(this.message); - - @override - String toString() => 'MobileCodeHelperException: $message'; -} + + const MobileCodeHelperException(this.message); + + @override + String toString() => 'MobileCodeHelperException: $message'; +} diff --git a/mobile_agent/lib/services/model_provider_polish_service.dart b/mobile_agent/lib/services/model_provider_polish_service.dart new file mode 100644 index 0000000..0700e38 --- /dev/null +++ b/mobile_agent/lib/services/model_provider_polish_service.dart @@ -0,0 +1,510 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'token_usage_service.dart'; + +enum ProviderPolishSource { provider, fallback } + +enum _ProviderFlavor { openAi, anthropic } + +class SkillPolishDraft { + const SkillPolishDraft({ + required this.name, + required this.description, + required this.tags, + required this.actions, + required this.prompts, + }); + + final String name; + final String description; + final List<String> tags; + final List<String> actions; + final List<String> prompts; + + SkillPolishDraft copyWith({ + String? name, + String? description, + List<String>? tags, + List<String>? actions, + List<String>? prompts, + }) { + return SkillPolishDraft( + name: name ?? this.name, + description: description ?? this.description, + tags: tags ?? this.tags, + actions: actions ?? this.actions, + prompts: prompts ?? this.prompts, + ); + } +} + +class SkillPolishResult { + const SkillPolishResult({ + required this.draft, + required this.source, + this.fallbackReason, + }); + + final SkillPolishDraft draft; + final ProviderPolishSource source; + final String? fallbackReason; + + bool get usedProvider => source == ProviderPolishSource.provider; +} + +class _ProviderConfig { + const _ProviderConfig({ + required this.baseUrl, + required this.apiKey, + required this.model, + }); + + final String baseUrl; + final String apiKey; + final String model; + + bool get isUsable => baseUrl.trim().isNotEmpty && apiKey.trim().isNotEmpty; +} + +class ModelProviderPolishService { + ModelProviderPolishService._(); + + static final instance = ModelProviderPolishService._(); + + static const _defaultBaseUrl = 'https://token-plan-cn.xiaomimimo.com/anthropic'; + static const _defaultModel = 'mimo-v2.5-pro'; + static const _managedProviderEnabled = bool.fromEnvironment('MOBILECODE_MANAGED_PROVIDER'); + static const _managedBaseUrl = String.fromEnvironment( + 'MOBILECODE_MANAGED_BASE_URL', + defaultValue: _defaultBaseUrl, + ); + static const _managedModel = String.fromEnvironment( + 'MOBILECODE_MANAGED_MODEL', + defaultValue: _defaultModel, + ); + static const _managedApiKey = String.fromEnvironment('MOBILECODE_MANAGED_API_KEY'); + + static const _baseUrlKey = 'mobilecode.baseUrl'; + static const _apiKeyKey = 'mobilecode.apiKey'; + static const _modelKey = 'mobilecode.model'; + static const _providerModeKey = 'mobilecode.providerMode'; + + Future<SkillPolishResult> polishSkillDraft(SkillPolishDraft draft) async { + final fallback = _fallbackSkillDraft(draft); + final config = await _loadProviderConfig(); + if (!config.isUsable) { + return SkillPolishResult( + draft: fallback, + source: ProviderPolishSource.fallback, + fallbackReason: config.baseUrl.trim().isEmpty + ? 'Provider Base URL is empty.' + : 'Provider API key is empty.', + ); + } + + final flavor = _detectFlavor(config.baseUrl, config.model); + final started = DateTime.now(); + final systemPrompt = _skillPolishSystemPrompt; + final userPrompt = _skillPolishUserPrompt(draft); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 12); + + try { + final request = await client + .postUrl(flavor == _ProviderFlavor.anthropic + ? _anthropicMessagesUri(config.baseUrl) + : _openAiChatUri(config.baseUrl)) + .timeout(const Duration(seconds: 12)); + request.headers.contentType = ContentType.json; + if (flavor == _ProviderFlavor.anthropic) { + request.headers.set('anthropic-version', '2023-06-01'); + request.headers.set('x-api-key', config.apiKey); + } + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer ${config.apiKey}'); + request.write(jsonEncode(_requestBody(flavor, config.model, systemPrompt, userPrompt))); + + final response = await request.close().timeout(const Duration(seconds: 80)); + final body = await utf8.decodeStream(response); + if (response.statusCode < 200 || response.statusCode >= 300) { + await _recordUsage( + flavor: flavor, + model: config.model, + durationMs: DateTime.now().difference(started).inMilliseconds, + success: false, + inputChars: systemPrompt.length + userPrompt.length, + outputChars: 0, + ); + return SkillPolishResult( + draft: fallback, + source: ProviderPolishSource.fallback, + fallbackReason: 'Provider HTTP ${response.statusCode}: ${_compact(body, limit: 180)}', + ); + } + + final text = _extractProviderText(body); + final providerDraft = _parseProviderDraft(text, fallback); + await _recordUsage( + flavor: flavor, + model: config.model, + durationMs: DateTime.now().difference(started).inMilliseconds, + success: true, + usage: _providerUsageFromBody(flavor, body), + inputChars: systemPrompt.length + userPrompt.length, + outputChars: text.length, + ); + return SkillPolishResult( + draft: providerDraft, + source: ProviderPolishSource.provider, + ); + } on TimeoutException { + await _recordUsage( + flavor: flavor, + model: config.model, + durationMs: DateTime.now().difference(started).inMilliseconds, + success: false, + inputChars: systemPrompt.length + userPrompt.length, + outputChars: 0, + ); + return SkillPolishResult( + draft: fallback, + source: ProviderPolishSource.fallback, + fallbackReason: 'Provider timed out while polishing the skill draft.', + ); + } on SocketException catch (error) { + await _recordUsage( + flavor: flavor, + model: config.model, + durationMs: DateTime.now().difference(started).inMilliseconds, + success: false, + inputChars: systemPrompt.length + userPrompt.length, + outputChars: 0, + ); + return SkillPolishResult( + draft: fallback, + source: ProviderPolishSource.fallback, + fallbackReason: 'Network error: ${_friendlySocketError(error)}', + ); + } on Object catch (error) { + await _recordUsage( + flavor: flavor, + model: config.model, + durationMs: DateTime.now().difference(started).inMilliseconds, + success: false, + inputChars: systemPrompt.length + userPrompt.length, + outputChars: 0, + ); + return SkillPolishResult( + draft: fallback, + source: ProviderPolishSource.fallback, + fallbackReason: _compact(error.toString(), limit: 180), + ); + } finally { + client.close(force: true); + } + } + + Future<_ProviderConfig> _loadProviderConfig() async { + final prefs = await SharedPreferences.getInstance(); + final useManaged = _managedProviderEnabled && prefs.getString(_providerModeKey) != 'custom'; + if (useManaged) { + return const _ProviderConfig( + baseUrl: _managedBaseUrl, + apiKey: _managedApiKey, + model: _managedModel, + ); + } + return _ProviderConfig( + baseUrl: _savedOrDefault(prefs.getString(_baseUrlKey), _defaultBaseUrl), + apiKey: prefs.getString(_apiKeyKey) ?? '', + model: _savedOrDefault(prefs.getString(_modelKey), _defaultModel), + ); + } + + Map<String, dynamic> _requestBody( + _ProviderFlavor flavor, + String model, + String systemPrompt, + String userPrompt, + ) { + final resolvedModel = model.trim().isEmpty + ? (flavor == _ProviderFlavor.anthropic ? _defaultModel : 'gpt-4o-mini') + : model.trim(); + if (flavor == _ProviderFlavor.anthropic) { + return { + 'model': resolvedModel, + 'system': systemPrompt, + 'max_tokens': 900, + 'temperature': 0.2, + 'messages': [ + {'role': 'user', 'content': userPrompt}, + ], + }; + } + return { + 'model': resolvedModel, + 'temperature': 0.2, + 'messages': [ + {'role': 'system', 'content': systemPrompt}, + {'role': 'user', 'content': userPrompt}, + ], + }; + } + + Future<void> _recordUsage({ + required _ProviderFlavor flavor, + required String model, + required int durationMs, + required bool success, + TokenUsageSnapshot? usage, + int inputChars = 0, + int outputChars = 0, + }) async { + try { + await TokenUsageService.instance.recordCompleted( + provider: flavor == _ProviderFlavor.anthropic ? 'anthropic' : 'openai', + model: model, + endpoint: 'skill_polish', + durationMs: durationMs, + success: success, + usage: usage, + inputChars: inputChars, + outputChars: outputChars, + ); + } catch (_) { + // Usage telemetry should never block the user-facing polish result. + } + } + + SkillPolishDraft _parseProviderDraft(String text, SkillPolishDraft fallback) { + final decoded = jsonDecode(_stripJsonFence(text)); + if (decoded is! Map<String, dynamic>) { + throw const FormatException('Provider did not return a JSON object.'); + } + return SkillPolishDraft( + name: _nonEmpty(decoded['name'], fallback.name), + description: _nonEmpty(decoded['description'], fallback.description), + tags: _stringList(decoded['tags'], fallback.tags), + actions: _stringList(decoded['actions'], fallback.actions), + prompts: _stringList(decoded['prompts'], fallback.prompts), + ); + } + + SkillPolishDraft _fallbackSkillDraft(SkillPolishDraft draft) { + final rawName = draft.name.trim(); + final rawDescription = draft.description.trim(); + final inferredName = rawName.isNotEmpty + ? rawName + : rawDescription.isNotEmpty + ? '${rawDescription.split(RegExp(r'\s+')).take(5).join(' ')} Skill' + : 'MobileCode Custom Skill'; + + final tags = <String>{...draft.tags, 'custom', 'user-created'}; + final slug = _slugify(inferredName); + final prompts = draft.prompts.isNotEmpty + ? draft.prompts + : ['${slug.isEmpty ? 'custom_skill' : slug}.guidance']; + final description = rawDescription.isEmpty + ? 'A user-created MobileCode skill for reusable prompt guidance, guarded actions, and mobile-first coding workflow preferences.' + : rawDescription.toLowerCase().contains('mobilecode') + ? rawDescription + : '$rawDescription\n\nStandardized: Use this skill as reviewed MobileCode guidance. It may add prompt context and structured action labels, but it must not execute code without user confirmation.'; + + return SkillPolishDraft( + name: inferredName, + description: description, + tags: tags.toList(), + actions: draft.actions, + prompts: prompts, + ); + } +} + +const _skillPolishSystemPrompt = ''' +You are MobileCode Skill Standardizer. + +Turn a user's rough custom skill idea into a safe, production-ready MobileCode Skill draft. +Return JSON only. Do not include markdown fences. + +Schema: +{ + "name": "2-6 words", + "description": "one concise paragraph explaining what this skill contributes", + "tags": ["custom", "html", "mobile"], + "actions": ["optional.structured_action_id"], + "prompts": ["optional.prompt_gate_id"] +} + +Rules: +- This is a local MobileCode skill, not a script runtime. +- Do not invent shell commands, background services, or MCP execution. +- Prefer mobile-first HTML/UI, GitHub Pages, runtime diagnostics, testing, release QA, or repository workflows when relevant. +- Keep action and prompt IDs lowercase snake/dot style, such as html.mobile_review or github.pages_publish. +- If the user intent is vague, keep the skill safe and prompt-oriented. +'''; + +String _skillPolishUserPrompt(SkillPolishDraft draft) { + return jsonEncode({ + 'name': draft.name, + 'description': draft.description, + 'tags': draft.tags, + 'actions': draft.actions, + 'prompts': draft.prompts, + }); +} + +_ProviderFlavor _detectFlavor(String baseUrl, String model) { + final probe = '$baseUrl $model'.toLowerCase(); + if (probe.contains('anthropic') || probe.contains('claude') || probe.contains('mimo-')) { + return _ProviderFlavor.anthropic; + } + return _ProviderFlavor.openAi; +} + +String _normalizedBaseUrl(String baseUrl) { + return baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; +} + +String _savedOrDefault(String? value, String fallback) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? fallback : trimmed; +} + +Uri _parseBaseUrl(String baseUrl) { + final uri = Uri.parse(_normalizedBaseUrl(baseUrl)); + if (!uri.hasScheme || uri.host.isEmpty) { + throw const FormatException('Invalid provider URL'); + } + return uri; +} + +Uri _openAiChatUri(String baseUrl) { + final normalized = _normalizedBaseUrl(baseUrl); + final uri = _parseBaseUrl(normalized); + if (normalized.endsWith('/chat/completions')) return uri; + return Uri.parse('$normalized/chat/completions'); +} + +Uri _anthropicMessagesUri(String baseUrl) { + final normalized = _normalizedBaseUrl(baseUrl); + final uri = _parseBaseUrl(normalized); + if (normalized.endsWith('/v1/messages') || normalized.endsWith('/messages')) { + return uri; + } + if (normalized.endsWith('/v1')) { + return Uri.parse('$normalized/messages'); + } + return Uri.parse('$normalized/v1/messages'); +} + +String _extractProviderText(String body) { + final decoded = jsonDecode(body); + if (decoded is Map<String, dynamic>) { + final choices = decoded['choices']; + if (choices is List && choices.isNotEmpty) { + final first = choices.first; + if (first is Map<String, dynamic>) { + final message = first['message']; + if (message is Map<String, dynamic>) { + final content = message['content']; + if (content is String && content.trim().isNotEmpty) return content.trim(); + } + final text = first['text']; + if (text is String && text.trim().isNotEmpty) return text.trim(); + } + } + final content = decoded['content']; + if (content is List && content.isNotEmpty) { + final parts = <String>[]; + for (final item in content) { + if (item is Map<String, dynamic>) { + final text = item['text']; + if (text is String && text.trim().isNotEmpty) parts.add(text.trim()); + } + } + if (parts.isNotEmpty) return parts.join('\n\n'); + } + } + throw const FormatException('Provider returned no text content.'); +} + +TokenUsageSnapshot _providerUsageFromBody(_ProviderFlavor flavor, String body) { + try { + final decoded = jsonDecode(body); + if (decoded is Map<String, dynamic>) { + return flavor == _ProviderFlavor.anthropic + ? TokenUsageService.parseAnthropicUsage(decoded) + : TokenUsageService.parseOpenAiUsage(decoded); + } + } catch (_) { + // Providers without usage metadata fall back to local estimation. + } + return TokenUsageSnapshot.empty; +} + +String _stripJsonFence(String value) { + var text = value.trim(); + if (text.startsWith('```')) { + text = text.replaceFirst(RegExp(r'^```(?:json)?\s*', multiLine: true), ''); + text = text.replaceFirst(RegExp(r'\s*```$', multiLine: true), ''); + } + final start = text.indexOf('{'); + final end = text.lastIndexOf('}'); + if (start >= 0 && end > start) { + return text.substring(start, end + 1); + } + return text; +} + +String _nonEmpty(Object? value, String fallback) { + final text = value?.toString().trim(); + return text == null || text.isEmpty ? fallback : text; +} + +List<String> _stringList(Object? value, List<String> fallback) { + if (value is List) { + final list = value + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toSet() + .toList(); + if (list.isNotEmpty) return list; + } + if (value is String) { + final list = value + .split(RegExp(r'[,,\n]')) + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toSet() + .toList(); + if (list.isNotEmpty) return list; + } + return fallback; +} + +String _slugify(String value) { + return value + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'_+'), '_') + .replaceAll(RegExp(r'^_|_$'), ''); +} + +String _compact(String value, {int limit = 800}) { + final trimmed = value.trim().replaceAll(RegExp(r'\s+'), ' '); + if (trimmed.length <= limit) return trimmed; + return '${trimmed.substring(0, limit)}...'; +} + +String _friendlySocketError(SocketException error) { + final raw = error.message.trim().isEmpty ? error.toString() : error.message.trim(); + final lower = raw.toLowerCase(); + if (lower.contains('failed host lookup') || + lower.contains('no address associated') || + lower.contains('temporary failure in name resolution')) { + return '$raw. Network/DNS/proxy issue: the device cannot resolve the provider host.'; + } + return raw; +} diff --git a/mobile_agent/lib/services/repo_intent_polish_service.dart b/mobile_agent/lib/services/repo_intent_polish_service.dart new file mode 100644 index 0000000..34a724a --- /dev/null +++ b/mobile_agent/lib/services/repo_intent_polish_service.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:shared_preferences/shared_preferences.dart'; + +const _defaultBaseUrl = 'https://token-plan-cn.xiaomimimo.com/anthropic'; +const _defaultModel = 'mimo-v2.5-pro'; +const _managedProviderEnabled = bool.fromEnvironment('MOBILECODE_MANAGED_PROVIDER'); +const _managedBaseUrl = String.fromEnvironment( + 'MOBILECODE_MANAGED_BASE_URL', + defaultValue: _defaultBaseUrl, +); +const _managedModel = String.fromEnvironment( + 'MOBILECODE_MANAGED_MODEL', + defaultValue: _defaultModel, +); +const _managedApiKey = String.fromEnvironment('MOBILECODE_MANAGED_API_KEY'); + +const _baseUrlKey = 'mobilecode.baseUrl'; +const _apiKeyKey = 'mobilecode.apiKey'; +const _modelKey = 'mobilecode.model'; +const _providerModeKey = 'mobilecode.providerMode'; + +const _allowedLanguages = {'Dart', 'Python', 'JavaScript', 'TypeScript', 'Go'}; + +const repoIntentPolishSystemPrompt = ''' +You polish a short repository creation intent into a GitHub repository draft. + +Return only valid JSON with this exact shape: +{ + "name": "kebab-case-repo-name", + "description": "one concise GitHub repository description", + "language": "Dart|Python|JavaScript|TypeScript|Go", + "private": false, + "addReadme": true +} + +Rules: +- name must be lowercase kebab-case, GitHub-safe, max 64 chars. +- description must be one sentence, max 140 chars. +- choose the closest language from the allowed list only. +- infer private=true only if the user says private/internal/team-only. +- addReadme should normally be true for initialized mobile projects. +- do not include markdown fences, explanations, or extra keys. +'''; + +enum _RepoProviderFlavor { openAi, anthropic } + +class RepoIntentDraft { + const RepoIntentDraft({ + required this.name, + required this.description, + required this.language, + required this.isPrivate, + required this.addReadme, + required this.source, + this.fallbackReason, + }); + + final String name; + final String description; + final String language; + final bool isPrivate; + final bool addReadme; + final String source; + final String? fallbackReason; + + bool get usedProvider => source == 'provider'; + + RepoIntentDraft copyWith({ + String? name, + String? description, + String? language, + bool? isPrivate, + bool? addReadme, + String? source, + String? fallbackReason, + }) { + return RepoIntentDraft( + name: name ?? this.name, + description: description ?? this.description, + language: language ?? this.language, + isPrivate: isPrivate ?? this.isPrivate, + addReadme: addReadme ?? this.addReadme, + source: source ?? this.source, + fallbackReason: fallbackReason ?? this.fallbackReason, + ); + } +} + +class RepoIntentPolishService { + Future<RepoIntentDraft> polish(String intent) async { + final fallback = fallbackDraft(intent); + final config = await _loadProviderConfig(); + if (config == null) { + return fallback.copyWith(fallbackReason: 'Provider is not configured.'); + } + + final flavor = _detectFlavor(config.baseUrl, config.model); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final request = await client + .postUrl(flavor == _RepoProviderFlavor.anthropic ? _anthropicMessagesUri(config.baseUrl) : _openAiChatUri(config.baseUrl)) + .timeout(const Duration(seconds: 10)); + request.headers.contentType = ContentType.json; + if (flavor == _RepoProviderFlavor.anthropic) { + request.headers.set('anthropic-version', '2023-06-01'); + request.headers.set('x-api-key', config.apiKey); + } + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer ${config.apiKey}'); + request.write(jsonEncode(_requestBody(flavor, config.model, intent))); + + final response = await request.close().timeout(const Duration(seconds: 45)); + final body = await utf8.decodeStream(response); + if (response.statusCode < 200 || response.statusCode >= 300) { + return fallback.copyWith(fallbackReason: 'Provider HTTP ${response.statusCode}.'); + } + final text = _extractProviderText(body); + final draft = _parseDraft(text, fallback); + return draft.copyWith(source: 'provider', fallbackReason: null); + } on TimeoutException { + return fallback.copyWith(fallbackReason: 'Provider timed out.'); + } on SocketException catch (error) { + return fallback.copyWith(fallbackReason: _friendlySocketError(error)); + } on Object catch (error) { + return fallback.copyWith(fallbackReason: _compact(error.toString(), 120)); + } finally { + client.close(force: true); + } + } + + RepoIntentDraft fallbackDraft(String rawIntent) { + final intent = rawIntent.trim(); + final normalized = intent.toLowerCase(); + final language = _detectLanguage(normalized); + final isPrivate = normalized.contains('private') || + intent.contains('私有') || + intent.contains('内部') || + intent.contains('团队'); + return RepoIntentDraft( + name: _repoNameFromIntent(intent, language), + description: _repoDescriptionFromIntent(intent, language), + language: language, + isPrivate: isPrivate, + addReadme: true, + source: 'fallback', + ); + } + + Future<_RepoProviderConfig?> _loadProviderConfig() async { + final prefs = await SharedPreferences.getInstance(); + final useManaged = _managedProviderEnabled && prefs.getString(_providerModeKey) != 'custom'; + final baseUrl = useManaged ? _managedBaseUrl : _savedOrDefault(prefs.getString(_baseUrlKey), _defaultBaseUrl); + final apiKey = useManaged ? _managedApiKey : (prefs.getString(_apiKeyKey) ?? ''); + final model = useManaged ? _managedModel : _savedOrDefault(prefs.getString(_modelKey), _defaultModel); + if (baseUrl.trim().isEmpty || apiKey.trim().isEmpty) return null; + return _RepoProviderConfig( + baseUrl: baseUrl.trim(), + apiKey: apiKey.trim(), + model: model.trim(), + ); + } + + Map<String, dynamic> _requestBody(_RepoProviderFlavor flavor, String model, String intent) { + final resolvedModel = model.isEmpty ? (flavor == _RepoProviderFlavor.anthropic ? _defaultModel : 'gpt-4o-mini') : model; + if (flavor == _RepoProviderFlavor.anthropic) { + return { + 'model': resolvedModel, + 'system': repoIntentPolishSystemPrompt, + 'max_tokens': 500, + 'temperature': 0.2, + 'messages': [ + {'role': 'user', 'content': intent}, + ], + }; + } + return { + 'model': resolvedModel, + 'temperature': 0.2, + 'response_format': {'type': 'json_object'}, + 'messages': [ + {'role': 'system', 'content': repoIntentPolishSystemPrompt}, + {'role': 'user', 'content': intent}, + ], + }; + } +} + +class _RepoProviderConfig { + const _RepoProviderConfig({ + required this.baseUrl, + required this.apiKey, + required this.model, + }); + + final String baseUrl; + final String apiKey; + final String model; +} + +RepoIntentDraft _parseDraft(String text, RepoIntentDraft fallback) { + final jsonText = _extractJsonObject(text); + if (jsonText == null) { + throw const FormatException('Provider did not return JSON.'); + } + final decoded = jsonDecode(jsonText); + if (decoded is! Map<String, dynamic>) { + throw const FormatException('Provider JSON is not an object.'); + } + final language = decoded['language']?.toString().trim(); + final safeLanguage = _allowedLanguages.contains(language) ? language! : fallback.language; + final name = _sanitizeRepoName(decoded['name']?.toString() ?? fallback.name); + final description = _compact( + (decoded['description']?.toString().trim().isEmpty ?? true) ? fallback.description : decoded['description'].toString(), + 140, + ); + return RepoIntentDraft( + name: name.isEmpty ? fallback.name : name, + description: description.isEmpty ? fallback.description : description, + language: safeLanguage, + isPrivate: decoded['private'] is bool ? decoded['private'] as bool : fallback.isPrivate, + addReadme: decoded['addReadme'] is bool ? decoded['addReadme'] as bool : fallback.addReadme, + source: 'provider', + ); +} + +String? _extractJsonObject(String text) { + final trimmed = text.trim(); + final withoutFence = trimmed + .replaceFirst(RegExp(r'^```(?:json)?', multiLine: true), '') + .replaceFirst(RegExp(r'```$', multiLine: true), '') + .trim(); + final start = withoutFence.indexOf('{'); + final end = withoutFence.lastIndexOf('}'); + if (start == -1 || end == -1 || end <= start) return null; + return withoutFence.substring(start, end + 1); +} + +String _extractProviderText(String body) { + final decoded = jsonDecode(body); + if (decoded is! Map<String, dynamic>) return ''; + final choices = decoded['choices']; + if (choices is List && choices.isNotEmpty) { + final first = choices.first; + if (first is Map<String, dynamic>) { + final message = first['message']; + if (message is Map<String, dynamic>) { + final content = message['content']; + if (content is String && content.trim().isNotEmpty) return content.trim(); + } + final text = first['text']; + if (text is String && text.trim().isNotEmpty) return text.trim(); + } + } + final content = decoded['content']; + if (content is List) { + final parts = <String>[]; + for (final item in content) { + if (item is Map<String, dynamic>) { + final text = item['text']; + if (text is String && text.trim().isNotEmpty) parts.add(text.trim()); + } + } + return parts.join('\n'); + } + return ''; +} + +String _detectLanguage(String normalizedIntent) { + if (normalizedIntent.contains('flutter') || normalizedIntent.contains('dart')) return 'Dart'; + if (normalizedIntent.contains('typescript') || normalizedIntent.contains('next') || normalizedIntent.contains('react')) { + return 'TypeScript'; + } + if (normalizedIntent.contains('python') || normalizedIntent.contains('fastapi') || normalizedIntent.contains('data')) { + return 'Python'; + } + if (normalizedIntent.contains('golang') || normalizedIntent.contains(' go ') || normalizedIntent.contains('gin')) return 'Go'; + return 'JavaScript'; +} + +String _repoNameFromIntent(String intent, String language) { + final source = intent.isEmpty ? 'mobilecode ${language.toLowerCase()} project' : intent.toLowerCase(); + final matches = RegExp(r'[a-z0-9]+').allMatches(source).map((match) => match.group(0)!).where((part) { + return !const {'a', 'an', 'the', 'for', 'with', 'and', 'to', 'of', 'app', 'project', 'repo', 'repository'}.contains(part); + }).take(5).toList(); + final slug = matches.isEmpty ? 'mobilecode-${language.toLowerCase()}-${DateTime.now().millisecondsSinceEpoch % 100000}' : matches.join('-'); + return _sanitizeRepoName(slug); +} + +String _repoDescriptionFromIntent(String intent, String language) { + final trimmed = intent.trim(); + if (trimmed.isEmpty) return 'A $language project initialized from a MobileCode intent.'; + return _compact(trimmed.replaceAll(RegExp(r'\s+'), ' '), 140); +} + +String _sanitizeRepoName(String value) { + final slug = value + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9._-]+'), '-') + .replaceAll(RegExp(r'-+'), '-') + .replaceAll(RegExp(r'^[-.]+|[-.]+$'), ''); + if (slug.length <= 64) return slug; + return slug.substring(0, 64).replaceAll(RegExp(r'[-.]+$'), ''); +} + +_RepoProviderFlavor _detectFlavor(String baseUrl, String model) { + final probe = '$baseUrl $model'.toLowerCase(); + if (probe.contains('anthropic') || probe.contains('claude') || probe.contains('mimo-')) { + return _RepoProviderFlavor.anthropic; + } + return _RepoProviderFlavor.openAi; +} + +String _normalizedBaseUrl(String baseUrl) { + return baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; +} + +String _savedOrDefault(String? value, String fallback) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? fallback : trimmed; +} + +Uri _parseBaseUrl(String baseUrl) { + final uri = Uri.parse(_normalizedBaseUrl(baseUrl)); + if (!uri.hasScheme || uri.host.isEmpty) { + throw const FormatException('Invalid URL'); + } + return uri; +} + +Uri _openAiChatUri(String baseUrl) { + final normalized = _normalizedBaseUrl(baseUrl); + final uri = _parseBaseUrl(normalized); + if (normalized.endsWith('/chat/completions')) return uri; + return Uri.parse('$normalized/chat/completions'); +} + +Uri _anthropicMessagesUri(String baseUrl) { + final normalized = _normalizedBaseUrl(baseUrl); + final uri = _parseBaseUrl(normalized); + if (normalized.endsWith('/v1/messages') || normalized.endsWith('/messages')) { + return uri; + } + if (normalized.endsWith('/v1')) { + return Uri.parse('$normalized/messages'); + } + return Uri.parse('$normalized/v1/messages'); +} + +String _friendlySocketError(SocketException error) { + final raw = error.message.trim().isEmpty ? error.toString() : error.message.trim(); + final lower = raw.toLowerCase(); + if (lower.contains('failed host lookup') || + lower.contains('no address associated') || + lower.contains('temporary failure in name resolution')) { + return 'Network/DNS issue: the device cannot resolve the provider host.'; + } + return raw; +} + +String _compact(String value, int limit) { + final trimmed = value.trim().replaceAll(RegExp(r'\s+'), ' '); + if (trimmed.length <= limit) return trimmed; + return '${trimmed.substring(0, limit - 1)}...'; +} diff --git a/mobile_agent/lib/services/repo_knowledge_digest_service.dart b/mobile_agent/lib/services/repo_knowledge_digest_service.dart new file mode 100644 index 0000000..0d03f78 --- /dev/null +++ b/mobile_agent/lib/services/repo_knowledge_digest_service.dart @@ -0,0 +1,1004 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/github_repo.dart'; +import 'github_repo_hub_service.dart'; +import 'memory_service.dart'; +import 'role_library_service.dart'; + +const _digestDefaultBaseUrl = 'https://token-plan-cn.xiaomimimo.com/anthropic'; +const _digestDefaultModel = 'mimo-v2.5-pro'; +const _digestManagedProviderEnabled = bool.fromEnvironment('MOBILECODE_MANAGED_PROVIDER'); +const _digestManagedBaseUrl = String.fromEnvironment( + 'MOBILECODE_MANAGED_BASE_URL', + defaultValue: _digestDefaultBaseUrl, +); +const _digestManagedModel = String.fromEnvironment( + 'MOBILECODE_MANAGED_MODEL', + defaultValue: _digestDefaultModel, +); +const _digestManagedApiKey = String.fromEnvironment('MOBILECODE_MANAGED_API_KEY'); + +const _digestBaseUrlKey = 'mobilecode.baseUrl'; +const _digestApiKeyKey = 'mobilecode.apiKey'; +const _digestModelKey = 'mobilecode.model'; +const _digestProviderModeKey = 'mobilecode.providerMode'; + +const repoKnowledgeDigestSystemPrompt = ''' +You are MobileCode Repo Intelligence. + +Analyze repository metadata and README snippets, then suggest only practical +MobileCode roles and Memory rules. Return JSON only, no markdown fences. + +Schema: +{ + "summary": "2 concise sentences", + "techStacks": ["Dart", "GitHub Actions"], + "projectTypes": ["mobile app", "HTML demo"], + "roleSuggestions": [ + { + "name": "2-4 words", + "summary": "one short sentence", + "mission": "what this role owns in one MobileCode execution lane", + "personality": "working style and tone", + "responsibilities": ["3-5 concrete responsibilities"], + "guardrails": ["2-4 boundaries"], + "successCriteria": ["2-4 observable criteria"], + "promptTemplate": "system-style role prompt written in second person", + "rationale": "why this role is needed", + "evidenceRepos": ["owner/repo"] + } + ], + "memoryRules": [ + { + "title": "short title", + "category": "preference|workflow|stack|release|naming", + "rule": "one durable rule", + "rationale": "why this belongs in app memory", + "evidenceRepos": ["owner/repo"] + } + ] +} + +Rules: +- Do not mention secrets, tokens, or private config. +- Do not ask to scan full source code. +- Prefer mobile coding, GitHub Pages, Actions, release QA, Runtime, Skill/MCP + curation, and HTML/UI roles. +- Every suggestion must include evidenceRepos. +- If evidence is thin, return fewer suggestions. +'''; + +const memoryRulePolishSystemPrompt = ''' +You are MobileCode Memory Rule Standardizer. + +Turn a rough user-edited memory rule into one durable MobileCode memory rule. +Return JSON only, no markdown fences. + +Schema: +{ + "title": "short title", + "category": "preference|workflow|stack|release|naming|repo-insight", + "rule": "one clear durable rule" +} + +Rules: +- Keep the rule about durable user preferences, engineering norms, repository + workflow, release process, naming, or mobile runtime constraints. +- Do not store secrets, tokens, URLs with credentials, or one-off task details. +- The rule must be useful in future MobileCode sessions. +- Prefer one concise sentence for "rule". +'''; + +enum _DigestProviderFlavor { openAi, anthropic } + +@immutable +class RepoReadmeSample { + const RepoReadmeSample({ + required this.repo, + required this.readme, + required this.watched, + }); + + final GitHubRepo repo; + final String readme; + final bool watched; +} + +@immutable +class RepoKnowledgeDigest { + const RepoKnowledgeDigest({ + required this.summary, + required this.techStacks, + required this.projectTypes, + required this.roleProposals, + required this.memoryProposals, + required this.analyzedRepos, + required this.source, + this.fallbackReason, + }); + + final String summary; + final List<String> techStacks; + final List<String> projectTypes; + final List<RoleProposal> roleProposals; + final List<MemoryRuleProposal> memoryProposals; + final List<String> analyzedRepos; + final String source; + final String? fallbackReason; + + bool get usedProvider => source == 'provider'; +} + +@immutable +class RolePolishResult { + const RolePolishResult({ + required this.role, + required this.usedProvider, + this.fallbackReason, + }); + + final MobileCodeRole role; + final bool usedProvider; + final String? fallbackReason; +} + +@immutable +class MemoryRulePolishResult { + const MemoryRulePolishResult({ + required this.rule, + required this.usedProvider, + this.fallbackReason, + }); + + final MemoryRule rule; + final bool usedProvider; + final String? fallbackReason; +} + +class RepoKnowledgeDigestService { + Future<RepoKnowledgeDigest> analyzeWatchedAndOwnerRepos(GitHubRepoHubService hub) async { + await hub.initialize(); + if (!hub.isAuthenticated) { + throw StateError('GitHub access is required to analyze owner repositories.'); + } + + final repos = await _selectRepos(hub); + final samples = await _loadReadmeSamples(hub, repos); + final fallback = _heuristicDigest(samples, fallbackReason: 'Provider is not configured.'); + if (samples.isEmpty) return fallback; + + final config = await _loadProviderConfig(); + if (config == null) return fallback; + + final flavor = _detectFlavor(config.baseUrl, config.model); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final request = await client + .postUrl(flavor == _DigestProviderFlavor.anthropic + ? _anthropicMessagesUri(config.baseUrl) + : _openAiChatUri(config.baseUrl)) + .timeout(const Duration(seconds: 10)); + request.headers.contentType = ContentType.json; + if (flavor == _DigestProviderFlavor.anthropic) { + request.headers.set('anthropic-version', '2023-06-01'); + request.headers.set('x-api-key', config.apiKey); + } + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer ${config.apiKey}'); + request.write(jsonEncode(_requestBody(flavor, config.model, samples))); + + final response = await request.close().timeout(const Duration(seconds: 70)); + final body = await utf8.decodeStream(response); + if (response.statusCode < 200 || response.statusCode >= 300) { + return fallback.copyWith(fallbackReason: 'Provider HTTP ${response.statusCode}.'); + } + final text = _extractProviderText(body).trim(); + if (text.isEmpty) { + return fallback.copyWith( + fallbackReason: 'AI provider returned an empty response; local heuristic suggestions are shown.', + ); + } + try { + return _parseDigest(text, samples, fallback); + } on FormatException { + return fallback.copyWith( + fallbackReason: 'AI provider response was not structured JSON; local heuristic suggestions are shown.', + ); + } + } on TimeoutException { + return fallback.copyWith(fallbackReason: 'Provider timed out; heuristic suggestions shown.'); + } on SocketException catch (error) { + return fallback.copyWith(fallbackReason: _friendlySocketError(error)); + } on Object catch (error) { + return fallback.copyWith(fallbackReason: _compact(error.toString(), 140)); + } finally { + client.close(force: true); + } + } + + List<RoleProposal> buildRoleProposals(RepoKnowledgeDigest digest) => digest.roleProposals; + + List<MemoryRuleProposal> buildMemoryRuleProposals(RepoKnowledgeDigest digest) => digest.memoryProposals; + + Future<RolePolishResult> polishRole(MobileCodeRole draft) async { + final fallback = _fallbackPolishedRole(draft); + final config = await _loadProviderConfig(); + if (config == null) { + return RolePolishResult( + role: fallback, + usedProvider: false, + fallbackReason: 'Provider is not configured; used local role template.', + ); + } + + try { + final text = await _completeWithProvider( + config: config, + systemPrompt: rolePolishSystemPrompt, + userPrompt: _rolePolishPrompt(draft), + maxTokens: 1200, + ); + final polished = RoleLibraryService.instance + .parsePolishedOutput(text, fallbackIntent: _roleIntent(draft)) + .copyWith( + id: draft.id, + avatarAsset: draft.avatarAsset, + colorValue: draft.colorValue, + builtIn: false, + enabled: true, + ); + return RolePolishResult(role: polished, usedProvider: true); + } on TimeoutException { + return RolePolishResult( + role: fallback, + usedProvider: false, + fallbackReason: 'Provider timed out; used local role template.', + ); + } on SocketException catch (error) { + return RolePolishResult( + role: fallback, + usedProvider: false, + fallbackReason: _friendlySocketError(error), + ); + } on Object catch (error) { + return RolePolishResult( + role: fallback, + usedProvider: false, + fallbackReason: _compact(error.toString(), 140), + ); + } + } + + Future<MemoryRulePolishResult> polishMemoryRule(MemoryRule draft) async { + final fallback = _fallbackPolishedMemoryRule(draft); + final config = await _loadProviderConfig(); + if (config == null) { + return MemoryRulePolishResult( + rule: fallback, + usedProvider: false, + fallbackReason: 'Provider is not configured; used local memory template.', + ); + } + + try { + final text = await _completeWithProvider( + config: config, + systemPrompt: memoryRulePolishSystemPrompt, + userPrompt: _memoryRulePolishPrompt(draft), + maxTokens: 500, + ); + final polished = _parseMemoryRulePolish(text, fallback); + return MemoryRulePolishResult(rule: polished, usedProvider: true); + } on TimeoutException { + return MemoryRulePolishResult( + rule: fallback, + usedProvider: false, + fallbackReason: 'Provider timed out; used local memory template.', + ); + } on SocketException catch (error) { + return MemoryRulePolishResult( + rule: fallback, + usedProvider: false, + fallbackReason: _friendlySocketError(error), + ); + } on Object catch (error) { + return MemoryRulePolishResult( + rule: fallback, + usedProvider: false, + fallbackReason: _compact(error.toString(), 140), + ); + } + } + + Future<List<GitHubRepo>> _selectRepos(GitHubRepoHubService hub) async { + final watchlist = await hub.loadWatchlist(); + final byKey = <String, GitHubRepo>{}; + + for (final key in watchlist.take(25)) { + final parts = key.split('/'); + if (parts.length != 2) continue; + try { + final repo = await hub.github.getRepository(parts[0], parts[1], public: true); + byKey[GitHubRepoHubService.repoKey(repo)] = repo; + } on Object catch (error) { + debugPrint('[RepoDigest] Skipping watched repo $key: $error'); + } + } + + final ownerItems = await hub.loadHubItems(owner: hub.currentUser, sort: 'pushed'); + for (final item in ownerItems) { + byKey.putIfAbsent(item.key, () => item.repo); + if (byKey.length >= 25) break; + } + + final watchedFirst = <GitHubRepo>[]; + final rest = <GitHubRepo>[]; + for (final repo in byKey.values) { + if (watchlist.contains(GitHubRepoHubService.repoKey(repo))) { + watchedFirst.add(repo); + } else { + rest.add(repo); + } + } + watchedFirst.sort((a, b) => b.pushedAt.compareTo(a.pushedAt)); + rest.sort((a, b) => b.pushedAt.compareTo(a.pushedAt)); + return [...watchedFirst, ...rest].take(25).toList(growable: false); + } + + Future<List<RepoReadmeSample>> _loadReadmeSamples( + GitHubRepoHubService hub, + List<GitHubRepo> repos, + ) async { + final watchlist = await hub.loadWatchlist(); + final samples = <RepoReadmeSample>[]; + var totalChars = 0; + for (final repo in repos) { + if (totalChars >= 80000) break; + try { + final readme = await hub.github.getReadmeContent( + repo.owner, + repo.name, + public: !repo.isPrivate, + ); + final snippet = _compact(readme, 6000); + if (snippet.trim().isEmpty && repo.description.trim().isEmpty) continue; + totalChars += snippet.length; + samples.add(RepoReadmeSample( + repo: repo, + readme: snippet, + watched: watchlist.contains(GitHubRepoHubService.repoKey(repo)), + )); + } on Object catch (error) { + debugPrint('[RepoDigest] README failed for ${repo.fullName}: $error'); + } + } + return samples; + } + + Future<_DigestProviderConfig?> _loadProviderConfig() async { + final prefs = await SharedPreferences.getInstance(); + final useManaged = _digestManagedProviderEnabled && prefs.getString(_digestProviderModeKey) != 'custom'; + final baseUrl = useManaged ? _digestManagedBaseUrl : _savedOrDefault(prefs.getString(_digestBaseUrlKey), _digestDefaultBaseUrl); + final apiKey = useManaged ? _digestManagedApiKey : (prefs.getString(_digestApiKeyKey) ?? ''); + final model = useManaged ? _digestManagedModel : _savedOrDefault(prefs.getString(_digestModelKey), _digestDefaultModel); + if (baseUrl.trim().isEmpty || apiKey.trim().isEmpty) return null; + return _DigestProviderConfig(baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), model: model.trim()); + } + + Future<String> _completeWithProvider({ + required _DigestProviderConfig config, + required String systemPrompt, + required String userPrompt, + required int maxTokens, + }) async { + final flavor = _detectFlavor(config.baseUrl, config.model); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final request = await client + .postUrl(flavor == _DigestProviderFlavor.anthropic + ? _anthropicMessagesUri(config.baseUrl) + : _openAiChatUri(config.baseUrl)) + .timeout(const Duration(seconds: 10)); + request.headers.contentType = ContentType.json; + if (flavor == _DigestProviderFlavor.anthropic) { + request.headers.set('anthropic-version', '2023-06-01'); + request.headers.set('x-api-key', config.apiKey); + } + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer ${config.apiKey}'); + request.write(jsonEncode(_completionRequestBody( + flavor: flavor, + model: config.model, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + maxTokens: maxTokens, + ))); + + final response = await request.close().timeout(const Duration(seconds: 70)); + final body = await utf8.decodeStream(response); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError('Provider HTTP ${response.statusCode}: ${_compact(body, 180)}'); + } + final text = _extractProviderText(body); + if (text.trim().isEmpty) { + throw const FormatException('Provider returned an empty response.'); + } + return text; + } finally { + client.close(force: true); + } + } + + Map<String, dynamic> _completionRequestBody({ + required _DigestProviderFlavor flavor, + required String model, + required String systemPrompt, + required String userPrompt, + required int maxTokens, + }) { + final resolvedModel = model.isEmpty ? (flavor == _DigestProviderFlavor.anthropic ? _digestDefaultModel : 'gpt-4o-mini') : model; + if (flavor == _DigestProviderFlavor.anthropic) { + return { + 'model': resolvedModel, + 'system': systemPrompt, + 'max_tokens': maxTokens, + 'temperature': 0.2, + 'messages': [ + {'role': 'user', 'content': userPrompt}, + ], + }; + } + return { + 'model': resolvedModel, + 'temperature': 0.2, + 'response_format': {'type': 'json_object'}, + 'messages': [ + {'role': 'system', 'content': systemPrompt}, + {'role': 'user', 'content': userPrompt}, + ], + }; + } + + Map<String, dynamic> _requestBody( + _DigestProviderFlavor flavor, + String model, + List<RepoReadmeSample> samples, + ) { + final content = _samplesPrompt(samples); + final resolvedModel = model.isEmpty ? (flavor == _DigestProviderFlavor.anthropic ? _digestDefaultModel : 'gpt-4o-mini') : model; + if (flavor == _DigestProviderFlavor.anthropic) { + return { + 'model': resolvedModel, + 'system': repoKnowledgeDigestSystemPrompt, + 'max_tokens': 1800, + 'temperature': 0.2, + 'messages': [ + {'role': 'user', 'content': content}, + ], + }; + } + return { + 'model': resolvedModel, + 'temperature': 0.2, + 'response_format': {'type': 'json_object'}, + 'messages': [ + {'role': 'system', 'content': repoKnowledgeDigestSystemPrompt}, + {'role': 'user', 'content': content}, + ], + }; + } +} + +extension on RepoKnowledgeDigest { + RepoKnowledgeDigest copyWith({ + String? summary, + List<String>? techStacks, + List<String>? projectTypes, + List<RoleProposal>? roleProposals, + List<MemoryRuleProposal>? memoryProposals, + List<String>? analyzedRepos, + String? source, + String? fallbackReason, + }) { + return RepoKnowledgeDigest( + summary: summary ?? this.summary, + techStacks: techStacks ?? this.techStacks, + projectTypes: projectTypes ?? this.projectTypes, + roleProposals: roleProposals ?? this.roleProposals, + memoryProposals: memoryProposals ?? this.memoryProposals, + analyzedRepos: analyzedRepos ?? this.analyzedRepos, + source: source ?? this.source, + fallbackReason: fallbackReason ?? this.fallbackReason, + ); + } +} + +class _DigestProviderConfig { + const _DigestProviderConfig({ + required this.baseUrl, + required this.apiKey, + required this.model, + }); + + final String baseUrl; + final String apiKey; + final String model; +} + +MobileCodeRole _fallbackPolishedRole(MobileCodeRole draft) { + final local = RoleLibraryService.instance.standardizeLocalIntent(_roleIntent(draft)); + return local.copyWith( + id: draft.id, + name: draft.name.trim().isEmpty ? local.name : _compact(draft.name, 40), + summary: draft.summary.trim().isEmpty ? local.summary : _compact(draft.summary, 120), + mission: draft.mission.trim().isEmpty ? local.mission : _compact(draft.mission, 260), + personality: draft.personality.trim().isEmpty ? local.personality : _compact(draft.personality, 180), + responsibilities: draft.responsibilities.where((item) => item.trim().isNotEmpty).take(5).toList().isEmpty + ? local.responsibilities + : draft.responsibilities.where((item) => item.trim().isNotEmpty).take(5).toList(), + guardrails: draft.guardrails.where((item) => item.trim().isNotEmpty).take(4).toList().isEmpty + ? local.guardrails + : draft.guardrails.where((item) => item.trim().isNotEmpty).take(4).toList(), + successCriteria: draft.successCriteria.where((item) => item.trim().isNotEmpty).take(4).toList().isEmpty + ? local.successCriteria + : draft.successCriteria.where((item) => item.trim().isNotEmpty).take(4).toList(), + promptTemplate: draft.promptTemplate.trim().isEmpty ? local.promptTemplate : _compact(draft.promptTemplate, 900), + avatarAsset: draft.avatarAsset, + colorValue: draft.colorValue, + builtIn: false, + enabled: true, + ); +} + +MemoryRule _fallbackPolishedMemoryRule(MemoryRule draft) { + final rule = _compact(draft.rule.trim().isEmpty ? 'Use repository evidence before making durable MobileCode decisions.' : draft.rule, 420); + final title = draft.title.trim().isEmpty ? _titleFromRule(rule) : _compact(draft.title, 70); + final category = _memoryCategory(draft.category); + return draft.copyWith( + title: title, + category: category, + rule: rule, + source: draft.source.trim().isEmpty ? 'repo-knowledge-polish' : draft.source, + enabled: true, + ); +} + +String _rolePolishPrompt(MobileCodeRole draft) { + return jsonEncode({ + 'instruction': 'Standardize this user-edited MobileCode role. Preserve intent, improve structure, and keep it bounded.', + 'role': { + 'name': draft.name, + 'summary': draft.summary, + 'mission': draft.mission, + 'personality': draft.personality, + 'responsibilities': draft.responsibilities, + 'guardrails': draft.guardrails, + 'successCriteria': draft.successCriteria, + 'promptTemplate': draft.promptTemplate, + }, + }); +} + +String _memoryRulePolishPrompt(MemoryRule draft) { + return jsonEncode({ + 'instruction': 'Standardize this user-edited MobileCode memory rule. Keep only durable future-use guidance.', + 'memoryRule': { + 'title': draft.title, + 'category': draft.category, + 'rule': draft.rule, + 'source': draft.source, + 'evidenceRepos': draft.evidenceRepos, + }, + }); +} + +String _roleIntent(MobileCodeRole draft) { + return [ + draft.name, + draft.summary, + draft.mission, + draft.personality, + ...draft.responsibilities, + ...draft.guardrails, + ...draft.successCriteria, + draft.promptTemplate, + ].where((item) => item.trim().isNotEmpty).join('\n'); +} + +MemoryRule _parseMemoryRulePolish(String text, MemoryRule fallback) { + final jsonText = _extractJsonObject(text); + if (jsonText == null) return fallback; + final decoded = jsonDecode(jsonText); + if (decoded is! Map<String, dynamic>) return fallback; + final rule = decoded['rule']?.toString().trim(); + return fallback.copyWith( + title: _compact(_nonEmptyText(decoded['title'], fallback.title), 70), + category: _memoryCategory(decoded['category']?.toString() ?? fallback.category), + rule: rule == null || rule.isEmpty ? fallback.rule : _compact(rule, 420), + enabled: true, + ); +} + +String _nonEmptyText(Object? value, String fallback) { + final text = value?.toString().trim(); + return text == null || text.isEmpty ? fallback : text; +} + +String _memoryCategory(String value) { + final normalized = value.trim().toLowerCase(); + const allowed = {'preference', 'workflow', 'stack', 'release', 'naming', 'repo-insight'}; + return allowed.contains(normalized) ? normalized : 'repo-insight'; +} + +String _titleFromRule(String rule) { + final normalized = rule.trim().replaceAll(RegExp(r'\s+'), ' '); + if (normalized.isEmpty) return 'Repo insight rule'; + final firstSentence = normalized.split(RegExp(r'[。.!?]')).first.trim(); + return _compact(firstSentence.isEmpty ? normalized : firstSentence, 70); +} + +RepoKnowledgeDigest _parseDigest( + String text, + List<RepoReadmeSample> samples, + RepoKnowledgeDigest fallback, +) { + final jsonText = _extractJsonObject(text); + if (jsonText == null) throw const FormatException('Provider did not return JSON.'); + final decoded = jsonDecode(jsonText); + if (decoded is! Map<String, dynamic>) throw const FormatException('Provider JSON is not an object.'); + + final roleItems = (decoded['roleSuggestions'] as List<dynamic>?) ?? const []; + final memoryItems = (decoded['memoryRules'] as List<dynamic>?) ?? const []; + final roles = <RoleProposal>[]; + final rules = <MemoryRuleProposal>[]; + final now = DateTime.now(); + final evidenceFallback = samples.map((sample) => sample.repo.fullName).take(3).toList(growable: false); + + for (var i = 0; i < roleItems.length && roles.length < 5; i++) { + final item = roleItems[i]; + if (item is! Map<String, dynamic>) continue; + final name = _compact(item['name']?.toString() ?? 'Repo Specialist', 40); + final evidence = _nonEmptyList(item['evidenceRepos']).isEmpty + ? evidenceFallback + : _nonEmptyList(item['evidenceRepos']).take(5).toList(); + roles.add(RoleProposal( + proposalId: 'repo_role_${now.microsecondsSinceEpoch}_$i', + sourceRoleId: 'repo-knowledge-digest', + role: MobileCodeRole( + id: 'repo_role_${now.microsecondsSinceEpoch}_$i', + name: name, + summary: _compact(item['summary']?.toString() ?? 'Repository-informed MobileCode role.', 120), + mission: _compact(item['mission']?.toString() ?? 'Improve repository-specific MobileCode work.', 260), + personality: _compact(item['personality']?.toString() ?? 'Careful, concrete, and mobile-aware.', 180), + responsibilities: _nonEmptyList(item['responsibilities']).take(5).toList(), + guardrails: _nonEmptyList(item['guardrails']).take(4).toList(), + successCriteria: _nonEmptyList(item['successCriteria']).take(4).toList(), + promptTemplate: _compact(item['promptTemplate']?.toString() ?? 'You adapt MobileCode work to repository evidence and mobile constraints.', 700), + avatarAsset: RoleLibraryService.defaultCustomAvatarAsset, + colorValue: 0xFF2555FF, + builtIn: false, + enabled: true, + ), + prompt: 'Repo evidence: ${evidence.join(', ')}', + rationale: _compact(item['rationale']?.toString() ?? 'Suggested from README evidence.', 260), + runId: 'repo_digest_${now.millisecondsSinceEpoch}', + status: RoleProposalStatus.pending, + createdAt: now, + )); + } + + for (var i = 0; i < memoryItems.length && rules.length < 8; i++) { + final item = memoryItems[i]; + if (item is! Map<String, dynamic>) continue; + final evidence = _nonEmptyList(item['evidenceRepos']).isEmpty + ? evidenceFallback + : _nonEmptyList(item['evidenceRepos']).take(5).toList(); + final rule = MemoryRule( + id: 'repo_memory_${now.microsecondsSinceEpoch}_$i', + title: _compact(item['title']?.toString() ?? 'Repo workflow rule', 70), + category: _compact(item['category']?.toString() ?? 'repo-insight', 30), + rule: _compact(item['rule']?.toString() ?? 'Prefer repository-specific workflows when generating code.', 400), + source: 'repo-knowledge-digest', + evidenceRepos: evidence, + createdAt: now, + ); + rules.add(MemoryRuleProposal( + proposalId: 'repo_memory_proposal_${now.microsecondsSinceEpoch}_$i', + rule: rule, + rationale: _compact(item['rationale']?.toString() ?? 'Suggested from README evidence.', 260), + evidenceRepos: evidence, + status: MemoryRuleProposalStatus.pending, + createdAt: now, + )); + } + + return RepoKnowledgeDigest( + summary: _compact(decoded['summary']?.toString() ?? fallback.summary, 420), + techStacks: _nonEmptyList(decoded['techStacks']).isEmpty ? fallback.techStacks : _nonEmptyList(decoded['techStacks']).take(10).toList(), + projectTypes: _nonEmptyList(decoded['projectTypes']).isEmpty ? fallback.projectTypes : _nonEmptyList(decoded['projectTypes']).take(8).toList(), + roleProposals: roles.isEmpty ? fallback.roleProposals : roles, + memoryProposals: rules.isEmpty ? fallback.memoryProposals : rules, + analyzedRepos: fallback.analyzedRepos, + source: 'provider', + ); +} + +RepoKnowledgeDigest _heuristicDigest(List<RepoReadmeSample> samples, {String? fallbackReason}) { + final repos = samples.map((sample) => sample.repo.fullName).toList(growable: false); + final text = samples.map((sample) => '${sample.repo.fullName}\n${sample.repo.description}\n${sample.readme}').join('\n').toLowerCase(); + final stacks = <String>{ + for (final sample in samples) + if ((sample.repo.language ?? '').trim().isNotEmpty) sample.repo.language!.trim(), + if (text.contains('flutter')) 'Flutter', + if (text.contains('github actions') || text.contains('.github/workflows')) 'GitHub Actions', + if (text.contains('pages') || text.contains('gh-pages')) 'GitHub Pages', + if (text.contains('mcp')) 'MCP', + if (text.contains('skill.md') || text.contains('skill.yaml')) 'Skills', + }.toList() + ..sort(); + final types = <String>{ + if (text.contains('mobile') || text.contains('android') || text.contains('ios')) 'mobile app', + if (text.contains('html') || text.contains('webview') || text.contains('pages')) 'web demo', + if (text.contains('release') || text.contains('apk') || text.contains('artifact')) 'release pipeline', + if (text.contains('skill') || text.contains('mcp')) 'agent tooling', + }.toList() + ..sort(); + final evidence = repos.take(4).toList(growable: false); + final now = DateTime.now(); + final roles = <RoleProposal>[]; + final rules = <MemoryRuleProposal>[]; + + void addRole({ + required String name, + required String summary, + required String mission, + required List<String> responsibilities, + }) { + final index = roles.length; + roles.add(RoleProposal( + proposalId: 'repo_role_${now.microsecondsSinceEpoch}_$index', + sourceRoleId: 'repo-knowledge-heuristic', + role: MobileCodeRole( + id: 'repo_role_${now.microsecondsSinceEpoch}_$index', + name: name, + summary: summary, + mission: mission, + personality: 'Concise, evidence-driven, and careful about mobile runtime limits.', + responsibilities: responsibilities, + guardrails: const [ + 'Do not infer secrets or private implementation details from README snippets.', + 'Prefer GitHub-backed workflows when local runtime is not available.', + ], + successCriteria: const [ + 'Advice cites repository evidence.', + 'Actions are small enough for a phone-first workflow.', + ], + promptTemplate: 'You are a repository-informed MobileCode role. Use README evidence, keep work phone-friendly, and explain GitHub workflow tradeoffs.', + avatarAsset: RoleLibraryService.defaultCustomAvatarAsset, + colorValue: 0xFF0B9B7E, + builtIn: false, + enabled: true, + ), + prompt: 'Repo evidence: ${evidence.join(', ')}', + rationale: 'Heuristic suggestion from repository languages, README keywords, and workflow hints.', + runId: 'repo_digest_${now.millisecondsSinceEpoch}', + status: RoleProposalStatus.pending, + createdAt: now, + )); + } + + void addRule(String title, String category, String rule) { + final index = rules.length; + rules.add(MemoryRuleProposal( + proposalId: 'repo_memory_proposal_${now.microsecondsSinceEpoch}_$index', + rule: MemoryRule( + id: 'repo_memory_${now.microsecondsSinceEpoch}_$index', + title: title, + category: category, + rule: rule, + source: 'repo-knowledge-heuristic', + evidenceRepos: evidence, + createdAt: now, + ), + rationale: 'Heuristic rule from repository README patterns.', + evidenceRepos: evidence, + status: MemoryRuleProposalStatus.pending, + createdAt: now, + )); + } + + if (text.contains('pages') || text.contains('gh-pages') || text.contains('html')) { + addRole( + name: 'Pages Publisher', + summary: 'Keeps HTML demos mobile-ready and GitHub Pages friendly.', + mission: 'Own publish readiness, Pages constraints, and shareable output cards.', + responsibilities: const [ + 'Check title, viewport, and mobile layout before publishing.', + 'Prefer GitHub Pages for lightweight HTML demos.', + 'Explain Pages failures with actionable next steps.', + ], + ); + addRule('Prefer Pages for HTML demos', 'release', 'When a generated project is a lightweight HTML/web demo, prefer GitHub Pages publish over local heavy builds.'); + } + if (text.contains('actions') || text.contains('workflow') || text.contains('artifact') || text.contains('apk')) { + addRole( + name: 'Actions Builder', + summary: 'Moves heavy builds into GitHub Actions.', + mission: 'Own workflow dispatch, artifact interpretation, and build failure summaries.', + responsibilities: const [ + 'Use Actions for APK or heavyweight build jobs.', + 'Surface artifact links and failed job summaries.', + 'Avoid requiring a full local compiler when GitHub can build remotely.', + ], + ); + addRule('Use Actions for heavy builds', 'workflow', 'For APK, Gradle, iOS archive, or release artifacts, prefer GitHub Actions and show artifact/download status in MobileCode.'); + } + if (text.contains('skill') || text.contains('mcp')) { + addRole( + name: 'Skill Curator', + summary: 'Reviews Skill/MCP repos before install.', + mission: 'Own provenance checks, manifest review, and disabled-by-default MCP registration.', + responsibilities: const [ + 'Review manifest and source before installing skills.', + 'Register MCP candidates disabled by default.', + 'Flag unknown commands, network tools, and weak provenance.', + ], + ); + addRule('Review connectors first', 'preference', 'Skill and MCP repositories must be reviewed for manifest, command, provenance, and risk before installation or registration.'); + } + if (roles.isEmpty) { + addRole( + name: 'Repo Reviewer', + summary: 'Adapts MobileCode work to the user repository set.', + mission: 'Summarize repo evidence and keep generated work aligned with existing stacks.', + responsibilities: const [ + 'Infer stack and workflow from README evidence.', + 'Recommend small phone-friendly next actions.', + 'Avoid broad claims when README evidence is thin.', + ], + ); + } + if (rules.isEmpty) { + addRule('Use repository evidence', 'workflow', 'When working inside a GitHub-linked chat, infer stack and release workflow from README evidence before making code or publish decisions.'); + } + + return RepoKnowledgeDigest( + summary: samples.isEmpty + ? 'No readable README evidence was found yet.' + : 'Analyzed ${samples.length} repositories from watchlist and the active GitHub account. Suggestions are heuristic because the model provider was unavailable.', + techStacks: stacks.isEmpty ? const ['GitHub'] : stacks.take(10).toList(), + projectTypes: types.isEmpty ? const ['repository workspace'] : types.take(8).toList(), + roleProposals: roles, + memoryProposals: rules, + analyzedRepos: repos, + source: 'heuristic', + fallbackReason: fallbackReason, + ); +} + +String _samplesPrompt(List<RepoReadmeSample> samples) { + final buffer = StringBuffer('Analyze these MobileCode user repositories. Do not infer secrets.\n\n'); + for (final sample in samples) { + final repo = sample.repo; + buffer.writeln('---'); + buffer.writeln('repo: ${repo.fullName}'); + buffer.writeln('watched: ${sample.watched}'); + buffer.writeln('private: ${repo.isPrivate}'); + buffer.writeln('language: ${repo.language ?? 'unknown'}'); + buffer.writeln('topics: ${repo.topics.take(8).join(', ')}'); + buffer.writeln('description: ${repo.description}'); + buffer.writeln('readme_snippet:\n${sample.readme}'); + } + return buffer.toString(); +} + +String? _extractJsonObject(String text) { + final trimmed = text.trim(); + final withoutFence = trimmed + .replaceFirst(RegExp(r'^```(?:json)?', multiLine: true), '') + .replaceFirst(RegExp(r'```$', multiLine: true), '') + .trim(); + final start = withoutFence.indexOf('{'); + final end = withoutFence.lastIndexOf('}'); + if (start == -1 || end == -1 || end <= start) return null; + return withoutFence.substring(start, end + 1); +} + +String _extractProviderText(String body) { + final decoded = jsonDecode(body); + if (decoded is! Map<String, dynamic>) return ''; + final choices = decoded['choices']; + if (choices is List && choices.isNotEmpty) { + final first = choices.first; + if (first is Map<String, dynamic>) { + final message = first['message']; + if (message is Map<String, dynamic>) { + final content = message['content']; + if (content is String && content.trim().isNotEmpty) return content.trim(); + } + final text = first['text']; + if (text is String && text.trim().isNotEmpty) return text.trim(); + } + } + final content = decoded['content']; + if (content is List) { + final parts = <String>[]; + for (final item in content) { + if (item is Map<String, dynamic>) { + final text = item['text']; + if (text is String && text.trim().isNotEmpty) parts.add(text.trim()); + } + } + return parts.join('\n'); + } + return ''; +} + +List<String> _nonEmptyList(dynamic value) { + if (value is! List) return const []; + return value.map((item) => item.toString().trim()).where((item) => item.isNotEmpty).toList(); +} + +_DigestProviderFlavor _detectFlavor(String baseUrl, String model) { + final probe = '$baseUrl $model'.toLowerCase(); + if (probe.contains('anthropic') || probe.contains('claude') || probe.contains('mimo-')) { + return _DigestProviderFlavor.anthropic; + } + return _DigestProviderFlavor.openAi; +} + +String _normalizedBaseUrl(String baseUrl) { + return baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; +} + +String _savedOrDefault(String? value, String fallback) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? fallback : trimmed; +} + +Uri _parseBaseUrl(String baseUrl) { + final uri = Uri.parse(_normalizedBaseUrl(baseUrl)); + if (!uri.hasScheme || uri.host.isEmpty) { + throw const FormatException('Invalid URL'); + } + return uri; +} + +Uri _openAiChatUri(String baseUrl) { + final normalized = _normalizedBaseUrl(baseUrl); + final uri = _parseBaseUrl(normalized); + if (normalized.endsWith('/chat/completions')) return uri; + return Uri.parse('$normalized/chat/completions'); +} + +Uri _anthropicMessagesUri(String baseUrl) { + final normalized = _normalizedBaseUrl(baseUrl); + final uri = _parseBaseUrl(normalized); + if (normalized.endsWith('/v1/messages') || normalized.endsWith('/messages')) { + return uri; + } + if (normalized.endsWith('/v1')) { + return Uri.parse('$normalized/messages'); + } + return Uri.parse('$normalized/v1/messages'); +} + +String _friendlySocketError(SocketException error) { + final raw = error.message.trim().isEmpty ? error.toString() : error.message.trim(); + final lower = raw.toLowerCase(); + if (lower.contains('failed host lookup') || + lower.contains('no address associated') || + lower.contains('temporary failure in name resolution')) { + return 'Network/DNS issue: the device cannot resolve the provider host.'; + } + return raw; +} + +String _compact(String value, int limit) { + final trimmed = value.trim().replaceAll(RegExp(r'\s+'), ' '); + if (trimmed.length <= limit) return trimmed; + return '${trimmed.substring(0, limit - 1)}...'; +} diff --git a/mobile_agent/lib/services/role_library_service.dart b/mobile_agent/lib/services/role_library_service.dart new file mode 100644 index 0000000..86033f3 --- /dev/null +++ b/mobile_agent/lib/services/role_library_service.dart @@ -0,0 +1,766 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const rolePolishSystemPrompt = ''' +You are MobileCode Role Standardizer. + +Turn a user's rough role idea into a production-ready MobileCode role card. +Return JSON only. Do not include markdown fences. + +Schema: +{ + "name": "2-4 words", + "summary": "one short sentence", + "mission": "what this role owns in one execution lane", + "personality": "working style and tone", + "responsibilities": ["3-5 concrete responsibilities"], + "guardrails": ["2-4 boundaries this role must respect"], + "successCriteria": ["2-4 observable acceptance criteria"], + "promptTemplate": "system-style role prompt written in second person" +} + +Rules: +- MobileCode runs these roles as role personalities inside one agent execution lane, not as parallel agents. +- Keep the role useful for mobile coding, HTML generation, GitHub Pages, runtime diagnostics, or release QA. +- Avoid vague titles like Expert or Assistant unless the user explicitly asked for that name. +- The role must be bounded, testable, and safe for a mobile app environment. +'''; + +class MobileCodeRole { + const MobileCodeRole({ + required this.id, + required this.name, + required this.summary, + required this.mission, + required this.personality, + required this.responsibilities, + required this.guardrails, + required this.successCriteria, + required this.promptTemplate, + required this.avatarAsset, + required this.colorValue, + this.builtIn = false, + this.enabled = true, + }); + + final String id; + final String name; + final String summary; + final String mission; + final String personality; + final List<String> responsibilities; + final List<String> guardrails; + final List<String> successCriteria; + final String promptTemplate; + final String avatarAsset; + final int colorValue; + final bool builtIn; + final bool enabled; + + MobileCodeRole copyWith({ + String? id, + String? name, + String? summary, + String? mission, + String? personality, + List<String>? responsibilities, + List<String>? guardrails, + List<String>? successCriteria, + String? promptTemplate, + String? avatarAsset, + int? colorValue, + bool? builtIn, + bool? enabled, + }) { + return MobileCodeRole( + id: id ?? this.id, + name: name ?? this.name, + summary: summary ?? this.summary, + mission: mission ?? this.mission, + personality: personality ?? this.personality, + responsibilities: responsibilities ?? this.responsibilities, + guardrails: guardrails ?? this.guardrails, + successCriteria: successCriteria ?? this.successCriteria, + promptTemplate: promptTemplate ?? this.promptTemplate, + avatarAsset: avatarAsset ?? this.avatarAsset, + colorValue: colorValue ?? this.colorValue, + builtIn: builtIn ?? this.builtIn, + enabled: enabled ?? this.enabled, + ); + } + + Map<String, dynamic> toJson() { + return { + 'id': id, + 'name': name, + 'summary': summary, + 'mission': mission, + 'personality': personality, + 'responsibilities': responsibilities, + 'guardrails': guardrails, + 'successCriteria': successCriteria, + 'promptTemplate': promptTemplate, + 'avatarAsset': avatarAsset, + 'colorValue': colorValue, + 'builtIn': builtIn, + 'enabled': enabled, + }; + } + + factory MobileCodeRole.fromJson(Map<String, dynamic> json) { + return MobileCodeRole( + id: json['id'] as String? ?? _newCustomRoleId(), + name: json['name'] as String? ?? 'Custom Role', + summary: json['summary'] as String? ?? '', + mission: json['mission'] as String? ?? '', + personality: json['personality'] as String? ?? '', + responsibilities: _stringList(json['responsibilities']), + guardrails: _stringList(json['guardrails']), + successCriteria: _stringList(json['successCriteria']), + promptTemplate: json['promptTemplate'] as String? ?? '', + avatarAsset: json['avatarAsset'] as String? ?? RoleLibraryService.defaultCustomAvatarAsset, + colorValue: json['colorValue'] as int? ?? 0xFF7557E8, + builtIn: json['builtIn'] as bool? ?? false, + enabled: json['enabled'] as bool? ?? true, + ); + } +} + +enum RoleProposalStatus { pending, accepted, dismissed } + +class RoleProposal { + const RoleProposal({ + required this.proposalId, + required this.sourceRoleId, + required this.role, + required this.prompt, + required this.rationale, + required this.runId, + required this.status, + required this.createdAt, + }); + + final String proposalId; + final String sourceRoleId; + final MobileCodeRole role; + final String prompt; + final String rationale; + final String runId; + final RoleProposalStatus status; + final DateTime createdAt; + + RoleProposal copyWith({ + String? proposalId, + String? sourceRoleId, + MobileCodeRole? role, + String? prompt, + String? rationale, + String? runId, + RoleProposalStatus? status, + DateTime? createdAt, + }) { + return RoleProposal( + proposalId: proposalId ?? this.proposalId, + sourceRoleId: sourceRoleId ?? this.sourceRoleId, + role: role ?? this.role, + prompt: prompt ?? this.prompt, + rationale: rationale ?? this.rationale, + runId: runId ?? this.runId, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + ); + } + + Map<String, dynamic> toJson() { + return { + 'proposalId': proposalId, + 'sourceRoleId': sourceRoleId, + 'role': role.toJson(), + 'prompt': prompt, + 'rationale': rationale, + 'runId': runId, + 'status': status.name, + 'createdAt': createdAt.toIso8601String(), + }; + } + + factory RoleProposal.fromJson(Map<String, dynamic> json) { + final statusName = json['status'] as String? ?? RoleProposalStatus.pending.name; + return RoleProposal( + proposalId: json['proposalId'] as String? ?? _newRoleProposalId(), + sourceRoleId: json['sourceRoleId'] as String? ?? '', + role: MobileCodeRole.fromJson(Map<String, dynamic>.from(json['role'] as Map? ?? const {})) + .copyWith(builtIn: false), + prompt: json['prompt'] as String? ?? '', + rationale: json['rationale'] as String? ?? '', + runId: json['runId'] as String? ?? '', + status: RoleProposalStatus.values.firstWhere( + (item) => item.name == statusName, + orElse: () => RoleProposalStatus.pending, + ), + createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now(), + ); + } +} + +class RoleLibraryService extends ChangeNotifier { + RoleLibraryService._(); + + static final RoleLibraryService instance = RoleLibraryService._(); + + static const defaultCustomAvatarAsset = 'assets/role_avatars/avatar-batch2-24-rounded-icon.svg'; + + static const _customRolesKey = 'mobilecode.role_library.custom.v1'; + static const _disabledRoleIdsKey = 'mobilecode.role_library.disabled.v1'; + static const _roleProposalsKey = 'mobilecode.role_library.proposals.v1'; + + SharedPreferences? _prefs; + bool _initialized = false; + final List<MobileCodeRole> _roles = []; + final List<RoleProposal> _proposals = []; + + bool get isInitialized => _initialized; + + List<MobileCodeRole> get allRoles => List.unmodifiable(_roles); + + List<MobileCodeRole> get enabledRoles => + _roles.where((role) => role.enabled).toList(growable: false); + + List<RoleProposal> get pendingProposals => _proposals + .where((proposal) => proposal.status == RoleProposalStatus.pending) + .toList(growable: false); + + List<MobileCodeRole> get recruitmentRoles { + if (_roles.isEmpty) return _defaultRoles.take(5).toList(growable: false); + return enabledRoles.take(5).toList(growable: false); + } + + Future<void> initialize() async { + if (_initialized) return; + _prefs = await SharedPreferences.getInstance(); + _roles + ..clear() + ..addAll(_defaultRoles); + _loadPersistedState(); + _initialized = true; + notifyListeners(); + } + + Future<void> setRoleEnabled(String id, bool enabled) async { + final index = _roles.indexWhere((role) => role.id == id); + if (index == -1) return; + _roles[index] = _roles[index].copyWith(enabled: enabled); + await _persistState(); + notifyListeners(); + } + + Future<void> upsertCustomRole(MobileCodeRole role) async { + final normalized = role.copyWith( + id: role.id.trim().isEmpty ? _newCustomRoleId() : role.id, + builtIn: false, + enabled: true, + ); + final index = _roles.indexWhere((item) => item.id == normalized.id); + if (index == -1) { + _roles.add(normalized); + } else { + _roles[index] = normalized; + } + await _persistState(); + notifyListeners(); + } + + Future<void> removeCustomRole(String id) async { + _roles.removeWhere((role) => role.id == id && !role.builtIn); + await _persistState(); + notifyListeners(); + } + + Future<RoleProposal?> createProposalFromPrompt( + String prompt, + String runId, + List<MobileCodeRole> enabledRoles, + ) async { + final promptText = prompt.trim(); + if (promptText.isEmpty) return null; + final candidates = enabledRoles.isEmpty ? _defaultRoles : enabledRoles; + final scored = candidates + .map((role) => MapEntry(role, _scoreRoleForPrompt(promptText, role))) + .toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final best = scored.isEmpty ? _defaultRoles.first : scored.first.key; + final bestScore = scored.isEmpty ? 0 : scored.first.value; + if (bestScore >= 3) return null; + + final title = _titleFromIntent(promptText); + final proposedRole = best.copyWith( + id: _newCustomRoleId(), + name: title.length <= 24 ? title : '${title.substring(0, 24)}...', + summary: 'Suggested role for this task. Pending approval.', + mission: _compact(promptText, 120), + personality: '${best.personality} Adapt the role to this concrete user task.', + responsibilities: [ + ...best.responsibilities.take(2), + 'Own this task-specific workflow until the result is explainable.', + 'Convert lessons from this run into a reusable role card if approved.', + ], + guardrails: [ + ...best.guardrails.take(2), + 'Do not become a parallel agent; stay inside the RR single execution lane.', + ], + successCriteria: [ + ...best.successCriteria.take(2), + 'The user can decide whether this role is worth saving.', + ], + promptTemplate: + 'You are a task-specific MobileCode role derived from ${best.name}. Focus on: ${_compact(promptText, 180)}. Keep work bounded, mobile-first, and verifiable.', + builtIn: false, + enabled: true, + ); + final proposal = RoleProposal( + proposalId: _newRoleProposalId(), + sourceRoleId: best.id, + role: proposedRole, + prompt: _compact(promptText, 360), + rationale: + 'No enabled role matched this prompt strongly enough. MobileCode adapted ${best.name} for this run; save it only if this specialty should be reused.', + runId: runId, + status: RoleProposalStatus.pending, + createdAt: DateTime.now(), + ); + _proposals.insert(0, proposal); + _trimProposals(); + await _persistState(); + notifyListeners(); + return proposal; + } + + Future<void> acceptProposal(String proposalId, {MobileCodeRole? editedRole}) async { + final index = _proposals.indexWhere((proposal) => proposal.proposalId == proposalId); + if (index == -1) return; + final proposal = _proposals[index]; + await upsertCustomRole((editedRole ?? proposal.role).copyWith(builtIn: false, enabled: true)); + _proposals[index] = proposal.copyWith(status: RoleProposalStatus.accepted); + await _persistState(); + notifyListeners(); + } + + Future<void> dismissProposal(String proposalId) async { + final index = _proposals.indexWhere((proposal) => proposal.proposalId == proposalId); + if (index == -1) return; + _proposals[index] = _proposals[index].copyWith(status: RoleProposalStatus.dismissed); + await _persistState(); + notifyListeners(); + } + + MobileCodeRole standardizeLocalIntent(String intent) { + final compact = _compact(intent, 72); + final title = _titleFromIntent(compact); + final mission = compact.isEmpty + ? 'Turn a rough mobile coding task into a bounded, testable contribution.' + : compact; + return MobileCodeRole( + id: _newCustomRoleId(), + name: title, + summary: 'Custom role standardized from your intent.', + mission: mission, + personality: 'Calm, explicit, mobile-first, and biased toward small verifiable steps.', + responsibilities: const [ + 'Clarify the user intent before changing code.', + 'Keep edits scoped to the requested product surface.', + 'Explain failures with a concrete recovery action.', + ], + guardrails: const [ + 'Do not invent platform capabilities.', + 'Do not run destructive commands without explicit confirmation.', + ], + successCriteria: const [ + 'The output has a clear owner and acceptance criteria.', + 'The next action can be verified on phone or CI.', + ], + promptTemplate: + 'You are this MobileCode role inside a single execution lane. Own the requested scope, keep the work mobile-first, and return concrete implementation guidance with risks and verification steps.', + avatarAsset: defaultCustomAvatarAsset, + colorValue: 0xFF7557E8, + ); + } + + MobileCodeRole parsePolishedOutput(String output, {String fallbackIntent = ''}) { + final jsonText = _extractJsonObject(output); + if (jsonText == null) return standardizeLocalIntent(fallbackIntent); + try { + final decoded = jsonDecode(jsonText); + if (decoded is! Map<String, dynamic>) return standardizeLocalIntent(fallbackIntent); + final fallback = standardizeLocalIntent(fallbackIntent); + return MobileCodeRole( + id: _newCustomRoleId(), + name: _nonEmpty(decoded['name'], fallback.name), + summary: _nonEmpty(decoded['summary'], fallback.summary), + mission: _nonEmpty(decoded['mission'], fallback.mission), + personality: _nonEmpty(decoded['personality'], fallback.personality), + responsibilities: _stringList(decoded['responsibilities']).isEmpty + ? fallback.responsibilities + : _stringList(decoded['responsibilities']), + guardrails: _stringList(decoded['guardrails']).isEmpty + ? fallback.guardrails + : _stringList(decoded['guardrails']), + successCriteria: _stringList(decoded['successCriteria']).isEmpty + ? fallback.successCriteria + : _stringList(decoded['successCriteria']), + promptTemplate: _nonEmpty(decoded['promptTemplate'], fallback.promptTemplate), + avatarAsset: defaultCustomAvatarAsset, + colorValue: fallback.colorValue, + ); + } catch (_) { + return standardizeLocalIntent(fallbackIntent); + } + } + + void _loadPersistedState() { + final disabledIds = (_prefs?.getStringList(_disabledRoleIdsKey) ?? const <String>[]).toSet(); + for (var index = 0; index < _roles.length; index++) { + final role = _roles[index]; + _roles[index] = role.copyWith(enabled: !disabledIds.contains(role.id)); + } + + final rawCustomRoles = _prefs?.getString(_customRolesKey); + if (rawCustomRoles == null || rawCustomRoles.trim().isEmpty) return; + try { + final decoded = jsonDecode(rawCustomRoles); + if (decoded is List) { + for (final item in decoded) { + if (item is Map<String, dynamic>) { + _roles.add(MobileCodeRole.fromJson(item).copyWith(builtIn: false)); + } + } + } + } catch (error) { + debugPrint('[RoleLibrary] Failed to load custom roles: $error'); + } + + final rawProposals = _prefs?.getString(_roleProposalsKey); + if (rawProposals == null || rawProposals.trim().isEmpty) return; + try { + final decoded = jsonDecode(rawProposals); + if (decoded is List) { + _proposals + ..clear() + ..addAll( + decoded + .whereType<Map>() + .map((item) => RoleProposal.fromJson(Map<String, dynamic>.from(item))), + ); + _trimProposals(); + } + } catch (error) { + debugPrint('[RoleLibrary] Failed to load role proposals: $error'); + } + } + + Future<void> _persistState() async { + final customRoles = _roles.where((role) => !role.builtIn).map((role) => role.toJson()).toList(); + final disabledBuiltIns = _roles + .where((role) => role.builtIn && !role.enabled) + .map((role) => role.id) + .toList(growable: false); + await _prefs?.setString(_customRolesKey, jsonEncode(customRoles)); + await _prefs?.setStringList(_disabledRoleIdsKey, disabledBuiltIns); + await _prefs?.setString(_roleProposalsKey, jsonEncode(_proposals.map((proposal) => proposal.toJson()).toList())); + } + + void _trimProposals() { + _proposals.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + if (_proposals.length > 30) { + _proposals.removeRange(30, _proposals.length); + } + } +} + +String _newCustomRoleId() => 'custom_${DateTime.now().microsecondsSinceEpoch}'; + +String _newRoleProposalId() => 'proposal_${DateTime.now().microsecondsSinceEpoch}'; + +String _nonEmpty(Object? value, String fallback) { + final text = value?.toString().trim(); + return text == null || text.isEmpty ? fallback : text; +} + +List<String> _stringList(Object? value) { + if (value is List) { + return value + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + if (value is String && value.trim().isNotEmpty) { + return value + .split(RegExp(r'[\n,;]')) + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + return const []; +} + +String? _extractJsonObject(String output) { + final fenced = RegExp(r'```(?:json)?\s*([\s\S]*?)```').firstMatch(output)?.group(1); + final source = fenced ?? output; + final start = source.indexOf('{'); + final end = source.lastIndexOf('}'); + if (start == -1 || end <= start) return null; + return source.substring(start, end + 1); +} + +String _compact(String value, int limit) { + final normalized = value.trim().replaceAll(RegExp(r'\s+'), ' '); + if (normalized.length <= limit) return normalized; + return '${normalized.substring(0, limit - 1)}…'; +} + +String _titleFromIntent(String intent) { + if (intent.isEmpty) return 'Custom Builder'; + final cleaned = intent.replaceAll(RegExp('[^\\w\\s\u4e00-\u9fff-]'), ' ').trim(); + final words = cleaned.split(RegExp(r'\s+')).where((word) => word.isNotEmpty).take(3); + final title = words.map((word) => word[0].toUpperCase() + word.substring(1)).join(' '); + if (title.isNotEmpty) return title; + return intent.length <= 10 ? intent : '${intent.substring(0, 10)}...'; +} + +int _scoreRoleForPrompt(String prompt, MobileCodeRole role) { + final haystack = [ + prompt.toLowerCase(), + if (prompt.contains('网页')) ' web html mobile_web_builder ', + if (prompt.contains('界面') || prompt.contains('设计') || prompt.contains('布局')) ' ui designer ', + if (prompt.contains('发布') || prompt.contains('部署')) ' release publish pages ', + if (prompt.contains('运行') || prompt.contains('构建') || prompt.contains('终端')) ' runtime build shell ', + if (prompt.contains('无障碍') || prompt.contains('可访问')) ' accessibility ', + ].join(' '); + final roleText = [ + role.id, + role.name, + role.summary, + role.mission, + role.personality, + ...role.responsibilities, + ...role.guardrails, + ...role.successCriteria, + ].join(' ').toLowerCase(); + final tokens = roleText + .split(RegExp(r'[^a-z0-9_+#.-]+')) + .where((token) => token.length >= 4) + .toSet(); + var score = 0; + for (final token in tokens) { + if (haystack.contains(token)) score++; + } + if (role.id.contains('github') && haystack.contains('github')) score += 3; + if (role.id.contains('web') && (haystack.contains('html') || haystack.contains('web'))) score += 2; + if (role.id.contains('runtime') && (haystack.contains('runtime') || haystack.contains('build'))) score += 2; + if (role.id.contains('release') && (haystack.contains('publish') || haystack.contains('pages'))) score += 2; + return score; +} + +const _defaultRoles = <MobileCodeRole>[ + MobileCodeRole( + id: 'planner', + name: 'Planner', + summary: 'Breaks a request into the smallest useful mobile coding plan.', + mission: 'Clarify the request and choose the safest build path.', + personality: 'Structured, skeptical, and practical.', + responsibilities: [ + 'Clarify scope and stop line.', + 'Pick the next smallest verifiable step.', + 'Name risks before implementation.', + ], + guardrails: [ + 'No speculative platform expansion.', + 'No unrelated refactors.', + ], + successCriteria: [ + 'The task has a concrete next action.', + 'The user can understand why this path matters.', + ], + promptTemplate: + 'You are Planner inside one MobileCode execution lane. Clarify the goal, reduce scope, and propose the smallest verifiable action.', + avatarAsset: 'assets/role_avatars/avatar-batch2-01-mist-studio.svg', + colorValue: 0xFF7557E8, + builtIn: true, + ), + MobileCodeRole( + id: 'ui_designer', + name: 'UI Designer', + summary: 'Keeps generated HTML and app UI polished on phone screens.', + mission: 'Keep the generated web page mobile-first and visually polished.', + personality: 'Tasteful, precise, and allergic to cramped layouts.', + responsibilities: [ + 'Protect mobile spacing and touch targets.', + 'Choose coherent visual hierarchy.', + 'Flag overflow and unclear affordances.', + ], + guardrails: [ + 'No decorative clutter that hides content.', + 'No unreadable small-screen text.', + ], + successCriteria: [ + 'The UI works at 360dp width.', + 'Primary actions are visible and tappable.', + ], + promptTemplate: + 'You are UI Designer inside one MobileCode execution lane. Make the UI mobile-first, legible, and visually coherent.', + avatarAsset: 'assets/role_avatars/avatar-batch2-02-office-glasses.svg', + colorValue: 0xFFB7791F, + builtIn: true, + ), + MobileCodeRole( + id: 'mobile_web_builder', + name: 'Mobile Web Builder', + summary: 'Builds self-contained HTML artifacts that preview well in WebView.', + mission: 'Write the local HTML artifact and keep it self-contained.', + personality: 'Fast, concrete, and implementation-minded.', + responsibilities: [ + 'Generate portable index.html files.', + 'Avoid private path leaks in generated pages.', + 'Keep code readable enough for phone inspection.', + ], + guardrails: [ + 'No hidden external dependencies unless requested.', + 'No desktop-only interaction model.', + ], + successCriteria: [ + 'The artifact opens in WebView and browser.', + 'The code can be copied or published.', + ], + promptTemplate: + 'You are Mobile Web Builder inside one MobileCode execution lane. Produce self-contained, previewable HTML that works on mobile.', + avatarAsset: 'assets/role_avatars/avatar-batch2-09-blue-cap.svg', + colorValue: 0xFF16B9C7, + builtIn: true, + ), + MobileCodeRole( + id: 'runtime_reviewer', + name: 'Runtime Reviewer', + summary: 'Checks runtime paths, provider state, and recovery hints.', + mission: 'Check file paths, previewability, and recovery hints.', + personality: 'Calm, diagnostic, and explicit about constraints.', + responsibilities: [ + 'Explain missing runtime capabilities.', + 'Verify file/folder actions stay in workspace.', + 'Turn errors into next-step recovery guidance.', + ], + guardrails: [ + 'No unsafe shell escalation.', + 'No silent fallback that hides failure.', + ], + successCriteria: [ + 'Failures have a user-facing cause.', + 'Recovery actions are visible.', + ], + promptTemplate: + 'You are Runtime Reviewer inside one MobileCode execution lane. Diagnose runtime state, paths, and safe recovery steps.', + avatarAsset: 'assets/role_avatars/avatar-batch2-15-tech.svg', + colorValue: 0xFF0B9B7E, + builtIn: true, + ), + MobileCodeRole( + id: 'release_checker', + name: 'Release Checker', + summary: 'Prepares a result for sharing, Pages publish, and QA.', + mission: 'Prepare the result for browser preview and GitHub Pages.', + personality: 'Release-minded, concise, and evidence-driven.', + responsibilities: [ + 'Check publish readiness.', + 'Surface code URL, Pages URL, and local file path.', + 'Record verification evidence.', + ], + guardrails: [ + 'No release claim without an artifact.', + 'No hiding permission or token failures.', + ], + successCriteria: [ + 'The user can open the work and repo.', + 'The result has a clear verification status.', + ], + promptTemplate: + 'You are Release Checker inside one MobileCode execution lane. Turn completed work into shareable, verifiable output.', + avatarAsset: 'assets/role_avatars/avatar-batch2-18-pencil-wash.svg', + colorValue: 0xFF2555FF, + builtIn: true, + ), + MobileCodeRole( + id: 'accessibility_auditor', + name: 'Accessibility Auditor', + summary: 'Reviews generated pages for basic semantic and touch accessibility.', + mission: 'Find accessibility blockers before preview or publish.', + personality: 'Careful, inclusive, and specific.', + responsibilities: [ + 'Check headings, alt text, labels, and focus states.', + 'Flag low contrast and tiny tap targets.', + 'Suggest minimal fixes.', + ], + guardrails: [ + 'No cosmetic-only accessibility claims.', + 'No inaccessible icon-only controls without labels.', + ], + successCriteria: [ + 'Core actions have readable labels.', + 'Warnings are actionable.', + ], + promptTemplate: + 'You are Accessibility Auditor inside one MobileCode execution lane. Review mobile web output for semantic, touch, and readability issues.', + avatarAsset: 'assets/role_avatars/avatar-batch2-21-navy.svg', + colorValue: 0xFF4F8F2D, + builtIn: true, + ), + MobileCodeRole( + id: 'github_publisher', + name: 'GitHub Publisher', + summary: 'Owns repository, Pages, Actions, and artifact status details.', + mission: 'Keep GitHub operations understandable and recoverable.', + personality: 'Operational, exact, and permission-aware.', + responsibilities: [ + 'Map token errors to recovery actions.', + 'Connect local artifacts to repos and Pages URLs.', + 'Explain Actions and artifact state.', + ], + guardrails: [ + 'No broad token advice without scope details.', + 'No destructive repo changes without confirmation.', + ], + successCriteria: [ + 'GitHub failures name the missing permission.', + 'Publish success includes Pages and repo links.', + ], + promptTemplate: + 'You are GitHub Publisher inside one MobileCode execution lane. Make repo, Pages, Actions, and artifact workflows clear and reversible.', + avatarAsset: 'assets/role_avatars/avatar-batch2-23-mono.svg', + colorValue: 0xFF0B1020, + builtIn: true, + ), + MobileCodeRole( + id: 'prompt_refiner', + name: 'Prompt Refiner', + summary: 'Turns vague app ideas into buildable MobileCode instructions.', + mission: 'Convert user intent into a crisp implementation prompt.', + personality: 'Curious, compact, and product-aware.', + responsibilities: [ + 'Extract the product goal.', + 'Add constraints the agent needs to build safely.', + 'Preserve the user’s intent and tone.', + ], + guardrails: [ + 'No over-specifying beyond the user’s ask.', + 'No hidden feature expansion.', + ], + successCriteria: [ + 'The prompt is buildable in one pass.', + 'The user can recognize their original idea.', + ], + promptTemplate: + 'You are Prompt Refiner inside one MobileCode execution lane. Make rough requests precise without changing the user’s intent.', + avatarAsset: 'assets/role_avatars/avatar-batch2-35-yellow-bucket.svg', + colorValue: 0xFFE0526E, + builtIn: true, + ), +]; diff --git a/mobile_agent/lib/services/runtime_manager.dart b/mobile_agent/lib/services/runtime_manager.dart index a04fd22..6118590 100644 --- a/mobile_agent/lib/services/runtime_manager.dart +++ b/mobile_agent/lib/services/runtime_manager.dart @@ -1,26 +1,26 @@ -// lib/services/runtime_manager.dart -// Selects and routes commands to the best available MobileCode runtime. - -import 'dart:async'; - -import 'external_termux_provider.dart'; -import 'mobile_code_helper_provider.dart'; -import 'runtime_actions.dart'; -import 'runtime_placeholder_providers.dart'; -import 'runtime_provider.dart'; -import 'termux_service.dart'; - -class RuntimeManager { - final List<RuntimeProvider> _providers; - final StreamController<String> _logController = StreamController<String>.broadcast(); - final List<StreamSubscription<String>> _logSubscriptions = []; - - RuntimeProvider? _activeProvider; - RuntimeHealth? _activeHealth; - bool _initialized = false; - - RuntimeManager({required List<RuntimeProvider> providers}) : _providers = providers; - +// lib/services/runtime_manager.dart +// Selects and routes commands to the best available MobileCode runtime. + +import 'dart:async'; + +import 'external_termux_provider.dart'; +import 'mobile_code_helper_provider.dart'; +import 'runtime_actions.dart'; +import 'runtime_placeholder_providers.dart'; +import 'runtime_provider.dart'; +import 'termux_service.dart'; + +class RuntimeManager { + final List<RuntimeProvider> _providers; + final StreamController<String> _logController = StreamController<String>.broadcast(); + final List<StreamSubscription<String>> _logSubscriptions = []; + + RuntimeProvider? _activeProvider; + RuntimeHealth? _activeHealth; + bool _initialized = false; + + RuntimeManager({required List<RuntimeProvider> providers}) : _providers = providers; + factory RuntimeManager.withExternalTermux( TermuxService termux, { Uri? helperBaseUri, @@ -28,43 +28,51 @@ class RuntimeManager { return RuntimeManager( providers: [ EmbeddedLiteRuntimeProvider(), + TermuxDaemonProvider(baseUri: helperBaseUri), MobileCodeHelperProvider(baseUri: helperBaseUri), ExternalTermuxProvider(termux), CloudRuntimeProvider(), WebViewOnlyRuntimeProvider(), ], - ); - } - - Stream<String> get logStream => _logController.stream; - RuntimeProvider? get activeProvider => _activeProvider; - RuntimeHealth? get activeHealth => _activeHealth; - List<RuntimeProvider> get providers => List.unmodifiable(_providers); - - Future<void> initialize() async { - if (_initialized) return; - - for (final provider in _providers) { - try { - await provider.initialize(); - } catch (e) { - _logController.add('[runtime] ${provider.name} initialization failed: $e'); - } - _logSubscriptions.add(provider.logStream.listen(_logController.add)); - } - - await refresh(); - _initialized = true; - } - + ); + } + + Stream<String> get logStream => _logController.stream; + RuntimeProvider? get activeProvider => _activeProvider; + RuntimeHealth? get activeHealth => _activeHealth; + List<RuntimeProvider> get providers => List.unmodifiable(_providers); + + Future<void> initialize() async { + if (_initialized) return; + + for (final provider in _providers) { + try { + await provider.initialize(); + } catch (e) { + _logController.add('[runtime] ${provider.name} initialization failed: $e'); + } + _logSubscriptions.add(provider.logStream.listen(_logController.add)); + } + + await refresh(); + _initialized = true; + } + Future<List<RuntimeHealth>> refresh() async { final health = <RuntimeHealth>[]; + RuntimeHealth? selectedHealth; + RuntimeProvider? selectedProvider; for (final provider in _providers) { try { - health.add(await provider.healthCheck()); + final item = await provider.healthCheck(); + health.add(item); + if (selectedProvider == null && item.available && item.ready) { + selectedHealth = item; + selectedProvider = provider; + } } catch (e) { - health.add(RuntimeHealth( + final item = RuntimeHealth( type: provider.type, name: provider.name, available: false, @@ -73,364 +81,358 @@ class RuntimeManager { capabilities: RuntimeCapabilities.none, missingDependencies: const ['Health check'], recoveryActions: const ['Open runtime diagnostics and retry.'], - )); - } - } - - _activeHealth = health.firstWhere( - (item) => item.available && item.ready, - orElse: () => health.last, - ); - - _activeProvider = _providers.firstWhere( - (provider) => provider.type == _activeHealth!.type, - orElse: () => _providers.last, - ); - - return health; - } - - Future<RuntimeCapabilities> capabilities() async { - await _ensureReady(); - return _activeProvider!.capabilities(); - } - - Future<RuntimeCommandResult> execute( - String command, { - String? workingDir, - Map<String, String>? environment, - Duration? timeout, - }) async { - await _ensureReady(); - return _activeProvider!.execute( - command, - workingDir: workingDir, - environment: environment, - timeout: timeout, - ); - } - - Stream<String> executeStream( - String command, { - String? workingDir, - Map<String, String>? environment, - }) async* { - await _ensureReady(); - yield* _activeProvider!.executeStream( - command, - workingDir: workingDir, - environment: environment, - ); - } - - Future<RuntimeSyncResult> syncWorkspace({ - required String sourcePath, - required String targetPath, - }) async { - await _ensureReady(); - return _activeProvider!.syncWorkspace(sourcePath: sourcePath, targetPath: targetPath); - } - - Future<BuildResult> buildWeb(String projectPath) async { - await _ensureReady(); - return _activeProvider!.buildWeb(projectPath); - } - - Future<BuildResult> buildApk(String projectPath, {BuildMode mode = BuildMode.debug}) async { - await _ensureReady(); - return _activeProvider!.buildApk(projectPath, mode: mode); - } - - Future<InstallResult> installApk(String apkPath) async { - await _ensureReady(); - return _activeProvider!.installApk(apkPath); - } - - Future<void> launchApp(String packageName) async { - await _ensureReady(); - await _activeProvider!.launchApp(packageName); - } - - Future<void> uninstallApp(String packageName) async { - await _ensureReady(); - await _activeProvider!.uninstallApp(packageName); - } - - Future<void> stopCurrentTask() async { - await _ensureReady(); - await _activeProvider!.stopCurrentTask(); - } - - Future<void> stopTask(String taskId) async { - await _ensureReady(); - final id = taskId.trim(); - if (id.isEmpty) { - throw ArgumentError.value(taskId, 'taskId', 'Task ID is required.'); - } - final provider = _activeProvider!; - if (provider is RuntimeTaskController) { - await (provider as RuntimeTaskController).stopTask(id); - return; - } - if (provider is RuntimeTaskMonitor) { - final task = await (provider as RuntimeTaskMonitor).currentTask(); - if (task?.taskId == id) { - await provider.stopCurrentTask(); - return; - } - } - throw UnsupportedError('${provider.name} cannot stop task $id by ID.'); - } - - Future<RuntimeTaskSnapshot?> currentTaskSnapshot() async { - await _ensureReady(); - final provider = _activeProvider; - if (provider == null || provider is! RuntimeTaskMonitor) return null; - return (provider as RuntimeTaskMonitor).currentTask(); - } - - Future<List<RuntimeTaskSnapshot>> taskHistory({int limit = 20}) async { - await _ensureReady(); - final provider = _activeProvider; - if (provider == null || provider is! RuntimeTaskMonitor) return const []; - return (provider as RuntimeTaskMonitor).listTasks(limit: limit); - } - - Future<List<String>> taskLogs(String taskId, {int limit = 200}) async { - await _ensureReady(); - final provider = _activeProvider; - if (provider == null || provider is! RuntimeTaskMonitor) return const []; - return (provider as RuntimeTaskMonitor).taskLogs(taskId, limit: limit); - } - - Future<RuntimeProjectProfile> preflightProject( - String projectPath, { - String? packageManager, - }) async { - await _ensureReady(); - final provider = _activeProvider!; - final caps = await provider.capabilities(); - if (!caps.shell) { - return runtimeProjectPreflightFailure( - projectPath: projectPath, - summary: 'Active runtime cannot inspect project files because shell execution is unavailable.', - recoveryHint: 'Start MobileCode Helper, External Termux, or Cloud Runtime before running project actions.', - ); - } - - try { - if (provider is RuntimeProjectInspector) { - return await (provider as RuntimeProjectInspector).preflightProject( - projectPath, - packageManager: packageManager, ); + health.add(item); } - - final probe = await provider.execute( - runtimeProjectProbeCommand, - workingDir: projectPath, - timeout: const Duration(seconds: 8), - ); - if (!probe.success) { - return runtimeProjectPreflightFailure( - projectPath: projectPath, - summary: 'Project preflight failed: ${probe.stderr.trim().isEmpty ? probe.stdout.trim() : probe.stderr.trim()}', - recoveryHint: runtimeActionRecoveryHint( - action: RuntimeActionType.installDependencies, - capabilities: caps, - result: probe, - ), - ); - } - - return profileRuntimeProject( - projectPath: projectPath, - probeOutput: probe.stdout, - capabilities: caps, - packageManagerOverride: packageManager, - ); - } on Object catch (error) { - return runtimeProjectPreflightFailure( - projectPath: projectPath, - summary: 'Project preflight failed: $error', - ); - } - } - - Future<RuntimeActionResult> runAction(RuntimeActionRequest request) async { - await _ensureReady(); - final provider = _activeProvider!; - final caps = await provider.capabilities(); - RuntimeActionRequest effectiveRequest = request; - RuntimeProjectProfile? profile; - if (runtimeActionNeedsProjectProfile(request.type) && - normalizeRuntimePackageManager(request.packageManager) == null) { - profile = await preflightProject(request.projectPath); - if (!profile.recognized) { - const skippedReason = 'Project preflight could not identify a supported project.'; - return RuntimeActionResult( - action: request.type, - success: false, - summary: profile.summary, - results: const [], - skippedReason: skippedReason, - recoveryHint: profile.recoveryHint, - ); - } - if (!runtimeProjectToolchainAvailable(profile, caps)) { - const skippedReason = 'Project toolchain is not available in the active runtime.'; - return RuntimeActionResult( - action: request.type, - success: false, - summary: profile.summary, - results: const [], - skippedReason: skippedReason, - recoveryHint: profile.recoveryHint, - ); - } - effectiveRequest = RuntimeActionRequest( - type: request.type, - projectPath: request.projectPath, - message: request.message, - packageManager: profile.packageManager, - timeout: request.timeout, - ); } - final plan = planRuntimeAction(effectiveRequest, caps); - if (plan == null) { - const skippedReason = 'Missing capability or required action input.'; - return RuntimeActionResult( - action: effectiveRequest.type, - success: false, - summary: 'Runtime cannot plan ${effectiveRequest.type.name} with current capabilities.', - results: const [], - skippedReason: skippedReason, - recoveryHint: runtimeActionRecoveryHint( - action: effectiveRequest.type, - capabilities: caps, - skippedReason: skippedReason, - ), - ); - } + _activeHealth = selectedHealth ?? health.last; + _activeProvider = selectedProvider ?? _providers.last; - final results = <RuntimeCommandResult>[]; - for (final command in plan.commands) { - final result = await provider.execute( - command, - workingDir: effectiveRequest.projectPath, - timeout: effectiveRequest.timeout, - ); - results.add(result); - if (!result.success) { - return RuntimeActionResult( - action: effectiveRequest.type, - success: false, - summary: '${plan.summary} Failed at: $command', - results: List.unmodifiable(results), - recoveryHint: runtimeActionRecoveryHint( - action: effectiveRequest.type, - capabilities: caps, - result: result, - ), - ); - } - } - - return RuntimeActionResult( - action: effectiveRequest.type, - success: true, - summary: profile == null ? plan.summary : '${plan.summary} ${profile.summary}', - results: List.unmodifiable(results), - ); - } - - Future<RuntimeActionPipelineResult> validateProject({ - required String projectPath, - String? packageManager, - String? message, - }) async { - await _ensureReady(); - final caps = await _activeProvider!.capabilities(); - final profile = await preflightProject(projectPath, packageManager: packageManager); - if (!profile.recognized) { - return RuntimeActionPipelineResult( - success: false, - summary: profile.summary, - steps: const [], - recoveryHint: profile.recoveryHint, - profile: profile, - ); - } - if (!runtimeProjectToolchainAvailable(profile, caps)) { - return RuntimeActionPipelineResult( - success: false, - summary: profile.summary, - steps: const [], - recoveryHint: profile.recoveryHint, - profile: profile, - ); - } - - final requests = profile.validationActions - .map( - (action) => RuntimeActionRequest( - type: action, - projectPath: projectPath, - packageManager: profile.packageManager, - message: message, - ), - ) - .toList(); - final result = await runActionPipeline(requests); - return RuntimeActionPipelineResult( - success: result.success, - summary: '${profile.summary} ${result.summary}', - steps: result.steps, - recoveryHint: result.recoveryHint, - profile: profile, - ); - } - - Future<RuntimeActionPipelineResult> runActionPipeline( - List<RuntimeActionRequest> requests, - ) async { - final steps = <RuntimeActionResult>[]; - for (final request in requests) { - final result = await runAction(request); - steps.add(result); - if (!result.success) { - return RuntimeActionPipelineResult( - success: false, - summary: 'Stopped at ${request.type.name}: ${result.summary}', - steps: List.unmodifiable(steps), - recoveryHint: result.recoveryHint, - ); - } - } - - return RuntimeActionPipelineResult( - success: true, - summary: 'Runtime validation completed: ${steps.map((step) => step.action.name).join(' -> ')}.', - steps: List.unmodifiable(steps), - ); - } - - Future<void> _ensureReady() async { - if (!_initialized) { - await initialize(); - } - _activeProvider ??= _providers.last; - } - - Future<void> dispose() async { - for (final subscription in _logSubscriptions) { - await subscription.cancel(); - } - _logSubscriptions.clear(); - if (!_logController.isClosed) { - await _logController.close(); - } - _initialized = false; + return health; } -} + + Future<RuntimeCapabilities> capabilities() async { + await _ensureReady(); + return _activeProvider!.capabilities(); + } + + Future<RuntimeCommandResult> execute( + String command, { + String? workingDir, + Map<String, String>? environment, + Duration? timeout, + }) async { + await _ensureReady(); + return _activeProvider!.execute( + command, + workingDir: workingDir, + environment: environment, + timeout: timeout, + ); + } + + Stream<String> executeStream( + String command, { + String? workingDir, + Map<String, String>? environment, + }) async* { + await _ensureReady(); + yield* _activeProvider!.executeStream( + command, + workingDir: workingDir, + environment: environment, + ); + } + + Future<RuntimeSyncResult> syncWorkspace({ + required String sourcePath, + required String targetPath, + }) async { + await _ensureReady(); + return _activeProvider!.syncWorkspace(sourcePath: sourcePath, targetPath: targetPath); + } + + Future<BuildResult> buildWeb(String projectPath) async { + await _ensureReady(); + return _activeProvider!.buildWeb(projectPath); + } + + Future<BuildResult> buildApk(String projectPath, {BuildMode mode = BuildMode.debug}) async { + await _ensureReady(); + return _activeProvider!.buildApk(projectPath, mode: mode); + } + + Future<InstallResult> installApk(String apkPath) async { + await _ensureReady(); + return _activeProvider!.installApk(apkPath); + } + + Future<void> launchApp(String packageName) async { + await _ensureReady(); + await _activeProvider!.launchApp(packageName); + } + + Future<void> uninstallApp(String packageName) async { + await _ensureReady(); + await _activeProvider!.uninstallApp(packageName); + } + + Future<void> stopCurrentTask() async { + await _ensureReady(); + await _activeProvider!.stopCurrentTask(); + } + + Future<void> stopTask(String taskId) async { + await _ensureReady(); + final id = taskId.trim(); + if (id.isEmpty) { + throw ArgumentError.value(taskId, 'taskId', 'Task ID is required.'); + } + final provider = _activeProvider!; + if (provider is RuntimeTaskController) { + await (provider as RuntimeTaskController).stopTask(id); + return; + } + if (provider is RuntimeTaskMonitor) { + final task = await (provider as RuntimeTaskMonitor).currentTask(); + if (task?.taskId == id) { + await provider.stopCurrentTask(); + return; + } + } + throw UnsupportedError('${provider.name} cannot stop task $id by ID.'); + } + + Future<RuntimeTaskSnapshot?> currentTaskSnapshot() async { + await _ensureReady(); + final provider = _activeProvider; + if (provider == null || provider is! RuntimeTaskMonitor) return null; + return (provider as RuntimeTaskMonitor).currentTask(); + } + + Future<List<RuntimeTaskSnapshot>> taskHistory({int limit = 20}) async { + await _ensureReady(); + final provider = _activeProvider; + if (provider == null || provider is! RuntimeTaskMonitor) return const []; + return (provider as RuntimeTaskMonitor).listTasks(limit: limit); + } + + Future<List<String>> taskLogs(String taskId, {int limit = 200}) async { + await _ensureReady(); + final provider = _activeProvider; + if (provider == null || provider is! RuntimeTaskMonitor) return const []; + return (provider as RuntimeTaskMonitor).taskLogs(taskId, limit: limit); + } + + Future<RuntimeProjectProfile> preflightProject( + String projectPath, { + String? packageManager, + }) async { + await _ensureReady(); + final provider = _activeProvider!; + final caps = await provider.capabilities(); + if (!caps.shell) { + return runtimeProjectPreflightFailure( + projectPath: projectPath, + summary: 'Active runtime cannot inspect project files because shell execution is unavailable.', + recoveryHint: 'Start MobileCode Helper, External Termux, or Cloud Runtime before running project actions.', + ); + } + + try { + if (provider is RuntimeProjectInspector) { + return await (provider as RuntimeProjectInspector).preflightProject( + projectPath, + packageManager: packageManager, + ); + } + + final probe = await provider.execute( + runtimeProjectProbeCommand, + workingDir: projectPath, + timeout: const Duration(seconds: 8), + ); + if (!probe.success) { + return runtimeProjectPreflightFailure( + projectPath: projectPath, + summary: 'Project preflight failed: ${probe.stderr.trim().isEmpty ? probe.stdout.trim() : probe.stderr.trim()}', + recoveryHint: runtimeActionRecoveryHint( + action: RuntimeActionType.installDependencies, + capabilities: caps, + result: probe, + ), + ); + } + + return profileRuntimeProject( + projectPath: projectPath, + probeOutput: probe.stdout, + capabilities: caps, + packageManagerOverride: packageManager, + ); + } on Object catch (error) { + return runtimeProjectPreflightFailure( + projectPath: projectPath, + summary: 'Project preflight failed: $error', + ); + } + } + + Future<RuntimeActionResult> runAction(RuntimeActionRequest request) async { + await _ensureReady(); + final provider = _activeProvider!; + final caps = await provider.capabilities(); + RuntimeActionRequest effectiveRequest = request; + RuntimeProjectProfile? profile; + if (runtimeActionNeedsProjectProfile(request.type) && + normalizeRuntimePackageManager(request.packageManager) == null) { + profile = await preflightProject(request.projectPath); + if (!profile.recognized) { + const skippedReason = 'Project preflight could not identify a supported project.'; + return RuntimeActionResult( + action: request.type, + success: false, + summary: profile.summary, + results: const [], + skippedReason: skippedReason, + recoveryHint: profile.recoveryHint, + ); + } + if (!runtimeProjectToolchainAvailable(profile, caps)) { + const skippedReason = 'Project toolchain is not available in the active runtime.'; + return RuntimeActionResult( + action: request.type, + success: false, + summary: profile.summary, + results: const [], + skippedReason: skippedReason, + recoveryHint: profile.recoveryHint, + ); + } + effectiveRequest = RuntimeActionRequest( + type: request.type, + projectPath: request.projectPath, + message: request.message, + packageManager: profile.packageManager, + timeout: request.timeout, + ); + } + + final plan = planRuntimeAction(effectiveRequest, caps); + if (plan == null) { + const skippedReason = 'Missing capability or required action input.'; + return RuntimeActionResult( + action: effectiveRequest.type, + success: false, + summary: 'Runtime cannot plan ${effectiveRequest.type.name} with current capabilities.', + results: const [], + skippedReason: skippedReason, + recoveryHint: runtimeActionRecoveryHint( + action: effectiveRequest.type, + capabilities: caps, + skippedReason: skippedReason, + ), + ); + } + + final results = <RuntimeCommandResult>[]; + for (final command in plan.commands) { + final result = await provider.execute( + command, + workingDir: effectiveRequest.projectPath, + timeout: effectiveRequest.timeout, + ); + results.add(result); + if (!result.success) { + return RuntimeActionResult( + action: effectiveRequest.type, + success: false, + summary: '${plan.summary} Failed at: $command', + results: List.unmodifiable(results), + recoveryHint: runtimeActionRecoveryHint( + action: effectiveRequest.type, + capabilities: caps, + result: result, + ), + ); + } + } + + return RuntimeActionResult( + action: effectiveRequest.type, + success: true, + summary: profile == null ? plan.summary : '${plan.summary} ${profile.summary}', + results: List.unmodifiable(results), + ); + } + + Future<RuntimeActionPipelineResult> validateProject({ + required String projectPath, + String? packageManager, + String? message, + }) async { + await _ensureReady(); + final caps = await _activeProvider!.capabilities(); + final profile = await preflightProject(projectPath, packageManager: packageManager); + if (!profile.recognized) { + return RuntimeActionPipelineResult( + success: false, + summary: profile.summary, + steps: const [], + recoveryHint: profile.recoveryHint, + profile: profile, + ); + } + if (!runtimeProjectToolchainAvailable(profile, caps)) { + return RuntimeActionPipelineResult( + success: false, + summary: profile.summary, + steps: const [], + recoveryHint: profile.recoveryHint, + profile: profile, + ); + } + + final requests = profile.validationActions + .map( + (action) => RuntimeActionRequest( + type: action, + projectPath: projectPath, + packageManager: profile.packageManager, + message: message, + ), + ) + .toList(); + final result = await runActionPipeline(requests); + return RuntimeActionPipelineResult( + success: result.success, + summary: '${profile.summary} ${result.summary}', + steps: result.steps, + recoveryHint: result.recoveryHint, + profile: profile, + ); + } + + Future<RuntimeActionPipelineResult> runActionPipeline( + List<RuntimeActionRequest> requests, + ) async { + final steps = <RuntimeActionResult>[]; + for (final request in requests) { + final result = await runAction(request); + steps.add(result); + if (!result.success) { + return RuntimeActionPipelineResult( + success: false, + summary: 'Stopped at ${request.type.name}: ${result.summary}', + steps: List.unmodifiable(steps), + recoveryHint: result.recoveryHint, + ); + } + } + + return RuntimeActionPipelineResult( + success: true, + summary: 'Runtime validation completed: ${steps.map((step) => step.action.name).join(' -> ')}.', + steps: List.unmodifiable(steps), + ); + } + + Future<void> _ensureReady() async { + if (!_initialized) { + await initialize(); + } + _activeProvider ??= _providers.last; + } + + Future<void> dispose() async { + for (final subscription in _logSubscriptions) { + await subscription.cancel(); + } + _logSubscriptions.clear(); + if (!_logController.isClosed) { + await _logController.close(); + } + _initialized = false; + } +} diff --git a/mobile_agent/lib/services/runtime_provider.dart b/mobile_agent/lib/services/runtime_provider.dart index ef73989..e4772f5 100644 --- a/mobile_agent/lib/services/runtime_provider.dart +++ b/mobile_agent/lib/services/runtime_provider.dart @@ -165,6 +165,36 @@ class RuntimeTaskSnapshot { bool get running => status == RuntimeTaskStatus.running || status == RuntimeTaskStatus.queued; bool get canCancel => running; + + RuntimeTaskSnapshot copyWith({ + String? taskId, + RuntimeTaskStatus? status, + String? command, + String? workingDir, + DateTime? startedAt, + DateTime? finishedAt, + int? exitCode, + Duration? duration, + List<String>? logs, + RuntimeProviderType? providerType, + String? error, + RuntimeTaskFailureKind? failureKind, + }) { + return RuntimeTaskSnapshot( + taskId: taskId ?? this.taskId, + status: status ?? this.status, + command: command ?? this.command, + providerType: providerType ?? this.providerType, + workingDir: workingDir ?? this.workingDir, + startedAt: startedAt ?? this.startedAt, + finishedAt: finishedAt ?? this.finishedAt, + exitCode: exitCode ?? this.exitCode, + duration: duration ?? this.duration, + logs: logs ?? this.logs, + error: error ?? this.error, + failureKind: failureKind ?? this.failureKind, + ); + } } /// Result of syncing workspaces between the app and a runtime backend. diff --git a/mobile_agent/lib/services/skill_manager_service.dart b/mobile_agent/lib/services/skill_manager_service.dart index c72774f..76c64bf 100644 --- a/mobile_agent/lib/services/skill_manager_service.dart +++ b/mobile_agent/lib/services/skill_manager_service.dart @@ -1,1196 +1,1868 @@ -// lib/services/skill_manager_service.dart -// Skill Manager Service - Full lifecycle skill management + MCP server management -// 技能管理与MCP服务器管理服务 - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../models/skill_model.dart'; - -// ═══════════════════════════════════════════════════════════════════════════ -// Exceptions -// ═══════════════════════════════════════════════════════════════════════════ - -class SkillException implements Exception { - final String message; - final String? skillId; - SkillException(this.message, {this.skillId}); - @override - String toString() => 'SkillException[$skillId]: $message'; -} - -class SkillNotFoundException extends SkillException { - SkillNotFoundException(String skillId) : super('Skill not found', skillId: skillId); -} - -class SkillAlreadyInstalledException extends SkillException { - SkillAlreadyInstalledException(String skillId) : super('Skill already installed', skillId: skillId); -} - -class McpServerException implements Exception { - final String message; - final String? serverId; - McpServerException(this.message, {this.serverId}); - @override - String toString() => 'McpServerException[$serverId]: $message'; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Skill Manager Service -// ═══════════════════════════════════════════════════════════════════════════ - -/// Manages the full lifecycle of Skills and MCP Servers. -/// -/// ## Skill Lifecycle -/// Discovery (built-in + GitHub marketplace) -/// -> Import (GitHub one-click / local ZIP / URL) -/// -> Installation -/// -> Enable/Disable -/// -> Update -/// -> Uninstall -/// -/// ## MCP Server Management -/// Register MCP servers from skills -/// -> Enable/Disable -/// -> Monitor status -class SkillManagerService extends ChangeNotifier { - // ── Singleton ────────────────────────────────── - - static SkillManagerService? _instance; - static SkillManagerService get instance => _instance ??= SkillManagerService._internal(); - static void reset() => _instance = null; - - SkillManagerService._internal(); - - factory SkillManagerService() => instance; - - // ── Storage Keys ─────────────────────────────── - - static const String _skillsKey = 'skill_manager_installed_skills'; - static const String _mcpServersKey = 'skill_manager_mcp_servers'; - static const String _logsKey = 'skill_manager_install_logs'; - static const String _enabledSkillsKey = 'skill_manager_enabled_skills'; - - // ── Internal State ───────────────────────────── - - SharedPreferences? _prefs; - final Map<String, Skill> _skills = {}; - final Map<String, McpServer> _mcpServers = {}; - final List<SkillInstallLog> _logs = []; - final List<Skill> _builtInSkills = []; - bool _initialized = false; - - // ── Public Getters ───────────────────────────── - - bool get isInitialized => _initialized; - - /// All registered skills (both installed and built-in). - List<Skill> get allSkills => List.unmodifiable(_skills.values); - - /// Currently installed skills. - List<Skill> get installedSkills => - List.unmodifiable(_skills.values.where((s) => s.isInstalled)); - - /// Enabled skills. - List<Skill> get enabledSkills => - List.unmodifiable(_skills.values.where((s) => s.isEnabled)); - - /// All MCP servers. - List<McpServer> get allMcpServers => List.unmodifiable(_mcpServers.values); - - /// Installation logs. - List<SkillInstallLog> get logs => List.unmodifiable(_logs); - - // ── Initialization ───────────────────────────── - - /// Initialize the service: load persisted skills, MCP servers, and logs. - Future<void> initialize() async { - if (_initialized) return; - - debugPrint('[SkillManager] Initializing...'); - - _prefs = await SharedPreferences.getInstance(); - - // Register built-in skills first - _registerBuiltInSkills(); - - // Load persisted installed skills - await _loadPersistedSkills(); - - // Load persisted MCP servers - await _loadPersistedMcpServers(); - - // Load persisted logs - await _loadPersistedLogs(); - - _initialized = true; - debugPrint('[SkillManager] Initialized: ${_skills.length} skills, ${_mcpServers.length} MCP servers'); - notifyListeners(); - } - - void _ensureInitialized() { - if (!_initialized) { - throw StateError('SkillManagerService not initialized. Call initialize() first.'); - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // Skill Discovery - // ═══════════════════════════════════════════════════════════════════════ - - /// Get built-in skills (pre-bundled with the app). - List<Skill> getBuiltInSkills() { - _ensureInitialized(); - return List.unmodifiable(_builtInSkills); - } - - /// Get skills filtered by installation status. - List<Skill> getSkillsByInstallStatus({required bool installed}) { - _ensureInitialized(); - return List.unmodifiable(_skills.values.where((s) => s.isInstalled == installed)); - } - - /// Get a skill by its ID. - Skill? getSkill(String id) { - _ensureInitialized(); - return _skills[id]; - } - +// lib/services/skill_manager_service.dart +// Skill Manager Service - Full lifecycle skill management + MCP server management +// 技能管理与MCP服务器管理服务 + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/skill_model.dart'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Exceptions +// ═══════════════════════════════════════════════════════════════════════════ + +class SkillException implements Exception { + final String message; + final String? skillId; + SkillException(this.message, {this.skillId}); + @override + String toString() => 'SkillException[$skillId]: $message'; +} + +class SkillNotFoundException extends SkillException { + SkillNotFoundException(String skillId) : super('Skill not found', skillId: skillId); +} + +class SkillAlreadyInstalledException extends SkillException { + SkillAlreadyInstalledException(String skillId) : super('Skill already installed', skillId: skillId); +} + +class McpServerException implements Exception { + final String message; + final String? serverId; + McpServerException(this.message, {this.serverId}); + @override + String toString() => 'McpServerException[$serverId]: $message'; +} + +class RegistrySourceException implements Exception { + final String message; + RegistrySourceException(this.message); + @override + String toString() => 'RegistrySourceException: $message'; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Skill Manager Service +// ═══════════════════════════════════════════════════════════════════════════ + +/// Manages the full lifecycle of Skills and MCP Servers. +/// +/// ## Skill Lifecycle +/// Discovery (built-in + GitHub marketplace) +/// -> Import (GitHub one-click / local ZIP / URL) +/// -> Installation +/// -> Enable/Disable +/// -> Update +/// -> Uninstall +/// +/// ## MCP Server Management +/// Register MCP servers from skills +/// -> Enable/Disable +/// -> Monitor status +class SkillManagerService extends ChangeNotifier { + // ── Singleton ────────────────────────────────── + + static SkillManagerService? _instance; + static SkillManagerService get instance => _instance ??= SkillManagerService._internal(); + static void reset() => _instance = null; + + SkillManagerService._internal(); + + factory SkillManagerService() => instance; + + // ── Storage Keys ─────────────────────────────── + + static const String _skillsKey = 'skill_manager_installed_skills'; + static const String _mcpServersKey = 'skill_manager_mcp_servers'; + static const String _logsKey = 'skill_manager_install_logs'; + static const String _enabledSkillsKey = 'skill_manager_enabled_skills'; + static const String _builtInSkillStateKey = 'skill_manager_builtin_skill_state'; + + // ── Internal State ───────────────────────────── + + SharedPreferences? _prefs; + final Map<String, Skill> _skills = {}; + final Map<String, McpServer> _mcpServers = {}; + final List<SkillInstallLog> _logs = []; + final List<Skill> _builtInSkills = []; + bool _initialized = false; + + // ── Public Getters ───────────────────────────── + + bool get isInitialized => _initialized; + + /// All registered skills (both installed and built-in). + List<Skill> get allSkills => List.unmodifiable(_skills.values); + + /// Currently installed skills. + List<Skill> get installedSkills => + List.unmodifiable(_skills.values.where((s) => s.isInstalled)); + + /// Enabled skills. + List<Skill> get enabledSkills => + List.unmodifiable(_skills.values.where((s) => s.isEnabled)); + + /// All MCP servers. + List<McpServer> get allMcpServers => List.unmodifiable(_mcpServers.values); + + /// Installation logs. + List<SkillInstallLog> get logs => List.unmodifiable(_logs); + + // ── Initialization ───────────────────────────── + + /// Initialize the service: load persisted skills, MCP servers, and logs. + Future<void> initialize() async { + if (_initialized) return; + + debugPrint('[SkillManager] Initializing...'); + + _prefs = await SharedPreferences.getInstance(); + + // Register built-in skills first + _registerBuiltInSkills(); + + // Load persisted installed skills + await _loadPersistedSkills(); + + // Apply persisted built-in uninstall/enable overrides after defaults register. + await _loadPersistedBuiltInSkillState(); + + // Load persisted MCP servers + await _loadPersistedMcpServers(); + + // Load persisted logs + await _loadPersistedLogs(); + + _initialized = true; + debugPrint('[SkillManager] Initialized: ${_skills.length} skills, ${_mcpServers.length} MCP servers'); + notifyListeners(); + } + + void _ensureInitialized() { + if (!_initialized) { + throw StateError('SkillManagerService not initialized. Call initialize() first.'); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Skill Discovery + // ═══════════════════════════════════════════════════════════════════════ + + /// Get built-in skills (pre-bundled with the app). + List<Skill> getBuiltInSkills() { + _ensureInitialized(); + return List.unmodifiable(_builtInSkills); + } + + /// Get skills filtered by installation status. + List<Skill> getSkillsByInstallStatus({required bool installed}) { + _ensureInitialized(); + return List.unmodifiable(_skills.values.where((s) => s.isInstalled == installed)); + } + + /// Get a skill by its ID. + Skill? getSkill(String id) { + _ensureInitialized(); + return _skills[id]; + } + /// Search skills by query (matches name, description, tags, author). Future<List<Skill>> searchSkills(String query) async { - _ensureInitialized(); + if (!_initialized) { + await initialize(); + } final lowerQuery = query.toLowerCase(); - return List.unmodifiable( - _skills.values.where((skill) { - return skill.name.toLowerCase().contains(lowerQuery) || - skill.description.toLowerCase().contains(lowerQuery) || - skill.author.toLowerCase().contains(lowerQuery) || - skill.tags.any((t) => t.toLowerCase().contains(lowerQuery)) || - skill.actions.any((a) => a.toLowerCase().contains(lowerQuery)); - }), - ); - } - - /// Get skills filtered by a specific tag. - List<Skill> getSkillsByTag(String tag) { - _ensureInitialized(); - final lowerTag = tag.toLowerCase(); - return List.unmodifiable( - _skills.values.where((s) => s.tags.any((t) => t.toLowerCase() == lowerTag)), - ); - } - - /// Get available skills that are not yet installed. - Future<List<Skill>> getAvailableSkills() async { - _ensureInitialized(); - return getSkillsByInstallStatus(installed: false); - } - - // ═══════════════════════════════════════════════════════════════════════ - // GitHub Skill Discovery - // ═══════════════════════════════════════════════════════════════════════ - - /// Search GitHub for repositories tagged as mobilecode skills. - /// - /// Uses GitHub search API to find repositories with topic "mobilecode-skill". + return List.unmodifiable( + _skills.values.where((skill) { + return skill.name.toLowerCase().contains(lowerQuery) || + skill.description.toLowerCase().contains(lowerQuery) || + skill.author.toLowerCase().contains(lowerQuery) || + skill.tags.any((t) => t.toLowerCase().contains(lowerQuery)) || + skill.actions.any((a) => a.toLowerCase().contains(lowerQuery)); + }), + ); + } + + /// Get skills filtered by a specific tag. + List<Skill> getSkillsByTag(String tag) { + _ensureInitialized(); + final lowerTag = tag.toLowerCase(); + return List.unmodifiable( + _skills.values.where((s) => s.tags.any((t) => t.toLowerCase() == lowerTag)), + ); + } + + /// Get available skills that are not yet installed. + Future<List<Skill>> getAvailableSkills() async { + _ensureInitialized(); + return getSkillsByInstallStatus(installed: false); + } + + /// Build a compact prompt context from enabled HTML/UI skills. + /// + /// This is the bridge from Skill Manager state into the mini-agent loop. It + /// intentionally returns guidance, not executable plugin code. + Future<String> buildHtmlGenerationSkillContext() async { + if (!_initialized) { + await initialize(); + } + + final active = _skills.values + .where((skill) => skill.isInstalled && skill.isEnabled) + .where((skill) => + skill.tags.any((tag) { + final lower = tag.toLowerCase(); + return lower == 'html' || + lower == 'ui' || + lower == 'ux' || + lower == 'accessibility' || + lower == 'animation' || + lower == 'design-system'; + }) || + skill.actions.any((action) => action.startsWith('html.') || action.startsWith('a11y.') || action.startsWith('motion.'))) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + + if (active.isEmpty) { + return 'No optional HTML/UI skills are enabled. Use the baseline mobile-first HTML requirements only.'; + } + + final buffer = StringBuffer() + ..writeln('Active MobileCode HTML/UI skills:') + ..writeln('- Apply these as product-native guidance, not as external tool execution.'); + + for (final skill in active.take(8)) { + buffer.writeln('- ${skill.id}: ${skill.description}'); + if (skill.actions.isNotEmpty) { + buffer.writeln(' Actions: ${skill.actions.take(3).join(', ')}'); + } + if (skill.prompts.isNotEmpty) { + buffer.writeln(' Prompt gates: ${skill.prompts.take(3).join(', ')}'); + } + } + + buffer.writeln('Required HTML output gates: mobile-first layout, semantic HTML, touch-friendly controls, accessible labels/focus states, reduced-motion fallback, cohesive visual system, no remote assets unless explicitly requested.'); + return buffer.toString().trim(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // GitHub Skill Discovery + // ═══════════════════════════════════════════════════════════════════════ + + /// Search GitHub for public skill/MCP repositories. + /// + /// This borrows the GitMarket-style discovery pattern (query + category-like + /// provenance + stars/recency ranking) but adapts it to skills instead of APKs. + /// Results are GitHub metadata only; installation still requires manifest + /// preview through [importFromGitHub]. Future<List<Skill>> searchGitHubSkills({String? query, String language = ''}) async { - _ensureInitialized(); - - try { - final q = StringBuffer('topic:mobilecode-skill'); - if (query != null && query.isNotEmpty) { - q.write(' $query'); - } - if (language.isNotEmpty) { - q.write(' language:$language'); - } - - final uri = Uri.https( - 'api.github.com', - '/search/repositories', - {'q': q.toString(), 'sort': 'stars', 'order': 'desc', 'per_page': '30'}, - ); - - debugPrint('[SkillManager] Searching GitHub: ${uri.toString()}'); - final response = await http.get(uri); - - if (response.statusCode != 200) { - throw SkillException('GitHub API error: ${response.statusCode}'); - } - - final data = jsonDecode(response.body) as Map<String, dynamic>; - final items = data['items'] as List<dynamic>? ?? []; - - final results = <Skill>[]; - for (final item in items) { - final repo = item as Map<String, dynamic>; - final fullName = repo['full_name'] as String? ?? ''; - final stars = repo['stargazers_count'] as int? ?? 0; - - // Construct a Skill from GitHub repo metadata - // (Detailed skill.yaml will be fetched during import) - final skill = Skill( - id: fullName.replaceAll('/', '_'), - name: repo['name'] as String? ?? 'Unknown', - version: '1.0.0', - description: repo['description'] as String? ?? 'No description', - author: fullName.split('/').first, - tags: ['github', ...(repo['topics'] as List<dynamic>?)?.cast<String>() ?? []], - actions: const [], - prompts: const [], - mcpServers: const [], - source: SkillSource.github, - githubUrl: repo['html_url'] as String?, - rating: 0.0, - installCount: stars, - ); - results.add(skill); - } - - debugPrint('[SkillManager] Found ${results.length} skills on GitHub'); - return results; - } catch (e) { - debugPrint('[SkillManager] GitHub search failed: $e'); - // Return demo data if API fails - return _getDemoGitHubSkills(); - } - } - - /// Get trending/popular skills from GitHub. - Future<List<Skill>> getTrendingSkills() async { - return searchGitHubSkills(query: 'stars:>5'); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Import - // ═══════════════════════════════════════════════════════════════════════ - - /// Import a skill from a GitHub repository URL. - /// - /// Flow: - /// 1. Parse the GitHub URL - /// 2. Fetch skill.yaml from the repo's default branch - /// 3. Parse the manifest into a Skill object - /// 4. Return the skill for preview before installation - Future<Skill> importFromGitHub(String githubUrl) async { - _ensureInitialized(); - debugPrint('[SkillManager] Importing from GitHub: $githubUrl'); - - try { - // Parse URL: https://github.com/user/repo -> raw.githubusercontent.com - final parsed = Uri.parse(githubUrl); - final segments = parsed.pathSegments; - if (segments.length < 2) { - throw SkillException('Invalid GitHub URL: $githubUrl'); - } - - final owner = segments[0]; - final repo = segments[1]; - - // Try to fetch skill.yaml from main branch first, then master - String? yamlContent; - for (final branch in ['main', 'master']) { - final rawUri = Uri.https( - 'raw.githubusercontent.com', - '/$owner/$repo/$branch/skill.yaml', - ); - final response = await http.get(rawUri); - if (response.statusCode == 200) { - yamlContent = response.body; - break; - } - } - - // Fallback: try skill.yml - yamlContent ??= await _tryFetchYamlAlternate(owner, repo); - - if (yamlContent == null) { - // If no skill.yaml found, create a skill from repo metadata - return _skillFromGitHubMetadata(githubUrl, owner, repo); - } - - // Parse YAML content (simplified YAML-like parsing) - final manifest = _parseYamlLike(yamlContent); - final skill = Skill.fromManifest( - manifest, - source: SkillSource.github, - githubUrl: githubUrl, - ); - - // Store in registry (not installed yet) - _skills[skill.id] = skill; - notifyListeners(); - - debugPrint('[SkillManager] Imported skill: ${skill.id}'); - return skill; - } catch (e) { - debugPrint('[SkillManager] GitHub import failed: $e'); - throw SkillException('Failed to import from GitHub: $e'); - } - } - - /// Import from a local ZIP file path. - Future<Skill> importFromZip(String zipPath) async { - _ensureInitialized(); - debugPrint('[SkillManager] Importing from ZIP: $zipPath'); - - try { - final file = File(zipPath); - if (!await file.exists()) { - throw SkillException('ZIP file not found: $zipPath'); - } - - // Extract to temp directory and parse skill.yaml - final appDir = await getApplicationDocumentsDirectory(); - final extractDir = Directory(path.join(appDir.path, 'skills', '_temp_${DateTime.now().millisecondsSinceEpoch}')); - await extractDir.create(recursive: true); - - // TODO: Implement ZIP extraction (using archive package) - // For now, look for skill.yaml in the same directory - final skillYamlFile = File(path.join(file.parent.path, 'skill.yaml')); - if (!await skillYamlFile.exists()) { - throw SkillException('skill.yaml not found in ZIP'); - } - - final content = await skillYamlFile.readAsString(); - final manifest = _parseYamlLike(content); - final skill = Skill.fromManifest( - manifest, - source: SkillSource.local, - localPath: extractDir.path, - ); - - _skills[skill.id] = skill; - notifyListeners(); - - return skill; - } catch (e) { - throw SkillException('Failed to import from ZIP: $e'); + if (!_initialized) { + await initialize(); } - } - - /// Import from a generic URL. + + try { + final queries = _githubSkillDiscoveryQueries(query: query, language: language); + final byId = <String, Skill>{}; + + for (final q in queries) { + final uri = Uri.https( + 'api.github.com', + '/search/repositories', + {'q': q, 'sort': 'stars', 'order': 'desc', 'per_page': '12'}, + ); + + debugPrint('[SkillManager] Searching GitHub skills: ${uri.toString()}'); + final response = await http.get(uri).timeout(const Duration(seconds: 12)); + if (response.statusCode != 200) { + debugPrint('[SkillManager] GitHub skill query failed HTTP ${response.statusCode}: $q'); + continue; + } + + final data = jsonDecode(response.body) as Map<String, dynamic>; + final items = data['items'] as List<dynamic>? ?? []; + for (final item in items) { + if (item is! Map<String, dynamic>) continue; + final skill = _skillFromGitHubRepo(item, queryText: query); + byId.putIfAbsent(skill.id, () => skill); + } + } + + final results = byId.values.toList(growable: false) + ..sort((a, b) { + final byRating = b.rating.compareTo(a.rating); + if (byRating != 0) return byRating; + return b.installCount.compareTo(a.installCount); + }); + if (results.isEmpty) { + throw SkillException('GitHub skill discovery returned no public candidates'); + } + debugPrint('[SkillManager] Found ${results.length} skills on GitHub'); + return List.unmodifiable(results.take(30)); + } catch (e) { + debugPrint('[SkillManager] GitHub search failed: $e'); + // Return demo data if API fails + return _getDemoGitHubSkills(); + } + } + + List<String> _githubSkillDiscoveryQueries({String? query, String language = ''}) { + final trimmed = query?.trim(); + final userQuery = trimmed == null || trimmed.isEmpty ? '' : ' $trimmed'; + final languageFilter = language.trim().isEmpty ? '' : ' language:${language.trim()}'; + final baseQueries = [ + 'topic:mobilecode-skill$userQuery', + 'topic:codex-skill$userQuery', + 'topic:agent-skill$userQuery', + 'SKILL.md$userQuery in:readme,description', + 'mcp server$userQuery in:name,description,topics', + ]; + return baseQueries.map((q) => '$q archived:false$languageFilter').toList(growable: false); + } + + Skill _skillFromGitHubRepo(Map<String, dynamic> repo, {String? queryText}) { + final fullName = repo['full_name'] as String? ?? ''; + final topics = ((repo['topics'] as List<dynamic>?) ?? const []).whereType<String>().toList(growable: false); + final stars = repo['stargazers_count'] as int? ?? 0; + final description = repo['description'] as String? ?? 'No description'; + final score = _githubSkillScore( + name: repo['name'] as String? ?? '', + description: description, + topics: topics, + stars: stars, + queryText: queryText, + ); + + return Skill( + id: fullName.replaceAll('/', '_'), + name: repo['name'] as String? ?? 'Unknown', + version: '1.0.0', + description: description, + author: fullName.split('/').first, + tags: ['github', 'public-provenance', ...topics], + actions: const [], + prompts: const ['Preview manifest before install', 'Verify GitHub provenance'], + mcpServers: const [], + source: SkillSource.github, + githubUrl: repo['html_url'] as String?, + rating: score, + installCount: stars, + ); + } + + double _githubSkillScore({ + required String name, + required String description, + required List<String> topics, + required int stars, + String? queryText, + }) { + var score = 0.0; + final haystack = '$name $description ${topics.join(' ')}'.toLowerCase(); + for (final signal in const ['mobilecode-skill', 'codex-skill', 'agent-skill', 'skill', 'mcp', 'prompt']) { + if (haystack.contains(signal)) score += 1.5; + } + final query = queryText?.trim().toLowerCase(); + if (query != null && query.isNotEmpty && haystack.contains(query)) score += 2; + score += stars.clamp(0, 5000) / 1000.0; + return double.parse(score.toStringAsFixed(2)); + } + + /// Get trending/popular skills from GitHub. + Future<List<Skill>> getTrendingSkills() async { + return searchGitHubSkills(query: 'stars:>5'); + } + + /// Search the curated public skill source, then normalize results to GitHub-backed skills. + /// + /// MobileCode intentionally does not depend on account-gated marketplace web flows. + /// Results come from public GitHub provenance and still go through [importFromGitHub] + /// before installation. + Future<List<Skill>> searchCuratedSkillSources({String? query, int limit = 12}) async { + if (!_initialized) { + await initialize(); + } + final searchQuery = (query == null || query.trim().isEmpty) ? 'html ui design mobile' : query.trim(); + return _getCuratedGitHubSkills(searchQuery, limit: limit); + } + + /// Search public MCP candidates and return disabled MCP server candidates. + /// + /// MobileCode does not depend on account-gated MCP registry web flows. GitHub metadata + /// is treated as provenance only. The returned servers are not started or enabled + /// until the user reviews and registers them. + Future<List<McpServer>> searchMcpRegistryServers({String? query, int limit = 12}) async { + if (!_initialized) { + await initialize(); + } + final searchQuery = (query == null || query.trim().isEmpty) ? 'github fetch browser filesystem' : query.trim(); + + try { + final uri = Uri.https( + 'api.github.com', + '/search/repositories', + { + 'q': 'mcp server $searchQuery in:name,description,topics', + 'sort': 'stars', + 'order': 'desc', + 'per_page': '$limit', + }, + ); + final response = await http.get(uri).timeout(const Duration(seconds: 12)); + if (response.statusCode != 200) { + throw RegistrySourceException('MCP registry search returned HTTP ${response.statusCode}'); + } + final decoded = jsonDecode(response.body) as Map<String, dynamic>; + final items = decoded['items'] as List<dynamic>? ?? const []; + final results = <McpServer>[]; + for (final item in items) { + if (item is! Map<String, dynamic>) continue; + final server = _mcpServerFromGitHubRepo(item); + if (server != null) results.add(server); + } + if (results.isNotEmpty) return List.unmodifiable(results.take(limit)); + } catch (e) { + debugPrint('[SkillManager] MCP registry search fallback: $e'); + } + + return _getCuratedMcpServers(searchQuery, limit: limit); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Import + // ═══════════════════════════════════════════════════════════════════════ + + /// Import a skill from a GitHub repository URL. + /// + /// Flow: + /// 1. Parse the GitHub URL + /// 2. Fetch skill.yaml from the repo's default branch + /// 3. Parse the manifest into a Skill object + /// 4. Return the skill for preview before installation + Future<Skill> importFromGitHub(String githubUrl) async { + _ensureInitialized(); + debugPrint('[SkillManager] Importing from GitHub: $githubUrl'); + + try { + // Parse URL: https://github.com/user/repo -> raw.githubusercontent.com + final parsed = Uri.parse(githubUrl); + final segments = parsed.pathSegments; + if (segments.length < 2) { + throw SkillException('Invalid GitHub URL: $githubUrl'); + } + + final owner = segments[0]; + final repo = segments[1]; + + // Try to fetch skill.yaml from main branch first, then master + String? yamlContent; + for (final branch in ['main', 'master']) { + final rawUri = Uri.https( + 'raw.githubusercontent.com', + '/$owner/$repo/$branch/skill.yaml', + ); + final response = await http.get(rawUri); + if (response.statusCode == 200) { + yamlContent = response.body; + break; + } + } + + // Fallback: try skill.yml + yamlContent ??= await _tryFetchYamlAlternate(owner, repo); + + if (yamlContent == null) { + // If no skill.yaml found, create a skill from repo metadata + return _skillFromGitHubMetadata(githubUrl, owner, repo); + } + + // Parse YAML content (simplified YAML-like parsing) + final manifest = _parseYamlLike(yamlContent); + final skill = Skill.fromManifest( + manifest, + source: SkillSource.github, + githubUrl: githubUrl, + ); + + // Store in registry (not installed yet) + _skills[skill.id] = skill; + notifyListeners(); + + debugPrint('[SkillManager] Imported skill: ${skill.id}'); + return skill; + } catch (e) { + debugPrint('[SkillManager] GitHub import failed: $e'); + throw SkillException('Failed to import from GitHub: $e'); + } + } + + /// Import from a local ZIP file path. + Future<Skill> importFromZip(String zipPath) async { + _ensureInitialized(); + debugPrint('[SkillManager] Importing from ZIP: $zipPath'); + + try { + final file = File(zipPath); + if (!await file.exists()) { + throw SkillException('ZIP file not found: $zipPath'); + } + + // Extract to temp directory and parse skill.yaml + final appDir = await getApplicationDocumentsDirectory(); + final extractDir = Directory(path.join(appDir.path, 'skills', '_temp_${DateTime.now().millisecondsSinceEpoch}')); + await extractDir.create(recursive: true); + + // TODO: Implement ZIP extraction (using archive package) + // For now, look for skill.yaml in the same directory + final skillYamlFile = File(path.join(file.parent.path, 'skill.yaml')); + if (!await skillYamlFile.exists()) { + throw SkillException('skill.yaml not found in ZIP'); + } + + final content = await skillYamlFile.readAsString(); + final manifest = _parseYamlLike(content); + final skill = Skill.fromManifest( + manifest, + source: SkillSource.local, + localPath: extractDir.path, + ); + + _skills[skill.id] = skill; + notifyListeners(); + + return skill; + } catch (e) { + throw SkillException('Failed to import from ZIP: $e'); + } + } + + /// Import from a generic URL. Future<Skill> importFromUrl(String url) async { - _ensureInitialized(); - debugPrint('[SkillManager] Importing from URL: $url'); - - try { - final uri = Uri.parse(url); - final response = await http.get(uri); - - if (response.statusCode != 200) { - throw SkillException('HTTP ${response.statusCode}'); - } - - final manifest = _parseYamlLike(response.body); - final skill = Skill.fromManifest( - manifest, - source: SkillSource.local, - ); - - _skills[skill.id] = skill; - notifyListeners(); - - return skill; - } catch (e) { - throw SkillException('Failed to import from URL: $e'); - } + _ensureInitialized(); + debugPrint('[SkillManager] Importing from URL: $url'); + + try { + final uri = Uri.parse(url); + final response = await http.get(uri); + + if (response.statusCode != 200) { + throw SkillException('HTTP ${response.statusCode}'); + } + + final manifest = _parseYamlLike(response.body); + final skill = Skill.fromManifest( + manifest, + source: SkillSource.local, + ); + + _skills[skill.id] = skill; + notifyListeners(); + + return skill; + } catch (e) { + throw SkillException('Failed to import from URL: $e'); + } } - // ═══════════════════════════════════════════════════════════════════════ - // Install / Uninstall / Update - // ═══════════════════════════════════════════════════════════════════════ - - /// Install a skill: mark as installed, register MCP servers, persist. - Future<void> install(Skill skill) async { - _ensureInitialized(); - - if (_skills[skill.id]?.isInstalled ?? false) { - throw SkillAlreadyInstalledException(skill.id); - } - - debugPrint('[SkillManager] Installing skill: ${skill.id}'); - - // Mark as installed - final installed = skill.copyWith( - isInstalled: true, - installedAt: DateTime.now(), - ); - _skills[skill.id] = installed; - - // Register MCP servers from this skill - if (skill.hasMcpServers) { - await registerMcpServers(skill.id); - } - - // Persist - await _persistSkills(); - _addLog(skill.id, 'install', true); - - notifyListeners(); - debugPrint('[SkillManager] Installed: ${skill.id}'); + /// Preview a GitHub-backed skill install. The returned skill is not installed + /// until [install] is called from the review UI. + Future<Skill> previewSkillInstallFromRepo(String repoUrl) { + return importFromGitHub(repoUrl); } - /// Uninstall a skill: remove files, unregister MCP, mark as uninstalled. - Future<void> uninstall(String skillId) async { + /// Build a disabled MCP candidate from public GitHub provenance. + /// + /// Registering this candidate must not start the server. The user reviews the + /// command and scope first, then [registerReviewedMcpCandidate] stores it. + Future<McpServer> previewMcpInstallFromRepo({ + required String fullName, + required String repoUrl, + required String name, + String? description, + }) async { _ensureInitialized(); - - final skill = _skills[skillId]; - if (skill == null) throw SkillNotFoundException(skillId); - - debugPrint('[SkillManager] Uninstalling skill: $skillId'); - - // Disable first if enabled - if (skill.isEnabled) { - await disable(skillId); - } - - // Unregister associated MCP servers - if (skill.hasMcpServers) { - await _unregisterMcpServersForSkill(skillId); - } - - // Remove local files if not built-in - if (skill.localPath != null && skill.source != SkillSource.builtIn) { - try { - final dir = Directory(skill.localPath!); - if (await dir.exists()) { - await dir.delete(recursive: true); - } - } catch (e) { - debugPrint('[SkillManager] Failed to delete skill dir: $e'); - } - } - - // Mark as uninstalled (keep in registry for potential re-enable) - _skills[skillId] = skill.copyWith( - isInstalled: false, + final packageName = _guessNpmPackageName(fullName, name); + return McpServer( + id: _mcpRegistryId(fullName), + name: name.trim().isEmpty ? fullName.split('/').last : name.trim(), + type: 'stdio', + command: packageName == null ? '' : 'npx -y $packageName', + description: [ + if (description != null && description.trim().isNotEmpty) description.trim(), + 'Source: $repoUrl', + ].join('\n\n'), + version: 'registry-preview', isEnabled: false, - installedAt: null, - ); - - // Remove from registry if not built-in and not from GitHub - if (skill.source != SkillSource.builtIn && skill.source != SkillSource.github) { - _skills.remove(skillId); - } - - await _persistSkills(); - _addLog(skillId, 'uninstall', true); - - notifyListeners(); - debugPrint('[SkillManager] Uninstalled: $skillId'); - } - - /// Check if there's an update available for a skill. - Future<Skill?> checkUpdate(String skillId) async { - _ensureInitialized(); - - final skill = _skills[skillId]; - if (skill == null) return null; - if (skill.source != SkillSource.github || skill.githubUrl == null) return null; - - try { - // Fetch latest skill.yaml from GitHub - final latest = await importFromGitHub(skill.githubUrl!); - - // Simple version comparison (assuming semver) - if (_isNewerVersion(latest.version, skill.version)) { - return latest; - } - return null; - } catch (e) { - debugPrint('[SkillManager] Update check failed: $e'); - return null; - } - } - - /// Apply an update: reinstall the skill with the latest version. - Future<void> update(String skillId) async { - _ensureInitialized(); - - final skill = _skills[skillId]; - if (skill == null) throw SkillNotFoundException(skillId); - - debugPrint('[SkillManager] Updating skill: $skillId'); - - // Re-import from source - Skill? updated; - if (skill.githubUrl != null) { - updated = await importFromGitHub(skill.githubUrl!); - } else if (skill.localPath != null) { - // Re-read local skill.yaml - final yamlFile = File('${skill.localPath}/skill.yaml'); - if (await yamlFile.exists()) { - final content = await yamlFile.readAsString(); - updated = Skill.fromManifest( - _parseYamlLike(content), - source: skill.source, - localPath: skill.localPath, - ); - } - } - - if (updated == null) { - throw SkillException('Cannot determine update source', skillId: skillId); - } - - // Preserve installation state - _skills[skillId] = updated.copyWith( - isInstalled: true, - isEnabled: skill.isEnabled, - installedAt: skill.installedAt, - usageCount: skill.usageCount, - ); - - // Re-register MCP servers - if (updated.hasMcpServers) { - await registerMcpServers(skillId); - } - - await _persistSkills(); - _addLog(skillId, 'update', true); - - notifyListeners(); - debugPrint('[SkillManager] Updated: $skillId to v${updated.version}'); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Enable / Disable - // ═══════════════════════════════════════════════════════════════════════ - - /// Enable a skill: activate its actions and prompt templates. - Future<void> enable(String skillId) async { - _ensureInitialized(); - - final skill = _skills[skillId]; - if (skill == null) throw SkillNotFoundException(skillId); - - if (skill.isEnabled) return; - - debugPrint('[SkillManager] Enabling skill: $skillId'); - - _skills[skillId] = skill.copyWith(isEnabled: true); - await _persistEnabledSkills(); - _addLog(skillId, 'enable', true); - - notifyListeners(); - } - - /// Disable a skill: deactivate its actions and prompt templates. - Future<void> disable(String skillId) async { - _ensureInitialized(); - - final skill = _skills[skillId]; - if (skill == null) throw SkillNotFoundException(skillId); - - if (!skill.isEnabled) return; - - debugPrint('[SkillManager] Disabling skill: $skillId'); - - _skills[skillId] = skill.copyWith(isEnabled: false); - await _persistEnabledSkills(); - _addLog(skillId, 'disable', true); - - notifyListeners(); - } - - /// Toggle a skill's enabled state. - Future<void> toggle(String skillId) async { - _ensureInitialized(); - - final skill = _skills[skillId]; - if (skill == null) throw SkillNotFoundException(skillId); - - if (skill.isEnabled) { - await disable(skillId); - } else { - await enable(skillId); - } - } - - /// Increment usage count for a skill. - void recordUsage(String skillId) { - final skill = _skills[skillId]; - if (skill != null) { - _skills[skillId] = skill.copyWith(usageCount: skill.usageCount + 1); - notifyListeners(); - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // MCP Server Management - // ═══════════════════════════════════════════════════════════════════════ - - /// Register MCP servers defined by a skill. - Future<void> registerMcpServers(String skillId) async { - _ensureInitialized(); - - final skill = _skills[skillId]; - if (skill == null) return; - - debugPrint('[SkillManager] Registering MCP servers for skill: $skillId'); - - for (final mcpId in skill.mcpServers) { - // Check if already registered - if (_mcpServers.containsKey(mcpId)) { - // Add skill ID to the existing server's skill IDs - final existing = _mcpServers[mcpId]!; - if (!existing.skillIds.contains(skillId)) { - _mcpServers[mcpId] = existing.copyWith( - skillIds: [...existing.skillIds, skillId], - ); - } - continue; - } - - // Try to load MCP server config from skill manifest or create a placeholder - final mcpServer = McpServer( - id: mcpId, - name: mcpId, - type: 'stdio', - command: '', - skillIds: [skillId], - registeredAt: DateTime.now(), - ); - - _mcpServers[mcpId] = mcpServer; - } - - await _persistMcpServers(); - notifyListeners(); - } - - /// Enable an MCP server. - Future<void> enableMcpServer(String serverId) async { - _ensureInitialized(); - - final server = _mcpServers[serverId]; - if (server == null) throw McpServerException('Server not found', serverId: serverId); - - debugPrint('[SkillManager] Enabling MCP server: $serverId'); - - _mcpServers[serverId] = server.copyWith( - isEnabled: true, - status: McpServerStatus.starting, - ); - notifyListeners(); - - // Simulate connection start - await Future.delayed(const Duration(milliseconds: 500)); - - _mcpServers[serverId] = _mcpServers[serverId]!.copyWith( - status: McpServerStatus.running, - lastConnectedAt: DateTime.now(), + status: McpServerStatus.stopped, + logs: const [ + 'Registered as disabled metadata. Review command, environment variables, and permissions before enabling.', + ], ); - - await _persistMcpServers(); - notifyListeners(); } - /// Disable an MCP server. - Future<void> disableMcpServer(String serverId) async { + Future<void> registerReviewedMcpCandidate(McpServer candidate) async { _ensureInitialized(); - - final server = _mcpServers[serverId]; - if (server == null) return; - - debugPrint('[SkillManager] Disabling MCP server: $serverId'); - - _mcpServers[serverId] = server.copyWith( + await addCustomMcpServer(candidate.copyWith( isEnabled: false, status: McpServerStatus.stopped, - ); - - await _persistMcpServers(); - notifyListeners(); - } - - /// Toggle an MCP server's enabled state. - Future<void> toggleMcpServer(String serverId) async { - final server = _mcpServers[serverId]; - if (server == null) return; - - if (server.isEnabled) { - await disableMcpServer(serverId); - } else { - await enableMcpServer(serverId); - } - } - - /// Get an MCP server by ID. - McpServer? getMcpServer(String id) => _mcpServers[id]; - - /// Get all MCP servers associated with a skill. - List<McpServer> getMcpServersForSkill(String skillId) { - return List.unmodifiable( - _mcpServers.values.where((s) => s.skillIds.contains(skillId)), - ); - } - - /// Remove a custom MCP server. - Future<void> removeMcpServer(String serverId) async { - _ensureInitialized(); - - _mcpServers.remove(serverId); - await _persistMcpServers(); - notifyListeners(); + )); } - /// Add a custom MCP server (user-defined, not from a skill). - Future<void> addCustomMcpServer(McpServer server) async { + bool isGitHubSkillInstalled(String repoUrl) { _ensureInitialized(); - - _mcpServers[server.id] = server; - await _persistMcpServers(); - notifyListeners(); + final normalized = repoUrl.trim().toLowerCase(); + return _skills.values.any((skill) => + skill.isInstalled && (skill.githubUrl ?? '').trim().toLowerCase() == normalized); } - /// Update an MCP server's configuration. - Future<void> updateMcpServer(McpServer server) async { + bool isMcpRepoRegistered(String fullName) { _ensureInitialized(); - - if (!_mcpServers.containsKey(server.id)) { - throw McpServerException('Server not found', serverId: server.id); - } - - _mcpServers[server.id] = server; - await _persistMcpServers(); - notifyListeners(); - } - - // ── Private: MCP unregistration ──────────────── - - Future<void> _unregisterMcpServersForSkill(String skillId) async { - final serversToCheck = _mcpServers.values.where((s) => s.skillIds.contains(skillId)).toList(); - - for (final server in serversToCheck) { - final newSkillIds = server.skillIds.where((id) => id != skillId).toList(); - - if (newSkillIds.isEmpty) { - // No other skills use this server - disable and remove - if (server.isEnabled) { - await disableMcpServer(server.id); - } - _mcpServers.remove(server.id); - } else { - // Other skills still use it - just update skill IDs - _mcpServers[server.id] = server.copyWith(skillIds: newSkillIds); - } - } - - await _persistMcpServers(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Private: Built-in Skills - // ═══════════════════════════════════════════════════════════════════════ - - void _registerBuiltInSkills() { - _builtInSkills.clear(); - - // Built-in Flutter Development Skill - _builtInSkills.add(Skill( - id: 'flutter_dev', - name: 'Flutter开发助手', - version: '1.0.0', - description: 'Flutter开发相关的Prompt模板和Actions,包括项目创建、Widget生成、状态管理模板等', - author: 'mobilecode-team', - tags: const ['flutter', 'dart', 'mobile', 'built-in'], - actions: const [ - 'flutter.create_project', - 'flutter.add_widget', - 'flutter.add_state_management', - 'flutter.run_app', - 'flutter.hot_reload', - ], - prompts: const [ - 'flutter_code_gen', - 'flutter_widget_guide', - 'flutter_state_management', - 'flutter_best_practices', - ], - mcpServers: const [], - source: SkillSource.builtIn, - isEnabled: true, - isInstalled: true, - installedAt: DateTime.now(), - )); - - // Built-in Git Skill - _builtInSkills.add(Skill( - id: 'git_helper', - name: 'Git助手', - version: '1.0.0', - description: 'Git版本控制相关的Prompt模板和Actions,提交信息生成、分支管理、冲突解决等', - author: 'mobilecode-team', - tags: const ['git', 'version-control', 'built-in'], - actions: const [ - 'git.generate_commit_message', - 'git.suggest_branch_name', - 'git.resolve_conflict', - 'git.view_history', - ], - prompts: const [ - 'git_commit_message_gen', - 'git_workflow_guide', - 'git_conflict_resolution', - ], - mcpServers: const [], - source: SkillSource.builtIn, - isEnabled: true, - isInstalled: true, - installedAt: DateTime.now(), - )); - - // Built-in Code Review Skill - _builtInSkills.add(Skill( - id: 'code_review', - name: '代码审查助手', - version: '1.0.0', - description: '代码审查相关的Prompt模板,帮助发现潜在问题、优化建议和最佳实践检查', - author: 'mobilecode-team', - tags: const ['code-review', 'quality', 'built-in'], - actions: const [ - 'ai.review_code', - 'ai.find_bugs', - 'ai.suggest_optimizations', - ], - prompts: const [ - 'code_review_checklist', - 'bug_hunting_guide', - 'performance_audit', - ], - mcpServers: const [], - source: SkillSource.builtIn, - isEnabled: true, - isInstalled: true, - installedAt: DateTime.now(), - )); - - // Built-in MCP-enabled skill (GitHub tools) - _builtInSkills.add(Skill( - id: 'github_tools', - name: 'GitHub工具集', - version: '1.0.0', - description: '通过MCP协议连接GitHub API,支持Issue管理、PR审查、仓库操作等', - author: 'mobilecode-team', - tags: const ['github', 'mcp', 'integration', 'built-in'], - actions: const [ - 'github.list_issues', - 'github.create_pr', - 'github.review_pr', - ], - prompts: const [ - 'github_issue_template', - 'github_pr_description', - ], - mcpServers: const ['github_mcp_server'], - source: SkillSource.builtIn, - isEnabled: false, - isInstalled: true, - installedAt: DateTime.now(), - )); - - // Add built-in skills to main registry - for (final skill in _builtInSkills) { - _skills[skill.id] = skill; - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // Private: Persistence - // ═══════════════════════════════════════════════════════════════════════ - - Future<void> _persistSkills() async { - try { - final installed = _skills.values.where((s) => s.isInstalled && s.source != SkillSource.builtIn).toList(); - final jsonList = installed.map((s) => jsonEncode(s.toJson())).toList(); - await _prefs?.setStringList(_skillsKey, jsonList); - } catch (e) { - debugPrint('[SkillManager] Failed to persist skills: $e'); - } - } - - Future<void> _loadPersistedSkills() async { - try { - final jsonList = _prefs?.getStringList(_skillsKey) ?? []; - for (final jsonStr in jsonList) { - try { - final skill = Skill.fromJsonString(jsonStr); - // Don't override built-in skills - if (!_skills.containsKey(skill.id)) { - _skills[skill.id] = skill; - } - } catch (e) { - debugPrint('[SkillManager] Failed to parse skill: $e'); - } - } - debugPrint('[SkillManager] Loaded ${_skills.length} skills'); - } catch (e) { - debugPrint('[SkillManager] Failed to load skills: $e'); - } - } - - Future<void> _persistMcpServers() async { - try { - final jsonList = _mcpServers.values.map((s) => jsonEncode(s.toJson())).toList(); - await _prefs?.setStringList(_mcpServersKey, jsonList); - } catch (e) { - debugPrint('[SkillManager] Failed to persist MCP servers: $e'); - } - } - - Future<void> _loadPersistedMcpServers() async { - try { - final jsonList = _prefs?.getStringList(_mcpServersKey) ?? []; - for (final jsonStr in jsonList) { - try { - final server = McpServer.fromJson(jsonDecode(jsonStr) as Map<String, dynamic>); - // Reset runtime-only fields - server.status = server.isEnabled ? McpServerStatus.stopped : McpServerStatus.stopped; - _mcpServers[server.id] = server; - } catch (e) { - debugPrint('[SkillManager] Failed to parse MCP server: $e'); - } - } - debugPrint('[SkillManager] Loaded ${_mcpServers.length} MCP servers'); - } catch (e) { - debugPrint('[SkillManager] Failed to load MCP servers: $e'); - } - } - - Future<void> _persistEnabledSkills() async { - try { - final enabled = _skills.values.where((s) => s.isEnabled).map((s) => s.id).toList(); - await _prefs?.setStringList(_enabledSkillsKey, enabled); - } catch (e) { - debugPrint('[SkillManager] Failed to persist enabled skills: $e'); - } - } - - Future<void> _loadPersistedLogs() async { - try { - final jsonList = _prefs?.getStringList(_logsKey) ?? []; - _logs.addAll( - jsonList.map((s) => SkillInstallLog.fromJson(jsonDecode(s) as Map<String, dynamic>)), - ); - } catch (e) { - debugPrint('[SkillManager] Failed to load logs: $e'); - } - } - - void _addLog(String skillId, String operation, bool success, {String? error}) { - final log = SkillInstallLog( - skillId: skillId, - operation: operation, - timestamp: DateTime.now(), - success: success, - error: error, - ); - _logs.add(log); - - // Persist last 100 logs - if (_logs.length > 100) { - _logs.removeAt(0); - } - - try { - final jsonList = _logs.map((l) => jsonEncode(l.toJson())).toList(); - _prefs?.setStringList(_logsKey, jsonList); - } catch (e) { - debugPrint('[SkillManager] Failed to persist log: $e'); - } + return _mcpServers.containsKey(_mcpRegistryId(fullName)); } // ═══════════════════════════════════════════════════════════════════════ - // Private: Helpers + // Install / Uninstall / Update // ═══════════════════════════════════════════════════════════════════════ - - /// Try alternate YAML filenames. - Future<String?> _tryFetchYamlAlternate(String owner, String repo) async { - for (final filename in ['skill.yml', 'Skill.yaml', 'Skill.yml']) { - for (final branch in ['main', 'master']) { - final uri = Uri.https( - 'raw.githubusercontent.com', - '/$owner/$repo/$branch/$filename', - ); - final response = await http.get(uri); - if (response.statusCode == 200) { - return response.body; - } - } - } - return null; - } - - /// Create a Skill from GitHub repo metadata when no skill.yaml is found. - Skill _skillFromGitHubMetadata(String githubUrl, String owner, String repo) { - return Skill( - id: '${owner}_$repo', - name: repo, - version: '1.0.0', - description: 'Imported from GitHub: $owner/$repo', - author: owner, - tags: const ['github', 'imported'], - actions: const [], - prompts: const [], - mcpServers: const [], - source: SkillSource.github, - githubUrl: githubUrl, + + /// Install a skill: mark as installed, register MCP servers, persist. + Future<void> install(Skill skill) async { + _ensureInitialized(); + + if (_skills[skill.id]?.isInstalled ?? false) { + throw SkillAlreadyInstalledException(skill.id); + } + + debugPrint('[SkillManager] Installing skill: ${skill.id}'); + + // Mark as installed + final installed = skill.copyWith( + isInstalled: true, + installedAt: DateTime.now(), + ); + _skills[skill.id] = installed; + + // Register MCP servers from this skill + if (skill.hasMcpServers) { + await registerMcpServers(skill.id); + } + + // Persist + await _persistSkills(); + _addLog(skill.id, 'install', true); + + notifyListeners(); + debugPrint('[SkillManager] Installed: ${skill.id}'); + } + + /// Uninstall a skill: remove files, unregister MCP, mark as uninstalled. + Future<void> uninstall(String skillId) async { + _ensureInitialized(); + + final skill = _skills[skillId]; + if (skill == null) throw SkillNotFoundException(skillId); + + debugPrint('[SkillManager] Uninstalling skill: $skillId'); + + // Disable first if enabled + if (skill.isEnabled) { + await disable(skillId); + } + + // Unregister associated MCP servers + if (skill.hasMcpServers) { + await _unregisterMcpServersForSkill(skillId); + } + + // Remove local files if not built-in + if (skill.localPath != null && skill.source != SkillSource.builtIn) { + try { + final dir = Directory(skill.localPath!); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (e) { + debugPrint('[SkillManager] Failed to delete skill dir: $e'); + } + } + + // Mark as uninstalled (keep in registry for potential re-enable) + _skills[skillId] = skill.copyWith( + isInstalled: false, + isEnabled: false, + installedAt: null, + ); + + // Remove from registry if not built-in and not from GitHub + if (skill.source != SkillSource.builtIn && skill.source != SkillSource.github) { + _skills.remove(skillId); + } + + await _persistSkills(); + _addLog(skillId, 'uninstall', true); + + notifyListeners(); + debugPrint('[SkillManager] Uninstalled: $skillId'); + } + + /// Check if there's an update available for a skill. + Future<Skill?> checkUpdate(String skillId) async { + _ensureInitialized(); + + final skill = _skills[skillId]; + if (skill == null) return null; + if (skill.source != SkillSource.github || skill.githubUrl == null) return null; + + try { + // Fetch latest skill.yaml from GitHub + final latest = await importFromGitHub(skill.githubUrl!); + + // Simple version comparison (assuming semver) + if (_isNewerVersion(latest.version, skill.version)) { + return latest; + } + return null; + } catch (e) { + debugPrint('[SkillManager] Update check failed: $e'); + return null; + } + } + + /// Apply an update: reinstall the skill with the latest version. + Future<void> update(String skillId) async { + _ensureInitialized(); + + final skill = _skills[skillId]; + if (skill == null) throw SkillNotFoundException(skillId); + + debugPrint('[SkillManager] Updating skill: $skillId'); + + // Re-import from source + Skill? updated; + if (skill.githubUrl != null) { + updated = await importFromGitHub(skill.githubUrl!); + } else if (skill.localPath != null) { + // Re-read local skill.yaml + final yamlFile = File('${skill.localPath}/skill.yaml'); + if (await yamlFile.exists()) { + final content = await yamlFile.readAsString(); + updated = Skill.fromManifest( + _parseYamlLike(content), + source: skill.source, + localPath: skill.localPath, + ); + } + } + + if (updated == null) { + throw SkillException('Cannot determine update source', skillId: skillId); + } + + // Preserve installation state + _skills[skillId] = updated.copyWith( + isInstalled: true, + isEnabled: skill.isEnabled, + installedAt: skill.installedAt, + usageCount: skill.usageCount, + ); + + // Re-register MCP servers + if (updated.hasMcpServers) { + await registerMcpServers(skillId); + } + + await _persistSkills(); + _addLog(skillId, 'update', true); + + notifyListeners(); + debugPrint('[SkillManager] Updated: $skillId to v${updated.version}'); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Enable / Disable + // ═══════════════════════════════════════════════════════════════════════ + + /// Enable a skill: activate its actions and prompt templates. + Future<void> enable(String skillId) async { + _ensureInitialized(); + + final skill = _skills[skillId]; + if (skill == null) throw SkillNotFoundException(skillId); + + if (skill.isEnabled) return; + + debugPrint('[SkillManager] Enabling skill: $skillId'); + + _skills[skillId] = skill.copyWith(isEnabled: true); + await _persistEnabledSkills(); + _addLog(skillId, 'enable', true); + + notifyListeners(); + } + + /// Disable a skill: deactivate its actions and prompt templates. + Future<void> disable(String skillId) async { + _ensureInitialized(); + + final skill = _skills[skillId]; + if (skill == null) throw SkillNotFoundException(skillId); + + if (!skill.isEnabled) return; + + debugPrint('[SkillManager] Disabling skill: $skillId'); + + _skills[skillId] = skill.copyWith(isEnabled: false); + await _persistEnabledSkills(); + _addLog(skillId, 'disable', true); + + notifyListeners(); + } + + /// Toggle a skill's enabled state. + Future<void> toggle(String skillId) async { + _ensureInitialized(); + + final skill = _skills[skillId]; + if (skill == null) throw SkillNotFoundException(skillId); + + if (skill.isEnabled) { + await disable(skillId); + } else { + await enable(skillId); + } + } + + /// Increment usage count for a skill. + void recordUsage(String skillId) { + final skill = _skills[skillId]; + if (skill != null) { + _skills[skillId] = skill.copyWith(usageCount: skill.usageCount + 1); + notifyListeners(); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // MCP Server Management + // ═══════════════════════════════════════════════════════════════════════ + + /// Register MCP servers defined by a skill. + Future<void> registerMcpServers(String skillId) async { + _ensureInitialized(); + + final skill = _skills[skillId]; + if (skill == null) return; + + debugPrint('[SkillManager] Registering MCP servers for skill: $skillId'); + + for (final mcpId in skill.mcpServers) { + // Check if already registered + if (_mcpServers.containsKey(mcpId)) { + // Add skill ID to the existing server's skill IDs + final existing = _mcpServers[mcpId]!; + if (!existing.skillIds.contains(skillId)) { + _mcpServers[mcpId] = existing.copyWith( + skillIds: [...existing.skillIds, skillId], + ); + } + continue; + } + + // Try to load MCP server config from skill manifest or create a placeholder + final mcpServer = McpServer( + id: mcpId, + name: mcpId, + type: 'stdio', + command: '', + skillIds: [skillId], + registeredAt: DateTime.now(), + ); + + _mcpServers[mcpId] = mcpServer; + } + + await _persistMcpServers(); + notifyListeners(); + } + + /// Enable an MCP server. + Future<void> enableMcpServer(String serverId) async { + _ensureInitialized(); + + final server = _mcpServers[serverId]; + if (server == null) throw McpServerException('Server not found', serverId: serverId); + + debugPrint('[SkillManager] Enabling MCP server: $serverId'); + + _mcpServers[serverId] = server.copyWith( + isEnabled: true, + status: McpServerStatus.starting, + ); + notifyListeners(); + + // Simulate connection start + await Future.delayed(const Duration(milliseconds: 500)); + + _mcpServers[serverId] = _mcpServers[serverId]!.copyWith( + status: McpServerStatus.running, + lastConnectedAt: DateTime.now(), + ); + + await _persistMcpServers(); + notifyListeners(); + } + + /// Disable an MCP server. + Future<void> disableMcpServer(String serverId) async { + _ensureInitialized(); + + final server = _mcpServers[serverId]; + if (server == null) return; + + debugPrint('[SkillManager] Disabling MCP server: $serverId'); + + _mcpServers[serverId] = server.copyWith( + isEnabled: false, + status: McpServerStatus.stopped, + ); + + await _persistMcpServers(); + notifyListeners(); + } + + /// Toggle an MCP server's enabled state. + Future<void> toggleMcpServer(String serverId) async { + final server = _mcpServers[serverId]; + if (server == null) return; + + if (server.isEnabled) { + await disableMcpServer(serverId); + } else { + await enableMcpServer(serverId); + } + } + + /// Get an MCP server by ID. + McpServer? getMcpServer(String id) => _mcpServers[id]; + + /// Get all MCP servers associated with a skill. + List<McpServer> getMcpServersForSkill(String skillId) { + return List.unmodifiable( + _mcpServers.values.where((s) => s.skillIds.contains(skillId)), + ); + } + + /// Remove a custom MCP server. + Future<void> removeMcpServer(String serverId) async { + _ensureInitialized(); + + _mcpServers.remove(serverId); + await _persistMcpServers(); + notifyListeners(); + } + + /// Add a custom MCP server (user-defined, not from a skill). + Future<void> addCustomMcpServer(McpServer server) async { + _ensureInitialized(); + + _mcpServers[server.id] = server; + await _persistMcpServers(); + notifyListeners(); + } + + /// Update an MCP server's configuration. + Future<void> updateMcpServer(McpServer server) async { + _ensureInitialized(); + + if (!_mcpServers.containsKey(server.id)) { + throw McpServerException('Server not found', serverId: server.id); + } + + _mcpServers[server.id] = server; + await _persistMcpServers(); + notifyListeners(); + } + + // ── Private: MCP unregistration ──────────────── + + Future<void> _unregisterMcpServersForSkill(String skillId) async { + final serversToCheck = _mcpServers.values.where((s) => s.skillIds.contains(skillId)).toList(); + + for (final server in serversToCheck) { + final newSkillIds = server.skillIds.where((id) => id != skillId).toList(); + + if (newSkillIds.isEmpty) { + // No other skills use this server - disable and remove + if (server.isEnabled) { + await disableMcpServer(server.id); + } + _mcpServers.remove(server.id); + } else { + // Other skills still use it - just update skill IDs + _mcpServers[server.id] = server.copyWith(skillIds: newSkillIds); + } + } + + await _persistMcpServers(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Private: Built-in Skills + // ═══════════════════════════════════════════════════════════════════════ + + void _registerBuiltInSkills() { + _builtInSkills.clear(); + + // Built-in Flutter Development Skill + _builtInSkills.add(Skill( + id: 'flutter_dev', + name: 'Flutter开发助手', + version: '1.0.0', + description: 'Flutter开发相关的Prompt模板和Actions,包括项目创建、Widget生成、状态管理模板等', + author: 'mobilecode-team', + tags: const ['flutter', 'dart', 'mobile', 'built-in'], + actions: const [ + 'flutter.create_project', + 'flutter.add_widget', + 'flutter.add_state_management', + 'flutter.run_app', + 'flutter.hot_reload', + ], + prompts: const [ + 'flutter_code_gen', + 'flutter_widget_guide', + 'flutter_state_management', + 'flutter_best_practices', + ], + mcpServers: const [], + source: SkillSource.builtIn, + isEnabled: true, + isInstalled: true, + installedAt: DateTime.now(), + )); + + // Built-in Git Skill + _builtInSkills.add(Skill( + id: 'git_helper', + name: 'Git助手', + version: '1.0.0', + description: 'Git版本控制相关的Prompt模板和Actions,提交信息生成、分支管理、冲突解决等', + author: 'mobilecode-team', + tags: const ['git', 'version-control', 'built-in'], + actions: const [ + 'git.generate_commit_message', + 'git.suggest_branch_name', + 'git.resolve_conflict', + 'git.view_history', + ], + prompts: const [ + 'git_commit_message_gen', + 'git_workflow_guide', + 'git_conflict_resolution', + ], + mcpServers: const [], + source: SkillSource.builtIn, + isEnabled: true, + isInstalled: true, + installedAt: DateTime.now(), + )); + + // Built-in Code Review Skill + _builtInSkills.add(Skill( + id: 'code_review', + name: '代码审查助手', + version: '1.0.0', + description: '代码审查相关的Prompt模板,帮助发现潜在问题、优化建议和最佳实践检查', + author: 'mobilecode-team', + tags: const ['code-review', 'quality', 'built-in'], + actions: const [ + 'ai.review_code', + 'ai.find_bugs', + 'ai.suggest_optimizations', + ], + prompts: const [ + 'code_review_checklist', + 'bug_hunting_guide', + 'performance_audit', + ], + mcpServers: const [], + source: SkillSource.builtIn, + isEnabled: true, + isInstalled: true, + installedAt: DateTime.now(), + )); + + // Built-in MCP-enabled skill (GitHub tools) + _builtInSkills.add(Skill( + id: 'github_tools', + name: 'GitHub工具集', + version: '1.0.0', + description: '通过MCP协议连接GitHub API,支持Issue管理、PR审查、仓库操作等', + author: 'mobilecode-team', + tags: const ['github', 'mcp', 'integration', 'built-in'], + actions: const [ + 'github.list_issues', + 'github.create_pr', + 'github.review_pr', + ], + prompts: const [ + 'github_issue_template', + 'github_pr_description', + ], + mcpServers: const ['github_mcp_server'], + source: SkillSource.builtIn, + isEnabled: false, + isInstalled: true, + installedAt: DateTime.now(), + )); + + _registerBuiltInHtmlDesignSkills(); + + // Add built-in skills to main registry + for (final skill in _builtInSkills) { + _skills[skill.id] = skill; + } + } + + void _registerBuiltInHtmlDesignSkills() { + final now = DateTime.now(); + final skills = [ + Skill( + id: 'frontend_design', + name: 'Frontend Design', + version: '1.0.0', + description: 'HTML-first visual direction, typography, color, layout, motion, and non-generic product UI guidance internalized for MobileCode artifacts.', + author: 'mobilecode-team', + tags: const ['html', 'frontend', 'design', 'built-in', 'default'], + actions: const [ + 'html.choose_visual_direction', + 'html.compose_responsive_layout', + 'html.refine_visual_system', + ], + prompts: const [ + 'frontend_design_brief', + 'html_visual_quality_checklist', + 'mobile_preview_polish', + ], + mcpServers: const [], + source: SkillSource.builtIn, + githubUrl: 'https://github.com/anthropics/skills', + isEnabled: true, + isInstalled: true, + installedAt: now, + ), + Skill( + id: 'ui_ux_pro_max', + name: 'UI UX Pro Max', + version: '1.0.0', + description: 'Product-grade UX flow, information hierarchy, interaction states, and mobile-first polish for generated HTML experiences.', + author: 'mobilecode-team', + tags: const ['ux', 'mobile-ui', 'html', 'built-in', 'default'], + actions: const [ + 'ux.map_user_flow', + 'ux.design_empty_loading_error_states', + 'ux.audit_touch_targets', + ], + prompts: const [ + 'mobile_ux_flow_review', + 'ui_state_completeness', + 'tap_target_accessibility', + ], + mcpServers: const [], + source: SkillSource.builtIn, + githubUrl: 'https://github.com/nextlevelbuilder/ui-ux-pro-max-skill', + isEnabled: true, + isInstalled: true, + installedAt: now, + ), + Skill( + id: 'shadcn_ui', + name: 'shadcn/ui Pattern Kit', + version: '1.0.0', + description: 'Ownership-oriented component patterns, variants, dialogs, forms, cards, and registry thinking adapted to plain HTML/CSS and future React exports.', + author: 'mobilecode-team', + tags: const ['components', 'shadcn', 'html', 'built-in', 'default'], + actions: const [ + 'ui.compose_component_variants', + 'ui.design_dialog_form_controls', + 'ui.normalize_component_tokens', + ], + prompts: const [ + 'component_variant_matrix', + 'html_component_contract', + 'registry_component_review', + ], + mcpServers: const [], + source: SkillSource.builtIn, + githubUrl: 'https://github.com/giuseppe-trisciuoglio/developer-kit', + isEnabled: true, + isInstalled: true, + installedAt: now, + ), + Skill( + id: 'stitch_html_design', + name: 'Stitch HTML Design', + version: '1.0.0', + description: 'Prompt-to-interface structure, screenshot-inspired design translation, and high-fidelity HTML screen generation for MobileCode previews.', + author: 'mobilecode-team', + tags: const ['stitch', 'html', 'design-system', 'built-in', 'default'], + actions: const [ + 'html.translate_design_prompt', + 'html.extract_design_tokens', + 'html.generate_preview_screen', + ], + prompts: const [ + 'stitch_style_html_prompt', + 'design_token_extraction', + 'mobile_webview_screen_spec', + ], + mcpServers: const [], + source: SkillSource.builtIn, + githubUrl: 'https://github.com/google-labs-code/stitch-skills', + isEnabled: true, + isInstalled: true, + installedAt: now, + ), + Skill( + id: 'web_accessibility', + name: 'Web Accessibility', + version: '1.0.0', + description: 'Accessibility defaults for generated HTML: semantic structure, focus order, contrast, motion reduction, labels, and keyboard affordances.', + author: 'mobilecode-team', + tags: const ['accessibility', 'wcag', 'html', 'built-in', 'default'], + actions: const [ + 'a11y.audit_semantics', + 'a11y.check_focus_order', + 'a11y.enforce_motion_preferences', + ], + prompts: const [ + 'html_accessibility_checklist', + 'semantic_markup_review', + 'keyboard_navigation_review', + ], + mcpServers: const [], + source: SkillSource.builtIn, + githubUrl: 'https://github.com/supercent-io/skills-template', + isEnabled: true, + isInstalled: true, + installedAt: now, + ), + Skill( + id: 'web_design_guidelines', + name: 'Web Design Guidelines', + version: '1.0.0', + description: 'Vercel-style web craft guidance for responsive composition, performance-aware UI, hierarchy, and deployable web artifact quality.', + author: 'mobilecode-team', + tags: const ['web', 'guidelines', 'performance', 'built-in', 'default'], + actions: const [ + 'web.audit_visual_hierarchy', + 'web.check_responsive_breakpoints', + 'web.review_deployable_quality', + ], + prompts: const [ + 'web_design_review', + 'responsive_artifact_gate', + 'deployable_html_quality', + ], + mcpServers: const [], + source: SkillSource.builtIn, + githubUrl: 'https://github.com/vercel-labs/agent-skills', + isEnabled: true, + isInstalled: true, + installedAt: now, + ), + Skill( + id: 'ui_animation', + name: 'UI Animation', + version: '1.0.0', + description: 'CSS-first motion patterns, micro-interactions, page reveal timing, and reduced-motion fallbacks for HTML previews.', + author: 'mobilecode-team', + tags: const ['animation', 'css', 'motion', 'built-in', 'default'], + actions: const [ + 'motion.plan_page_reveal', + 'motion.add_micro_interactions', + 'motion.add_reduced_motion_fallback', + ], + prompts: const [ + 'css_motion_direction', + 'micro_interaction_review', + 'reduced_motion_gate', + ], + mcpServers: const [], + source: SkillSource.builtIn, + githubUrl: 'https://github.com/mblode/agent-skills', + isEnabled: true, + isInstalled: true, + installedAt: now, + ), + Skill( + id: 'figma_implement_design', + name: 'Figma Implement Design', + version: '1.0.0', + description: 'Figma-to-code discipline internalized as design context, asset fidelity, token translation, visual parity, and responsive validation.', + author: 'mobilecode-team', + tags: const ['figma', 'design-implementation', 'html', 'built-in', 'default'], + actions: const [ + 'figma.extract_design_context', + 'figma.translate_tokens_to_html', + 'figma.validate_visual_parity', + ], + prompts: const [ + 'figma_to_html_plan', + 'design_asset_fidelity', + 'visual_parity_checklist', + ], + mcpServers: const [], + source: SkillSource.builtIn, + githubUrl: 'https://github.com/figma/mcp-server-guide', + isEnabled: true, + isInstalled: true, + installedAt: now, + ), + Skill( + id: 'tailwind_design_system', + name: 'Tailwind Design System', + version: '1.0.0', + description: 'Tokenized spacing, typography, color, utility naming, and reusable design-system rules adapted for generated HTML/CSS.', + author: 'mobilecode-team', + tags: const ['tailwind', 'design-system', 'tokens', 'built-in', 'default'], + actions: const [ + 'design_system.define_tokens', + 'design_system.normalize_spacing', + 'design_system.audit_consistency', + ], + prompts: const [ + 'tailwind_token_plan', + 'html_css_tokenization', + 'design_system_consistency_review', + ], + mcpServers: const [], + source: SkillSource.builtIn, + githubUrl: 'https://github.com/wshobson/agents', + isEnabled: true, + isInstalled: true, + installedAt: now, + ), + ]; + + _builtInSkills.addAll(skills); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Private: Persistence + // ═══════════════════════════════════════════════════════════════════════ + + Future<void> _persistSkills() async { + try { + final installed = _skills.values.where((s) => s.isInstalled && s.source != SkillSource.builtIn).toList(); + final jsonList = installed.map((s) => jsonEncode(s.toJson())).toList(); + await _prefs?.setStringList(_skillsKey, jsonList); + await _persistBuiltInSkillState(); + } catch (e) { + debugPrint('[SkillManager] Failed to persist skills: $e'); + } + } + + Future<void> _loadPersistedSkills() async { + try { + final jsonList = _prefs?.getStringList(_skillsKey) ?? []; + for (final jsonStr in jsonList) { + try { + final skill = Skill.fromJsonString(jsonStr); + // Don't override built-in skills + if (!_skills.containsKey(skill.id)) { + _skills[skill.id] = skill; + } + } catch (e) { + debugPrint('[SkillManager] Failed to parse skill: $e'); + } + } + debugPrint('[SkillManager] Loaded ${_skills.length} skills'); + } catch (e) { + debugPrint('[SkillManager] Failed to load skills: $e'); + } + } + + Future<void> _persistMcpServers() async { + try { + final jsonList = _mcpServers.values.map((s) => jsonEncode(s.toJson())).toList(); + await _prefs?.setStringList(_mcpServersKey, jsonList); + } catch (e) { + debugPrint('[SkillManager] Failed to persist MCP servers: $e'); + } + } + + Future<void> _loadPersistedMcpServers() async { + try { + final jsonList = _prefs?.getStringList(_mcpServersKey) ?? []; + for (final jsonStr in jsonList) { + try { + final server = McpServer.fromJson(jsonDecode(jsonStr) as Map<String, dynamic>); + // Reset runtime-only fields + server.status = server.isEnabled ? McpServerStatus.stopped : McpServerStatus.stopped; + _mcpServers[server.id] = server; + } catch (e) { + debugPrint('[SkillManager] Failed to parse MCP server: $e'); + } + } + debugPrint('[SkillManager] Loaded ${_mcpServers.length} MCP servers'); + } catch (e) { + debugPrint('[SkillManager] Failed to load MCP servers: $e'); + } + } + + Future<void> _persistEnabledSkills() async { + try { + final enabled = _skills.values.where((s) => s.isEnabled).map((s) => s.id).toList(); + await _prefs?.setStringList(_enabledSkillsKey, enabled); + await _persistBuiltInSkillState(); + } catch (e) { + debugPrint('[SkillManager] Failed to persist enabled skills: $e'); + } + } + + Future<void> _persistBuiltInSkillState() async { + final builtInState = <String, dynamic>{}; + for (final skill in _skills.values.where((s) => s.source == SkillSource.builtIn)) { + builtInState[skill.id] = { + 'isInstalled': skill.isInstalled, + 'isEnabled': skill.isEnabled, + }; + } + await _prefs?.setString(_builtInSkillStateKey, jsonEncode(builtInState)); + } + + Future<void> _loadPersistedBuiltInSkillState() async { + try { + final raw = _prefs?.getString(_builtInSkillStateKey); + if (raw == null || raw.isEmpty) return; + + final state = jsonDecode(raw) as Map<String, dynamic>; + for (final entry in state.entries) { + final skill = _skills[entry.key]; + final value = entry.value; + if (skill == null || skill.source != SkillSource.builtIn || value is! Map<String, dynamic>) { + continue; + } + _skills[entry.key] = skill.copyWith( + isInstalled: value['isInstalled'] as bool? ?? skill.isInstalled, + isEnabled: value['isEnabled'] as bool? ?? skill.isEnabled, + ); + } + } catch (e) { + debugPrint('[SkillManager] Failed to load built-in skill state: $e'); + } + } + + Future<void> _loadPersistedLogs() async { + try { + final jsonList = _prefs?.getStringList(_logsKey) ?? []; + _logs.addAll( + jsonList.map((s) => SkillInstallLog.fromJson(jsonDecode(s) as Map<String, dynamic>)), + ); + } catch (e) { + debugPrint('[SkillManager] Failed to load logs: $e'); + } + } + + void _addLog(String skillId, String operation, bool success, {String? error}) { + final log = SkillInstallLog( + skillId: skillId, + operation: operation, + timestamp: DateTime.now(), + success: success, + error: error, + ); + _logs.add(log); + + // Persist last 100 logs + if (_logs.length > 100) { + _logs.removeAt(0); + } + + try { + final jsonList = _logs.map((l) => jsonEncode(l.toJson())).toList(); + _prefs?.setStringList(_logsKey, jsonList); + } catch (e) { + debugPrint('[SkillManager] Failed to persist log: $e'); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Private: Helpers + // ═══════════════════════════════════════════════════════════════════════ + + /// Try alternate YAML filenames. + Future<String?> _tryFetchYamlAlternate(String owner, String repo) async { + for (final filename in ['skill.yml', 'Skill.yaml', 'Skill.yml']) { + for (final branch in ['main', 'master']) { + final uri = Uri.https( + 'raw.githubusercontent.com', + '/$owner/$repo/$branch/$filename', + ); + final response = await http.get(uri); + if (response.statusCode == 200) { + return response.body; + } + } + } + return null; + } + + /// Create a Skill from GitHub repo metadata when no skill.yaml is found. + Skill _skillFromGitHubMetadata(String githubUrl, String owner, String repo) { + return Skill( + id: '${owner}_$repo', + name: repo, + version: '1.0.0', + description: 'Imported from GitHub: $owner/$repo', + author: owner, + tags: const ['github', 'imported'], + actions: const [], + prompts: const [], + mcpServers: const [], + source: SkillSource.github, + githubUrl: githubUrl, + ); + } + + /// Parse a simplified YAML-like format (key: value pairs). + /// + /// This is a lightweight parser for simple skill.yaml files. + /// For complex YAML, consider using the `yaml` package. + Map<String, dynamic> _parseYamlLike(String content) { + final result = <String, dynamic>{}; + String? currentListKey; + final currentList = <String>[]; + + for (final line in content.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) continue; + + // Check if it's a list item + if (trimmed.startsWith('- ')) { + final value = trimmed.substring(2).trim(); + currentList.add(_stripYamlQuotes(value)); + continue; + } + + // If we were collecting a list, save it before moving on + if (currentListKey != null && currentList.isNotEmpty) { + result[currentListKey] = List.unmodifiable(currentList); + currentList.clear(); + currentListKey = null; + } + + // Parse key: value + final colonIndex = trimmed.indexOf(':'); + if (colonIndex > 0) { + final key = trimmed.substring(0, colonIndex).trim(); + final value = trimmed.substring(colonIndex + 1).trim(); + + if (value.isEmpty) { + // This key likely has a list below it + currentListKey = key; + } else { + result[key] = _stripYamlQuotes(value); + } + } + } + + // Don't forget the last list + if (currentListKey != null && currentList.isNotEmpty) { + result[currentListKey] = List.unmodifiable(currentList); + } + + return result; + } + + String _stripYamlQuotes(String value) { + final trimmed = value.trim(); + if (trimmed.length < 2) return trimmed; + + final first = trimmed[0]; + final last = trimmed[trimmed.length - 1]; + if ((first == "'" && last == "'") || (first == '"' && last == '"')) { + return trimmed.substring(1, trimmed.length - 1); + } + return trimmed; + } + + McpServer? _mcpServerFromGitHubRepo(Map<String, dynamic> repo) { + final url = repo['html_url'] as String?; + final fullName = repo['full_name'] as String?; + if (url == null || fullName == null) return null; + final name = repo['name'] as String? ?? fullName.split('/').last; + final description = repo['description'] as String?; + final packageName = _guessNpmPackageName(fullName, name); + return McpServer( + id: _mcpRegistryId(fullName), + name: name, + type: 'stdio', + command: packageName == null ? '' : 'npx -y $packageName', + description: description == null ? 'MCP server discovered from public GitHub provenance: $url' : '$description\n\nSource: $url', + version: 'registry-preview', + isEnabled: false, + status: McpServerStatus.stopped, + logs: const [ + 'Imported as disabled metadata. Review command, environment variables, and permissions before enabling.', + ], ); } - /// Parse a simplified YAML-like format (key: value pairs). - /// - /// This is a lightweight parser for simple skill.yaml files. - /// For complex YAML, consider using the `yaml` package. - Map<String, dynamic> _parseYamlLike(String content) { - final result = <String, dynamic>{}; - String? currentListKey; - final currentList = <String>[]; - - for (final line in content.split('\n')) { - final trimmed = line.trim(); - if (trimmed.isEmpty || trimmed.startsWith('#')) continue; - - // Check if it's a list item - if (trimmed.startsWith('- ')) { - final value = trimmed.substring(2).trim(); - // Remove quotes if present - currentList.add(value.replaceAll(RegExp(r"^['"""'"]|["'""'"]$"), '')); - continue; - } - - // If we were collecting a list, save it before moving on - if (currentListKey != null && currentList.isNotEmpty) { - result[currentListKey] = List.unmodifiable(currentList); - currentList.clear(); - currentListKey = null; - } - - // Parse key: value - final colonIndex = trimmed.indexOf(':'); - if (colonIndex > 0) { - final key = trimmed.substring(0, colonIndex).trim(); - final value = trimmed.substring(colonIndex + 1).trim(); - - if (value.isEmpty) { - // This key likely has a list below it - currentListKey = key; - } else { - // Remove quotes - final cleanValue = value.replaceAll(RegExp(r"^['"""'"]|["'""'"]$"), ''); - result[key] = cleanValue; - } - } - } - - // Don't forget the last list - if (currentListKey != null && currentList.isNotEmpty) { - result[currentListKey] = List.unmodifiable(currentList); - } - - return result; - } - - /// Compare two semantic versions. - bool _isNewerVersion(String newVersion, String currentVersion) { - try { - final newParts = newVersion.split('.').map(int.parse).toList(); - final currentParts = currentVersion.split('.').map(int.parse).toList(); - - for (var i = 0; i < 3; i++) { - final newPart = i < newParts.length ? newParts[i] : 0; - final currentPart = i < currentParts.length ? currentParts[i] : 0; - if (newPart > currentPart) return true; - if (newPart < currentPart) return false; - } - return false; // equal - } catch (e) { - // Non-semver: just compare strings - return newVersion != currentVersion; - } + String _mcpRegistryId(String fullName) { + return 'mcp_registry_${fullName.replaceAll(RegExp(r'[^A-Za-z0-9_]+'), '_')}'; } - /// Demo data for when GitHub API is unavailable. - List<Skill> _getDemoGitHubSkills() { - return [ - Skill( - id: 'flutter_state_management', - name: 'Flutter状态管理', - version: '1.2.0', - description: 'Provider、Riverpod、Bloc、GetX等状态管理方案的Prompt模板和代码生成', - author: 'flutter-community', - tags: const ['flutter', 'state-management', 'riverpod', 'bloc'], - actions: const [ - 'flutter.add_riverpod_provider', - 'flutter.add_bloc_pattern', - 'flutter.generate_state_class', - ], - prompts: const [ - 'riverpod_setup_guide', - 'bloc_pattern_template', - 'state_management_comparison', - ], - mcpServers: const [], - source: SkillSource.github, - githubUrl: 'https://github.com/flutter-community/mobilecode-skill-state-management', - rating: 4.5, - installCount: 2340, - ), - Skill( - id: 'api_integration', - name: 'API集成助手', - version: '1.0.3', - description: 'REST API和GraphQL集成相关的代码生成、错误处理、缓存策略模板', - author: 'mobilecode-team', - tags: const ['api', 'http', 'graphql', 'dio'], - actions: const [ - 'flutter.create_api_service', - 'flutter.add_error_handler', - 'flutter.setup_dio', - ], - prompts: const [ - 'api_integration_template', - 'graphql_query_builder', - 'error_handling_pattern', - ], - mcpServers: const [], - source: SkillSource.github, - githubUrl: 'https://github.com/mobilecode-team/mobilecode-skill-api', - rating: 4.2, - installCount: 1890, - ), - Skill( - id: 'ui_component_library', - name: 'UI组件库生成器', - version: '2.0.0', - description: '快速生成Flutter UI组件、表单、对话框、列表等常用界面元素', - author: 'ui-wizard', - tags: const ['flutter', 'ui', 'components', 'design'], - actions: const [ - 'flutter.create_custom_widget', - 'flutter.generate_form', - 'flutter.create_dialog', - ], - prompts: const [ - 'custom_widget_template', - 'responsive_layout_guide', - 'animation_pattern', - ], - mcpServers: const [], - source: SkillSource.github, - githubUrl: 'https://github.com/ui-wizard/mobilecode-skill-ui', - rating: 4.8, - installCount: 5670, - ), - ]; - } -} + List<Skill> _getCuratedGitHubSkills(String query, {int limit = 12}) { + final curated = [ + _curatedSkill( + id: 'curated_frontend_design', + name: 'Frontend Design', + description: 'HTML/UI design direction, typography, layout, visual quality, and mobile preview polish.', + githubUrl: 'https://github.com/anthropics/skills', + ), + _curatedSkill( + id: 'curated_ui_ux_pro_max', + name: 'UI UX Pro Max', + description: 'Mobile UX flow, interface state coverage, and product-grade UI hierarchy.', + githubUrl: 'https://github.com/nextlevelbuilder/ui-ux-pro-max-skill', + ), + _curatedSkill( + id: 'curated_shadcn_ui', + name: 'shadcn/ui Pattern Kit', + description: 'Owned component patterns, variants, dialogs, forms, and registry-style component thinking.', + githubUrl: 'https://github.com/giuseppe-trisciuoglio/developer-kit', + ), + _curatedSkill( + id: 'curated_stitch_html_design', + name: 'Stitch HTML Design', + description: 'Prompt-to-interface structure and high-fidelity HTML screen generation.', + githubUrl: 'https://github.com/google-labs-code/stitch-skills', + ), + _curatedSkill( + id: 'curated_web_accessibility', + name: 'Web Accessibility', + description: 'Semantic HTML, focus order, contrast, labels, and reduced-motion defaults.', + githubUrl: 'https://github.com/supercent-io/skills-template', + ), + _curatedSkill( + id: 'curated_web_design_guidelines', + name: 'Web Design Guidelines', + description: 'Responsive composition, deployable HTML quality, and performance-aware web UI.', + githubUrl: 'https://github.com/vercel-labs/agent-skills', + ), + _curatedSkill( + id: 'curated_ui_animation', + name: 'UI Animation', + description: 'CSS-first motion, micro-interactions, page reveals, and reduced-motion fallback.', + githubUrl: 'https://github.com/mblode/agent-skills', + ), + _curatedSkill( + id: 'curated_figma_implement_design', + name: 'Figma Implement Design', + description: 'Design context extraction, token translation, asset fidelity, and visual parity discipline.', + githubUrl: 'https://github.com/figma/mcp-server-guide', + ), + _curatedSkill( + id: 'curated_tailwind_design_system', + name: 'Tailwind Design System', + description: 'Tokenized spacing, typography, color, and reusable design-system rules.', + githubUrl: 'https://github.com/wshobson/agents', + ), + ]; + + final lower = query.toLowerCase(); + final filtered = curated + .where((skill) => + lower.trim().isEmpty || + skill.name.toLowerCase().contains(lower) || + skill.description.toLowerCase().contains(lower) || + skill.tags.any((tag) => tag.toLowerCase().contains(lower))) + .toList(); + return List.unmodifiable((filtered.isEmpty ? curated : filtered).take(limit)); + } + + Skill _curatedSkill({ + required String id, + required String name, + required String description, + required String githubUrl, + }) { + return Skill( + id: id, + name: name, + version: '1.0.0', + description: description, + author: _repoOwnerFromGitHubUrl(githubUrl), + tags: const ['curated', 'github', 'html', 'ui', 'external-registry'], + actions: const [], + prompts: const [], + mcpServers: const [], + source: SkillSource.github, + githubUrl: githubUrl, + ); + } + + List<McpServer> _getCuratedMcpServers(String query, {int limit = 12}) { + final servers = [ + McpServer( + id: 'mcp_registry_github', + name: 'GitHub MCP Server', + type: 'stdio', + command: 'npx -y @modelcontextprotocol/server-github', + description: 'Repository, issue, and pull request tools. Requires a reviewed GitHub token in env.', + version: 'registry-preview', + env: const {'GITHUB_TOKEN': '<required>'}, + ), + McpServer( + id: 'mcp_registry_fetch', + name: 'Fetch MCP Server', + type: 'stdio', + command: 'npx -y @modelcontextprotocol/server-fetch', + description: 'HTTP fetch tools for documentation and public web content. Review network access before enabling.', + version: 'registry-preview', + ), + McpServer( + id: 'mcp_registry_filesystem', + name: 'Filesystem MCP Server', + type: 'stdio', + command: 'npx -y @modelcontextprotocol/server-filesystem <workspace>', + description: 'Workspace-bounded file access. Must be restricted to the MobileCode project directory.', + version: 'registry-preview', + ), + McpServer( + id: 'mcp_registry_playwright', + name: 'Browser/Playwright MCP Server', + type: 'stdio', + command: 'npx -y @playwright/mcp', + description: 'Browser automation for local preview QA. Keep disabled until the user confirms browser automation.', + version: 'registry-preview', + ), + ]; + final lower = query.toLowerCase(); + final filtered = servers + .where((server) => + lower.trim().isEmpty || + server.name.toLowerCase().contains(lower) || + (server.description ?? '').toLowerCase().contains(lower) || + server.command.toLowerCase().contains(lower)) + .toList(); + return List.unmodifiable((filtered.isEmpty ? servers : filtered).take(limit)); + } + + String _repoOwnerFromGitHubUrl(String githubUrl) { + final uri = Uri.tryParse(githubUrl); + if (uri == null || uri.pathSegments.isEmpty) return 'unknown'; + return uri.pathSegments[0]; + } + + String? _guessNpmPackageName(String fullName, String name) { + final lower = name.toLowerCase(); + if (!lower.contains('mcp')) return null; + if (lower.startsWith('@')) return name; + if (lower.startsWith('server-') || lower.startsWith('mcp-server-')) { + return name; + } + return null; + } + + /// Compare two semantic versions. + bool _isNewerVersion(String newVersion, String currentVersion) { + try { + final newParts = newVersion.split('.').map(int.parse).toList(); + final currentParts = currentVersion.split('.').map(int.parse).toList(); + + for (var i = 0; i < 3; i++) { + final newPart = i < newParts.length ? newParts[i] : 0; + final currentPart = i < currentParts.length ? currentParts[i] : 0; + if (newPart > currentPart) return true; + if (newPart < currentPart) return false; + } + return false; // equal + } catch (e) { + // Non-semver: just compare strings + return newVersion != currentVersion; + } + } + + /// Demo data for when GitHub API is unavailable. + List<Skill> _getDemoGitHubSkills() { + return [ + Skill( + id: 'flutter_state_management', + name: 'Flutter状态管理', + version: '1.2.0', + description: 'Provider、Riverpod、Bloc、GetX等状态管理方案的Prompt模板和代码生成', + author: 'flutter-community', + tags: const ['flutter', 'state-management', 'riverpod', 'bloc'], + actions: const [ + 'flutter.add_riverpod_provider', + 'flutter.add_bloc_pattern', + 'flutter.generate_state_class', + ], + prompts: const [ + 'riverpod_setup_guide', + 'bloc_pattern_template', + 'state_management_comparison', + ], + mcpServers: const [], + source: SkillSource.github, + githubUrl: 'https://github.com/flutter-community/mobilecode-skill-state-management', + rating: 4.5, + installCount: 2340, + ), + Skill( + id: 'api_integration', + name: 'API集成助手', + version: '1.0.3', + description: 'REST API和GraphQL集成相关的代码生成、错误处理、缓存策略模板', + author: 'mobilecode-team', + tags: const ['api', 'http', 'graphql', 'dio'], + actions: const [ + 'flutter.create_api_service', + 'flutter.add_error_handler', + 'flutter.setup_dio', + ], + prompts: const [ + 'api_integration_template', + 'graphql_query_builder', + 'error_handling_pattern', + ], + mcpServers: const [], + source: SkillSource.github, + githubUrl: 'https://github.com/mobilecode-team/mobilecode-skill-api', + rating: 4.2, + installCount: 1890, + ), + Skill( + id: 'ui_component_library', + name: 'UI组件库生成器', + version: '2.0.0', + description: '快速生成Flutter UI组件、表单、对话框、列表等常用界面元素', + author: 'ui-wizard', + tags: const ['flutter', 'ui', 'components', 'design'], + actions: const [ + 'flutter.create_custom_widget', + 'flutter.generate_form', + 'flutter.create_dialog', + ], + prompts: const [ + 'custom_widget_template', + 'responsive_layout_guide', + 'animation_pattern', + ], + mcpServers: const [], + source: SkillSource.github, + githubUrl: 'https://github.com/ui-wizard/mobilecode-skill-ui', + rating: 4.8, + installCount: 5670, + ), + ]; + } +} diff --git a/mobile_agent/lib/services/token_pricing_service.dart b/mobile_agent/lib/services/token_pricing_service.dart new file mode 100644 index 0000000..9b8ba6e --- /dev/null +++ b/mobile_agent/lib/services/token_pricing_service.dart @@ -0,0 +1,621 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class TokenPrice { + const TokenPrice({ + required this.provider, + required this.model, + required this.inputCostPerToken, + required this.outputCostPerToken, + required this.cacheReadCostPerToken, + required this.cacheWriteCostPerToken, + required this.sourceName, + required this.sourceUrl, + required this.updatedAt, + required this.custom, + this.notes = '', + }); + + final String provider; + final String model; + final double inputCostPerToken; + final double outputCostPerToken; + final double cacheReadCostPerToken; + final double cacheWriteCostPerToken; + final String sourceName; + final String sourceUrl; + final DateTime updatedAt; + final bool custom; + final String notes; + + double get inputPerMillion => inputCostPerToken * 1000000; + double get outputPerMillion => outputCostPerToken * 1000000; + double get cacheReadPerMillion => cacheReadCostPerToken * 1000000; + double get cacheWritePerMillion => cacheWriteCostPerToken * 1000000; + + String get key => priceKey(provider, model); + + TokenPrice copyWith({ + String? provider, + String? model, + double? inputCostPerToken, + double? outputCostPerToken, + double? cacheReadCostPerToken, + double? cacheWriteCostPerToken, + String? sourceName, + String? sourceUrl, + DateTime? updatedAt, + bool? custom, + String? notes, + }) { + return TokenPrice( + provider: provider ?? this.provider, + model: model ?? this.model, + inputCostPerToken: inputCostPerToken ?? this.inputCostPerToken, + outputCostPerToken: outputCostPerToken ?? this.outputCostPerToken, + cacheReadCostPerToken: cacheReadCostPerToken ?? this.cacheReadCostPerToken, + cacheWriteCostPerToken: cacheWriteCostPerToken ?? this.cacheWriteCostPerToken, + sourceName: sourceName ?? this.sourceName, + sourceUrl: sourceUrl ?? this.sourceUrl, + updatedAt: updatedAt ?? this.updatedAt, + custom: custom ?? this.custom, + notes: notes ?? this.notes, + ); + } + + Map<String, dynamic> toJson() { + return { + 'provider': provider, + 'model': model, + 'inputCostPerToken': inputCostPerToken, + 'outputCostPerToken': outputCostPerToken, + 'cacheReadCostPerToken': cacheReadCostPerToken, + 'cacheWriteCostPerToken': cacheWriteCostPerToken, + 'sourceName': sourceName, + 'sourceUrl': sourceUrl, + 'updatedAt': updatedAt.toIso8601String(), + 'custom': custom, + 'notes': notes, + }; + } + + factory TokenPrice.fromJson(Map<String, dynamic> json) { + return TokenPrice( + provider: json['provider'] as String? ?? 'custom', + model: json['model'] as String? ?? 'unknown', + inputCostPerToken: _doubleValue(json['inputCostPerToken'] ?? json['input_cost_per_token']), + outputCostPerToken: _doubleValue(json['outputCostPerToken'] ?? json['output_cost_per_token']), + cacheReadCostPerToken: _doubleValue( + json['cacheReadCostPerToken'] ?? json['cache_read_input_token_cost'] ?? json['cache_read_cost_per_token'], + ), + cacheWriteCostPerToken: _doubleValue( + json['cacheWriteCostPerToken'] ?? json['cache_creation_input_token_cost'] ?? json['cache_write_cost_per_token'], + ), + sourceName: json['sourceName'] as String? ?? 'Custom override', + sourceUrl: json['sourceUrl'] as String? ?? '', + updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? DateTime.now(), + custom: json['custom'] as bool? ?? false, + notes: json['notes'] as String? ?? '', + ); + } + + factory TokenPrice.fromLiteLlmEntry( + Map<String, dynamic> json, { + required String snapshotSourceName, + required String snapshotSourceUrl, + required DateTime snapshotUpdatedAt, + }) { + return TokenPrice( + provider: json['provider'] as String? ?? json['litellm_provider'] as String? ?? 'unknown', + model: json['model'] as String? ?? 'unknown', + inputCostPerToken: _doubleValue(json['input_cost_per_token']), + outputCostPerToken: _doubleValue(json['output_cost_per_token']), + cacheReadCostPerToken: _doubleValue(json['cache_read_input_token_cost'] ?? json['cache_read_cost_per_token']), + cacheWriteCostPerToken: _doubleValue(json['cache_creation_input_token_cost'] ?? json['cache_write_cost_per_token']), + sourceName: snapshotSourceName, + sourceUrl: snapshotSourceUrl, + updatedAt: snapshotUpdatedAt, + custom: false, + notes: json['notes'] as String? ?? '', + ); + } +} + +class TokenCostEstimate { + const TokenCostEstimate({ + required this.costUsd, + required this.price, + }); + + final double costUsd; + final TokenPrice price; +} + +class TokenPricingCatalog { + const TokenPricingCatalog({ + required this.sourceName, + required this.sourceUrl, + required this.updatedAt, + required this.snapshotCount, + required this.overrideCount, + }); + + final String sourceName; + final String sourceUrl; + final DateTime updatedAt; + final int snapshotCount; + final int overrideCount; +} + +class TokenPricingSnapshotUpdate { + const TokenPricingSnapshotUpdate({ + required this.sourceName, + required this.sourceUrl, + required this.updatedAt, + required this.prices, + required this.newCount, + required this.changedCount, + required this.unchangedCount, + }); + + final String sourceName; + final String sourceUrl; + final DateTime updatedAt; + final List<TokenPrice> prices; + final int newCount; + final int changedCount; + final int unchangedCount; + + int get modelCount => prices.length; + + Map<String, dynamic> toCacheJson() { + return { + 'sourceName': sourceName, + 'sourceUrl': sourceUrl, + 'updatedAt': updatedAt.toIso8601String(), + 'prices': prices.map((price) => price.toJson()).toList(), + }; + } +} + +class TokenPricingService extends ChangeNotifier { + TokenPricingService._(); + + static final TokenPricingService instance = TokenPricingService._(); + + static const _snapshotAsset = 'assets/token_pricing/litellm_price_snapshot.json'; + static const officialLiteLlmRawUrl = + 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'; + static const _overridesKey = 'mobilecode.token_pricing.overrides.v1'; + static const _snapshotCacheKey = 'mobilecode.token_pricing.snapshot_cache.v1'; + static final _fallbackUpdatedAt = DateTime(2026, 5, 18); + + final Map<String, TokenPrice> _snapshotPrices = {}; + final Map<String, TokenPrice> _overrides = {}; + SharedPreferences? _prefs; + var _initialized = false; + var _snapshotSourceName = 'MobileCode fallback prices'; + var _snapshotSourceUrl = 'https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json'; + var _snapshotUpdatedAt = _fallbackUpdatedAt; + + List<TokenPrice> get overrides => _overrides.values.toList(growable: false) + ..sort((a, b) => a.key.compareTo(b.key)); + + List<TokenPrice> get snapshotPrices => _snapshotPrices.values.toList(growable: false) + ..sort((a, b) => a.key.compareTo(b.key)); + + List<TokenPrice> get visiblePrices { + final combined = <String, TokenPrice>{..._snapshotPrices, ..._overrides}; + final values = combined.values.toList(growable: false) + ..sort((a, b) => a.key.compareTo(b.key)); + return values; + } + + TokenPricingCatalog get catalog => TokenPricingCatalog( + sourceName: _snapshotSourceName, + sourceUrl: _snapshotSourceUrl, + updatedAt: _snapshotUpdatedAt, + snapshotCount: _snapshotPrices.length, + overrideCount: _overrides.length, + ); + + Future<void> initialize() async { + if (_initialized) return; + _prefs = await SharedPreferences.getInstance(); + await _loadSnapshot(); + _loadCachedSnapshot(); + _loadOverrides(); + _initialized = true; + notifyListeners(); + } + + Future<TokenCostEstimate> estimateCost({ + required String provider, + required String model, + required int inputTokens, + required int outputTokens, + required int cacheReadTokens, + required int cacheWriteTokens, + }) async { + await initialize(); + final price = priceFor(provider: provider, model: model); + return TokenCostEstimate( + costUsd: estimateWithPrice( + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheReadTokens: cacheReadTokens, + cacheWriteTokens: cacheWriteTokens, + price: price, + ), + price: price, + ); + } + + TokenPrice priceFor({required String provider, required String model}) { + final providerText = provider.trim(); + final modelText = model.trim().isEmpty ? 'default' : model.trim(); + final keys = <String>[ + priceKey(providerText, modelText), + priceKey(_providerAlias(providerText), modelText), + priceKey('', modelText), + priceKey(providerText, _modelAlias(modelText)), + priceKey(_providerAlias(providerText), _modelAlias(modelText)), + ]; + for (final key in keys) { + final override = _overrides[key]; + if (override != null) return override; + } + for (final key in keys) { + final snapshot = _snapshotPrices[key]; + if (snapshot != null) return snapshot; + } + return _fallbackPrice(providerText, modelText); + } + + Future<void> upsertOverride(TokenPrice price) async { + await initialize(); + final normalized = price.copyWith( + provider: price.provider.trim().isEmpty ? 'custom' : price.provider.trim(), + model: price.model.trim().isEmpty ? 'default' : price.model.trim(), + sourceName: 'User override', + sourceUrl: '', + updatedAt: DateTime.now(), + custom: true, + ); + _overrides[normalized.key] = normalized; + await _persistOverrides(); + notifyListeners(); + } + + Future<void> removeOverride(String key) async { + await initialize(); + _overrides.remove(key); + await _persistOverrides(); + notifyListeners(); + } + + Future<TokenPricingSnapshotUpdate> checkLiteLlmUpdate({ + Uri? uri, + Duration timeout = const Duration(seconds: 20), + }) async { + await initialize(); + final sourceUri = uri ?? Uri.parse(officialLiteLlmRawUrl); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final request = await client.getUrl(sourceUri).timeout(const Duration(seconds: 10)); + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + final response = await request.close().timeout(timeout); + final body = await utf8.decodeStream(response); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('LiteLLM price update HTTP ${response.statusCode}'); + } + final remoteUpdatedAt = response.headers.value(HttpHeaders.lastModifiedHeader); + final parsed = parseLiteLlmSnapshot( + body, + sourceName: 'LiteLLM remote snapshot', + sourceUrl: sourceUri.toString(), + updatedAt: _parseHttpDate(remoteUpdatedAt) ?? DateTime.now(), + ); + if (parsed.isEmpty) { + throw StateError('LiteLLM price update returned no usable model prices.'); + } + return _buildUpdate(parsed); + } finally { + client.close(force: true); + } + } + + Future<void> applySnapshotUpdate(TokenPricingSnapshotUpdate update) async { + await initialize(); + if (update.prices.isEmpty) { + throw StateError('Cannot apply an empty pricing snapshot.'); + } + _snapshotSourceName = update.sourceName; + _snapshotSourceUrl = update.sourceUrl; + _snapshotUpdatedAt = update.updatedAt; + _snapshotPrices + ..clear() + ..addEntries(update.prices.map((price) => MapEntry(price.key, price))); + await _prefs?.setString(_snapshotCacheKey, jsonEncode(update.toCacheJson())); + notifyListeners(); + } + + @visibleForTesting + TokenPricingSnapshotUpdate buildUpdateFromJson( + String raw, { + required String sourceName, + required String sourceUrl, + required DateTime updatedAt, + }) { + final prices = parseLiteLlmSnapshot( + raw, + sourceName: sourceName, + sourceUrl: sourceUrl, + updatedAt: updatedAt, + ); + return _buildUpdate(prices); + } + + @visibleForTesting + void resetForTesting() { + _snapshotPrices.clear(); + _overrides.clear(); + _prefs = null; + _initialized = false; + _snapshotSourceName = 'MobileCode fallback prices'; + _snapshotSourceUrl = 'https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json'; + _snapshotUpdatedAt = _fallbackUpdatedAt; + } + + Future<void> _loadSnapshot() async { + try { + final raw = await rootBundle.loadString(_snapshotAsset); + final decoded = jsonDecode(raw); + if (decoded is! Map<String, dynamic>) return; + _snapshotSourceName = decoded['sourceName'] as String? ?? _snapshotSourceName; + _snapshotSourceUrl = decoded['sourceUrl'] as String? ?? _snapshotSourceUrl; + _snapshotUpdatedAt = DateTime.tryParse(decoded['updatedAt'] as String? ?? '') ?? _snapshotUpdatedAt; + final prices = parseLiteLlmSnapshot( + raw, + sourceName: _snapshotSourceName, + sourceUrl: _snapshotSourceUrl, + updatedAt: _snapshotUpdatedAt, + ); + _snapshotPrices + ..clear() + ..addEntries(prices.map((price) => MapEntry(price.key, price))); + } on Object catch (error) { + debugPrint('[TokenPricingService] Failed to load price snapshot: $error'); + final fallback = _fallbackPrice('anthropic', 'mimo-v2.5-pro'); + _snapshotPrices[fallback.key] = fallback; + } + } + + void _loadCachedSnapshot() { + final raw = _prefs?.getString(_snapshotCacheKey); + if (raw == null || raw.trim().isEmpty) return; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map<String, dynamic>) return; + _snapshotSourceName = decoded['sourceName'] as String? ?? _snapshotSourceName; + _snapshotSourceUrl = decoded['sourceUrl'] as String? ?? _snapshotSourceUrl; + _snapshotUpdatedAt = DateTime.tryParse(decoded['updatedAt'] as String? ?? '') ?? _snapshotUpdatedAt; + final prices = parseLiteLlmSnapshot( + raw, + sourceName: _snapshotSourceName, + sourceUrl: _snapshotSourceUrl, + updatedAt: _snapshotUpdatedAt, + ); + if (prices.isEmpty) return; + _snapshotPrices + ..clear() + ..addEntries(prices.map((price) => MapEntry(price.key, price))); + } on Object catch (error) { + debugPrint('[TokenPricingService] Failed to load cached price snapshot: $error'); + } + } + + void _loadOverrides() { + final raw = _prefs?.getString(_overridesKey); + if (raw == null || raw.trim().isEmpty) return; + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + _overrides + ..clear() + ..addEntries(decoded.whereType<Map>().map((entry) { + final price = TokenPrice.fromJson(Map<String, dynamic>.from(entry)).copyWith(custom: true); + return MapEntry(price.key, price); + })); + } + } on Object catch (error) { + debugPrint('[TokenPricingService] Failed to load price overrides: $error'); + } + } + + Future<void> _persistOverrides() async { + await _prefs?.setString(_overridesKey, jsonEncode(_overrides.values.map((price) => price.toJson()).toList())); + } + + TokenPrice _fallbackPrice(String provider, String model) { + return TokenPrice( + provider: provider.trim().isEmpty ? 'unknown' : provider, + model: model.trim().isEmpty ? 'default' : model, + inputCostPerToken: 0.000003, + outputCostPerToken: 0.000015, + cacheReadCostPerToken: 0.0000003, + cacheWriteCostPerToken: 0.00000375, + sourceName: 'MobileCode fallback', + sourceUrl: _snapshotSourceUrl, + updatedAt: _fallbackUpdatedAt, + custom: false, + notes: 'Fallback Anthropic-class estimate. Add a user override for billing-grade accuracy.', + ); + } + + TokenPricingSnapshotUpdate _buildUpdate(List<TokenPrice> prices) { + var newCount = 0; + var changedCount = 0; + var unchangedCount = 0; + for (final price in prices) { + final existing = _snapshotPrices[price.key]; + if (existing == null) { + newCount++; + } else if (_priceChanged(existing, price)) { + changedCount++; + } else { + unchangedCount++; + } + } + final sourceName = prices.isEmpty ? 'LiteLLM remote snapshot' : prices.first.sourceName; + final sourceUrl = prices.isEmpty ? officialLiteLlmRawUrl : prices.first.sourceUrl; + final updatedAt = prices.isEmpty ? DateTime.now() : prices.first.updatedAt; + return TokenPricingSnapshotUpdate( + sourceName: sourceName, + sourceUrl: sourceUrl, + updatedAt: updatedAt, + prices: prices, + newCount: newCount, + changedCount: changedCount, + unchangedCount: unchangedCount, + ); + } +} + +List<TokenPrice> parseLiteLlmSnapshot( + String raw, { + required String sourceName, + required String sourceUrl, + required DateTime updatedAt, +}) { + final decoded = jsonDecode(raw); + if (decoded is! Map<String, dynamic>) return const []; + final pricesNode = decoded['prices']; + if (pricesNode is List) { + return pricesNode + .whereType<Map>() + .map((entry) { + final map = Map<String, dynamic>.from(entry); + return TokenPrice.fromJson({ + ...map, + 'sourceName': map['sourceName'] as String? ?? decoded['sourceName'] as String? ?? sourceName, + 'sourceUrl': map['sourceUrl'] as String? ?? decoded['sourceUrl'] as String? ?? sourceUrl, + 'updatedAt': map['updatedAt'] as String? ?? decoded['updatedAt'] as String? ?? updatedAt.toIso8601String(), + 'custom': map['custom'] as bool? ?? false, + }); + }) + .where(_hasUsablePrice) + .toList(growable: false); + } + + final parsed = <TokenPrice>[]; + for (final entry in decoded.entries) { + final value = entry.value; + if (value is! Map) continue; + final map = Map<String, dynamic>.from(value); + if (!_hasLiteLlmCostFields(map)) continue; + final price = TokenPrice.fromLiteLlmEntry( + { + ...map, + 'model': map['model'] ?? entry.key, + }, + snapshotSourceName: sourceName, + snapshotSourceUrl: sourceUrl, + snapshotUpdatedAt: updatedAt, + ); + if (_hasUsablePrice(price)) parsed.add(price); + } + return parsed; +} + +double estimateWithPrice({ + required int inputTokens, + required int outputTokens, + required int cacheReadTokens, + required int cacheWriteTokens, + required TokenPrice price, +}) { + final nonCachedInput = math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens).toDouble(); + return (nonCachedInput * price.inputCostPerToken) + + (outputTokens * price.outputCostPerToken) + + (cacheReadTokens * price.cacheReadCostPerToken) + + (cacheWriteTokens * price.cacheWriteCostPerToken); +} + +bool _hasUsablePrice(TokenPrice price) { + return price.model.trim().isNotEmpty && + (price.inputCostPerToken > 0 || + price.outputCostPerToken > 0 || + price.cacheReadCostPerToken > 0 || + price.cacheWriteCostPerToken > 0); +} + +bool _priceChanged(TokenPrice a, TokenPrice b) { + const epsilon = 0.000000000001; + return (a.inputCostPerToken - b.inputCostPerToken).abs() > epsilon || + (a.outputCostPerToken - b.outputCostPerToken).abs() > epsilon || + (a.cacheReadCostPerToken - b.cacheReadCostPerToken).abs() > epsilon || + (a.cacheWriteCostPerToken - b.cacheWriteCostPerToken).abs() > epsilon; +} + +bool _hasLiteLlmCostFields(Map<String, dynamic> map) { + return map.containsKey('input_cost_per_token') || + map.containsKey('output_cost_per_token') || + map.containsKey('cache_read_input_token_cost') || + map.containsKey('cache_read_cost_per_token') || + map.containsKey('cache_creation_input_token_cost') || + map.containsKey('cache_write_cost_per_token'); +} + +DateTime? _parseHttpDate(String? value) { + if (value == null || value.trim().isEmpty) return null; + try { + return HttpDate.parse(value); + } on Object { + return null; + } +} + +String priceKey(String provider, String model) { + final providerPart = _normalize(provider); + final modelPart = _normalize(model); + return '$providerPart/$modelPart'; +} + +String _providerAlias(String provider) { + final normalized = _normalize(provider); + if (normalized.contains('anthropic') || normalized.contains('claude') || normalized.contains('mimo')) { + return 'anthropic'; + } + if (normalized.contains('openai') || normalized.contains('gpt')) return 'openai'; + if (normalized.contains('google') || normalized.contains('gemini')) return 'google'; + return normalized; +} + +String _modelAlias(String model) { + final normalized = _normalize(model); + if (normalized.contains('mimo-v2.5-pro')) return 'mimo-v2.5-pro'; + if (normalized.contains('gpt-4o-mini')) return 'gpt-4o-mini'; + if (normalized.contains('gpt-4o')) return 'gpt-4o'; + if (normalized.contains('claude-3-5-haiku')) return 'claude-3-5-haiku-latest'; + if (normalized.contains('claude-3-5-sonnet')) return 'claude-3-5-sonnet-latest'; + if (normalized.contains('claude-3-7-sonnet')) return 'claude-3-7-sonnet-latest'; + if (normalized.contains('sonnet-4') || normalized.contains('claude-sonnet-4')) return 'claude-sonnet-4-0'; + return normalized; +} + +String _normalize(String value) { + return value.trim().toLowerCase().replaceAll(RegExp(r'[^a-z0-9_.:-]+'), '-'); +} + +double _doubleValue(Object? value, {double fallback = 0}) { + if (value is double) return value; + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value) ?? fallback; + return fallback; +} diff --git a/mobile_agent/lib/services/token_usage_service.dart b/mobile_agent/lib/services/token_usage_service.dart new file mode 100644 index 0000000..d8f8e31 --- /dev/null +++ b/mobile_agent/lib/services/token_usage_service.dart @@ -0,0 +1,482 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'token_pricing_service.dart'; + +class TokenUsageSnapshot { + const TokenUsageSnapshot({ + required this.inputTokens, + required this.outputTokens, + required this.totalTokens, + required this.cacheReadTokens, + required this.cacheWriteTokens, + required this.cacheMissTokens, + required this.estimated, + }); + + final int inputTokens; + final int outputTokens; + final int totalTokens; + final int cacheReadTokens; + final int cacheWriteTokens; + final int cacheMissTokens; + final bool estimated; + + bool get hasProviderUsage => !estimated && totalTokens > 0; + + TokenUsageSnapshot merge(TokenUsageSnapshot other) { + return TokenUsageSnapshot( + inputTokens: math.max(inputTokens, other.inputTokens), + outputTokens: math.max(outputTokens, other.outputTokens), + totalTokens: math.max(totalTokens, other.totalTokens), + cacheReadTokens: math.max(cacheReadTokens, other.cacheReadTokens), + cacheWriteTokens: math.max(cacheWriteTokens, other.cacheWriteTokens), + cacheMissTokens: math.max(cacheMissTokens, other.cacheMissTokens), + estimated: estimated && other.estimated, + ); + } + + Map<String, dynamic> toJson() { + return { + 'inputTokens': inputTokens, + 'outputTokens': outputTokens, + 'totalTokens': totalTokens, + 'cacheReadTokens': cacheReadTokens, + 'cacheWriteTokens': cacheWriteTokens, + 'cacheMissTokens': cacheMissTokens, + 'estimated': estimated, + }; + } + + factory TokenUsageSnapshot.fromJson(Map<String, dynamic> json) { + return TokenUsageSnapshot( + inputTokens: json['inputTokens'] as int? ?? 0, + outputTokens: json['outputTokens'] as int? ?? 0, + totalTokens: json['totalTokens'] as int? ?? 0, + cacheReadTokens: json['cacheReadTokens'] as int? ?? 0, + cacheWriteTokens: json['cacheWriteTokens'] as int? ?? 0, + cacheMissTokens: json['cacheMissTokens'] as int? ?? 0, + estimated: json['estimated'] as bool? ?? true, + ); + } + + static const empty = TokenUsageSnapshot( + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheMissTokens: 0, + estimated: true, + ); +} + +class TokenUsageEvent { + const TokenUsageEvent({ + required this.id, + required this.provider, + required this.model, + required this.endpoint, + required this.sessionId, + required this.runId, + required this.roleId, + required this.durationMs, + required this.success, + required this.cancelled, + required this.usage, + required this.costEstimate, + required this.pricingSource, + required this.pricingUpdatedAt, + required this.createdAt, + }); + + final String id; + final String provider; + final String model; + final String endpoint; + final String? sessionId; + final String? runId; + final String? roleId; + final int durationMs; + final bool success; + final bool cancelled; + final TokenUsageSnapshot usage; + final double costEstimate; + final String pricingSource; + final DateTime pricingUpdatedAt; + final DateTime createdAt; + + Map<String, dynamic> toJson() { + return { + 'id': id, + 'provider': provider, + 'model': model, + 'endpoint': endpoint, + 'sessionId': sessionId, + 'runId': runId, + 'roleId': roleId, + 'durationMs': durationMs, + 'success': success, + 'cancelled': cancelled, + 'usage': usage.toJson(), + 'costEstimate': costEstimate, + 'pricingSource': pricingSource, + 'pricingUpdatedAt': pricingUpdatedAt.toIso8601String(), + 'createdAt': createdAt.toIso8601String(), + }; + } + + factory TokenUsageEvent.fromJson(Map<String, dynamic> json) { + return TokenUsageEvent( + id: json['id'] as String? ?? 'usage_${DateTime.now().microsecondsSinceEpoch}', + provider: json['provider'] as String? ?? 'unknown', + model: json['model'] as String? ?? 'unknown', + endpoint: json['endpoint'] as String? ?? 'unknown', + sessionId: json['sessionId'] as String?, + runId: json['runId'] as String?, + roleId: json['roleId'] as String?, + durationMs: json['durationMs'] as int? ?? 0, + success: json['success'] as bool? ?? false, + cancelled: json['cancelled'] as bool? ?? false, + usage: TokenUsageSnapshot.fromJson(Map<String, dynamic>.from(json['usage'] as Map? ?? const {})), + costEstimate: (json['costEstimate'] as num?)?.toDouble() ?? 0, + pricingSource: json['pricingSource'] as String? ?? 'Legacy estimate', + pricingUpdatedAt: DateTime.tryParse(json['pricingUpdatedAt'] as String? ?? '') ?? DateTime(2026, 5, 18), + createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ?? DateTime.now(), + ); + } +} + +class TokenUsageSummary { + const TokenUsageSummary({ + required this.totalTokens, + required this.inputTokens, + required this.outputTokens, + required this.cacheReadTokens, + required this.cacheWriteTokens, + required this.cacheMissTokens, + required this.requestCount, + required this.successCount, + required this.estimatedCount, + required this.costEstimate, + required this.byProvider, + }); + + final int totalTokens; + final int inputTokens; + final int outputTokens; + final int cacheReadTokens; + final int cacheWriteTokens; + final int cacheMissTokens; + final int requestCount; + final int successCount; + final int estimatedCount; + final double costEstimate; + final Map<String, int> byProvider; + + double get cacheHitRate { + final denominator = cacheReadTokens + cacheWriteTokens + cacheMissTokens; + if (denominator <= 0) return 0; + return cacheReadTokens / denominator; + } + + static const empty = TokenUsageSummary( + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheMissTokens: 0, + requestCount: 0, + successCount: 0, + estimatedCount: 0, + costEstimate: 0, + byProvider: {}, + ); +} + +class TokenUsageAccumulator { + TokenUsageAccumulator({required this.providerKind}); + + final String providerKind; + TokenUsageSnapshot _snapshot = TokenUsageSnapshot.empty; + + void addChunk(Map<String, dynamic> chunk) { + final parsed = providerKind == 'anthropic' + ? TokenUsageService.parseAnthropicUsage(chunk) + : TokenUsageService.parseOpenAiUsage(chunk); + if (parsed.totalTokens > 0 || parsed.inputTokens > 0 || parsed.outputTokens > 0) { + _snapshot = _snapshot.merge(parsed); + } + } + + TokenUsageSnapshot snapshot({required int inputChars, required int outputChars}) { + if (_snapshot.hasProviderUsage) return _snapshot; + return TokenUsageService.estimateUsage(inputChars: inputChars, outputChars: outputChars); + } +} + +class TokenUsageService extends ChangeNotifier { + TokenUsageService._(); + + static final TokenUsageService instance = TokenUsageService._(); + static const _eventsKey = 'mobilecode.token_usage.events.v1'; + + final _summaryController = StreamController<TokenUsageSummary>.broadcast(); + final List<TokenUsageEvent> _events = []; + SharedPreferences? _prefs; + bool _initialized = false; + + List<TokenUsageEvent> get recentEvents => List.unmodifiable(_events); + + TokenUsageSummary get summary => _summarize(_events); + + Future<void> initialize() async { + if (_initialized) return; + _prefs = await SharedPreferences.getInstance(); + final raw = _prefs?.getString(_eventsKey); + if (raw != null && raw.trim().isNotEmpty) { + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + _events + ..clear() + ..addAll(decoded.whereType<Map>().map((item) => TokenUsageEvent.fromJson(Map<String, dynamic>.from(item)))); + } + } catch (error) { + debugPrint('[TokenUsageService] Failed to load usage events: $error'); + } + } + _initialized = true; + _publish(); + } + + Stream<TokenUsageSummary> watchSummary() async* { + await initialize(); + yield summary; + yield* _summaryController.stream; + } + + String recordStarted({ + required String provider, + required String model, + required String endpoint, + String? sessionId, + String? runId, + String? roleId, + }) { + return 'usage_${DateTime.now().microsecondsSinceEpoch}'; + } + + Future<TokenUsageEvent> recordCompleted({ + required String provider, + required String model, + required String endpoint, + required int durationMs, + required bool success, + bool cancelled = false, + String? sessionId, + String? runId, + String? roleId, + TokenUsageSnapshot? usage, + int inputChars = 0, + int outputChars = 0, + }) async { + await initialize(); + final snapshot = usage?.hasProviderUsage == true + ? usage! + : estimateUsage(inputChars: inputChars, outputChars: outputChars); + final resolvedModel = model.isEmpty ? 'default' : model; + final costEstimate = await TokenPricingService.instance.estimateCost( + provider: provider, + model: resolvedModel, + inputTokens: snapshot.inputTokens, + outputTokens: snapshot.outputTokens, + cacheReadTokens: snapshot.cacheReadTokens, + cacheWriteTokens: snapshot.cacheWriteTokens, + ); + final event = TokenUsageEvent( + id: 'usage_${DateTime.now().microsecondsSinceEpoch}', + provider: provider, + model: resolvedModel, + endpoint: endpoint, + sessionId: sessionId, + runId: runId, + roleId: roleId, + durationMs: durationMs, + success: success, + cancelled: cancelled, + usage: snapshot, + costEstimate: costEstimate.costUsd, + pricingSource: costEstimate.price.sourceName, + pricingUpdatedAt: costEstimate.price.updatedAt, + createdAt: DateTime.now(), + ); + _events.insert(0, event); + if (_events.length > 300) _events.removeRange(300, _events.length); + await _persist(); + _publish(); + return event; + } + + Future<TokenUsageEvent> recordCancelled({ + required String provider, + required String model, + required String endpoint, + required int durationMs, + String? sessionId, + String? runId, + String? roleId, + int inputChars = 0, + int outputChars = 0, + }) { + return recordCompleted( + provider: provider, + model: model, + endpoint: endpoint, + durationMs: durationMs, + success: false, + cancelled: true, + sessionId: sessionId, + runId: runId, + roleId: roleId, + inputChars: inputChars, + outputChars: outputChars, + ); + } + + static TokenUsageSnapshot parseOpenAiUsage(Map<String, dynamic> data) { + final usage = _usageMap(data); + if (usage == null) return TokenUsageSnapshot.empty; + final input = _intValue(usage['prompt_tokens']); + final output = _intValue(usage['completion_tokens']); + final total = _intValue(usage['total_tokens'], fallback: input + output); + final details = usage['prompt_tokens_details']; + final cached = details is Map ? _intValue(details['cached_tokens']) : 0; + final miss = math.max(0, input - cached); + return TokenUsageSnapshot( + inputTokens: input, + outputTokens: output, + totalTokens: total, + cacheReadTokens: cached, + cacheWriteTokens: 0, + cacheMissTokens: miss, + estimated: total == 0, + ); + } + + static TokenUsageSnapshot parseAnthropicUsage(Map<String, dynamic> data) { + final usage = _usageMap(data); + if (usage == null) return TokenUsageSnapshot.empty; + final input = _intValue(usage['input_tokens']); + final output = _intValue(usage['output_tokens']); + final cacheWrite = _intValue(usage['cache_creation_input_tokens']); + final cacheRead = _intValue(usage['cache_read_input_tokens']); + final total = input + output; + return TokenUsageSnapshot( + inputTokens: input, + outputTokens: output, + totalTokens: total, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, + cacheMissTokens: math.max(0, input - cacheRead - cacheWrite), + estimated: total == 0, + ); + } + + static TokenUsageSnapshot estimateUsage({required int inputChars, required int outputChars}) { + final input = math.max(1, (inputChars / 4).ceil()); + final output = math.max(1, (outputChars / 4).ceil()); + return TokenUsageSnapshot( + inputTokens: input, + outputTokens: output, + totalTokens: input + output, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cacheMissTokens: input, + estimated: true, + ); + } + + static double estimateCost(TokenUsageSnapshot usage) { + return (usage.inputTokens * 0.000003) + (usage.outputTokens * 0.000015); + } + + Future<void> _persist() async { + await _prefs?.setString(_eventsKey, jsonEncode(_events.map((event) => event.toJson()).toList())); + } + + void _publish() { + notifyListeners(); + if (!_summaryController.isClosed) _summaryController.add(summary); + } + + TokenUsageSummary _summarize(List<TokenUsageEvent> events) { + final byProvider = <String, int>{}; + var input = 0; + var output = 0; + var total = 0; + var cacheRead = 0; + var cacheWrite = 0; + var cacheMiss = 0; + var success = 0; + var estimated = 0; + var cost = 0.0; + for (final event in events) { + input += event.usage.inputTokens; + output += event.usage.outputTokens; + total += event.usage.totalTokens; + cacheRead += event.usage.cacheReadTokens; + cacheWrite += event.usage.cacheWriteTokens; + cacheMiss += event.usage.cacheMissTokens; + if (event.success) success++; + if (event.usage.estimated) estimated++; + cost += event.costEstimate; + byProvider[event.provider] = (byProvider[event.provider] ?? 0) + event.usage.totalTokens; + } + return TokenUsageSummary( + totalTokens: total, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, + cacheMissTokens: cacheMiss, + requestCount: events.length, + successCount: success, + estimatedCount: estimated, + costEstimate: cost, + byProvider: byProvider, + ); + } + + static Map<String, dynamic>? _usageMap(Map<String, dynamic> data) { + final usage = data['usage']; + if (usage is Map) return Map<String, dynamic>.from(usage); + final message = data['message']; + if (message is Map && message['usage'] is Map) { + return Map<String, dynamic>.from(message['usage'] as Map); + } + final delta = data['delta']; + if (delta is Map && delta['usage'] is Map) { + return Map<String, dynamic>.from(delta['usage'] as Map); + } + if (data.containsKey('input_tokens') || + data.containsKey('prompt_tokens') || + data.containsKey('output_tokens') || + data.containsKey('completion_tokens')) { + return data; + } + return null; + } + + static int _intValue(Object? value, {int fallback = 0}) { + if (value is int) return value; + if (value is num) return value.round(); + if (value is String) return int.tryParse(value) ?? fallback; + return fallback; + } +} diff --git a/mobile_agent/lib/services/voice_service.dart b/mobile_agent/lib/services/voice_service.dart index c7ffb96..242918e 100644 --- a/mobile_agent/lib/services/voice_service.dart +++ b/mobile_agent/lib/services/voice_service.dart @@ -333,7 +333,11 @@ class VoiceService { if (!_speech.isListening) return _transcript; _stopAudioLevelSimulation(); - await _speech.stop(); + try { + await _speech.stop().timeout(const Duration(seconds: 2)); + } on TimeoutException { + await _speech.cancel().timeout(const Duration(milliseconds: 800), onTimeout: () {}); + } _setState(VoiceState.processing); @@ -392,8 +396,10 @@ class VoiceService { switch (status) { case 'listening': _setState(VoiceState.listening); + break; case 'notListening': _stopAudioLevelSimulation(); + break; case 'done': // Final result already handled in _onSpeechResult. break; diff --git a/mobile_agent/lib/themes/app_theme.dart b/mobile_agent/lib/themes/app_theme.dart index c688f21..c1d17f2 100644 --- a/mobile_agent/lib/themes/app_theme.dart +++ b/mobile_agent/lib/themes/app_theme.dart @@ -1,386 +1,507 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -/// Mobile Agent App Theme -/// Aurora Light is the default MobileCode product theme. -class AppTheme { - AppTheme._(); - - // ── Core Colors ────────────────────────────────────────────── - static const Color auroraBackground = Color(0xFFF7FAFF); - static const Color auroraSurface = Color(0xFFFFFFFF); - static const Color auroraSurfaceSoft = Color(0xFFF0F5FF); - static const Color auroraBorder = Color(0xFFDDE7F7); - static const Color auroraText = Color(0xFF0B1020); - static const Color auroraTextMuted = Color(0xFF536079); - static const Color auroraTextFaint = Color(0xFF8B97AD); +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Mobile Agent App Theme +/// Aurora Light is the default MobileCode product theme. +class AppTheme { + AppTheme._(); + + // ── Core Colors ────────────────────────────────────────────── + static const Color auroraBackground = Color(0xFFF7FAFF); + static const Color auroraSurface = Color(0xFFFFFFFF); + static const Color auroraSurfaceSoft = Color(0xFFF0F5FF); + static const Color auroraBorder = Color(0xFFDDE7F7); + static const Color auroraText = Color(0xFF0B1020); + static const Color auroraTextMuted = Color(0xFF536079); + static const Color auroraTextFaint = Color(0xFF8B97AD); static const Color auroraBlue = Color(0xFF2555FF); static const Color auroraCyan = Color(0xFF16B9C7); static const Color auroraViolet = Color(0xFF7557E8); + static const Color claudeBackground = Color(0xFFFFFAF3); + static const Color claudeSurface = Color(0xFFFFFFFF); + static const Color claudeSurfaceSoft = Color(0xFFFFF1DD); + static const Color claudeBorder = Color(0xFFEEDCC6); + static const Color claudeText = Color(0xFF1B130A); + static const Color claudeTextMuted = Color(0xFF755C43); + static const Color claudeTextFaint = Color(0xFFA48767); + static const Color claudeAmber = Color(0xFFD97706); + static const Color claudeCoral = Color(0xFFEF925B); + + static const Color deepSpace = Color(0xFF030508); + static const Color surfaceDark = Color(0xFF0A0E1A); + static const Color surfaceElevated = Color(0xFF12162B); + static const Color surfaceCard = Color(0xFF161B2E); + static const Color divider = Color(0xFF1E2440); + static const Color border = Color(0xFF2A3050); + + static const Color violet = Color(0xFF7B2FF7); + static const Color violetLight = Color(0xFF9B5FFF); + static const Color violetDark = Color(0xFF5A1DB5); + static const Color violetGlow = Color(0x407B2FF7); + + static const Color cyan = Color(0xFF00D4AA); + static const Color cyanLight = Color(0xFF33E0BF); + static const Color cyanGlow = Color(0x4000D4AA); + + static const Color textPrimary = Color(0xFFE8ECF4); + static const Color textSecondary = Color(0xFF8B92B9); + static const Color textTertiary = Color(0xFF5A6080); + + static const Color error = Color(0xFFFF4757); + static const Color warning = Color(0xFFFFA502); + static const Color success = Color(0xFF2ED573); + + // ── Gradients ──────────────────────────────────────────────── + static const Gradient auroraGradient = LinearGradient( + colors: [violet, cyan], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + static const Gradient violetGradient = LinearGradient( + colors: [violetDark, violet, violetLight], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + static const Gradient surfaceGradient = LinearGradient( + colors: [surfaceDark, deepSpace], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + // ── Glassmorphism ──────────────────────────────────────────── + static BoxDecoration glassDecoration = BoxDecoration( + color: surfaceCard.withOpacity(0.6), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: border.withOpacity(0.5), width: 1), + boxShadow: [ + BoxShadow( + color: violetGlow.withOpacity(0.1), + blurRadius: 20, + spreadRadius: 0, + ), + ], + ); + + static BoxDecoration glassDecorationRounded = BoxDecoration( + color: surfaceCard.withOpacity(0.5), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: border.withOpacity(0.4), width: 1), + ); + + // ── Typography ─────────────────────────────────────────────── + static const String fontCode = 'JetBrainsMono'; + static const String fontDisplay = 'Inter'; + + static TextTheme get textTheme => const TextTheme( + displayLarge: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: textPrimary, + letterSpacing: 0, + ), + displayMedium: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: textPrimary, + ), + titleLarge: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + titleMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: textPrimary, + ), + bodyLarge: TextStyle(fontSize: 16, color: textPrimary), + bodyMedium: TextStyle(fontSize: 14, color: textSecondary), + bodySmall: TextStyle(fontSize: 12, color: textTertiary), + labelLarge: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: textPrimary, + ), + ); + + // ── Aurora Light Theme ─────────────────────────────────────── + static ThemeData get auroraLightTheme => ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: auroraBackground, + colorScheme: const ColorScheme.light( + primary: auroraBlue, + onPrimary: Colors.white, + secondary: auroraCyan, + onSecondary: Colors.white, + tertiary: auroraViolet, + surface: auroraSurface, + surfaceContainerHighest: auroraSurfaceSoft, + error: error, + onError: Colors.white, + onSurface: auroraText, + outline: auroraBorder, + ), + textTheme: const TextTheme( + displayLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: auroraText, letterSpacing: 0), + displayMedium: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: auroraText), + titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: auroraText), + titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: auroraText), + bodyLarge: TextStyle(fontSize: 16, color: auroraText), + bodyMedium: TextStyle(fontSize: 14, color: auroraTextMuted), + bodySmall: TextStyle(fontSize: 12, color: auroraTextFaint), + labelLarge: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: auroraText), + ), + appBarTheme: const AppBarTheme( + backgroundColor: auroraSurface, + foregroundColor: auroraText, + elevation: 0, + centerTitle: true, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + ), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: auroraSurface, + indicatorColor: auroraBlue.withOpacity(0.10), + labelTextStyle: WidgetStateProperty.resolveWith( + (states) => TextStyle( + color: states.contains(WidgetState.selected) ? auroraBlue : auroraTextFaint, + fontSize: 11, + fontWeight: states.contains(WidgetState.selected) ? FontWeight.w800 : FontWeight.w600, + ), + ), + iconTheme: WidgetStateProperty.resolveWith( + (states) => IconThemeData( + color: states.contains(WidgetState.selected) ? auroraBlue : auroraTextFaint, + ), + ), + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: auroraBlue, + foregroundColor: Colors.white, + elevation: 2, + ), + cardTheme: CardThemeData( + color: auroraSurface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: const BorderSide(color: auroraBorder), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: auroraSurfaceSoft, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: auroraBorder), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: auroraBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: auroraBlue, width: 1.6), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + labelStyle: const TextStyle(color: auroraTextMuted), + hintStyle: const TextStyle(color: auroraTextFaint), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: auroraBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: auroraBlue, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 13), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ), + iconButtonTheme: IconButtonThemeData( + style: IconButton.styleFrom( + foregroundColor: auroraText, + backgroundColor: auroraSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: const BorderSide(color: auroraBorder), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: auroraBlue), + ), + chipTheme: ChipThemeData( + backgroundColor: auroraSurface, + selectedColor: auroraBlue.withOpacity(0.12), + labelStyle: const TextStyle(color: auroraTextMuted, fontSize: 12, fontWeight: FontWeight.w700), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: const BorderSide(color: auroraBorder), + ), + ), + dividerTheme: const DividerThemeData(color: auroraBorder, thickness: 1, space: 1), + snackBarTheme: SnackBarThemeData( + backgroundColor: auroraText, + contentTextStyle: const TextStyle(color: Colors.white), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + behavior: SnackBarBehavior.floating, + ), + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: auroraText, + borderRadius: BorderRadius.circular(8), + ), + textStyle: const TextStyle(color: Colors.white, fontSize: 12), + ), + ); - static const Color deepSpace = Color(0xFF030508); - static const Color surfaceDark = Color(0xFF0A0E1A); - static const Color surfaceElevated = Color(0xFF12162B); - static const Color surfaceCard = Color(0xFF161B2E); - static const Color divider = Color(0xFF1E2440); - static const Color border = Color(0xFF2A3050); - - static const Color violet = Color(0xFF7B2FF7); - static const Color violetLight = Color(0xFF9B5FFF); - static const Color violetDark = Color(0xFF5A1DB5); - static const Color violetGlow = Color(0x407B2FF7); - - static const Color cyan = Color(0xFF00D4AA); - static const Color cyanLight = Color(0xFF33E0BF); - static const Color cyanGlow = Color(0x4000D4AA); - - static const Color textPrimary = Color(0xFFE8ECF4); - static const Color textSecondary = Color(0xFF8B92B9); - static const Color textTertiary = Color(0xFF5A6080); - - static const Color error = Color(0xFFFF4757); - static const Color warning = Color(0xFFFFA502); - static const Color success = Color(0xFF2ED573); - - // ── Gradients ──────────────────────────────────────────────── - static const Gradient auroraGradient = LinearGradient( - colors: [violet, cyan], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); - - static const Gradient violetGradient = LinearGradient( - colors: [violetDark, violet, violetLight], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); - - static const Gradient surfaceGradient = LinearGradient( - colors: [surfaceDark, deepSpace], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ); + static ThemeData get codexBlueLightTheme => auroraLightTheme; - // ── Glassmorphism ──────────────────────────────────────────── - static BoxDecoration glassDecoration = BoxDecoration( - color: surfaceCard.withOpacity(0.6), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: border.withOpacity(0.5), width: 1), - boxShadow: [ - BoxShadow( - color: violetGlow.withOpacity(0.1), - blurRadius: 20, - spreadRadius: 0, + static ThemeData get claudeYellowLightTheme { + final base = auroraLightTheme; + return base.copyWith( + scaffoldBackgroundColor: claudeBackground, + colorScheme: const ColorScheme.light( + primary: claudeAmber, + onPrimary: Colors.white, + secondary: claudeCoral, + onSecondary: Colors.white, + tertiary: auroraBlue, + surface: claudeSurface, + surfaceContainerHighest: claudeSurfaceSoft, + error: error, + onError: Colors.white, + onSurface: claudeText, + outline: claudeBorder, ), - ], - ); - - static BoxDecoration glassDecorationRounded = BoxDecoration( - color: surfaceCard.withOpacity(0.5), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: border.withOpacity(0.4), width: 1), - ); - - // ── Typography ─────────────────────────────────────────────── - static const String fontCode = 'JetBrainsMono'; - static const String fontDisplay = 'Inter'; - - static TextTheme get textTheme => const TextTheme( - displayLarge: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: textPrimary, - letterSpacing: 0, - ), - displayMedium: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: textPrimary, - ), - titleLarge: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - titleMedium: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: textPrimary, - ), - bodyLarge: TextStyle(fontSize: 16, color: textPrimary), - bodyMedium: TextStyle(fontSize: 14, color: textSecondary), - bodySmall: TextStyle(fontSize: 12, color: textTertiary), - labelLarge: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: textPrimary, - ), - ); - - // ── Aurora Light Theme ─────────────────────────────────────── - static ThemeData get auroraLightTheme => ThemeData( - useMaterial3: true, - brightness: Brightness.light, - scaffoldBackgroundColor: auroraBackground, - colorScheme: const ColorScheme.light( - primary: auroraBlue, - onPrimary: Colors.white, - secondary: auroraCyan, - onSecondary: Colors.white, - tertiary: auroraViolet, - surface: auroraSurface, - surfaceContainerHighest: auroraSurfaceSoft, - error: error, - onError: Colors.white, - onSurface: auroraText, - outline: auroraBorder, + appBarTheme: const AppBarTheme( + backgroundColor: claudeSurface, + foregroundColor: claudeText, + elevation: 0, + centerTitle: true, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, ), - textTheme: const TextTheme( - displayLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: auroraText, letterSpacing: 0), - displayMedium: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: auroraText), - titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: auroraText), - titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: auroraText), - bodyLarge: TextStyle(fontSize: 16, color: auroraText), - bodyMedium: TextStyle(fontSize: 14, color: auroraTextMuted), - bodySmall: TextStyle(fontSize: 12, color: auroraTextFaint), - labelLarge: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: auroraText), - ), - appBarTheme: const AppBarTheme( - backgroundColor: auroraSurface, - foregroundColor: auroraText, - elevation: 0, - centerTitle: true, - systemOverlayStyle: SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - statusBarBrightness: Brightness.light, - ), - ), - navigationBarTheme: NavigationBarThemeData( - backgroundColor: auroraSurface, - indicatorColor: auroraBlue.withOpacity(0.10), - labelTextStyle: WidgetStateProperty.resolveWith( - (states) => TextStyle( - color: states.contains(WidgetState.selected) ? auroraBlue : auroraTextFaint, - fontSize: 11, - fontWeight: states.contains(WidgetState.selected) ? FontWeight.w800 : FontWeight.w600, - ), - ), - iconTheme: WidgetStateProperty.resolveWith( - (states) => IconThemeData( - color: states.contains(WidgetState.selected) ? auroraBlue : auroraTextFaint, - ), - ), - ), - floatingActionButtonTheme: const FloatingActionButtonThemeData( - backgroundColor: auroraBlue, - foregroundColor: Colors.white, - elevation: 2, - ), - cardTheme: CardThemeData( - color: auroraSurface, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - side: const BorderSide(color: auroraBorder), - ), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: auroraSurfaceSoft, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: auroraBorder), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: auroraBorder), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: auroraBlue, width: 1.6), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - labelStyle: const TextStyle(color: auroraTextMuted), - hintStyle: const TextStyle(color: auroraTextFaint), - ), - filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom( - backgroundColor: auroraBlue, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: auroraBlue, - foregroundColor: Colors.white, - elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 13), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - ), - iconButtonTheme: IconButtonThemeData( - style: IconButton.styleFrom( - foregroundColor: auroraText, - backgroundColor: auroraSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: const BorderSide(color: auroraBorder), - ), - ), - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom(foregroundColor: auroraBlue), - ), - chipTheme: ChipThemeData( - backgroundColor: auroraSurface, - selectedColor: auroraBlue.withOpacity(0.12), - labelStyle: const TextStyle(color: auroraTextMuted, fontSize: 12, fontWeight: FontWeight.w700), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(999), - side: const BorderSide(color: auroraBorder), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: claudeSurface, + indicatorColor: claudeAmber.withOpacity(0.12), + labelTextStyle: WidgetStateProperty.resolveWith( + (states) => TextStyle( + color: states.contains(WidgetState.selected) ? claudeAmber : claudeTextFaint, + fontSize: 11, + fontWeight: states.contains(WidgetState.selected) ? FontWeight.w800 : FontWeight.w600, ), ), - dividerTheme: const DividerThemeData(color: auroraBorder, thickness: 1, space: 1), - snackBarTheme: SnackBarThemeData( - backgroundColor: auroraText, - contentTextStyle: const TextStyle(color: Colors.white), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - behavior: SnackBarBehavior.floating, - ), - tooltipTheme: TooltipThemeData( - decoration: BoxDecoration( - color: auroraText, - borderRadius: BorderRadius.circular(8), + iconTheme: WidgetStateProperty.resolveWith( + (states) => IconThemeData( + color: states.contains(WidgetState.selected) ? claudeAmber : claudeTextFaint, ), - textStyle: const TextStyle(color: Colors.white, fontSize: 12), ), - ); - - // ── Dark Theme ─────────────────────────────────────────────── - static ThemeData get darkTheme => ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - scaffoldBackgroundColor: deepSpace, - colorScheme: const ColorScheme.dark( - primary: violet, - onPrimary: Colors.white, - secondary: cyan, - onSecondary: Colors.white, - surface: surfaceDark, - surfaceContainerHighest: surfaceElevated, - error: error, - onError: Colors.white, - onSurface: textPrimary, - outline: border, + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: claudeAmber, + foregroundColor: Colors.white, + elevation: 2, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: claudeSurfaceSoft, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: claudeBorder), ), - textTheme: textTheme, - appBarTheme: const AppBarTheme( - backgroundColor: surfaceDark, - foregroundColor: textPrimary, - elevation: 0, - centerTitle: true, - systemOverlayStyle: SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.light, - ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: claudeBorder), ), - bottomNavigationBarTheme: const BottomNavigationBarThemeData( - backgroundColor: surfaceDark, - selectedItemColor: violet, - unselectedItemColor: textTertiary, - type: BottomNavigationBarType.fixed, - elevation: 8, - selectedLabelStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w500), - unselectedLabelStyle: TextStyle(fontSize: 11), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: claudeAmber, width: 1.6), ), - floatingActionButtonTheme: const FloatingActionButtonThemeData( - backgroundColor: violet, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + labelStyle: const TextStyle(color: claudeTextMuted), + hintStyle: const TextStyle(color: claudeTextFaint), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: claudeAmber, foregroundColor: Colors.white, - elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), ), - cardTheme: CardThemeData( - color: surfaceCard, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: claudeAmber, + foregroundColor: Colors.white, elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: surfaceElevated, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: violet, width: 2), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - hintStyle: const TextStyle(color: textTertiary), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: violet, - foregroundColor: Colors.white, - elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 13), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom(foregroundColor: cyan), - ), - chipTheme: ChipThemeData( - backgroundColor: surfaceElevated, - selectedColor: violet.withOpacity(0.3), - labelStyle: const TextStyle(color: textSecondary, fontSize: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - dividerTheme: const DividerThemeData( - color: divider, - thickness: 1, - space: 1, - ), - snackBarTheme: SnackBarThemeData( - backgroundColor: surfaceElevated, - contentTextStyle: const TextStyle(color: textPrimary), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - behavior: SnackBarBehavior.floating, + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: claudeAmber), + ), + chipTheme: ChipThemeData( + backgroundColor: claudeSurface, + selectedColor: claudeAmber.withOpacity(0.12), + labelStyle: const TextStyle( + color: claudeTextMuted, + fontSize: 12, + fontWeight: FontWeight.w700, ), - tooltipTheme: TooltipThemeData( - decoration: BoxDecoration( - color: surfaceCard, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: border), - ), - textStyle: const TextStyle(color: textPrimary, fontSize: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: const BorderSide(color: claudeBorder), ), - ); - - // ── Syntax Highlighting Colors ─────────────────────────────── - static const Color syntaxKeyword = Color(0xFF7B2FF7); - static const Color syntaxString = Color(0xFF00D4AA); - static const Color syntaxComment = Color(0xFF5A6080); - static const Color syntaxNumber = Color(0xFFFFA502); - static const Color syntaxFunction = Color(0xFF4FC3F7); - static const Color syntaxType = Color(0xFFCE93D8); - static const Color syntaxOperator = Color(0xFFFF8A65); + ), + dividerTheme: const DividerThemeData(color: claudeBorder, thickness: 1, space: 1), + snackBarTheme: SnackBarThemeData( + backgroundColor: claudeText, + contentTextStyle: const TextStyle(color: Colors.white), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + behavior: SnackBarBehavior.floating, + ), + ); + } - // ── Animation Durations ────────────────────────────────────── - static const Duration animFast = Duration(milliseconds: 150); - static const Duration animNormal = Duration(milliseconds: 300); - static const Duration animSlow = Duration(milliseconds: 500); -} + // ── Dark Theme ─────────────────────────────────────────────── + static ThemeData get darkTheme => ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: deepSpace, + colorScheme: const ColorScheme.dark( + primary: violet, + onPrimary: Colors.white, + secondary: cyan, + onSecondary: Colors.white, + surface: surfaceDark, + surfaceContainerHighest: surfaceElevated, + error: error, + onError: Colors.white, + onSurface: textPrimary, + outline: border, + ), + textTheme: textTheme, + appBarTheme: const AppBarTheme( + backgroundColor: surfaceDark, + foregroundColor: textPrimary, + elevation: 0, + centerTitle: true, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: surfaceDark, + selectedItemColor: violet, + unselectedItemColor: textTertiary, + type: BottomNavigationBarType.fixed, + elevation: 8, + selectedLabelStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w500), + unselectedLabelStyle: TextStyle(fontSize: 11), + ), + floatingActionButtonTheme: const FloatingActionButtonThemeData( + backgroundColor: violet, + foregroundColor: Colors.white, + elevation: 4, + ), + cardTheme: CardThemeData( + color: surfaceCard, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surfaceElevated, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: violet, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + hintStyle: const TextStyle(color: textTertiary), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: violet, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: cyan), + ), + chipTheme: ChipThemeData( + backgroundColor: surfaceElevated, + selectedColor: violet.withOpacity(0.3), + labelStyle: const TextStyle(color: textSecondary, fontSize: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + dividerTheme: const DividerThemeData( + color: divider, + thickness: 1, + space: 1, + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: surfaceElevated, + contentTextStyle: const TextStyle(color: textPrimary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + ), + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: surfaceCard, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: border), + ), + textStyle: const TextStyle(color: textPrimary, fontSize: 12), + ), + ); + + // ── Syntax Highlighting Colors ─────────────────────────────── + static const Color syntaxKeyword = Color(0xFF7B2FF7); + static const Color syntaxString = Color(0xFF00D4AA); + static const Color syntaxComment = Color(0xFF5A6080); + static const Color syntaxNumber = Color(0xFFFFA502); + static const Color syntaxFunction = Color(0xFF4FC3F7); + static const Color syntaxType = Color(0xFFCE93D8); + static const Color syntaxOperator = Color(0xFFFF8A65); + + // ── Animation Durations ────────────────────────────────────── + static const Duration animFast = Duration(milliseconds: 150); + static const Duration animNormal = Duration(milliseconds: 300); + static const Duration animSlow = Duration(milliseconds: 500); +} diff --git a/mobile_agent/lib/widgets/animated_background.dart b/mobile_agent/lib/widgets/animated_background.dart index ff81406..019671c 100644 --- a/mobile_agent/lib/widgets/animated_background.dart +++ b/mobile_agent/lib/widgets/animated_background.dart @@ -1,1300 +1,1399 @@ -// ============================================================ -// animated_background.dart — MobileCode Dynamic Backgrounds -// ============================================================ -// Per-theme animated backgrounds rendered via CustomPainter: -// - DeepSpace: Floating star particles with parallax -// - Aurora: Gradient wave animation (sinusoidal bands) -// - MidnightForest: Firefly particles with organic movement -// - CyberSunset: Pulsing gradient orbs with trails -// - MonochromeGeek: Animated noise texture overlay -// ============================================================ - -import 'dart:math' show Random, pi, sin, cos, atan2, sqrt, Point; -import 'dart:typed_data'; -import 'package:flutter/material.dart'; -import '../core/theme_manager.dart'; - -// ============================================================ -// SECTION 1: Background Router Widget -// ============================================================ - -/// Routes to the correct animated background painter based on -/// the active [AppTheme]. Wraps any screen to provide a dynamic -/// animated backdrop that responds to the current theme. -class AnimatedBackground extends StatelessWidget { - final Widget child; - final AppTheme theme; - final bool animate; - - const AnimatedBackground({ - Key? key, - required this.child, - required this.theme, - this.animate = true, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Stack( - fit: StackFit.expand, - children: [ - // Layer 1: Themed animated background - _buildBackground(context), - // Layer 2: Subtle vignette overlay for depth - _buildVignette(), - // Layer 3: Content - child, - ], - ); - } - - Widget _buildBackground(BuildContext context) { - switch (theme) { - case AppTheme.deepSpace: - return DeepSpaceParticles(animate: animate); - case AppTheme.aurora: - return AuroraWaves(animate: animate); - case AppTheme.midnightForest: - return MidnightFireflies(animate: animate); +// ============================================================ +// animated_background.dart — MobileCode Dynamic Backgrounds +// ============================================================ +// Per-theme animated backgrounds rendered via CustomPainter: +// - DeepSpace: Floating star particles with parallax +// - Aurora: Gradient wave animation (sinusoidal bands) +// - MidnightForest: Firefly particles with organic movement +// - CyberSunset: Pulsing gradient orbs with trails +// - MonochromeGeek: Animated noise texture overlay +// ============================================================ + +import 'dart:math' show Random, pi, sin, cos, atan2, sqrt, Point; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import '../core/theme_manager.dart'; + +// ============================================================ +// SECTION 1: Background Router Widget +// ============================================================ + +/// Routes to the correct animated background painter based on +/// the active [AppTheme]. Wraps any screen to provide a dynamic +/// animated backdrop that responds to the current theme. +class AnimatedBackground extends StatelessWidget { + final Widget child; + final AppTheme theme; + final bool animate; + + const AnimatedBackground({ + Key? key, + required this.child, + required this.theme, + this.animate = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + // Layer 1: Themed animated background + _buildBackground(context), + // Layer 2: Subtle vignette overlay for depth + _buildVignette(), + // Layer 3: Content + child, + ], + ); + } + + Widget _buildBackground(BuildContext context) { + switch (theme) { + case AppTheme.deepSpace: + return DeepSpaceParticles(animate: animate); + case AppTheme.aurora: + return AuroraWaves(animate: animate); + case AppTheme.midnightForest: + return MidnightFireflies(animate: animate); case AppTheme.cyberSunset: return CyberSunsetOrbs(animate: animate); case AppTheme.monochromeGeek: return MonochromeNoise(animate: animate); + case AppTheme.claudeYellow: + return const ThemedSoftGlowBackground( + base: Color(0xFF19110A), + primary: Color(0xFFD97706), + accent: Color(0xFFFFB86B), + ); + case AppTheme.codexBlue: + return const ThemedSoftGlowBackground( + base: Color(0xFF071326), + primary: Color(0xFF2555FF), + accent: Color(0xFF16B9C7), + ); } } - - Widget _buildVignette() { - final baseColor = _baseColorForTheme(theme); - return IgnorePointer( - child: Container( - decoration: BoxDecoration( - gradient: RadialGradient( - center: Alignment.center, - radius: 1.2, - colors: [ - Colors.transparent, - baseColor.withOpacity(0.6), - ], - stops: const [0.4, 1.0], - ), - ), - ), - ); - } - - Color _baseColorForTheme(AppTheme t) { - switch (t) { - case AppTheme.deepSpace: - return const Color(0xFF030508); - case AppTheme.aurora: - return const Color(0xFF0A1628); - case AppTheme.midnightForest: - return const Color(0xFF0A1A0A); + + Widget _buildVignette() { + final baseColor = _baseColorForTheme(theme); + return IgnorePointer( + child: Container( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment.center, + radius: 1.2, + colors: [ + Colors.transparent, + baseColor.withOpacity(0.6), + ], + stops: const [0.4, 1.0], + ), + ), + ), + ); + } + + Color _baseColorForTheme(AppTheme t) { + switch (t) { + case AppTheme.deepSpace: + return const Color(0xFF030508); + case AppTheme.aurora: + return const Color(0xFF0A1628); + case AppTheme.midnightForest: + return const Color(0xFF0A1A0A); case AppTheme.cyberSunset: return const Color(0xFF0D0B2B); case AppTheme.monochromeGeek: return const Color(0xFF000000); + case AppTheme.claudeYellow: + return const Color(0xFF19110A); + case AppTheme.codexBlue: + return const Color(0xFF071326); } } } // ============================================================ -// SECTION 2: DeepSpace — Floating Star Particles +// SECTION 1.5: Soft Brand Background — Lightweight themed glow // ============================================================ -/// A starfield with multiple particle layers: -/// - Background distant stars (slow drift, small) -/// - Mid-layer stars (medium speed, twinkling) -/// - Foreground nebula wisps (parallax on pointer) -class DeepSpaceParticles extends StatefulWidget { - final bool animate; - const DeepSpaceParticles({Key? key, this.animate = true}) : super(key: key); - - @override - State<DeepSpaceParticles> createState() => _DeepSpaceParticlesState(); -} - -class _DeepSpaceParticlesState extends State<DeepSpaceParticles> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - final List<StarParticle> _particles = []; - final Random _random = Random(42); - - @override - void initState() { - super.initState(); - _initParticles(); - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 30), - ); - if (widget.animate) { - _controller.repeat(); - } - } - - void _initParticles() { - // Background stars (small, slow) - for (int i = 0; i < 80; i++) { - _particles.add(StarParticle( - x: _random.nextDouble(), - y: _random.nextDouble(), - size: 0.5 + _random.nextDouble() * 1.5, - speed: 0.05 + _random.nextDouble() * 0.1, - brightness: 0.3 + _random.nextDouble() * 0.4, - twinkleSpeed: 0.5 + _random.nextDouble() * 2, - twinklePhase: _random.nextDouble() * 2 * pi, - layer: 0, - )); - } - // Mid stars (medium) - for (int i = 0; i < 40; i++) { - _particles.add(StarParticle( - x: _random.nextDouble(), - y: _random.nextDouble(), - size: 1.2 + _random.nextDouble() * 2, - speed: 0.1 + _random.nextDouble() * 0.2, - brightness: 0.5 + _random.nextDouble() * 0.5, - twinkleSpeed: 1 + _random.nextDouble() * 3, - twinklePhase: _random.nextDouble() * 2 * pi, - layer: 1, - )); - } - // Bright foreground stars - for (int i = 0; i < 15; i++) { - _particles.add(StarParticle( - x: _random.nextDouble(), - y: _random.nextDouble(), - size: 2 + _random.nextDouble() * 3, - speed: 0.2 + _random.nextDouble() * 0.3, - brightness: 0.7 + _random.nextDouble() * 0.3, - twinkleSpeed: 2 + _random.nextDouble() * 4, - twinklePhase: _random.nextDouble() * 2 * pi, - layer: 2, - color: [ - const Color(0xFF7B2FF7), - const Color(0xFF00D4AA), - const Color(0xFF9D5CFF), - const Color(0xFF33E5C0), - Colors.white, - ][_random.nextInt(5)], - )); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return CustomPaint( - painter: _DeepSpacePainter( - progress: _controller.value, - particles: _particles, - ), - size: Size.infinite, - ); - }, - ); - } -} - -class StarParticle { - double x, y; - final double size; - final double speed; - final double brightness; - final double twinkleSpeed; - final double twinklePhase; - final int layer; - final Color? color; - - StarParticle({ - required this.x, - required this.y, - required this.size, - required this.speed, - required this.brightness, - required this.twinkleSpeed, - required this.twinklePhase, - required this.layer, - this.color, - }); -} - -class _DeepSpacePainter extends CustomPainter { - final double progress; - final List<StarParticle> particles; - - _DeepSpacePainter({required this.progress, required this.particles}); - - @override - void paint(Canvas canvas, Size size) { - // Deep space gradient background - final bgPaint = Paint() - ..shader = RadialGradient( - center: const Alignment(0.3, -0.5), - radius: 0.8, - colors: [ - const Color(0xFF0A0E2A), - const Color(0xFF030508), - const Color(0xFF020204), - ], - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); - canvas.drawRect(Offset.zero & size, bgPaint); - - // Subtle nebula glow - final nebulaPaint = Paint() - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 60) - ..color = const Color(0xFF7B2FF7).withOpacity(0.06); - canvas.drawCircle( - Offset(size.width * 0.3, size.height * 0.25), - size.width * 0.35, - nebulaPaint, - ); - final nebulaPaint2 = Paint() - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 50) - ..color = const Color(0xFF00D4AA).withOpacity(0.04); - canvas.drawCircle( - Offset(size.width * 0.7, size.height * 0.6), - size.width * 0.25, - nebulaPaint2, - ); - - // Draw particles - for (final p in particles) { - final driftX = (progress * p.speed) % 1.0; - final drawX = ((p.x + driftX) % 1.0) * size.width; - final drawY = p.y * size.height; - final twinkle = sin(progress * p.twinkleSpeed * 2 * pi + p.twinklePhase); - final alpha = (p.brightness * (0.6 + 0.4 * twinkle)).clamp(0.0, 1.0); - - final starPaint = Paint() - ..color = (p.color ?? Colors.white).withOpacity(alpha) - ..maskFilter = p.layer >= 2 - ? const MaskFilter.blur(BlurStyle.normal, 2) - : null; - - canvas.drawCircle(Offset(drawX, drawY), p.size, starPaint); - - // Cross flare for bright stars - if (p.layer >= 2 && p.color != null) { - final flarePaint = Paint() - ..color = p.color!.withOpacity(alpha * 0.3) - ..strokeWidth = 0.5; - canvas.drawLine( - Offset(drawX - p.size * 4, drawY), - Offset(drawX + p.size * 4, drawY), - flarePaint, - ); - canvas.drawLine( - Offset(drawX, drawY - p.size * 4), - Offset(drawX, drawY + p.size * 4), - flarePaint, - ); - } - } - } - - @override - bool shouldRepaint(covariant _DeepSpacePainter old) => true; -} - -// ============================================================ -// SECTION 3: Aurora — Gradient Wave Animation -// ============================================================ - -/// Northern-lights inspired animated gradient waves using -/// multiple overlapping sine bands with phase offsets. -class AuroraWaves extends StatefulWidget { - final bool animate; - const AuroraWaves({Key? key, this.animate = true}) : super(key: key); - - @override - State<AuroraWaves> createState() => _AuroraWavesState(); -} +class ThemedSoftGlowBackground extends StatelessWidget { + final Color base; + final Color primary; + final Color accent; -class _AuroraWavesState extends State<AuroraWaves> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 12), - ); - if (widget.animate) { - _controller.repeat(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return CustomPaint( - painter: _AuroraPainter(progress: _controller.value), - size: Size.infinite, - ); - }, - ); - } -} - -class _AuroraPainter extends CustomPainter { - final double progress; - - _AuroraPainter({required this.progress}); - - @override - void paint(Canvas canvas, Size size) { - // Base dark teal background - final bgPaint = Paint() - ..shader = const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xFF0A1628), - Color(0xFF0C1E30), - Color(0xFF081020), - ], - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); - canvas.drawRect(Offset.zero & size, bgPaint); - - // Aurora wave bands - final bands = [ - _AuroraBand( - baseY: 0.15, - amplitude: 0.08, - frequency: 2, - phase: progress * 2 * pi, - phase2: progress * 2 * pi * 0.7, - colors: [const Color(0xFF00FF88), const Color(0xFF00CC6A)], - opacity: 0.15, - blur: 40, - ), - _AuroraBand( - baseY: 0.35, - amplitude: 0.1, - frequency: 1.5, - phase: progress * 2 * pi * 1.3 + 1.0, - phase2: progress * 2 * pi * 0.9 + 2.0, - colors: [const Color(0xFFFF6B9D), const Color(0xFFFF8FB0)], - opacity: 0.12, - blur: 50, - ), - _AuroraBand( - baseY: 0.55, - amplitude: 0.06, - frequency: 2.5, - phase: progress * 2 * pi * 0.8 + 2.5, - phase2: progress * 2 * pi * 1.1 + 0.5, - colors: [const Color(0xFF00FFAA), const Color(0xFF00DD99)], - opacity: 0.1, - blur: 35, - ), - _AuroraBand( - baseY: 0.25, - amplitude: 0.12, - frequency: 1.2, - phase: -progress * 2 * pi * 0.6 + 3.0, - phase2: -progress * 2 * pi * 0.8 + 1.5, - colors: [const Color(0xFFAA44FF), const Color(0xFF00FF88)], - opacity: 0.08, - blur: 55, - ), - ]; - - for (final band in bands) { - _drawAuroraBand(canvas, size, band); - } - - // Subtle star dots - final random = Random(7); - final starPaint = Paint() - ..color = Colors.white.withOpacity(0.3); - for (int i = 0; i < 30; i++) { - final sx = random.nextDouble() * size.width; - final sy = random.nextDouble() * size.height * 0.5; - final twinkle = sin(progress * 3 + i) * 0.5 + 0.5; - canvas.drawCircle( - Offset(sx, sy), - 0.8 * twinkle, - starPaint..color = Colors.white.withOpacity(0.25 * twinkle), - ); - } - } - - void _drawAuroraBand(Canvas canvas, Size size, _AuroraBand band) { - final path = Path(); - const steps = 100; - double firstY = 0; - - for (int i = 0; i <= steps; i++) { - final t = i / steps; - final x = t * size.width; - final wave1 = sin(t * band.frequency * 2 * pi + band.phase); - final wave2 = sin(t * band.frequency * 1.5 * 2 * pi + band.phase2); - final y = (band.baseY + band.amplitude * (wave1 * 0.6 + wave2 * 0.4)) * - size.height; - if (i == 0) { - path.moveTo(x, y); - firstY = y; - } else { - path.lineTo(x, y); - } - } - - // Close the path at bottom to create filled region - path.lineTo(size.width, size.height); - path.lineTo(0, size.height); - path.close(); - - final gradient = LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - band.colors[0].withOpacity(band.opacity), - band.colors[1].withOpacity(band.opacity * 0.5), - band.colors[1].withOpacity(0), - ], - stops: const [0.0, 0.3, 1.0], - ); - - final paint = Paint() - ..shader = gradient.createShader( - Rect.fromLTWH(0, firstY * 0.5, size.width, size.height), - ) - ..maskFilter = MaskFilter.blur(BlurStyle.normal, band.blur); - - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant _AuroraPainter old) => true; -} - -class _AuroraBand { - final double baseY; - final double amplitude; - final double frequency; - final double phase; - final double phase2; - final List<Color> colors; - final double opacity; - final double blur; - - _AuroraBand({ - required this.baseY, - required this.amplitude, - required this.frequency, - required this.phase, - required this.phase2, - required this.colors, - required this.opacity, - required this.blur, - }); -} - -// ============================================================ -// SECTION 4: MidnightForest — Floating Fireflies -// ============================================================ - -/// Organic firefly particles that drift with Perlin-like movement, -/// leaving faint trails. Colors: emerald + amber. -class MidnightFireflies extends StatefulWidget { - final bool animate; - const MidnightFireflies({Key? key, this.animate = true}) : super(key: key); - - @override - State<MidnightFireflies> createState() => _MidnightFirefliesState(); -} - -class _MidnightFirefliesState extends State<MidnightFireflies> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - final List<Firefly> _fireflies = []; - final Random _random = Random(123); - - @override - void initState() { - super.initState(); - for (int i = 0; i < 50; i++) { - _fireflies.add(Firefly( - x: _random.nextDouble(), - y: 0.2 + _random.nextDouble() * 0.7, - size: 1.5 + _random.nextDouble() * 3, - speedX: (_random.nextDouble() - 0.5) * 0.15, - speedY: (_random.nextDouble() - 0.5) * 0.08, - phase: _random.nextDouble() * 2 * pi, - glowPhase: _random.nextDouble() * 2 * pi, - glowSpeed: 1.5 + _random.nextDouble() * 3, - color: [ - const Color(0xFF2ECC71), - const Color(0xFFF39C12), - const Color(0xFF52D687), - const Color(0xFFF5B041), - const Color(0xFF82E0AA), - ][_random.nextInt(5)], - trailLength: 3 + _random.nextInt(6), - )); - } - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 20), - ); - if (widget.animate) { - _controller.repeat(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return CustomPaint( - painter: _FireflyPainter( - progress: _controller.value, - fireflies: _fireflies, - time: _controller.lastElapsedDuration?.inMilliseconds.toDouble() ?? - 0, - ), - size: Size.infinite, - ); - }, - ); - } -} - -class Firefly { - double x, y; - final double size; - final double speedX; - final double speedY; - final double phase; - final double glowPhase; - final double glowSpeed; - final Color color; - final int trailLength; - final List<Offset> trail = []; - - Firefly({ - required this.x, - required this.y, - required this.size, - required this.speedX, - required this.speedY, - required this.phase, - required this.glowPhase, - required this.glowSpeed, - required this.color, - required this.trailLength, - }); -} - -class _FireflyPainter extends CustomPainter { - final double progress; - final List<Firefly> fireflies; - final double time; - - _FireflyPainter({ - required this.progress, - required this.fireflies, - required this.time, - }); - - @override - void paint(Canvas canvas, Size size) { - // Dark green background - final bgPaint = Paint() - ..shader = RadialGradient( - center: const Alignment(0.5, 0.8), - radius: 1.2, - colors: [ - const Color(0xFF0F3D0F), - const Color(0xFF0A1A0A), - const Color(0xFF060F06), - ], - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); - canvas.drawRect(Offset.zero & size, bgPaint); - - // Subtle fog layers - final fogPaint = Paint() - ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 80) - ..color = const Color(0xFF1A3A1A).withOpacity(0.2); - canvas.drawCircle( - Offset(size.width * 0.2, size.height * 0.7), - size.width * 0.4, - fogPaint, - ); - - // Update and draw fireflies - for (final f in fireflies) { - // Organic movement using sine combinations - final organicX = sin(progress * 2 * pi * 2 + f.phase) * 0.02; - final organicY = cos(progress * 2 * pi * 1.5 + f.phase * 1.3) * 0.015; - - f.x += f.speedX * 0.01 + organicX * 0.01; - f.y += f.speedY * 0.01 + organicY * 0.01; - - // Wrap around - if (f.x < -0.05) f.x = 1.05; - if (f.x > 1.05) f.x = -0.05; - if (f.y < 0) f.y = 1.0; - if (f.y > 1.0) f.y = 0; - - final drawX = f.x * size.width; - final drawY = f.y * size.height; - - // Update trail - f.trail.add(Offset(drawX, drawY)); - if (f.trail.length > f.trailLength) { - f.trail.removeAt(0); - } - - // Glow intensity (pulsing) - final glowIntensity = - (sin(progress * f.glowSpeed * 2 * pi + f.glowPhase) * 0.5 + 0.5); - final alpha = 0.3 + glowIntensity * 0.7; - - // Draw trail - if (f.trail.length > 1) { - for (int i = 0; i < f.trail.length - 1; i++) { - final trailAlpha = (i / f.trail.length) * alpha * 0.3; - final trailPaint = Paint() - ..color = f.color.withOpacity(trailAlpha) - ..strokeWidth = f.size * (i / f.trail.length) * 0.5 - ..strokeCap = StrokeCap.round; - canvas.drawLine(f.trail[i], f.trail[i + 1], trailPaint); - } - } - - // Outer glow - final glowPaint = Paint() - ..color = f.color.withOpacity(alpha * 0.3) - ..maskFilter = MaskFilter.blur(BlurStyle.normal, f.size * 3); - canvas.drawCircle(Offset(drawX, drawY), f.size * 2, glowPaint); - - // Inner core - final corePaint = Paint() - ..color = f.color.withOpacity(alpha) - ..maskFilter = MaskFilter.blur(BlurStyle.normal, f.size * 0.5); - canvas.drawCircle(Offset(drawX, drawY), f.size, corePaint); - } - } - - @override - bool shouldRepaint(covariant _FireflyPainter old) => true; -} - -// ============================================================ -// SECTION 5: CyberSunset — Gradient Orb Movement -// ============================================================ - -/// Large, slowly drifting gradient orbs that overlap to create -/// warm sunset hues with a cyberpunk edge. -class CyberSunsetOrbs extends StatefulWidget { - final bool animate; - const CyberSunsetOrbs({Key? key, this.animate = true}) : super(key: key); - - @override - State<CyberSunsetOrbs> createState() => _CyberSunsetOrbsState(); -} - -class _CyberSunsetOrbsState extends State<CyberSunsetOrbs> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 18), - ); - if (widget.animate) { - _controller.repeat(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } + const ThemedSoftGlowBackground({ + Key? key, + required this.base, + required this.primary, + required this.accent, + }) : super(key: key); @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return CustomPaint( - painter: _CyberSunsetPainter(progress: _controller.value), - size: Size.infinite, - ); - }, - ); - } -} - -class _CyberSunsetPainter extends CustomPainter { - final double progress; - - _CyberSunsetPainter({required this.progress}); - - @override - void paint(Canvas canvas, Size size) { - // Deep purple-blue base - final bgPaint = Paint() - ..shader = RadialGradient( - center: const Alignment(0.5, 0.3), - radius: 1.0, - colors: [ - const Color(0xFF1A1645), - const Color(0xFF0D0B2B), - const Color(0xFF08071E), - ], - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); - canvas.drawRect(Offset.zero & size, bgPaint); - - // Orb definitions with movement paths - final orbs = [ - // Large orange orb - _DriftingOrb( - baseX: 0.2, - baseY: 0.4, - radius: 0.35, - driftAmpX: 0.15, - driftAmpY: 0.1, - driftFreqX: 1.0, - driftFreqY: 0.7, - phaseX: 0, - phaseY: 1.5, - color: const Color(0xFFFF7B54), - opacity: 0.12, - blur: 80, + return CustomPaint( + painter: _ThemedSoftGlowPainter( + base: base, + primary: primary, + accent: accent, ), - // Purple orb - _DriftingOrb( - baseX: 0.7, - baseY: 0.3, - radius: 0.3, - driftAmpX: 0.12, - driftAmpY: 0.15, - driftFreqX: 0.8, - driftFreqY: 1.1, - phaseX: 2.0, - phaseY: 0.5, - color: const Color(0xFF9B59B6), - opacity: 0.1, - blur: 70, - ), - // Secondary orange - _DriftingOrb( - baseX: 0.5, - baseY: 0.7, - radius: 0.25, - driftAmpX: 0.1, - driftAmpY: 0.08, - driftFreqX: 1.2, - driftFreqY: 0.9, - phaseX: 1.0, - phaseY: 3.0, - color: const Color(0xFFFF9D80), - opacity: 0.08, - blur: 60, - ), - // Accent magenta orb - _DriftingOrb( - baseX: 0.3, - baseY: 0.6, - radius: 0.18, - driftAmpX: 0.2, - driftAmpY: 0.12, - driftFreqX: 0.6, - driftFreqY: 0.5, - phaseX: 3.5, - phaseY: 2.0, - color: const Color(0xFFE84393), - opacity: 0.06, - blur: 50, - ), - // Warm amber orb - _DriftingOrb( - baseX: 0.8, - baseY: 0.6, - radius: 0.2, - driftAmpX: 0.08, - driftAmpY: 0.18, - driftFreqX: 1.5, - driftFreqY: 1.0, - phaseX: 4.0, - phaseY: 0.0, - color: const Color(0xFFFFA502), - opacity: 0.07, - blur: 55, - ), - ]; - - // Draw orbs back-to-front - for (final orb in orbs) { - final ox = (orb.baseX + - sin(progress * 2 * pi * orb.driftFreqX + orb.phaseX) * - orb.driftAmpX) * - size.width; - final oy = (orb.baseY + - cos(progress * 2 * pi * orb.driftFreqY + orb.phaseY) * - orb.driftAmpY) * - size.height; - - final orbPaint = Paint() - ..color = orb.color.withOpacity(orb.opacity) - ..maskFilter = MaskFilter.blur(BlurStyle.normal, orb.blur); - canvas.drawCircle( - Offset(ox, oy), - orb.radius * size.shortestSide, - orbPaint, - ); - } - - // Subtle grid overlay for cyber feel - final gridPaint = Paint() - ..color = const Color(0xFFFF7B54).withOpacity(0.03) - ..strokeWidth = 0.5; - final gridSpacing = size.width / 20; - for (int i = 0; i < 20; i++) { - final x = i * gridSpacing; - canvas.drawLine( - Offset(x, 0), - Offset(x, size.height), - gridPaint, - ); - } - for (int i = 0; i < 30; i++) { - final y = i * (size.height / 30); - canvas.drawLine( - Offset(0, y), - Offset(size.width, y), - gridPaint, - ); - } - } - - @override - bool shouldRepaint(covariant _CyberSunsetPainter old) => true; -} - -class _DriftingOrb { - final double baseX; - final double baseY; - final double radius; - final double driftAmpX; - final double driftAmpY; - final double driftFreqX; - final double driftFreqY; - final double phaseX; - final double phaseY; - final Color color; - final double opacity; - final double blur; - - _DriftingOrb({ - required this.baseX, - required this.baseY, - required this.radius, - required this.driftAmpX, - required this.driftAmpY, - required this.driftFreqX, - required this.driftFreqY, - required this.phaseX, - required this.phaseY, - required this.color, - required this.opacity, - required this.blur, - }); -} - -// ============================================================ -// SECTION 6: MonochromeGeek — Subtle Noise Texture -// ============================================================ - -/// A nearly imperceptible animated noise field that gives the -/// pure-black background texture without visual distraction. -class MonochromeNoise extends StatefulWidget { - final bool animate; - const MonochromeNoise({Key? key, this.animate = true}) : super(key: key); - - @override - State<MonochromeNoise> createState() => _MonochromeNoiseState(); -} - -class _MonochromeNoiseState extends State<MonochromeNoise> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - final List<NoiseGrain> _grains = []; - final Random _random = Random(999); - - @override - void initState() { - super.initState(); - // Generate static noise grains - for (int i = 0; i < 200; i++) { - _grains.add(NoiseGrain( - x: _random.nextDouble(), - y: _random.nextDouble(), - brightness: 0.02 + _random.nextDouble() * 0.06, - size: 0.5 + _random.nextDouble() * 1.5, - phase: _random.nextDouble() * 2 * pi, - speed: 0.3 + _random.nextDouble() * 1.5, - )); - } - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 8), - ); - if (widget.animate) { - _controller.repeat(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return CustomPaint( - painter: _NoisePainter( - progress: _controller.value, - grains: _grains, - ), - size: Size.infinite, - ); - }, + size: Size.infinite, ); } } -class NoiseGrain { - final double x; - final double y; - final double brightness; - final double size; - final double phase; - final double speed; +class _ThemedSoftGlowPainter extends CustomPainter { + final Color base; + final Color primary; + final Color accent; - NoiseGrain({ - required this.x, - required this.y, - required this.brightness, - required this.size, - required this.phase, - required this.speed, + const _ThemedSoftGlowPainter({ + required this.base, + required this.primary, + required this.accent, }); -} - -class _NoisePainter extends CustomPainter { - final double progress; - final List<NoiseGrain> grains; - - _NoisePainter({required this.progress, required this.grains}); @override void paint(Canvas canvas, Size size) { - // Pure black base + final rect = Offset.zero & size; canvas.drawRect( - Offset.zero & size, - Paint()..color = Colors.black, + rect, + Paint() + ..shader = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + base, + Color.lerp(base, primary, 0.16)!, + Color.lerp(base, accent, 0.12)!, + ], + ).createShader(rect), ); - // Noise grain field - for (final grain in grains) { - final flicker = sin(progress * grain.speed * 2 * pi + grain.phase); - final alpha = grain.brightness * (0.5 + 0.5 * flicker); - final brightness = (0.05 + alpha * 0.1).clamp(0.0, 0.15); - - final paint = Paint() - ..color = Color.fromRGBO( - (brightness * 255).toInt(), - (brightness * 255).toInt(), - (brightness * 255).toInt(), - 1.0, - ) - ..strokeWidth = grain.size; - canvas.drawPoints( - PointMode.points, - [Offset(grain.x * size.width, grain.y * size.height)], - paint, - ); - } - - // Subtle scan line effect - final scanlinePaint = Paint() - ..color = const Color(0xFFFFFFFF).withOpacity(0.02); - final lineHeight = 2.0; - final gap = 4.0; - for (double y = 0; y < size.height; y += (lineHeight + gap)) { - canvas.drawRect( - Rect.fromLTWH(0, y, size.width, lineHeight), - scanlinePaint, - ); - } - - // Very subtle CRT vignette - final vignettePaint = Paint() - ..shader = RadialGradient( - center: Alignment.center, - radius: 1.0, - colors: [ - Colors.transparent, - const Color(0xFF000000).withOpacity(0.3), - ], - stops: const [0.5, 1.0], - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); - canvas.drawRect(Offset.zero & size, vignettePaint); - } - - @override - bool shouldRepaint(covariant _NoisePainter old) => true; -} - -// ============================================================ -// SECTION 7: Animated Gradient Container (utility) -// ============================================================ - -/// A reusable container that smoothly animates its gradient -/// background between two color sets. -class AnimatedGradientContainer extends StatefulWidget { - final List<Color> colors; - final List<Color> altColors; - final List<double>? stops; - final Alignment begin; - final Alignment end; - final Duration duration; - final Widget? child; - - const AnimatedGradientContainer({ - Key? key, - required this.colors, - required this.altColors, - this.stops, - this.begin = Alignment.topLeft, - this.end = Alignment.bottomRight, - this.duration = const Duration(seconds: 6), - this.child, - }) : super(key: key); - - @override - State<AnimatedGradientContainer> createState() => - _AnimatedGradientContainerState(); -} - -class _AnimatedGradientContainerState extends State<AnimatedGradientContainer> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation<double> _blend; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: widget.duration, + final glowPaint = Paint()..maskFilter = const MaskFilter.blur(BlurStyle.normal, 70); + canvas.drawCircle( + Offset(size.width * 0.22, size.height * 0.24), + size.shortestSide * 0.34, + glowPaint..color = primary.withOpacity(0.12), ); - _blend = Tween<double>(begin: 0, end: 1).animate( - CurvedAnimation( - parent: _controller, - curve: Curves.easeInOutSine, - ), + canvas.drawCircle( + Offset(size.width * 0.82, size.height * 0.72), + size.shortestSide * 0.28, + glowPaint..color = accent.withOpacity(0.10), ); - _controller.repeat(reverse: true); - } - @override - void dispose() { - _controller.dispose(); - super.dispose(); + final linePaint = Paint() + ..color = accent.withOpacity(0.045) + ..strokeWidth = 0.8; + for (int i = 0; i < 8; i++) { + final y = size.height * (0.16 + i * 0.11); + canvas.drawLine(Offset(0, y), Offset(size.width, y + size.height * 0.04), linePaint); + } } @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - final blendedColors = List<Color>.generate( - widget.colors.length, - (i) => Color.lerp(widget.colors[i], widget.altColors[i], _blend.value)!, - ); - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: widget.begin, - end: widget.end, - colors: blendedColors, - stops: widget.stops, - ), - ), - child: widget.child, - ); - }, - ); - } + bool shouldRepaint(covariant _ThemedSoftGlowPainter old) => + old.base != base || old.primary != primary || old.accent != accent; } // ============================================================ -// SECTION 8: Particle Burst Effect (transient animation) +// SECTION 2: DeepSpace — Floating Star Particles // ============================================================ - -/// A burst of particles that radiates from a point and fades. -/// Used for success feedback, button clicks, etc. -class ParticleBurst extends StatefulWidget { - final Offset origin; - final int particleCount; - final Color color; - final Duration duration; - final VoidCallback? onComplete; - - const ParticleBurst({ - Key? key, - required this.origin, - this.particleCount = 20, - required this.color, - this.duration = const Duration(milliseconds: 800), - this.onComplete, - }) : super(key: key); - - @override - State<ParticleBurst> createState() => _ParticleBurstState(); -} - -class _ParticleBurstState extends State<ParticleBurst> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late List<BurstParticle> _particles; - final Random _random = Random(); - - @override - void initState() { - super.initState(); - _particles = List.generate(widget.particleCount, (i) { - final angle = _random.nextDouble() * 2 * pi; - final speed = 50 + _random.nextDouble() * 150; - return BurstParticle( - angle: angle, - speed: speed, - size: 2 + _random.nextDouble() * 4, - opacity: 0.6 + _random.nextDouble() * 0.4, - decay: 0.5 + _random.nextDouble() * 0.5, - ); - }); - _controller = AnimationController( - vsync: this, - duration: widget.duration, - ); - _controller.forward().then((_) { - widget.onComplete?.call(); - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return CustomPaint( - painter: _BurstPainter( - progress: _controller.value, - origin: widget.origin, - particles: _particles, - color: widget.color, - ), - size: Size.infinite, - ); - }, - ); - } -} - -class BurstParticle { - final double angle; - final double speed; - final double size; - final double opacity; - final double decay; - - BurstParticle({ - required this.angle, - required this.speed, - required this.size, - required this.opacity, - required this.decay, - }); -} - -class _BurstPainter extends CustomPainter { - final double progress; - final Offset origin; - final List<BurstParticle> particles; - final Color color; - - _BurstPainter({ - required this.progress, - required this.origin, - required this.particles, - required this.color, - }); - - @override - void paint(Canvas canvas, Size size) { - for (final p in particles) { - final distance = p.speed * progress; - final x = origin.dx + cos(p.angle) * distance; - final y = origin.dy + sin(p.angle) * distance; - final currentOpacity = p.opacity * (1 - progress) * p.decay; - final currentSize = p.size * (1 - progress * 0.5); - - if (currentOpacity <= 0) continue; - - final paint = Paint() - ..color = color.withOpacity(currentOpacity) - ..maskFilter = MaskFilter.blur(BlurStyle.normal, currentSize); - canvas.drawCircle(Offset(x, y), currentSize, paint); - } - } - - @override - bool shouldRepaint(covariant _BurstPainter old) => true; -} + +/// A starfield with multiple particle layers: +/// - Background distant stars (slow drift, small) +/// - Mid-layer stars (medium speed, twinkling) +/// - Foreground nebula wisps (parallax on pointer) +class DeepSpaceParticles extends StatefulWidget { + final bool animate; + const DeepSpaceParticles({Key? key, this.animate = true}) : super(key: key); + + @override + State<DeepSpaceParticles> createState() => _DeepSpaceParticlesState(); +} + +class _DeepSpaceParticlesState extends State<DeepSpaceParticles> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + final List<StarParticle> _particles = []; + final Random _random = Random(42); + + @override + void initState() { + super.initState(); + _initParticles(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 30), + ); + if (widget.animate) { + _controller.repeat(); + } + } + + void _initParticles() { + // Background stars (small, slow) + for (int i = 0; i < 80; i++) { + _particles.add(StarParticle( + x: _random.nextDouble(), + y: _random.nextDouble(), + size: 0.5 + _random.nextDouble() * 1.5, + speed: 0.05 + _random.nextDouble() * 0.1, + brightness: 0.3 + _random.nextDouble() * 0.4, + twinkleSpeed: 0.5 + _random.nextDouble() * 2, + twinklePhase: _random.nextDouble() * 2 * pi, + layer: 0, + )); + } + // Mid stars (medium) + for (int i = 0; i < 40; i++) { + _particles.add(StarParticle( + x: _random.nextDouble(), + y: _random.nextDouble(), + size: 1.2 + _random.nextDouble() * 2, + speed: 0.1 + _random.nextDouble() * 0.2, + brightness: 0.5 + _random.nextDouble() * 0.5, + twinkleSpeed: 1 + _random.nextDouble() * 3, + twinklePhase: _random.nextDouble() * 2 * pi, + layer: 1, + )); + } + // Bright foreground stars + for (int i = 0; i < 15; i++) { + _particles.add(StarParticle( + x: _random.nextDouble(), + y: _random.nextDouble(), + size: 2 + _random.nextDouble() * 3, + speed: 0.2 + _random.nextDouble() * 0.3, + brightness: 0.7 + _random.nextDouble() * 0.3, + twinkleSpeed: 2 + _random.nextDouble() * 4, + twinklePhase: _random.nextDouble() * 2 * pi, + layer: 2, + color: [ + const Color(0xFF7B2FF7), + const Color(0xFF00D4AA), + const Color(0xFF9D5CFF), + const Color(0xFF33E5C0), + Colors.white, + ][_random.nextInt(5)], + )); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _DeepSpacePainter( + progress: _controller.value, + particles: _particles, + ), + size: Size.infinite, + ); + }, + ); + } +} + +class StarParticle { + double x, y; + final double size; + final double speed; + final double brightness; + final double twinkleSpeed; + final double twinklePhase; + final int layer; + final Color? color; + + StarParticle({ + required this.x, + required this.y, + required this.size, + required this.speed, + required this.brightness, + required this.twinkleSpeed, + required this.twinklePhase, + required this.layer, + this.color, + }); +} + +class _DeepSpacePainter extends CustomPainter { + final double progress; + final List<StarParticle> particles; + + _DeepSpacePainter({required this.progress, required this.particles}); + + @override + void paint(Canvas canvas, Size size) { + // Deep space gradient background + final bgPaint = Paint() + ..shader = RadialGradient( + center: const Alignment(0.3, -0.5), + radius: 0.8, + colors: [ + const Color(0xFF0A0E2A), + const Color(0xFF030508), + const Color(0xFF020204), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + canvas.drawRect(Offset.zero & size, bgPaint); + + // Subtle nebula glow + final nebulaPaint = Paint() + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 60) + ..color = const Color(0xFF7B2FF7).withOpacity(0.06); + canvas.drawCircle( + Offset(size.width * 0.3, size.height * 0.25), + size.width * 0.35, + nebulaPaint, + ); + final nebulaPaint2 = Paint() + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 50) + ..color = const Color(0xFF00D4AA).withOpacity(0.04); + canvas.drawCircle( + Offset(size.width * 0.7, size.height * 0.6), + size.width * 0.25, + nebulaPaint2, + ); + + // Draw particles + for (final p in particles) { + final driftX = (progress * p.speed) % 1.0; + final drawX = ((p.x + driftX) % 1.0) * size.width; + final drawY = p.y * size.height; + final twinkle = sin(progress * p.twinkleSpeed * 2 * pi + p.twinklePhase); + final alpha = (p.brightness * (0.6 + 0.4 * twinkle)).clamp(0.0, 1.0); + + final starPaint = Paint() + ..color = (p.color ?? Colors.white).withOpacity(alpha) + ..maskFilter = p.layer >= 2 + ? const MaskFilter.blur(BlurStyle.normal, 2) + : null; + + canvas.drawCircle(Offset(drawX, drawY), p.size, starPaint); + + // Cross flare for bright stars + if (p.layer >= 2 && p.color != null) { + final flarePaint = Paint() + ..color = p.color!.withOpacity(alpha * 0.3) + ..strokeWidth = 0.5; + canvas.drawLine( + Offset(drawX - p.size * 4, drawY), + Offset(drawX + p.size * 4, drawY), + flarePaint, + ); + canvas.drawLine( + Offset(drawX, drawY - p.size * 4), + Offset(drawX, drawY + p.size * 4), + flarePaint, + ); + } + } + } + + @override + bool shouldRepaint(covariant _DeepSpacePainter old) => true; +} + +// ============================================================ +// SECTION 3: Aurora — Gradient Wave Animation +// ============================================================ + +/// Northern-lights inspired animated gradient waves using +/// multiple overlapping sine bands with phase offsets. +class AuroraWaves extends StatefulWidget { + final bool animate; + const AuroraWaves({Key? key, this.animate = true}) : super(key: key); + + @override + State<AuroraWaves> createState() => _AuroraWavesState(); +} + +class _AuroraWavesState extends State<AuroraWaves> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 12), + ); + if (widget.animate) { + _controller.repeat(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _AuroraPainter(progress: _controller.value), + size: Size.infinite, + ); + }, + ); + } +} + +class _AuroraPainter extends CustomPainter { + final double progress; + + _AuroraPainter({required this.progress}); + + @override + void paint(Canvas canvas, Size size) { + // Base dark teal background + final bgPaint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF0A1628), + Color(0xFF0C1E30), + Color(0xFF081020), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + canvas.drawRect(Offset.zero & size, bgPaint); + + // Aurora wave bands + final bands = [ + _AuroraBand( + baseY: 0.15, + amplitude: 0.08, + frequency: 2, + phase: progress * 2 * pi, + phase2: progress * 2 * pi * 0.7, + colors: [const Color(0xFF00FF88), const Color(0xFF00CC6A)], + opacity: 0.15, + blur: 40, + ), + _AuroraBand( + baseY: 0.35, + amplitude: 0.1, + frequency: 1.5, + phase: progress * 2 * pi * 1.3 + 1.0, + phase2: progress * 2 * pi * 0.9 + 2.0, + colors: [const Color(0xFFFF6B9D), const Color(0xFFFF8FB0)], + opacity: 0.12, + blur: 50, + ), + _AuroraBand( + baseY: 0.55, + amplitude: 0.06, + frequency: 2.5, + phase: progress * 2 * pi * 0.8 + 2.5, + phase2: progress * 2 * pi * 1.1 + 0.5, + colors: [const Color(0xFF00FFAA), const Color(0xFF00DD99)], + opacity: 0.1, + blur: 35, + ), + _AuroraBand( + baseY: 0.25, + amplitude: 0.12, + frequency: 1.2, + phase: -progress * 2 * pi * 0.6 + 3.0, + phase2: -progress * 2 * pi * 0.8 + 1.5, + colors: [const Color(0xFFAA44FF), const Color(0xFF00FF88)], + opacity: 0.08, + blur: 55, + ), + ]; + + for (final band in bands) { + _drawAuroraBand(canvas, size, band); + } + + // Subtle star dots + final random = Random(7); + final starPaint = Paint() + ..color = Colors.white.withOpacity(0.3); + for (int i = 0; i < 30; i++) { + final sx = random.nextDouble() * size.width; + final sy = random.nextDouble() * size.height * 0.5; + final twinkle = sin(progress * 3 + i) * 0.5 + 0.5; + canvas.drawCircle( + Offset(sx, sy), + 0.8 * twinkle, + starPaint..color = Colors.white.withOpacity(0.25 * twinkle), + ); + } + } + + void _drawAuroraBand(Canvas canvas, Size size, _AuroraBand band) { + final path = Path(); + const steps = 100; + double firstY = 0; + + for (int i = 0; i <= steps; i++) { + final t = i / steps; + final x = t * size.width; + final wave1 = sin(t * band.frequency * 2 * pi + band.phase); + final wave2 = sin(t * band.frequency * 1.5 * 2 * pi + band.phase2); + final y = (band.baseY + band.amplitude * (wave1 * 0.6 + wave2 * 0.4)) * + size.height; + if (i == 0) { + path.moveTo(x, y); + firstY = y; + } else { + path.lineTo(x, y); + } + } + + // Close the path at bottom to create filled region + path.lineTo(size.width, size.height); + path.lineTo(0, size.height); + path.close(); + + final gradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + band.colors[0].withOpacity(band.opacity), + band.colors[1].withOpacity(band.opacity * 0.5), + band.colors[1].withOpacity(0), + ], + stops: const [0.0, 0.3, 1.0], + ); + + final paint = Paint() + ..shader = gradient.createShader( + Rect.fromLTWH(0, firstY * 0.5, size.width, size.height), + ) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, band.blur); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _AuroraPainter old) => true; +} + +class _AuroraBand { + final double baseY; + final double amplitude; + final double frequency; + final double phase; + final double phase2; + final List<Color> colors; + final double opacity; + final double blur; + + _AuroraBand({ + required this.baseY, + required this.amplitude, + required this.frequency, + required this.phase, + required this.phase2, + required this.colors, + required this.opacity, + required this.blur, + }); +} + +// ============================================================ +// SECTION 4: MidnightForest — Floating Fireflies +// ============================================================ + +/// Organic firefly particles that drift with Perlin-like movement, +/// leaving faint trails. Colors: emerald + amber. +class MidnightFireflies extends StatefulWidget { + final bool animate; + const MidnightFireflies({Key? key, this.animate = true}) : super(key: key); + + @override + State<MidnightFireflies> createState() => _MidnightFirefliesState(); +} + +class _MidnightFirefliesState extends State<MidnightFireflies> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + final List<Firefly> _fireflies = []; + final Random _random = Random(123); + + @override + void initState() { + super.initState(); + for (int i = 0; i < 50; i++) { + _fireflies.add(Firefly( + x: _random.nextDouble(), + y: 0.2 + _random.nextDouble() * 0.7, + size: 1.5 + _random.nextDouble() * 3, + speedX: (_random.nextDouble() - 0.5) * 0.15, + speedY: (_random.nextDouble() - 0.5) * 0.08, + phase: _random.nextDouble() * 2 * pi, + glowPhase: _random.nextDouble() * 2 * pi, + glowSpeed: 1.5 + _random.nextDouble() * 3, + color: [ + const Color(0xFF2ECC71), + const Color(0xFFF39C12), + const Color(0xFF52D687), + const Color(0xFFF5B041), + const Color(0xFF82E0AA), + ][_random.nextInt(5)], + trailLength: 3 + _random.nextInt(6), + )); + } + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 20), + ); + if (widget.animate) { + _controller.repeat(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _FireflyPainter( + progress: _controller.value, + fireflies: _fireflies, + time: _controller.lastElapsedDuration?.inMilliseconds.toDouble() ?? + 0, + ), + size: Size.infinite, + ); + }, + ); + } +} + +class Firefly { + double x, y; + final double size; + final double speedX; + final double speedY; + final double phase; + final double glowPhase; + final double glowSpeed; + final Color color; + final int trailLength; + final List<Offset> trail = []; + + Firefly({ + required this.x, + required this.y, + required this.size, + required this.speedX, + required this.speedY, + required this.phase, + required this.glowPhase, + required this.glowSpeed, + required this.color, + required this.trailLength, + }); +} + +class _FireflyPainter extends CustomPainter { + final double progress; + final List<Firefly> fireflies; + final double time; + + _FireflyPainter({ + required this.progress, + required this.fireflies, + required this.time, + }); + + @override + void paint(Canvas canvas, Size size) { + // Dark green background + final bgPaint = Paint() + ..shader = RadialGradient( + center: const Alignment(0.5, 0.8), + radius: 1.2, + colors: [ + const Color(0xFF0F3D0F), + const Color(0xFF0A1A0A), + const Color(0xFF060F06), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + canvas.drawRect(Offset.zero & size, bgPaint); + + // Subtle fog layers + final fogPaint = Paint() + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 80) + ..color = const Color(0xFF1A3A1A).withOpacity(0.2); + canvas.drawCircle( + Offset(size.width * 0.2, size.height * 0.7), + size.width * 0.4, + fogPaint, + ); + + // Update and draw fireflies + for (final f in fireflies) { + // Organic movement using sine combinations + final organicX = sin(progress * 2 * pi * 2 + f.phase) * 0.02; + final organicY = cos(progress * 2 * pi * 1.5 + f.phase * 1.3) * 0.015; + + f.x += f.speedX * 0.01 + organicX * 0.01; + f.y += f.speedY * 0.01 + organicY * 0.01; + + // Wrap around + if (f.x < -0.05) f.x = 1.05; + if (f.x > 1.05) f.x = -0.05; + if (f.y < 0) f.y = 1.0; + if (f.y > 1.0) f.y = 0; + + final drawX = f.x * size.width; + final drawY = f.y * size.height; + + // Update trail + f.trail.add(Offset(drawX, drawY)); + if (f.trail.length > f.trailLength) { + f.trail.removeAt(0); + } + + // Glow intensity (pulsing) + final glowIntensity = + (sin(progress * f.glowSpeed * 2 * pi + f.glowPhase) * 0.5 + 0.5); + final alpha = 0.3 + glowIntensity * 0.7; + + // Draw trail + if (f.trail.length > 1) { + for (int i = 0; i < f.trail.length - 1; i++) { + final trailAlpha = (i / f.trail.length) * alpha * 0.3; + final trailPaint = Paint() + ..color = f.color.withOpacity(trailAlpha) + ..strokeWidth = f.size * (i / f.trail.length) * 0.5 + ..strokeCap = StrokeCap.round; + canvas.drawLine(f.trail[i], f.trail[i + 1], trailPaint); + } + } + + // Outer glow + final glowPaint = Paint() + ..color = f.color.withOpacity(alpha * 0.3) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, f.size * 3); + canvas.drawCircle(Offset(drawX, drawY), f.size * 2, glowPaint); + + // Inner core + final corePaint = Paint() + ..color = f.color.withOpacity(alpha) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, f.size * 0.5); + canvas.drawCircle(Offset(drawX, drawY), f.size, corePaint); + } + } + + @override + bool shouldRepaint(covariant _FireflyPainter old) => true; +} + +// ============================================================ +// SECTION 5: CyberSunset — Gradient Orb Movement +// ============================================================ + +/// Large, slowly drifting gradient orbs that overlap to create +/// warm sunset hues with a cyberpunk edge. +class CyberSunsetOrbs extends StatefulWidget { + final bool animate; + const CyberSunsetOrbs({Key? key, this.animate = true}) : super(key: key); + + @override + State<CyberSunsetOrbs> createState() => _CyberSunsetOrbsState(); +} + +class _CyberSunsetOrbsState extends State<CyberSunsetOrbs> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 18), + ); + if (widget.animate) { + _controller.repeat(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _CyberSunsetPainter(progress: _controller.value), + size: Size.infinite, + ); + }, + ); + } +} + +class _CyberSunsetPainter extends CustomPainter { + final double progress; + + _CyberSunsetPainter({required this.progress}); + + @override + void paint(Canvas canvas, Size size) { + // Deep purple-blue base + final bgPaint = Paint() + ..shader = RadialGradient( + center: const Alignment(0.5, 0.3), + radius: 1.0, + colors: [ + const Color(0xFF1A1645), + const Color(0xFF0D0B2B), + const Color(0xFF08071E), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + canvas.drawRect(Offset.zero & size, bgPaint); + + // Orb definitions with movement paths + final orbs = [ + // Large orange orb + _DriftingOrb( + baseX: 0.2, + baseY: 0.4, + radius: 0.35, + driftAmpX: 0.15, + driftAmpY: 0.1, + driftFreqX: 1.0, + driftFreqY: 0.7, + phaseX: 0, + phaseY: 1.5, + color: const Color(0xFFFF7B54), + opacity: 0.12, + blur: 80, + ), + // Purple orb + _DriftingOrb( + baseX: 0.7, + baseY: 0.3, + radius: 0.3, + driftAmpX: 0.12, + driftAmpY: 0.15, + driftFreqX: 0.8, + driftFreqY: 1.1, + phaseX: 2.0, + phaseY: 0.5, + color: const Color(0xFF9B59B6), + opacity: 0.1, + blur: 70, + ), + // Secondary orange + _DriftingOrb( + baseX: 0.5, + baseY: 0.7, + radius: 0.25, + driftAmpX: 0.1, + driftAmpY: 0.08, + driftFreqX: 1.2, + driftFreqY: 0.9, + phaseX: 1.0, + phaseY: 3.0, + color: const Color(0xFFFF9D80), + opacity: 0.08, + blur: 60, + ), + // Accent magenta orb + _DriftingOrb( + baseX: 0.3, + baseY: 0.6, + radius: 0.18, + driftAmpX: 0.2, + driftAmpY: 0.12, + driftFreqX: 0.6, + driftFreqY: 0.5, + phaseX: 3.5, + phaseY: 2.0, + color: const Color(0xFFE84393), + opacity: 0.06, + blur: 50, + ), + // Warm amber orb + _DriftingOrb( + baseX: 0.8, + baseY: 0.6, + radius: 0.2, + driftAmpX: 0.08, + driftAmpY: 0.18, + driftFreqX: 1.5, + driftFreqY: 1.0, + phaseX: 4.0, + phaseY: 0.0, + color: const Color(0xFFFFA502), + opacity: 0.07, + blur: 55, + ), + ]; + + // Draw orbs back-to-front + for (final orb in orbs) { + final ox = (orb.baseX + + sin(progress * 2 * pi * orb.driftFreqX + orb.phaseX) * + orb.driftAmpX) * + size.width; + final oy = (orb.baseY + + cos(progress * 2 * pi * orb.driftFreqY + orb.phaseY) * + orb.driftAmpY) * + size.height; + + final orbPaint = Paint() + ..color = orb.color.withOpacity(orb.opacity) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, orb.blur); + canvas.drawCircle( + Offset(ox, oy), + orb.radius * size.shortestSide, + orbPaint, + ); + } + + // Subtle grid overlay for cyber feel + final gridPaint = Paint() + ..color = const Color(0xFFFF7B54).withOpacity(0.03) + ..strokeWidth = 0.5; + final gridSpacing = size.width / 20; + for (int i = 0; i < 20; i++) { + final x = i * gridSpacing; + canvas.drawLine( + Offset(x, 0), + Offset(x, size.height), + gridPaint, + ); + } + for (int i = 0; i < 30; i++) { + final y = i * (size.height / 30); + canvas.drawLine( + Offset(0, y), + Offset(size.width, y), + gridPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _CyberSunsetPainter old) => true; +} + +class _DriftingOrb { + final double baseX; + final double baseY; + final double radius; + final double driftAmpX; + final double driftAmpY; + final double driftFreqX; + final double driftFreqY; + final double phaseX; + final double phaseY; + final Color color; + final double opacity; + final double blur; + + _DriftingOrb({ + required this.baseX, + required this.baseY, + required this.radius, + required this.driftAmpX, + required this.driftAmpY, + required this.driftFreqX, + required this.driftFreqY, + required this.phaseX, + required this.phaseY, + required this.color, + required this.opacity, + required this.blur, + }); +} + +// ============================================================ +// SECTION 6: MonochromeGeek — Subtle Noise Texture +// ============================================================ + +/// A nearly imperceptible animated noise field that gives the +/// pure-black background texture without visual distraction. +class MonochromeNoise extends StatefulWidget { + final bool animate; + const MonochromeNoise({Key? key, this.animate = true}) : super(key: key); + + @override + State<MonochromeNoise> createState() => _MonochromeNoiseState(); +} + +class _MonochromeNoiseState extends State<MonochromeNoise> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + final List<NoiseGrain> _grains = []; + final Random _random = Random(999); + + @override + void initState() { + super.initState(); + // Generate static noise grains + for (int i = 0; i < 200; i++) { + _grains.add(NoiseGrain( + x: _random.nextDouble(), + y: _random.nextDouble(), + brightness: 0.02 + _random.nextDouble() * 0.06, + size: 0.5 + _random.nextDouble() * 1.5, + phase: _random.nextDouble() * 2 * pi, + speed: 0.3 + _random.nextDouble() * 1.5, + )); + } + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 8), + ); + if (widget.animate) { + _controller.repeat(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _NoisePainter( + progress: _controller.value, + grains: _grains, + ), + size: Size.infinite, + ); + }, + ); + } +} + +class NoiseGrain { + final double x; + final double y; + final double brightness; + final double size; + final double phase; + final double speed; + + NoiseGrain({ + required this.x, + required this.y, + required this.brightness, + required this.size, + required this.phase, + required this.speed, + }); +} + +class _NoisePainter extends CustomPainter { + final double progress; + final List<NoiseGrain> grains; + + _NoisePainter({required this.progress, required this.grains}); + + @override + void paint(Canvas canvas, Size size) { + // Pure black base + canvas.drawRect( + Offset.zero & size, + Paint()..color = Colors.black, + ); + + // Noise grain field + for (final grain in grains) { + final flicker = sin(progress * grain.speed * 2 * pi + grain.phase); + final alpha = grain.brightness * (0.5 + 0.5 * flicker); + final brightness = (0.05 + alpha * 0.1).clamp(0.0, 0.15); + + final paint = Paint() + ..color = Color.fromRGBO( + (brightness * 255).toInt(), + (brightness * 255).toInt(), + (brightness * 255).toInt(), + 1.0, + ) + ..strokeWidth = grain.size; + canvas.drawPoints( + PointMode.points, + [Offset(grain.x * size.width, grain.y * size.height)], + paint, + ); + } + + // Subtle scan line effect + final scanlinePaint = Paint() + ..color = const Color(0xFFFFFFFF).withOpacity(0.02); + final lineHeight = 2.0; + final gap = 4.0; + for (double y = 0; y < size.height; y += (lineHeight + gap)) { + canvas.drawRect( + Rect.fromLTWH(0, y, size.width, lineHeight), + scanlinePaint, + ); + } + + // Very subtle CRT vignette + final vignettePaint = Paint() + ..shader = RadialGradient( + center: Alignment.center, + radius: 1.0, + colors: [ + Colors.transparent, + const Color(0xFF000000).withOpacity(0.3), + ], + stops: const [0.5, 1.0], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + canvas.drawRect(Offset.zero & size, vignettePaint); + } + + @override + bool shouldRepaint(covariant _NoisePainter old) => true; +} + +// ============================================================ +// SECTION 7: Animated Gradient Container (utility) +// ============================================================ + +/// A reusable container that smoothly animates its gradient +/// background between two color sets. +class AnimatedGradientContainer extends StatefulWidget { + final List<Color> colors; + final List<Color> altColors; + final List<double>? stops; + final Alignment begin; + final Alignment end; + final Duration duration; + final Widget? child; + + const AnimatedGradientContainer({ + Key? key, + required this.colors, + required this.altColors, + this.stops, + this.begin = Alignment.topLeft, + this.end = Alignment.bottomRight, + this.duration = const Duration(seconds: 6), + this.child, + }) : super(key: key); + + @override + State<AnimatedGradientContainer> createState() => + _AnimatedGradientContainerState(); +} + +class _AnimatedGradientContainerState extends State<AnimatedGradientContainer> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation<double> _blend; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: widget.duration, + ); + _blend = Tween<double>(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOutSine, + ), + ); + _controller.repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final blendedColors = List<Color>.generate( + widget.colors.length, + (i) => Color.lerp(widget.colors[i], widget.altColors[i], _blend.value)!, + ); + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: widget.begin, + end: widget.end, + colors: blendedColors, + stops: widget.stops, + ), + ), + child: widget.child, + ); + }, + ); + } +} + +// ============================================================ +// SECTION 8: Particle Burst Effect (transient animation) +// ============================================================ + +/// A burst of particles that radiates from a point and fades. +/// Used for success feedback, button clicks, etc. +class ParticleBurst extends StatefulWidget { + final Offset origin; + final int particleCount; + final Color color; + final Duration duration; + final VoidCallback? onComplete; + + const ParticleBurst({ + Key? key, + required this.origin, + this.particleCount = 20, + required this.color, + this.duration = const Duration(milliseconds: 800), + this.onComplete, + }) : super(key: key); + + @override + State<ParticleBurst> createState() => _ParticleBurstState(); +} + +class _ParticleBurstState extends State<ParticleBurst> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late List<BurstParticle> _particles; + final Random _random = Random(); + + @override + void initState() { + super.initState(); + _particles = List.generate(widget.particleCount, (i) { + final angle = _random.nextDouble() * 2 * pi; + final speed = 50 + _random.nextDouble() * 150; + return BurstParticle( + angle: angle, + speed: speed, + size: 2 + _random.nextDouble() * 4, + opacity: 0.6 + _random.nextDouble() * 0.4, + decay: 0.5 + _random.nextDouble() * 0.5, + ); + }); + _controller = AnimationController( + vsync: this, + duration: widget.duration, + ); + _controller.forward().then((_) { + widget.onComplete?.call(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return CustomPaint( + painter: _BurstPainter( + progress: _controller.value, + origin: widget.origin, + particles: _particles, + color: widget.color, + ), + size: Size.infinite, + ); + }, + ); + } +} + +class BurstParticle { + final double angle; + final double speed; + final double size; + final double opacity; + final double decay; + + BurstParticle({ + required this.angle, + required this.speed, + required this.size, + required this.opacity, + required this.decay, + }); +} + +class _BurstPainter extends CustomPainter { + final double progress; + final Offset origin; + final List<BurstParticle> particles; + final Color color; + + _BurstPainter({ + required this.progress, + required this.origin, + required this.particles, + required this.color, + }); + + @override + void paint(Canvas canvas, Size size) { + for (final p in particles) { + final distance = p.speed * progress; + final x = origin.dx + cos(p.angle) * distance; + final y = origin.dy + sin(p.angle) * distance; + final currentOpacity = p.opacity * (1 - progress) * p.decay; + final currentSize = p.size * (1 - progress * 0.5); + + if (currentOpacity <= 0) continue; + + final paint = Paint() + ..color = color.withOpacity(currentOpacity) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, currentSize); + canvas.drawCircle(Offset(x, y), currentSize, paint); + } + } + + @override + bool shouldRepaint(covariant _BurstPainter old) => true; +} diff --git a/mobile_agent/lib/widgets/glass_card_widget.dart b/mobile_agent/lib/widgets/glass_card_widget.dart index b004506..370d7ae 100644 --- a/mobile_agent/lib/widgets/glass_card_widget.dart +++ b/mobile_agent/lib/widgets/glass_card_widget.dart @@ -1,340 +1,338 @@ -import 'package:flutter/material.dart'; -import '../themes/app_theme.dart'; - -/// Glassmorphism card container widget -/// Frosted glass effect with subtle border and optional gradient glow -/// Hover/active states support -class GlassCardWidget extends StatefulWidget { - final Widget child; - final double? width; - final double? height; - final EdgeInsetsGeometry? padding; - final EdgeInsetsGeometry? margin; - final double borderRadius; - final VoidCallback? onTap; - final bool glowEffect; - final List<Color>? glowColors; - final double glowIntensity; - - const GlassCardWidget({ - super.key, - required this.child, - this.width, - this.height, - this.padding, - this.margin, - this.borderRadius = 16.0, - this.onTap, - this.glowEffect = false, - this.glowColors, - this.glowIntensity = 0.1, - }); - - @override - State<GlassCardWidget> createState() => _GlassCardWidgetState(); -} - -class _GlassCardWidgetState extends State<GlassCardWidget> - with SingleTickerProviderStateMixin { - bool _isHovered = false; - bool _isPressed = false; - - @override - Widget build(BuildContext context) { - final glowColors = widget.glowColors ?? - [AppTheme.violet.withOpacity(0.3), AppTheme.cyan.withOpacity(0.2)]; - - Widget card = Container( - width: widget.width, - height: widget.height, - margin: widget.margin, - padding: widget.padding, - decoration: BoxDecoration( - // Gradient background for glass effect - gradient: LinearGradient( - colors: [ - AppTheme.surfaceCard.withOpacity(0.7), - AppTheme.surfaceDark.withOpacity(0.5), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(widget.borderRadius), - // Subtle border - border: Border.all( - color: _isHovered - ? AppTheme.violet.withOpacity(0.3) - : AppTheme.border.withOpacity(0.4), - width: _isHovered ? 1.2 : 0.8, - ), - // Glow effect - boxShadow: [ - if (widget.glowEffect || _isHovered) - BoxShadow( - color: glowColors[0].withOpacity( - widget.glowEffect ? widget.glowIntensity : 0.08, - ), - blurRadius: _isHovered ? 20 : 12, - spreadRadius: _isHovered ? 2 : 0, - ), - if (widget.glowEffect || _isHovered) - BoxShadow( - color: glowColors.length > 1 - ? glowColors[1].withOpacity( - widget.glowEffect ? widget.glowIntensity * 0.5 : 0.04, - ) - : Colors.transparent, - blurRadius: _isHovered ? 12 : 6, - spreadRadius: 0, - ), - // Default subtle shadow - BoxShadow( - color: Colors.black.withOpacity(0.08), - blurRadius: 8, - offset: const Offset(0, 2), - ), +import 'package:flutter/material.dart'; +import '../themes/app_theme.dart'; + +/// Glassmorphism card container widget +/// Frosted glass effect with subtle border and optional gradient glow +/// Hover/active states support +class GlassCardWidget extends StatefulWidget { + final Widget child; + final double? width; + final double? height; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final double borderRadius; + final VoidCallback? onTap; + final bool glowEffect; + final List<Color>? glowColors; + final double glowIntensity; + + const GlassCardWidget({ + super.key, + required this.child, + this.width, + this.height, + this.padding, + this.margin, + this.borderRadius = 16.0, + this.onTap, + this.glowEffect = false, + this.glowColors, + this.glowIntensity = 0.1, + }); + + @override + State<GlassCardWidget> createState() => _GlassCardWidgetState(); +} + +class _GlassCardWidgetState extends State<GlassCardWidget> + with SingleTickerProviderStateMixin { + bool _isHovered = false; + bool _isPressed = false; + + @override + Widget build(BuildContext context) { + final glowColors = widget.glowColors ?? + [AppTheme.violet.withOpacity(0.3), AppTheme.cyan.withOpacity(0.2)]; + + Widget card = Container( + width: widget.width, + height: widget.height, + margin: widget.margin, + padding: widget.padding, + decoration: BoxDecoration( + // Gradient background for glass effect + gradient: LinearGradient( + colors: [ + AppTheme.surfaceCard.withOpacity(0.7), + AppTheme.surfaceDark.withOpacity(0.5), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(widget.borderRadius), + // Subtle border + border: Border.all( + color: _isHovered + ? AppTheme.violet.withOpacity(0.3) + : AppTheme.border.withOpacity(0.4), + width: _isHovered ? 1.2 : 0.8, + ), + // Glow effect + boxShadow: [ + if (widget.glowEffect || _isHovered) + BoxShadow( + color: glowColors[0].withOpacity( + widget.glowEffect ? widget.glowIntensity : 0.08, + ), + blurRadius: _isHovered ? 20 : 12, + spreadRadius: _isHovered ? 2 : 0, + ), + if (widget.glowEffect || _isHovered) + BoxShadow( + color: glowColors.length > 1 + ? glowColors[1].withOpacity( + widget.glowEffect ? widget.glowIntensity * 0.5 : 0.04, + ) + : Colors.transparent, + blurRadius: _isHovered ? 12 : 6, + spreadRadius: 0, + ), + // Default subtle shadow + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), ], - // Transform for press effect - transform: _isPressed - ? (Matrix4.identity()..scale(0.98)) - : null, ), child: widget.child, ); - - // Add interactivity if onTap is provided - if (widget.onTap != null) { - card = MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: GestureDetector( - onTapDown: (_) => setState(() => _isPressed = true), - onTapUp: (_) { - setState(() => _isPressed = false); - widget.onTap!(); - }, + + // Add interactivity if onTap is provided + if (widget.onTap != null) { + card = MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: GestureDetector( + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) { + setState(() => _isPressed = false); + widget.onTap!(); + }, onTapCancel: () => setState(() => _isPressed = false), child: AnimatedContainer( duration: AppTheme.animFast, curve: Curves.easeInOut, + transform: _isPressed ? (Matrix4.identity()..scale(0.98)) : null, + transformAlignment: Alignment.center, child: card, ), ), - ); - } - - return card; - } -} - -/// Glass container with stronger blur effect for overlays -class GlassOverlay extends StatelessWidget { - final Widget child; - final double? width; - final double? height; - final EdgeInsetsGeometry? padding; - final double borderRadius; - final double blur; - - const GlassOverlay({ - super.key, - required this.child, - this.width, - this.height, - this.padding, - this.borderRadius = 20.0, - this.blur = 10.0, - }); - - @override - Widget build(BuildContext context) { - return Container( - width: width, - height: height, - padding: padding, - decoration: BoxDecoration( - color: AppTheme.surfaceCard.withOpacity(0.6), - borderRadius: BorderRadius.circular(borderRadius), - border: Border.all( - color: Colors.white.withOpacity(0.1), - width: 1, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 20, - spreadRadius: 0, - ), - ], - ), - child: child, - ); - } -} - -/// Info card with glass effect for stats display -class GlassInfoCard extends StatelessWidget { - final String title; - final String value; - final IconData icon; - final Color? iconColor; - final VoidCallback? onTap; - - const GlassInfoCard({ - super.key, - required this.title, - required this.value, - required this.icon, - this.iconColor, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GlassCardWidget( - onTap: onTap, - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - (iconColor ?? AppTheme.violet).withOpacity(0.3), - (iconColor ?? AppTheme.violet).withOpacity(0.1), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - icon, - size: 22, - color: iconColor ?? AppTheme.violetLight, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textTertiary, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppTheme.textPrimary, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -/// List tile with glass effect -class GlassListTile extends StatelessWidget { - final Widget? leading; - final String title; - final String? subtitle; - final Widget? trailing; - final VoidCallback? onTap; - final EdgeInsetsGeometry? padding; - - const GlassListTile({ - super.key, - this.leading, - required this.title, - this.subtitle, - this.trailing, - this.onTap, - this.padding, - }); - - @override - Widget build(BuildContext context) { - return GlassCardWidget( - onTap: onTap, - padding: padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - borderRadius: 12, - child: Row( - children: [ - if (leading != null) ...[ - leading!, - const SizedBox(width: 14), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppTheme.textPrimary, - ), - ), - if (subtitle != null) ...[ - const SizedBox(height: 2), - Text( - subtitle!, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textTertiary, - ), - ), - ], - ], - ), - ), - if (trailing != null) trailing!, - ], - ), - ); - } -} - -/// Divider with glass effect -class GlassDivider extends StatelessWidget { - final double indent; - final double endIndent; - - const GlassDivider({ - super.key, - this.indent = 16, - this.endIndent = 16, - }); - - @override - Widget build(BuildContext context) { - return Container( - margin: EdgeInsets.symmetric(horizontal: indent), - height: 1, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppTheme.divider.withOpacity(0.0), - AppTheme.divider.withOpacity(0.8), - AppTheme.divider.withOpacity(0.0), - ], - stops: const [0.0, 0.5, 1.0], - ), - ), - ); - } -} + ); + } + + return card; + } +} + +/// Glass container with stronger blur effect for overlays +class GlassOverlay extends StatelessWidget { + final Widget child; + final double? width; + final double? height; + final EdgeInsetsGeometry? padding; + final double borderRadius; + final double blur; + + const GlassOverlay({ + super.key, + required this.child, + this.width, + this.height, + this.padding, + this.borderRadius = 20.0, + this.blur = 10.0, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + padding: padding, + decoration: BoxDecoration( + color: AppTheme.surfaceCard.withOpacity(0.6), + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: Colors.white.withOpacity(0.1), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 20, + spreadRadius: 0, + ), + ], + ), + child: child, + ); + } +} + +/// Info card with glass effect for stats display +class GlassInfoCard extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final Color? iconColor; + final VoidCallback? onTap; + + const GlassInfoCard({ + super.key, + required this.title, + required this.value, + required this.icon, + this.iconColor, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GlassCardWidget( + onTap: onTap, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + (iconColor ?? AppTheme.violet).withOpacity(0.3), + (iconColor ?? AppTheme.violet).withOpacity(0.1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + size: 22, + color: iconColor ?? AppTheme.violetLight, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textTertiary, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// List tile with glass effect +class GlassListTile extends StatelessWidget { + final Widget? leading; + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + final EdgeInsetsGeometry? padding; + + const GlassListTile({ + super.key, + this.leading, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + this.padding, + }); + + @override + Widget build(BuildContext context) { + return GlassCardWidget( + onTap: onTap, + padding: padding ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + borderRadius: 12, + child: Row( + children: [ + if (leading != null) ...[ + leading!, + const SizedBox(width: 14), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppTheme.textPrimary, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textTertiary, + ), + ), + ], + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ); + } +} + +/// Divider with glass effect +class GlassDivider extends StatelessWidget { + final double indent; + final double endIndent; + + const GlassDivider({ + super.key, + this.indent = 16, + this.endIndent = 16, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.symmetric(horizontal: indent), + height: 1, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.divider.withOpacity(0.0), + AppTheme.divider.withOpacity(0.8), + AppTheme.divider.withOpacity(0.0), + ], + stops: const [0.0, 0.5, 1.0], + ), + ), + ); + } +} diff --git a/mobile_agent/pubspec.yaml b/mobile_agent/pubspec.yaml index 5b11dd9..3234a28 100644 --- a/mobile_agent/pubspec.yaml +++ b/mobile_agent/pubspec.yaml @@ -2,7 +2,7 @@ name: mobile_agent description: "Mobile Agent - A lightweight Vibing Coding AI companion for mobile devices" publish_to: 'none' -version: 0.1.0+19 +version: 0.1.39+58 environment: sdk: ^3.6.0 @@ -48,6 +48,7 @@ dependencies: # UI & Content flutter_markdown: ^0.7.6 + flutter_svg: ^2.0.17 cupertino_icons: ^1.0.8 # Platform Services @@ -80,3 +81,7 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/role_avatars/ + - assets/icons/ + - assets/token_pricing/ diff --git a/mobile_agent/test/services/device_telemetry_service_test.dart b/mobile_agent/test/services/device_telemetry_service_test.dart new file mode 100644 index 0000000..682df99 --- /dev/null +++ b/mobile_agent/test/services/device_telemetry_service_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_agent/services/device_telemetry_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('mobilecode/system_tools'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + }); + + test('reads Android telemetry from method channel', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getDeviceTelemetry'); + return { + 'platform': 'android', + 'manufacturer': 'MobileCode', + 'model': 'Test Phone', + 'androidVersion': '15', + 'sdkInt': 35, + 'abis': ['arm64-v8a'], + 'cpuCores': 8, + 'cpuUsagePercent': 42.5, + 'totalMemoryMb': 8192, + 'availableMemoryMb': 4096, + 'lowMemory': false, + 'appRssMb': 180, + 'appHeapMb': 72, + 'storageTotalMb': 128000, + 'storageFreeMb': 64000, + 'batteryLevel': 88, + 'batteryCharging': true, + 'batteryTemperatureC': 31.2, + 'thermalStatus': 1, + 'timestamp': 1710000000000, + 'fallback': false, + }; + }); + + final snapshot = await DeviceTelemetryService.instance.getLatestSnapshot(); + + expect(snapshot.fallback, isFalse); + expect(snapshot.model, 'Test Phone'); + expect(snapshot.cpuUsagePercent, 42.5); + expect(snapshot.memoryUsedPercent, closeTo(0.5, 0.01)); + expect(snapshot.batteryCharging, isTrue); + }); + + test('falls back when platform channel is unavailable', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'missing'); + }); + + final snapshot = await DeviceTelemetryService.instance.getLatestSnapshot(); + + expect(snapshot.fallback, isTrue); + expect(snapshot.cpuCores, greaterThan(0)); + expect(snapshot.appRssMb, greaterThanOrEqualTo(0)); + }); +} diff --git a/mobile_agent/test/services/github_pages_service_test.dart b/mobile_agent/test/services/github_pages_service_test.dart new file mode 100644 index 0000000..55f1325 --- /dev/null +++ b/mobile_agent/test/services/github_pages_service_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_agent/services/github_deep_service.dart'; +import 'package:mobile_agent/services/github_pages_service.dart'; + +void main() { + group('GitHubPagesService.describeFailure', () { + test('maps unauthorized tokens to login recovery', () { + final details = GitHubPagesService.describeFailure(const GitHubDeepException( + message: 'Bad credentials', + endpoint: '/user', + statusCode: 401, + )); + + expect(details.failureKind, 'auth_invalid'); + expect(details.recoveryHint, contains('fresh token')); + }); + + test('maps permission failures to Pages token scope guidance', () { + final details = GitHubPagesService.describeFailure(const GitHubDeepException( + message: 'Resource not accessible by personal access token', + endpoint: '/repos/me/site/pages', + statusCode: 403, + )); + + expect(details.failureKind, 'permission_denied'); + expect(details.recoveryHint, contains('Pages read/write')); + expect(details.recoveryHint, contains('Administration read/write')); + }); + + test('maps validation failures to repo and Pages settings guidance', () { + final details = GitHubPagesService.describeFailure(const GitHubDeepException( + message: 'Validation Failed', + endpoint: '/user/repos', + statusCode: 422, + )); + + expect(details.failureKind, 'validation_failed'); + expect(details.recoveryHint, contains('repository name')); + }); + }); +} diff --git a/mobile_agent/test/services/html_publish_readiness_service_test.dart b/mobile_agent/test/services/html_publish_readiness_service_test.dart new file mode 100644 index 0000000..2b4d9f5 --- /dev/null +++ b/mobile_agent/test/services/html_publish_readiness_service_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_agent/services/html_publish_readiness_service.dart'; + +void main() { + group('HtmlPublishReadinessService', () { + late HtmlPublishReadinessService service; + + setUp(() { + service = HtmlPublishReadinessService(); + }); + + test('accepts a self-contained mobile HTML document', () { + final report = service.checkHtml(''' +<!doctype html> +<html lang="en"> + <head> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>MobileCode Demo + + + +
+ +
+ + +'''); + + expect(report.blocked, isFalse); + expect(report.statusLabel, 'Ready'); + expect(report.warningIssues, isEmpty); + }); + + test('blocks missing title and viewport', () { + final report = service.checkHtml('
Hello
'); + + expect(report.blocked, isTrue); + expect(report.blockingIssues.map((issue) => issue.code), containsAll(['missing_title', 'missing_viewport'])); + }); + + test('blocks leaked app-private paths', () { + final report = service.checkHtml(''' + + + + Leak + +
bad
+ +'''); + + expect(report.blocked, isTrue); + expect(report.blockingIssues.map((issue) => issue.code), contains('private_path_leak')); + }); + + test('warns for remote references unless explicitly allowed', () { + final report = service.checkHtml(''' + + + + Remote + +
+ +'''); + + expect(report.blocked, isFalse); + expect(report.warningIssues.map((issue) => issue.code), contains('remote_references')); + + final allowed = service.checkHtml(''' + + + + Remote + +
+ +''', allowRemoteAssets: true); + + expect(allowed.warningIssues.map((issue) => issue.code), isNot(contains('remote_references'))); + }); + + test('warns for accessibility and touch target gaps', () { + final report = service.checkHtml(''' + + + + A11y + +
+ +'''); + + final codes = report.warningIssues.map((issue) => issue.code); + expect(codes, contains('unnamed_controls')); + expect(codes, contains('missing_image_alt')); + expect(codes, contains('touch_targets_unclear')); + expect(codes, contains('semantic_structure')); + }); + }); +} diff --git a/mobile_agent/test/services/role_library_service_test.dart b/mobile_agent/test/services/role_library_service_test.dart new file mode 100644 index 0000000..4d07495 --- /dev/null +++ b/mobile_agent/test/services/role_library_service_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_agent/services/role_library_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('RoleLibraryService proposals', () { + test('creates, accepts, and dismisses pending role proposals', () async { + SharedPreferences.setMockInitialValues({}); + final service = RoleLibraryService.instance; + await service.initialize(); + + final proposal = await service.createProposalFromPrompt( + '帮我研究一个陌生资料目录并提炼复用检查表', + 'run_test_accept', + service.recruitmentRoles, + ); + + expect(proposal, isNotNull); + expect(proposal!.status, RoleProposalStatus.pending); + expect(service.pendingProposals.any((item) => item.proposalId == proposal.proposalId), isTrue); + + await service.acceptProposal(proposal.proposalId); + + expect(service.pendingProposals.any((item) => item.proposalId == proposal.proposalId), isFalse); + expect(service.allRoles.any((role) => role.id == proposal.role.id && !role.builtIn), isTrue); + + final dismissed = await service.createProposalFromPrompt( + '帮我梳理冷门素材目录的命名习惯', + 'run_test_dismiss', + service.recruitmentRoles, + ); + + expect(dismissed, isNotNull); + await service.dismissProposal(dismissed!.proposalId); + + expect(service.pendingProposals.any((item) => item.proposalId == dismissed.proposalId), isFalse); + expect(service.allRoles.any((role) => role.id == dismissed.role.id), isFalse); + }); + }); +} diff --git a/mobile_agent/test/services/token_pricing_service_test.dart b/mobile_agent/test/services/token_pricing_service_test.dart new file mode 100644 index 0000000..f2f8922 --- /dev/null +++ b/mobile_agent/test/services/token_pricing_service_test.dart @@ -0,0 +1,119 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_agent/services/token_pricing_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TokenPricingService.instance.resetForTesting(); + SharedPreferences.setMockInitialValues({}); + }); + + test('loads built-in LiteLLM-compatible snapshot and estimates cost', () async { + final service = TokenPricingService.instance; + await service.initialize(); + + final estimate = await service.estimateCost( + provider: 'OpenAI', + model: 'gpt-4o-mini', + inputTokens: 1000000, + outputTokens: 1000000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + ); + + expect(estimate.price.sourceName, contains('LiteLLM')); + expect(estimate.price.inputPerMillion, closeTo(0.15, 0.001)); + expect(estimate.price.outputPerMillion, closeTo(0.60, 0.001)); + expect(estimate.costUsd, closeTo(0.75, 0.01)); + }); + + test('user override wins over snapshot', () async { + final service = TokenPricingService.instance; + await service.initialize(); + + await service.upsertOverride(TokenPrice( + provider: 'openai', + model: 'gpt-4o-mini', + inputCostPerToken: 0.000001, + outputCostPerToken: 0.000002, + cacheReadCostPerToken: 0, + cacheWriteCostPerToken: 0, + sourceName: 'User override', + sourceUrl: '', + updatedAt: DateTime(2026, 5, 18), + custom: true, + )); + + final estimate = await service.estimateCost( + provider: 'openai', + model: 'gpt-4o-mini', + inputTokens: 1000000, + outputTokens: 1000000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + ); + + expect(estimate.price.custom, isTrue); + expect(estimate.costUsd, closeTo(3.0, 0.01)); + }); + + test('parses and applies official LiteLLM model-map snapshot', () async { + final service = TokenPricingService.instance; + await service.initialize(); + + final updatedAt = DateTime(2026, 5, 18); + final update = service.buildUpdateFromJson( + jsonEncode({ + 'gpt-manual-test': { + 'litellm_provider': 'openai', + 'input_cost_per_token': 0.000001, + 'output_cost_per_token': 0.000002, + 'cache_read_input_token_cost': 0.0000002, + 'cache_creation_input_token_cost': 0.0000005, + }, + }), + sourceName: 'LiteLLM remote snapshot', + sourceUrl: TokenPricingService.officialLiteLlmRawUrl, + updatedAt: updatedAt, + ); + + expect(update.modelCount, 1); + expect(update.newCount, 1); + + await service.applySnapshotUpdate(update); + final catalog = service.catalog; + expect(catalog.sourceName, 'LiteLLM remote snapshot'); + expect(catalog.snapshotCount, 1); + + final estimate = await service.estimateCost( + provider: 'openai', + model: 'gpt-manual-test', + inputTokens: 1000000, + outputTokens: 1000000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + ); + + expect(estimate.price.sourceUrl, TokenPricingService.officialLiteLlmRawUrl); + expect(estimate.costUsd, closeTo(3.0, 0.01)); + + service.resetForTesting(); + await service.initialize(); + expect(service.catalog.sourceName, 'LiteLLM remote snapshot'); + + final reloadedEstimate = await service.estimateCost( + provider: 'openai', + model: 'gpt-manual-test', + inputTokens: 1000000, + outputTokens: 1000000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + ); + + expect(reloadedEstimate.costUsd, closeTo(3.0, 0.01)); + }); +} diff --git a/mobile_agent/test/services/token_usage_service_test.dart b/mobile_agent/test/services/token_usage_service_test.dart new file mode 100644 index 0000000..644dbd6 --- /dev/null +++ b/mobile_agent/test/services/token_usage_service_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_agent/services/token_usage_service.dart'; + +void main() { + group('TokenUsageService parsers', () { + test('parses OpenAI usage with cached tokens', () { + final usage = TokenUsageService.parseOpenAiUsage({ + 'usage': { + 'prompt_tokens': 120, + 'completion_tokens': 30, + 'total_tokens': 150, + 'prompt_tokens_details': {'cached_tokens': 50}, + }, + }); + + expect(usage.inputTokens, 120); + expect(usage.outputTokens, 30); + expect(usage.totalTokens, 150); + expect(usage.cacheReadTokens, 50); + expect(usage.cacheMissTokens, 70); + expect(usage.estimated, isFalse); + }); + + test('parses Anthropic usage with cache creation and read tokens', () { + final usage = TokenUsageService.parseAnthropicUsage({ + 'usage': { + 'input_tokens': 200, + 'output_tokens': 80, + 'cache_creation_input_tokens': 40, + 'cache_read_input_tokens': 60, + }, + }); + + expect(usage.inputTokens, 200); + expect(usage.outputTokens, 80); + expect(usage.totalTokens, 280); + expect(usage.cacheWriteTokens, 40); + expect(usage.cacheReadTokens, 60); + expect(usage.cacheMissTokens, 100); + expect(usage.estimated, isFalse); + }); + + test('accumulator falls back to local estimate when provider omits usage', () { + final accumulator = TokenUsageAccumulator(providerKind: 'openai')..addChunk({'choices': []}); + final snapshot = accumulator.snapshot(inputChars: 400, outputChars: 160); + + expect(snapshot.estimated, isTrue); + expect(snapshot.inputTokens, 100); + expect(snapshot.outputTokens, 40); + expect(snapshot.totalTokens, 140); + }); + }); +} diff --git a/mobile_agent/tooling/MainActivity.kt b/mobile_agent/tooling/MainActivity.kt index 41d0675..ebba642 100644 --- a/mobile_agent/tooling/MainActivity.kt +++ b/mobile_agent/tooling/MainActivity.kt @@ -1,9 +1,17 @@ package com.mobilecode.mobile_agent +import android.app.ActivityManager +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager +import android.os.BatteryManager import android.os.Build import android.os.Bundle +import android.os.Debug +import android.os.Environment +import android.os.PowerManager +import android.os.StatFs import android.util.Log import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -11,14 +19,20 @@ import io.flutter.plugin.common.MethodChannel import java.io.File class MainActivity : FlutterActivity() { + private var lastCpuTotal: Long? = null + private var lastCpuIdle: Long? = null + private var pendingDeepLink: String? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + captureDeepLink(intent) maybeStartHelperFromIntent(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + captureDeepLink(intent) maybeStartHelperFromIntent(intent) } @@ -62,6 +76,14 @@ class MainActivity : FlutterActivity() { "helperServiceStatus" -> { result.success(MobileCodeHelperService.status()) } + "getDeviceTelemetry" -> { + result.success(deviceTelemetry()) + } + "consumeInitialDeepLink" -> { + val link = pendingDeepLink + pendingDeepLink = null + result.success(link) + } else -> result.notImplemented() } } @@ -132,6 +154,91 @@ class MainActivity : FlutterActivity() { } } + private fun deviceTelemetry(): Map { + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memoryInfo) + + val debugMemoryInfo = Debug.MemoryInfo() + Debug.getMemoryInfo(debugMemoryInfo) + val runtime = Runtime.getRuntime() + val dataStat = StatFs(Environment.getDataDirectory().path) + val battery = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val batteryLevel = batteryLevel(battery) + val batteryStatus = battery?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 + val charging = batteryStatus == BatteryManager.BATTERY_STATUS_CHARGING || + batteryStatus == BatteryManager.BATTERY_STATUS_FULL + val batteryTemp = (battery?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0) / 10.0 + val thermalStatus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + (getSystemService(Context.POWER_SERVICE) as PowerManager).currentThermalStatus + } else { + -1 + } + + return mapOf( + "platform" to "android", + "manufacturer" to Build.MANUFACTURER.orEmpty(), + "brand" to Build.BRAND.orEmpty(), + "model" to Build.MODEL.orEmpty(), + "androidVersion" to Build.VERSION.RELEASE.orEmpty(), + "sdkInt" to Build.VERSION.SDK_INT, + "abis" to Build.SUPPORTED_ABIS.toList(), + "cpuCores" to runtime.availableProcessors(), + "cpuUsagePercent" to sampleCpuUsagePercent(), + "totalMemoryMb" to mb(memoryInfo.totalMem), + "availableMemoryMb" to mb(memoryInfo.availMem), + "lowMemory" to memoryInfo.lowMemory, + "appRssMb" to debugMemoryInfo.totalPss / 1024, + "appHeapMb" to mb(runtime.totalMemory() - runtime.freeMemory()), + "storageTotalMb" to mb(dataStat.totalBytes), + "storageFreeMb" to mb(dataStat.availableBytes), + "batteryLevel" to batteryLevel, + "batteryCharging" to charging, + "batteryTemperatureC" to batteryTemp, + "thermalStatus" to thermalStatus, + "timestamp" to System.currentTimeMillis(), + "fallback" to false + ) + } + + private fun batteryLevel(intent: Intent?): Int { + val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 + if (level < 0 || scale <= 0) return -1 + return ((level * 100.0) / scale).toInt() + } + + private fun mb(bytes: Long): Long = bytes / (1024L * 1024L) + + private fun sampleCpuUsagePercent(): Double { + val sample = readCpuStat() ?: return 0.0 + val previousTotal = lastCpuTotal + val previousIdle = lastCpuIdle + lastCpuTotal = sample.first + lastCpuIdle = sample.second + if (previousTotal == null || previousIdle == null) return 0.0 + val totalDelta = sample.first - previousTotal + val idleDelta = sample.second - previousIdle + if (totalDelta <= 0) return 0.0 + val busy = (totalDelta - idleDelta).coerceAtLeast(0) + return (busy * 100.0 / totalDelta).coerceIn(0.0, 100.0) + } + + private fun readCpuStat(): Pair? { + return try { + val firstLine = File("/proc/stat").bufferedReader().use { it.readLine() } ?: return null + val parts = firstLine.trim().split(Regex("\\s+")) + if (parts.isEmpty() || parts.first() != "cpu") return null + val values = parts.drop(1).mapNotNull { it.toLongOrNull() } + if (values.size < 5) return null + val idle = values[3] + values[4] + val total = values.sum() + Pair(total, idle) + } catch (_: Throwable) { + null + } + } + private fun maybeStartHelperFromIntent(intent: Intent?) { if (intent?.getBooleanExtra(EXTRA_START_HELPER, false) == true) { Log.i(TAG, "mobilecode_start_helper intent received") @@ -139,6 +246,14 @@ class MainActivity : FlutterActivity() { } } + private fun captureDeepLink(intent: Intent?) { + val data = intent?.dataString + if (!data.isNullOrBlank()) { + Log.i(TAG, "Captured deep link: $data") + pendingDeepLink = data + } + } + companion object { private const val TAG = "MobileCodeMain" private const val EXTRA_START_HELPER = "mobilecode_start_helper" diff --git a/mobile_agent/tooling/MobileCodeHelperService.kt b/mobile_agent/tooling/MobileCodeHelperService.kt index 6253bae..60abf73 100644 --- a/mobile_agent/tooling/MobileCodeHelperService.kt +++ b/mobile_agent/tooling/MobileCodeHelperService.kt @@ -1,963 +1,965 @@ -package com.mobilecode.mobile_agent - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Intent -import android.os.Build -import android.os.IBinder -import android.util.Log -import org.json.JSONArray -import org.json.JSONObject -import java.io.BufferedReader -import java.io.File -import java.io.InputStreamReader -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.Socket -import java.net.URLDecoder -import java.nio.charset.StandardCharsets -import java.util.Collections -import java.util.Locale -import java.util.UUID -import java.util.concurrent.TimeUnit -import kotlin.concurrent.thread - -class MobileCodeHelperService : Service() { - private var serverSocket: ServerSocket? = null - private var serverThread: Thread? = null - - private val appDataRoot: File - get() = File(applicationInfo.dataDir).canonicalFile - - private val defaultWorkspaceRoot: File - get() = File(filesDir, "mobilecode_runtime").apply { mkdirs() }.canonicalFile - - private val taskStateFile: File - get() = File(defaultWorkspaceRoot, "helper_task_state.json") - - private val taskDatabaseFile: File - get() = File(defaultWorkspaceRoot, "helper_tasks.json") - - override fun onCreate() { - super.onCreate() - createNotificationChannel() - loadPersistedTask() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent?.action == ACTION_STOP) { - stopCurrentProcess() - stopSelf() - return START_NOT_STICKY - } - - startForeground(NOTIFICATION_ID, buildNotification("Helper daemon listening on 127.0.0.1:$PORT")) - startServer() - return START_STICKY - } - - override fun onDestroy() { - stopServer() - stopCurrentProcess() - running = false - super.onDestroy() - } - - override fun onBind(intent: Intent?): IBinder? = null - - private fun startServer() { - if (serverThread?.isAlive == true) return - - running = true - lastError = "" - serverThread = thread(name = "MobileCodeHelperServer", isDaemon = true) { - try { - val socket = ServerSocket().apply { - reuseAddress = true - bind(InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT)) - } - serverSocket = socket - recordLog("helper: listening on 127.0.0.1:$PORT") - Log.i(TAG, "Helper server listening on 127.0.0.1:$PORT") - - while (!socket.isClosed) { - val client = socket.accept() - thread(name = "MobileCodeHelperClient", isDaemon = true) { - client.use { handleClient(it) } - } - } - } catch (error: Throwable) { - if (running) { - lastError = error.message ?: error.javaClass.simpleName - recordLog("helper error: $lastError") - Log.e(TAG, "Helper server failed", error) - } - } - } - } - - private fun stopServer() { - running = false - try { - serverSocket?.close() - } catch (_: Throwable) { - } - serverSocket = null - } - - private fun handleClient(socket: Socket) { - try { - handleClientUnchecked(socket) - } catch (error: Throwable) { - lastError = error.message ?: error.javaClass.simpleName - Log.e(TAG, "Helper request failed", error) - try { - writeJson( - socket, - 500, - JSONObject() - .put("success", false) - .put("failureKind", "unknown") - .put("error", lastError) - ) - } catch (_: Throwable) { - } - } - } - - private fun handleClientUnchecked(socket: Socket) { - val reader = BufferedReader(InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)) - val requestLine = reader.readLine() ?: return - val parts = requestLine.split(" ") - if (parts.size < 2) { - writeJson(socket, 400, JSONObject().put("success", false).put("error", "Malformed request line")) - return - } - - val method = parts[0] - val rawPath = parts[1] - val path = rawPath.substringBefore("?") - val query = rawPath.substringAfter("?", "") - val headers = mutableMapOf() - while (true) { - val line = reader.readLine() ?: return - if (line.isEmpty()) break - val index = line.indexOf(":") - if (index > 0) { - headers[line.substring(0, index).trim().lowercase(Locale.US)] = line.substring(index + 1).trim() - } - } - - val body = readBody(reader, headers["content-length"]?.toIntOrNull() ?: 0) - try { - when { - method == "GET" && path == "/v1/health" -> writeJson(socket, 200, healthJson()) - method == "GET" && path == "/v1/tasks/current" -> writeJson(socket, 200, taskJson()) - method == "GET" && path == "/v1/tasks" -> writeJson(socket, 200, taskHistoryJson(queryLimit(query, 20))) - method == "GET" && path.startsWith("/v1/tasks/") && path.endsWith("/logs") -> { - val taskId = path.removePrefix("/v1/tasks/").removeSuffix("/logs").trim('/') - writeJson(socket, 200, taskLogsJson(taskId, queryLimit(query, 200))) - } - method == "POST" && path == "/v1/execute" -> handleExecute(socket, body) - method == "POST" && path == "/v1/execute/stream" -> handleExecuteStream(socket, body) - method == "POST" && path == "/v1/project/preflight" -> handleProjectPreflight(socket, body) - method == "POST" && path == "/v1/task/stop" -> { - writeJson(socket, 200, stopTask(null)) - } - method == "POST" && path.startsWith("/v1/tasks/") && path.endsWith("/stop") -> { - val taskId = decodePathSegment(path.removePrefix("/v1/tasks/").removeSuffix("/stop").trim('/')) - val result = stopTask(taskId) - writeJson(socket, if (result.optBoolean("success", false)) 200 else 404, result) - } - else -> writeJson(socket, 404, JSONObject().put("success", false).put("error", "Unknown endpoint")) - } - } catch (error: Throwable) { - writeJson(socket, 400, JSONObject().put("success", false).put("error", error.message ?: error.javaClass.simpleName)) - } - } - - private fun handleExecute(socket: Socket, body: String) { - val payload = if (body.isBlank()) JSONObject() else JSONObject(body) - val command = payload.optString("command", "") - val args = commandArgs(command) - val cwd = validateCwd(payload.optString("cwd", "")) - val timeoutMs = payload.optLong("timeoutMs", 120_000L) - val env = envFromJson(payload) - val taskId = UUID.randomUUID().toString() - beginTask(taskId, command, cwd) - recordLog(taskId, "task $taskId: $command") - - val started = System.nanoTime() - val process = ProcessBuilder(args) - .directory(cwd) - .redirectErrorStream(false) - .apply { environment().putAll(env) } - .start() - - registerTaskProcess(taskId, process) - val stdout = StringBuilder() - val stderr = StringBuilder() - val stdoutThread = thread(isDaemon = true) { - process.inputStream.bufferedReader().forEachLine { - stdout.appendLine(it) - recordLog(taskId, "stdout: $it") - } - } - val stderrThread = thread(isDaemon = true) { - process.errorStream.bufferedReader().forEachLine { - stderr.appendLine(it) - recordLog(taskId, "stderr: $it") - } - } - - val finished = process.waitFor(timeoutMs.coerceAtLeast(1), TimeUnit.MILLISECONDS) - if (!finished) { - process.destroyForcibly() - stderr.appendLine("Command timed out after ${timeoutMs}ms.") - recordLog(taskId, "task timed out after ${timeoutMs}ms") - } - stdoutThread.join(500) - stderrThread.join(500) - val exitCode = if (finished) process.exitValue() else 124 - val durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) - clearTaskProcess(taskId, process) - val finalStatus = when { - isTaskCancelled(taskId) -> "cancelled" - !finished -> "timedOut" - exitCode == 0 -> "succeeded" - else -> "failed" - } - val stderrText = stderr.toString().trim() - finishTask(taskId, finalStatus, exitCode, durationMs, if (stderrText.isBlank()) null else stderrText) - - writeJson( - socket, - 200, - JSONObject() - .put("command", command) - .put("stdout", stdout.toString()) - .put("stderr", stderr.toString()) - .put("exitCode", exitCode) - .put("durationMs", durationMs) - .put("taskId", taskId) - .put("failureKind", taskFailureKind(taskId)) - ) - } - - private fun handleExecuteStream(socket: Socket, body: String) { - val payload = if (body.isBlank()) JSONObject() else JSONObject(body) - val command = payload.optString("command", "") - val args = commandArgs(command) - val cwd = validateCwd(payload.optString("cwd", "")) - val env = envFromJson(payload) - val taskId = UUID.randomUUID().toString() - beginTask(taskId, command, cwd) - recordLog(taskId, "task $taskId stream: $command") - - val output = socket.getOutputStream() - writeHeaders(output, 200, "application/x-ndjson", null) - val started = System.nanoTime() - val process = ProcessBuilder(args) - .directory(cwd) - .redirectErrorStream(false) - .apply { environment().putAll(env) } - .start() - - registerTaskProcess(taskId, process) - val writeLock = Any() - val stdoutThread = thread(isDaemon = true) { - process.inputStream.bufferedReader().forEachLine { line -> - writeNdjson(output, writeLock, JSONObject().put("type", "stdout").put("data", line)) - recordLog(taskId, "stdout: $line") - } - } - val stderrThread = thread(isDaemon = true) { - process.errorStream.bufferedReader().forEachLine { line -> - writeNdjson(output, writeLock, JSONObject().put("type", "stderr").put("data", line)) - recordLog(taskId, "stderr: $line") - } - } - val exitCode = process.waitFor() - stdoutThread.join(500) - stderrThread.join(500) - val durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) - clearTaskProcess(taskId, process) - val finalStatus = if (isTaskCancelled(taskId)) { - "cancelled" - } else if (exitCode == 0) { - "succeeded" - } else { - "failed" - } - finishTask(taskId, finalStatus, exitCode, durationMs, null) - writeNdjson( - output, - writeLock, - JSONObject().put("type", "exit").put("exitCode", exitCode).put("durationMs", durationMs).put("taskId", taskId) - ) - } - - private fun handleProjectPreflight(socket: Socket, body: String) { - val payload = if (body.isBlank()) JSONObject() else JSONObject(body) - val cwd = validateCwd(payload.optString("cwd", "")) - writeJson( - socket, - 200, - JSONObject() - .put("success", true) - .put("cwd", cwd.path) - .put("detectedFiles", inspectProjectFiles(cwd)) - ) - } - - private fun healthJson(): JSONObject { - return JSONObject() - .put("name", "MobileCode Helper Service") - .put("available", true) - .put("ready", running) +package com.mobilecode.mobile_agent + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.Collections +import java.util.Locale +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class MobileCodeHelperService : Service() { + private var serverSocket: ServerSocket? = null + private var serverThread: Thread? = null + + private val appDataRoot: File + get() = File(applicationInfo.dataDir).canonicalFile + + private val defaultWorkspaceRoot: File + get() = File(filesDir, "mobilecode_runtime").apply { mkdirs() }.canonicalFile + + private val taskStateFile: File + get() = File(defaultWorkspaceRoot, "helper_task_state.json") + + private val taskDatabaseFile: File + get() = File(defaultWorkspaceRoot, "helper_tasks.json") + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + loadPersistedTask() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopCurrentProcess() + stopSelf() + return START_NOT_STICKY + } + + startForeground(NOTIFICATION_ID, buildNotification("Helper daemon listening on 127.0.0.1:$PORT")) + startServer() + return START_STICKY + } + + override fun onDestroy() { + stopServer() + stopCurrentProcess() + running = false + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startServer() { + if (serverThread?.isAlive == true) return + + running = true + lastError = "" + serverThread = thread(name = "MobileCodeHelperServer", isDaemon = true) { + try { + val socket = ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT)) + } + serverSocket = socket + recordLog("helper: listening on 127.0.0.1:$PORT") + Log.i(TAG, "Helper server listening on 127.0.0.1:$PORT") + + while (!socket.isClosed) { + val client = socket.accept() + thread(name = "MobileCodeHelperClient", isDaemon = true) { + client.use { handleClient(it) } + } + } + } catch (error: Throwable) { + if (running) { + lastError = error.message ?: error.javaClass.simpleName + recordLog("helper error: $lastError") + Log.e(TAG, "Helper server failed", error) + } + } + } + } + + private fun stopServer() { + running = false + try { + serverSocket?.close() + } catch (_: Throwable) { + } + serverSocket = null + } + + private fun handleClient(socket: Socket) { + try { + handleClientUnchecked(socket) + } catch (error: Throwable) { + lastError = error.message ?: error.javaClass.simpleName + Log.e(TAG, "Helper request failed", error) + try { + writeJson( + socket, + 500, + JSONObject() + .put("success", false) + .put("failureKind", "unknown") + .put("error", lastError) + ) + } catch (_: Throwable) { + } + } + } + + private fun handleClientUnchecked(socket: Socket) { + val reader = BufferedReader(InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)) + val requestLine = reader.readLine() ?: return + val parts = requestLine.split(" ") + if (parts.size < 2) { + writeJson(socket, 400, JSONObject().put("success", false).put("error", "Malformed request line")) + return + } + + val method = parts[0] + val rawPath = parts[1] + val path = rawPath.substringBefore("?") + val query = rawPath.substringAfter("?", "") + val headers = mutableMapOf() + while (true) { + val line = reader.readLine() ?: return + if (line.isEmpty()) break + val index = line.indexOf(":") + if (index > 0) { + headers[line.substring(0, index).trim().lowercase(Locale.US)] = line.substring(index + 1).trim() + } + } + + val body = readBody(reader, headers["content-length"]?.toIntOrNull() ?: 0) + try { + when { + method == "GET" && path == "/v1/health" -> writeJson(socket, 200, healthJson()) + method == "GET" && path == "/v1/tasks/current" -> writeJson(socket, 200, taskJson()) + method == "GET" && path == "/v1/tasks" -> writeJson(socket, 200, taskHistoryJson(queryLimit(query, 20))) + method == "GET" && path.startsWith("/v1/tasks/") && path.endsWith("/logs") -> { + val taskId = path.removePrefix("/v1/tasks/").removeSuffix("/logs").trim('/') + writeJson(socket, 200, taskLogsJson(taskId, queryLimit(query, 200))) + } + method == "POST" && path == "/v1/execute" -> handleExecute(socket, body) + method == "POST" && path == "/v1/execute/stream" -> handleExecuteStream(socket, body) + method == "POST" && path == "/v1/project/preflight" -> handleProjectPreflight(socket, body) + method == "POST" && path == "/v1/task/stop" -> { + writeJson(socket, 200, stopTask(null)) + } + method == "POST" && path.startsWith("/v1/tasks/") && path.endsWith("/stop") -> { + val taskId = decodePathSegment(path.removePrefix("/v1/tasks/").removeSuffix("/stop").trim('/')) + val result = stopTask(taskId) + writeJson(socket, if (result.optBoolean("success", false)) 200 else 404, result) + } + else -> writeJson(socket, 404, JSONObject().put("success", false).put("error", "Unknown endpoint")) + } + } catch (error: Throwable) { + writeJson(socket, 400, JSONObject().put("success", false).put("error", error.message ?: error.javaClass.simpleName)) + } + } + + private fun handleExecute(socket: Socket, body: String) { + val payload = if (body.isBlank()) JSONObject() else JSONObject(body) + val command = payload.optString("command", "") + val args = commandArgs(command) + val cwd = validateCwd(payload.optString("cwd", "")) + val timeoutMs = payload.optLong("timeoutMs", 120_000L) + val env = envFromJson(payload) + val taskId = UUID.randomUUID().toString() + beginTask(taskId, command, cwd) + recordLog(taskId, "task $taskId: $command") + + val started = System.nanoTime() + val process = ProcessBuilder(args) + .directory(cwd) + .redirectErrorStream(false) + .apply { environment().putAll(env) } + .start() + + registerTaskProcess(taskId, process) + val stdout = StringBuilder() + val stderr = StringBuilder() + val stdoutThread = thread(isDaemon = true) { + process.inputStream.bufferedReader().forEachLine { + stdout.appendLine(it) + recordLog(taskId, "stdout: $it") + } + } + val stderrThread = thread(isDaemon = true) { + process.errorStream.bufferedReader().forEachLine { + stderr.appendLine(it) + recordLog(taskId, "stderr: $it") + } + } + + val finished = process.waitFor(timeoutMs.coerceAtLeast(1), TimeUnit.MILLISECONDS) + if (!finished) { + process.destroyForcibly() + stderr.appendLine("Command timed out after ${timeoutMs}ms.") + recordLog(taskId, "task timed out after ${timeoutMs}ms") + } + stdoutThread.join(500) + stderrThread.join(500) + val exitCode = if (finished) process.exitValue() else 124 + val durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) + clearTaskProcess(taskId, process) + val finalStatus = when { + isTaskCancelled(taskId) -> "cancelled" + !finished -> "timedOut" + exitCode == 0 -> "succeeded" + else -> "failed" + } + val stderrText = stderr.toString().trim() + finishTask(taskId, finalStatus, exitCode, durationMs, if (stderrText.isBlank()) null else stderrText) + + writeJson( + socket, + 200, + JSONObject() + .put("command", command) + .put("stdout", stdout.toString()) + .put("stderr", stderr.toString()) + .put("exitCode", exitCode) + .put("durationMs", durationMs) + .put("taskId", taskId) + .put("failureKind", taskFailureKind(taskId)) + ) + } + + private fun handleExecuteStream(socket: Socket, body: String) { + val payload = if (body.isBlank()) JSONObject() else JSONObject(body) + val command = payload.optString("command", "") + val args = commandArgs(command) + val cwd = validateCwd(payload.optString("cwd", "")) + val env = envFromJson(payload) + val taskId = UUID.randomUUID().toString() + beginTask(taskId, command, cwd) + recordLog(taskId, "task $taskId stream: $command") + + val output = socket.getOutputStream() + writeHeaders(output, 200, "application/x-ndjson", null) + val started = System.nanoTime() + val process = ProcessBuilder(args) + .directory(cwd) + .redirectErrorStream(false) + .apply { environment().putAll(env) } + .start() + + registerTaskProcess(taskId, process) + val writeLock = Any() + val stdoutThread = thread(isDaemon = true) { + process.inputStream.bufferedReader().forEachLine { line -> + writeNdjson(output, writeLock, JSONObject().put("type", "stdout").put("data", line)) + recordLog(taskId, "stdout: $line") + } + } + val stderrThread = thread(isDaemon = true) { + process.errorStream.bufferedReader().forEachLine { line -> + writeNdjson(output, writeLock, JSONObject().put("type", "stderr").put("data", line)) + recordLog(taskId, "stderr: $line") + } + } + val exitCode = process.waitFor() + stdoutThread.join(500) + stderrThread.join(500) + val durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) + clearTaskProcess(taskId, process) + val finalStatus = if (isTaskCancelled(taskId)) { + "cancelled" + } else if (exitCode == 0) { + "succeeded" + } else { + "failed" + } + finishTask(taskId, finalStatus, exitCode, durationMs, null) + writeNdjson( + output, + writeLock, + JSONObject().put("type", "exit").put("exitCode", exitCode).put("durationMs", durationMs).put("taskId", taskId) + ) + } + + private fun handleProjectPreflight(socket: Socket, body: String) { + val payload = if (body.isBlank()) JSONObject() else JSONObject(body) + val cwd = validateCwd(payload.optString("cwd", "")) + writeJson( + socket, + 200, + JSONObject() + .put("success", true) + .put("cwd", cwd.path) + .put("detectedFiles", inspectProjectFiles(cwd)) + ) + } + + private fun healthJson(): JSONObject { + return JSONObject() + .put("name", "MobileCode Helper Service") + .put("available", true) + .put("ready", running) .put("status", if (lastError.isBlank()) "Android foreground service is running." else lastError) .put("protocolVersion", 1) + .put("runtimeKind", "helperApk") + .put("termux", false) .put("authRequired", false) - .put( - "capabilities", - JSONObject() - .put("shell", true) - .put("git", hasBinary("git")) - .put("node", hasBinary("node") || hasBinary("npm")) - .put("python", hasBinary("python") || hasBinary("python3")) - .put("flutter", hasBinary("flutter")) - .put("androidBuild", hasBinary("flutter") && hasBinary("java")) - .put("pty", false) - .put("backgroundService", true) - .put("webViewPreview", true) - .put("cloudBuild", false) - ) - .put( - "taskRegistry", - JSONObject() - .put("runningCount", runningTaskCount()) - .put("maxTasks", MAX_TASKS) - ) - .put("missingDependencies", JSONArray()) - .put("recoveryActions", JSONArray()) - } - - private fun taskJson(): JSONObject { - val snapshot = synchronized(taskLock) { - syncCurrentTaskLocked() - taskSnapshotJson() - } - return JSONObject() - .put("running", snapshot.optString("status") == "running") - .put("runningCount", runningTaskCount()) - .put("taskId", snapshot.optString("id", "")) - .put("command", snapshot.optString("command", "")) - .put("logs", snapshot.optJSONArray("logs") ?: JSONArray()) - .put("task", snapshot) - } - - private fun taskHistoryJson(limit: Int): JSONObject { - val tasks = JSONArray() - synchronized(taskLock) { - taskHistory.take(limit.coerceAtLeast(1)).forEach { tasks.put(JSONObject(it.toString())) } - } - return JSONObject() - .put("tasks", tasks) - .put("count", tasks.length()) - } - - private fun taskLogsJson(taskId: String, limit: Int): JSONObject { - val logs = JSONArray() - synchronized(taskLock) { - val task = taskHistory.firstOrNull { it.optString("id") == taskId || it.optString("taskId") == taskId } - val source = task?.optJSONArray("logs") ?: JSONArray() - val start = (source.length() - limit.coerceAtLeast(1)).coerceAtLeast(0) - for (index in start until source.length()) { - logs.put(source.optString(index)) - } - } - return JSONObject() - .put("taskId", taskId) - .put("logs", logs) - } - - private fun findTaskLocked(taskId: String): JSONObject? { - return taskHistory.firstOrNull { it.optString("id") == taskId || it.optString("taskId") == taskId } - } - - private fun taskIdentity(task: JSONObject): String = task.optString("id", task.optString("taskId", "")) - - private fun appendLogToTaskLocked(task: JSONObject, line: String) { - val logs = task.optJSONArray("logs") ?: JSONArray().also { task.put("logs", it) } - logs.put(line) - while (logs.length() > MAX_LOG_LINES) { - logs.remove(0) - } - } - - private fun syncCurrentTaskLocked() { - val runningTask = taskHistory.firstOrNull { task -> - val taskId = taskIdentity(task) - task.optString("status") == "running" && taskProcesses.containsKey(taskId) - } - val nextTask = runningTask ?: taskHistory.firstOrNull() - if (nextTask == null) { - currentTaskId = "" - currentCommand = "" - currentTaskCwd = "" - currentTaskStatus = "unknown" - currentTaskStartedAtMs = 0L - currentTaskFinishedAtMs = 0L - currentTaskExitCode = null - currentTaskDurationMs = 0L - currentTaskError = "" - currentTaskFailureKind = "none" - currentProcess = null - synchronized(recentLogs) { recentLogs.clear() } - return - } - applyTaskJsonLocked(nextTask) - currentProcess = taskProcesses[currentTaskId] - } - - private fun isTaskCancelled(taskId: String): Boolean { - synchronized(taskLock) { - return findTaskLocked(taskId)?.optString("status") == "cancelled" - } - } - - private fun taskFailureKind(taskId: String): String { - synchronized(taskLock) { - return findTaskLocked(taskId)?.optString("failureKind", "none") ?: "none" - } - } - - private fun runningTaskCount(): Int { - synchronized(taskLock) { - return taskHistory.count { task -> - task.optString("status") == "running" && taskProcesses.containsKey(taskIdentity(task)) - } - } - } - - private fun beginTask(taskId: String, command: String, cwd: File) { - synchronized(taskLock) { - currentTaskId = taskId - currentCommand = command - currentTaskCwd = cwd.path - currentTaskStatus = "running" - currentTaskStartedAtMs = System.currentTimeMillis() - currentTaskFinishedAtMs = 0L - currentTaskExitCode = null - currentTaskDurationMs = 0L - currentTaskError = "" - currentTaskFailureKind = "none" - synchronized(recentLogs) { - recentLogs.clear() - } - persistTaskStateLocked() - } - } - - private fun registerTaskProcess(taskId: String, process: Process) { - synchronized(taskLock) { - taskProcesses[taskId] = process - if (taskId == currentTaskId) currentProcess = process - persistTaskStateLocked() - } - } - - private fun clearTaskProcess(taskId: String, process: Process) { - synchronized(taskLock) { - if (taskProcesses[taskId] == process) { - taskProcesses.remove(taskId) - } - if (currentProcess == process) { - currentProcess = null - } - syncCurrentTaskLocked() - persistTaskStateLocked() - } - } - - private fun finishTask(taskId: String, status: String, exitCode: Int, durationMs: Long, error: String?) { - synchronized(taskLock) { - val task = findTaskLocked(taskId) ?: return - val finalStatus = if (task.optString("status") == "cancelled") "cancelled" else status - val finalError = if (finalStatus == "cancelled" && error.isNullOrBlank() && task.optString("error").isNotBlank()) { - task.optString("error") - } else { - error ?: "" - } - task.put("status", finalStatus) - task.put("finishedAtMs", System.currentTimeMillis()) - task.put("exitCode", exitCode) - task.put("durationMs", durationMs) - if (finalError.isNotBlank()) task.put("error", finalError) - task.put("failureKind", classifyFailure(finalStatus, exitCode, finalError.ifBlank { null })) - syncCurrentTaskLocked() - persistTaskStateLocked() - } - } - - private fun recordLog(taskId: String, line: String) { - synchronized(taskLock) { - val task = findTaskLocked(taskId) ?: return - appendLogToTaskLocked(task, line) - if (taskId == currentTaskId) appendLog(line) - persistTaskStateLocked() - } - } - - private fun recordLog(line: String) { - synchronized(taskLock) { - appendLog(line) - persistTaskStateLocked() - } - } - - private fun loadPersistedTask() { - synchronized(taskLock) { - try { - taskHistory.clear() - val database = taskDatabaseFile - if (database.exists()) { - val decoded = JSONObject(database.readText()).optJSONArray("tasks") ?: JSONArray() - for (index in 0 until decoded.length()) { - val task = decoded.optJSONObject(index) ?: continue - taskHistory.add(task) - } - } - if (taskHistory.isEmpty() && taskStateFile.exists()) { - taskHistory.add(JSONObject(taskStateFile.readText())) - } - val now = System.currentTimeMillis() - taskHistory.forEach { task -> - if (task.optString("status") == "running") { - task.put("status", "lost") - task.put("finishedAtMs", now) - task.put("error", "Helper service restarted before this task completed.") - task.put("failureKind", "runtimeLost") - appendLogToTaskLocked(task, "task lost after helper restart") - } - } - syncCurrentTaskLocked() - persistTaskStateLocked() - } catch (error: Throwable) { - lastError = "failed to load task state: ${error.message ?: error.javaClass.simpleName}" - } - } - } - - private fun persistTaskStateLocked() { - if (currentTaskId.isBlank() && currentCommand.isBlank() && taskHistory.isEmpty()) return - try { - upsertTaskHistoryLocked() - syncCurrentTaskLocked() - val file = taskStateFile - file.parentFile?.mkdirs() - file.writeText(taskSnapshotJson().toString()) - val tasks = JSONArray() - taskHistory.take(MAX_TASKS).forEach { tasks.put(it) } - taskDatabaseFile.writeText(JSONObject().put("tasks", tasks).toString()) - } catch (error: Throwable) { - lastError = "failed to persist task state: ${error.message ?: error.javaClass.simpleName}" - } - } - - private fun applyTaskJsonLocked(json: JSONObject) { - currentTaskId = json.optString("id", "") - currentCommand = json.optString("command", "") - currentTaskCwd = json.optString("cwd", "") - currentTaskStatus = json.optString("status", "unknown") - currentTaskStartedAtMs = json.optLong("startedAtMs", 0L) - currentTaskFinishedAtMs = json.optLong("finishedAtMs", 0L) - currentTaskExitCode = if (json.has("exitCode") && !json.isNull("exitCode")) json.optInt("exitCode") else null - currentTaskDurationMs = json.optLong("durationMs", 0L) - currentTaskError = json.optString("error", "") - currentTaskFailureKind = json.optString("failureKind", classifyFailure(currentTaskStatus, currentTaskExitCode, currentTaskError)) - synchronized(recentLogs) { - recentLogs.clear() - val logs = json.optJSONArray("logs") ?: JSONArray() - for (index in 0 until logs.length()) { - recentLogs.add(logs.optString(index)) - } - } - } - - private fun upsertTaskHistoryLocked() { - val snapshot = taskSnapshotJson() - val existing = taskHistory.indexOfFirst { it.optString("id") == currentTaskId || it.optString("taskId") == currentTaskId } - if (existing >= 0) { - taskHistory[existing] = snapshot - } else { - taskHistory.add(0, snapshot) - } - while (taskHistory.size > MAX_TASKS) { - taskHistory.removeAt(taskHistory.size - 1) - } - } - - private fun taskSnapshotJson(): JSONObject { - val logs = JSONArray() - synchronized(recentLogs) { - recentLogs.forEach { logs.put(it) } - } - val json = JSONObject() - .put("id", currentTaskId) - .put("taskId", currentTaskId) - .put("command", currentCommand) - .put("cwd", currentTaskCwd) - .put("status", if (currentTaskStatus.isBlank()) "unknown" else currentTaskStatus) - .put("startedAtMs", currentTaskStartedAtMs) - .put("finishedAtMs", currentTaskFinishedAtMs) - .put("durationMs", currentTaskDurationMs) - .put("logs", logs) - .put("provider", "mobileCodeHelper") - .put("failureKind", currentTaskFailureKind) - if (currentTaskExitCode != null) json.put("exitCode", currentTaskExitCode) - if (currentTaskError.isNotBlank()) json.put("error", currentTaskError) - return json - } - - private fun validateCwd(rawCwd: String): File { - val cwd = if (rawCwd.isBlank()) defaultWorkspaceRoot else File(rawCwd).canonicalFile - val rootPath = appDataRoot.path - if (cwd.path == rootPath || cwd.path.startsWith(rootPath + File.separator)) { - if (!cwd.exists()) cwd.mkdirs() - return cwd - } - throw IllegalArgumentException("cwd is outside MobileCode app data: ${cwd.path}") - } - - private fun commandArgs(command: String): List { - if (command.isBlank()) throw IllegalArgumentException("command cannot be empty") - val lowered = command.lowercase(Locale.US) - dangerousFragments.forEach { fragment -> - if (lowered.contains(fragment)) throw IllegalArgumentException("dangerous command fragment blocked: $fragment") - } - val parts = splitCommand(command) - if (parts.isEmpty()) throw IllegalArgumentException("command cannot be empty") - var executable = File(parts[0]).name.lowercase(Locale.US) - if (executable.endsWith(".exe")) executable = executable.removeSuffix(".exe") - if (!allowedCommands.contains(executable)) { - throw IllegalArgumentException("command is not allowed: $executable") - } - return parts - } - - private fun splitCommand(command: String): List { - val result = mutableListOf() - val current = StringBuilder() - var quote: Char? = null - var escaped = false - for (char in command) { - when { - escaped -> { - current.append(char) - escaped = false - } - char == '\\' -> escaped = true - quote != null && char == quote -> quote = null - quote == null && (char == '"' || char == '\'') -> quote = char - quote == null && char.isWhitespace() -> { - if (current.isNotEmpty()) { - result.add(current.toString()) - current.clear() - } - } - else -> current.append(char) - } - } - if (quote != null) throw IllegalArgumentException("unterminated quote in command") - if (current.isNotEmpty()) result.add(current.toString()) - return result - } - - private fun envFromJson(payload: JSONObject): Map { - val env = payload.optJSONObject("env") ?: return emptyMap() - val result = mutableMapOf() - val keys = env.keys() - while (keys.hasNext()) { - val key = keys.next() - result[key] = env.optString(key) - } - return result - } - - private fun inspectProjectFiles(cwd: File): JSONArray { - val files = JSONArray() - cwd.walkTopDown() - .maxDepth(2) - .filter { projectMarkers.contains(it.name) } - .map { file -> - val relative = cwd.toPath().relativize(file.toPath()).toString().replace(File.separatorChar, '/') - "./$relative" - } - .distinct() - .sorted() - .forEach { files.put(it) } - return files - } - - private fun hasBinary(name: String): Boolean { - val path = System.getenv("PATH") ?: return false - return path.split(File.pathSeparator).any { directory -> - val candidate = File(directory, name) - candidate.exists() && candidate.canExecute() - } - } - - private fun stopCurrentProcess(): Boolean = stopTask(null).optBoolean("stopped", false) - - private fun stopTask(taskId: String?): JSONObject { - val requestedTaskId = taskId?.trim().orEmpty() - var stoppingTaskId = "" - val process = synchronized(taskLock) { - val task = if (requestedTaskId.isNotEmpty()) { - findTaskLocked(requestedTaskId) ?: return JSONObject() - .put("success", false) - .put("stopped", false) - .put("taskId", requestedTaskId) - .put("failureKind", "unknown") - .put("error", "Task not found: $requestedTaskId") - } else { - taskHistory.firstOrNull { candidate -> - candidate.optString("status") == "running" && taskProcesses.containsKey(taskIdentity(candidate)) - } ?: return JSONObject().put("success", true).put("stopped", false) - } - stoppingTaskId = taskIdentity(task) - val process = taskProcesses.remove(stoppingTaskId) - if (process == null) { - return JSONObject() - .put("success", true) - .put("stopped", false) - .put("taskId", stoppingTaskId) - .put("task", JSONObject(task.toString())) - } - appendLogToTaskLocked(task, "task stopped") - val finishedAtMs = System.currentTimeMillis() - val startedAtMs = task.optLong("startedAtMs", finishedAtMs) - task.put("status", "cancelled") - task.put("finishedAtMs", finishedAtMs) - task.put("durationMs", (finishedAtMs - startedAtMs).coerceAtLeast(0L)) - task.put("error", "Task cancelled by MobileCode.") - task.put("failureKind", "cancelled") - if (currentProcess == process) currentProcess = null - syncCurrentTaskLocked() - persistTaskStateLocked() - process - } - - return try { - process.destroy() - if (!process.waitFor(2, TimeUnit.SECONDS)) { - process.destroyForcibly() - } - synchronized(taskLock) { - val task = findTaskLocked(stoppingTaskId) - JSONObject() - .put("success", true) - .put("stopped", true) - .put("taskId", stoppingTaskId) - .put("task", if (task == null) JSONObject() else JSONObject(task.toString())) - } - } catch (error: Throwable) { - JSONObject() - .put("success", false) - .put("stopped", false) - .put("taskId", requestedTaskId.ifEmpty { stoppingTaskId }) - .put("failureKind", "unknown") - .put("error", error.message ?: error.javaClass.simpleName) - } - } - - private fun decodePathSegment(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8.name()) - - private fun queryLimit(query: String, fallback: Int): Int { - return query.split("&") - .firstOrNull { it.substringBefore("=") == "limit" } - ?.substringAfter("=", "") - ?.toIntOrNull() - ?.coerceIn(1, MAX_TASKS) - ?: fallback - } - - private fun readBody(reader: BufferedReader, contentLength: Int): String { - if (contentLength <= 0) return "" - val buffer = CharArray(contentLength) - var read = 0 - while (read < contentLength) { - val count = reader.read(buffer, read, contentLength - read) - if (count < 0) break - read += count - } - return String(buffer, 0, read) - } - - private fun writeJson(socket: Socket, statusCode: Int, payload: JSONObject) { - val body = payload.toString() - val output = socket.getOutputStream() - writeHeaders(output, statusCode, "application/json", body.toByteArray(StandardCharsets.UTF_8).size) - output.write(body.toByteArray(StandardCharsets.UTF_8)) - output.flush() - } - - private fun writeHeaders(output: java.io.OutputStream, statusCode: Int, contentType: String, contentLength: Int?) { - val reason = when (statusCode) { - 200 -> "OK" - 400 -> "Bad Request" - 404 -> "Not Found" - else -> "OK" - } - val headers = buildString { - append("HTTP/1.1 $statusCode $reason\r\n") - append("Content-Type: $contentType\r\n") - append("Connection: close\r\n") - if (contentLength != null) append("Content-Length: $contentLength\r\n") - append("\r\n") - } - output.write(headers.toByteArray(StandardCharsets.UTF_8)) - output.flush() - } - - private fun writeNdjson(output: java.io.OutputStream, lock: Any, payload: JSONObject) { - synchronized(lock) { - output.write((payload.toString() + "\n").toByteArray(StandardCharsets.UTF_8)) - output.flush() - } - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "MobileCode Helper Runtime", - NotificationManager.IMPORTANCE_LOW - ) - getSystemService(NotificationManager::class.java).createNotificationChannel(channel) - } - } - - private fun buildNotification(text: String): Notification { - val launchIntent = packageManager.getLaunchIntentForPackage(packageName) - val flags = PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 - val pendingIntent = launchIntent?.let { PendingIntent.getActivity(this, 0, it, flags) } - val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Notification.Builder(this, CHANNEL_ID) - } else { - @Suppress("DEPRECATION") - Notification.Builder(this) - } - builder - .setSmallIcon(android.R.drawable.stat_sys_upload_done) - .setContentTitle("MobileCode Helper") - .setContentText(text) - .setOngoing(true) - if (pendingIntent != null) builder.setContentIntent(pendingIntent) - return builder.build() - } - - companion object { - const val ACTION_STOP = "com.mobilecode.mobile_agent.action.STOP_HELPER" - private const val CHANNEL_ID = "mobilecode_helper_runtime" - private const val TAG = "MobileCodeHelper" - private const val NOTIFICATION_ID = 8765 - private const val PORT = 8765 - private const val MAX_LOG_LINES = 200 - private const val MAX_TASKS = 50 - - @Volatile private var running = false - @Volatile private var lastError = "" - @Volatile private var currentProcess: Process? = null - @Volatile private var currentTaskId = "" - @Volatile private var currentCommand = "" - @Volatile private var currentTaskCwd = "" - @Volatile private var currentTaskStatus = "unknown" - @Volatile private var currentTaskStartedAtMs = 0L - @Volatile private var currentTaskFinishedAtMs = 0L - @Volatile private var currentTaskExitCode: Int? = null - @Volatile private var currentTaskDurationMs = 0L - @Volatile private var currentTaskError = "" - @Volatile private var currentTaskFailureKind = "none" - private val taskLock = Any() - private val recentLogs = Collections.synchronizedList(mutableListOf()) - private val taskHistory = Collections.synchronizedList(mutableListOf()) - private val taskProcesses = Collections.synchronizedMap(mutableMapOf()) - - fun status(): Map { - return mapOf( - "running" to running, - "port" to PORT, - "lastError" to lastError, - "taskId" to currentTaskId, - "command" to currentCommand, - "taskRunning" to (taskProcesses.isNotEmpty()), - "taskRunningCount" to taskProcesses.size, - "taskStatus" to currentTaskStatus, - "taskStartedAtMs" to currentTaskStartedAtMs, - "taskFinishedAtMs" to currentTaskFinishedAtMs, - "taskFailureKind" to currentTaskFailureKind, - "taskHistoryCount" to taskHistory.size - ) - } - - private fun appendLog(line: String) { - synchronized(recentLogs) { - recentLogs.add(line) - while (recentLogs.size > MAX_LOG_LINES) { - recentLogs.removeAt(0) - } - } - } - - private val allowedCommands = setOf( - "pwd", "ls", "cat", "head", "tail", "grep", "find", "wc", "sort", "uniq", - "sed", "awk", "mkdir", "touch", "cp", "mv", "rm", "git", "node", "npm", - "npx", "python", "python3", "pip", "pip3", "dart", "flutter", "java", - "javac", "gradle", "chmod", "tar", "zip", "unzip", "curl", "wget", - "which", "whoami", "date", "echo" - ) - - private val dangerousFragments = listOf( - "rm -rf /", - "rm -rf /*", - "mkfs", - "dd if=", - ":(){:|:&};:", - "chmod -r 777 /", - "chown -r", - "reboot", - "shutdown", - "poweroff", - "su " - ) - - private val projectMarkers = setOf( - "package.json", - "pubspec.yaml", - "requirements.txt", - "pyproject.toml", - ".git" - ) - - private fun classifyFailure(status: String, exitCode: Int?, error: String?): String { - val message = (error ?: "").lowercase(Locale.US) - return when { - status == "succeeded" -> "none" - status == "cancelled" -> "cancelled" - status == "timedOut" -> "timeout" - status == "lost" -> "runtimeLost" - message.contains("outside mobilecode app data") -> "cwdOutsideWorkspace" - message.contains("not allowed") || message.contains("dangerous command") -> "commandBlocked" - listOf("command not found", "no such file or directory", "not found", "cannot find", "is not recognized").any { message.contains(it) } -> "dependencyMissing" - exitCode != null && exitCode != 0 -> "processFailed" - else -> "unknown" - } - } - } -} + .put( + "capabilities", + JSONObject() + .put("shell", true) + .put("git", hasBinary("git")) + .put("node", hasBinary("node") || hasBinary("npm")) + .put("python", hasBinary("python") || hasBinary("python3")) + .put("flutter", hasBinary("flutter")) + .put("androidBuild", hasBinary("flutter") && hasBinary("java")) + .put("pty", false) + .put("backgroundService", true) + .put("webViewPreview", true) + .put("cloudBuild", false) + ) + .put( + "taskRegistry", + JSONObject() + .put("runningCount", runningTaskCount()) + .put("maxTasks", MAX_TASKS) + ) + .put("missingDependencies", JSONArray()) + .put("recoveryActions", JSONArray()) + } + + private fun taskJson(): JSONObject { + val snapshot = synchronized(taskLock) { + syncCurrentTaskLocked() + taskSnapshotJson() + } + return JSONObject() + .put("running", snapshot.optString("status") == "running") + .put("runningCount", runningTaskCount()) + .put("taskId", snapshot.optString("id", "")) + .put("command", snapshot.optString("command", "")) + .put("logs", snapshot.optJSONArray("logs") ?: JSONArray()) + .put("task", snapshot) + } + + private fun taskHistoryJson(limit: Int): JSONObject { + val tasks = JSONArray() + synchronized(taskLock) { + taskHistory.take(limit.coerceAtLeast(1)).forEach { tasks.put(JSONObject(it.toString())) } + } + return JSONObject() + .put("tasks", tasks) + .put("count", tasks.length()) + } + + private fun taskLogsJson(taskId: String, limit: Int): JSONObject { + val logs = JSONArray() + synchronized(taskLock) { + val task = taskHistory.firstOrNull { it.optString("id") == taskId || it.optString("taskId") == taskId } + val source = task?.optJSONArray("logs") ?: JSONArray() + val start = (source.length() - limit.coerceAtLeast(1)).coerceAtLeast(0) + for (index in start until source.length()) { + logs.put(source.optString(index)) + } + } + return JSONObject() + .put("taskId", taskId) + .put("logs", logs) + } + + private fun findTaskLocked(taskId: String): JSONObject? { + return taskHistory.firstOrNull { it.optString("id") == taskId || it.optString("taskId") == taskId } + } + + private fun taskIdentity(task: JSONObject): String = task.optString("id", task.optString("taskId", "")) + + private fun appendLogToTaskLocked(task: JSONObject, line: String) { + val logs = task.optJSONArray("logs") ?: JSONArray().also { task.put("logs", it) } + logs.put(line) + while (logs.length() > MAX_LOG_LINES) { + logs.remove(0) + } + } + + private fun syncCurrentTaskLocked() { + val runningTask = taskHistory.firstOrNull { task -> + val taskId = taskIdentity(task) + task.optString("status") == "running" && taskProcesses.containsKey(taskId) + } + val nextTask = runningTask ?: taskHistory.firstOrNull() + if (nextTask == null) { + currentTaskId = "" + currentCommand = "" + currentTaskCwd = "" + currentTaskStatus = "unknown" + currentTaskStartedAtMs = 0L + currentTaskFinishedAtMs = 0L + currentTaskExitCode = null + currentTaskDurationMs = 0L + currentTaskError = "" + currentTaskFailureKind = "none" + currentProcess = null + synchronized(recentLogs) { recentLogs.clear() } + return + } + applyTaskJsonLocked(nextTask) + currentProcess = taskProcesses[currentTaskId] + } + + private fun isTaskCancelled(taskId: String): Boolean { + synchronized(taskLock) { + return findTaskLocked(taskId)?.optString("status") == "cancelled" + } + } + + private fun taskFailureKind(taskId: String): String { + synchronized(taskLock) { + return findTaskLocked(taskId)?.optString("failureKind", "none") ?: "none" + } + } + + private fun runningTaskCount(): Int { + synchronized(taskLock) { + return taskHistory.count { task -> + task.optString("status") == "running" && taskProcesses.containsKey(taskIdentity(task)) + } + } + } + + private fun beginTask(taskId: String, command: String, cwd: File) { + synchronized(taskLock) { + currentTaskId = taskId + currentCommand = command + currentTaskCwd = cwd.path + currentTaskStatus = "running" + currentTaskStartedAtMs = System.currentTimeMillis() + currentTaskFinishedAtMs = 0L + currentTaskExitCode = null + currentTaskDurationMs = 0L + currentTaskError = "" + currentTaskFailureKind = "none" + synchronized(recentLogs) { + recentLogs.clear() + } + persistTaskStateLocked() + } + } + + private fun registerTaskProcess(taskId: String, process: Process) { + synchronized(taskLock) { + taskProcesses[taskId] = process + if (taskId == currentTaskId) currentProcess = process + persistTaskStateLocked() + } + } + + private fun clearTaskProcess(taskId: String, process: Process) { + synchronized(taskLock) { + if (taskProcesses[taskId] == process) { + taskProcesses.remove(taskId) + } + if (currentProcess == process) { + currentProcess = null + } + syncCurrentTaskLocked() + persistTaskStateLocked() + } + } + + private fun finishTask(taskId: String, status: String, exitCode: Int, durationMs: Long, error: String?) { + synchronized(taskLock) { + val task = findTaskLocked(taskId) ?: return + val finalStatus = if (task.optString("status") == "cancelled") "cancelled" else status + val finalError = if (finalStatus == "cancelled" && error.isNullOrBlank() && task.optString("error").isNotBlank()) { + task.optString("error") + } else { + error ?: "" + } + task.put("status", finalStatus) + task.put("finishedAtMs", System.currentTimeMillis()) + task.put("exitCode", exitCode) + task.put("durationMs", durationMs) + if (finalError.isNotBlank()) task.put("error", finalError) + task.put("failureKind", classifyFailure(finalStatus, exitCode, finalError.ifBlank { null })) + syncCurrentTaskLocked() + persistTaskStateLocked() + } + } + + private fun recordLog(taskId: String, line: String) { + synchronized(taskLock) { + val task = findTaskLocked(taskId) ?: return + appendLogToTaskLocked(task, line) + if (taskId == currentTaskId) appendLog(line) + persistTaskStateLocked() + } + } + + private fun recordLog(line: String) { + synchronized(taskLock) { + appendLog(line) + persistTaskStateLocked() + } + } + + private fun loadPersistedTask() { + synchronized(taskLock) { + try { + taskHistory.clear() + val database = taskDatabaseFile + if (database.exists()) { + val decoded = JSONObject(database.readText()).optJSONArray("tasks") ?: JSONArray() + for (index in 0 until decoded.length()) { + val task = decoded.optJSONObject(index) ?: continue + taskHistory.add(task) + } + } + if (taskHistory.isEmpty() && taskStateFile.exists()) { + taskHistory.add(JSONObject(taskStateFile.readText())) + } + val now = System.currentTimeMillis() + taskHistory.forEach { task -> + if (task.optString("status") == "running") { + task.put("status", "lost") + task.put("finishedAtMs", now) + task.put("error", "Helper service restarted before this task completed.") + task.put("failureKind", "runtimeLost") + appendLogToTaskLocked(task, "task lost after helper restart") + } + } + syncCurrentTaskLocked() + persistTaskStateLocked() + } catch (error: Throwable) { + lastError = "failed to load task state: ${error.message ?: error.javaClass.simpleName}" + } + } + } + + private fun persistTaskStateLocked() { + if (currentTaskId.isBlank() && currentCommand.isBlank() && taskHistory.isEmpty()) return + try { + upsertTaskHistoryLocked() + syncCurrentTaskLocked() + val file = taskStateFile + file.parentFile?.mkdirs() + file.writeText(taskSnapshotJson().toString()) + val tasks = JSONArray() + taskHistory.take(MAX_TASKS).forEach { tasks.put(it) } + taskDatabaseFile.writeText(JSONObject().put("tasks", tasks).toString()) + } catch (error: Throwable) { + lastError = "failed to persist task state: ${error.message ?: error.javaClass.simpleName}" + } + } + + private fun applyTaskJsonLocked(json: JSONObject) { + currentTaskId = json.optString("id", "") + currentCommand = json.optString("command", "") + currentTaskCwd = json.optString("cwd", "") + currentTaskStatus = json.optString("status", "unknown") + currentTaskStartedAtMs = json.optLong("startedAtMs", 0L) + currentTaskFinishedAtMs = json.optLong("finishedAtMs", 0L) + currentTaskExitCode = if (json.has("exitCode") && !json.isNull("exitCode")) json.optInt("exitCode") else null + currentTaskDurationMs = json.optLong("durationMs", 0L) + currentTaskError = json.optString("error", "") + currentTaskFailureKind = json.optString("failureKind", classifyFailure(currentTaskStatus, currentTaskExitCode, currentTaskError)) + synchronized(recentLogs) { + recentLogs.clear() + val logs = json.optJSONArray("logs") ?: JSONArray() + for (index in 0 until logs.length()) { + recentLogs.add(logs.optString(index)) + } + } + } + + private fun upsertTaskHistoryLocked() { + val snapshot = taskSnapshotJson() + val existing = taskHistory.indexOfFirst { it.optString("id") == currentTaskId || it.optString("taskId") == currentTaskId } + if (existing >= 0) { + taskHistory[existing] = snapshot + } else { + taskHistory.add(0, snapshot) + } + while (taskHistory.size > MAX_TASKS) { + taskHistory.removeAt(taskHistory.size - 1) + } + } + + private fun taskSnapshotJson(): JSONObject { + val logs = JSONArray() + synchronized(recentLogs) { + recentLogs.forEach { logs.put(it) } + } + val json = JSONObject() + .put("id", currentTaskId) + .put("taskId", currentTaskId) + .put("command", currentCommand) + .put("cwd", currentTaskCwd) + .put("status", if (currentTaskStatus.isBlank()) "unknown" else currentTaskStatus) + .put("startedAtMs", currentTaskStartedAtMs) + .put("finishedAtMs", currentTaskFinishedAtMs) + .put("durationMs", currentTaskDurationMs) + .put("logs", logs) + .put("provider", "mobileCodeHelper") + .put("failureKind", currentTaskFailureKind) + if (currentTaskExitCode != null) json.put("exitCode", currentTaskExitCode) + if (currentTaskError.isNotBlank()) json.put("error", currentTaskError) + return json + } + + private fun validateCwd(rawCwd: String): File { + val cwd = if (rawCwd.isBlank()) defaultWorkspaceRoot else File(rawCwd).canonicalFile + val rootPath = appDataRoot.path + if (cwd.path == rootPath || cwd.path.startsWith(rootPath + File.separator)) { + if (!cwd.exists()) cwd.mkdirs() + return cwd + } + throw IllegalArgumentException("cwd is outside MobileCode app data: ${cwd.path}") + } + + private fun commandArgs(command: String): List { + if (command.isBlank()) throw IllegalArgumentException("command cannot be empty") + val lowered = command.lowercase(Locale.US) + dangerousFragments.forEach { fragment -> + if (lowered.contains(fragment)) throw IllegalArgumentException("dangerous command fragment blocked: $fragment") + } + val parts = splitCommand(command) + if (parts.isEmpty()) throw IllegalArgumentException("command cannot be empty") + var executable = File(parts[0]).name.lowercase(Locale.US) + if (executable.endsWith(".exe")) executable = executable.removeSuffix(".exe") + if (!allowedCommands.contains(executable)) { + throw IllegalArgumentException("command is not allowed: $executable") + } + return parts + } + + private fun splitCommand(command: String): List { + val result = mutableListOf() + val current = StringBuilder() + var quote: Char? = null + var escaped = false + for (char in command) { + when { + escaped -> { + current.append(char) + escaped = false + } + char == '\\' -> escaped = true + quote != null && char == quote -> quote = null + quote == null && (char == '"' || char == '\'') -> quote = char + quote == null && char.isWhitespace() -> { + if (current.isNotEmpty()) { + result.add(current.toString()) + current.clear() + } + } + else -> current.append(char) + } + } + if (quote != null) throw IllegalArgumentException("unterminated quote in command") + if (current.isNotEmpty()) result.add(current.toString()) + return result + } + + private fun envFromJson(payload: JSONObject): Map { + val env = payload.optJSONObject("env") ?: return emptyMap() + val result = mutableMapOf() + val keys = env.keys() + while (keys.hasNext()) { + val key = keys.next() + result[key] = env.optString(key) + } + return result + } + + private fun inspectProjectFiles(cwd: File): JSONArray { + val files = JSONArray() + cwd.walkTopDown() + .maxDepth(2) + .filter { projectMarkers.contains(it.name) } + .map { file -> + val relative = cwd.toPath().relativize(file.toPath()).toString().replace(File.separatorChar, '/') + "./$relative" + } + .distinct() + .sorted() + .forEach { files.put(it) } + return files + } + + private fun hasBinary(name: String): Boolean { + val path = System.getenv("PATH") ?: return false + return path.split(File.pathSeparator).any { directory -> + val candidate = File(directory, name) + candidate.exists() && candidate.canExecute() + } + } + + private fun stopCurrentProcess(): Boolean = stopTask(null).optBoolean("stopped", false) + + private fun stopTask(taskId: String?): JSONObject { + val requestedTaskId = taskId?.trim().orEmpty() + var stoppingTaskId = "" + val process = synchronized(taskLock) { + val task = if (requestedTaskId.isNotEmpty()) { + findTaskLocked(requestedTaskId) ?: return JSONObject() + .put("success", false) + .put("stopped", false) + .put("taskId", requestedTaskId) + .put("failureKind", "unknown") + .put("error", "Task not found: $requestedTaskId") + } else { + taskHistory.firstOrNull { candidate -> + candidate.optString("status") == "running" && taskProcesses.containsKey(taskIdentity(candidate)) + } ?: return JSONObject().put("success", true).put("stopped", false) + } + stoppingTaskId = taskIdentity(task) + val process = taskProcesses.remove(stoppingTaskId) + if (process == null) { + return JSONObject() + .put("success", true) + .put("stopped", false) + .put("taskId", stoppingTaskId) + .put("task", JSONObject(task.toString())) + } + appendLogToTaskLocked(task, "task stopped") + val finishedAtMs = System.currentTimeMillis() + val startedAtMs = task.optLong("startedAtMs", finishedAtMs) + task.put("status", "cancelled") + task.put("finishedAtMs", finishedAtMs) + task.put("durationMs", (finishedAtMs - startedAtMs).coerceAtLeast(0L)) + task.put("error", "Task cancelled by MobileCode.") + task.put("failureKind", "cancelled") + if (currentProcess == process) currentProcess = null + syncCurrentTaskLocked() + persistTaskStateLocked() + process + } + + return try { + process.destroy() + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly() + } + synchronized(taskLock) { + val task = findTaskLocked(stoppingTaskId) + JSONObject() + .put("success", true) + .put("stopped", true) + .put("taskId", stoppingTaskId) + .put("task", if (task == null) JSONObject() else JSONObject(task.toString())) + } + } catch (error: Throwable) { + JSONObject() + .put("success", false) + .put("stopped", false) + .put("taskId", requestedTaskId.ifEmpty { stoppingTaskId }) + .put("failureKind", "unknown") + .put("error", error.message ?: error.javaClass.simpleName) + } + } + + private fun decodePathSegment(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8.name()) + + private fun queryLimit(query: String, fallback: Int): Int { + return query.split("&") + .firstOrNull { it.substringBefore("=") == "limit" } + ?.substringAfter("=", "") + ?.toIntOrNull() + ?.coerceIn(1, MAX_TASKS) + ?: fallback + } + + private fun readBody(reader: BufferedReader, contentLength: Int): String { + if (contentLength <= 0) return "" + val buffer = CharArray(contentLength) + var read = 0 + while (read < contentLength) { + val count = reader.read(buffer, read, contentLength - read) + if (count < 0) break + read += count + } + return String(buffer, 0, read) + } + + private fun writeJson(socket: Socket, statusCode: Int, payload: JSONObject) { + val body = payload.toString() + val output = socket.getOutputStream() + writeHeaders(output, statusCode, "application/json", body.toByteArray(StandardCharsets.UTF_8).size) + output.write(body.toByteArray(StandardCharsets.UTF_8)) + output.flush() + } + + private fun writeHeaders(output: java.io.OutputStream, statusCode: Int, contentType: String, contentLength: Int?) { + val reason = when (statusCode) { + 200 -> "OK" + 400 -> "Bad Request" + 404 -> "Not Found" + else -> "OK" + } + val headers = buildString { + append("HTTP/1.1 $statusCode $reason\r\n") + append("Content-Type: $contentType\r\n") + append("Connection: close\r\n") + if (contentLength != null) append("Content-Length: $contentLength\r\n") + append("\r\n") + } + output.write(headers.toByteArray(StandardCharsets.UTF_8)) + output.flush() + } + + private fun writeNdjson(output: java.io.OutputStream, lock: Any, payload: JSONObject) { + synchronized(lock) { + output.write((payload.toString() + "\n").toByteArray(StandardCharsets.UTF_8)) + output.flush() + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "MobileCode Helper Runtime", + NotificationManager.IMPORTANCE_LOW + ) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + + private fun buildNotification(text: String): Notification { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + val pendingIntent = launchIntent?.let { PendingIntent.getActivity(this, 0, it, flags) } + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + } + builder + .setSmallIcon(android.R.drawable.stat_sys_upload_done) + .setContentTitle("MobileCode Helper") + .setContentText(text) + .setOngoing(true) + if (pendingIntent != null) builder.setContentIntent(pendingIntent) + return builder.build() + } + + companion object { + const val ACTION_STOP = "com.mobilecode.mobile_agent.action.STOP_HELPER" + private const val CHANNEL_ID = "mobilecode_helper_runtime" + private const val TAG = "MobileCodeHelper" + private const val NOTIFICATION_ID = 8765 + private const val PORT = 8765 + private const val MAX_LOG_LINES = 200 + private const val MAX_TASKS = 50 + + @Volatile private var running = false + @Volatile private var lastError = "" + @Volatile private var currentProcess: Process? = null + @Volatile private var currentTaskId = "" + @Volatile private var currentCommand = "" + @Volatile private var currentTaskCwd = "" + @Volatile private var currentTaskStatus = "unknown" + @Volatile private var currentTaskStartedAtMs = 0L + @Volatile private var currentTaskFinishedAtMs = 0L + @Volatile private var currentTaskExitCode: Int? = null + @Volatile private var currentTaskDurationMs = 0L + @Volatile private var currentTaskError = "" + @Volatile private var currentTaskFailureKind = "none" + private val taskLock = Any() + private val recentLogs = Collections.synchronizedList(mutableListOf()) + private val taskHistory = Collections.synchronizedList(mutableListOf()) + private val taskProcesses = Collections.synchronizedMap(mutableMapOf()) + + fun status(): Map { + return mapOf( + "running" to running, + "port" to PORT, + "lastError" to lastError, + "taskId" to currentTaskId, + "command" to currentCommand, + "taskRunning" to (taskProcesses.isNotEmpty()), + "taskRunningCount" to taskProcesses.size, + "taskStatus" to currentTaskStatus, + "taskStartedAtMs" to currentTaskStartedAtMs, + "taskFinishedAtMs" to currentTaskFinishedAtMs, + "taskFailureKind" to currentTaskFailureKind, + "taskHistoryCount" to taskHistory.size + ) + } + + private fun appendLog(line: String) { + synchronized(recentLogs) { + recentLogs.add(line) + while (recentLogs.size > MAX_LOG_LINES) { + recentLogs.removeAt(0) + } + } + } + + private val allowedCommands = setOf( + "pwd", "ls", "cat", "head", "tail", "grep", "find", "wc", "sort", "uniq", + "sed", "awk", "mkdir", "touch", "cp", "mv", "rm", "git", "node", "npm", + "npx", "python", "python3", "pip", "pip3", "dart", "flutter", "java", + "javac", "gradle", "chmod", "tar", "zip", "unzip", "curl", "wget", + "which", "whoami", "date", "echo" + ) + + private val dangerousFragments = listOf( + "rm -rf /", + "rm -rf /*", + "mkfs", + "dd if=", + ":(){:|:&};:", + "chmod -r 777 /", + "chown -r", + "reboot", + "shutdown", + "poweroff", + "su " + ) + + private val projectMarkers = setOf( + "package.json", + "pubspec.yaml", + "requirements.txt", + "pyproject.toml", + ".git" + ) + + private fun classifyFailure(status: String, exitCode: Int?, error: String?): String { + val message = (error ?: "").lowercase(Locale.US) + return when { + status == "succeeded" -> "none" + status == "cancelled" -> "cancelled" + status == "timedOut" -> "timeout" + status == "lost" -> "runtimeLost" + message.contains("outside mobilecode app data") -> "cwdOutsideWorkspace" + message.contains("not allowed") || message.contains("dangerous command") -> "commandBlocked" + listOf("command not found", "no such file or directory", "not found", "cannot find", "is not recognized").any { message.contains(it) } -> "dependencyMissing" + exitCode != null && exitCode != 0 -> "processFailed" + else -> "unknown" + } + } + } +} diff --git a/mobile_agent/tooling/mobilecode_helper_daemon.py b/mobile_agent/tooling/mobilecode_helper_daemon.py index 48c94a2..4599e74 100644 --- a/mobile_agent/tooling/mobilecode_helper_daemon.py +++ b/mobile_agent/tooling/mobilecode_helper_daemon.py @@ -1,390 +1,390 @@ -#!/usr/bin/env python3 -""" -MobileCode Helper daemon prototype. - -Runs a localhost HTTP server that implements the MobileCode Helper Runtime -Protocol v1. It is intentionally small and dependency-free so it can run inside -Termux while the real Helper APK is being built. -""" - -from __future__ import annotations - -import argparse -import json -import os -import queue -import shlex -import signal -import subprocess -import sys -import threading -import time -import uuid -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from typing import Any -from urllib.parse import parse_qs, unquote, urlparse - - -DEFAULT_ALLOWED_COMMANDS = { - "pwd", - "ls", - "cat", - "head", - "tail", - "grep", - "find", - "wc", - "sort", - "uniq", - "sed", - "awk", - "mkdir", - "touch", - "cp", - "mv", - "rm", - "git", - "node", - "npm", - "npx", - "python", - "python3", - "pip", - "pip3", - "dart", - "flutter", - "java", - "javac", - "gradle", - "chmod", - "tar", - "zip", - "unzip", - "curl", - "wget", - "which", - "whoami", - "date", - "echo", -} - -DANGEROUS_FRAGMENTS = ( - "rm -rf /", - "rm -rf /*", - "mkfs", - "dd if=", - ":(){:|:&};:", - "chmod -R 777 /", - "chown -R", - "reboot", - "shutdown", - "poweroff", - "su ", -) - -PROJECT_MARKERS = {"package.json", "pubspec.yaml", "requirements.txt", "pyproject.toml", ".git"} - - -class HelperState: - def __init__( - self, - workspace_root: Path, - allow_unsafe: bool = False, - auth_token: str | None = None, - max_tasks: int = 50, - ) -> None: - self.workspace_root = workspace_root.resolve() - self.allow_unsafe = allow_unsafe - self.auth_token = (auth_token or "").strip() - self.max_tasks = max_tasks - self.current_lock = threading.Lock() - self.task_file = self.workspace_root / ".mobilecode-helper-task.json" - self.task_database_file = self.workspace_root / ".mobilecode-helper-tasks.json" - self.current_task: dict[str, Any] | None = None - self.tasks: list[dict[str, Any]] = [] - self.processes: dict[str, subprocess.Popen[str]] = {} - self._load_tasks() - - def capabilities(self) -> dict[str, bool]: - return { - "shell": True, - "git": has_binary("git"), - "node": has_binary("node") or has_binary("npm"), - "python": has_binary("python") or has_binary("python3"), - "flutter": has_binary("flutter"), - "androidBuild": has_binary("flutter") and has_binary("java"), - "pty": False, - "backgroundService": False, - "webViewPreview": True, - "cloudBuild": False, - } - - def validate_cwd(self, cwd: str | None) -> Path: - if not cwd: - return self.workspace_root - path = Path(cwd).expanduser().resolve() - if path == self.workspace_root or self.workspace_root in path.parents: - return path - raise ValueError(f"cwd is outside workspace root: {path}") - - def command_args(self, command: str) -> list[str]: - if not command.strip(): - raise ValueError("command cannot be empty") - lowered = command.lower() - for fragment in DANGEROUS_FRAGMENTS: - if fragment in lowered: - raise ValueError(f"dangerous command fragment blocked: {fragment}") - parts = shlex.split(command) - if not parts: - raise ValueError("command cannot be empty") - if self.allow_unsafe: - return parts - executable = Path(parts[0]).name.lower() - if executable.endswith(".exe"): - executable = executable[:-4] - if executable not in DEFAULT_ALLOWED_COMMANDS: - raise ValueError(f"command is not allowed: {executable}") - return parts - - def begin_task(self, command: str, cwd: Path) -> dict[str, Any]: - task = { - "id": f"task-{int(time.time() * 1000)}-{uuid.uuid4().hex[:8]}", - "taskId": "", - "command": command, - "cwd": str(cwd), - "status": "running", - "startedAtMs": int(time.time() * 1000), - "finishedAtMs": 0, - "durationMs": 0, - "logs": [], - "provider": "mobileCodeHelper", - "failureKind": "none", - } - task["taskId"] = task["id"] - with self.current_lock: - self.current_task = task - self.tasks.insert(0, task) - del self.tasks[self.max_tasks :] - self._persist_tasks_locked() - return task - - def register_process(self, task_id: str, process: subprocess.Popen[str]) -> None: - with self.current_lock: - if self._find_task_locked(task_id) is None: - return - self.processes[task_id] = process - self._persist_tasks_locked() - - def clear_process(self, task_id: str, process: subprocess.Popen[str]) -> None: - with self.current_lock: - if self.processes.get(task_id) is process: - self.processes.pop(task_id, None) - self._persist_tasks_locked() - - def append_log(self, task_id: str, line: str) -> None: - with self.current_lock: - task = self._find_task_locked(task_id) - if task is None: - return - self._append_task_log_locked(task, line) - self._persist_tasks_locked() - - def finish_task(self, task_id: str, status: str, exit_code: int | None, duration_ms: int, error: str | None = None) -> None: - with self.current_lock: - task = self._find_task_locked(task_id) - if task is None: - return - if task.get("status") == "cancelled": - status = "cancelled" - error = error or str(task.get("error") or "") or None - task["status"] = status - task["finishedAtMs"] = int(time.time() * 1000) - task["durationMs"] = duration_ms - if exit_code is not None: - task["exitCode"] = exit_code - if error: - task["error"] = error - task["failureKind"] = classify_failure(status, exit_code, error) - self._sync_current_task_locked() - self._persist_tasks_locked() - - def task_snapshot(self) -> dict[str, Any]: - with self.current_lock: - self._sync_current_task_locked() - return clone_task(self.current_task or {}) - - def task_list(self, limit: int = 20) -> list[dict[str, Any]]: - with self.current_lock: - tasks = self.tasks[: max(1, min(limit, self.max_tasks))] - return [clone_task(task) for task in tasks] - - def task_logs(self, task_id: str, limit: int = 200) -> list[str]: - with self.current_lock: - for task in self.tasks: - if task.get("id") == task_id or task.get("taskId") == task_id: - logs = task.get("logs", []) - if not isinstance(logs, list): - return [] - return [str(line) for line in logs[-max(1, limit) :]] - return [] - - def find_task(self, task_id: str) -> dict[str, Any] | None: - with self.current_lock: - task = self._find_task_locked(task_id) - if task is not None: - return clone_task(task) - return None - - def current_running_task_id(self) -> str | None: - with self.current_lock: - if self.current_task is not None: - task_id = task_identity(self.current_task) - if self.current_task.get("status") == "running" and task_id in self.processes: - return task_id - for task in self.tasks: - task_id = task_identity(task) - if task.get("status") == "running" and task_id in self.processes: - return task_id - return None - - def is_task_cancelled(self, task_id: str) -> bool: - with self.current_lock: - task = self._find_task_locked(task_id) - return task is not None and task.get("status") == "cancelled" - - def cancel_task(self, task_id: str) -> tuple[subprocess.Popen[str] | None, dict[str, Any] | None, bool]: - with self.current_lock: - task = self._find_task_locked(task_id) - if task is None: - return None, None, False - process = self.processes.pop(task_id, None) - if process is None: - return None, clone_task(task), False - self._append_task_log_locked(task, "task stopped") - finished_at_ms = int(time.time() * 1000) - started_at_ms = int(task.get("startedAtMs") or finished_at_ms) - task["status"] = "cancelled" - task["finishedAtMs"] = finished_at_ms - task["durationMs"] = max(0, finished_at_ms - started_at_ms) - task["error"] = "Task cancelled by MobileCode." - task["failureKind"] = "cancelled" - self._sync_current_task_locked() - self._persist_tasks_locked() - return process, clone_task(task), True - - def _find_task_locked(self, task_id: str) -> dict[str, Any] | None: - for task in self.tasks: - if task.get("id") == task_id or task.get("taskId") == task_id: - return task - return None - - def _append_task_log_locked(self, task: dict[str, Any], line: str) -> None: - logs = task.setdefault("logs", []) - if isinstance(logs, list): - logs.append(line) - del logs[:-200] - - def _sync_current_task_locked(self) -> None: - if not self.tasks: - self.current_task = None - return - for task in self.tasks: - if task.get("status") == "running" and task_identity(task) in self.processes: - self.current_task = task - return - self.current_task = self.tasks[0] - - def has_running_tasks(self) -> bool: - with self.current_lock: - return any(task.get("status") == "running" and task_identity(task) in self.processes for task in self.tasks) - - def running_task_count(self) -> int: - with self.current_lock: - return sum(1 for task in self.tasks if task.get("status") == "running" and task_identity(task) in self.processes) - - def cancel_current_task(self, task_id: str, process: Any) -> dict[str, Any] | None: - with self.current_lock: - task = self._find_task_locked(task_id) - if task is None or self.processes.get(task_id) is not process: - return None - self.processes.pop(task_id, None) - self._append_task_log_locked(task, "task stopped") - finished_at_ms = int(time.time() * 1000) - started_at_ms = int(task.get("startedAtMs") or finished_at_ms) - task["status"] = "cancelled" - task["finishedAtMs"] = finished_at_ms - task["durationMs"] = max(0, finished_at_ms - started_at_ms) - task["error"] = "Task cancelled by MobileCode." - task["failureKind"] = "cancelled" - self._sync_current_task_locked() - self._persist_tasks_locked() - return clone_task(task) - - def inspect_project(self, cwd: Path, max_depth: int = 2) -> list[str]: - detected: set[str] = set() - for root, dirs, files in os.walk(cwd): - root_path = Path(root) - depth = len(root_path.relative_to(cwd).parts) - if depth >= max_depth: - dirs[:] = [] - for directory in list(dirs): - if directory in PROJECT_MARKERS: - detected.add(f"./{(root_path / directory).relative_to(cwd).as_posix()}") - if directory == ".git": - dirs.remove(directory) - for filename in files: - if filename in PROJECT_MARKERS: - detected.add(f"./{(root_path / filename).relative_to(cwd).as_posix()}") - return sorted(detected) - - def _load_tasks(self) -> None: - try: - loaded_tasks: list[dict[str, Any]] = [] - if self.task_database_file.exists(): - decoded = json.loads(self.task_database_file.read_text(encoding="utf-8")) - if isinstance(decoded, dict): - decoded = decoded.get("tasks", []) - if isinstance(decoded, list): - loaded_tasks = [item for item in decoded if isinstance(item, dict)] - elif self.task_file.exists(): - decoded = json.loads(self.task_file.read_text(encoding="utf-8")) - if isinstance(decoded, dict): - loaded_tasks = [decoded] - - for task in loaded_tasks: - if task.get("status") == "running": - task["status"] = "lost" - task["finishedAtMs"] = int(time.time() * 1000) - task["failureKind"] = "runtimeLost" - task["error"] = "Helper daemon restarted before this task completed." - logs = task.setdefault("logs", []) - if isinstance(logs, list): - logs.append("task lost after helper restart") - else: - task["failureKind"] = task.get("failureKind") or classify_failure( - str(task.get("status") or "unknown"), - int(task["exitCode"]) if isinstance(task.get("exitCode"), int) else None, - str(task.get("error") or "") or None, - ) - loaded_tasks.sort(key=lambda item: int(item.get("startedAtMs") or 0), reverse=True) - self.tasks = loaded_tasks[: self.max_tasks] - self._sync_current_task_locked() - self._persist_tasks_locked() - except Exception: - self.current_task = None - self.tasks = [] - - def _persist_tasks_locked(self) -> None: - self._sync_current_task_locked() - if not self.current_task: - return - self.task_file.write_text(json.dumps(self.current_task, ensure_ascii=False), encoding="utf-8") - payload = {"tasks": self.tasks[: self.max_tasks]} - self.task_database_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") - - +#!/usr/bin/env python3 +""" +MobileCode Helper daemon prototype. + +Runs a localhost HTTP server that implements the MobileCode Helper Runtime +Protocol v1. It is intentionally small and dependency-free so it can run inside +Termux while the real Helper APK is being built. +""" + +from __future__ import annotations + +import argparse +import json +import os +import queue +import shlex +import signal +import subprocess +import sys +import threading +import time +import uuid +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, unquote, urlparse + + +DEFAULT_ALLOWED_COMMANDS = { + "pwd", + "ls", + "cat", + "head", + "tail", + "grep", + "find", + "wc", + "sort", + "uniq", + "sed", + "awk", + "mkdir", + "touch", + "cp", + "mv", + "rm", + "git", + "node", + "npm", + "npx", + "python", + "python3", + "pip", + "pip3", + "dart", + "flutter", + "java", + "javac", + "gradle", + "chmod", + "tar", + "zip", + "unzip", + "curl", + "wget", + "which", + "whoami", + "date", + "echo", +} + +DANGEROUS_FRAGMENTS = ( + "rm -rf /", + "rm -rf /*", + "mkfs", + "dd if=", + ":(){:|:&};:", + "chmod -R 777 /", + "chown -R", + "reboot", + "shutdown", + "poweroff", + "su ", +) + +PROJECT_MARKERS = {"package.json", "pubspec.yaml", "requirements.txt", "pyproject.toml", ".git"} + + +class HelperState: + def __init__( + self, + workspace_root: Path, + allow_unsafe: bool = False, + auth_token: str | None = None, + max_tasks: int = 50, + ) -> None: + self.workspace_root = workspace_root.resolve() + self.allow_unsafe = allow_unsafe + self.auth_token = (auth_token or "").strip() + self.max_tasks = max_tasks + self.current_lock = threading.Lock() + self.task_file = self.workspace_root / ".mobilecode-helper-task.json" + self.task_database_file = self.workspace_root / ".mobilecode-helper-tasks.json" + self.current_task: dict[str, Any] | None = None + self.tasks: list[dict[str, Any]] = [] + self.processes: dict[str, subprocess.Popen[str]] = {} + self._load_tasks() + + def capabilities(self) -> dict[str, bool]: + return { + "shell": True, + "git": has_binary("git"), + "node": has_binary("node") or has_binary("npm"), + "python": has_binary("python") or has_binary("python3"), + "flutter": has_binary("flutter"), + "androidBuild": has_binary("flutter") and has_binary("java"), + "pty": False, + "backgroundService": False, + "webViewPreview": True, + "cloudBuild": False, + } + + def validate_cwd(self, cwd: str | None) -> Path: + if not cwd: + return self.workspace_root + path = Path(cwd).expanduser().resolve() + if path == self.workspace_root or self.workspace_root in path.parents: + return path + raise ValueError(f"cwd is outside workspace root: {path}") + + def command_args(self, command: str) -> list[str]: + if not command.strip(): + raise ValueError("command cannot be empty") + lowered = command.lower() + for fragment in DANGEROUS_FRAGMENTS: + if fragment in lowered: + raise ValueError(f"dangerous command fragment blocked: {fragment}") + parts = shlex.split(command) + if not parts: + raise ValueError("command cannot be empty") + if self.allow_unsafe: + return parts + executable = Path(parts[0]).name.lower() + if executable.endswith(".exe"): + executable = executable[:-4] + if executable not in DEFAULT_ALLOWED_COMMANDS: + raise ValueError(f"command is not allowed: {executable}") + return parts + + def begin_task(self, command: str, cwd: Path) -> dict[str, Any]: + task = { + "id": f"task-{int(time.time() * 1000)}-{uuid.uuid4().hex[:8]}", + "taskId": "", + "command": command, + "cwd": str(cwd), + "status": "running", + "startedAtMs": int(time.time() * 1000), + "finishedAtMs": 0, + "durationMs": 0, + "logs": [], + "provider": "mobileCodeHelper", + "failureKind": "none", + } + task["taskId"] = task["id"] + with self.current_lock: + self.current_task = task + self.tasks.insert(0, task) + del self.tasks[self.max_tasks :] + self._persist_tasks_locked() + return task + + def register_process(self, task_id: str, process: subprocess.Popen[str]) -> None: + with self.current_lock: + if self._find_task_locked(task_id) is None: + return + self.processes[task_id] = process + self._persist_tasks_locked() + + def clear_process(self, task_id: str, process: subprocess.Popen[str]) -> None: + with self.current_lock: + if self.processes.get(task_id) is process: + self.processes.pop(task_id, None) + self._persist_tasks_locked() + + def append_log(self, task_id: str, line: str) -> None: + with self.current_lock: + task = self._find_task_locked(task_id) + if task is None: + return + self._append_task_log_locked(task, line) + self._persist_tasks_locked() + + def finish_task(self, task_id: str, status: str, exit_code: int | None, duration_ms: int, error: str | None = None) -> None: + with self.current_lock: + task = self._find_task_locked(task_id) + if task is None: + return + if task.get("status") == "cancelled": + status = "cancelled" + error = error or str(task.get("error") or "") or None + task["status"] = status + task["finishedAtMs"] = int(time.time() * 1000) + task["durationMs"] = duration_ms + if exit_code is not None: + task["exitCode"] = exit_code + if error: + task["error"] = error + task["failureKind"] = classify_failure(status, exit_code, error) + self._sync_current_task_locked() + self._persist_tasks_locked() + + def task_snapshot(self) -> dict[str, Any]: + with self.current_lock: + self._sync_current_task_locked() + return clone_task(self.current_task or {}) + + def task_list(self, limit: int = 20) -> list[dict[str, Any]]: + with self.current_lock: + tasks = self.tasks[: max(1, min(limit, self.max_tasks))] + return [clone_task(task) for task in tasks] + + def task_logs(self, task_id: str, limit: int = 200) -> list[str]: + with self.current_lock: + for task in self.tasks: + if task.get("id") == task_id or task.get("taskId") == task_id: + logs = task.get("logs", []) + if not isinstance(logs, list): + return [] + return [str(line) for line in logs[-max(1, limit) :]] + return [] + + def find_task(self, task_id: str) -> dict[str, Any] | None: + with self.current_lock: + task = self._find_task_locked(task_id) + if task is not None: + return clone_task(task) + return None + + def current_running_task_id(self) -> str | None: + with self.current_lock: + if self.current_task is not None: + task_id = task_identity(self.current_task) + if self.current_task.get("status") == "running" and task_id in self.processes: + return task_id + for task in self.tasks: + task_id = task_identity(task) + if task.get("status") == "running" and task_id in self.processes: + return task_id + return None + + def is_task_cancelled(self, task_id: str) -> bool: + with self.current_lock: + task = self._find_task_locked(task_id) + return task is not None and task.get("status") == "cancelled" + + def cancel_task(self, task_id: str) -> tuple[subprocess.Popen[str] | None, dict[str, Any] | None, bool]: + with self.current_lock: + task = self._find_task_locked(task_id) + if task is None: + return None, None, False + process = self.processes.pop(task_id, None) + if process is None: + return None, clone_task(task), False + self._append_task_log_locked(task, "task stopped") + finished_at_ms = int(time.time() * 1000) + started_at_ms = int(task.get("startedAtMs") or finished_at_ms) + task["status"] = "cancelled" + task["finishedAtMs"] = finished_at_ms + task["durationMs"] = max(0, finished_at_ms - started_at_ms) + task["error"] = "Task cancelled by MobileCode." + task["failureKind"] = "cancelled" + self._sync_current_task_locked() + self._persist_tasks_locked() + return process, clone_task(task), True + + def _find_task_locked(self, task_id: str) -> dict[str, Any] | None: + for task in self.tasks: + if task.get("id") == task_id or task.get("taskId") == task_id: + return task + return None + + def _append_task_log_locked(self, task: dict[str, Any], line: str) -> None: + logs = task.setdefault("logs", []) + if isinstance(logs, list): + logs.append(line) + del logs[:-200] + + def _sync_current_task_locked(self) -> None: + if not self.tasks: + self.current_task = None + return + for task in self.tasks: + if task.get("status") == "running" and task_identity(task) in self.processes: + self.current_task = task + return + self.current_task = self.tasks[0] + + def has_running_tasks(self) -> bool: + with self.current_lock: + return any(task.get("status") == "running" and task_identity(task) in self.processes for task in self.tasks) + + def running_task_count(self) -> int: + with self.current_lock: + return sum(1 for task in self.tasks if task.get("status") == "running" and task_identity(task) in self.processes) + + def cancel_current_task(self, task_id: str, process: Any) -> dict[str, Any] | None: + with self.current_lock: + task = self._find_task_locked(task_id) + if task is None or self.processes.get(task_id) is not process: + return None + self.processes.pop(task_id, None) + self._append_task_log_locked(task, "task stopped") + finished_at_ms = int(time.time() * 1000) + started_at_ms = int(task.get("startedAtMs") or finished_at_ms) + task["status"] = "cancelled" + task["finishedAtMs"] = finished_at_ms + task["durationMs"] = max(0, finished_at_ms - started_at_ms) + task["error"] = "Task cancelled by MobileCode." + task["failureKind"] = "cancelled" + self._sync_current_task_locked() + self._persist_tasks_locked() + return clone_task(task) + + def inspect_project(self, cwd: Path, max_depth: int = 2) -> list[str]: + detected: set[str] = set() + for root, dirs, files in os.walk(cwd): + root_path = Path(root) + depth = len(root_path.relative_to(cwd).parts) + if depth >= max_depth: + dirs[:] = [] + for directory in list(dirs): + if directory in PROJECT_MARKERS: + detected.add(f"./{(root_path / directory).relative_to(cwd).as_posix()}") + if directory == ".git": + dirs.remove(directory) + for filename in files: + if filename in PROJECT_MARKERS: + detected.add(f"./{(root_path / filename).relative_to(cwd).as_posix()}") + return sorted(detected) + + def _load_tasks(self) -> None: + try: + loaded_tasks: list[dict[str, Any]] = [] + if self.task_database_file.exists(): + decoded = json.loads(self.task_database_file.read_text(encoding="utf-8")) + if isinstance(decoded, dict): + decoded = decoded.get("tasks", []) + if isinstance(decoded, list): + loaded_tasks = [item for item in decoded if isinstance(item, dict)] + elif self.task_file.exists(): + decoded = json.loads(self.task_file.read_text(encoding="utf-8")) + if isinstance(decoded, dict): + loaded_tasks = [decoded] + + for task in loaded_tasks: + if task.get("status") == "running": + task["status"] = "lost" + task["finishedAtMs"] = int(time.time() * 1000) + task["failureKind"] = "runtimeLost" + task["error"] = "Helper daemon restarted before this task completed." + logs = task.setdefault("logs", []) + if isinstance(logs, list): + logs.append("task lost after helper restart") + else: + task["failureKind"] = task.get("failureKind") or classify_failure( + str(task.get("status") or "unknown"), + int(task["exitCode"]) if isinstance(task.get("exitCode"), int) else None, + str(task.get("error") or "") or None, + ) + loaded_tasks.sort(key=lambda item: int(item.get("startedAtMs") or 0), reverse=True) + self.tasks = loaded_tasks[: self.max_tasks] + self._sync_current_task_locked() + self._persist_tasks_locked() + except Exception: + self.current_task = None + self.tasks = [] + + def _persist_tasks_locked(self) -> None: + self._sync_current_task_locked() + if not self.current_task: + return + self.task_file.write_text(json.dumps(self.current_task, ensure_ascii=False), encoding="utf-8") + payload = {"tasks": self.tasks[: self.max_tasks]} + self.task_database_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + def has_binary(name: str) -> bool: path_env = os.environ.get("PATH", "") for directory in path_env.split(os.pathsep): @@ -394,422 +394,435 @@ def has_binary(name: str) -> bool: return False -def clone_task(task: dict[str, Any]) -> dict[str, Any]: - copied = dict(task) - logs = copied.get("logs", []) - if isinstance(logs, list): - copied["logs"] = [str(line) for line in logs] - return copied - - -def task_identity(task: dict[str, Any]) -> str: - return str(task.get("id") or task.get("taskId") or "") +def is_termux_environment() -> bool: + prefix = os.environ.get("PREFIX", "") + home = os.environ.get("HOME", "") + return "com.termux" in prefix or "com.termux" in home or bool(os.environ.get("TERMUX_VERSION")) -def classify_failure(status: str, exit_code: int | None, error: str | None) -> str: - normalized_status = status.strip() - message = (error or "").lower() - if normalized_status == "succeeded": - return "none" - if normalized_status == "cancelled": - return "cancelled" - if normalized_status == "timedOut": - return "timeout" - if normalized_status == "lost": - return "runtimeLost" - if "outside workspace" in message: - return "cwdOutsideWorkspace" - if "not allowed" in message or "dangerous command" in message: - return "commandBlocked" - dependency_markers = ( - "command not found", - "no such file or directory", - "not found", - "cannot find", - "is not recognized", - ) - if any(marker in message for marker in dependency_markers): - return "dependencyMissing" - if exit_code is not None and exit_code != 0: - return "processFailed" - return "unknown" +def runtime_kind() -> str: + return "termuxDaemon" if is_termux_environment() else "helperPrototype" -def json_bytes(payload: dict[str, Any]) -> bytes: - return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") - - -def read_json(handler: BaseHTTPRequestHandler) -> dict[str, Any]: - length = int(handler.headers.get("Content-Length", "0")) - if length <= 0: - return {} - data = handler.rfile.read(length) - decoded = json.loads(data.decode("utf-8")) - if not isinstance(decoded, dict): - raise ValueError("JSON body must be an object") - return decoded - - -def command_result( - command: str, - args: list[str], - cwd: Path, - env: dict[str, str] | None, - timeout_ms: int, -) -> dict[str, Any]: - started = time.monotonic() - merged_env = os.environ.copy() - if env: - merged_env.update({str(k): str(v) for k, v in env.items()}) - completed = subprocess.run( - args, - cwd=str(cwd), - env=merged_env, - text=True, - capture_output=True, - timeout=max(timeout_ms / 1000, 1), - ) - duration_ms = int((time.monotonic() - started) * 1000) - return { - "command": command, - "stdout": completed.stdout, - "stderr": completed.stderr, - "exitCode": completed.returncode, - "durationMs": duration_ms, - } - - -class MobileCodeHandler(BaseHTTPRequestHandler): - server_version = "MobileCodeHelper/0.1" - - @property - def state(self) -> HelperState: - return self.server.state # type: ignore[attr-defined] - - def do_GET(self) -> None: # noqa: N802 - try: - if not self.authorized(): - self.send_error_json(HTTPStatus.UNAUTHORIZED, "Missing or invalid MobileCode Helper token", "authFailed") - return - - parsed = urlparse(self.path) - path = parsed.path - query = parse_qs(parsed.query) - if path == "/v1/health": - self.send_json( - { - "name": "MobileCode Helper Prototype", +def clone_task(task: dict[str, Any]) -> dict[str, Any]: + copied = dict(task) + logs = copied.get("logs", []) + if isinstance(logs, list): + copied["logs"] = [str(line) for line in logs] + return copied + + +def task_identity(task: dict[str, Any]) -> str: + return str(task.get("id") or task.get("taskId") or "") + + +def classify_failure(status: str, exit_code: int | None, error: str | None) -> str: + normalized_status = status.strip() + message = (error or "").lower() + if normalized_status == "succeeded": + return "none" + if normalized_status == "cancelled": + return "cancelled" + if normalized_status == "timedOut": + return "timeout" + if normalized_status == "lost": + return "runtimeLost" + if "outside workspace" in message: + return "cwdOutsideWorkspace" + if "not allowed" in message or "dangerous command" in message: + return "commandBlocked" + dependency_markers = ( + "command not found", + "no such file or directory", + "not found", + "cannot find", + "is not recognized", + ) + if any(marker in message for marker in dependency_markers): + return "dependencyMissing" + if exit_code is not None and exit_code != 0: + return "processFailed" + return "unknown" + + +def json_bytes(payload: dict[str, Any]) -> bytes: + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +def read_json(handler: BaseHTTPRequestHandler) -> dict[str, Any]: + length = int(handler.headers.get("Content-Length", "0")) + if length <= 0: + return {} + data = handler.rfile.read(length) + decoded = json.loads(data.decode("utf-8")) + if not isinstance(decoded, dict): + raise ValueError("JSON body must be an object") + return decoded + + +def command_result( + command: str, + args: list[str], + cwd: Path, + env: dict[str, str] | None, + timeout_ms: int, +) -> dict[str, Any]: + started = time.monotonic() + merged_env = os.environ.copy() + if env: + merged_env.update({str(k): str(v) for k, v in env.items()}) + completed = subprocess.run( + args, + cwd=str(cwd), + env=merged_env, + text=True, + capture_output=True, + timeout=max(timeout_ms / 1000, 1), + ) + duration_ms = int((time.monotonic() - started) * 1000) + return { + "command": command, + "stdout": completed.stdout, + "stderr": completed.stderr, + "exitCode": completed.returncode, + "durationMs": duration_ms, + } + + +class MobileCodeHandler(BaseHTTPRequestHandler): + server_version = "MobileCodeHelper/0.1" + + @property + def state(self) -> HelperState: + return self.server.state # type: ignore[attr-defined] + + def do_GET(self) -> None: # noqa: N802 + try: + if not self.authorized(): + self.send_error_json(HTTPStatus.UNAUTHORIZED, "Missing or invalid MobileCode Helper token", "authFailed") + return + + parsed = urlparse(self.path) + path = parsed.path + query = parse_qs(parsed.query) + if path == "/v1/health": + self.send_json( + { + "name": "MobileCode Helper Prototype", "available": True, "ready": True, "status": f"Helper daemon running at {self.server.server_address}", "protocolVersion": 1, + "runtimeKind": runtime_kind(), + "termux": is_termux_environment(), + "workspaceRoot": str(self.state.workspace_root), "authRequired": bool(self.state.auth_token), "capabilities": self.state.capabilities(), - "taskRegistry": { - "runningCount": self.state.running_task_count(), - "maxTasks": self.state.max_tasks, - }, - "missingDependencies": [], - "recoveryActions": [], - } - ) - return - if path == "/v1/tasks/current": - task = self.state.task_snapshot() - self.send_json( - { - "running": task.get("status") == "running", - "runningCount": self.state.running_task_count(), - "taskId": task.get("id", ""), - "command": task.get("command", ""), - "logs": task.get("logs", []), - "task": task, - } - ) - return - if path == "/v1/tasks": - limit = int((query.get("limit") or ["20"])[0]) - tasks = self.state.task_list(limit) - self.send_json({"tasks": tasks, "count": len(tasks)}) - return - if path.startswith("/v1/tasks/") and path.endswith("/logs"): - task_id = unquote(path.removeprefix("/v1/tasks/").removesuffix("/logs").strip("/")) - limit = int((query.get("limit") or ["200"])[0]) - self.send_json({"taskId": task_id, "logs": self.state.task_logs(task_id, limit)}) - return - self.send_error_json(HTTPStatus.NOT_FOUND, "Unknown endpoint") - except Exception as exc: # pragma: no cover - defensive server boundary - self.send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) - - def do_POST(self) -> None: # noqa: N802 - try: - if not self.authorized(): - self.send_error_json(HTTPStatus.UNAUTHORIZED, "Missing or invalid MobileCode Helper token", "authFailed") - return - - path = urlparse(self.path).path - if path == "/v1/execute": - self.handle_execute() - return - if path == "/v1/execute/stream": - self.handle_execute_stream() - return - if path == "/v1/project/preflight": - self.handle_project_preflight() - return - if path == "/v1/task/stop": - self.handle_stop() - return - if path.startswith("/v1/tasks/") and path.endswith("/stop"): - task_id = unquote(path.removeprefix("/v1/tasks/").removesuffix("/stop").strip("/")) - self.handle_stop(task_id=task_id) - return - self.send_error_json(HTTPStatus.NOT_FOUND, "Endpoint is not implemented in prototype") - except Exception as exc: - self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) - - def authorized(self) -> bool: - token = self.state.auth_token - if not token: - return True - header_token = self.headers.get("X-MobileCode-Token", "") - bearer = self.headers.get("Authorization", "") - return header_token == token or bearer == f"Bearer {token}" - - def handle_execute(self) -> None: - payload = read_json(self) - command = str(payload.get("command", "")) - cwd = self.state.validate_cwd(payload.get("cwd")) - env = payload.get("env") if isinstance(payload.get("env"), dict) else None - timeout_ms = int(payload.get("timeoutMs", 120000)) - args = self.state.command_args(command) - task = self.state.begin_task(command, cwd) - task_id = str(task["id"]) - self.state.append_log(task_id, f"task {task_id}: {command}") - started = time.monotonic() - merged_env = os.environ.copy() - if env: - merged_env.update({str(k): str(v) for k, v in env.items()}) - process = subprocess.Popen( - args, - cwd=str(cwd), - env=merged_env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self.state.register_process(task_id, process) - timed_out = False - try: - stdout, stderr = process.communicate(timeout=max(timeout_ms / 1000, 1)) - except subprocess.TimeoutExpired: - timed_out = True - process.kill() - stdout, stderr = process.communicate() - stderr = (stderr or "") + f"\nCommand timed out after {timeout_ms}ms.\n" - duration_ms = int((time.monotonic() - started) * 1000) - exit_code = 124 if timed_out else process.returncode - for line in (stdout or "").splitlines(): - self.state.append_log(task_id, f"stdout: {line}") - for line in (stderr or "").splitlines(): - self.state.append_log(task_id, f"stderr: {line}") - self.state.clear_process(task_id, process) - cancelled = self.state.is_task_cancelled(task_id) - status = "cancelled" if cancelled else "timedOut" if timed_out else "succeeded" if exit_code == 0 else "failed" - self.state.finish_task(task_id, status, exit_code, duration_ms, (stderr or "").strip() or None) - failure_kind = (self.state.find_task(task_id) or {}).get("failureKind", "none") - self.send_json( - { - "command": command, - "stdout": stdout or "", - "stderr": stderr or "", - "exitCode": exit_code, - "durationMs": duration_ms, - "taskId": task_id, - "failureKind": failure_kind, - } - ) - - def handle_execute_stream(self) -> None: - payload = read_json(self) - command = str(payload.get("command", "")) - cwd = self.state.validate_cwd(payload.get("cwd")) - env = payload.get("env") if isinstance(payload.get("env"), dict) else None - args = self.state.command_args(command) - task = self.state.begin_task(command, cwd) - task_id = str(task["id"]) - self.state.append_log(task_id, f"task {task_id} stream: {command}") - - merged_env = os.environ.copy() - if env: - merged_env.update({str(k): str(v) for k, v in env.items()}) - - process = subprocess.Popen( - args, - cwd=str(cwd), - env=merged_env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=1, - universal_newlines=True, - ) - - self.state.register_process(task_id, process) - - self.send_response(HTTPStatus.OK) - self.send_header("Content-Type", "application/x-ndjson") - self.send_header("Cache-Control", "no-cache") - self.end_headers() - - started = time.monotonic() - output_queue: queue.Queue[tuple[str, str | None]] = queue.Queue() - - def pump(stream: Any, stream_type: str) -> None: - for line in iter(stream.readline, ""): - output_queue.put((stream_type, line.rstrip("\n"))) - output_queue.put((stream_type, None)) - - threads = [ - threading.Thread(target=pump, args=(process.stdout, "stdout"), daemon=True), - threading.Thread(target=pump, args=(process.stderr, "stderr"), daemon=True), - ] - for thread in threads: - thread.start() - - ended_streams = 0 - while ended_streams < 2: - stream_type, line = output_queue.get() - if line is None: - ended_streams += 1 - continue - self.state.append_log(task_id, f"{stream_type}: {line}") - self.write_ndjson({"type": stream_type, "data": line}) - - exit_code = process.wait() - duration_ms = int((time.monotonic() - started) * 1000) - self.state.clear_process(task_id, process) - cancelled = self.state.is_task_cancelled(task_id) - status = "cancelled" if cancelled else "succeeded" if exit_code == 0 else "failed" - self.state.finish_task(task_id, status, exit_code, duration_ms) - self.write_ndjson({"type": "exit", "exitCode": exit_code, "durationMs": duration_ms, "taskId": task_id}) - - def handle_project_preflight(self) -> None: - payload = read_json(self) - cwd = self.state.validate_cwd(payload.get("cwd")) - detected_files = self.state.inspect_project(cwd) - self.send_json( - { - "success": True, - "cwd": str(cwd), - "detectedFiles": detected_files, - } - ) - - def handle_stop(self, task_id: str | None = None) -> None: - if task_id is None: - task_id = self.state.current_running_task_id() - if task_id is None: - self.send_json({"success": True, "stopped": False}) - return - if task_id: - task = self.state.find_task(task_id) - if task is None: - self.send_error_json(HTTPStatus.NOT_FOUND, f"Task not found: {task_id}", "unknown") - return - process, snapshot, stopped = self.state.cancel_task(task_id) - if not stopped or process is None: - self.send_json({"success": True, "stopped": False, "taskId": task_id, "task": snapshot or task}) - return - process.send_signal(signal.SIGTERM) - try: - process.wait(timeout=2) - except subprocess.TimeoutExpired: - process.kill() - snapshot = self.state.find_task(task_id) or snapshot or {} - self.send_json({"success": True, "stopped": True, "taskId": snapshot.get("id", task_id or ""), "task": snapshot}) - - def send_json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None: - data = json_bytes(payload) - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def send_error_json(self, status: HTTPStatus, message: str, failure_kind: str | None = None) -> None: - payload: dict[str, Any] = {"error": message, "success": False} - if failure_kind: - payload["failureKind"] = failure_kind - self.send_json(payload, status=status) - - def write_ndjson(self, payload: dict[str, Any]) -> None: - self.wfile.write(json_bytes(payload) + b"\n") - self.wfile.flush() - - def log_message(self, fmt: str, *args: Any) -> None: - sys.stderr.write("[mobilecode-helper] " + fmt % args + "\n") - - -class MobileCodeServer(ThreadingHTTPServer): - def __init__(self, address: tuple[str, int], state: HelperState) -> None: - super().__init__(address, MobileCodeHandler) - self.state = state - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run the MobileCode Helper daemon prototype.") - parser.add_argument("--host", default="127.0.0.1", help="Bind host, default: 127.0.0.1") - parser.add_argument("--port", default=8765, type=int, help="Bind port, default: 8765") - parser.add_argument( - "--workspace-root", - default=os.environ.get("MOBILECODE_WORKSPACE_ROOT", str(Path.home() / "mobilecode_projects")), - help="Allowed workspace root for command cwd.", - ) - parser.add_argument( - "--allow-unsafe", - action="store_true", - help="Disable command allowlist. Only use for trusted local debugging.", - ) - parser.add_argument( - "--auth-token", - default=os.environ.get("MOBILECODE_HELPER_TOKEN", ""), - help="Optional localhost token required through X-MobileCode-Token or Authorization: Bearer.", - ) - parser.add_argument( - "--max-tasks", - default=50, - type=int, - help="Maximum recoverable task snapshots to keep, default: 50.", - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - workspace_root = Path(args.workspace_root).expanduser() - workspace_root.mkdir(parents=True, exist_ok=True) - state = HelperState( - workspace_root=workspace_root, - allow_unsafe=args.allow_unsafe, - auth_token=args.auth_token, - max_tasks=max(1, args.max_tasks), - ) - server = MobileCodeServer((args.host, args.port), state) - auth_label = "auth: required" if state.auth_token else "auth: disabled" - print( - f"MobileCode Helper daemon listening on http://{args.host}:{args.port} " - f"(workspace: {state.workspace_root}, {auth_label})", - flush=True, - ) - try: - server.serve_forever() - except KeyboardInterrupt: - print("\nStopping MobileCode Helper daemon.", flush=True) - finally: - server.server_close() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) + "taskRegistry": { + "runningCount": self.state.running_task_count(), + "maxTasks": self.state.max_tasks, + }, + "missingDependencies": [], + "recoveryActions": [], + } + ) + return + if path == "/v1/tasks/current": + task = self.state.task_snapshot() + self.send_json( + { + "running": task.get("status") == "running", + "runningCount": self.state.running_task_count(), + "taskId": task.get("id", ""), + "command": task.get("command", ""), + "logs": task.get("logs", []), + "task": task, + } + ) + return + if path == "/v1/tasks": + limit = int((query.get("limit") or ["20"])[0]) + tasks = self.state.task_list(limit) + self.send_json({"tasks": tasks, "count": len(tasks)}) + return + if path.startswith("/v1/tasks/") and path.endswith("/logs"): + task_id = unquote(path.removeprefix("/v1/tasks/").removesuffix("/logs").strip("/")) + limit = int((query.get("limit") or ["200"])[0]) + self.send_json({"taskId": task_id, "logs": self.state.task_logs(task_id, limit)}) + return + self.send_error_json(HTTPStatus.NOT_FOUND, "Unknown endpoint") + except Exception as exc: # pragma: no cover - defensive server boundary + self.send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) + + def do_POST(self) -> None: # noqa: N802 + try: + if not self.authorized(): + self.send_error_json(HTTPStatus.UNAUTHORIZED, "Missing or invalid MobileCode Helper token", "authFailed") + return + + path = urlparse(self.path).path + if path == "/v1/execute": + self.handle_execute() + return + if path == "/v1/execute/stream": + self.handle_execute_stream() + return + if path == "/v1/project/preflight": + self.handle_project_preflight() + return + if path == "/v1/task/stop": + self.handle_stop() + return + if path.startswith("/v1/tasks/") and path.endswith("/stop"): + task_id = unquote(path.removeprefix("/v1/tasks/").removesuffix("/stop").strip("/")) + self.handle_stop(task_id=task_id) + return + self.send_error_json(HTTPStatus.NOT_FOUND, "Endpoint is not implemented in prototype") + except Exception as exc: + self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc)) + + def authorized(self) -> bool: + token = self.state.auth_token + if not token: + return True + header_token = self.headers.get("X-MobileCode-Token", "") + bearer = self.headers.get("Authorization", "") + return header_token == token or bearer == f"Bearer {token}" + + def handle_execute(self) -> None: + payload = read_json(self) + command = str(payload.get("command", "")) + cwd = self.state.validate_cwd(payload.get("cwd")) + env = payload.get("env") if isinstance(payload.get("env"), dict) else None + timeout_ms = int(payload.get("timeoutMs", 120000)) + args = self.state.command_args(command) + task = self.state.begin_task(command, cwd) + task_id = str(task["id"]) + self.state.append_log(task_id, f"task {task_id}: {command}") + started = time.monotonic() + merged_env = os.environ.copy() + if env: + merged_env.update({str(k): str(v) for k, v in env.items()}) + process = subprocess.Popen( + args, + cwd=str(cwd), + env=merged_env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.state.register_process(task_id, process) + timed_out = False + try: + stdout, stderr = process.communicate(timeout=max(timeout_ms / 1000, 1)) + except subprocess.TimeoutExpired: + timed_out = True + process.kill() + stdout, stderr = process.communicate() + stderr = (stderr or "") + f"\nCommand timed out after {timeout_ms}ms.\n" + duration_ms = int((time.monotonic() - started) * 1000) + exit_code = 124 if timed_out else process.returncode + for line in (stdout or "").splitlines(): + self.state.append_log(task_id, f"stdout: {line}") + for line in (stderr or "").splitlines(): + self.state.append_log(task_id, f"stderr: {line}") + self.state.clear_process(task_id, process) + cancelled = self.state.is_task_cancelled(task_id) + status = "cancelled" if cancelled else "timedOut" if timed_out else "succeeded" if exit_code == 0 else "failed" + self.state.finish_task(task_id, status, exit_code, duration_ms, (stderr or "").strip() or None) + failure_kind = (self.state.find_task(task_id) or {}).get("failureKind", "none") + self.send_json( + { + "command": command, + "stdout": stdout or "", + "stderr": stderr or "", + "exitCode": exit_code, + "durationMs": duration_ms, + "taskId": task_id, + "failureKind": failure_kind, + } + ) + + def handle_execute_stream(self) -> None: + payload = read_json(self) + command = str(payload.get("command", "")) + cwd = self.state.validate_cwd(payload.get("cwd")) + env = payload.get("env") if isinstance(payload.get("env"), dict) else None + args = self.state.command_args(command) + task = self.state.begin_task(command, cwd) + task_id = str(task["id"]) + self.state.append_log(task_id, f"task {task_id} stream: {command}") + + merged_env = os.environ.copy() + if env: + merged_env.update({str(k): str(v) for k, v in env.items()}) + + process = subprocess.Popen( + args, + cwd=str(cwd), + env=merged_env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=1, + universal_newlines=True, + ) + + self.state.register_process(task_id, process) + + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/x-ndjson") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + + started = time.monotonic() + output_queue: queue.Queue[tuple[str, str | None]] = queue.Queue() + + def pump(stream: Any, stream_type: str) -> None: + for line in iter(stream.readline, ""): + output_queue.put((stream_type, line.rstrip("\n"))) + output_queue.put((stream_type, None)) + + threads = [ + threading.Thread(target=pump, args=(process.stdout, "stdout"), daemon=True), + threading.Thread(target=pump, args=(process.stderr, "stderr"), daemon=True), + ] + for thread in threads: + thread.start() + + ended_streams = 0 + while ended_streams < 2: + stream_type, line = output_queue.get() + if line is None: + ended_streams += 1 + continue + self.state.append_log(task_id, f"{stream_type}: {line}") + self.write_ndjson({"type": stream_type, "data": line}) + + exit_code = process.wait() + duration_ms = int((time.monotonic() - started) * 1000) + self.state.clear_process(task_id, process) + cancelled = self.state.is_task_cancelled(task_id) + status = "cancelled" if cancelled else "succeeded" if exit_code == 0 else "failed" + self.state.finish_task(task_id, status, exit_code, duration_ms) + self.write_ndjson({"type": "exit", "exitCode": exit_code, "durationMs": duration_ms, "taskId": task_id}) + + def handle_project_preflight(self) -> None: + payload = read_json(self) + cwd = self.state.validate_cwd(payload.get("cwd")) + detected_files = self.state.inspect_project(cwd) + self.send_json( + { + "success": True, + "cwd": str(cwd), + "detectedFiles": detected_files, + } + ) + + def handle_stop(self, task_id: str | None = None) -> None: + if task_id is None: + task_id = self.state.current_running_task_id() + if task_id is None: + self.send_json({"success": True, "stopped": False}) + return + if task_id: + task = self.state.find_task(task_id) + if task is None: + self.send_error_json(HTTPStatus.NOT_FOUND, f"Task not found: {task_id}", "unknown") + return + process, snapshot, stopped = self.state.cancel_task(task_id) + if not stopped or process is None: + self.send_json({"success": True, "stopped": False, "taskId": task_id, "task": snapshot or task}) + return + process.send_signal(signal.SIGTERM) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + snapshot = self.state.find_task(task_id) or snapshot or {} + self.send_json({"success": True, "stopped": True, "taskId": snapshot.get("id", task_id or ""), "task": snapshot}) + + def send_json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None: + data = json_bytes(payload) + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def send_error_json(self, status: HTTPStatus, message: str, failure_kind: str | None = None) -> None: + payload: dict[str, Any] = {"error": message, "success": False} + if failure_kind: + payload["failureKind"] = failure_kind + self.send_json(payload, status=status) + + def write_ndjson(self, payload: dict[str, Any]) -> None: + self.wfile.write(json_bytes(payload) + b"\n") + self.wfile.flush() + + def log_message(self, fmt: str, *args: Any) -> None: + sys.stderr.write("[mobilecode-helper] " + fmt % args + "\n") + + +class MobileCodeServer(ThreadingHTTPServer): + def __init__(self, address: tuple[str, int], state: HelperState) -> None: + super().__init__(address, MobileCodeHandler) + self.state = state + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the MobileCode Helper daemon prototype.") + parser.add_argument("--host", default="127.0.0.1", help="Bind host, default: 127.0.0.1") + parser.add_argument("--port", default=8765, type=int, help="Bind port, default: 8765") + parser.add_argument( + "--workspace-root", + default=os.environ.get("MOBILECODE_WORKSPACE_ROOT", str(Path.home() / "mobilecode_projects")), + help="Allowed workspace root for command cwd.", + ) + parser.add_argument( + "--allow-unsafe", + action="store_true", + help="Disable command allowlist. Only use for trusted local debugging.", + ) + parser.add_argument( + "--auth-token", + default=os.environ.get("MOBILECODE_HELPER_TOKEN", ""), + help="Optional localhost token required through X-MobileCode-Token or Authorization: Bearer.", + ) + parser.add_argument( + "--max-tasks", + default=50, + type=int, + help="Maximum recoverable task snapshots to keep, default: 50.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + workspace_root = Path(args.workspace_root).expanduser() + workspace_root.mkdir(parents=True, exist_ok=True) + state = HelperState( + workspace_root=workspace_root, + allow_unsafe=args.allow_unsafe, + auth_token=args.auth_token, + max_tasks=max(1, args.max_tasks), + ) + server = MobileCodeServer((args.host, args.port), state) + auth_label = "auth: required" if state.auth_token else "auth: disabled" + print( + f"MobileCode Helper daemon listening on http://{args.host}:{args.port} " + f"(workspace: {state.workspace_root}, {auth_label})", + flush=True, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopping MobileCode Helper daemon.", flush=True) + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mobile_agent/tooling/prepare_android_project.py b/mobile_agent/tooling/prepare_android_project.py index a2895d5..89c04da 100644 --- a/mobile_agent/tooling/prepare_android_project.py +++ b/mobile_agent/tooling/prepare_android_project.py @@ -21,6 +21,13 @@ def main() -> None: manifest = Path('android/app/src/main/AndroidManifest.xml') text = manifest.read_text() text = text.replace('android:label="mobile_agent"', 'android:label="MobileCode"') + text = text.replace('android:icon="@mipmap/ic_launcher"', 'android:icon="@drawable/mobilecode_launcher"') + if 'android:roundIcon=' not in text: + text = text.replace( + 'android:icon="@drawable/mobilecode_launcher"', + 'android:icon="@drawable/mobilecode_launcher"\n android:roundIcon="@drawable/mobilecode_launcher"', + 1, + ) for permission in ( ' ', ' ', @@ -67,6 +74,25 @@ def main() -> None: ' android:theme="@android:style/Theme.NoDisplay" />' ) text = ensure_application_child(text, helper_launcher, 'android:name=".MobileCodeHelperLauncherActivity"') + if 'android:scheme="mobilecode"' not in text: + oauth_filter = ( + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' ' + ) + text = text.replace(' \n ', ' \n' + oauth_filter + '\n ', 1) + impeller_opt_out = ( + ' ' + ) + text = ensure_application_child(text, impeller_opt_out, 'io.flutter.embedding.android.EnableImpeller') manifest.write_text(text) gradle = Path('android/app/build.gradle.kts') @@ -121,6 +147,39 @@ def main() -> None: launch.parent.mkdir(parents=True, exist_ok=True) launch.write_text(launch_background) + launcher_icon = ''' + + + + + + + + + +''' + launcher = Path('android/app/src/main/res/drawable/mobilecode_launcher.xml') + launcher.parent.mkdir(parents=True, exist_ok=True) + launcher.write_text(launcher_icon) + activity = Path('android/app/src/main/kotlin/com/mobilecode/mobile_agent/MainActivity.kt') activity.parent.mkdir(parents=True, exist_ok=True) activity.write_text(Path('tooling/MainActivity.kt').read_text()) diff --git a/mobile_agent/tooling/run_mobilecode_helper_daemon.sh b/mobile_agent/tooling/run_mobilecode_helper_daemon.sh index 9308026..a3b7e70 100644 --- a/mobile_agent/tooling/run_mobilecode_helper_daemon.sh +++ b/mobile_agent/tooling/run_mobilecode_helper_daemon.sh @@ -1,15 +1,19 @@ -#!/data/data/com.termux/files/usr/bin/bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WORKSPACE_ROOT="${MOBILECODE_WORKSPACE_ROOT:-$HOME/mobilecode_projects}" - +#!/data/data/com.termux/files/usr/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_ROOT="${MOBILECODE_WORKSPACE_ROOT:-$HOME/mobilecode_projects}" + if ! command -v python3 >/dev/null 2>&1; then echo "python3 is required. In Termux run: pkg install -y python" >&2 exit 1 fi +if ! command -v git >/dev/null 2>&1; then + echo "warning: git is not installed. Repo Hub will use Remote-linked mode until you run: pkg install -y git" >&2 +fi + exec python3 "$SCRIPT_DIR/mobilecode_helper_daemon.py" \ - --host "${MOBILECODE_HELPER_HOST:-127.0.0.1}" \ - --port "${MOBILECODE_HELPER_PORT:-8765}" \ - --workspace-root "$WORKSPACE_ROOT" + --host "${MOBILECODE_HELPER_HOST:-127.0.0.1}" \ + --port "${MOBILECODE_HELPER_PORT:-8765}" \ + --workspace-root "$WORKSPACE_ROOT" diff --git a/promo/mobilecode-remotion/package-lock.json b/promo/mobilecode-remotion/package-lock.json new file mode 100644 index 0000000..6c07a2c --- /dev/null +++ b/promo/mobilecode-remotion/package-lock.json @@ -0,0 +1,2949 @@ +{ + "name": "mobilecode-promo-video", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mobilecode-promo-video", + "version": "0.1.0", + "dependencies": { + "@remotion/cli": "4.0.462", + "react": "19.2.0", + "react-dom": "19.2.0", + "remotion": "4.0.462" + }, + "devDependencies": { + "@types/react": "19.2.5", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", + "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.45.0.tgz", + "integrity": "sha512-vLQw8cY7Me6pvTTMkMhOiH9UCuINzfTOETCeDxbGNeNfDqc/7QlxloUH1Ylp/Zz2ek0O8kc6YdygV2vWAPakrA==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.45.0.tgz", + "integrity": "sha512-LfKbAMZVkxRS7PpEIVnWOY/l0KcHv+rjO7pYY3O0TPCZvbHWfrnQjn8JPacPIfuq6Yv7r4f8lhcl7yHSynoRkQ==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.45.0.tgz", + "integrity": "sha512-Bobi6AaQYEc7TWmPJ8Q0/hcUtBN7pLUC2qjoC7oZR4FcGqGztby6k7A1SWlmswoMOEIhYsOrgDaemrSDAC0QVQ==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@remotion/bundler": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.462.tgz", + "integrity": "sha512-GlKjrFkrkl/UgiSTCzMPxRtTnOswOUozo5kk0TmK27tDlF9Mkm6Vyy/Dk5UptPIxDeTi8RIvGWrRRhgVUgzzeQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/media-parser": "4.0.462", + "@remotion/studio": "4.0.462", + "@remotion/studio-shared": "4.0.462", + "@remotion/timeline-utils": "4.0.462", + "@rspack/core": "1.7.6", + "@rspack/plugin-react-refresh": "1.6.1", + "esbuild": "0.28.0", + "loader-utils": "2.0.4", + "postcss": "8.5.10", + "postcss-value-parser": "4.2.0", + "react-refresh": "0.18.0", + "remotion": "4.0.462", + "style-loader": "4.0.0", + "webpack": "5.105.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/cli": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/cli/-/cli-4.0.462.tgz", + "integrity": "sha512-ZFeXBM2bl1Eku8OroJyCOK+kflMTbJ7HBhUaH8qN3WSyX0dOngBKkNa9DRFbiDJVDGgE0ziG7uiSuU/oB1HPqQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/bundler": "4.0.462", + "@remotion/media-utils": "4.0.462", + "@remotion/player": "4.0.462", + "@remotion/renderer": "4.0.462", + "@remotion/studio": "4.0.462", + "@remotion/studio-server": "4.0.462", + "@remotion/studio-shared": "4.0.462", + "dotenv": "17.3.1", + "minimist": "1.2.6", + "prompts": "2.4.2", + "remotion": "4.0.462" + }, + "bin": { + "remotion": "remotion-cli.js", + "remotionb": "remotionb-cli.js", + "remotiond": "remotiond-cli.js" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/compositor-darwin-arm64": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.462.tgz", + "integrity": "sha512-soKDNA0jSppazP9jxbz4OFQRTVS9mIj5w+5EtmcH23gCdnZejjCEO8839YE7nhHi5C7wgqU/sdkwRs3FyfP+tg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-darwin-x64": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.462.tgz", + "integrity": "sha512-xAkk7095Ud/Ugs3SP7GERnTwm2sljuZ4K89Gb5Y8tvdObqH70u1TMobzNWPcBOJcYOl7hgfc4dqV9pvMTHo+hw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-gnu": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.462.tgz", + "integrity": "sha512-8Qy3lGKQsX/Kuxlji9AENnlpzRSDuG/K5DIe7o73YBitxey539sp1D3Ww0VCrE0uyH4Gj85KiPrT8YEESMcpCA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-musl": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.462.tgz", + "integrity": "sha512-dZjq3H8GngDdZs3/cqOdrPKOhW1UhQb99emYNx+G4Mv4kIpqX3sOEGYZNokCDZxR8hiAehI8BqblHk1NGDa0mQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-gnu": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.462.tgz", + "integrity": "sha512-nfqqBOc81+CnkmoOFhQF0EzqiGK1N5FwrcwrxexJxoQkxpNk4kMRp2CKyaOd6cHEx1hQKKCl/pPiScEez8ZwfA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-musl": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.462.tgz", + "integrity": "sha512-AFNmG/Ll2lP3Hh9E7qjWkTIKaNfxZwQJeke2bWmcOV+tlWJa/mFccNVHnoDP5NpjpYwAHvemCUxmPIV1l2/7lw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-win32-x64-msvc": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.462.tgz", + "integrity": "sha512-lXjrweVS3cEpfR33TH7so93GKUtx77zfK0iKBkrTcwZw7IIYYIBG8Y7H2M2MTIW3xQDPtuJPhOuVTiUFcocmcw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@remotion/licensing": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.462.tgz", + "integrity": "sha512-NbTiGVmqp7NJsLbjJP84jB5oLO92a6TJiq2KckpdpZlekLPbITpKp984rXMZjZgE/B0LkeYkIHRNO75AikFQLQ==", + "license": "MIT" + }, + "node_modules/@remotion/media-parser": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.462.tgz", + "integrity": "sha512-wSXZcVhNppSxsw+kDF6Y00YqnTQvw88OU3sM6P2JR6IKzsKq2bSMgm4amqVs1K1hP7m3CAdx4NqG/5H9a9/kzw==", + "license": "Remotion License https://remotion.dev/license" + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.462.tgz", + "integrity": "sha512-naC7SVMO979E6kRzXxL3H1IoaNdTucE+7NbYDbt6ixrlsFtenpiRaVH0RlJPmYXmcwss3dvmmabVwrQ7RrMB5Q==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.45.0", + "remotion": "4.0.462" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/player": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.462.tgz", + "integrity": "sha512-w/zBCWwLXsoNwsokaPwKxpdv+CBw52FuR3pQZegZvcnKwx1d8bq2P4xBjhCs+o5XP7kiRS0FQIIFYiCmXZXYgw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.462" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.462.tgz", + "integrity": "sha512-qwOsGvtGb3CIpYMrVeO+wTwJN+C3uigT9gKyM0tl24IrPbq8UWqkX1mDJv5pYcWUbCj1mEdeakh7ue9+zgdEgA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/licensing": "4.0.462", + "@remotion/streaming": "4.0.462", + "execa": "5.1.1", + "extract-zip": "2.0.1", + "remotion": "4.0.462", + "source-map": "0.8.0-beta.0", + "ws": "8.17.1" + }, + "optionalDependencies": { + "@remotion/compositor-darwin-arm64": "4.0.462", + "@remotion/compositor-darwin-x64": "4.0.462", + "@remotion/compositor-linux-arm64-gnu": "4.0.462", + "@remotion/compositor-linux-arm64-musl": "4.0.462", + "@remotion/compositor-linux-x64-gnu": "4.0.462", + "@remotion/compositor-linux-x64-musl": "4.0.462", + "@remotion/compositor-win32-x64-msvc": "4.0.462" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/streaming": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.462.tgz", + "integrity": "sha512-o3+GgxZURUr1wwlTSKg3JRIF/r3ctSMUrMFB3rjxlcXKNW4Z/Izkho2lWmZlONYtZHJVshg6hfS3OxjTK2zwdA==", + "license": "MIT" + }, + "node_modules/@remotion/studio": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.462.tgz", + "integrity": "sha512-Rl+VhBesr1cCDuFogE2p+oAIF/IVM0fQYZDWWSLHFwF37gb6jLIfEgrrFqgarPwNTAEZwY2qqr2c7J3uooeH1Q==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@remotion/media-utils": "4.0.462", + "@remotion/player": "4.0.462", + "@remotion/renderer": "4.0.462", + "@remotion/studio-shared": "4.0.462", + "@remotion/timeline-utils": "4.0.462", + "@remotion/web-renderer": "4.0.462", + "@remotion/zod-types": "4.0.462", + "mediabunny": "1.45.0", + "memfs": "3.4.3", + "open": "8.4.2", + "remotion": "4.0.462", + "semver": "7.5.3", + "zod": "4.3.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/studio-server": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/studio-server/-/studio-server-4.0.462.tgz", + "integrity": "sha512-RI4KwdLju6yKO1IDDAtLVy0gelYf+rfLqhpRpRoutFj3X9euajbX2o7Fghfc3Qn6UfAz3VA78U8z4ZO75BAeQg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "7.24.1", + "@babel/types": "7.24.0", + "@remotion/bundler": "4.0.462", + "@remotion/renderer": "4.0.462", + "@remotion/studio-shared": "4.0.462", + "memfs": "3.4.3", + "open": "8.4.2", + "prettier": "3.8.1", + "recast": "0.23.11", + "remotion": "4.0.462", + "semver": "7.5.3" + } + }, + "node_modules/@remotion/studio-shared": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.462.tgz", + "integrity": "sha512-j8Q5+E4IWqpKWwSanxGI7+WG1rblpyf9Fb65cUoXmGrC28gq+dMqvPo+od2gSd77hfKZ80Po1u45cdP83B9tNA==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.462" + } + }, + "node_modules/@remotion/timeline-utils": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/timeline-utils/-/timeline-utils-4.0.462.tgz", + "integrity": "sha512-xC6ErhwO9Jz6oP+0vfJHJVxEl7ZVTbyJOEbz4BzT1h0Wq4f6d9hH7d8VvmrvZ2IQwb1VaqrkR7URvaW+VfJ14A==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.45.0" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.462.tgz", + "integrity": "sha512-Wj2IIACwNaRWGxc79LKs1vzbIEzhvs63u1rnJwvh+EDuHbhMZBCU7wJGVuSKZifNsKb9k999HiSS43d+j1OhpQ==", + "license": "UNLICENSED", + "dependencies": { + "@mediabunny/aac-encoder": "1.45.0", + "@mediabunny/flac-encoder": "1.45.0", + "@mediabunny/mp3-encoder": "1.45.0", + "@remotion/licensing": "4.0.462", + "mediabunny": "1.45.0", + "remotion": "4.0.462" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remotion/zod-types": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.462.tgz", + "integrity": "sha512-q+wgcvD4AVFg17hPvpklZUU1oUt/A/tK1dTNi1o7NyXZmqic3Ko0iZpW01lEe5AsOW7so4XSWgv89Gs+r/R4dQ==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.462" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@rspack/binding": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.6.tgz", + "integrity": "sha512-/NrEcfo8Gx22hLGysanrV6gHMuqZSxToSci/3M4kzEQtF5cPjfOv5pqeLK/+B6cr56ul/OmE96cCdWcXeVnFjQ==", + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.6", + "@rspack/binding-darwin-x64": "1.7.6", + "@rspack/binding-linux-arm64-gnu": "1.7.6", + "@rspack/binding-linux-arm64-musl": "1.7.6", + "@rspack/binding-linux-x64-gnu": "1.7.6", + "@rspack/binding-linux-x64-musl": "1.7.6", + "@rspack/binding-wasm32-wasi": "1.7.6", + "@rspack/binding-win32-arm64-msvc": "1.7.6", + "@rspack/binding-win32-ia32-msvc": "1.7.6", + "@rspack/binding-win32-x64-msvc": "1.7.6" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.6.tgz", + "integrity": "sha512-NZ9AWtB1COLUX1tA9HQQvWpTy07NSFfKBU8A6ylWd5KH8AePZztpNgLLAVPTuNO4CZXYpwcoclf8jG/luJcQdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.6.tgz", + "integrity": "sha512-J2g6xk8ZS7uc024dNTGTHxoFzFovAZIRixUG7PiciLKTMP78svbSSWrmW6N8oAsAkzYfJWwQpVgWfFNRHvYxSw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.6.tgz", + "integrity": "sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.6.tgz", + "integrity": "sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.6.tgz", + "integrity": "sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.6.tgz", + "integrity": "sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.6.tgz", + "integrity": "sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.6.tgz", + "integrity": "sha512-INj7aVXjBvlZ84kEhSK4kJ484ub0i+BzgnjDWOWM1K+eFYDZjLdAsQSS3fGGXwVc3qKbPIssFfnftATDMTEJHQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.6.tgz", + "integrity": "sha512-lXGvC+z67UMcw58In12h8zCa9IyYRmuptUBMItQJzu+M278aMuD1nETyGLL7e4+OZ2lvrnnBIcjXN1hfw2yRzw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.6.tgz", + "integrity": "sha512-zeUxEc0ZaPpmaYlCeWcjSJUPuRRySiSHN23oJ2Xyw0jsQ01Qm4OScPdr0RhEOFuK/UE+ANyRtDo4zJsY52Hadw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.6.tgz", + "integrity": "sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.6", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", + "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.11.tgz", + "integrity": "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", + "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", + "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", + "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "license": "ISC" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "license": "Unlicense" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mediabunny": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.45.0.tgz", + "integrity": "sha512-oK3sMMYbucoF6LUX62L/2M9d+p9ve6KDQgL87kNfhsB0/XmTe9iRLUcgQgg9Gpgvi8Sb96zYfOUL6i17y0bdNg==", + "license": "MPL-2.0", + "peer": true, + "workspaces": [ + ".", + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/memfs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", + "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remotion": { + "version": "4.0.462", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.462.tgz", + "integrity": "sha512-0bcwIiN7H0IcHtcDlfWHE2ho46RF0VmnVN1fAc26uMkonmAMMhqB+1aogfTE8eiYESEzFokh0lRc0t6pI8wzNA==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.47.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz", + "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz", + "integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/promo/mobilecode-remotion/package.json b/promo/mobilecode-remotion/package.json new file mode 100644 index 0000000..0287ba2 --- /dev/null +++ b/promo/mobilecode-remotion/package.json @@ -0,0 +1,28 @@ +{ + "name": "mobilecode-promo-video", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "remotion studio src/index.ts", + "render:vertical": "remotion render src/index.ts MobileCodeVertical ../../docs/assets/mobilecode-promo-vertical.mp4 --codec h264 --crf 28 --pixel-format yuv420p", + "render:readme": "remotion render src/index.ts MobileCodeReadmeCover ../../docs/assets/mobilecode-readme-cover.mp4 --codec h264 --crf 28 --pixel-format yuv420p", + "render:principle": "remotion render src/index.ts MobileCodePrincipleExplainer ../../docs/assets/mobilecode-principle-remotion.mp4 --codec h264 --crf 28 --pixel-format yuv420p", + "render:principle:poster": "remotion still src/index.ts MobileCodePrincipleExplainer ../../docs/assets/mobilecode-principle-poster.png --frame=90", + "render:short": "remotion render src/index.ts MobileCodeShortTeaser ../../docs/assets/mobilecode-short-teaser.mp4 --codec h264 --crf 28 --pixel-format yuv420p", + "render:short:poster": "remotion still src/index.ts MobileCodeShortTeaser ../../docs/assets/mobilecode-short-poster.png --frame=60", + "voiceover": "powershell -ExecutionPolicy Bypass -File scripts/generate-voiceover.ps1", + "render": "npm run render:vertical && npm run render:readme && npm run render:principle && npm run render:principle:poster && npm run render:short && npm run render:short:poster" + }, + "dependencies": { + "@remotion/cli": "4.0.462", + "react": "19.2.0", + "react-dom": "19.2.0", + "remotion": "4.0.462" + }, + "devDependencies": { + "@types/react": "19.2.5", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3" + } +} diff --git a/promo/mobilecode-remotion/public/audio/mobilecode-principle-voiceover.wav b/promo/mobilecode-remotion/public/audio/mobilecode-principle-voiceover.wav new file mode 100644 index 0000000..4f939da Binary files /dev/null and b/promo/mobilecode-remotion/public/audio/mobilecode-principle-voiceover.wav differ diff --git a/promo/mobilecode-remotion/public/audio/mobilecode-short-voiceover.wav b/promo/mobilecode-remotion/public/audio/mobilecode-short-voiceover.wav new file mode 100644 index 0000000..55dd2cf Binary files /dev/null and b/promo/mobilecode-remotion/public/audio/mobilecode-short-voiceover.wav differ diff --git a/promo/mobilecode-remotion/public/recordings/.gitkeep b/promo/mobilecode-remotion/public/recordings/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/promo/mobilecode-remotion/public/recordings/.gitkeep @@ -0,0 +1 @@ + diff --git a/promo/mobilecode-remotion/public/recordings/README.md b/promo/mobilecode-remotion/public/recordings/README.md new file mode 100644 index 0000000..d220656 --- /dev/null +++ b/promo/mobilecode-remotion/public/recordings/README.md @@ -0,0 +1,13 @@ +# Recording Slots + +Put phone screen recordings here when you want the Remotion promo to use real product footage. + +Recommended clips: + +- `phone-chat-generate.mp4` — chat prompt, trace progress, generated artifact card. +- `phone-github-pages.mp4` — publish GitHub Pages and show the success card. +- `phone-repo-hub.mp4` — Repo Hub search, Pages badge, Actions/artifact surface. + +Then edit `src/recordedClips.ts` and set `enabled: true` for the clips you want to include. + +These raw recordings are ignored by git by default because they can be large or contain private tokens. Commit only polished rendered outputs in `docs/assets/`. diff --git a/promo/mobilecode-remotion/remotion.config.ts b/promo/mobilecode-remotion/remotion.config.ts new file mode 100644 index 0000000..3bb9d85 --- /dev/null +++ b/promo/mobilecode-remotion/remotion.config.ts @@ -0,0 +1,4 @@ +import {Config} from '@remotion/cli/config'; + +Config.setOverwriteOutput(true); +Config.setVideoImageFormat('jpeg'); diff --git a/promo/mobilecode-remotion/scripts/generate-voiceover.ps1 b/promo/mobilecode-remotion/scripts/generate-voiceover.ps1 new file mode 100644 index 0000000..e3b274e --- /dev/null +++ b/promo/mobilecode-remotion/scripts/generate-voiceover.ps1 @@ -0,0 +1,63 @@ +param( + [string]$VoiceName = "Microsoft Huihui Desktop", + [int]$PrincipleRate = 3, + [int]$ShortRate = 6 +) + +Add-Type -AssemblyName System.Speech + +$root = Split-Path -Parent $PSScriptRoot +$audioDir = Join-Path $root "public\audio" +New-Item -ItemType Directory -Force -Path $audioDir | Out-Null + +function Save-Voiceover { + param( + [string]$Path, + [string]$Text, + [string]$VoiceName, + [int]$Rate + ) + + $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer + try { + $synth.SelectVoice($VoiceName) + } catch { + $culture = New-Object System.Globalization.CultureInfo("zh-CN") + $synth.SelectVoiceByHints( + [System.Speech.Synthesis.VoiceGender]::Female, + [System.Speech.Synthesis.VoiceAge]::Adult, + 0, + $culture + ) + } + $synth.Rate = $Rate + $synth.Volume = 96 + $synth.SetOutputToWaveFile($Path) + $synth.Speak($Text) + $synth.SetOutputToNull() + $synth.Dispose() +} + +$principleText = [System.IO.File]::ReadAllText( + (Join-Path $PSScriptRoot "voiceover-principle.zh.txt"), + [System.Text.Encoding]::UTF8 +) + +$shortText = [System.IO.File]::ReadAllText( + (Join-Path $PSScriptRoot "voiceover-short.zh.txt"), + [System.Text.Encoding]::UTF8 +) + +Save-Voiceover ` + -Path (Join-Path $audioDir "mobilecode-principle-voiceover.wav") ` + -Text $principleText ` + -VoiceName $VoiceName ` + -Rate $PrincipleRate + +Save-Voiceover ` + -Path (Join-Path $audioDir "mobilecode-short-voiceover.wav") ` + -Text $shortText ` + -VoiceName $VoiceName ` + -Rate $ShortRate + +Write-Host "Generated voiceovers in $audioDir" diff --git a/promo/mobilecode-remotion/scripts/voiceover-principle.zh.txt b/promo/mobilecode-remotion/scripts/voiceover-principle.zh.txt new file mode 100644 index 0000000..efba32c --- /dev/null +++ b/promo/mobilecode-remotion/scripts/voiceover-principle.zh.txt @@ -0,0 +1,19 @@ +MobileCode 不是云端 IDE 的手机外壳。 +它把 AI coding harness 真正放到手机上。 +模型可以远程,但对话、文件、工具状态、预览和发布控制,都留在手机本机。 + +手机写代码的痛点,不只是屏幕小。 +真正的问题,是执行层不清楚。 +用户不应该猜测任务到底跑在 App、Termux、云端 shell、GitHub,还是隐藏的预览环境。 + +MobileCode 的答案,是手机保留控制面,重任务交给外部平台。 +它负责 agent trace、本地文件、WebView 预览、运行时诊断、仓库上下文和最终作品卡。 + +RuntimeProvider 把执行能力变成可替换的契约。 +Helper、Termux、WebViewOnly、Embedded Lite、Cloud Runtime,都可以接在同一个接口后面。 + +GitHub-first 让手机保持轻量,但工作流仍然真实。 +Repo Hub 发现仓库,Contents API 提交文件,GitHub Pages 发布网页,Actions 负责重型构建和产物。 + +最终,手机可以成为 AI coding 的控制室。 +它不需要本地编译一切,而是把生成、预览、解释、恢复和发布决策,放回用户手里。 diff --git a/promo/mobilecode-remotion/scripts/voiceover-short.zh.txt b/promo/mobilecode-remotion/scripts/voiceover-short.zh.txt new file mode 100644 index 0000000..165009c --- /dev/null +++ b/promo/mobilecode-remotion/scripts/voiceover-short.zh.txt @@ -0,0 +1,6 @@ +MobileCode 不是远程 IDE 外壳。 +它是真正运行在手机上的 AI coding harness。 +手机负责生成、预览和解释。 +Helper、Termux、GitHub Actions 负责执行和构建。 +从一句话到 HTML,从 WebView 到 GitHub Pages。 +MobileCode,让手机成为 AI coding 控制室。 diff --git a/promo/mobilecode-remotion/src/MobileCodePrincipleExplainer.tsx b/promo/mobilecode-remotion/src/MobileCodePrincipleExplainer.tsx new file mode 100644 index 0000000..bd5f4bc --- /dev/null +++ b/promo/mobilecode-remotion/src/MobileCodePrincipleExplainer.tsx @@ -0,0 +1,613 @@ +import React from 'react'; +import {AbsoluteFill, Audio, Easing, Sequence, interpolate, staticFile, useCurrentFrame} from 'remotion'; + +const fps = 30; +const sceneFrames = 375; + +const colors = { + bg: '#F7FAFF', + surface: '#FFFFFF', + ink: '#0B1020', + muted: '#536079', + line: '#DDE7F7', + blue: '#2555FF', + mint: '#0B9B7E', + purple: '#7557E8', + amber: '#B7791F', + red: '#D64562', + dark: '#111827', + softBlue: '#E9EEFF', + softMint: '#EAF8F3', + softPurple: '#F0ECFF', + softAmber: '#FFF6E5', + softRed: '#FFF0F4', +}; + +type Tint = 'blue' | 'mint' | 'purple' | 'amber' | 'red' | 'dark'; + +const tintMap: Record = { + blue: {fg: colors.blue, bg: colors.softBlue}, + mint: {fg: colors.mint, bg: colors.softMint}, + purple: {fg: colors.purple, bg: colors.softPurple}, + amber: {fg: colors.amber, bg: colors.softAmber}, + red: {fg: colors.red, bg: colors.softRed}, + dark: {fg: colors.dark, bg: '#EEF2F7'}, +}; + +const scenes = [ + { + eyebrow: 'Why MobileCode exists', + title: 'AI coding is moving to the phone.', + body: + 'But the phone should not pretend to be a desktop workstation. It needs a smaller, clearer harness for generation, preview, recovery, and shipping.', + caption: 'Not a cloud IDE wrapper. A phone-native coding harness.', + subtitle: 'MobileCode 不是云端 IDE 外壳,而是把 AI coding harness 真正放到手机上。', + type: 'hero', + }, + { + eyebrow: 'The pain', + title: 'Mobile coding breaks when execution is unclear.', + body: + 'Users do not want to debug whether an action belongs to the app, Termux, a cloud shell, GitHub, or a hidden preview.', + caption: 'The problem is not the screen size. The problem is an undefined execution layer.', + subtitle: '手机写代码的核心痛点不是屏幕小,而是执行层不清楚、失败不可恢复。', + type: 'pain', + }, + { + eyebrow: 'The answer', + title: 'Keep the harness on the phone. Move heavy work outward.', + body: + 'MobileCode owns chat state, tool trace, local files, WebView preview, runtime diagnostics, repo context, and the final work card.', + caption: 'Local control plane. External heavy lifting.', + subtitle: '手机保留对话、文件、预览、诊断和发布控制,把重构建交给外部平台。', + type: 'solution', + }, + { + eyebrow: 'Runtime principle', + title: 'RuntimeProvider turns execution into a replaceable contract.', + body: + 'The UI should not care whether work runs through Helper, Termux, WebViewOnly, Embedded Lite, or Cloud Runtime.', + caption: 'Interface first, backend second.', + subtitle: 'RuntimeProvider 让 Helper、Termux、WebViewOnly、Cloud 都成为可替换后端。', + type: 'architecture', + }, + { + eyebrow: 'GitHub-first loop', + title: 'The phone edits and explains. GitHub stores, builds, and ships.', + body: + 'Repo Hub, Contents API commits, Pages publishing, Actions runs, and release artifacts keep MobileCode lightweight but real.', + caption: 'Prompt to file to preview to repository to Pages or Actions.', + subtitle: 'GitHub 负责仓库、Pages、Actions 和产物,MobileCode 负责手机端闭环体验。', + type: 'github', + }, + { + eyebrow: 'Outcome', + title: 'A phone can become the AI coding control room.', + body: + 'Not because it compiles everything locally, but because it keeps the user-facing harness, state, explanations, previews, and shipping decisions close to the user.', + caption: 'MobileCode — build, preview, publish from your phone.', + subtitle: '最终目标:在手机上生成、预览、解释、发布,而不是伪装成桌面环境。', + type: 'outcome', + }, +] as const; + +export const principleDurationInFrames = scenes.length * sceneFrames; + +const fade = (frame: number) => { + const inOpacity = interpolate(frame, [0, 28], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.out(Easing.cubic), + }); + const outOpacity = interpolate(frame, [sceneFrames - 28, sceneFrames], [1, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.in(Easing.cubic), + }); + return Math.min(inOpacity, outOpacity); +}; + +const rise = (frame: number, delay = 0, distance = 34) => + interpolate(frame, [delay, delay + 36], [distance, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.out(Easing.cubic), + }); + +const reveal = (frame: number, delay = 0) => + interpolate(frame, [delay, delay + 30], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.out(Easing.cubic), + }); + +const Pill = ({children, tint}: {children: React.ReactNode; tint: Tint}) => { + const token = tintMap[tint]; + return ( + + {children} + + ); +}; + +const Card = ({ + title, + body, + tint, + index, + frame, +}: { + title: string; + body: string; + tint: Tint; + index: number; + frame: number; +}) => { + const token = tintMap[tint]; + const delay = 54 + index * 18; + return ( +
+
+ {index + 1} +
+
+
{title}
+
+ {body} +
+
+
+ ); +}; + +const PhoneHarness = ({frame}: {frame: number}) => { + const steps = ['Parse intent', 'Select tool', 'Call model', 'Write artifact', 'Preview', 'Publish']; + const done = Math.floor(interpolate(frame, [35, 220], [1, steps.length], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + })); + + return ( +
+
+
+
MobileCode
+ ready +
+
+
Runtime ready
+
+ Helper · Termux · WebViewOnly +
+
+
+ {steps.map((step, index) => { + const active = index < done; + return ( +
+ + {active ? '✓' : index + 1} + + {step} +
+ ); + })} +
+
+ Publish GitHub Pages +
+
+
+ ); +}; + +const HeaderText = ({ + eyebrow, + title, + body, + frame, + centered = false, +}: { + eyebrow: string; + title: string; + body: string; + frame: number; + centered?: boolean; +}) => { + return ( +
+
+ {eyebrow} +
+

+ {title} +

+

+ {body} +

+
+ ); +}; + +const SceneFrame = ({scene, index}: {scene: (typeof scenes)[number]; index: number}) => { + const frame = useCurrentFrame(); + const p = interpolate(frame, [0, sceneFrames], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + + const layout: React.CSSProperties = + scene.type === 'hero' || scene.type === 'outcome' + ? { + position: 'absolute', + inset: 96, + display: 'grid', + placeItems: 'center', + textAlign: 'center', + } + : { + position: 'absolute', + inset: 92, + display: 'grid', + gridTemplateColumns: '0.92fr 1.08fr', + alignItems: 'center', + gap: 70, + }; + + return ( + +
+ +
+ {scene.type === 'hero' && ( +
+ +
+ phone-native harness + remote model optional + GitHub-first shipping +
+
+ )} + + {scene.type === 'pain' && ( + <> + +
+ + + +
+ + )} + + {scene.type === 'solution' && ( + <> +
+ +
+
+ +
+ agent trace + file cards + preview + publish +
+
+ + )} + + {scene.type === 'architecture' && ( + <> + +
+ + + +
+ + )} + + {scene.type === 'github' && ( + <> + +
+ + + +
+ + )} + + {scene.type === 'outcome' && ( +
+ +
+ {[ + ['Local', 'chat, trace, files, preview, recovery', 'blue'], + ['Routed', 'Helper, Termux, WebViewOnly, Cloud', 'purple'], + ['Shipped', 'GitHub Pages, Actions, artifacts', 'mint'], + ].map(([title, body, tint]) => { + const token = tintMap[tint as Tint]; + return ( +
+
{title}
+
{body}
+
+ ); + })} +
+
+ )} +
+ +
+
+ {scene.subtitle} +
+
+
+ + Scene {String(index + 1).padStart(2, '0')} /{' '} + {String(scenes.length).padStart(2, '0')} + + {scene.caption} +
+
+
+
+ + ); +}; + +export const MobileCodePrincipleExplainer = () => { + return ( + + + ); +}; diff --git a/promo/mobilecode-remotion/src/MobileCodePromo.tsx b/promo/mobilecode-remotion/src/MobileCodePromo.tsx new file mode 100644 index 0000000..3ed7e48 --- /dev/null +++ b/promo/mobilecode-remotion/src/MobileCodePromo.tsx @@ -0,0 +1,911 @@ +import React from 'react'; +import { + AbsoluteFill, + Easing, + OffthreadVideo, + Sequence, + interpolate, + spring, + staticFile, + useCurrentFrame, + useVideoConfig, +} from 'remotion'; +import {recordedClips} from './recordedClips'; + +type PromoFormat = 'vertical' | 'wide'; + +type PromoProps = { + format: PromoFormat; +}; + +const colors = { + bg: '#F7FAFF', + surface: '#FFFFFF', + ink: '#0B1020', + muted: '#536079', + border: '#DDE7F7', + blue: '#2555FF', + mint: '#0B9B7E', + mintSoft: '#EAF8F3', + purple: '#7557E8', + purpleSoft: '#F0ECFF', + amber: '#B7791F', + amberSoft: '#FFF6E5', + dark: '#111827', +}; + +const scenes = { + hook: {from: 0, duration: 150}, + trace: {from: 150, duration: 180}, + artifact: {from: 330, duration: 180}, + runtime: {from: 510, duration: 180}, + github: {from: 690, duration: 210}, + proof: {from: 900, duration: 180}, + close: {from: 1080, duration: 180}, +}; + +const clamp = (value: number, min = 0, max = 1) => Math.min(max, Math.max(min, value)); + +const sceneProgress = (frame: number, from: number, duration: number) => + clamp((frame - from) / duration); + +const fadeInOut = (frame: number, from: number, duration: number) => { + const start = interpolate(frame, [from, from + 24], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.out(Easing.cubic), + }); + const end = interpolate(frame, [from + duration - 24, from + duration], [1, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.in(Easing.cubic), + }); + return Math.min(start, end); +}; + +const lineReveal = (frame: number, offset: number) => + interpolate(frame, [offset, offset + 34], [26, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.out(Easing.cubic), + }); + +const stackBase: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', +}; + +const enabledRecordings = recordedClips.filter((clip) => clip.enabled); + +const pill = ( + text: string, + tint: 'blue' | 'mint' | 'purple' | 'amber' | 'dark', +): React.CSSProperties => { + const map = { + blue: [colors.blue, '#E9EEFF'], + mint: [colors.mint, colors.mintSoft], + purple: [colors.purple, colors.purpleSoft], + amber: [colors.amber, colors.amberSoft], + dark: [colors.dark, '#EEF2F7'], + } as const; + const [fg, bg] = map[tint]; + return { + color: fg, + background: bg, + border: `2px solid ${fg}22`, + borderRadius: 999, + padding: '10px 18px', + fontWeight: 900, + fontSize: 24, + lineHeight: 1, + whiteSpace: 'nowrap', + }; +}; + +const Shell = ({children, format}: {children: React.ReactNode; format: PromoFormat}) => { + const isWide = format === 'wide'; + return ( + +
+ {children} +
+
+ ); +}; + +const PhoneMock = ({ + progress, + compact = false, +}: { + progress: number; + compact?: boolean; +}) => { + const traceDone = Math.floor(interpolate(progress, [0, 1], [1, 5], {extrapolateRight: 'clamp'})); + return ( +
+
+
+
+ ✓ +
+
+
MobileCode
+
+ Phone-native AI coding harness +
+
+
+ +
+
Runtime ready
+
+ WebView Only · Helper · Termux fallback +
+
+ +
+ {['Parse instruction', 'Select tool', 'Call model provider', 'Write artifact', 'Preview result'].map( + (label, index) => { + const done = index < traceDone; + return ( +
+
+ {done ? '✓' : index + 1} +
+
{label}
+
+ ); + }, + )} +
+ +
+
Generated artifact
+
+ mobilecode_projects/agent_snake/index.html +
+
+ Code + Preview + Publish +
+
+
+
+ ); +}; + +const Headline = ({ + eyebrow, + title, + body, + frame, + from, + align = 'left', +}: { + eyebrow: string; + title: string; + body: string; + frame: number; + from: number; + align?: 'left' | 'center'; +}) => ( +
+
+ {eyebrow} +
+
+ {title} +
+
+ {body} +
+
+); + +const FeatureCard = ({ + title, + body, + tint, + delay, +}: { + title: string; + body: string; + tint: 'blue' | 'mint' | 'purple' | 'amber'; + delay: number; +}) => { + const frame = useCurrentFrame(); + const opacity = interpolate(frame, [delay, delay + 24], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const y = interpolate(frame, [delay, delay + 24], [36, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.out(Easing.cubic), + }); + const tintColor = colors[tint]; + const soft = { + blue: '#E9EEFF', + mint: colors.mintSoft, + purple: colors.purpleSoft, + amber: colors.amberSoft, + }[tint]; + return ( +
+
+ ◆ +
+
{title}
+
+ {body} +
+
+ ); +}; + +const RecordingReel = ({frame, isWide}: {frame: number; isWide: boolean}) => { + if (enabledRecordings.length === 0) { + return null; + } + + const localFrame = frame >= scenes.artifact.from ? frame - scenes.artifact.from : frame; + const slotLength = Math.max(1, Math.floor(scenes.artifact.duration / enabledRecordings.length)); + const activeIndex = Math.min(enabledRecordings.length - 1, Math.floor(localFrame / slotLength)); + const activeClip = enabledRecordings[activeIndex] ?? enabledRecordings[0]; + + return ( +
+
+
+ +
+
+
+
+ {activeClip.title} +
+
+ {activeClip.caption} +
+
+ {enabledRecordings.map((clip, index) => ( + + {index + 1}. {clip.title} + + ))} +
+
+
+ ); +}; + +const HookScene = ({format}: {format: PromoFormat}) => { + const frame = useCurrentFrame(); + const {fps} = useVideoConfig(); + const phoneScale = spring({frame, fps, config: {damping: 18, stiffness: 120}}); + const isWide = format === 'wide'; + return ( + +
+
+ +
+ Phone-native harness + Remote model optional +
+
+
+ +
+
+
+ ); +}; + +const TraceScene = ({format}: {format: PromoFormat}) => { + const frame = useCurrentFrame(); + const p = sceneProgress(frame, scenes.trace.from, scenes.trace.duration); + const isWide = format === 'wide'; + return ( + +
+ +
+ +
+
+
+ ); +}; + +const ArtifactScene = ({format}: {format: PromoFormat}) => { + const frame = useCurrentFrame(); + const isWide = format === 'wide'; + const hasRecordings = enabledRecordings.length > 0; + return ( + +
+ + {hasRecordings ? ( + + ) : ( +
+ + + + +
+ )} +
+
+ ); +}; + +const RuntimeScene = ({format}: {format: PromoFormat}) => { + const frame = useCurrentFrame(); + const isWide = format === 'wide'; + const providers = [ + ['WebViewOnly', 'Preview when shell is unavailable', 'mint'], + ['MobileCode Helper', 'Foreground service for controlled execution', 'blue'], + ['External Termux', 'Fallback shell and tools', 'amber'], + ['Cloud Runtime', 'Heavy builds later', 'purple'], + ] as const; + return ( + +
+ +
+ {providers.map(([title, body, tint], index) => ( + + ))} +
+
+
+ ); +}; + +const GithubScene = ({format}: {format: PromoFormat}) => { + const frame = useCurrentFrame(); + const isWide = format === 'wide'; + const cards = [ + ['Public repo search', 'Any GitHub repo can be discovered without forcing login.', 'blue'], + ['Owner repo management', 'Your token unlocks create, commit, Pages, and Actions.', 'mint'], + ['Repo chat binding', 'Talk to MobileCode with a specific repo context.', 'purple'], + ['Release assets', 'Find APK, zip, and artifacts from GitHub surfaces.', 'amber'], + ] as const; + return ( + +
+ +
+ {cards.map(([title, body, tint], index) => ( + + ))} +
+
+
+ ); +}; + +const ProofScene = ({format}: {format: PromoFormat}) => { + const frame = useCurrentFrame(); + const isWide = format === 'wide'; + return ( + +
+
+ +
+ v0.1.24+43 + Android smoke passed +
+
+
+ {[ + ['Mobile Runtime CI', 'passed'], + ['Build Android APK', 'passed'], + ['Android App Smoke Test', 'passed'], + ['GitHub Pages demo', 'live'], + ].map(([title, status], index) => ( +
+ {title} + ✓ {status} +
+ ))} +
+
+
+ ); +}; + +const CloseScene = ({format}: {format: PromoFormat}) => { + const frame = useCurrentFrame(); + const isWide = format === 'wide'; + const scale = interpolate(frame, [scenes.close.from, scenes.close.from + 80], [0.94, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.out(Easing.cubic), + }); + return ( + +
+
+
+ M +
+
+ MobileCode +
+
+ Phone-native AI coding harness. +
+ Build, preview, publish from your phone. +
+
+ Download APK + Open Demo Lab + GitHub Pages +
+
+
+
+ ); +}; + +const WideReadmeComposition = () => { + const frame = useCurrentFrame(); + const opacity = fadeInOut(frame, 0, 420); + const p = sceneProgress(frame, 0, 420); + return ( + +
+
+ +
+
+ +
+ Phone-native harness + GitHub-first shipping + RuntimeProvider + v0.1.24 APK +
+
+
+
+ ); +}; + +export const MobileCodePromo = ({format}: PromoProps) => { + const frame = useCurrentFrame(); + + if (format === 'wide') { + return ; + } + + return ( + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ ); +}; diff --git a/promo/mobilecode-remotion/src/MobileCodeShortTeaser.tsx b/promo/mobilecode-remotion/src/MobileCodeShortTeaser.tsx new file mode 100644 index 0000000..c4104f4 --- /dev/null +++ b/promo/mobilecode-remotion/src/MobileCodeShortTeaser.tsx @@ -0,0 +1,309 @@ +import React from 'react'; +import {AbsoluteFill, Audio, Easing, Sequence, interpolate, staticFile, useCurrentFrame} from 'remotion'; + +const fps = 30; +const sceneFrames = 150; + +const colors = { + bg: '#F7FAFF', + surface: '#FFFFFF', + ink: '#0B1020', + muted: '#536079', + line: '#DDE7F7', + blue: '#2555FF', + mint: '#0B9B7E', + purple: '#7557E8', + amber: '#B7791F', + dark: '#111827', + softBlue: '#E9EEFF', + softMint: '#EAF8F3', + softPurple: '#F0ECFF', +}; + +const scenes = [ + { + eyebrow: 'Not remote IDE', + title: 'The harness runs on your phone.', + subtitle: 'MobileCode 不是远程 IDE 外壳,而是真正运行在手机上的 AI coding harness。', + chips: ['local files', 'tool trace', 'preview'], + }, + { + eyebrow: 'Runtime routing', + title: 'Light local loop. Heavy work routed out.', + subtitle: '手机负责生成、预览、解释,Helper、Termux、GitHub Actions 负责执行和构建。', + chips: ['RuntimeProvider', 'Helper', 'GitHub Actions'], + }, + { + eyebrow: 'Ship from mobile', + title: 'Prompt to page. Phone to GitHub.', + subtitle: '生成 HTML,WebView 预览,一键发布 GitHub Pages,并得到可分享作品卡。', + chips: ['WebView', 'GitHub Pages', 'release card'], + }, +] as const; + +export const shortTeaserDurationInFrames = scenes.length * sceneFrames; + +const reveal = (frame: number, delay = 0) => + interpolate(frame, [delay, delay + 24], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.out(Easing.cubic), + }); + +const rise = (frame: number, delay = 0, distance = 34) => + interpolate(frame, [delay, delay + 30], [distance, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: Easing.out(Easing.cubic), + }); + +const fade = (frame: number) => { + const fadeIn = interpolate(frame, [0, 18], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + const fadeOut = interpolate(frame, [sceneFrames - 20, sceneFrames], [1, 0], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + return Math.min(fadeIn, fadeOut); +}; + +const MiniPhone = ({frame}: {frame: number}) => { + const progress = interpolate(frame, [20, 130], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + + return ( +
+
+
+
MobileCode
+
+ phone-native harness +
+
+
+ {['Trace', 'Runtime', 'Preview', 'Publish'].map((label, index) => { + const active = progress > index / 4; + return ( +
+ {active ? '✓ ' : '· '} + {label} +
+ ); + })} +
+
+ Publish Pages +
+
+
+ ); +}; + +const Scene = ({scene, index}: {scene: (typeof scenes)[number]; index: number}) => { + const frame = useCurrentFrame(); + const progress = interpolate(frame, [0, sceneFrames], [0, 1], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }); + + return ( + +
+
+
+
+ {scene.eyebrow} +
+

+ {scene.title} +

+
+ {scene.chips.map((chip, chipIndex) => { + const chipColors = [colors.softBlue, colors.softMint, colors.softPurple]; + const chipInk = [colors.blue, colors.mint, colors.purple]; + return ( + + {chip} + + ); + })} +
+
+
+ +
+
+
+
+ {scene.subtitle} +
+
+
+
+
+ + ); +}; + +export const MobileCodeShortTeaser = () => { + return ( + + + ); +}; diff --git a/promo/mobilecode-remotion/src/Root.tsx b/promo/mobilecode-remotion/src/Root.tsx new file mode 100644 index 0000000..fef6d0a --- /dev/null +++ b/promo/mobilecode-remotion/src/Root.tsx @@ -0,0 +1,48 @@ +import {Composition} from 'remotion'; +import {MobileCodePromo} from './MobileCodePromo'; +import { + MobileCodePrincipleExplainer, + principleDurationInFrames, +} from './MobileCodePrincipleExplainer'; +import {MobileCodeShortTeaser, shortTeaserDurationInFrames} from './MobileCodeShortTeaser'; + +export const Root = () => { + return ( + <> + + + + + + ); +}; diff --git a/promo/mobilecode-remotion/src/index.ts b/promo/mobilecode-remotion/src/index.ts new file mode 100644 index 0000000..69bbf50 --- /dev/null +++ b/promo/mobilecode-remotion/src/index.ts @@ -0,0 +1,4 @@ +import {registerRoot} from 'remotion'; +import {Root} from './Root'; + +registerRoot(Root); diff --git a/promo/mobilecode-remotion/src/recordedClips.ts b/promo/mobilecode-remotion/src/recordedClips.ts new file mode 100644 index 0000000..3603471 --- /dev/null +++ b/promo/mobilecode-remotion/src/recordedClips.ts @@ -0,0 +1,31 @@ +export type RecordedClip = { + id: string; + title: string; + caption: string; + file: string; + enabled: boolean; +}; + +export const recordedClips: RecordedClip[] = [ + { + id: 'chat-generate', + title: 'Real phone run', + caption: 'Prompt, trace, generated artifact, and preview on Android.', + file: 'recordings/phone-chat-generate.mp4', + enabled: false, + }, + { + id: 'github-pages', + title: 'Pages publish', + caption: 'Publish phone-generated HTML to GitHub Pages.', + file: 'recordings/phone-github-pages.mp4', + enabled: false, + }, + { + id: 'repo-hub', + title: 'Repo Hub', + caption: 'Search repos, bind workspace, inspect Actions, download artifacts.', + file: 'recordings/phone-repo-hub.mp4', + enabled: false, + }, +]; diff --git a/promo/mobilecode-remotion/tsconfig.json b/promo/mobilecode-remotion/tsconfig.json new file mode 100644 index 0000000..c937b9e --- /dev/null +++ b/promo/mobilecode-remotion/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +}