diff --git a/.github/weekly-release-notes.md b/.github/weekly-release-notes.md new file mode 100644 index 0000000..e88c9e8 --- /dev/null +++ b/.github/weekly-release-notes.md @@ -0,0 +1,25 @@ +Weekly development snapshot, not a stable release. All CI checks and build jobs +must succeed before this prerelease is published. + +Choose `client` for the Flutter application or `server` for the Gin backend. +Asset names include the operating system and CPU architecture; `x64` means +x86-64/AMD64. Extract entire desktop bundles, including libraries and data. +Server archives include configuration examples, never databases or credentials. + +## Installation caveats + +- Android APKs currently use the project's debug signing configuration. These + are testing builds, not Play Store releases. A new CI runner may use a different + key, so Android may require uninstalling a previous build before installation. + Uninstalling removes local app data: keep your server URL and recovery code, + or use an existing device to generate a new device login code. +- macOS applications are not Developer ID signed or notarized. Only run snapshots + you trust; Gatekeeper may require explicitly approving the application. +- Linux desktop bundles require compatible system GTK 3 and libsecret libraries + and are built on Ubuntu 24.04. They are not fully static executables. +- Windows desktop bundles require the Microsoft Visual C++ runtime. +- iOS installable packages require Apple signing/provisioning and are not included. + +See `BUILD-INFO.txt` for the source commit and build run. Verify downloads with +`SHA256SUMS` (for example, `sha256sum --check SHA256SUMS` after downloading all +listed files). Checksums detect damaged downloads; they are not signatures. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc40e9c..c77a7e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,12 @@ name: CI on: push: pull_request: + workflow_call: + inputs: + flutter-version: + description: Flutter SDK version used for CI and release checks + type: string + default: '3.44.8' permissions: contents: read @@ -18,6 +24,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version: ${{ inputs.flutter-version || '3.44.8' }} channel: stable cache: true diff --git a/.github/workflows/weekly-release.yml b/.github/workflows/weekly-release.yml new file mode 100644 index 0000000..ec7036d --- /dev/null +++ b/.github/workflows/weekly-release.yml @@ -0,0 +1,263 @@ +name: Weekly release + +on: + schedule: + # Monday 09:17 in Asia/Shanghai; avoid the busy start of the hour. + - cron: '17 1 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: weekly-release + cancel-in-progress: false + +env: + # Keep weekly builds on the project's tested SDK rather than a moving channel. + FLUTTER_VERSION: '3.44.8' + +jobs: + prepare: + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + tag: ${{ steps.release.outputs.tag }} + sha: ${{ steps.release.outputs.sha }} + flutter-version: ${{ steps.release.outputs.flutter-version }} + steps: + - name: Identify this release + id: release + shell: bash + run: | + set -euo pipefail + # Run number distinguishes manual builds; reruns retain the same tag. + { + echo "tag=weekly-${GITHUB_RUN_NUMBER}" + echo "sha=${GITHUB_SHA}" + echo "flutter-version=${FLUTTER_VERSION}" + } >> "$GITHUB_OUTPUT" + + checks: + needs: prepare + uses: ./.github/workflows/ci.yml + with: + flutter-version: ${{ needs.prepare.outputs.flutter-version }} + + android: + needs: [prepare, checks] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.sha }} + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + - run: flutter pub get + - name: Build per-ABI APKs + run: flutter build apk --release --split-per-abi --build-number="$GITHUB_RUN_NUMBER" + - name: Name release assets + shell: bash + run: | + set -euo pipefail + mkdir -p dist + cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk "dist/pawmate-client-${RELEASE_TAG}-android-armv7.apk" + cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk "dist/pawmate-client-${RELEASE_TAG}-android-arm64.apk" + cp build/app/outputs/flutter-apk/app-x86_64-release.apk "dist/pawmate-client-${RELEASE_TAG}-android-x64.apk" + - uses: actions/upload-artifact@v4 + with: + name: weekly-client-android + path: dist/* + if-no-files-found: error + retention-days: 14 + + desktop: + needs: [prepare, checks] + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - os: linux + arch: x64 + runner: ubuntu-24.04 + - os: windows + arch: x64 + runner: windows-2022 + - os: macos + arch: x64 + runner: macos-15-intel + xcode_arch: x86_64 + - os: macos + arch: arm64 + runner: macos-15 + xcode_arch: arm64 + runs-on: ${{ matrix.runner }} + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + TARGET_OS: ${{ matrix.os }} + TARGET_ARCH: ${{ matrix.arch }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.sha }} + - name: Install Linux build dependencies + if: matrix.os == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev liblzma-dev + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + architecture: ${{ matrix.arch }} + cache: true + - run: flutter pub get + - name: Build desktop client + shell: bash + env: + # Flutter forwards these overrides to Xcode; don't label a universal + # binary as a single architecture merely because its runner is native. + FLUTTER_XCODE_ARCHS: ${{ matrix.xcode_arch }} + FLUTTER_XCODE_ONLY_ACTIVE_ARCH: 'YES' + run: | + set -euo pipefail + flutter build "$TARGET_OS" --release --build-number="$GITHUB_RUN_NUMBER" + - name: Package Linux bundle + if: matrix.os == 'linux' + run: | + mkdir -p dist + tar -czf "dist/pawmate-client-${RELEASE_TAG}-linux-x64.tar.gz" -C build/linux/x64/release/bundle . + - name: Package Windows bundle + if: matrix.os == 'windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force dist | Out-Null + Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath "dist/pawmate-client-$env:RELEASE_TAG-windows-x64.zip" + - name: Package macOS application + if: matrix.os == 'macos' + shell: bash + env: + EXPECTED_ARCH: ${{ matrix.xcode_arch }} + run: | + set -euo pipefail + app=build/macos/Build/Products/Release/pawmate.app + test "$(lipo -archs "$app/Contents/MacOS/pawmate")" = "$EXPECTED_ARCH" + mkdir -p dist + ditto -c -k --sequesterRsrc --keepParent "$app" "dist/pawmate-client-${RELEASE_TAG}-macos-${TARGET_ARCH}.zip" + - uses: actions/upload-artifact@v4 + with: + name: weekly-client-${{ matrix.os }}-${{ matrix.arch }} + path: dist/* + if-no-files-found: error + retention-days: 14 + + server: + needs: [prepare, checks] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [linux, windows, darwin] + arch: [amd64, arm64] + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + CGO_ENABLED: '0' + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.sha }} + - uses: actions/setup-go@v6 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + - name: Cross-compile server and package runtime files + shell: bash + run: | + set -euo pipefail + arch="$GOARCH" + os="$GOOS" + binary=pawmate-server + if [[ "$arch" == amd64 ]]; then arch=x64; fi + if [[ "$os" == darwin ]]; then os=macos; fi + if [[ "$os" == windows ]]; then binary+=.exe; fi + package="pawmate-server-${RELEASE_TAG}-${os}-${arch}" + mkdir -p "stage/$package" dist + (cd server && go build -trimpath -ldflags='-s -w' -o "../stage/$package/$binary" ./cmd/pawmate-server) + cp server/README.md server/.env.example "stage/$package/" + # Never bundle a developer's SQLite database or real environment file. + if [[ "$os" == windows ]]; then + (cd stage && zip -r "../dist/$package.zip" "$package") + else + tar -czf "dist/$package.tar.gz" -C stage "$package" + fi + - uses: actions/upload-artifact@v4 + with: + name: weekly-server-${{ matrix.os }}-${{ matrix.arch }} + path: dist/* + if-no-files-found: error + retention-days: 14 + + publish: + needs: [prepare, checks, android, desktop, server] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_SHA: ${{ needs.prepare.outputs.sha }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.sha }} + - uses: actions/download-artifact@v4 + with: + pattern: weekly-* + path: dist + merge-multiple: true + - name: Verify assets and create checksums + shell: bash + run: | + set -euo pipefail + # 3 Android APKs, 4 desktop bundles, and 6 server archives. + test "$(find dist -maxdepth 1 -type f | wc -l)" -eq 13 + printf 'Commit: %s\nFlutter: %s\nWorkflow: %s/%s/actions/runs/%s\n' \ + "$RELEASE_SHA" "$FLUTTER_VERSION" "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" > dist/BUILD-INFO.txt + (cd dist && sha256sum ./* > SHA256SUMS) + - name: Publish complete prerelease + shell: bash + run: | + set -euo pipefail + # Public releases remain immutable; a failed draft upload can be retried. + if gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft' > release-state.txt 2> release-error.txt; then + if [[ "$(cat release-state.txt)" != true ]]; then + echo "Release $RELEASE_TAG is already public; leaving it unchanged." + exit 0 + fi + # Draft releases may not have created their Git tag yet. + test "$(gh release view "$RELEASE_TAG" --json targetCommitish --jq '.targetCommitish')" = "$RELEASE_SHA" + else + # Create as a draft so a network failure never exposes partial assets. + gh release create "$RELEASE_TAG" --target "$RELEASE_SHA" \ + --title "Pawmate $RELEASE_TAG" --notes-file .github/weekly-release-notes.md \ + --prerelease --draft --latest=false + fi + gh release upload "$RELEASE_TAG" dist/* --clobber + gh release edit "$RELEASE_TAG" --draft=false --prerelease --latest=false diff --git a/.metadata b/.metadata index d312f63..aa78107 100644 --- a/.metadata +++ b/.metadata @@ -15,7 +15,19 @@ migration: - platform: root create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: android + - platform: ios + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: linux + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: macos + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: web + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: windows create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..37c5230 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,200 @@ +# Pawmate UI Engineering Guidelines + +These guidelines define the visual language and implementation constraints for +the Pawmate Flutter client. Pawmate is a private two-person digital home. Its +interface should feel like a shared hand-made journal: warm, personal, and +slightly imperfect while remaining predictable, readable, accessible, and easy +to operate. + +## Visual language + +Use the following design direction for new screens and reusable components: + +```text +Pawmate Hand-drawn Home Journal +warm / intimate / doodled / paper-based / calm / lightly imperfect +``` + +The hand-drawn character comes from paper surfaces, ink outlines, restrained +crayon accents, stickers, and stable organic geometry. Do not make the UI look +random or unfinished. Decoration must support navigation and emotional tone, +never obscure content or actions. + +## Design tokens + +Centralize these values in the Flutter design system. Do not scatter equivalent +color or spacing literals through feature widgets. + +### Color tokens + +```dart +const paper = Color(0xFFFFF8E7); +const ink = Color(0xFF3C3530); +const softBrown = Color(0xFF8B7666); +const rose = Color(0xFFE98B8B); +const lavender = Color(0xFFA99BD6); +const mint = Color(0xFF9BC7B0); +const sun = Color(0xFFF3C969); +const sky = Color(0xFF9DC6D8); +``` + +- Use `paper` as the primary application surface and `ink` instead of pure + black for primary text and line work. +- Use one or two accent colors per screen. Feature colors are semantic accents, + not replacement themes. +- Use low-saturation semantic colors for success, warning, and error states. +- Maintain WCAG AA contrast for body text, controls, and error messages. +- Never use an accent color as the only indication of state. + +### Geometry and elevation tokens + +- Standard outline width: `2.0`; emphasis outline width: `3.0` logical pixels. +- Use organic rounded rectangles with small, bounded radius variation rather + than unrelated shapes per widget. +- Use short, low-opacity, slightly offset shadows instead of heavy Material + elevation. Shadows should suggest paper separation, not depth-heavy cards. +- Use a consistent spacing scale (for example 4, 8, 12, 16, 24, 32 logical + pixels) and preserve a minimum 44x44 logical-pixel touch target. + +## Component architecture + +Build visual behavior into reusable widgets under `lib/design/`, organized by +responsibility: + +```text +theme/ colors.dart, spacing.dart, pawmate_theme.dart +layouts/ HanddrawnScaffold +components/ HanddrawnCard, HanddrawnActionCard, HanddrawnButton, + HanddrawnTextField, ChatMessageBubble +illustrations/ Reusable decorative compositions +icons/ purpose-based icon families and the shared crayon renderer +``` + +Feature screens should compose these components instead of drawing one-off +cards, buttons, fields, or borders. Components must expose semantic Flutter +properties (`label`, `enabled`, `onPressed`, validation state) and preserve +standard focus, keyboard, screen-reader, and test behavior. + +Import color and spacing tokens directly from their `theme/` modules. Keep +networking, credentials, message delivery and other business state in +`lib/features/`; the design layer must not depend on feature implementations. +Future Sticker, PaperNote and CoupleAvatar widgets belong in `components/`, +not new top-level categories. See `lib/design/README.md` for the directory contract. + +## Stable hand-drawn rendering + +### Crayon and oil-pastel icon language + +This is a core Pawmate visual requirement: icon strokes should have visible +pigment grain, bounded hand-drawn jitter or irregular pressure overdraw, with +slightly rough or intermittently broken edges. Simulate crayon or oil pastel +catching on paper for a casual, warm, handmade feel. Do not substitute clean +uniform vector outlines or blurry noise for this texture. Silhouettes must +remain recognizable and actions must retain readable native text labels. + +Organize Dart icon families by purpose under `lib/design/icons/` (for example +`access/`, followed by `chat/`, `home/` and `play/` when needed), sharing the +seeded stroke renderer. Keep texture deterministic across rebuilds, theme +changes with identical inputs, screenshots and golden tests. + +- Implement organic outlines and paper details with `CustomPainter` or SVG + assets where appropriate. +- If a painter uses jitter, use a stable seed derived from widget identity or an + explicit parameter. Never generate a new random shape during every `build`; + that causes visual flicker and invalidates golden tests. +- Keep decorative textures lightweight. Prefer small tiled PNG/WebP assets or + procedural low-contrast details over full-screen high-resolution images. +- Prefer SVG for line icons and PNG/WebP for illustrations and stickers. +- Do not replace the entire layout with a bitmap. Text, controls, and content + must remain native Flutter widgets. +- Material or Cupertino primitives may provide behavior, but their visual + styling must be adapted through the Pawmate theme and hand-drawn components. + +## Typography + +Use two type roles: + +- Display role: an appropriately licensed hand-written or rounded font for + titles, empty states, and short sticker labels. +- Reading role: a clear Chinese-capable sans-serif font for chat, recipes, + learning content, form labels, errors, and all substantial body text. + +Use a monospace face for server URLs, invitation codes, and other technical +values when it improves copying and scanning. Never use a decorative font as the +only presentation of an important instruction or error. + +## Page composition + +Pages should feel like rooms in one shared home while retaining conventional +information architecture: + +```text +header: title + optional small illustration or couple status +content: primary paper/card surface +navigation: stable, labeled, easy to reach +decoration: restrained stickers, tape, doodles, or notes +``` + +Suggested thematic metaphors are optional and must not replace labels: + +```text +chat living room / message wall +recipes kitchen notes +language shared desk / flash cards +games play corner +backup storage box / folder +``` + +## Motion and interaction + +Use Flutter animation primitives for short, purposeful feedback: + +- micro-interaction: 120–180 ms; +- page transition: 220–300 ms; +- celebratory pairing or milestone animation: 600–900 ms. + +Motion may suggest paper unfolding, a button settling, a sticker wobbling, or +two pieces joining. Keep animations subtle, interruptible, and non-blocking. +Honor `MediaQuery.disableAnimations` and provide an equivalent static state. +Do not use motion as the only way to communicate success, failure, or progress. + +## Onboarding and private data + +The server URL, invitation code, access token, chat content, media, exports, and +backups are sensitive in different ways: + +- validate and normalize server URLs before requests; +- never log authorization headers, invitation links, or private content; +- keep technical values selectable and easy to copy; +- show clear loading, unreachable-server, incompatible-version, invalid-input, + expired-invitation, and success states; +- store authentication tokens only in platform secure storage when that flow is + implemented; +- do not enable production cleartext HTTP merely to simplify local development. + +## Accessibility and localization + +- Every icon-only control needs a semantic label and tooltip where appropriate. +- Preserve logical reading order and support text scaling without clipping. +- Do not encode meaning with color alone; pair color with text, iconography, or + shape. +- Keep body text readable at large accessibility text scales. +- Route all user-facing Flutter copy, validation feedback, semantics, and + tooltips through the localization resources. Provide English and Simplified + Chinese translations. Chinese sentence endings must not use terminal Chinese + punctuation such as `。`, `!`, or `?`; keep UI copy concise and natural. +- Route user-facing strings through the localization layer when one is added; + do not hard-code text into painters or image assets. + +## Review checklist + +For a visual Flutter change, reviewers should check: + +- [ ] The screen uses shared Pawmate components and design tokens. +- [ ] Hand-drawn geometry is stable across rebuilds. +- [ ] Decorative elements do not reduce readability or touch targets. +- [ ] Loading, empty, error, and success states are designed. +- [ ] Text remains readable with larger accessibility sizes. +- [ ] Icons and controls have semantic labels. +- [ ] Before/after screenshots or a short recording are included for meaningful + visual changes. diff --git a/README.md b/README.md index 50b7f99..c402caf 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ A new Flutter project. +## Weekly development releases + +The [weekly release workflow](.github/workflows/weekly-release.yml) publishes +platform-specific client and server packages every Monday at 09:17 Asia/Shanghai. +It also supports manual runs on the default branch. These are prereleases, not +stable builds. See [the release guide](books/src/reference/weekly-releases.md) +for supported architectures, installation caveats, and checksum verification. + ## Getting Started This project is a starting point for a Flutter application. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0436d30..8f77da7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "com.example.pawmate" + namespace = "dev.pawmate.app" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -15,8 +15,8 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.pawmate" + // Keep the application ID aligned with MainActivity's package. + applicationId = "dev.pawmate.app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..d30de11 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,5 @@ to allow setting breakpoints, to provide hot reload, etc. --> + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d6e3814..ee3662a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,12 @@ + + + android:icon="@mipmap/ic_launcher" + android:allowBackup="false" + android:usesCleartextTraffic="false"> + + + + + + + diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..b3799fa Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/kotlin/com/example/pawmate/MainActivity.kt b/android/app/src/main/kotlin/dev/pawmate/app/MainActivity.kt similarity index 76% rename from android/app/src/main/kotlin/com/example/pawmate/MainActivity.kt rename to android/app/src/main/kotlin/dev/pawmate/app/MainActivity.kt index 743b4f3..9c27646 100644 --- a/android/app/src/main/kotlin/com/example/pawmate/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/pawmate/app/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.pawmate +package dev.pawmate.app import io.flutter.embedding.android.FlutterActivity diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..7d7a283 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..f1273ab 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..861be7b 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..58af4b7 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..996d670 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/pawmate.png b/assets/pawmate.png new file mode 100644 index 0000000..5a58f1b Binary files /dev/null and b/assets/pawmate.png differ diff --git a/books/book.toml b/books/book.toml index 4be68ac..bfdc6c1 100644 --- a/books/book.toml +++ b/books/book.toml @@ -1,6 +1,6 @@ [book] -title = "Pawmate Implementation Guide" -description = "A human-in-the-loop guide for building the private two-person pawmate app." +title = "Pawmate Documentation" +description = "Product behavior, architecture, and API documentation for Pawmate." language = "en" src = "src" diff --git a/books/src/README.md b/books/src/README.md index 26f9931..b3a5977 100644 --- a/books/src/README.md +++ b/books/src/README.md @@ -1,21 +1,14 @@ -# Pawmate Implementation Guide +# Pawmate Documentation Pawmate is a private, self-hostable application for two people. It combines a -Flutter client with a Gin server and will eventually support messaging, shared -home content, backups, search, calls, and games. +Flutter client with a Gin server and currently supports pairing, per-device +sessions, member profiles, and synchronized text chat. Shared home content, +backups, search, calls, and games remain planned features. -This book turns product decisions into small implementation slices. Each slice -is designed for a human to review before code is written and before a branch is -merged. It deliberately favors a working private instance for one couple over -premature multi-tenant infrastructure. - -## How to use this book - -1. Read the proposed chapter and decide on its open questions. -2. Create a feature branch yourself. -3. Implement the chapter in small commits. -4. Run the verification checklist. -5. Open a pull request and use the repository template for review. +Pawmate is designed for two people sharing one private instance. This book +describes the product's current behavior, service boundaries, data rules, and +API contracts. It is reference documentation for readers of the project; the +source code and server README remain the operational references. Build the book locally from the repository root: diff --git a/books/src/SUMMARY.md b/books/src/SUMMARY.md index 4fff79c..2bba8c0 100644 --- a/books/src/SUMMARY.md +++ b/books/src/SUMMARY.md @@ -1,11 +1,8 @@ -# Summary +# Pawmate documentation - [Introduction](README.md) -- [Current foundation](current-foundation.md) -- [Working human-in-the-loop](human-in-the-loop.md) -- [First vertical slice: instance onboarding and pairing](first-vertical-slice/README.md) - - [Requirements and decisions](first-vertical-slice/requirements.md) - - [User journey and API contract](first-vertical-slice/contract.md) - - [Draft implementation code](first-vertical-slice/draft-code.md) - - [Implementation plan](first-vertical-slice/implementation.md) - - [Verification and review](first-vertical-slice/verification.md) +- [Current product and architecture](current-foundation.md) +- [Pairing and member identity](reference/pairing-and-identity.md) +- [Client journeys and service contracts](reference/client-and-api.md) +- [Pairing service and persistence](reference/pairing-and-storage.md) +- [Weekly releases](reference/weekly-releases.md) diff --git a/books/src/current-foundation.md b/books/src/current-foundation.md index 7ac99e0..55a48b6 100644 --- a/books/src/current-foundation.md +++ b/books/src/current-foundation.md @@ -1,11 +1,11 @@ # Current foundation -The repository currently contains two independent applications: +The repository currently contains two applications: | Area | Location | Current responsibility | | -------------- | --------------- | ---------------------------------- | | Flutter client | repository root | Android-ready Pawmate client shell | -| Gin server | `server/` | Private-instance discovery API | +| Gin server | `server/` | Private-instance discovery and pairing API | The Gin server already exposes these unauthenticated discovery endpoints: @@ -18,5 +18,114 @@ GET /api/v1/instance Pawmate instance. It reports an instance identifier, display name, API version, public URL when configured, and the currently advertised features. -The next slice extends this foundation with a safe onboarding and pairing flow. -It must not weaken the existing discovery endpoint or make private data public. +The Flutter client supports the inviter's server setup, invitation creation, +status checks, accepting a pasted invitation or one opened through the Android +`pawmate://pair` scheme, and restoring a member after reinstall with a recovery +code. On startup, valid paired credentials lead to the couple home, invalid +credentials lead to recovery, and network errors remain retryable. The Gin +service persists the single couple relationship in SQLite and exposes a +session-validation endpoint. Each member can stay signed in on multiple devices, +with an independent access token for each installation. Existing token records +are automatically migrated on server startup. + +## Access and onboarding + +New installations open an access screen with four choices: Create invitation, +Accept invitation, Restore data, and Add device. Each opens a focused page with +back navigation. An existing pending invitation reopens its invitation status +page; valid paired sessions open Chat, and invalid saved sessions lead to +recovery. + +**Accept invitation** lets the invitee paste the complete `pawmate://pair` link. +Clipboard access happens only after pressing Paste link. The client validates +the link, then shows its server address for review; it does not redeem an invite +until the invitee explicitly presses Accept invitation. External links share +the same parser. Do not publish real invitation links in screenshots or logs. + +**Restore data** restores account access to data already on the server; it does +not import a backup or copy a database. Recovery rotates credentials and signs +out that member's other devices. **Add device** is the non-destructive choice +when another installation remains signed in. + +The access icon family lives in `lib/design/icons/access/`, with shared dry-wax rendering +in `crayon_strokes.dart`. Pigment grain, bounded jitter, irregular pressure +overdraw and small broken edges give native paths a warm crayon/oil-pastel feel. +Fixed purpose-based seeds make fresh repaints pixel-stable. Four illustrated +action cards retain native keyboard activation and screen-reader labels. Rows +grow with their text rather than clipping content in a fixed-aspect-ratio grid. + +The rest of `lib/design/` is grouped by responsibility: `theme/` contains separate +color and spacing tokens plus the app theme, `layouts/` contains the scaffold, +`components/` contains reusable cards, buttons, fields and message bubbles, and +`illustrations/` contains decorative compositions. Import token files directly; +design widgets do not depend on feature services. See `lib/design/README.md`. + +## Member identity during pairing + +The inviter sets an avatar and nickname while configuring the server; the invitee +does the same on the invitation review page before accepting. Both use +`MemberProfileEditor`: a centered, deterministic crayon circle/plus upload control, +followed by a native nickname field. Cancelling image selection preserves the +previous selection; malformed images and missing values show localized errors. + +The client crops a selected PNG/JPEG to a 256×256 PNG and sends a `profile` object +with the invitation request. Gin bounds request size, while the pairing service +validates nickname length, image bytes and dimensions. SQLite stores the profile +in `member_profiles` in the same GORM transaction as invitation creation or +acceptance. A failed identity validation never consumes the invitation. + +`PairingSession` restores both identities from the authenticated session endpoint. +The Home tab displays each nickname and portrait; recovery and device login keep +the same member identity. Existing couples without profiles are not reset; a +future profile-editing flow can fill in their missing identity. + +## Multiple devices and chat + +To add a phone, tablet or computer, use **My devices → Add a device** on an +already signed-in device. On the new installation, choose **Sign in on another +device** and enter the same server URL, the one-time device login code, and a +device name. Device login codes expire after ten minutes and do not change the +member's recovery code or sign out existing devices. The home screen also lists +the current member's devices and allows signing out another device. It never +lists or removes the partner's devices. + +The couple space now opens on the **Chat** tab, followed by **Play** and **Home**. +The first chat slice supports persisted text messages, earlier history pages, +cross-device synchronization, failed-send retries, read receipts and unread +reminders. Device management and recovery notes live in Home; Play is a placeholder. + +Messages use a server sequence ID and a client-generated idempotency ID. Retrying +the same send cannot create a duplicate, even if its first response was lost. +Read progress belongs to a member rather than a device: reading on one of your +devices updates your other devices and the partner's read labels. Only rendered +incoming messages inside the foreground chat viewport advance the read cursor. +Scrolling older history does not acknowledge newer messages outside the viewport. + +Foreground synchronization currently polls every two seconds. Chat displays an +unread badge; a message arriving on another tab shows an in-app reminder without +its private content. Background OS push notifications, media, games, recipes and +a durable offline outbox are future work. The input draft and failed sends are +retained across tab switches but not process restarts. Confirmed messages and +read cursors are persisted in SQLite. + +The server now accesses SQLite through GORM models, rather than hand-written +CRUD SQL. `server/internal/pairing/models.go` maps the existing tables while +keeping API models independent of storage details. The official GORM SQLite +dialector uses the existing pure-Go `modernc.org/sqlite` connection. Reviewed +additive SQL migrations retain the original constraints and timestamp units; +startup deliberately avoids `AutoMigrate` on an existing private database. +Existing tokens, recovery codes, message IDs and read cursors remain compatible. +Credential rotation, one-time code redemption and message/read upserts keep +explicit transaction boundaries. SQL tracing is disabled for privacy. + +The API contracts are `GET /api/v1/chat/messages`, `POST /api/v1/chat/messages` +and `POST /api/v1/chat/read`; see `server/README.md` for pagination, limits and +receipt semantics. + +Recovery codes are single-use and must be saved by each member outside the app. +Recovery revokes all of that member's sessions and outstanding device login codes, +then issues a new token and recovery code; the partner remains signed in. Use +device login codes for normal additional-device sign-in and recovery only when +access has been lost. Newly added devices do not receive recovery secrets. +Production deployments must mount and back up the configured SQLite database +path. diff --git a/books/src/first-vertical-slice/README.md b/books/src/first-vertical-slice/README.md deleted file mode 100644 index 19786fb..0000000 --- a/books/src/first-vertical-slice/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# First vertical slice: instance onboarding and pairing - -The first product feature is not chat. It is the secure path that lets two -people connect the Flutter app to their own server and establish their one -couple relationship. - -At the end of this slice, a user can: - -1. Enter a Pawmate server URL when launching the app for the first time. -2. Verify that the URL points to a compatible Pawmate instance. -3. Register or sign in. -4. Create a one-time invitation. -5. Let the other person redeem that invitation from the same instance. -6. Enter a minimal shared-home screen once the couple exists. - -This chapter is intentionally a vertical slice: it changes the Flutter UI, -server API, persistence, and tests together. diff --git a/books/src/first-vertical-slice/contract.md b/books/src/first-vertical-slice/contract.md deleted file mode 100644 index 4d452c8..0000000 --- a/books/src/first-vertical-slice/contract.md +++ /dev/null @@ -1,58 +0,0 @@ -# User journey and API contract - -## User journey - -```text -Launch app - -> enter server URL - -> GET /api/v1/instance - -> compatible instance? - -> no: explain the problem and allow URL editing - -> yes: register or sign in - -> paired already? - -> yes: open shared home - -> no: create or redeem an invitation - -> open shared home -``` - -## Preserve the discovery endpoint - -The existing endpoint remains public: - -```http -GET /api/v1/instance -``` - -It should return only non-sensitive metadata. The Flutter client must reject an -unknown API version before attempting authentication. - -## New API surface - -Use `/api/v1` consistently. Exact request and response field names should be -documented in tests before the Flutter UI depends on them. - -```text -POST /api/v1/auth/register -POST /api/v1/auth/login -POST /api/v1/auth/refresh -POST /api/v1/auth/logout - -GET /api/v1/me -GET /api/v1/couple -POST /api/v1/pairing-invites -POST /api/v1/pairing-invites/redeem -``` - -Suggested behavior: - -| Endpoint | Authentication | Success behavior | -| ------------------------------ | -------------- | ------------------------------------------------------------- | -| `POST /auth/register` | No | Creates a user only while the instance has capacity. | -| `POST /auth/login` | No | Returns an access token and refresh token. | -| `GET /me` | Yes | Returns the current user and pairing state. | -| `GET /couple` | Yes | Returns the couple or a not-paired state. | -| `POST /pairing-invites` | Yes | Creates one expiring, single-use invite for an unpaired user. | -| `POST /pairing-invites/redeem` | Yes | Atomically creates the couple and consumes the invite. | - -Return generic authentication errors. Do not reveal whether a display name, -email address, invite code, or server contains a particular user. diff --git a/books/src/first-vertical-slice/draft-code.md b/books/src/first-vertical-slice/draft-code.md deleted file mode 100644 index 5ffccf8..0000000 --- a/books/src/first-vertical-slice/draft-code.md +++ /dev/null @@ -1,307 +0,0 @@ -# Draft implementation code - -This page contains the first code to copy into the feature branch. It is a -starting point, not a completed security implementation. Review the database, -password hashing, token signing, and error policy before adapting it. - -## Flutter dependencies - -Add these packages from the Flutter project root: - -```bash -flutter pub add http flutter_secure_storage -``` - -`http` performs discovery requests. `flutter_secure_storage` keeps login tokens -out of ordinary preferences. The instance URL itself is not a secret and may be -stored separately later. - -## Flutter: discover an instance - -Create `lib/features/onboarding/instance_api.dart`: - -```dart -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -class InstanceInfo { - const InstanceInfo({ - required this.apiVersion, - required this.id, - required this.name, - required this.features, - }); - - factory InstanceInfo.fromJson(Map json) { - return InstanceInfo( - apiVersion: json['api_version'] as String, - id: json['id'] as String, - name: json['name'] as String, - features: List.from(json['features'] as List), - ); - } - - final String apiVersion; - final List features; - final String id; - final String name; -} - -class InstanceApi { - InstanceApi(this._client); - - final http.Client _client; - - void close() => _client.close(); - - Future discover(String rawURL) async { - final baseURL = Uri.parse(rawURL.trim()); - if (!baseURL.hasScheme || !baseURL.hasAuthority) { - throw const FormatException('Enter a complete server URL.'); - } - - final response = await _client - .get(baseURL.resolve('/api/v1/instance')) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode != 200) { - throw StateError('The server could not be verified.'); - } - - final instance = InstanceInfo.fromJson( - jsonDecode(response.body) as Map, - ); - if (instance.apiVersion != 'v1') { - throw StateError('This Pawmate server is not compatible with this app.'); - } - return instance; - } -} -``` - -The Android emulator reaches a server on the development machine through -`http://10.0.2.2:8080`, not `http://localhost:8080`. - -## Flutter: initial URL form - -Create `lib/features/onboarding/instance_setup_page.dart`. This deliberately -keeps routing out of the widget; the successful callback can later save the -configuration and move to authentication. - -```dart -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; - -import 'instance_api.dart'; - -class InstanceSetupPage extends StatefulWidget { - const InstanceSetupPage({super.key, required this.onConnected}); - - final ValueChanged onConnected; - - @override - State createState() => _InstanceSetupPageState(); -} - -class _InstanceSetupPageState extends State { - final _formKey = GlobalKey(); - final _urlController = TextEditingController(text: 'http://10.0.2.2:8080'); - final _api = InstanceApi(http.Client()); - bool _isConnecting = false; - String? _errorMessage; - - @override - void dispose() { - _urlController.dispose(); - _api.close(); - super.dispose(); - } - - Future _connect() async { - if (!_formKey.currentState!.validate()) return; - - setState(() { - _isConnecting = true; - _errorMessage = null; - }); - try { - final instance = await _api.discover(_urlController.text); - if (!mounted) return; - widget.onConnected(instance); - } on FormatException catch (error) { - setState(() => _errorMessage = error.message); - } on Object { - setState(() => _errorMessage = 'Could not connect to that Pawmate server.'); - } finally { - if (mounted) setState(() => _isConnecting = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Connect your little home', - style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 12), - const Text('Enter the URL of your private Pawmate server.'), - const SizedBox(height: 24), - TextFormField( - controller: _urlController, - autocorrect: false, - keyboardType: TextInputType.url, - validator: (value) => value == null || value.trim().isEmpty - ? 'Server URL is required.' - : null, - decoration: const InputDecoration(labelText: 'Server URL'), - ), - if (_errorMessage != null) ...[ - const SizedBox(height: 12), - Text(_errorMessage!, - style: TextStyle(color: Theme.of(context).colorScheme.error)), - ], - const SizedBox(height: 24), - FilledButton( - onPressed: _isConnecting ? null : _connect, - child: Text(_isConnecting ? 'Connecting…' : 'Connect'), - ), - ], - ), - ), - ), - ), - ), - ); - } -} -``` - -Replace `Scaffold`, `TextFormField`, and `FilledButton` with the shared -hand-drawn widgets once those components exist; the networking code should not -change. - -## Gin: request and response types - -Add `server/internal/httpapi/auth.go`. The handler depends on an interface so -the HTTP contract can be tested before the final SQLite implementation exists. - -```go -package httpapi - -import ( - "context" - "errors" - "net/http" - - "github.com/gin-gonic/gin" -) - -var ErrCapacityReached = errors.New("instance capacity reached") - -type RegisterInput struct { - DisplayName string `json:"display_name" binding:"required,min=1,max=40"` - Password string `json:"password" binding:"required,min=12,max=128"` -} - -type Session struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} - -type AuthService interface { - Register(context.Context, RegisterInput) (Session, error) -} - -func registerHandler(service AuthService) gin.HandlerFunc { - return func(c *gin.Context) { - var input RegisterInput - if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) - return - } - - session, err := service.Register(c.Request.Context(), input) - switch { - case errors.Is(err, ErrCapacityReached): - c.JSON(http.StatusConflict, gin.H{"error": "registration_closed"}) - case err != nil: - c.Error(err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal_error"}) - default: - c.JSON(http.StatusCreated, session) - } - } -} -``` - -Before committing, run `gofmt -w internal/httpapi/auth.go`; the book displays -spaces for readability, but Go source must be formatted with `gofmt`. - -## Gin: add the route - -Update `NewRouter` only after an `AuthService` implementation is available. -Pass dependencies explicitly instead of creating database connections inside a -handler: - -```go -type Dependencies struct { - Auth AuthService -} - -func NewRouter(cfg config.Config, logger *slog.Logger, deps Dependencies) *gin.Engine { - // Existing middleware and public routes stay unchanged. - // ... - api := router.Group("/api/v1") - api.GET("/instance", instanceHandler(cfg)) - api.POST("/auth/register", registerHandler(deps.Auth)) - return router -} -``` - -Update all existing router tests to supply a fake `AuthService`. This makes -capacity and error behavior deterministic without requiring a database for every -HTTP test. - -## Gin: pairing transaction shape - -The repository method must perform pairing atomically. The following is the -essential shape for a SQLite-backed implementation; adapt table and driver -details after approving the persistence choice. - -```go -func (store *Store) RedeemInvite(ctx context.Context, userID, code string) error { - return store.withTransaction(ctx, func(tx *sql.Tx) error { - invite, err := store.findActiveInviteForUpdate(ctx, tx, hashCode(code)) - if err != nil { - return err - } - if invite.CreatorUserID == userID { - return ErrCannotRedeemOwnInvite - } - if err := store.assertUnpaired(ctx, tx, userID); err != nil { - return err - } - if err := store.assertUnpaired(ctx, tx, invite.CreatorUserID); err != nil { - return err - } - if err := store.createCouple(ctx, tx, invite.CreatorUserID, userID); err != nil { - return err - } - return store.markInviteRedeemed(ctx, tx, invite.ID) - }) -} -``` - -`findActiveInviteForUpdate` must reject expired and previously redeemed codes. -For SQLite, write transactions serialize writers; still test two concurrent -redemption attempts and ensure only one succeeds. diff --git a/books/src/first-vertical-slice/implementation.md b/books/src/first-vertical-slice/implementation.md deleted file mode 100644 index eaa1ad6..0000000 --- a/books/src/first-vertical-slice/implementation.md +++ /dev/null @@ -1,62 +0,0 @@ -# Implementation plan - -## 1. Create the feature branch - -Run this yourself after approving the decisions in this chapter: - -```bash -git switch -c feat/instance-onboarding -``` - -## 2. Build the Gin persistence and domain layer - -1. Choose and configure the approved database. -2. Add migrations for users, couples, pairing invites, and refresh tokens. -3. Add a domain service that enforces the two-user limit in one transaction. -4. Keep HTTP handlers thin: parse input, call the domain service, return a - documented response. -5. Add authenticated middleware before adding protected endpoints. - -The pairing redemption transaction must verify all of the following together: - -- the caller is not already paired; -- the invitation exists and is not expired or redeemed; -- the caller did not create the invitation; -- neither side would make the instance exceed two users; -- the invitation is marked redeemed and the couple is created atomically. - -## 3. Add Flutter infrastructure - -1. Create an `InstanceConfig` model containing the normalized base URL and - instance metadata. -2. Store the configured URL locally; store access and refresh tokens only in - platform secure storage. -3. Add an API client that attaches the access token only to protected requests. -4. Give all network states clear UI: loading, unreachable server, incompatible - version, invalid credentials, expired invitation, and successful pairing. -5. Keep the first-launch route separate from the later home route. - -Do not log authorization headers, tokens, or server URLs in release builds. - -## 4. Implement the first screens - -1. Server URL entry and connection test. -2. Register/sign-in screen. -3. Pairing choice: create invite or redeem invite. -4. Minimal paired home screen showing both display names. - -Use the shared hand-drawn design components rather than introducing a one-off -visual style for onboarding. - -## 5. Integrate in small commits - -Suggested order: - -```text -docs: approve the onboarding contract -server: add persistence and authentication -server: add pairing invitations -flutter: add instance configuration -flutter: add authentication and pairing flow -test: cover the complete onboarding journey -``` diff --git a/books/src/first-vertical-slice/requirements.md b/books/src/first-vertical-slice/requirements.md deleted file mode 100644 index 41ccd40..0000000 --- a/books/src/first-vertical-slice/requirements.md +++ /dev/null @@ -1,42 +0,0 @@ -# Requirements and decisions - -## Product rules - -- A Pawmate instance is private and supports exactly one couple: at most two - users may be active in a couple. -- A user must select a server before signing in or pairing. -- A pairing invitation is single-use and short-lived. -- Once paired, both client and server scope shared data by the couple ID. -- The server URL and login session must survive an app restart. - -## Decisions to approve before implementation - -| Decision | Recommended starting point | Why it needs approval | -| --- | --- | --- | -| Database | SQLite | It is simple to self-host and sufficient for one couple. | -| Authentication | Password plus short-lived access token and refresh token | It avoids treating the private network as authentication. | -| Transport | HTTPS in production | Messages, backups, and tokens are private data. | -| Local development URL | `http://10.0.2.2:8080` on the Android emulator | This reaches the host machine from the emulator. | -| Pairing mechanism | QR code containing a one-time opaque code, with text fallback | It is simple to share in person and does not expose a reusable secret. | - -Do not accept a certificate error silently. For local HTTP development, permit -cleartext traffic only in the Android debug configuration; production builds -must use HTTPS. - -## Initial data model - -```text -users - id, display_name, password_hash, created_at - -couples - id, first_user_id, second_user_id, created_at - -pairing_invites - id, creator_user_id, code_hash, expires_at, redeemed_at - -refresh_tokens - id, user_id, token_hash, expires_at, revoked_at -``` - -Store password hashes and invitation/token hashes, never the original secret. diff --git a/books/src/first-vertical-slice/verification.md b/books/src/first-vertical-slice/verification.md deleted file mode 100644 index 546b682..0000000 --- a/books/src/first-vertical-slice/verification.md +++ /dev/null @@ -1,46 +0,0 @@ -# Verification and review - -## Server checks - -```bash -cd server -gofmt -w . -go vet ./... -go test ./... -``` - -Add tests for at least these cases: - -- a compatible and incompatible instance response; -- registration up to the two-user limit; -- password verification failure; -- expired, reused, self-redeemed, and valid invitations; -- two concurrent redemption attempts for one invitation; -- an already-paired user attempting to pair again. - -## Flutter checks - -```bash -dart format . -flutter analyze -flutter test -``` - -Manually verify on an Android emulator: - -1. Start the server and enter `http://10.0.2.2:8080`. -2. Confirm an unreachable or invalid URL has a useful recovery path. -3. Register two accounts and pair them with one invitation. -4. Restart the app and confirm the server selection and session behavior are - correct. -5. Confirm a third account cannot join the private instance. - -## Pull request review - -Before merging, verify that: - -- server and Flutter API contracts agree; -- no production cleartext traffic was enabled; -- no secrets or private test data appear in the diff; -- migration and rollback behavior is understood; -- the two-user invariant is enforced by the server, not only by the UI. diff --git a/books/src/human-in-the-loop.md b/books/src/human-in-the-loop.md deleted file mode 100644 index e37105e..0000000 --- a/books/src/human-in-the-loop.md +++ /dev/null @@ -1,17 +0,0 @@ -# Working human-in-the-loop - -The book proposes implementation steps; it does not replace product or security -decisions. Before starting a chapter, explicitly approve its choices about data -storage, authentication, URL handling, and user experience. - -For every chapter: - -1. Create a branch, for example `feat/instance-onboarding`. -2. Keep Flutter and Gin changes in the same branch when they change one API - contract. -3. Add tests with the behavior, not after it. -4. Verify the feature against a real Android emulator and a local server. -5. Review privacy implications before exporting, syncing, or sharing data. - -Do not commit a real server URL, passwords, access tokens, chat content, backup -archives, or screenshots containing private messages. diff --git a/books/src/reference/client-and-api.md b/books/src/reference/client-and-api.md new file mode 100644 index 0000000..a6ac3bb --- /dev/null +++ b/books/src/reference/client-and-api.md @@ -0,0 +1,78 @@ +# Client journeys and service contracts + +## Client journeys + +On a new installation, the access screen offers invitation creation, +invitation acceptance, account recovery, and additional-device sign-in. Each +choice opens a focused flow. Existing pending invitations resume their status +view, valid paired sessions open the couple space, and revoked or invalid +sessions lead to recovery. Network failures remain retryable and do not erase +saved credentials. + +Invitation links use the `pawmate://pair` scheme. The client parses pasted and +externally opened links through the same validation path. It displays the server +address for review before redeeming the invitation. Clipboard access occurs only +when the user selects the paste action. + +The paired space opens on Chat and also contains Play and Home. Home presents +member profiles, recovery guidance, and device management. Play is currently a +placeholder. + +## Discovery and pairing API + +`GET /healthz` reports server health. `GET /api/v1/instance` returns public +instance metadata: identifier, display name, API version, optional public URL, +and advertised features. It does not return member or couple data. + +The pairing API creates and redeems invitations, checks invitation status, +restores access with recovery codes, and validates sessions: + +```text +POST /api/v1/pairing/invites +GET /api/v1/pairing/invites/status +POST /api/v1/pairing/invites/redeem +POST /api/v1/pairing/recover +GET /api/v1/pairing/session +``` + +Invitation creation and acceptance include a member profile. Invitation codes +expire after ten minutes and can be redeemed once. The authenticated session +response includes the current member's role and profile and, when paired, the +partner profile. See `server/README.md` for request fields and limits. + +## Device API + +Device login codes are issued by an authenticated member and redeemed without an +existing access token on the new installation: + +```text +GET /api/v1/pairing/devices +DELETE /api/v1/pairing/devices/:id +POST /api/v1/pairing/devices/login-codes +POST /api/v1/pairing/devices/login-codes/redeem +``` + +Device listings are scoped to the authenticated member. A device login code +expires after ten minutes, can be redeemed once, and returns a new device token +without disclosing a recovery code. + +## Chat API + +All chat endpoints require an active paired-device token. The server derives +member and pair identity from the token: + +```text +GET /api/v1/chat/messages +POST /api/v1/chat/messages +POST /api/v1/chat/read +``` + +Messages have a server sequence ID and a client-generated idempotency ID. +History supports pagination in either direction. Read cursors belong to members +and only move forward, so reading on one device updates receipts across all of a +member's devices. The client acknowledges incoming messages only when they are +rendered in the foreground chat viewport. + +The client polls every two seconds while foregrounded. It pauses in the +background and catches up on resume. Unread alerts contain no message content. +See `server/README.md` for pagination fields, limits, and receipt semantics. diff --git a/books/src/reference/pairing-and-identity.md b/books/src/reference/pairing-and-identity.md new file mode 100644 index 0000000..9fd0018 --- /dev/null +++ b/books/src/reference/pairing-and-identity.md @@ -0,0 +1,43 @@ +# Pairing and member identity + +## Instance and couple model + +A Pawmate server represents one private instance and one couple. The paired +members share a pair identifier, while each member has an independent role, +profile, recovery code, and set of device sessions. The server enforces these +relationships; client navigation does not grant access by itself. + +An invitation is a short-lived, single-use bearer credential. Its code is +returned to the inviter for sharing, while the server stores only its hash. +Accepting an invitation associates the invitee with the inviter in a single +transaction. Rejected profile data does not consume the invitation. + +## Member profiles + +Each member profile contains a nickname and an optional avatar. Nicknames are +trimmed, limited to 1–32 Unicode characters, and cannot contain control +characters. Avatars arrive as PNG or JPEG data, are limited in size and +dimensions, and are re-encoded by the server as PNG to remove embedded metadata. +Profiles are saved in the same transaction as invitation creation or +acceptance. Existing pairs without profiles remain valid. + +## Recovery and devices + +Each member receives a single-use recovery code when pairing succeeds. Recovery +rotates that member's credentials, revokes their active sessions and outstanding +device login codes, and leaves the partner's sessions active. Recovery codes are +not delivered to additional devices. + +An authenticated device can issue a one-time login code for another installation. +The code expires after ten minutes and creates an independent device session. +Members can list and revoke their own device sessions; they cannot inspect or +revoke the partner's devices. Revoked credentials are rejected by subsequent +authenticated requests. + +## Persistence and privacy + +SQLite stores the pair, profiles, hashed credentials, device sessions, messages, +and read cursors. The database is private instance data and needs persistent +storage and protected backups. Credential values are stored as hashes. Message +content is stored as readable text in SQLite, so Pawmate chat is not end-to-end +encrypted. diff --git a/books/src/reference/pairing-and-storage.md b/books/src/reference/pairing-and-storage.md new file mode 100644 index 0000000..a34e488 --- /dev/null +++ b/books/src/reference/pairing-and-storage.md @@ -0,0 +1,41 @@ +# Pairing service and persistence + +## Service boundaries + +The Gin HTTP layer validates request shape, derives authenticated identity from +the bearer token, calls the pairing domain service, and maps domain outcomes to +HTTP responses. Pairing rules and transaction boundaries live in the service +layer rather than in handlers. + +The service enforces one couple per private instance and prevents a member from +joining or creating a second relationship. Invitation acceptance atomically +checks the code, expiry, redemption state, inviter and invitee eligibility, +stores both profiles and credentials, and marks the invitation redeemed. A +failed operation leaves the invitation and pair state unchanged. + +## Credential lifecycle + +Invitation codes, recovery codes, device login codes, and access tokens are +bearer credentials. The database retains hashes rather than reusable plaintext +values. Invitation and device login codes expire after ten minutes and are +single-use. Recovery replaces the member's credential set and revokes their +other sessions; device login adds a session without changing recovery access. + +The session endpoint validates the presented device token and returns the +member's role, pair identifier, profile, and partner profile when paired. A +revoked token receives HTTP 401. The Flutter client treats that response as an +invalid session, while connection failures remain retryable. + +## SQLite storage + +GORM models represent pairing, profiles, device sessions, login codes, chat +messages, and read cursors. They remain separate from HTTP response types. The +official SQLite dialector uses the existing pure-Go SQLite connection. + +Schema updates are additive SQL migrations that preserve existing table names, +constraints, identifiers, and timestamp units. Startup does not run GORM +`AutoMigrate` against an existing private database. Multi-step credential, +pairing, device-code, and read-cursor operations use explicit transactions. +SQL tracing is disabled to keep credential digests and private content out of +logs. Database backups should be taken before server upgrades and stored +securely. diff --git a/books/src/reference/weekly-releases.md b/books/src/reference/weekly-releases.md new file mode 100644 index 0000000..fd0776c --- /dev/null +++ b/books/src/reference/weekly-releases.md @@ -0,0 +1,83 @@ +# Weekly releases + +The `Weekly release` GitHub Actions workflow publishes development snapshots to +this repository's GitHub Releases, without a separate distribution repository. +It runs every Monday at 01:17 UTC (09:17 Asia/Shanghai) and can also be started +from **Actions → Weekly release → Run workflow** on the default branch. GitHub +scheduled jobs may be delayed; their timing is not an exact-time guarantee. +The workflow must be merged into the default branch before scheduling works. + +## Supported artifacts + +| Component | Operating system | Architectures | Format | +| --- | --- | --- | --- | +| Flutter client | Android | armv7, arm64, x64 | APK per ABI | +| Flutter client | Linux | x64 | tar.gz of the entire bundle | +| Flutter client | Windows | x64 | ZIP of the entire bundle | +| Flutter client | macOS | x64, arm64 | ZIP containing the app | +| Gin server | Linux | x64, arm64 | tar.gz | +| Gin server | Windows | x64, arm64 | ZIP | +| Gin server | macOS | x64, arm64 | tar.gz | + +The desktop client matrix is deliberately not identical to the server matrix. +Flutter 3.44.8's official Linux and Windows SDK archives are x64 only. Native +ARM64 desktop builds on these platforms need an additional, verified SDK/toolchain +strategy; running an x64 SDK on an ARM64 runner does not produce an ARM64 bundle. +macOS uses separate native runners and explicit Xcode architecture settings, +then checks the executable's architecture before packaging. iOS is excluded +until Apple signing and provisioning are configured. + +When extending this matrix, verify the official Flutter SDK manifests for +[Linux](https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json) +and [Windows](https://storage.googleapis.com/flutter_infra_release/releases/releases_windows.json), +and the current [GitHub-hosted runner labels](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). + +## Release contract + +1. Capture the triggering default-branch commit and create a `weekly-N` tag name, + where `N` is the workflow run number. Rerunning the same run retains its tag. +2. Reuse the existing Flutter and Go checks through `workflow_call`. Normal + push/PR CI remains check-only; compilation happens in the release workflow. +3. Build all packages from that same commit. Client builds pin Flutter 3.44.8; + server builds follow `server/go.mod` and use the pure-Go SQLite driver with + `CGO_ENABLED=0`. Matrix failures do not cancel other builders, but prevent + publication of the snapshot. +4. Download all 13 packages, add source/run metadata and SHA-256 checksums, and + upload them to a draft prerelease. Only make the release public after all + uploads succeed. A retry can repair a draft; an existing public snapshot is + never overwritten. + +Build jobs have read-only repository permissions. Only the publication job has +`contents: write`, using the automatic GitHub token. No personal access token, +separate repository, or signing secrets are required for these testing snapshots. +Tags are not semantic version numbers and weekly builds never replace GitHub's +stable **Latest** release. Concurrent runs are serialized, and build artifacts +are retained for 14 days; published release assets are not automatically deleted. + +## Before installing a snapshot + +Android currently uses the Gradle project's debug signing configuration. Each +runner can generate a different debug key, so upgrading an existing installation +may fail with a signature mismatch. Do not uninstall until you have saved your +server URL and recovery code, or have another authenticated device available. +A permanent release keystore should be added before stable Android distribution. +The pipeline does not weaken the application's network or security configuration. + +macOS packages are not Developer ID signed/notarized. Linux bundles require GTK 3 +and libsecret on a system compatible with the Ubuntu 24.04 build environment. +Windows bundles need the Visual C++ runtime. Keep all desktop bundle files together. + +Server packages contain only the binary, README, and `.env.example`. Keep database +files outside extracted release directories and set `PAWMATE_DATABASE_PATH` to a +persistent location before replacing the server binary. Configuration is passed +through environment variables; copying `.env.example` alone does not load it. + +To verify all downloaded assets on Linux: + +```bash +sha256sum --check SHA256SUMS +``` + +Download every listed asset first, or verify only your chosen file's matching +checksum entry. Checksums detect corruption, not publisher identity. Read the +snapshot's installation warnings and `BUILD-INFO.txt` before using it. diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..eca1ba7 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..246be5f --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,120 @@ +{ + "images": [ + { + "filename": "Icon-App-20x20@2x.png", + "idiom": "universal", + "scale": "2x", + "size": "20x20", + "platform": "ios" + }, + { + "filename": "Icon-App-20x20@3x.png", + "idiom": "universal", + "scale": "3x", + "size": "20x20", + "platform": "ios" + }, + { + "filename": "Icon-App-29x29@2x.png", + "idiom": "universal", + "scale": "2x", + "size": "29x29", + "platform": "ios" + }, + { + "filename": "Icon-App-29x29@3x.png", + "idiom": "universal", + "scale": "3x", + "size": "29x29", + "platform": "ios" + }, + { + "filename": "Icon-App-38x38@2x.png", + "idiom": "universal", + "scale": "2x", + "size": "38x38", + "platform": "ios" + }, + { + "filename": "Icon-App-38x38@3x.png", + "idiom": "universal", + "scale": "3x", + "size": "38x38", + "platform": "ios" + }, + { + "filename": "Icon-App-40x40@2x.png", + "idiom": "universal", + "scale": "2x", + "size": "40x40", + "platform": "ios" + }, + { + "filename": "Icon-App-40x40@3x.png", + "idiom": "universal", + "scale": "3x", + "size": "40x40", + "platform": "ios" + }, + { + "filename": "Icon-App-60x60@2x.png", + "idiom": "universal", + "scale": "2x", + "size": "60x60", + "platform": "ios" + }, + { + "filename": "Icon-App-60x60@3x.png", + "idiom": "universal", + "scale": "3x", + "size": "60x60", + "platform": "ios" + }, + { + "filename": "Icon-App-64x64@2x.png", + "idiom": "universal", + "scale": "2x", + "size": "64x64", + "platform": "ios" + }, + { + "filename": "Icon-App-64x64@3x.png", + "idiom": "universal", + "scale": "3x", + "size": "64x64", + "platform": "ios" + }, + { + "filename": "Icon-App-68x68@2x.png", + "idiom": "universal", + "scale": "2x", + "size": "68x68", + "platform": "ios" + }, + { + "filename": "Icon-App-76x76@2x.png", + "idiom": "universal", + "scale": "2x", + "size": "76x76", + "platform": "ios" + }, + { + "filename": "Icon-App-83.5x83.5@2x.png", + "idiom": "universal", + "scale": "2x", + "size": "83.5x83.5", + "platform": "ios" + }, + { + "filename": "Icon-App-1024x1024@1x.png", + "idiom": "universal", + "scale": "1x", + "size": "1024x1024", + "platform": "ios" + } + ], + "info": { + "version": 1, + "author": "icons_launcher" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..6cb14d5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..0703113 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..5baca1a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..4dce8d2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..a473c17 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png new file mode 100644 index 0000000..734ead8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png new file mode 100644 index 0000000..2e804bb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..db06a09 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..77ae809 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..77ae809 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..fe49212 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png new file mode 100644 index 0000000..06cb1c5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png new file mode 100644 index 0000000..996d670 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png new file mode 100644 index 0000000..c2927e9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..e8d92e7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..2aefceb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..e33b72e --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Pawmate + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + pawmate + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..75b21aa --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +output-dir: lib/l10n/generated diff --git a/lib/design/README.md b/lib/design/README.md new file mode 100644 index 0000000..8d34d55 --- /dev/null +++ b/lib/design/README.md @@ -0,0 +1,27 @@ +# Pawmate design system + +Keep presentation primitives separate from feature state, networking and storage. +Import the specific component or token file you need; do not reintroduce flat +compatibility files or a catch-all barrel at the design root. + +| Directory | Responsibility | +| --- | --- | +| `theme/` | Color/spacing tokens and Material theme construction | +| `layouts/` | Page scaffolds and shared layout shells | +| `components/` | Reusable content surfaces, controls and display widgets | +| `illustrations/` | Reusable decorative compositions | +| `icons/` | Purpose-based symbols and the shared deterministic crayon renderer | + +`theme/colors.dart` owns `PawmateColors`; `theme/spacing.dart` owns `PawmateSpace`. +Only application setup and theme tests need `theme/pawmate_theme.dart`. Widgets +that only use colors or spacing should import those token modules directly. + +Feature pages compose layouts, components, illustrations and icons. The design +layer must not import `features/`, API clients or credential models. Stateful +business actions stay in their features: for example, `ChatMessageBubble` +receives text, status and an optional retry callback, but never sends messages. + +Do not create a subdirectory for every widget. Add purpose-based icon families +as needed; see [the crayon icon guidelines](icons/README.md) for texture, seeds, +semantics and preview generation. Moving files must not alter visual geometry, +tokens or interaction behavior. diff --git a/lib/design/components/chat_message_bubble.dart b/lib/design/components/chat_message_bubble.dart new file mode 100644 index 0000000..90b768b --- /dev/null +++ b/lib/design/components/chat_message_bubble.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import 'handdrawn_card.dart'; +import '../theme/colors.dart'; +import '../../l10n/generated/app_localizations.dart'; + +/// A native, selectable message on a stable paper surface with a labeled state. +class ChatMessageBubble extends StatelessWidget { + const ChatMessageBubble({ + required this.text, + required this.own, + required this.status, + super.key, + this.time, + this.onRetry, + }); + + final String text; + final bool own; + final String status; + final DateTime? time; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => Align( + alignment: own ? Alignment.centerRight : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: constraints.maxWidth * .88), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: HanddrawnCard( + color: own + ? Color.alphaBlend( + PawmateColors.rose.withAlpha(35), + PawmateColors.paper, + ) + : PawmateColors.paper, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + own ? 'You' : 'Your partner', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 4), + SelectableText(text), + const SizedBox(height: 8), + Text( + [ + if (time != null) + '${time!.toLocal().hour.toString().padLeft(2, '0')}:${time!.toLocal().minute.toString().padLeft(2, '0')}', + if (status.isNotEmpty) status, + ].join(' · '), + style: Theme.of(context).textTheme.labelSmall, + ), + if (onRetry != null) + TextButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(AppLocalizations.of(context)!.retryMessage), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/design/components/couple_avatar.dart b/lib/design/components/couple_avatar.dart new file mode 100644 index 0000000..6443c22 --- /dev/null +++ b/lib/design/components/couple_avatar.dart @@ -0,0 +1,56 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../icons/profile/profile_doodle_icon.dart'; +import '../theme/colors.dart'; +import '../theme/spacing.dart'; + +/// Displays one member's portrait and nickname on a shared journal surface. +class CoupleAvatar extends StatelessWidget { + const CoupleAvatar({ + required this.avatar, + required this.nickname, + required this.label, + super.key, + }); + + final Uint8List avatar; + final String nickname; + final String label; + + @override + Widget build(BuildContext context) => Column( + children: [ + SizedBox.square( + dimension: 80, + child: CustomPaint( + foregroundPainter: const ProfileDoodlePainter(showPlus: false), + child: Padding( + padding: const EdgeInsets.all(9), + child: ClipOval( + child: Image.memory( + avatar, + fit: BoxFit.cover, + excludeFromSemantics: true, + ), + ), + ), + ), + ), + const SizedBox(height: PawmateSpace.small), + Text( + nickname, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle(color: PawmateColors.softBrown), + ), + ], + ); +} diff --git a/lib/design/components/crayon_avatar_button.dart b/lib/design/components/crayon_avatar_button.dart new file mode 100644 index 0000000..10035f3 --- /dev/null +++ b/lib/design/components/crayon_avatar_button.dart @@ -0,0 +1,53 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../icons/profile/profile_doodle_icon.dart'; + +/// An accessible avatar upload control with a deterministic crayon silhouette. +class CrayonAvatarButton extends StatelessWidget { + const CrayonAvatarButton({ + required this.label, + required this.onPressed, + super.key, + this.avatar, + }); + + final String label; + final Uint8List? avatar; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) => Tooltip( + message: label, + child: Semantics( + label: label, + button: true, + enabled: onPressed != null, + child: Material( + color: Colors.transparent, + child: InkResponse( + onTap: onPressed, + radius: 56, + // The crayon artwork stays still; no Material ink overlay or ripple. + splashFactory: NoSplash.splashFactory, + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + child: SizedBox.square( + dimension: 112, + child: CustomPaint( + foregroundPainter: ProfileDoodlePainter( + hasAvatar: avatar != null, + ), + child: Padding( + padding: const EdgeInsets.all(13), + child: avatar == null + ? null + : ClipOval(child: Image.memory(avatar!, fit: BoxFit.cover)), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/lib/design/components/handdrawn_action_card.dart b/lib/design/components/handdrawn_action_card.dart new file mode 100644 index 0000000..4f85286 --- /dev/null +++ b/lib/design/components/handdrawn_action_card.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'handdrawn_card.dart'; +import '../theme/colors.dart'; +import '../theme/spacing.dart'; + +/// A whole-card action with native focus, keyboard activation and screen reading. +class HanddrawnActionCard extends StatelessWidget { + const HanddrawnActionCard({ + required this.label, + required this.description, + required this.illustration, + required this.onPressed, + required this.color, + super.key, + }); + + final String label; + final String description; + final Widget illustration; + final VoidCallback onPressed; + final Color color; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: label, + hint: description, + onTap: onPressed, + child: ExcludeSemantics( + child: HanddrawnCard( + color: color, + padding: EdgeInsets.zero, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(16), + focusColor: PawmateColors.lavender.withAlpha(65), + child: Padding( + padding: const EdgeInsets.all(PawmateSpace.large), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center(child: illustration), + const SizedBox(height: PawmateSpace.medium), + Text( + label, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: PawmateSpace.small), + Text( + description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: PawmateColors.ink, + height: 1.4, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); +} diff --git a/lib/design/components/handdrawn_button.dart b/lib/design/components/handdrawn_button.dart new file mode 100644 index 0000000..e92d4e7 --- /dev/null +++ b/lib/design/components/handdrawn_button.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../theme/colors.dart'; + +/// A semantic Material button styled as a small hand-drawn paper label. +class HanddrawnButton extends StatelessWidget { + const HanddrawnButton({ + required this.label, + required this.onPressed, + super.key, + this.icon, + this.primary = true, + }); + + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final bool primary; + + @override + Widget build(BuildContext context) { + final shape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide( + color: primary ? PawmateColors.ink : PawmateColors.softBrown, + width: 2, + ), + ); + final child = icon == null + ? Text(label) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20), + const SizedBox(width: 8), + Flexible(child: Text(label, textAlign: TextAlign.center)), + ], + ); + + return SizedBox( + width: double.infinity, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 52), + child: primary + ? FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: PawmateColors.ink, + foregroundColor: PawmateColors.paper, + disabledBackgroundColor: PawmateColors.softBrown.withAlpha( + 100, + ), + disabledForegroundColor: PawmateColors.paper, + elevation: 0, + shape: shape, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + child: child, + ) + : OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: PawmateColors.ink, + side: const BorderSide( + color: PawmateColors.softBrown, + width: 2, + ), + shape: shape, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + child: child, + ), + ), + ); + } +} diff --git a/lib/design/components/handdrawn_card.dart b/lib/design/components/handdrawn_card.dart new file mode 100644 index 0000000..f435d70 --- /dev/null +++ b/lib/design/components/handdrawn_card.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import '../theme/colors.dart'; + +/// A paper-like surface with a stable ink outline and a small offset shadow. +class HanddrawnCard extends StatelessWidget { + const HanddrawnCard({ + required this.child, + super.key, + this.color, + this.padding = const EdgeInsets.all(20), + }); + + final Widget child; + final Color? color; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: color ?? PawmateColors.card, + border: Border.all(color: PawmateColors.ink, width: 2), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(14), + bottomRight: Radius.circular(20), + bottomLeft: Radius.circular(13), + ), + boxShadow: const [ + BoxShadow( + color: Color(0x263C3530), + offset: Offset(3, 4), + blurRadius: 0, + ), + ], + ), + padding: padding, + child: child, + ); + } +} diff --git a/lib/design/components/handdrawn_text_field.dart b/lib/design/components/handdrawn_text_field.dart new file mode 100644 index 0000000..2f670c5 --- /dev/null +++ b/lib/design/components/handdrawn_text_field.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// A URL-friendly form field that keeps native Flutter semantics and focus. +class HanddrawnTextField extends StatelessWidget { + const HanddrawnTextField({ + required this.controller, + required this.labelText, + super.key, + this.hintText, + this.validator, + this.prefixIcon, + this.keyboardType = TextInputType.url, + this.enabled = true, + }); + + final TextEditingController controller; + final String labelText; + final String? hintText; + final FormFieldValidator? validator; + final IconData? prefixIcon; + final TextInputType keyboardType; + final bool enabled; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + enabled: enabled, + autocorrect: false, + keyboardType: keyboardType, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + prefixIcon: prefixIcon == null ? null : Icon(prefixIcon), + ), + validator: validator, + ); + } +} diff --git a/lib/design/icons/README.md b/lib/design/icons/README.md new file mode 100644 index 0000000..d7a4fbd --- /dev/null +++ b/lib/design/icons/README.md @@ -0,0 +1,36 @@ +# Pawmate crayon icons + +Organize native Dart illustrations by purpose, not by individual feature pages: + +- `access/`: invitations, recovery and additional-device login. +- `profile/`: avatar upload circles and plus signs, reused by profile controls. +- Future `chat/`, `home/` and `play/` families should reuse `CrayonStrokes`. +- `crayon_strokes.dart`: shared dry-wax renderer, not a catalog of symbols. + +Use granular crayon/oil-pastel strokes with bounded jitter, irregular pressure +overdraw and slightly rough or broken edges. The result should feel casual, +warm and handmade, like pigment catching on journal paper. Keep silhouettes +legible and use labels for actions; texture is decorative, not semantic. + +All strokes use fixed purpose-based seeds. Repainting identical inputs must +produce identical pixels. Keep drawing inside a normalized coordinate space, +wrap static illustrations in repaint boundaries, and do not add animated noise. +Expose optional semantic labels for standalone icons; illustrations inside a +labeled action should not be announced twice. Text remains native Flutter text. + +Pixel-stability and access-flow tests live in `test/login_methods_test.dart`. +To regenerate the before/after widget previews with a readable local font: + +```bash +PAWMATE_PREVIEW_FONT=/path/to/reading-font.ttf flutter test \ + test/onboarding_preview_test.dart --dart-define=PAWMATE_CAPTURE_PREVIEWS=true +``` + +This opt-in capture test is skipped in normal CI. `login-before.png` is a +reconstruction of the former stacked composition, not a historical device +screenshot. Both previews contain no real invitation or credentials. + +Member-profile previews are captured by `test/profile_preview_test.dart`. +Use a Chinese-capable reading font and optionally set +`PAWMATE_PREVIEW_MONO_FONT` to a monospace font for server URLs. The opt-in test +writes English and Chinese inviter/invitee screenshots under `books/src/images/`. diff --git a/lib/design/icons/access/access_doodle_icon.dart b/lib/design/icons/access/access_doodle_icon.dart new file mode 100644 index 0000000..9c1198b --- /dev/null +++ b/lib/design/icons/access/access_doodle_icon.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; + +import '../../theme/colors.dart'; +import '../crayon_strokes.dart'; + +/// Purpose-based symbols for the four private-home access methods. +enum AccessDoodle { createInvitation, acceptInvitation, restoreHome, addDevice } + +/// A native, deterministic crayon illustration, never a substituted bitmap. +class AccessDoodleIcon extends StatelessWidget { + const AccessDoodleIcon({ + required this.symbol, + super.key, + this.size = 80, + this.color = PawmateColors.rose, + this.semanticLabel, + }); + + final AccessDoodle symbol; + final double size; + final Color color; + final String? semanticLabel; + + @override + Widget build(BuildContext context) => Semantics( + label: semanticLabel, + excludeSemantics: true, + child: RepaintBoundary( + child: SizedBox.square( + dimension: size, + child: CustomPaint(painter: _AccessPainter(symbol, color)), + ), + ), + ); +} + +class _AccessPainter extends CustomPainter { + const _AccessPainter(this.symbol, this.color); + + final AccessDoodle symbol; + final Color color; + + /// Builds a soft, slightly asymmetric journal heart in the icon's 96px space. + Path _heart(double x, double y, double scale) => Path() + ..moveTo(x, y + 5 * scale) + ..cubicTo( + x - 12 * scale, + y - 7 * scale, + x - 21 * scale, + y + 8 * scale, + x - 10 * scale, + y + 17 * scale, + ) + ..lineTo(x, y + 25 * scale) + ..lineTo(x + 12 * scale, y + 15 * scale) + ..cubicTo( + x + 21 * scale, + y + 5 * scale, + x + 10 * scale, + y - 7 * scale, + x, + y + 5 * scale, + ) + ..close(); + + /// Renders each purpose with the same stable pressure/grain vocabulary. + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.scale(size.width / 96, size.height / 96); + final crayon = CrayonStrokes(canvas, seed: 410 + symbol.index * 97); + switch (symbol) { + case AccessDoodle.createInvitation: + final envelope = Path() + ..moveTo(12, 37) + ..lineTo(68, 35) + ..lineTo(70, 74) + ..quadraticBezierTo(39, 77, 14, 73) + ..close(); + crayon.wash(envelope, color); + crayon.stroke(envelope, PawmateColors.ink); + crayon.stroke( + Path() + ..moveTo(13, 38) + ..lineTo(41, 59) + ..lineTo(68, 36), + PawmateColors.ink, + ); + crayon.stroke( + Path() + ..moveTo(15, 72) + ..lineTo(31, 55) + ..moveTo(51, 54) + ..lineTo(69, 72), + PawmateColors.ink, + width: 1.8, + ); + final heart = _heart(73, 10, 0.64); + crayon.wash(heart, color); + crayon.stroke(heart, PawmateColors.ink, width: 2.3); + crayon.stroke( + Path() + ..moveTo(67, 53) + ..quadraticBezierTo(86, 51, 85, 36) + ..moveTo(79, 41) + ..lineTo(85, 34) + ..lineTo(90, 41), + PawmateColors.ink, + ); + case AccessDoodle.acceptInvitation: + final open = Path() + ..moveTo(15, 44) + ..lineTo(47, 17) + ..lineTo(80, 42) + ..lineTo(77, 77) + ..quadraticBezierTo(47, 80, 18, 76) + ..close(); + crayon.wash(open, color); + crayon.stroke(open, PawmateColors.ink); + final note = Path() + ..moveTo(28, 29) + ..lineTo(65, 27) + ..lineTo(67, 58) + ..lineTo(28, 59) + ..close(); + canvas.drawPath(note, Paint()..color = PawmateColors.card); + crayon.stroke(note, PawmateColors.ink, width: 2.3); + final heart = _heart(47, 32, 0.55); + crayon.wash(heart, color); + crayon.stroke(heart, PawmateColors.ink, width: 2); + crayon.stroke( + Path() + ..moveTo(16, 45) + ..lineTo(47, 66) + ..lineTo(79, 43) + ..moveTo(19, 75) + ..lineTo(37, 60) + ..moveTo(58, 59) + ..lineTo(77, 76), + PawmateColors.ink, + ); + case AccessDoodle.restoreHome: + final box = Path() + ..moveTo(19, 46) + ..lineTo(75, 44) + ..lineTo(73, 78) + ..quadraticBezierTo(46, 82, 21, 77) + ..close(); + crayon.wash(box, color); + crayon.stroke(box, PawmateColors.ink); + crayon.stroke( + Path() + ..moveTo(15, 44) + ..lineTo(19, 34) + ..lineTo(77, 32) + ..lineTo(81, 44) + ..close(), + PawmateColors.ink, + ); + crayon.stroke( + Path()..addOval(const Rect.fromLTWH(32, 54, 15, 15)), + PawmateColors.ink, + ); + crayon.stroke( + Path() + ..moveTo(47, 61) + ..lineTo(64, 61) + ..lineTo(64, 68) + ..moveTo(57, 61) + ..lineTo(57, 67), + PawmateColors.ink, + ); + crayon.stroke( + Path() + ..moveTo(26, 24) + ..cubicTo(29, 6, 60, 6, 68, 23) + ..moveTo(69, 13) + ..lineTo(69, 25) + ..lineTo(58, 22), + PawmateColors.ink, + ); + case AccessDoodle.addDevice: + final laptop = Path() + ..moveTo(13, 24) + ..lineTo(64, 22) + ..lineTo(66, 61) + ..lineTo(14, 63) + ..close(); + crayon.wash(laptop, color); + crayon.stroke(laptop, PawmateColors.ink); + crayon.stroke( + Path() + ..moveTo(13, 62) + ..lineTo(7, 72) + ..quadraticBezierTo(37, 77, 72, 71) + ..lineTo(66, 61), + PawmateColors.ink, + ); + final phone = Path() + ..moveTo(59, 38) + ..quadraticBezierTo(57, 36, 57, 41) + ..lineTo(58, 79) + ..quadraticBezierTo(70, 82, 82, 79) + ..lineTo(82, 38) + ..close(); + canvas.drawPath(phone, Paint()..color = PawmateColors.card); + crayon.wash(phone, color); + crayon.stroke(phone, PawmateColors.ink); + crayon.stroke( + Path() + ..moveTo(65, 43) + ..lineTo(74, 43) + ..moveTo(65, 74) + ..lineTo(74, 74), + PawmateColors.ink, + width: 1.8, + ); + crayon.stroke( + Path() + ..moveTo(72, 11) + ..lineTo(72, 27) + ..moveTo(64, 19) + ..lineTo(80, 19), + PawmateColors.ink, + ); + } + canvas.restore(); + } + + @override + bool shouldRepaint(covariant _AccessPainter oldDelegate) => + oldDelegate.symbol != symbol || oldDelegate.color != color; +} diff --git a/lib/design/icons/crayon_strokes.dart b/lib/design/icons/crayon_strokes.dart new file mode 100644 index 0000000..9e8a579 --- /dev/null +++ b/lib/design/icons/crayon_strokes.dart @@ -0,0 +1,74 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Draws dry-wax strokes with seeded pressure variation, grain and small gaps. +/// Geometry is sampled from native paths; the seed never depends on build time. +class CrayonStrokes { + CrayonStrokes(this.canvas, {required int seed}) : _random = math.Random(seed); + + final Canvas canvas; + final math.Random _random; + + /// Layers translucent, slightly offset marks along a recognizable silhouette. + void stroke(Path path, Color color, {double width = 2.8}) { + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + for (final metric in path.computeMetrics()) { + for (var distance = 0.0; distance < metric.length; distance += 1.8) { + final start = metric.getTangentForOffset(distance)!; + final end = metric.getTangentForOffset( + math.min(distance + 1.9, metric.length), + )!; + final normal = Offset(-start.vector.dy, start.vector.dx); + final pressure = 0.72 + _random.nextDouble() * 0.45; + final drift = normal * ((_random.nextDouble() - 0.5) * 0.75); + // Short dry patches expose the actual paper beneath, not painted white. + if (_random.nextDouble() > 0.045) { + paint + ..color = color.withAlpha(155 + _random.nextInt(85)) + ..strokeWidth = width * pressure; + canvas.drawLine(start.position + drift, end.position + drift, paint); + } + // An uneven second pressure line supplies rough edges and pigment grain. + paint + ..color = color.withAlpha(45 + _random.nextInt(80)) + ..strokeWidth = 0.45 + _random.nextDouble() * 0.7; + final edge = normal * (width * (_random.nextDouble() - 0.5)); + canvas.drawLine(start.position + edge, end.position + edge, paint); + } + } + } + + /// Adds a restrained crayon wash with hatch marks and scattered pigment. + void wash(Path shape, Color color) { + final bounds = shape.getBounds(); + canvas.drawPath(shape, Paint()..color = color.withAlpha(38)); + canvas.save(); + canvas.clipPath(shape); + final paint = Paint() + ..strokeCap = StrokeCap.round + ..strokeWidth = 3.2; + for (var y = bounds.top; y < bounds.bottom + 12; y += 4.5) { + paint.color = color.withAlpha(45 + _random.nextInt(45)); + canvas.drawLine( + Offset(bounds.left, y), + Offset(bounds.right, y - 9 + _random.nextDouble()), + paint, + ); + } + for (var i = 0; i < 100; i++) { + paint.color = color.withAlpha(70 + _random.nextInt(70)); + canvas.drawCircle( + Offset( + bounds.left + _random.nextDouble() * bounds.width, + bounds.top + _random.nextDouble() * bounds.height, + ), + 0.3 + _random.nextDouble() * 0.6, + paint, + ); + } + canvas.restore(); + } +} diff --git a/lib/design/icons/profile/profile_doodle_icon.dart b/lib/design/icons/profile/profile_doodle_icon.dart new file mode 100644 index 0000000..8a89c06 --- /dev/null +++ b/lib/design/icons/profile/profile_doodle_icon.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import '../../theme/colors.dart'; +import '../crayon_strokes.dart'; + +/// Draws a stable wax-textured avatar circle and its upload plus sign. +class ProfileDoodlePainter extends CustomPainter { + const ProfileDoodlePainter({this.hasAvatar = false, this.showPlus = true}); + + final bool hasAvatar; + final bool showPlus; + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.scale(size.width / 112, size.height / 112); + final crayon = CrayonStrokes(canvas, seed: 937); + final circle = Path() + ..moveTo(56, 7) + ..cubicTo(83, 5, 106, 30, 104, 57) + ..cubicTo(107, 84, 80, 107, 54, 104) + ..cubicTo(27, 106, 5, 81, 8, 53) + ..cubicTo(6, 27, 29, 6, 56, 7) + ..close(); + crayon.stroke(circle, PawmateColors.softBrown, width: 3.2); + if (!showPlus) { + canvas.restore(); + return; + } + final center = hasAvatar ? const Offset(92, 91) : const Offset(56, 56); + if (hasAvatar) { + canvas.drawCircle(center, 15, Paint()..color = PawmateColors.paper); + } + final extent = hasAvatar ? 8.0 : 15.0; + crayon.stroke( + Path() + ..moveTo(center.dx - extent, center.dy) + ..lineTo(center.dx + extent, center.dy) + ..moveTo(center.dx, center.dy - extent) + ..lineTo(center.dx, center.dy + extent), + PawmateColors.rose, + width: 3.4, + ); + canvas.restore(); + } + + @override + bool shouldRepaint(ProfileDoodlePainter oldDelegate) => + oldDelegate.hasAvatar != hasAvatar || oldDelegate.showPlus != showPlus; +} diff --git a/lib/design/layouts/handdrawn_scaffold.dart b/lib/design/layouts/handdrawn_scaffold.dart new file mode 100644 index 0000000..6591125 --- /dev/null +++ b/lib/design/layouts/handdrawn_scaffold.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import '../theme/colors.dart'; + +/// Provides the paper surface and restrained app bar shared by Pawmate pages. +class HanddrawnScaffold extends StatelessWidget { + const HanddrawnScaffold({ + required this.title, + required this.body, + super.key, + this.bottomNavigationBar, + this.actions, + }); + + final String? title; + final Widget body; + final Widget? bottomNavigationBar; + final List? actions; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: title == null + ? null + : Text( + title!, + style: const TextStyle( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + actions: actions, + ), + body: body, + bottomNavigationBar: bottomNavigationBar, + ); + } +} diff --git a/lib/design/theme/colors.dart b/lib/design/theme/colors.dart new file mode 100644 index 0000000..bf15f99 --- /dev/null +++ b/lib/design/theme/colors.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +/// Shared colors for the Pawmate hand-drawn home-journal visual language. +abstract final class PawmateColors { + static const paper = Color(0xFFFFF8E7); + static const ink = Color(0xFF3C3530); + static const softBrown = Color(0xFF8B7666); + static const rose = Color(0xFFE98B8B); + static const lavender = Color(0xFFA99BD6); + static const mint = Color(0xFF9BC7B0); + static const sun = Color(0xFFF3C969); + static const sky = Color(0xFF9DC6D8); + static const error = Color(0xFFC96F6F); + static const card = Color(0xFFFFFCF3); + static const rosePaper = Color(0xFFFFEEE8); + static const lavenderPaper = Color(0xFFF2EEF9); +} diff --git a/lib/design/theme/pawmate_theme.dart b/lib/design/theme/pawmate_theme.dart new file mode 100644 index 0000000..60298bd --- /dev/null +++ b/lib/design/theme/pawmate_theme.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import 'colors.dart'; + +/// Creates the Material theme adapted to Pawmate's paper and ink surfaces. +ThemeData buildPawmateTheme() { + final colorScheme = + ColorScheme.fromSeed( + seedColor: PawmateColors.rose, + brightness: Brightness.light, + ).copyWith( + primary: PawmateColors.ink, + onPrimary: PawmateColors.paper, + secondary: PawmateColors.lavender, + onSecondary: PawmateColors.ink, + surface: PawmateColors.paper, + onSurface: PawmateColors.ink, + error: PawmateColors.error, + onError: PawmateColors.paper, + ); + + return ThemeData( + colorScheme: colorScheme, + scaffoldBackgroundColor: PawmateColors.paper, + useMaterial3: true, + appBarTheme: const AppBarTheme( + backgroundColor: PawmateColors.paper, + surfaceTintColor: Colors.transparent, + foregroundColor: PawmateColors.ink, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: PawmateColors.card, + labelStyle: const TextStyle(color: PawmateColors.softBrown), + hintStyle: TextStyle(color: PawmateColors.softBrown.withAlpha(170)), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: PawmateColors.ink, width: 2), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: PawmateColors.ink, width: 2), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: PawmateColors.lavender, width: 3), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: PawmateColors.error, width: 2), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: PawmateColors.error, width: 3), + ), + ), + snackBarTheme: const SnackBarThemeData( + backgroundColor: PawmateColors.ink, + contentTextStyle: TextStyle(color: PawmateColors.paper), + ), + ); +} diff --git a/lib/design/theme/spacing.dart b/lib/design/theme/spacing.dart new file mode 100644 index 0000000..0ab8c08 --- /dev/null +++ b/lib/design/theme/spacing.dart @@ -0,0 +1,9 @@ +/// Shared dimensions for paper components and responsive onboarding layouts. +abstract final class PawmateSpace { + static const small = 8.0; + static const medium = 12.0; + static const large = 16.0; + static const page = 24.0; + static const section = 32.0; + static const contentWidth = 680.0; +} diff --git a/lib/features/chat/chat_api.dart b/lib/features/chat/chat_api.dart new file mode 100644 index 0000000..eff5239 --- /dev/null +++ b/lib/features/chat/chat_api.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../pairing/pairing_api.dart'; +import '../pairing/pairing_credentials.dart'; + +/// A text message whose identity and order are assigned by the private server. +class ChatMessage { + const ChatMessage({ + required this.id, + required this.clientID, + required this.sender, + required this.text, + required this.createdAt, + }); + + /// Decodes a persisted message, including messages from this member's other devices. + factory ChatMessage.fromJson(Map json) => ChatMessage( + id: json['id'] as int, + clientID: json['client_id'] as String, + sender: json['sender'] as String, + text: json['text'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + ); + + final int id; + final String clientID; + final String sender; + final String text; + final DateTime createdAt; +} + +/// History and shared member-level receipts returned in a single snapshot. +class ChatSnapshot { + const ChatSnapshot({ + required this.messages, + required this.hasMore, + required this.latestID, + required this.readID, + required this.partnerReadID, + required this.unreadCount, + }); + + /// Decodes message pagination and the current unread count. + factory ChatSnapshot.fromJson(Map json) => ChatSnapshot( + messages: (json['messages'] as List) + .map((item) => ChatMessage.fromJson(item as Map)) + .toList(), + hasMore: json['has_more'] as bool, + latestID: json['latest_id'] as int, + readID: json['read_id'] as int, + partnerReadID: json['partner_read_id'] as int, + unreadCount: json['unread_count'] as int, + ); + + final List messages; + final bool hasMore; + final int latestID; + final int readID; + final int partnerReadID; + final int unreadCount; +} + +/// Calls authenticated chat endpoints with bounded requests and no private logging. +class ChatApi { + ChatApi(this.credentials, [http.Client? client]) + : _client = client ?? http.Client(); + + final SavedPairingCredentials credentials; + final http.Client _client; + + /// Releases the HTTP client when the couple space is closed. + void close() => _client.close(); + + /// Retrieves recent history, incremental messages, or an older history page. + Future messages({int afterID = 0, int beforeID = 0}) async { + final uri = + PairingApi.resolveEndpoint( + PairingApi.parseServerURL(credentials.serverURL), + '/api/v1/chat/messages', + ).replace( + queryParameters: { + if (afterID > 0) 'after_id': '$afterID', + if (beforeID > 0) 'before_id': '$beforeID', + 'limit': '50', + }, + ); + final response = await _client + .get(uri, headers: _headers) + .timeout(const Duration(seconds: 10)); + return ChatSnapshot.fromJson(_decode(response)); + } + + /// Sends text with a stable client id so a lost response can be safely retried. + Future send(String clientID, String text) async => + ChatMessage.fromJson( + await _post('/messages', {'client_id': clientID, 'text': text}), + ); + + /// Advances this member's read cursor; receipts are shared across devices. + Future markRead(int messageID) async => + (await _post('/read', {'message_id': messageID}))['read_id'] as int; + + Map get _headers => { + 'authorization': 'Bearer ${credentials.accessToken}', + 'content-type': 'application/json', + }; + + /// Sends a JSON request without placing message text or credentials in a URL. + Future> _post( + String path, + Map body, + ) async { + final response = await _client + .post( + PairingApi.resolveEndpoint( + PairingApi.parseServerURL(credentials.serverURL), + '/api/v1/chat$path', + ), + headers: _headers, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 10)); + return _decode(response); + } + + /// Retains HTTP status so revoked devices return to the existing session gate. + Map _decode(http.Response response) { + if (response.statusCode != 200) { + throw PairingApiException( + 'Could not synchronize messages (${response.statusCode}).', + statusCode: response.statusCode, + ); + } + return jsonDecode(utf8.decode(response.bodyBytes)) as Map; + } +} diff --git a/lib/features/chat/chat_controller.dart b/lib/features/chat/chat_controller.dart new file mode 100644 index 0000000..930bc64 --- /dev/null +++ b/lib/features/chat/chat_controller.dart @@ -0,0 +1,242 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import '../pairing/pairing_api.dart'; +import 'chat_api.dart'; + +/// A local outgoing message keeps the same id during ambiguous network retries. +class OutgoingMessage { + OutgoingMessage(this.clientID, this.text); + final String clientID; + final String text; + bool sending = true; +} + +/// Shares messages, receipts and an in-memory outbox across the couple-space tabs. +class ChatController extends ChangeNotifier { + ChatController(this.api); + + final ChatApi api; + final List _messages = []; + final List _outbox = []; + Timer? _timer; + bool _disposed = false; + bool _syncing = false; + bool _reading = false; + bool _historyLoading = false; + bool _foreground = true; + bool _chatVisible = true; + DateTime? _readRetryAt; + int _knownLatestID = 0; + int _syncAfter = 0; + int _requestedReadID = 0; + int _readID = 0; + int partnerReadID = 0; + int unreadCount = 0; + bool loaded = false; + bool hasOlder = false; + bool unauthorized = false; + String? error; + + List get messages => List.unmodifiable(_messages); + List get outbox => List.unmodifiable(_outbox); + bool get loading => !loaded && _syncing; + bool get historyLoading => _historyLoading; + bool get foreground => _foreground; + bool get caughtUp => loaded && _syncAfter >= _knownLatestID; + + /// Stops queued read advances as soon as another tab becomes visible. + void setChatVisible(bool visible) { + _chatVisible = visible; + } + + /// Polls only while the application is foregrounded; history stays in memory. + void start() { + unawaited(synchronize()); + _timer ??= Timer.periodic(const Duration(seconds: 2), (_) { + if (_foreground) unawaited(synchronize()); + }); + } + + /// Resumes synchronization after backgrounding without generating read receipts. + void setForeground(bool foreground) { + _foreground = foreground; + if (foreground) unawaited(synchronize()); + _emit(); + } + + /// Fetches every forward page; sending locally never advances the fetch cursor. + Future synchronize() async { + if (_disposed || _syncing || unauthorized) return; + _syncing = true; + _emit(); + try { + bool more; + do { + final previous = _syncAfter; + final snapshot = await api.messages(afterID: _syncAfter); + if (_disposed) return; + _merge(snapshot.messages); + if (_syncAfter == 0) hasOlder = snapshot.hasMore; + if (snapshot.messages.isNotEmpty) { + _syncAfter = snapshot.messages.last.id; + } + _applyReceipts(snapshot); + loaded = true; + error = null; + // Initial history is the most recent window, not an ascending delta page. + more = previous > 0 && snapshot.hasMore && _syncAfter > previous; + _emit(); + } while (more && !_disposed); + } catch (failure) { + _handleError(failure); + } finally { + _syncing = false; + _emit(); + } + } + + /// Prepends older history without changing the independent forward cursor. + Future loadOlder() async { + if (_disposed || _historyLoading || !hasOlder || _messages.isEmpty) return; + _historyLoading = true; + _emit(); + try { + final snapshot = await api.messages(beforeID: _messages.first.id); + if (_disposed) return; + _merge(snapshot.messages); + hasOlder = snapshot.hasMore; + error = null; + } catch (failure) { + _handleError(failure); + } finally { + _historyLoading = false; + _emit(); + } + } + + /// Queues a new message using a cryptographically random, retry-safe client id. + Future send(String text) async { + text = text.trim(); + if (_disposed || text.isEmpty || text.runes.length > 4000) return; + final random = Random.secure(); + final id = List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + final outgoing = OutgoingMessage(id, text); + _outbox.add(outgoing); + _emit(); + await _send(outgoing); + } + + /// Retries failed text with its original client id to prevent duplicate messages. + Future retry(OutgoingMessage outgoing) async { + if (_disposed || outgoing.sending || !_outbox.contains(outgoing)) return; + outgoing.sending = true; + _emit(); + await _send(outgoing); + } + + /// Reconciles the response with messages already observed through polling. + Future _send(OutgoingMessage outgoing) async { + try { + final message = await api.send(outgoing.clientID, outgoing.text); + if (_disposed) return; + _merge([message]); + _outbox.remove(outgoing); + unawaited(synchronize()); + } catch (failure) { + if (!_disposed) outgoing.sending = false; + _handleError(failure); + } + _emit(); + } + + /// Marks only the rendered cursor supplied by the visible, foreground chat page. + Future markVisibleRead(int id) async { + if (_disposed || + !_foreground || + !_chatVisible || + !caughtUp || + id <= _readID || + unauthorized || + (_readRetryAt != null && DateTime.now().isBefore(_readRetryAt!))) { + return; + } + _requestedReadID = max(_requestedReadID, id); + if (_reading) return; + _reading = true; + try { + while (_requestedReadID > _readID && + _foreground && + _chatVisible && + !_disposed) { + final read = await api.markRead(_requestedReadID); + if (_disposed) return; + _readID = max(_readID, read); + _readRetryAt = null; + _emit(); + } + // Only a fresh server snapshot can count messages arriving during the receipt. + unawaited(synchronize()); + } catch (failure) { + _readRetryAt = DateTime.now().add(const Duration(seconds: 2)); + _handleError(failure); + } finally { + _reading = false; + _emit(); + } + } + + /// Merges by server id and removes outbox echoes sent by this installation. + void _merge(List incoming) { + final byID = {for (final message in _messages) message.id: message}; + for (final message in incoming) { + _knownLatestID = max(_knownLatestID, message.id); + byID[message.id] = message; + if (message.sender == api.credentials.role) { + _outbox.removeWhere( + (outgoing) => outgoing.clientID == message.clientID, + ); + } + } + _messages + ..clear() + ..addAll(byID.values); + _messages.sort((a, b) => a.id.compareTo(b.id)); + } + + /// Never regresses read receipts when an older in-flight snapshot completes. + void _applyReceipts(ChatSnapshot snapshot) { + _knownLatestID = max(_knownLatestID, snapshot.latestID); + partnerReadID = max(partnerReadID, snapshot.partnerReadID); + _readID = max(_readID, snapshot.readID); + // Ignore stale unread metadata after a newer local read acknowledgment. + if (snapshot.readID >= _readID) unreadCount = snapshot.unreadCount; + } + + /// Reports connectivity failures without exposing private message content. + void _handleError(Object failure) { + if (_disposed) return; + if (failure is PairingApiException && failure.statusCode == 401) { + unauthorized = true; + } + error = 'Messages could not synchronize. Check your connection and retry.'; + } + + /// Guards against asynchronous completions after leaving the couple space. + void _emit() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _timer?.cancel(); + api.close(); + super.dispose(); + } +} diff --git a/lib/features/chat/chat_page.dart b/lib/features/chat/chat_page.dart new file mode 100644 index 0000000..48fb5bc --- /dev/null +++ b/lib/features/chat/chat_page.dart @@ -0,0 +1,246 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../design/components/chat_message_bubble.dart'; +import '../../design/components/handdrawn_card.dart'; +import 'chat_controller.dart'; +import '../../l10n/generated/app_localizations.dart'; + +/// The default couple-space tab, with text history and visibility-based receipts. +class ChatPage extends StatefulWidget { + const ChatPage({required this.controller, required this.active, super.key}); + + final ChatController controller; + final bool active; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + final _draft = TextEditingController(); + final _scroll = ScrollController(); + final _viewportKey = GlobalKey(); + final Map _incomingKeys = {}; + bool _atLatest = true; + + @override + void initState() { + super.initState(); + _scroll.addListener(_onScroll); + } + + /// Reads incoming messages inside the rendered viewport, never on fetch alone. + void _scheduleRead() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + widget.active && + widget.controller.foreground && + _scroll.hasClients) { + final viewport = _viewportKey.currentContext?.findRenderObject(); + if (viewport is! RenderBox || !viewport.hasSize) return; + final bounds = viewport.localToGlobal(Offset.zero) & viewport.size; + var visibleID = 0; + for (final entry in _incomingKeys.entries) { + final bubble = entry.value.currentContext?.findRenderObject(); + if (bubble is RenderBox && bubble.hasSize) { + final messageBounds = + bubble.localToGlobal(Offset.zero) & bubble.size; + if (messageBounds.overlaps(bounds) && entry.key > visibleID) { + visibleID = entry.key; + } + } + } + if (visibleID > 0) { + unawaited(widget.controller.markVisibleRead(visibleID)); + } + } + }); + } + + /// Keeps unread messages unread while browsing earlier history. + void _onScroll() { + final atLatest = _scroll.offset <= 24; + if (_atLatest != atLatest) setState(() => _atLatest = atLatest); + if (widget.controller.messages.isNotEmpty) { + _scheduleRead(); + } + } + + /// Jumps to the latest message using the user's animation preference. + void _showLatest() { + if (!_scroll.hasClients) return; + if (MediaQuery.disableAnimationsOf(context)) { + _scroll.jumpTo(0); + } else { + unawaited( + _scroll.animateTo( + 0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ), + ); + } + } + + /// Sends a valid draft while retaining independent failed messages for retry. + void _send() { + final text = _draft.text.trim(); + if (text.isEmpty || text.runes.length > 4000) return; + _draft.clear(); + unawaited(widget.controller.send(text)); + _showLatest(); + } + + @override + void dispose() { + _draft.dispose(); + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + final chat = widget.controller; + final messages = chat.messages.reversed.toList(); + final pending = chat.outbox.reversed.toList(); + if (messages.isNotEmpty && widget.active) { + _scheduleRead(); + } + return Column( + children: [ + if (chat.error != null) + MaterialBanner( + content: Text(l10n.chatError), + actions: [ + TextButton( + onPressed: () => chat.synchronize(), + child: Text(l10n.retry), + ), + ], + ), + Expanded( + child: SizedBox.expand( + key: _viewportKey, + child: chat.loading + ? const Center(child: CircularProgressIndicator()) + : messages.isEmpty && pending.isEmpty + ? Center( + child: Padding( + padding: EdgeInsets.all(24), + child: HanddrawnCard(child: Text(l10n.firstWords)), + ), + ) + : ListView.builder( + controller: _scroll, + reverse: true, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + itemCount: + pending.length + + messages.length + + (chat.hasOlder ? 1 : 0), + itemBuilder: (context, index) { + if (index < pending.length) { + final outgoing = pending[index]; + return ChatMessageBubble( + key: ValueKey('pending-${outgoing.clientID}'), + text: outgoing.text, + own: true, + status: outgoing.sending + ? l10n.sending + : l10n.notSent, + onRetry: outgoing.sending + ? null + : () => chat.retry(outgoing), + ); + } + final messageIndex = index - pending.length; + if (messageIndex >= messages.length) { + return TextButton( + onPressed: chat.historyLoading + ? null + : () => chat.loadOlder(), + child: Text( + chat.historyLoading + ? l10n.loading + : l10n.loadEarlier, + ), + ); + } + final message = messages[messageIndex]; + final own = message.sender == chat.api.credentials.role; + return ChatMessageBubble( + key: own + ? ValueKey(message.id) + : _incomingKeys.putIfAbsent( + message.id, + () => GlobalKey(), + ), + text: message.text, + own: own, + time: message.createdAt, + status: own + ? (message.id <= chat.partnerReadID + ? l10n.read + : l10n.unread) + : '', + ); + }, + ), + ), + ), + if (!_atLatest && chat.unreadCount > 0) + TextButton.icon( + onPressed: _showLatest, + icon: const Icon(Icons.arrow_downward), + label: Text(l10n.unreadGoLatest(chat.unreadCount)), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: ValueListenableBuilder( + valueListenable: _draft, + builder: (context, draft, _) => Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _draft, + minLines: 1, + maxLines: 4, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + labelText: l10n.messagePartner, + errorText: draft.text.runes.length > 4000 + ? l10n.messageTooLong + : null, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + tooltip: l10n.sendMessage, + onPressed: + draft.text.trim().isEmpty || + draft.text.runes.length > 4000 || + chat.unauthorized + ? null + : _send, + icon: const Icon(Icons.send), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ); +} diff --git a/lib/features/pairing/device_login_page.dart b/lib/features/pairing/device_login_page.dart new file mode 100644 index 0000000..49054c9 --- /dev/null +++ b/lib/features/pairing/device_login_page.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; + +import '../../design/components/handdrawn_button.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/components/handdrawn_text_field.dart'; +import '../../design/icons/access/access_doodle_icon.dart'; +import '../../design/theme/colors.dart'; +import '../../design/theme/spacing.dart'; +import 'paired_home_page.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'widgets/pairing_error_note.dart'; +import '../../l10n/generated/app_localizations.dart'; + +/// Adds this installation to an existing member using a one-time login code. +class DeviceLoginPage extends StatefulWidget { + const DeviceLoginPage({super.key}); + + @override + State createState() => _DeviceLoginPageState(); +} + +class _DeviceLoginPageState extends State { + final _formKey = GlobalKey(); + final _server = TextEditingController(); + final _code = TextEditingController(); + final _name = TextEditingController(); + final _api = PairingApi(); + final _credentials = PairingCredentials(); + bool _busy = false; + bool _initializedName = false; + String? _error; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initializedName) { + _name.text = AppLocalizations.of(context)!.myDeviceName; + _initializedName = true; + } + } + + @override + void dispose() { + _server.dispose(); + _code.dispose(); + _name.dispose(); + _api.close(); + super.dispose(); + } + + /// Consumes a login code and saves only this device's independent credentials. + Future _signIn() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final result = await _api.redeemDeviceLoginCode( + _server.text, + _code.text, + _name.text, + ); + final saved = SavedPairingCredentials( + serverURL: _server.text.trim(), + accessToken: result.accessToken, + // A device login grants a session, never the member's recovery secret. + recoveryCode: '', + pairID: result.pairID, + role: result.role, + ); + var storageWarning = false; + try { + await _credentials.save( + serverURL: saved.serverURL, + accessToken: saved.accessToken, + recoveryCode: saved.recoveryCode, + pairID: saved.pairID, + role: saved.role, + ); + } on Object { + storageWarning = true; + } + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => PairedHomePage( + credentials: saved, + session: PairingSession( + role: saved.role, + status: 'paired', + pairID: saved.pairID, + ), + storageWarning: storageWarning, + ), + ), + (_) => false, + ); + } on PairingApiException catch (error) { + if (mounted) { + setState( + () => _error = switch (error.message) { + 'invalid_device_code' => AppLocalizations.of( + context, + )!.invalidDeviceCode, + 'https_required' => AppLocalizations.of( + context, + )!.invalidServerAddress, + _ => AppLocalizations.of(context)!.requestFailed, + }, + ); + } + } on Object { + if (mounted) { + setState(() => _error = AppLocalizations.of(context)!.couldNotSignIn); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => HanddrawnScaffold( + title: AppLocalizations.of(context)!.bringHome, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + HanddrawnCard( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center( + child: AccessDoodleIcon( + symbol: AccessDoodle.addDevice, + color: PawmateColors.lavender, + ), + ), + const SizedBox(height: PawmateSpace.large), + Text( + AppLocalizations.of(context)!.signInAnotherDevice, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Text( + AppLocalizations.of(context)!.deviceSignInHelp, + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), + ), + const SizedBox(height: 20), + HanddrawnTextField( + controller: _server, + labelText: AppLocalizations.of(context)!.serverUrl, + hintText: AppLocalizations.of(context)!.serverUrlHint, + validator: (value) => value == null || value.trim().isEmpty + ? AppLocalizations.of(context)!.enterServerAddress + : null, + ), + const SizedBox(height: 12), + HanddrawnTextField( + controller: _code, + labelText: AppLocalizations.of(context)!.deviceLoginCode, + keyboardType: TextInputType.text, + validator: (value) => value == null || value.trim().isEmpty + ? AppLocalizations.of(context)!.enterLoginCode + : null, + ), + const SizedBox(height: 12), + HanddrawnTextField( + controller: _name, + labelText: AppLocalizations.of(context)!.deviceName, + keyboardType: TextInputType.text, + validator: (value) => + value == null || + value.trim().isEmpty || + value.trim().runes.length > 80 + ? AppLocalizations.of(context)!.deviceNameLength + : null, + ), + const SizedBox(height: 20), + HanddrawnButton( + label: _busy + ? AppLocalizations.of(context)!.signingIn + : AppLocalizations.of(context)!.signIn, + icon: Icons.devices, + onPressed: _busy ? null : _signIn, + ), + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context)!.back), + ), + ], + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 16), + PairingErrorNote(message: _error!), + ], + ], + ), + ), + ); +} diff --git a/lib/features/pairing/invitation_link.dart b/lib/features/pairing/invitation_link.dart new file mode 100644 index 0000000..cac243f --- /dev/null +++ b/lib/features/pairing/invitation_link.dart @@ -0,0 +1,36 @@ +import 'pairing_api.dart'; + +/// Validates the same private invitation contract for pasted and external links. +abstract final class InvitationLink { + /// Parses a complete one-time link without contacting or logging its server. + static Uri parse(String value) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || + uri.scheme != 'pawmate' || + uri.host != 'pair' || + uri.userInfo.isNotEmpty || + uri.hasPort || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/')) { + throw const FormatException( + 'Paste the complete pawmate://pair invitation link.', + ); + } + final values = uri.queryParametersAll; + if (values['server']?.length != 1 || + values['code']?.length != 1 || + values['code']!.single.trim().isEmpty) { + throw const FormatException( + 'This invitation needs one server address and one invitation code.', + ); + } + try { + PairingApi.parseServerURL(values['server']!.single); + } on PairingApiException { + throw const FormatException( + 'The invitation contains an invalid server address.', + ); + } + return uri; + } +} diff --git a/lib/features/pairing/invite_link_entry_page.dart b/lib/features/pairing/invite_link_entry_page.dart new file mode 100644 index 0000000..b224966 --- /dev/null +++ b/lib/features/pairing/invite_link_entry_page.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../design/components/handdrawn_button.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/components/handdrawn_text_field.dart'; +import '../../design/icons/access/access_doodle_icon.dart'; +import '../../design/theme/spacing.dart'; +import 'invitation_link.dart'; +import 'invitee_accept_page.dart'; +import 'widgets/pairing_error_note.dart'; +import '../../l10n/generated/app_localizations.dart'; + +/// Receives an explicitly pasted link before showing the server/accept review. +class InviteLinkEntryPage extends StatefulWidget { + const InviteLinkEntryPage({super.key}); + + @override + State createState() => _InviteLinkEntryPageState(); +} + +class _InviteLinkEntryPageState extends State { + final _formKey = GlobalKey(); + final _link = TextEditingController(); + String? _clipboardError; + + @override + void dispose() { + _link.dispose(); + super.dispose(); + } + + /// Reads the clipboard only after a user gesture, never automatically at startup. + Future _paste() async { + final l10n = AppLocalizations.of(context)!; + try { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (!mounted) return; + setState(() { + if (data?.text?.trim().isNotEmpty == true) { + _link.text = data!.text!.trim(); + _clipboardError = null; + } else { + _clipboardError = l10n.pasteInvitationLinkFirst; + } + }); + } on Object { + if (mounted) { + setState(() => _clipboardError = l10n.clipboardUnavailable); + } + } + } + + /// Opens a review page; simply pasting a link never accepts the invitation. + void _review() { + if (!_formKey.currentState!.validate()) return; + final uri = InvitationLink.parse(_link.text); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => InviteeAcceptPage(inviteUri: uri)), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnScaffold( + title: l10n.acceptInvitation, + body: SafeArea( + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.all(PawmateSpace.page), + children: [ + HanddrawnCard( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center( + child: AccessDoodleIcon( + symbol: AccessDoodle.acceptInvitation, + ), + ), + const SizedBox(height: PawmateSpace.large), + Text( + l10n.anInvitationForTwo, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: PawmateSpace.medium), + Text( + l10n.invitationPasteHelp, + style: TextStyle(height: 1.4), + ), + const SizedBox(height: PawmateSpace.page), + HanddrawnTextField( + controller: _link, + labelText: l10n.invitationLink, + hintText: l10n.invitationLinkHint, + validator: (value) { + try { + InvitationLink.parse(value ?? ''); + return null; + } on FormatException { + return l10n.invalidInvitationLink; + } + }, + ), + const SizedBox(height: PawmateSpace.medium), + HanddrawnButton( + label: l10n.pasteLink, + onPressed: _paste, + icon: Icons.content_paste, + primary: false, + ), + const SizedBox(height: PawmateSpace.large), + HanddrawnButton( + label: l10n.reviewInvitation, + onPressed: _review, + ), + ], + ), + ), + ), + if (_clipboardError != null) ...[ + const SizedBox(height: PawmateSpace.large), + PairingErrorNote(message: _clipboardError!), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/pairing/invitee_accept_page.dart b/lib/features/pairing/invitee_accept_page.dart new file mode 100644 index 0000000..8fde532 --- /dev/null +++ b/lib/features/pairing/invitee_accept_page.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; + +import '../../design/components/handdrawn_button.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/theme/colors.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'paired_home_page.dart'; +import 'widgets/pairing_error_note.dart'; +import 'widgets/member_profile_editor.dart'; +import '../../l10n/generated/app_localizations.dart'; + +/// Lets the invitee review and accept a one-time link opened from another app. +class InviteeAcceptPage extends StatefulWidget { + const InviteeAcceptPage({required this.inviteUri, super.key}); + + final Uri inviteUri; + + @override + State createState() => _InviteeAcceptPageState(); +} + +class _InviteeAcceptPageState extends State { + final _formKey = GlobalKey(); + final _profileKey = GlobalKey(); + final _api = PairingApi(); + final _credentials = PairingCredentials(); + bool _isLoading = false; + String? _errorMessage; + + @override + void dispose() { + _api.close(); + super.dispose(); + } + + Future _acceptInvite() async { + if (!_formKey.currentState!.validate()) return; + final profile = _profileKey.currentState!.profile!; + final l10n = AppLocalizations.of(context)!; + final serverURL = widget.inviteUri.queryParameters['server']; + final code = widget.inviteUri.queryParameters['code']; + if (serverURL == null || code == null || code.isEmpty) { + setState( + () => + _errorMessage = AppLocalizations.of(context)!.incompleteInvitation, + ); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + try { + final result = await _api.redeemInvite(serverURL, code, profile: profile); + final token = result.inviteeToken; + final recoveryCode = result.recoveryCode; + final pairID = result.pairID; + if (token == null || recoveryCode == null || pairID == null) { + throw PairingApiException(l10n.incompleteCredentials); + } + var storageWarning = false; + try { + await _credentials.save( + serverURL: serverURL, + accessToken: token, + recoveryCode: recoveryCode, + pairID: pairID, + role: 'invitee', + ); + } on Object { + storageWarning = true; + } + if (!mounted) return; + final credentials = SavedPairingCredentials( + serverURL: serverURL, + accessToken: token, + recoveryCode: recoveryCode, + pairID: pairID, + role: 'invitee', + ); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => PairedHomePage( + credentials: credentials, + session: PairingSession( + pairID: pairID, + role: 'invitee', + status: 'paired', + profile: profile, + ), + recoveryCodeToSave: recoveryCode, + storageWarning: storageWarning, + ), + ), + (_) => false, + ); + } on PairingApiException catch (error) { + if (mounted) { + setState( + () => _errorMessage = switch (error.message) { + 'invalid_profile' => l10n.profileRejected, + 'https_required' => l10n.invalidServerAddress, + _ => l10n.requestFailed, + }, + ); + } + } on Object { + if (mounted) { + setState( + () => _errorMessage = AppLocalizations.of(context)!.couldNotAccept, + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final serverURL = widget.inviteUri.queryParameters['server'] ?? 'Unknown'; + return HanddrawnScaffold( + title: AppLocalizations.of(context)!.joinHome, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + HanddrawnCard( + color: const Color(0xFFF4F0FF), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + MemberProfileEditor(key: _profileKey, enabled: !_isLoading), + const SizedBox(height: 12), + Text( + AppLocalizations.of(context)!.invited, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.acceptOneTimeHelp, + textAlign: TextAlign.center, + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.privateServer, + style: TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + SelectableText( + serverURL, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 20), + HanddrawnButton( + onPressed: _isLoading ? null : _acceptInvite, + icon: Icons.favorite_border, + label: _isLoading + ? AppLocalizations.of(context)!.joining + : AppLocalizations.of(context)!.acceptInvitation, + ), + ], + ), + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 18), + PairingErrorNote(message: _errorMessage!), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart new file mode 100644 index 0000000..0120ab1 --- /dev/null +++ b/lib/features/pairing/inviter_setup_page.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../design/layouts/handdrawn_scaffold.dart'; +import 'widgets/invite_result_card.dart'; +import 'widgets/pairing_error_note.dart'; +import 'widgets/server_setup_card.dart'; +import 'widgets/member_profile_editor.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'paired_home_page.dart'; +import '../../l10n/generated/app_localizations.dart'; + +class InviterSetupPage extends StatefulWidget { + const InviterSetupPage({super.key}); + + @override + State createState() => _InviterSetupPageState(); +} + +class _InviterSetupPageState extends State { + final _formKey = GlobalKey(); + final _profileKey = GlobalKey(); + final _serverURLController = TextEditingController(); + final _api = PairingApi(); + final _credentials = PairingCredentials(); + PairingInvite? _invite; + PairingStatus? _status; + String? _errorMessage; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _restoreSavedInvite(); + } + + Future _restoreSavedInvite() async { + try { + final credentials = await _credentials.read(); + if (!mounted || credentials == null || credentials.role != 'inviter') { + return; + } + _serverURLController.text = credentials.serverURL; + final inviteURL = credentials.inviteURL; + final expiresAt = credentials.expiresAt; + if (inviteURL != null && expiresAt != null) { + setState(() { + _invite = PairingInvite( + inviteURL: inviteURL, + inviterToken: credentials.accessToken, + recoveryCode: credentials.recoveryCode, + expiresAt: expiresAt, + ); + }); + await _refreshStatus(); + } + } on Object { + // A damaged local credential record must not block server setup. + } + } + + /// Releases controllers and the HTTP client owned by this page. + @override + void dispose() { + _serverURLController.dispose(); + _api.close(); + super.dispose(); + } + + /// Sends the configured server URL and displays the returned invitation. + Future _createInvite() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _errorMessage = null; + _isLoading = true; + _invite = null; + _status = null; + }); + try { + final invite = await _api.createInvite( + _serverURLController.text, + profile: _profileKey.currentState!.profile!, + ); + if (!mounted) return; + setState(() => _invite = invite); + try { + await _credentials.save( + serverURL: _serverURLController.text.trim(), + accessToken: invite.inviterToken, + recoveryCode: invite.recoveryCode, + pairID: '', + role: 'inviter', + inviteURL: invite.inviteURL, + expiresAt: invite.expiresAt, + ); + } on Object { + if (mounted) { + setState(() { + _errorMessage = AppLocalizations.of( + context, + )!.invitationCreatedStorageWarning; + }); + } + } + } on PairingApiException catch (error) { + if (mounted) { + final l10n = AppLocalizations.of(context)!; + setState( + () => _errorMessage = + error.message == 'https_required' || + error.message.startsWith('Use an HTTP') + ? l10n.invalidServerAddress + : error.message == 'invalid_profile' + ? l10n.profileRejected + : error.statusCode == null + ? l10n.serverUnreachable + : l10n.requestFailed, + ); + } + } on Object { + if (mounted) { + setState( + () => _errorMessage = AppLocalizations.of(context)!.serverUnreachable, + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + /// Refreshes the invitation state shown to the inviter. + Future _refreshStatus() async { + final invite = _invite; + if (invite == null) return; + setState(() => _isLoading = true); + try { + final status = await _api.getStatus( + _serverURLController.text, + invite.inviterToken, + ); + if (status.status == 'paired' && status.pairID != null) { + var storageWarning = false; + try { + await _credentials.save( + serverURL: _serverURLController.text.trim(), + accessToken: invite.inviterToken, + recoveryCode: invite.recoveryCode, + pairID: status.pairID!, + role: 'inviter', + inviteURL: invite.inviteURL, + expiresAt: invite.expiresAt, + ); + } on Object { + storageWarning = true; + } + if (!mounted) return; + final credentials = SavedPairingCredentials( + serverURL: _serverURLController.text.trim(), + accessToken: invite.inviterToken, + recoveryCode: invite.recoveryCode, + pairID: status.pairID!, + role: 'inviter', + inviteURL: invite.inviteURL, + expiresAt: invite.expiresAt, + ); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => PairedHomePage( + credentials: credentials, + session: PairingSession( + pairID: status.pairID, + role: 'inviter', + status: 'paired', + ), + recoveryCodeToSave: storageWarning ? invite.recoveryCode : null, + storageWarning: storageWarning, + ), + ), + (_) => false, + ); + return; + } + if (mounted) setState(() => _status = status); + } on PairingApiException { + if (mounted) { + setState( + () => _errorMessage = AppLocalizations.of(context)!.requestFailed, + ); + } + } on Object { + if (mounted) { + setState( + () => _errorMessage = AppLocalizations.of( + context, + )!.serverUnreachableShort, + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + /// Copies the bearer invitation link to the platform clipboard. + Future _copyInvite() async { + final invite = _invite; + if (invite == null) return; + await Clipboard.setData(ClipboardData(text: invite.inviteURL)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.invitationCopied)), + ); + } + } + + /// Builds the inviter's server configuration and invitation screen. + @override + Widget build(BuildContext context) { + final invite = _invite; + final status = _status; + return HanddrawnScaffold( + title: AppLocalizations.of(context)!.invitePartner, + body: SafeArea( + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + children: [ + const SizedBox(height: 6), + if (invite == null || !invite.expiresAt.isAfter(DateTime.now())) + ServerSetupCard( + formKey: _formKey, + controller: _serverURLController, + isLoading: _isLoading, + onCreateInvite: () => _createInvite(), + profileEditor: MemberProfileEditor( + key: _profileKey, + enabled: !_isLoading, + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 18), + PairingErrorNote(message: _errorMessage!), + ], + if (invite != null) ...[ + const SizedBox(height: 22), + InviteResultCard( + invite: invite, + recoveryCode: invite.recoveryCode, + status: status, + isLoading: _isLoading, + onCopy: () => _copyInvite(), + onRefresh: () => _refreshStatus(), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/pairing/login_methods_page.dart b/lib/features/pairing/login_methods_page.dart new file mode 100644 index 0000000..cb04d04 --- /dev/null +++ b/lib/features/pairing/login_methods_page.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +import '../../design/components/handdrawn_action_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/icons/access/access_doodle_icon.dart'; +import '../../design/theme/colors.dart'; +import '../../design/theme/spacing.dart'; +import '../../l10n/generated/app_localizations.dart'; +import '../../l10n/locale_controller.dart'; +import 'device_login_page.dart'; +import 'invite_link_entry_page.dart'; +import 'inviter_setup_page.dart'; +import 'pairing_recovery_page.dart'; + +/// Offers four distinct access paths before asking for sensitive form values. +class LoginMethodsPage extends StatelessWidget { + const LoginMethodsPage({super.key}); + + /// Opens one focused flow; returning restores the same two-by-two landing page. + void _open(BuildContext context, Widget page) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + } + + Future _chooseLanguage(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + final selected = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text(l10n.chooseLanguage), + children: [ + SimpleDialogOption( + onPressed: () => Navigator.pop(context, 'en'), + child: Text(l10n.english), + ), + SimpleDialogOption( + onPressed: () => Navigator.pop(context, 'zh'), + child: Text(l10n.chinese), + ), + ], + ), + ); + if (selected != null && context.mounted) { + await LocaleControllerScope.of(context).setLocale(Locale(selected)); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnScaffold( + title: null, + actions: [ + IconButton( + tooltip: l10n.changeLanguage, + onPressed: () => _chooseLanguage(context), + icon: const Icon(Icons.language), + ), + ], + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(PawmateSpace.page), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: PawmateSpace.contentWidth, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.homeHeadline, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: PawmateSpace.medium), + Text( + l10n.homeIntro, + style: TextStyle(color: PawmateColors.ink, height: 1.4), + ), + const SizedBox(height: PawmateSpace.page), + // Content-driven rows keep two columns even with large text. + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: HanddrawnActionCard( + label: l10n.createInvitation, + description: l10n.createInvitationDescription, + illustration: const AccessDoodleIcon( + symbol: AccessDoodle.createInvitation, + ), + color: PawmateColors.rosePaper, + onPressed: () => + _open(context, const InviterSetupPage()), + ), + ), + const SizedBox(width: PawmateSpace.large), + Expanded( + child: HanddrawnActionCard( + label: l10n.acceptInvitation, + description: l10n.acceptInvitationDescription, + illustration: const AccessDoodleIcon( + symbol: AccessDoodle.acceptInvitation, + ), + color: PawmateColors.rosePaper, + onPressed: () => + _open(context, const InviteLinkEntryPage()), + ), + ), + ], + ), + ), + const SizedBox(height: PawmateSpace.large), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: HanddrawnActionCard( + label: l10n.restoreData, + description: l10n.restoreDataDescription, + illustration: const AccessDoodleIcon( + symbol: AccessDoodle.restoreHome, + color: PawmateColors.lavender, + ), + color: PawmateColors.lavenderPaper, + onPressed: () => + _open(context, const PairingRecoveryPage()), + ), + ), + const SizedBox(width: PawmateSpace.large), + Expanded( + child: HanddrawnActionCard( + label: l10n.addDevice, + description: l10n.addDeviceDescription, + illustration: const AccessDoodleIcon( + symbol: AccessDoodle.addDevice, + color: PawmateColors.lavender, + ), + color: PawmateColors.lavenderPaper, + onPressed: () => + _open(context, const DeviceLoginPage()), + ), + ), + ], + ), + ), + const SizedBox(height: PawmateSpace.page), + Text( + l10n.alreadyPaired, + style: TextStyle(color: PawmateColors.ink, height: 1.5), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/pairing/member_profile.dart b/lib/features/pairing/member_profile.dart new file mode 100644 index 0000000..6137561 --- /dev/null +++ b/lib/features/pairing/member_profile.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// A server-owned member identity that is shared by all of their devices. +class MemberProfile { + const MemberProfile({required this.nickname, required this.avatar}); + + final String nickname; + final Uint8List avatar; + + /// Decodes a bounded portrait returned by the authenticated instance. + factory MemberProfile.fromJson(Map json) => MemberProfile( + nickname: json['nickname'] as String, + avatar: base64Decode(json['avatar_base64'] as String), + ); + + /// Encodes identity alongside the invitation request, never in the link URL. + Map toJson() => { + 'nickname': nickname, + 'avatar_base64': base64Encode(avatar), + }; +} diff --git a/lib/features/pairing/paired_home_page.dart b/lib/features/pairing/paired_home_page.dart new file mode 100644 index 0000000..52f7cf3 --- /dev/null +++ b/lib/features/pairing/paired_home_page.dart @@ -0,0 +1,335 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../design/components/handdrawn_card.dart'; +import '../../design/components/couple_avatar.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/theme/colors.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'widgets/pairing_error_note.dart'; +import 'widgets/recovery_code_note.dart'; +import 'widgets/devices_card.dart'; +import '../chat/chat_api.dart'; +import '../chat/chat_controller.dart'; +import '../chat/chat_page.dart'; +import 'pairing_session_gate.dart'; +import '../../l10n/generated/app_localizations.dart'; + +/// Opens the couple space on chat, retaining drafts and scroll state between tabs. +class PairedHomePage extends StatefulWidget { + const PairedHomePage({ + required this.credentials, + required this.session, + super.key, + this.recoveryCodeToSave, + this.storageWarning = false, + this.chatApi, + }); + + final SavedPairingCredentials credentials; + final PairingSession session; + final String? recoveryCodeToSave; + final bool storageWarning; + final ChatApi? chatApi; + + @override + State createState() => _PairedHomePageState(); +} + +class _PairedHomePageState extends State + with WidgetsBindingObserver { + late final ChatController _chat; + final _profileApi = PairingApi(); + late PairingSession _profileSession; + int _tab = 0; + int _lastUnread = 0; + bool _loaded = false; + bool _redirecting = false; + bool _homeNotice = true; + + @override + void initState() { + super.initState(); + _profileSession = widget.session; + if (widget.session.profile == null || widget.session.partner == null) { + unawaited(_loadProfiles()); + } + WidgetsBinding.instance.addObserver(this); + _chat = ChatController(widget.chatApi ?? ChatApi(widget.credentials)) + ..addListener(_changed); + _chat.setForeground( + WidgetsBinding.instance.lifecycleState == null || + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, + ); + _chat.start(); + } + + /// Reads server-owned identities after invitation, recovery or device login. + Future _loadProfiles() async { + try { + final session = await _profileApi.validateSession( + widget.credentials.serverURL, + widget.credentials.accessToken, + ); + if (mounted) setState(() => _profileSession = session); + } on Object { + // A profile lookup must not interrupt an otherwise usable chat session. + } + } + + /// Pauses polling and read receipts when the application leaves the foreground. + @override + void didChangeAppLifecycleState(AppLifecycleState state) => + _chat.setForeground(state == AppLifecycleState.resumed); + + /// Shows content-free unread reminders and redirects revoked device sessions. + void _changed() { + if (!mounted) return; + if (_chat.unauthorized && !_redirecting) { + _redirecting = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const PairingSessionGate()), + (_) => false, + ); + }); + } + if (_loaded && + _chat.foreground && + _tab != 0 && + _chat.unreadCount > _lastUnread) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.unreadFromPartner(_chat.unreadCount), + ), + action: SnackBarAction( + label: AppLocalizations.of(context)!.openChat, + onPressed: () => _selectTab(0), + ), + ), + ); + } + _lastUnread = _chat.unreadCount; + _loaded = _chat.loaded; + setState(() {}); + } + + /// Changes the active tab without acknowledging unseen messages. + void _selectTab(int index) { + _chat.setChatVisible(index == 0); + setState(() { + _tab = index; + if (index == 2) _homeNotice = false; + }); + } + + @override + void dispose() { + _profileApi.close(); + WidgetsBinding.instance.removeObserver(this); + _chat.removeListener(_changed); + _chat.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnScaffold( + title: [l10n.ourConversation, l10n.playCorner, l10n.ourLittleHome][_tab], + bottomNavigationBar: NavigationBar( + selectedIndex: _tab, + onDestinationSelected: _selectTab, + destinations: [ + NavigationDestination( + icon: Badge( + isLabelVisible: _chat.unreadCount > 0, + label: Text( + _chat.unreadCount > 99 ? '99+' : '${_chat.unreadCount}', + ), + child: const Icon(Icons.chat_bubble_outline), + ), + label: l10n.chat, + tooltip: l10n.chatUnread(_chat.unreadCount), + ), + NavigationDestination( + icon: Icon(Icons.sports_esports_outlined), + label: l10n.play, + ), + NavigationDestination( + icon: Icon(Icons.home_outlined), + label: l10n.home, + ), + ], + ), + body: Column( + children: [ + if (_homeNotice && + (widget.recoveryCodeToSave != null || widget.storageWarning)) + MaterialBanner( + content: Text( + widget.storageWarning + ? l10n.loginNotSaved + : l10n.saveRecoveryCodeHome, + ), + actions: [ + TextButton( + onPressed: () => _selectTab(2), + child: Text(l10n.viewHome), + ), + ], + ), + Expanded( + child: IndexedStack( + index: _tab, + children: [ + ChatPage(controller: _chat, active: _tab == 0), + Center( + child: Padding( + padding: EdgeInsets.all(24), + child: HanddrawnCard(child: Text(l10n.gamesComing)), + ), + ), + _HomeDetails( + credentials: widget.credentials, + session: _profileSession, + recoveryCodeToSave: widget.recoveryCodeToSave, + storageWarning: widget.storageWarning, + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Keeps pairing details, device management and recovery notes in the Home tab. +class _HomeDetails extends StatelessWidget { + const _HomeDetails({ + required this.credentials, + required this.session, + this.recoveryCodeToSave, + this.storageWarning = false, + }); + + final SavedPairingCredentials credentials; + final PairingSession session; + final String? recoveryCodeToSave; + final bool storageWarning; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + HanddrawnCard( + color: const Color(0xFFF4F0FF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (session.profile != null || session.partner != null) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (session.profile case final profile?) + Expanded( + child: CoupleAvatar( + avatar: profile.avatar, + nickname: profile.nickname, + label: l10n.yourProfile, + ), + ), + const SizedBox(width: 16), + if (session.partner case final partner?) + Expanded( + child: CoupleAvatar( + avatar: partner.avatar, + nickname: partner.nickname, + label: l10n.partnerProfile, + ), + ), + ], + ), + const SizedBox(height: 16), + ], + const Icon( + Icons.home_rounded, + size: 36, + color: PawmateColors.rose, + ), + const SizedBox(height: 12), + Text( + l10n.youAreHome, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + l10n.connectedAsRole(session.role), + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), + ), + const SizedBox(height: 16), + Text( + l10n.privateServer, + style: TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + SelectableText( + credentials.serverURL, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + ), + ), + if (session.pairID != null) ...[ + const SizedBox(height: 12), + Text( + l10n.homeId(session.pairID!), + style: const TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, + ), + ), + ], + ], + ), + ), + if (storageWarning) ...[ + const SizedBox(height: 18), + PairingErrorNote( + message: recoveryCodeToSave != null + ? l10n.storageWarningRecovery + : l10n.storageWarningDevice, + ), + ], + if (recoveryCodeToSave != null) ...[ + const SizedBox(height: 18), + RecoveryCodeNote(recoveryCode: recoveryCodeToSave!), + ], + const SizedBox(height: 18), + DevicesCard(credentials: credentials), + ], + ), + ); + } +} diff --git a/lib/features/pairing/pairing_api.dart b/lib/features/pairing/pairing_api.dart new file mode 100644 index 0000000..eda925c --- /dev/null +++ b/lib/features/pairing/pairing_api.dart @@ -0,0 +1,456 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'member_profile.dart'; + +/// A one-time invitation returned by the Pawmate instance. +class PairingInvite { + const PairingInvite({ + required this.expiresAt, + required this.inviteURL, + required this.inviterToken, + required this.recoveryCode, + }); + + /// Decodes the server's invitation response. + factory PairingInvite.fromJson(Map json) { + return PairingInvite( + expiresAt: DateTime.parse(json['expires_at'] as String), + inviteURL: json['invite_url'] as String, + inviterToken: json['inviter_token'] as String, + recoveryCode: json['recovery_code'] as String, + ); + } + + final DateTime expiresAt; + final String inviteURL; + final String inviterToken; + final String recoveryCode; +} + +/// Describes the pairing state observed by the inviting device. +class PairingStatus { + const PairingStatus({ + required this.status, + this.pairID, + this.inviteeToken, + this.recoveryCode, + }); + + /// Decodes a pairing status response. + factory PairingStatus.fromJson(Map json) { + return PairingStatus( + pairID: json['pair_id'] as String?, + status: json['status'] as String, + inviteeToken: json['invitee_token'] as String?, + recoveryCode: json['recovery_code'] as String?, + ); + } + + final String? pairID; + final String status; + final String? inviteeToken; + final String? recoveryCode; +} + +/// Credentials issued when a member restores access with a recovery code. +class RecoveredCredentials { + const RecoveredCredentials({ + required this.accessToken, + required this.pairID, + required this.recoveryCode, + required this.role, + }); + + factory RecoveredCredentials.fromJson(Map json) { + return RecoveredCredentials( + accessToken: json['access_token'] as String, + pairID: json['pair_id'] as String, + recoveryCode: json['recovery_code'] as String, + role: json['role'] as String, + ); + } + + final String accessToken; + final String pairID; + final String recoveryCode; + final String role; +} + +/// Represents the authenticated member's server-side pairing session. +class PairingSession { + const PairingSession({ + required this.role, + required this.status, + this.pairID, + this.profile, + this.partner, + }); + + factory PairingSession.fromJson(Map json) { + return PairingSession( + pairID: json['pair_id'] as String?, + role: json['role'] as String, + status: json['status'] as String, + profile: json['profile'] == null + ? null + : MemberProfile.fromJson(json['profile'] as Map), + partner: json['partner'] == null + ? null + : MemberProfile.fromJson(json['partner'] as Map), + ); + } + + final String? pairID; + final String role; + final String status; + final MemberProfile? profile; + final MemberProfile? partner; +} + +/// A short-lived code created by an already authenticated device. +class DeviceLoginCode { + const DeviceLoginCode({required this.code, required this.expiresAt}); + + final String code; + final DateTime expiresAt; +} + +/// Credentials for an additional device; recovery secrets are not transferred. +class DeviceLoginCredentials { + const DeviceLoginCredentials({ + required this.accessToken, + required this.pairID, + required this.role, + }); + + final String accessToken; + final String pairID; + final String role; +} + +/// Public metadata for one of the authenticated member's devices. +class PairingDevice { + const PairingDevice({ + required this.id, + required this.name, + required this.createdAt, + required this.current, + }); + + /// Decodes device metadata without access tokens or recovery codes. + factory PairingDevice.fromJson(Map json) => PairingDevice( + id: json['id'] as String, + name: json['name'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + current: json['current'] as bool, + ); + + final String id; + final String name; + final DateTime createdAt; + final bool current; +} + +/// Represents a user-facing error returned while calling the pairing API. +class PairingApiException implements Exception { + const PairingApiException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + @override + String toString() => message; +} + +/// Calls the instance pairing endpoints for the inviting device. +class PairingApi { + PairingApi([http.Client? client]) : _client = client ?? http.Client(); + + final http.Client _client; + + /// Provides an editable display label without collecting a device identifier. + static String get defaultDeviceName => kIsWeb + ? 'Web browser' + : switch (defaultTargetPlatform) { + TargetPlatform.android => 'Android device', + TargetPlatform.iOS => 'iOS device', + TargetPlatform.macOS => 'Mac', + TargetPlatform.windows => 'Windows computer', + TargetPlatform.linux => 'Linux computer', + TargetPlatform.fuchsia => 'Fuchsia device', + }; + + /// Releases the underlying HTTP client. + void close() => _client.close(); + + /// Creates an invitation on the configured server instance. + Future createInvite( + String rawServerURL, { + required MemberProfile profile, + }) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .post( + resolveEndpoint(serverURL, '/api/v1/pairing/invites'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({ + 'server_url': serverURL.toString(), + 'profile': profile.toJson(), + 'device_name': defaultDeviceName, + }), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 201) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + return PairingInvite.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Fetches the invitation state using the inviter's bearer token. + Future getStatus( + String rawServerURL, + String inviterToken, + ) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .get( + resolveEndpoint(serverURL, '/api/v1/pairing/invites/status'), + headers: {'authorization': 'Bearer $inviterToken'}, + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + return PairingStatus.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Validates an access token and returns the member's current pairing state. + Future validateSession( + String rawServerURL, + String accessToken, + ) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .get( + resolveEndpoint(serverURL, '/api/v1/pairing/session'), + headers: {'authorization': 'Bearer $accessToken'}, + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + return PairingSession.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Redeems a one-time invite code on the server encoded in the invite link. + Future redeemInvite( + String rawServerURL, + String code, { + required MemberProfile profile, + }) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .post( + resolveEndpoint(serverURL, '/api/v1/pairing/invites/redeem'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({ + 'code': code, + 'device_name': defaultDeviceName, + 'profile': profile.toJson(), + }), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + return PairingStatus.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Rotates credentials using a member's single-use recovery code. + Future recover( + String rawServerURL, + String recoveryCode, + ) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .post( + resolveEndpoint(serverURL, '/api/v1/pairing/recover'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({ + 'recovery_code': recoveryCode.trim(), + 'device_name': defaultDeviceName, + }), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + return RecoveredCredentials.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Lists the member's devices, identifying the token used for this request. + Future> getDevices(String server, String token) async { + final response = await _client + .get( + resolveEndpoint(_parseServerURL(server), '/api/v1/pairing/devices'), + headers: {'authorization': 'Bearer $token'}, + ) + .timeout(const Duration(seconds: 10)); + final body = _deviceResponse(response, 200); + return (body['devices'] as List) + .map((item) => PairingDevice.fromJson(item as Map)) + .toList(); + } + + /// Issues a one-time login code while keeping all device sessions active. + Future createDeviceLoginCode( + String server, + String token, + ) async { + final response = await _client + .post( + resolveEndpoint( + _parseServerURL(server), + '/api/v1/pairing/devices/login-codes', + ), + headers: {'authorization': 'Bearer $token'}, + ) + .timeout(const Duration(seconds: 10)); + final body = _deviceResponse(response, 201); + return DeviceLoginCode( + code: body['code'] as String, + expiresAt: DateTime.parse(body['expires_at'] as String), + ); + } + + /// Exchanges a code for this installation's independent access token. + Future redeemDeviceLoginCode( + String server, + String code, + String deviceName, + ) async { + final response = await _client + .post( + resolveEndpoint( + _parseServerURL(server), + '/api/v1/pairing/devices/login-codes/redeem', + ), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({ + 'code': code.trim(), + 'device_name': deviceName.trim(), + }), + ) + .timeout(const Duration(seconds: 10)); + final body = _deviceResponse(response, 200); + return DeviceLoginCredentials( + accessToken: body['access_token'] as String, + pairID: body['pair_id'] as String, + role: body['role'] as String, + ); + } + + /// Removes one device owned by this member, leaving other tokens active. + Future revokeDevice(String server, String token, String id) async { + final response = await _client + .delete( + resolveEndpoint( + _parseServerURL(server), + '/api/v1/pairing/devices/${Uri.encodeComponent(id)}', + ), + headers: {'authorization': 'Bearer $token'}, + ) + .timeout(const Duration(seconds: 10)); + if (response.statusCode != 204) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + } + + /// Checks a device endpoint's status before decoding its successful response. + Map _deviceResponse(http.Response response, int status) { + if (response.statusCode != status) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + return jsonDecode(response.body) as Map; + } + + /// Validates URLs, requiring HTTPS in release builds. + static Uri _parseServerURL(String rawServerURL) { + final uri = Uri.tryParse(rawServerURL.trim()); + if (kReleaseMode && uri?.scheme == 'http') { + throw const PairingApiException('https_required'); + } + if (uri == null || + uri.host.isEmpty || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment) { + throw const PairingApiException( + 'Use an HTTP or HTTPS server URL without credentials, a query, or a fragment.', + ); + } + return uri.replace(path: uri.path.replaceFirst(RegExp(r'/+$'), '')); + } + + /// Shares the instance URL policy with other authenticated feature clients. + static Uri parseServerURL(String rawServerURL) => + _parseServerURL(rawServerURL); + + /// Resolves an API path under the configured instance prefix. + static Uri resolveEndpoint(Uri serverURL, String endpointPath) { + final basePath = serverURL.path.endsWith('/') + ? serverURL.path + : '${serverURL.path}/'; + final relativePath = endpointPath.replaceFirst(RegExp(r'^/+'), ''); + return serverURL.replace(path: basePath).resolve(relativePath); + } + + /// Extracts a safe error message from a JSON API response. + String _messageFor(http.Response response) { + try { + final body = jsonDecode(response.body) as Map; + return body['error'] as String? ?? 'The server rejected the request.'; + } on Object { + return 'The server rejected the request (${response.statusCode}).'; + } + } +} diff --git a/lib/features/pairing/pairing_credentials.dart b/lib/features/pairing/pairing_credentials.dart new file mode 100644 index 0000000..e8aaea1 --- /dev/null +++ b/lib/features/pairing/pairing_credentials.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Stores the current member credentials in the platform secure storage. +class PairingCredentials { + PairingCredentials({FlutterSecureStorage? storage}) + : _storage = + storage ?? + const FlutterSecureStorage( + // This private client stores local Keychain items without cross-app sharing. + // macOS therefore needs no Keychain Sharing provisioning profile. + mOptions: MacOsOptions(usesDataProtectionKeychain: false), + ); + + static const _storageKey = 'pawmate.pairing.credentials.v1'; + + final FlutterSecureStorage _storage; + + /// Reads credentials saved by this installation, if any. + Future read() async { + final value = await _storage.read(key: _storageKey); + if (value == null) return null; + return SavedPairingCredentials.fromJson( + jsonDecode(value) as Map, + ); + } + + /// Saves credentials issued for one member of the couple. + Future save({ + required String serverURL, + required String accessToken, + required String recoveryCode, + required String pairID, + required String role, + String? inviteURL, + DateTime? expiresAt, + }) async { + await _storage.write( + key: _storageKey, + value: jsonEncode({ + 'server_url': serverURL, + 'access_token': accessToken, + 'recovery_code': recoveryCode, + 'pair_id': pairID, + 'role': role, + 'invite_url': ?inviteURL, + 'expires_at': ?expiresAt?.toIso8601String(), + }), + ); + } +} + +/// Credentials retained locally for this app installation. +class SavedPairingCredentials { + const SavedPairingCredentials({ + required this.serverURL, + required this.accessToken, + required this.recoveryCode, + required this.pairID, + required this.role, + this.inviteURL, + this.expiresAt, + }); + + factory SavedPairingCredentials.fromJson(Map json) { + return SavedPairingCredentials( + serverURL: json['server_url'] as String, + accessToken: json['access_token'] as String, + recoveryCode: json['recovery_code'] as String, + pairID: json['pair_id'] as String, + role: json['role'] as String, + inviteURL: json['invite_url'] as String?, + expiresAt: json['expires_at'] == null + ? null + : DateTime.parse(json['expires_at'] as String), + ); + } + + final String serverURL; + final String accessToken; + + /// Empty on additional devices: device sign-in never transfers this secret. + final String recoveryCode; + final String pairID; + final String role; + final String? inviteURL; + final DateTime? expiresAt; +} diff --git a/lib/features/pairing/pairing_recovery_page.dart b/lib/features/pairing/pairing_recovery_page.dart new file mode 100644 index 0000000..667ffac --- /dev/null +++ b/lib/features/pairing/pairing_recovery_page.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; + +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/theme/colors.dart'; +import '../../design/icons/access/access_doodle_icon.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'paired_home_page.dart'; +import 'widgets/pairing_error_note.dart'; +import 'widgets/pairing_recovery_card.dart'; +import 'device_login_page.dart'; +import '../../l10n/generated/app_localizations.dart'; + +/// Restores an invalid or reinstalled member session with its recovery code. +class PairingRecoveryPage extends StatefulWidget { + const PairingRecoveryPage({super.key, this.initialServerURL = ''}); + + final String initialServerURL; + + @override + State createState() => _PairingRecoveryPageState(); +} + +class _PairingRecoveryPageState extends State { + late final TextEditingController _serverController; + final _codeController = TextEditingController(); + final _api = PairingApi(); + final _credentials = PairingCredentials(); + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _serverController = TextEditingController(text: widget.initialServerURL); + } + + @override + void dispose() { + _serverController.dispose(); + _codeController.dispose(); + _api.close(); + super.dispose(); + } + + Future _recover() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + try { + final result = await _api.recover( + _serverController.text, + _codeController.text, + ); + final saved = SavedPairingCredentials( + serverURL: _serverController.text.trim(), + accessToken: result.accessToken, + recoveryCode: result.recoveryCode, + pairID: result.pairID, + role: result.role, + ); + var storageWarning = false; + try { + await _credentials.save( + serverURL: saved.serverURL, + accessToken: saved.accessToken, + recoveryCode: saved.recoveryCode, + pairID: saved.pairID, + role: saved.role, + ); + } on Object { + storageWarning = true; + } + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => PairedHomePage( + credentials: saved, + session: PairingSession( + pairID: result.pairID, + role: result.role, + status: 'paired', + ), + recoveryCodeToSave: result.recoveryCode, + storageWarning: storageWarning, + ), + ), + (_) => false, + ); + } on PairingApiException catch (error) { + if (mounted) { + setState(() => _errorMessage = _userMessage(context, error)); + } + } on Object { + if (mounted) { + setState( + () => _errorMessage = AppLocalizations.of(context)!.couldNotRestore, + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + String _userMessage(BuildContext context, PairingApiException error) { + final l10n = AppLocalizations.of(context)!; + return switch (error.message) { + 'invalid_recovery_code' => l10n.invalidRecoveryCode, + 'not_paired' => l10n.notPaired, + 'https_required' => l10n.invalidServerAddress, + _ => l10n.requestFailed, + }; + } + + @override + Widget build(BuildContext context) { + return HanddrawnScaffold( + title: AppLocalizations.of(context)!.restoreHome, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + HanddrawnCard( + color: const Color(0xFFFFF3CF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center( + child: AccessDoodleIcon( + symbol: AccessDoodle.restoreHome, + color: PawmateColors.lavender, + ), + ), + const SizedBox(height: 10), + Text( + AppLocalizations.of(context)!.reconnectTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + AppLocalizations.of(context)!.reconnectHelp, + textAlign: TextAlign.start, + style: TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 18), + TextButton.icon( + onPressed: _isLoading + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DeviceLoginPage(), + ), + ), + icon: const Icon(Icons.devices), + label: Text(AppLocalizations.of(context)!.deviceCodeSignIn), + ), + const SizedBox(height: 18), + PairingRecoveryCard( + serverController: _serverController, + codeController: _codeController, + isLoading: _isLoading, + onRecover: _recover, + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 18), + PairingErrorNote(message: _errorMessage!), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/pairing/pairing_session_gate.dart b/lib/features/pairing/pairing_session_gate.dart new file mode 100644 index 0000000..c0f6848 --- /dev/null +++ b/lib/features/pairing/pairing_session_gate.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; + +import '../../design/components/handdrawn_button.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/theme/colors.dart'; +import 'inviter_setup_page.dart'; +import 'login_methods_page.dart'; +import 'paired_home_page.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'pairing_recovery_page.dart'; +import '../../l10n/generated/app_localizations.dart'; + +/// Loads local credentials and routes to onboarding, recovery, or the home. +class PairingSessionGate extends StatefulWidget { + const PairingSessionGate({super.key}); + + @override + State createState() => _PairingSessionGateState(); +} + +enum _GateView { loading, setup, invitation, recovery, home, error } + +class _PairingSessionGateState extends State { + final _api = PairingApi(); + final _credentials = PairingCredentials(); + _GateView _view = _GateView.loading; + SavedPairingCredentials? _saved; + PairingSession? _session; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _restoreSession(); + } + + @override + void dispose() { + _api.close(); + super.dispose(); + } + + Future _restoreSession() async { + setState(() { + _view = _GateView.loading; + _errorMessage = null; + }); + try { + final credentials = await _credentials.read(); + if (!mounted) return; + if (credentials == null) { + setState(() => _view = _GateView.setup); + return; + } + _saved = credentials; + + final session = await _api.validateSession( + credentials.serverURL, + credentials.accessToken, + ); + if (!mounted) return; + if (session.status == 'paired' && session.pairID != null) { + final saved = SavedPairingCredentials( + serverURL: credentials.serverURL, + accessToken: credentials.accessToken, + recoveryCode: credentials.recoveryCode, + pairID: session.pairID!, + role: session.role, + inviteURL: credentials.inviteURL, + expiresAt: credentials.expiresAt, + ); + _saved = saved; + _session = session; + try { + await _credentials.save( + serverURL: saved.serverURL, + accessToken: saved.accessToken, + recoveryCode: saved.recoveryCode, + pairID: saved.pairID, + role: saved.role, + inviteURL: saved.inviteURL, + expiresAt: saved.expiresAt, + ); + } on Object { + // The authenticated in-memory session can still open the home. + } + if (mounted) setState(() => _view = _GateView.home); + } else if (session.status == 'pending' && session.role == 'inviter') { + setState(() => _view = _GateView.invitation); + } else { + setState(() => _view = _GateView.recovery); + } + } on PairingApiException catch (error) { + if (!mounted) return; + if (error.statusCode == 401) { + setState(() => _view = _GateView.recovery); + } else { + setState(() { + _errorMessage = _messageFor(error); + _view = _GateView.error; + }); + } + } on Object { + if (mounted) { + setState(() { + _errorMessage = AppLocalizations.of(context)!.savedAccessError; + _view = _GateView.error; + }); + } + } + } + + String _messageFor(PairingApiException error) { + if (error.message == 'https_required') { + return AppLocalizations.of(context)!.invalidServerAddress; + } + if (error.statusCode == null) { + return AppLocalizations.of(context)!.serverAccessError; + } + return AppLocalizations.of(context)!.serverAccessError; + } + + @override + Widget build(BuildContext context) { + return switch (_view) { + _GateView.loading => const _SessionLoadingPage(), + _GateView.setup => const LoginMethodsPage(), + _GateView.invitation => const InviterSetupPage(), + _GateView.recovery => PairingRecoveryPage( + initialServerURL: _saved?.serverURL ?? '', + ), + _GateView.home => PairedHomePage( + credentials: _saved!, + session: _session!, + ), + _GateView.error => _SessionErrorPage( + message: + _errorMessage ?? AppLocalizations.of(context)!.sessionRestoreError, + onRetry: _restoreSession, + onSetup: () => setState(() => _view = _GateView.setup), + ), + }; + } +} + +class _SessionLoadingPage extends StatelessWidget { + const _SessionLoadingPage(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnScaffold( + title: l10n.openingHome, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: PawmateColors.lavender), + SizedBox(height: 16), + Text(l10n.checkingSavedAccess), + ], + ), + ), + ); + } +} + +class _SessionErrorPage extends StatelessWidget { + const _SessionErrorPage({ + required this.message, + required this.onRetry, + required this.onSetup, + }); + + final String message; + final VoidCallback onRetry; + final VoidCallback onSetup; + + @override + Widget build(BuildContext context) { + return HanddrawnScaffold( + title: AppLocalizations.of(context)!.findingHome, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + HanddrawnCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.cloud_off_outlined, + color: PawmateColors.softBrown, + size: 32, + ), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + color: PawmateColors.ink, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + HanddrawnButton( + label: AppLocalizations.of(context)!.tryAgain, + icon: Icons.refresh, + onPressed: onRetry, + ), + const SizedBox(height: 10), + TextButton( + onPressed: onSetup, + child: Text(AppLocalizations.of(context)!.chooseSignInMethod), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/pairing/widgets/devices_card.dart b/lib/features/pairing/widgets/devices_card.dart new file mode 100644 index 0000000..9fdef68 --- /dev/null +++ b/lib/features/pairing/widgets/devices_card.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../design/components/handdrawn_button.dart'; +import '../../../design/components/handdrawn_card.dart'; +import '../pairing_api.dart'; +import '../pairing_credentials.dart'; +import '../pairing_session_gate.dart'; +import 'pairing_error_note.dart'; +import '../../../l10n/generated/app_localizations.dart'; + +/// Lists the current member's devices and authorizes additional installations. +class DevicesCard extends StatefulWidget { + const DevicesCard({required this.credentials, super.key}); + + final SavedPairingCredentials credentials; + + @override + State createState() => _DevicesCardState(); +} + +class _DevicesCardState extends State { + final _api = PairingApi(); + List? _devices; + DeviceLoginCode? _code; + String? _error; + bool _busy = false; + + @override + void initState() { + super.initState(); + _refresh(); + } + + @override + void dispose() { + _api.close(); + super.dispose(); + } + + /// Applies a device operation, showing retryable failures and expired sessions. + Future _perform(Future Function() operation) async { + setState(() { + _busy = true; + _error = null; + }); + try { + await operation(); + } on PairingApiException catch (error) { + if (!mounted) return; + if (error.statusCode == 401) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const PairingSessionGate()), + (_) => false, + ); + } else { + setState(() => _error = AppLocalizations.of(context)!.requestFailed); + } + } on Object { + if (mounted) { + setState( + () => _error = AppLocalizations.of(context)!.couldNotReachRetry, + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// Refreshes the list without replacing any member credentials. + Future _refresh() => _perform(() async { + final saved = widget.credentials; + final devices = await _api.getDevices(saved.serverURL, saved.accessToken); + if (mounted) setState(() => _devices = devices); + }); + + /// Creates a code for this member, invalidating this device's previous code. + Future _addDevice() => _perform(() async { + final saved = widget.credentials; + final code = await _api.createDeviceLoginCode( + saved.serverURL, + saved.accessToken, + ); + if (mounted) setState(() => _code = code); + }); + + /// Confirms and removes a remote device without signing out this installation. + Future _removeDevice(PairingDevice device) async { + final l10n = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.signOutDeviceTitle(device.name)), + content: Text(l10n.signOutDeviceHelp), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.signOutDevice), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await _perform(() async { + final saved = widget.credentials; + await _api.revokeDevice(saved.serverURL, saved.accessToken, device.id); + final devices = await _api.getDevices(saved.serverURL, saved.accessToken); + if (mounted) setState(() => _devices = devices); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final code = _code; + return HanddrawnCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.myDevices, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text(l10n.devicesHelp), + if (_devices != null) ...[ + for (final device in _devices!) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.name, + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + device.current + ? l10n.thisDevice + : l10n.addedDate( + MaterialLocalizations.of( + context, + ).formatShortDate(device.createdAt.toLocal()), + ), + ), + if (!device.current) + TextButton.icon( + onPressed: _busy ? null : () => _removeDevice(device), + icon: const Icon(Icons.logout), + label: Text(l10n.signOutDevice), + ), + ], + ), + ), + ], + if (_busy) + const Padding( + padding: EdgeInsets.all(12), + child: LinearProgressIndicator(), + ), + TextButton.icon( + onPressed: _busy ? null : _refresh, + icon: const Icon(Icons.refresh), + label: Text(l10n.refreshDevices), + ), + HanddrawnButton( + label: l10n.addDeviceAction, + icon: Icons.add_to_queue, + onPressed: _busy ? null : _addDevice, + ), + if (code != null) ...[ + const SizedBox(height: 16), + Text(l10n.newDeviceCodeHelp), + const SizedBox(height: 8), + SelectableText( + code.code, + style: const TextStyle(fontFamily: 'monospace'), + ), + Text( + l10n.singleUseExpires( + MaterialLocalizations.of( + context, + ).formatMediumDate(code.expiresAt.toLocal()), + ), + ), + TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: code.code)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.deviceCodeCopied)), + ); + } + }, + icon: const Icon(Icons.copy), + label: Text(l10n.copyLoginCode), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 12), + PairingErrorNote(message: _error!), + ], + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/invite_result_card.dart b/lib/features/pairing/widgets/invite_result_card.dart new file mode 100644 index 0000000..1fc8ecb --- /dev/null +++ b/lib/features/pairing/widgets/invite_result_card.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +import '../../../design/components/handdrawn_button.dart'; +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/theme/colors.dart'; +import '../pairing_api.dart'; +import 'recovery_code_note.dart'; +import '../../../l10n/generated/app_localizations.dart'; + +/// Displays the one-time invitation and the current partner pairing status. +class InviteResultCard extends StatelessWidget { + const InviteResultCard({ + required this.invite, + required this.recoveryCode, + required this.status, + required this.isLoading, + required this.onCopy, + required this.onRefresh, + super.key, + }); + + final PairingInvite invite; + final String recoveryCode; + final PairingStatus? status; + final bool isLoading; + final VoidCallback onCopy; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final isPaired = status?.status == 'paired'; + return HanddrawnCard( + color: const Color(0xFFF4F0FF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isPaired ? Icons.favorite : Icons.mail_outline, + color: isPaired ? PawmateColors.rose : PawmateColors.lavender, + size: 28, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + isPaired ? l10n.homeConnected : l10n.invitationReady, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + isPaired ? l10n.connectedMemories : l10n.shareInvitation, + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.35, + ), + ), + if (!isPaired) ...[ + const SizedBox(height: 16), + SelectableText( + invite.inviteURL, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + fontSize: 13, + height: 1.35, + ), + ), + const SizedBox(height: 14), + HanddrawnButton( + onPressed: onCopy, + icon: Icons.copy_outlined, + label: l10n.copyInvitation, + primary: false, + ), + const SizedBox(height: 10), + Text( + l10n.expiresAt( + MaterialLocalizations.of( + context, + ).formatMediumDate(invite.expiresAt.toLocal()), + ), + style: const TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, + ), + ), + ], + const SizedBox(height: 16), + RecoveryCodeNote(recoveryCode: recoveryCode), + const SizedBox(height: 18), + HanddrawnButton( + onPressed: isLoading ? null : onRefresh, + icon: isPaired ? Icons.check_circle_outline : Icons.refresh, + label: isPaired ? l10n.pairingComplete : l10n.checkPairingStatus, + primary: isPaired, + ), + if (!isPaired && status != null) ...[ + const SizedBox(height: 10), + Text( + l10n.waitingForPartner, + style: TextStyle(color: PawmateColors.softBrown), + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/member_profile_editor.dart b/lib/features/pairing/widgets/member_profile_editor.dart new file mode 100644 index 0000000..29d065d --- /dev/null +++ b/lib/features/pairing/widgets/member_profile_editor.dart @@ -0,0 +1,187 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; + +import '../../../design/components/crayon_avatar_button.dart'; +import '../../../design/components/handdrawn_text_field.dart'; +import '../../../design/theme/colors.dart'; +import '../../../design/theme/spacing.dart'; +import '../../../l10n/generated/app_localizations.dart'; +import '../member_profile.dart'; + +/// Collects required member identity inside the enclosing onboarding form. +class MemberProfileEditor extends StatefulWidget { + const MemberProfileEditor({super.key, this.enabled = true, this.selectImage}); + + final bool enabled; + final Future Function()? selectImage; + + @override + State createState() => MemberProfileEditorState(); +} + +class MemberProfileEditorState extends State { + final _nickname = TextEditingController(); + Uint8List? _avatar; + bool _picking = false; + String? _pickerError; + + /// Returns submitted identity only after the enclosing form is validated. + MemberProfile? get profile => _avatar == null + ? null + : MemberProfile(nickname: _nickname.text.trim(), avatar: _avatar!); + + /// Selects an image explicitly, then crops it locally to a bounded square PNG. + Future _pickAvatar(FormFieldState field) async { + if (_picking) return; + final l10n = AppLocalizations.of(context)!; + setState(() { + _picking = true; + _pickerError = null; + }); + try { + final file = widget.selectImage != null + ? await widget.selectImage!() + : await openFile( + acceptedTypeGroups: [ + XTypeGroup( + label: l10n.avatarImages, + extensions: const ['png', 'jpg', 'jpeg'], + mimeTypes: const ['image/png', 'image/jpeg'], + uniformTypeIdentifiers: const ['public.png', 'public.jpeg'], + ), + ], + ); + if (file == null) return; + if (await file.length() > 10 * 1024 * 1024) { + throw const FormatException('avatar_file_too_large'); + } + final buffer = await ui.ImmutableBuffer.fromUint8List( + await file.readAsBytes(), + ); + ui.ImageDescriptor? descriptor; + ui.Codec? codec; + ui.Image? image; + ui.Picture? picture; + ui.Image? resized; + try { + descriptor = await ui.ImageDescriptor.encoded(buffer); + if (descriptor.width < 1 || + descriptor.height < 1 || + descriptor.width * descriptor.height > 40000000) { + throw const FormatException('invalid_avatar'); + } + final scale = + 512 / + (descriptor.width > descriptor.height + ? descriptor.width + : descriptor.height); + codec = await descriptor.instantiateCodec( + targetWidth: (descriptor.width * scale).round().clamp(1, 512), + targetHeight: (descriptor.height * scale).round().clamp(1, 512), + ); + image = (await codec.getNextFrame()).image; + final side = (image.width < image.height ? image.width : image.height) + .toDouble(); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawImageRect( + image, + Rect.fromLTWH( + (image.width - side) / 2, + (image.height - side) / 2, + side, + side, + ), + const Rect.fromLTWH(0, 0, 256, 256), + Paint()..filterQuality = FilterQuality.high, + ); + picture = recorder.endRecording(); + resized = await picture.toImage(256, 256); + final data = await resized.toByteData(format: ui.ImageByteFormat.png); + if (data == null || data.lengthInBytes > 256 * 1024) { + throw const FormatException('invalid_avatar'); + } + if (!mounted) return; + final bytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + setState(() => _avatar = bytes); + field.didChange(bytes); + } finally { + resized?.dispose(); + picture?.dispose(); + image?.dispose(); + codec?.dispose(); + descriptor?.dispose(); + buffer.dispose(); + } + } on Object { + if (mounted) setState(() => _pickerError = l10n.avatarUploadError); + } finally { + if (mounted) setState(() => _picking = false); + } + } + + @override + void dispose() { + _nickname.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FormField( + validator: (value) => value == null ? l10n.avatarRequired : null, + builder: (field) => Column( + children: [ + CrayonAvatarButton( + label: _avatar == null ? l10n.uploadAvatar : l10n.changeAvatar, + avatar: _avatar, + onPressed: widget.enabled && !_picking + ? () => _pickAvatar(field) + : null, + ), + const SizedBox(height: PawmateSpace.small), + Text( + _avatar == null ? l10n.uploadAvatar : l10n.changeAvatar, + style: const TextStyle(color: PawmateColors.softBrown), + ), + if (_pickerError != null || field.hasError) ...[ + const SizedBox(height: PawmateSpace.small), + Text( + _pickerError ?? field.errorText!, + textAlign: TextAlign.center, + style: const TextStyle(color: PawmateColors.error), + ), + ], + ], + ), + ), + const SizedBox(height: PawmateSpace.page), + HanddrawnTextField( + controller: _nickname, + labelText: l10n.nickname, + hintText: l10n.nicknameHint, + keyboardType: TextInputType.text, + enabled: widget.enabled, + validator: (value) { + final name = value?.trim() ?? ''; + return name.isEmpty || + name.runes.length > 32 || + RegExp(r'[\x00-\x1F\x7F-\x9F]').hasMatch(name) + ? l10n.nicknameInvalid + : null; + }, + ), + ], + ); + } +} diff --git a/lib/features/pairing/widgets/pairing_error_note.dart b/lib/features/pairing/widgets/pairing_error_note.dart new file mode 100644 index 0000000..cc747d8 --- /dev/null +++ b/lib/features/pairing/widgets/pairing_error_note.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/theme/colors.dart'; + +/// Shows a readable network or validation error on a paper-like note. +class PairingErrorNote extends StatelessWidget { + const PairingErrorNote({required this.message, super.key}); + + final String message; + + @override + Widget build(BuildContext context) { + return HanddrawnCard( + color: const Color(0xFFFFE9E4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline, color: PawmateColors.error), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: PawmateColors.ink, height: 1.35), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/pairing_recovery_card.dart b/lib/features/pairing/widgets/pairing_recovery_card.dart new file mode 100644 index 0000000..feef901 --- /dev/null +++ b/lib/features/pairing/widgets/pairing_recovery_card.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../../../design/components/handdrawn_button.dart'; +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/components/handdrawn_text_field.dart'; +import '../../../design/theme/colors.dart'; +import '../../../l10n/generated/app_localizations.dart'; + +/// Collects the server address and recovery code after an app reinstall. +class PairingRecoveryCard extends StatelessWidget { + const PairingRecoveryCard({ + required this.serverController, + required this.codeController, + required this.isLoading, + required this.onRecover, + super.key, + }); + + final TextEditingController serverController; + final TextEditingController codeController; + final bool isLoading; + final VoidCallback onRecover; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnCard( + color: const Color(0xFFF0F6F0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.restoreExistingHome, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + l10n.recoveryHelp, + style: TextStyle(color: PawmateColors.softBrown, height: 1.35), + ), + const SizedBox(height: 14), + HanddrawnTextField( + controller: serverController, + labelText: l10n.serverUrlExisting, + hintText: l10n.serverUrlHint, + prefixIcon: Icons.link, + ), + const SizedBox(height: 12), + TextFormField( + controller: codeController, + autocorrect: false, + enableSuggestions: false, + textCapitalization: TextCapitalization.none, + decoration: InputDecoration( + labelText: l10n.recoveryCode, + prefixIcon: Icon(Icons.key_outlined), + ), + ), + const SizedBox(height: 14), + HanddrawnButton( + onPressed: isLoading ? null : onRecover, + icon: Icons.settings_backup_restore, + label: isLoading ? l10n.restoringAccess : l10n.restoreAccess, + primary: false, + ), + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/recovery_code_note.dart b/lib/features/pairing/widgets/recovery_code_note.dart new file mode 100644 index 0000000..569bc64 --- /dev/null +++ b/lib/features/pairing/widgets/recovery_code_note.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/theme/colors.dart'; +import '../../../l10n/generated/app_localizations.dart'; + +/// Presents a member's one-time recovery code with a copy action. +class RecoveryCodeNote extends StatelessWidget { + const RecoveryCodeNote({required this.recoveryCode, super.key}); + + final String recoveryCode; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnCard( + color: const Color(0xFFFFF3CF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.recoveryCodeTitle, + style: TextStyle( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + l10n.recoveryCodeHelp, + style: TextStyle(color: PawmateColors.softBrown, height: 1.35), + ), + const SizedBox(height: 12), + SelectableText( + recoveryCode, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: recoveryCode)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.recoveryCodeCopied)), + ); + } + }, + icon: const Icon(Icons.copy_outlined), + label: Text(l10n.copyCode), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/server_setup_card.dart b/lib/features/pairing/widgets/server_setup_card.dart new file mode 100644 index 0000000..22eb255 --- /dev/null +++ b/lib/features/pairing/widgets/server_setup_card.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import '../../../design/components/handdrawn_button.dart'; +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/components/handdrawn_text_field.dart'; +import '../../../design/theme/colors.dart'; +import '../../../design/icons/access/access_doodle_icon.dart'; +import '../../../l10n/generated/app_localizations.dart'; + +/// Collects and submits the inviter's private Pawmate server address. +class ServerSetupCard extends StatelessWidget { + const ServerSetupCard({ + required this.formKey, + required this.controller, + required this.isLoading, + required this.onCreateInvite, + super.key, + this.profileEditor, + }); + + final GlobalKey formKey; + final TextEditingController controller; + final bool isLoading; + final VoidCallback onCreateInvite; + final Widget? profileEditor; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnCard( + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (profileEditor != null) + profileEditor! + else + const Center( + child: AccessDoodleIcon(symbol: AccessDoodle.createInvitation), + ), + const SizedBox(height: 12), + Text( + l10n.chooseHomeAddress, + textAlign: TextAlign.start, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + l10n.trustedServerHelp, + style: TextStyle(color: PawmateColors.softBrown, height: 1.35), + ), + const SizedBox(height: 18), + HanddrawnTextField( + controller: controller, + labelText: l10n.serverUrlDomain, + hintText: l10n.serverUrlHint, + prefixIcon: Icons.link, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return l10n.enterServerUrl; + } + return null; + }, + ), + const SizedBox(height: 18), + HanddrawnButton( + onPressed: isLoading ? null : onCreateInvite, + icon: Icons.favorite_border, + label: isLoading ? l10n.makingRoom : l10n.createInvitationAction, + ), + ], + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..4713b97 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,165 @@ +{ + "@@locale": "en", + "chooseLanguage": "Language", + "english": "English", + "chinese": "简体中文", + "homeHeadline": "Your little home, together", + "homeIntro": "Choose how you’d like to come in.", + "createInvitation": "Create invitation", + "createInvitationDescription": "Start a home on your private server.", + "acceptInvitation": "Accept invitation", + "acceptInvitationDescription": "Join your partner using their link.", + "restoreData": "Restore data", + "restoreDataDescription": "Lost access? Use your recovery code.", + "addDevice": "Add device", + "addDeviceDescription": "Keep your other devices signed in.", + "alreadyPaired": "Already paired? Add a device instead of creating a new invitation. Your home’s data stays on your server.", + "invitePartner": "Invite your partner", + "chooseHomeAddress": "Choose your home address", + "trustedServerHelp": "Use the URL or domain of the private server you trust.", + "serverUrlDomain": "Server URL or domain", + "serverUrl": "Server URL", + "serverUrlExisting": "Existing server URL", + "serverUrlHint": "https://pawmate.example.com", + "enterServerUrl": "Enter your server URL.", + "makingRoom": "Making a little room…", + "createInvitationAction": "Create invitation", + "invitationCreatedStorageWarning": "Invitation created. Save the recovery code below; this device could not store it securely.", + "serverUnreachable": "Could not reach the Pawmate server.", + "serverUnreachableShort": "Could not reach the server.", + "invitationCopied": "Invitation link copied", + "invitationReady": "Your invitation is ready", + "homeConnected": "Your home is connected!", + "shareInvitation": "Share this one-time link with your partner.", + "connectedMemories": "You and your partner can start making memories together.", + "copyInvitation": "Copy invitation", + "expiresAt": "Expires {date}", + "@expiresAt": {"placeholders": {"date": {"type": "String"}}}, + "pairingComplete": "Pairing complete", + "checkPairingStatus": "Check pairing status", + "waitingForPartner": "Waiting for your partner to accept…", + "recoveryCodeTitle": "Your recovery code", + "recoveryCodeHelp": "Keep this code somewhere safe. It can restore your home after losing a device.", + "copyCode": "Copy code", + "recoveryCodeCopied": "Recovery code copied", + "anInvitationForTwo": "An invitation for two", + "invitationPasteHelp": "Paste the complete link your partner sent. You’ll review the server address before joining.", + "invitationLink": "Invitation link", + "pasteInvitationLinkFirst": "Copy your partner’s invitation link first.", + "clipboardUnavailable": "Clipboard unavailable. Paste or type the link into the field.", + "pasteLink": "Paste link", + "reviewInvitation": "Review invitation", + "invalidInvitationLink": "This invitation link is not valid.", + "incompleteInvitation": "This invitation link is incomplete.", + "joinHome": "Join your little home", + "invited": "You have been invited", + "acceptOneTimeHelp": "Accepting this one-time invitation will pair your device with your partner.", + "privateServer": "Private server", + "joining": "Joining…", + "couldNotAccept": "Could not accept this invitation.", + "incompleteCredentials": "The server did not return complete pairing credentials.", + "restoreHome": "Restore your home", + "reconnectTitle": "Let’s reconnect you", + "reconnectHelp": "This device needs a new login. If another device is still signed in, use its device login code. Recovery is for lost access and signs out all your other devices.", + "deviceCodeSignIn": "Sign in with a device login code", + "restoreExistingHome": "Restoring an existing home?", + "recoveryHelp": "Use your recovery code if you have lost access. Recovery signs out all your devices and replaces the recovery code; your partner stays signed in. To add a device, use a device login code instead.", + "recoveryCode": "Recovery code", + "restoringAccess": "Restoring access…", + "restoreAccess": "Restore access", + "invalidRecoveryCode": "That recovery code is not valid anymore.", + "notPaired": "This server does not have a completed couple pairing.", + "couldNotRestore": "Could not restore this home. Try again.", + "bringHome": "Bring your home along", + "signInAnotherDevice": "Sign in on another device", + "deviceSignInHelp": "On your signed-in phone, tablet or computer, open My devices and choose Add a device. Enter its server address and login code here. Your other devices will stay signed in.", + "deviceLoginCode": "Device login code", + "deviceName": "Device name", + "enterServerAddress": "Enter your server address.", + "enterLoginCode": "Enter the login code.", + "deviceNameLength": "Use a name between 1 and 80 characters.", + "signingIn": "Signing in…", + "signIn": "Sign in", + "back": "Back", + "invalidDeviceCode": "This login code expired or was already used. Generate a new one on your signed-in device.", + "couldNotSignIn": "Could not sign in. Check your connection and try again.", + "openingHome": "Opening your home", + "checkingSavedAccess": "Checking your saved access…", + "findingHome": "Finding your home", + "chooseSignInMethod": "Choose a sign-in method", + "ourConversation": "Our conversation", + "playCorner": "Play corner", + "ourLittleHome": "Our little home", + "chatUnread": "Chat, {count} unread messages", + "@chatUnread": {"placeholders": {"count": {"type": "int"}}}, + "viewHome": "View Home", + "retry": "Retry", + "unreadGoLatest": "{count} unread · Go to latest", + "@unreadGoLatest": {"placeholders": {"count": {"type": "int"}}}, + "messagePartner": "Message your partner", + "sendMessage": "Send message", + "retryMessage": "Retry message", + "myDevices": "My devices", + "signOutDeviceTitle": "Sign out {name}?", + "@signOutDeviceTitle": {"placeholders": {"name": {"type": "String"}}}, + "cancel": "Cancel", + "signOutDevice": "Sign out device", + "refreshDevices": "Refresh devices", + "deviceCodeCopied": "Device login code copied", + "copyLoginCode": "Copy login code", + "changeLanguage": "Change language", + "chat": "Chat", + "play": "Play", + "home": "Home", + "unreadFromPartner": "{count} unread messages from your partner", + "@unreadFromPartner": {"placeholders": {"count": {"type": "int"}}}, + "openChat": "Open chat", + "loginNotSaved": "This device could not save its login. Open Home for reconnect instructions.", + "saveRecoveryCodeHome": "Save your recovery code in Home before leaving.", + "gamesComing": "A little corner for playing together.\nGames will arrive here later.", + "youAreHome": "You are home together", + "connectedAsRole": "This device is connected as the {role}.", + "@connectedAsRole": {"placeholders": {"role": {"type": "String"}}}, + "homeId": "Home ID: {id}", + "@homeId": {"placeholders": {"id": {"type": "String"}}}, + "storageWarningRecovery": "This device could not securely save its login. Keep the recovery code below in case the app closes.", + "storageWarningDevice": "This device could not securely save its login. If the app closes, generate a new login code on another signed-in device to reconnect.", + "couldNotReachRetry": "Could not reach your server. Try again.", + "signOutDeviceHelp": "This device will need a new login code to reconnect. Your other devices will stay signed in.", + "devicesHelp": "Keep your phone, tablet and computer connected to the same home. Only your own devices appear here.", + "thisDevice": "This device", + "addedDate": "Added {date}", + "@addedDate": {"placeholders": {"date": {"type": "String"}}}, + "addDeviceAction": "Add a device", + "newDeviceCodeHelp": "On your new device, choose Sign in on another device. Use the server address above and this code. Keep it private: it grants access as you.", + "singleUseExpires": "Single use · Expires {date}", + "@singleUseExpires": {"placeholders": {"date": {"type": "String"}}}, + "chatError": "Chat could not be loaded. Check your connection and try again.", + "firstWords": "Your first words together\nSend a little hello to start your shared journal.", + "sending": "Sending…", + "notSent": "Not sent", + "loading": "Loading…", + "loadEarlier": "Load earlier messages", + "read": "Read", + "unread": "Unread", + "messageTooLong": "Use up to 4000 characters.", + "tryAgain": "Try again", + "savedAccessError": "Could not read or validate saved access on this device.", + "serverAccessError": "Could not reach your Pawmate server. Check your connection and retry.", + "sessionRestoreError": "Could not restore this session.", + "requestFailed": "The server could not complete this request. Try again.", + "invalidServerAddress": "Enter a valid HTTPS server address. HTTP is available in debug builds only.", + "invitationLinkHint": "pawmate://pair?server=…&code=…", + "myDeviceName": "My device", + "uploadAvatar": "Upload avatar", + "changeAvatar": "Change avatar", + "avatarImages": "Avatar images", + "avatarRequired": "Choose an avatar before continuing.", + "avatarUploadError": "Could not read this image. Choose a PNG or JPEG under 10 MB.", + "nickname": "Nickname", + "nicknameHint": "What should your partner call you?", + "nicknameInvalid": "Use a nickname between 1 and 32 characters, without control characters.", + "profileRejected": "The server could not save your profile. Check your nickname and avatar.", + "yourProfile": "You", + "partnerProfile": "Your partner" +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 0000000..e943ce1 --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,165 @@ +{ + "@@locale": "zh", + "chooseLanguage": "选择语言", + "english": "English", + "chinese": "简体中文", + "homeHeadline": "两个人的小家", + "homeIntro": "选择一种方式进入", + "createInvitation": "创建邀请", + "createInvitationDescription": "在你的私人服务器上创建小家", + "acceptInvitation": "接受邀请", + "acceptInvitationDescription": "通过伴侣发送的链接加入", + "restoreData": "恢复数据", + "restoreDataDescription": "无法登录时使用恢复码", + "addDevice": "添加设备", + "addDeviceDescription": "让其他设备也保持登录", + "alreadyPaired": "如果已经配对,可直接添加设备,无需重新创建邀请,小家的数据仍保存在你的服务器上", + "invitePartner": "邀请伴侣", + "chooseHomeAddress": "设置小家的地址", + "trustedServerHelp": "填写你信任的私人服务器网址或域名", + "serverUrlDomain": "服务器网址或域名", + "serverUrl": "服务器网址", + "serverUrlExisting": "已有服务器网址", + "serverUrlHint": "https://pawmate.example.com", + "enterServerUrl": "请输入服务器网址", + "makingRoom": "正在准备小家…", + "createInvitationAction": "创建邀请", + "invitationCreatedStorageWarning": "邀请已创建,请保存下方恢复码,此设备未能安全保存它", + "serverUnreachable": "无法连接到 Pawmate 服务器", + "serverUnreachableShort": "无法连接到服务器", + "invitationCopied": "邀请链接已复制", + "invitationReady": "邀请已准备好", + "homeConnected": "小家已连接", + "shareInvitation": "把这个一次性链接分享给伴侣", + "connectedMemories": "现在可以和伴侣一起留下回忆了", + "copyInvitation": "复制邀请", + "expiresAt": "有效期至 {date}", + "@expiresAt": {"placeholders": {"date": {"type": "String"}}}, + "pairingComplete": "配对完成", + "checkPairingStatus": "检查配对状态", + "waitingForPartner": "等待伴侣接受邀请…", + "recoveryCodeTitle": "你的小家恢复码", + "recoveryCodeHelp": "请妥善保存此恢复码,设备丢失后可用它恢复小家", + "copyCode": "复制代码", + "recoveryCodeCopied": "恢复码已复制", + "anInvitationForTwo": "一份双人邀请", + "invitationPasteHelp": "粘贴伴侣发来的完整链接,加入前可以先核对服务器地址", + "invitationLink": "邀请链接", + "pasteInvitationLinkFirst": "请先复制伴侣发来的邀请链接", + "clipboardUnavailable": "剪贴板不可用,请在下方粘贴或输入链接", + "pasteLink": "粘贴链接", + "reviewInvitation": "查看邀请", + "invalidInvitationLink": "邀请链接无效", + "incompleteInvitation": "邀请链接不完整", + "joinHome": "加入小家", + "invited": "你收到了一份邀请", + "acceptOneTimeHelp": "接受这份一次性邀请后,你的设备将与伴侣完成配对", + "privateServer": "私人服务器", + "joining": "正在加入…", + "couldNotAccept": "无法接受这份邀请", + "incompleteCredentials": "服务器返回的配对凭证不完整", + "restoreHome": "恢复小家", + "reconnectTitle": "重新连接小家", + "reconnectHelp": "此设备需要重新登录,如果其他设备仍处于登录状态,请使用设备登录码,恢复码适用于无法访问其他设备的情况,并会让其他设备退出登录", + "deviceCodeSignIn": "使用设备登录码登录", + "restoreExistingHome": "需要恢复已有的小家吗", + "recoveryHelp": "无法登录时使用恢复码,恢复后所有设备都会退出登录,恢复码也会更新,伴侣仍保持登录,添加设备请使用设备登录码", + "recoveryCode": "恢复码", + "restoringAccess": "正在恢复访问…", + "restoreAccess": "恢复访问", + "invalidRecoveryCode": "恢复码已失效", + "notPaired": "此服务器上还没有完成配对的小家", + "couldNotRestore": "无法恢复小家,请重试", + "bringHome": "在新设备上登录", + "signInAnotherDevice": "登录另一台设备", + "deviceSignInHelp": "在已登录的手机、平板或电脑上打开“我的设备”并选择“添加设备”,然后在这里输入服务器地址和登录码,其他设备会保持登录", + "deviceLoginCode": "设备登录码", + "deviceName": "设备名称", + "enterServerAddress": "请输入服务器地址", + "enterLoginCode": "请输入登录码", + "deviceNameLength": "设备名称长度需为 1 到 80 个字符", + "signingIn": "正在登录…", + "signIn": "登录", + "back": "返回", + "invalidDeviceCode": "登录码已过期或已使用,请在已登录设备上重新生成", + "couldNotSignIn": "无法登录,请检查网络后重试", + "openingHome": "正在打开小家", + "checkingSavedAccess": "正在检查已保存的登录信息…", + "findingHome": "正在寻找小家", + "chooseSignInMethod": "选择登录方式", + "ourConversation": "我们的聊天", + "playCorner": "游戏角", + "ourLittleHome": "我们的小家", + "chatUnread": "聊天,有 {count} 条未读消息", + "@chatUnread": {"placeholders": {"count": {"type": "int"}}}, + "viewHome": "查看小家", + "retry": "重试", + "unreadGoLatest": "有 {count} 条未读 · 查看最新消息", + "@unreadGoLatest": {"placeholders": {"count": {"type": "int"}}}, + "messagePartner": "给伴侣发消息", + "sendMessage": "发送消息", + "retryMessage": "重试发送", + "myDevices": "我的设备", + "signOutDeviceTitle": "让 {name} 退出登录", + "@signOutDeviceTitle": {"placeholders": {"name": {"type": "String"}}}, + "cancel": "取消", + "signOutDevice": "退出此设备", + "refreshDevices": "刷新设备", + "deviceCodeCopied": "设备登录码已复制", + "copyLoginCode": "复制登录码", + "changeLanguage": "切换语言", + "chat": "聊天", + "play": "游戏", + "home": "小家", + "unreadFromPartner": "有 {count} 条伴侣发来的未读消息", + "@unreadFromPartner": {"placeholders": {"count": {"type": "int"}}}, + "openChat": "打开聊天", + "loginNotSaved": "此设备未能保存登录信息,请打开小家查看重新连接说明", + "saveRecoveryCodeHome": "离开前请在小家中保存恢复码", + "gamesComing": "一起玩耍的小角落\n游戏功能即将到来", + "youAreHome": "你们已经回到小家", + "connectedAsRole": "此设备以{role}身份连接", + "@connectedAsRole": {"placeholders": {"role": {"type": "String"}}}, + "homeId": "小家编号:{id}", + "@homeId": {"placeholders": {"id": {"type": "String"}}}, + "storageWarningRecovery": "此设备未能安全保存登录信息,请保存下方恢复码,以免应用关闭后无法登录", + "storageWarningDevice": "此设备未能安全保存登录信息,若应用关闭,请在其他已登录设备上生成新的登录码以重新连接", + "couldNotReachRetry": "无法连接服务器,请重试", + "signOutDeviceHelp": "此设备需要新的登录码才能重新连接,其他设备会保持登录", + "devicesHelp": "让手机、平板和电脑连接到同一个小家,这里只会显示你自己的设备", + "thisDevice": "当前设备", + "addedDate": "添加于 {date}", + "@addedDate": {"placeholders": {"date": {"type": "String"}}}, + "addDeviceAction": "添加设备", + "newDeviceCodeHelp": "在新设备上选择“登录另一台设备”,使用上方服务器地址和此登录码,请妥善保管,它可以访问你的账户", + "singleUseExpires": "单次使用 · 有效期至 {date}", + "@singleUseExpires": {"placeholders": {"date": {"type": "String"}}}, + "chatError": "聊天暂时无法加载,请检查网络后重试", + "firstWords": "你们的第一句话\n发送一声问候,开启两个人的聊天日记", + "sending": "正在发送…", + "notSent": "发送失败", + "loading": "正在加载…", + "loadEarlier": "加载更早的消息", + "read": "已读", + "unread": "未读", + "messageTooLong": "消息最多可输入 4000 个字符", + "tryAgain": "重试", + "savedAccessError": "无法读取或验证此设备上保存的登录信息", + "serverAccessError": "无法连接到 Pawmate 服务器,请检查网络后重试", + "sessionRestoreError": "无法恢复此登录状态", + "requestFailed": "服务器暂时无法完成请求,请重试", + "invalidServerAddress": "请输入有效的 HTTPS 服务器地址。HTTP 仅在调试版本中可用。", + "invitationLinkHint": "pawmate://pair?server=…&code=…", + "myDeviceName": "我的设备", + "uploadAvatar": "上传头像", + "changeAvatar": "更换头像", + "avatarImages": "头像图片", + "avatarRequired": "请先选择头像", + "avatarUploadError": "无法读取这张图片,请选择小于 10 MB 的 PNG 或 JPEG 图片", + "nickname": "昵称", + "nicknameHint": "想让伴侣怎么称呼你", + "nicknameInvalid": "请输入 1 到 32 个字符的昵称,不要包含控制字符", + "profileRejected": "服务器未能保存个人资料,请检查昵称和头像", + "yourProfile": "你", + "partnerProfile": "你的伴侣" +} diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart new file mode 100644 index 0000000..2698a2c --- /dev/null +++ b/lib/l10n/generated/app_localizations.dart @@ -0,0 +1,1052 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'generated/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('zh'), + ]; + + /// No description provided for @chooseLanguage. + /// + /// In en, this message translates to: + /// **'Language'** + String get chooseLanguage; + + /// No description provided for @english. + /// + /// In en, this message translates to: + /// **'English'** + String get english; + + /// No description provided for @chinese. + /// + /// In en, this message translates to: + /// **'简体中文'** + String get chinese; + + /// No description provided for @homeHeadline. + /// + /// In en, this message translates to: + /// **'Your little home, together'** + String get homeHeadline; + + /// No description provided for @homeIntro. + /// + /// In en, this message translates to: + /// **'Choose how you’d like to come in.'** + String get homeIntro; + + /// No description provided for @createInvitation. + /// + /// In en, this message translates to: + /// **'Create invitation'** + String get createInvitation; + + /// No description provided for @createInvitationDescription. + /// + /// In en, this message translates to: + /// **'Start a home on your private server.'** + String get createInvitationDescription; + + /// No description provided for @acceptInvitation. + /// + /// In en, this message translates to: + /// **'Accept invitation'** + String get acceptInvitation; + + /// No description provided for @acceptInvitationDescription. + /// + /// In en, this message translates to: + /// **'Join your partner using their link.'** + String get acceptInvitationDescription; + + /// No description provided for @restoreData. + /// + /// In en, this message translates to: + /// **'Restore data'** + String get restoreData; + + /// No description provided for @restoreDataDescription. + /// + /// In en, this message translates to: + /// **'Lost access? Use your recovery code.'** + String get restoreDataDescription; + + /// No description provided for @addDevice. + /// + /// In en, this message translates to: + /// **'Add device'** + String get addDevice; + + /// No description provided for @addDeviceDescription. + /// + /// In en, this message translates to: + /// **'Keep your other devices signed in.'** + String get addDeviceDescription; + + /// No description provided for @alreadyPaired. + /// + /// In en, this message translates to: + /// **'Already paired? Add a device instead of creating a new invitation. Your home’s data stays on your server.'** + String get alreadyPaired; + + /// No description provided for @invitePartner. + /// + /// In en, this message translates to: + /// **'Invite your partner'** + String get invitePartner; + + /// No description provided for @chooseHomeAddress. + /// + /// In en, this message translates to: + /// **'Choose your home address'** + String get chooseHomeAddress; + + /// No description provided for @trustedServerHelp. + /// + /// In en, this message translates to: + /// **'Use the URL or domain of the private server you trust.'** + String get trustedServerHelp; + + /// No description provided for @serverUrlDomain. + /// + /// In en, this message translates to: + /// **'Server URL or domain'** + String get serverUrlDomain; + + /// No description provided for @serverUrl. + /// + /// In en, this message translates to: + /// **'Server URL'** + String get serverUrl; + + /// No description provided for @serverUrlExisting. + /// + /// In en, this message translates to: + /// **'Existing server URL'** + String get serverUrlExisting; + + /// No description provided for @serverUrlHint. + /// + /// In en, this message translates to: + /// **'https://pawmate.example.com'** + String get serverUrlHint; + + /// No description provided for @enterServerUrl. + /// + /// In en, this message translates to: + /// **'Enter your server URL.'** + String get enterServerUrl; + + /// No description provided for @makingRoom. + /// + /// In en, this message translates to: + /// **'Making a little room…'** + String get makingRoom; + + /// No description provided for @createInvitationAction. + /// + /// In en, this message translates to: + /// **'Create invitation'** + String get createInvitationAction; + + /// No description provided for @invitationCreatedStorageWarning. + /// + /// In en, this message translates to: + /// **'Invitation created. Save the recovery code below; this device could not store it securely.'** + String get invitationCreatedStorageWarning; + + /// No description provided for @serverUnreachable. + /// + /// In en, this message translates to: + /// **'Could not reach the Pawmate server.'** + String get serverUnreachable; + + /// No description provided for @serverUnreachableShort. + /// + /// In en, this message translates to: + /// **'Could not reach the server.'** + String get serverUnreachableShort; + + /// No description provided for @invitationCopied. + /// + /// In en, this message translates to: + /// **'Invitation link copied'** + String get invitationCopied; + + /// No description provided for @invitationReady. + /// + /// In en, this message translates to: + /// **'Your invitation is ready'** + String get invitationReady; + + /// No description provided for @homeConnected. + /// + /// In en, this message translates to: + /// **'Your home is connected!'** + String get homeConnected; + + /// No description provided for @shareInvitation. + /// + /// In en, this message translates to: + /// **'Share this one-time link with your partner.'** + String get shareInvitation; + + /// No description provided for @connectedMemories. + /// + /// In en, this message translates to: + /// **'You and your partner can start making memories together.'** + String get connectedMemories; + + /// No description provided for @copyInvitation. + /// + /// In en, this message translates to: + /// **'Copy invitation'** + String get copyInvitation; + + /// No description provided for @expiresAt. + /// + /// In en, this message translates to: + /// **'Expires {date}'** + String expiresAt(String date); + + /// No description provided for @pairingComplete. + /// + /// In en, this message translates to: + /// **'Pairing complete'** + String get pairingComplete; + + /// No description provided for @checkPairingStatus. + /// + /// In en, this message translates to: + /// **'Check pairing status'** + String get checkPairingStatus; + + /// No description provided for @waitingForPartner. + /// + /// In en, this message translates to: + /// **'Waiting for your partner to accept…'** + String get waitingForPartner; + + /// No description provided for @recoveryCodeTitle. + /// + /// In en, this message translates to: + /// **'Your recovery code'** + String get recoveryCodeTitle; + + /// No description provided for @recoveryCodeHelp. + /// + /// In en, this message translates to: + /// **'Keep this code somewhere safe. It can restore your home after losing a device.'** + String get recoveryCodeHelp; + + /// No description provided for @copyCode. + /// + /// In en, this message translates to: + /// **'Copy code'** + String get copyCode; + + /// No description provided for @recoveryCodeCopied. + /// + /// In en, this message translates to: + /// **'Recovery code copied'** + String get recoveryCodeCopied; + + /// No description provided for @anInvitationForTwo. + /// + /// In en, this message translates to: + /// **'An invitation for two'** + String get anInvitationForTwo; + + /// No description provided for @invitationPasteHelp. + /// + /// In en, this message translates to: + /// **'Paste the complete link your partner sent. You’ll review the server address before joining.'** + String get invitationPasteHelp; + + /// No description provided for @invitationLink. + /// + /// In en, this message translates to: + /// **'Invitation link'** + String get invitationLink; + + /// No description provided for @pasteInvitationLinkFirst. + /// + /// In en, this message translates to: + /// **'Copy your partner’s invitation link first.'** + String get pasteInvitationLinkFirst; + + /// No description provided for @clipboardUnavailable. + /// + /// In en, this message translates to: + /// **'Clipboard unavailable. Paste or type the link into the field.'** + String get clipboardUnavailable; + + /// No description provided for @pasteLink. + /// + /// In en, this message translates to: + /// **'Paste link'** + String get pasteLink; + + /// No description provided for @reviewInvitation. + /// + /// In en, this message translates to: + /// **'Review invitation'** + String get reviewInvitation; + + /// No description provided for @invalidInvitationLink. + /// + /// In en, this message translates to: + /// **'This invitation link is not valid.'** + String get invalidInvitationLink; + + /// No description provided for @incompleteInvitation. + /// + /// In en, this message translates to: + /// **'This invitation link is incomplete.'** + String get incompleteInvitation; + + /// No description provided for @joinHome. + /// + /// In en, this message translates to: + /// **'Join your little home'** + String get joinHome; + + /// No description provided for @invited. + /// + /// In en, this message translates to: + /// **'You have been invited'** + String get invited; + + /// No description provided for @acceptOneTimeHelp. + /// + /// In en, this message translates to: + /// **'Accepting this one-time invitation will pair your device with your partner.'** + String get acceptOneTimeHelp; + + /// No description provided for @privateServer. + /// + /// In en, this message translates to: + /// **'Private server'** + String get privateServer; + + /// No description provided for @joining. + /// + /// In en, this message translates to: + /// **'Joining…'** + String get joining; + + /// No description provided for @couldNotAccept. + /// + /// In en, this message translates to: + /// **'Could not accept this invitation.'** + String get couldNotAccept; + + /// No description provided for @incompleteCredentials. + /// + /// In en, this message translates to: + /// **'The server did not return complete pairing credentials.'** + String get incompleteCredentials; + + /// No description provided for @restoreHome. + /// + /// In en, this message translates to: + /// **'Restore your home'** + String get restoreHome; + + /// No description provided for @reconnectTitle. + /// + /// In en, this message translates to: + /// **'Let’s reconnect you'** + String get reconnectTitle; + + /// No description provided for @reconnectHelp. + /// + /// In en, this message translates to: + /// **'This device needs a new login. If another device is still signed in, use its device login code. Recovery is for lost access and signs out all your other devices.'** + String get reconnectHelp; + + /// No description provided for @deviceCodeSignIn. + /// + /// In en, this message translates to: + /// **'Sign in with a device login code'** + String get deviceCodeSignIn; + + /// No description provided for @restoreExistingHome. + /// + /// In en, this message translates to: + /// **'Restoring an existing home?'** + String get restoreExistingHome; + + /// No description provided for @recoveryHelp. + /// + /// In en, this message translates to: + /// **'Use your recovery code if you have lost access. Recovery signs out all your devices and replaces the recovery code; your partner stays signed in. To add a device, use a device login code instead.'** + String get recoveryHelp; + + /// No description provided for @recoveryCode. + /// + /// In en, this message translates to: + /// **'Recovery code'** + String get recoveryCode; + + /// No description provided for @restoringAccess. + /// + /// In en, this message translates to: + /// **'Restoring access…'** + String get restoringAccess; + + /// No description provided for @restoreAccess. + /// + /// In en, this message translates to: + /// **'Restore access'** + String get restoreAccess; + + /// No description provided for @invalidRecoveryCode. + /// + /// In en, this message translates to: + /// **'That recovery code is not valid anymore.'** + String get invalidRecoveryCode; + + /// No description provided for @notPaired. + /// + /// In en, this message translates to: + /// **'This server does not have a completed couple pairing.'** + String get notPaired; + + /// No description provided for @couldNotRestore. + /// + /// In en, this message translates to: + /// **'Could not restore this home. Try again.'** + String get couldNotRestore; + + /// No description provided for @bringHome. + /// + /// In en, this message translates to: + /// **'Bring your home along'** + String get bringHome; + + /// No description provided for @signInAnotherDevice. + /// + /// In en, this message translates to: + /// **'Sign in on another device'** + String get signInAnotherDevice; + + /// No description provided for @deviceSignInHelp. + /// + /// In en, this message translates to: + /// **'On your signed-in phone, tablet or computer, open My devices and choose Add a device. Enter its server address and login code here. Your other devices will stay signed in.'** + String get deviceSignInHelp; + + /// No description provided for @deviceLoginCode. + /// + /// In en, this message translates to: + /// **'Device login code'** + String get deviceLoginCode; + + /// No description provided for @deviceName. + /// + /// In en, this message translates to: + /// **'Device name'** + String get deviceName; + + /// No description provided for @enterServerAddress. + /// + /// In en, this message translates to: + /// **'Enter your server address.'** + String get enterServerAddress; + + /// No description provided for @enterLoginCode. + /// + /// In en, this message translates to: + /// **'Enter the login code.'** + String get enterLoginCode; + + /// No description provided for @deviceNameLength. + /// + /// In en, this message translates to: + /// **'Use a name between 1 and 80 characters.'** + String get deviceNameLength; + + /// No description provided for @signingIn. + /// + /// In en, this message translates to: + /// **'Signing in…'** + String get signingIn; + + /// No description provided for @signIn. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get signIn; + + /// No description provided for @back. + /// + /// In en, this message translates to: + /// **'Back'** + String get back; + + /// No description provided for @invalidDeviceCode. + /// + /// In en, this message translates to: + /// **'This login code expired or was already used. Generate a new one on your signed-in device.'** + String get invalidDeviceCode; + + /// No description provided for @couldNotSignIn. + /// + /// In en, this message translates to: + /// **'Could not sign in. Check your connection and try again.'** + String get couldNotSignIn; + + /// No description provided for @openingHome. + /// + /// In en, this message translates to: + /// **'Opening your home'** + String get openingHome; + + /// No description provided for @checkingSavedAccess. + /// + /// In en, this message translates to: + /// **'Checking your saved access…'** + String get checkingSavedAccess; + + /// No description provided for @findingHome. + /// + /// In en, this message translates to: + /// **'Finding your home'** + String get findingHome; + + /// No description provided for @chooseSignInMethod. + /// + /// In en, this message translates to: + /// **'Choose a sign-in method'** + String get chooseSignInMethod; + + /// No description provided for @ourConversation. + /// + /// In en, this message translates to: + /// **'Our conversation'** + String get ourConversation; + + /// No description provided for @playCorner. + /// + /// In en, this message translates to: + /// **'Play corner'** + String get playCorner; + + /// No description provided for @ourLittleHome. + /// + /// In en, this message translates to: + /// **'Our little home'** + String get ourLittleHome; + + /// No description provided for @chatUnread. + /// + /// In en, this message translates to: + /// **'Chat, {count} unread messages'** + String chatUnread(int count); + + /// No description provided for @viewHome. + /// + /// In en, this message translates to: + /// **'View Home'** + String get viewHome; + + /// No description provided for @retry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// No description provided for @unreadGoLatest. + /// + /// In en, this message translates to: + /// **'{count} unread · Go to latest'** + String unreadGoLatest(int count); + + /// No description provided for @messagePartner. + /// + /// In en, this message translates to: + /// **'Message your partner'** + String get messagePartner; + + /// No description provided for @sendMessage. + /// + /// In en, this message translates to: + /// **'Send message'** + String get sendMessage; + + /// No description provided for @retryMessage. + /// + /// In en, this message translates to: + /// **'Retry message'** + String get retryMessage; + + /// No description provided for @myDevices. + /// + /// In en, this message translates to: + /// **'My devices'** + String get myDevices; + + /// No description provided for @signOutDeviceTitle. + /// + /// In en, this message translates to: + /// **'Sign out {name}?'** + String signOutDeviceTitle(String name); + + /// No description provided for @cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// No description provided for @signOutDevice. + /// + /// In en, this message translates to: + /// **'Sign out device'** + String get signOutDevice; + + /// No description provided for @refreshDevices. + /// + /// In en, this message translates to: + /// **'Refresh devices'** + String get refreshDevices; + + /// No description provided for @deviceCodeCopied. + /// + /// In en, this message translates to: + /// **'Device login code copied'** + String get deviceCodeCopied; + + /// No description provided for @copyLoginCode. + /// + /// In en, this message translates to: + /// **'Copy login code'** + String get copyLoginCode; + + /// No description provided for @changeLanguage. + /// + /// In en, this message translates to: + /// **'Change language'** + String get changeLanguage; + + /// No description provided for @chat. + /// + /// In en, this message translates to: + /// **'Chat'** + String get chat; + + /// No description provided for @play. + /// + /// In en, this message translates to: + /// **'Play'** + String get play; + + /// No description provided for @home. + /// + /// In en, this message translates to: + /// **'Home'** + String get home; + + /// No description provided for @unreadFromPartner. + /// + /// In en, this message translates to: + /// **'{count} unread messages from your partner'** + String unreadFromPartner(int count); + + /// No description provided for @openChat. + /// + /// In en, this message translates to: + /// **'Open chat'** + String get openChat; + + /// No description provided for @loginNotSaved. + /// + /// In en, this message translates to: + /// **'This device could not save its login. Open Home for reconnect instructions.'** + String get loginNotSaved; + + /// No description provided for @saveRecoveryCodeHome. + /// + /// In en, this message translates to: + /// **'Save your recovery code in Home before leaving.'** + String get saveRecoveryCodeHome; + + /// No description provided for @gamesComing. + /// + /// In en, this message translates to: + /// **'A little corner for playing together.\nGames will arrive here later.'** + String get gamesComing; + + /// No description provided for @youAreHome. + /// + /// In en, this message translates to: + /// **'You are home together'** + String get youAreHome; + + /// No description provided for @connectedAsRole. + /// + /// In en, this message translates to: + /// **'This device is connected as the {role}.'** + String connectedAsRole(String role); + + /// No description provided for @homeId. + /// + /// In en, this message translates to: + /// **'Home ID: {id}'** + String homeId(String id); + + /// No description provided for @storageWarningRecovery. + /// + /// In en, this message translates to: + /// **'This device could not securely save its login. Keep the recovery code below in case the app closes.'** + String get storageWarningRecovery; + + /// No description provided for @storageWarningDevice. + /// + /// In en, this message translates to: + /// **'This device could not securely save its login. If the app closes, generate a new login code on another signed-in device to reconnect.'** + String get storageWarningDevice; + + /// No description provided for @couldNotReachRetry. + /// + /// In en, this message translates to: + /// **'Could not reach your server. Try again.'** + String get couldNotReachRetry; + + /// No description provided for @signOutDeviceHelp. + /// + /// In en, this message translates to: + /// **'This device will need a new login code to reconnect. Your other devices will stay signed in.'** + String get signOutDeviceHelp; + + /// No description provided for @devicesHelp. + /// + /// In en, this message translates to: + /// **'Keep your phone, tablet and computer connected to the same home. Only your own devices appear here.'** + String get devicesHelp; + + /// No description provided for @thisDevice. + /// + /// In en, this message translates to: + /// **'This device'** + String get thisDevice; + + /// No description provided for @addedDate. + /// + /// In en, this message translates to: + /// **'Added {date}'** + String addedDate(String date); + + /// No description provided for @addDeviceAction. + /// + /// In en, this message translates to: + /// **'Add a device'** + String get addDeviceAction; + + /// No description provided for @newDeviceCodeHelp. + /// + /// In en, this message translates to: + /// **'On your new device, choose Sign in on another device. Use the server address above and this code. Keep it private: it grants access as you.'** + String get newDeviceCodeHelp; + + /// No description provided for @singleUseExpires. + /// + /// In en, this message translates to: + /// **'Single use · Expires {date}'** + String singleUseExpires(String date); + + /// No description provided for @chatError. + /// + /// In en, this message translates to: + /// **'Chat could not be loaded. Check your connection and try again.'** + String get chatError; + + /// No description provided for @firstWords. + /// + /// In en, this message translates to: + /// **'Your first words together\nSend a little hello to start your shared journal.'** + String get firstWords; + + /// No description provided for @sending. + /// + /// In en, this message translates to: + /// **'Sending…'** + String get sending; + + /// No description provided for @notSent. + /// + /// In en, this message translates to: + /// **'Not sent'** + String get notSent; + + /// No description provided for @loading. + /// + /// In en, this message translates to: + /// **'Loading…'** + String get loading; + + /// No description provided for @loadEarlier. + /// + /// In en, this message translates to: + /// **'Load earlier messages'** + String get loadEarlier; + + /// No description provided for @read. + /// + /// In en, this message translates to: + /// **'Read'** + String get read; + + /// No description provided for @unread. + /// + /// In en, this message translates to: + /// **'Unread'** + String get unread; + + /// No description provided for @messageTooLong. + /// + /// In en, this message translates to: + /// **'Use up to 4000 characters.'** + String get messageTooLong; + + /// No description provided for @tryAgain. + /// + /// In en, this message translates to: + /// **'Try again'** + String get tryAgain; + + /// No description provided for @savedAccessError. + /// + /// In en, this message translates to: + /// **'Could not read or validate saved access on this device.'** + String get savedAccessError; + + /// No description provided for @serverAccessError. + /// + /// In en, this message translates to: + /// **'Could not reach your Pawmate server. Check your connection and retry.'** + String get serverAccessError; + + /// No description provided for @sessionRestoreError. + /// + /// In en, this message translates to: + /// **'Could not restore this session.'** + String get sessionRestoreError; + + /// No description provided for @requestFailed. + /// + /// In en, this message translates to: + /// **'The server could not complete this request. Try again.'** + String get requestFailed; + + /// No description provided for @invalidServerAddress. + /// + /// In en, this message translates to: + /// **'Enter a valid HTTPS server address. HTTP is available in debug builds only.'** + String get invalidServerAddress; + + /// No description provided for @invitationLinkHint. + /// + /// In en, this message translates to: + /// **'pawmate://pair?server=…&code=…'** + String get invitationLinkHint; + + /// No description provided for @myDeviceName. + /// + /// In en, this message translates to: + /// **'My device'** + String get myDeviceName; + + /// No description provided for @uploadAvatar. + /// + /// In en, this message translates to: + /// **'Upload avatar'** + String get uploadAvatar; + + /// No description provided for @changeAvatar. + /// + /// In en, this message translates to: + /// **'Change avatar'** + String get changeAvatar; + + /// No description provided for @avatarImages. + /// + /// In en, this message translates to: + /// **'Avatar images'** + String get avatarImages; + + /// No description provided for @avatarRequired. + /// + /// In en, this message translates to: + /// **'Choose an avatar before continuing.'** + String get avatarRequired; + + /// No description provided for @avatarUploadError. + /// + /// In en, this message translates to: + /// **'Could not read this image. Choose a PNG or JPEG under 10 MB.'** + String get avatarUploadError; + + /// No description provided for @nickname. + /// + /// In en, this message translates to: + /// **'Nickname'** + String get nickname; + + /// No description provided for @nicknameHint. + /// + /// In en, this message translates to: + /// **'What should your partner call you?'** + String get nicknameHint; + + /// No description provided for @nicknameInvalid. + /// + /// In en, this message translates to: + /// **'Use a nickname between 1 and 32 characters, without control characters.'** + String get nicknameInvalid; + + /// No description provided for @profileRejected. + /// + /// In en, this message translates to: + /// **'The server could not save your profile. Check your nickname and avatar.'** + String get profileRejected; + + /// No description provided for @yourProfile. + /// + /// In en, this message translates to: + /// **'You'** + String get yourProfile; + + /// No description provided for @partnerProfile. + /// + /// In en, this message translates to: + /// **'Your partner'** + String get partnerProfile; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart new file mode 100644 index 0000000..e5b0f1b --- /dev/null +++ b/lib/l10n/generated/app_localizations_en.dart @@ -0,0 +1,522 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get chooseLanguage => 'Language'; + + @override + String get english => 'English'; + + @override + String get chinese => '简体中文'; + + @override + String get homeHeadline => 'Your little home, together'; + + @override + String get homeIntro => 'Choose how you’d like to come in.'; + + @override + String get createInvitation => 'Create invitation'; + + @override + String get createInvitationDescription => + 'Start a home on your private server.'; + + @override + String get acceptInvitation => 'Accept invitation'; + + @override + String get acceptInvitationDescription => + 'Join your partner using their link.'; + + @override + String get restoreData => 'Restore data'; + + @override + String get restoreDataDescription => 'Lost access? Use your recovery code.'; + + @override + String get addDevice => 'Add device'; + + @override + String get addDeviceDescription => 'Keep your other devices signed in.'; + + @override + String get alreadyPaired => + 'Already paired? Add a device instead of creating a new invitation. Your home’s data stays on your server.'; + + @override + String get invitePartner => 'Invite your partner'; + + @override + String get chooseHomeAddress => 'Choose your home address'; + + @override + String get trustedServerHelp => + 'Use the URL or domain of the private server you trust.'; + + @override + String get serverUrlDomain => 'Server URL or domain'; + + @override + String get serverUrl => 'Server URL'; + + @override + String get serverUrlExisting => 'Existing server URL'; + + @override + String get serverUrlHint => 'https://pawmate.example.com'; + + @override + String get enterServerUrl => 'Enter your server URL.'; + + @override + String get makingRoom => 'Making a little room…'; + + @override + String get createInvitationAction => 'Create invitation'; + + @override + String get invitationCreatedStorageWarning => + 'Invitation created. Save the recovery code below; this device could not store it securely.'; + + @override + String get serverUnreachable => 'Could not reach the Pawmate server.'; + + @override + String get serverUnreachableShort => 'Could not reach the server.'; + + @override + String get invitationCopied => 'Invitation link copied'; + + @override + String get invitationReady => 'Your invitation is ready'; + + @override + String get homeConnected => 'Your home is connected!'; + + @override + String get shareInvitation => 'Share this one-time link with your partner.'; + + @override + String get connectedMemories => + 'You and your partner can start making memories together.'; + + @override + String get copyInvitation => 'Copy invitation'; + + @override + String expiresAt(String date) { + return 'Expires $date'; + } + + @override + String get pairingComplete => 'Pairing complete'; + + @override + String get checkPairingStatus => 'Check pairing status'; + + @override + String get waitingForPartner => 'Waiting for your partner to accept…'; + + @override + String get recoveryCodeTitle => 'Your recovery code'; + + @override + String get recoveryCodeHelp => + 'Keep this code somewhere safe. It can restore your home after losing a device.'; + + @override + String get copyCode => 'Copy code'; + + @override + String get recoveryCodeCopied => 'Recovery code copied'; + + @override + String get anInvitationForTwo => 'An invitation for two'; + + @override + String get invitationPasteHelp => + 'Paste the complete link your partner sent. You’ll review the server address before joining.'; + + @override + String get invitationLink => 'Invitation link'; + + @override + String get pasteInvitationLinkFirst => + 'Copy your partner’s invitation link first.'; + + @override + String get clipboardUnavailable => + 'Clipboard unavailable. Paste or type the link into the field.'; + + @override + String get pasteLink => 'Paste link'; + + @override + String get reviewInvitation => 'Review invitation'; + + @override + String get invalidInvitationLink => 'This invitation link is not valid.'; + + @override + String get incompleteInvitation => 'This invitation link is incomplete.'; + + @override + String get joinHome => 'Join your little home'; + + @override + String get invited => 'You have been invited'; + + @override + String get acceptOneTimeHelp => + 'Accepting this one-time invitation will pair your device with your partner.'; + + @override + String get privateServer => 'Private server'; + + @override + String get joining => 'Joining…'; + + @override + String get couldNotAccept => 'Could not accept this invitation.'; + + @override + String get incompleteCredentials => + 'The server did not return complete pairing credentials.'; + + @override + String get restoreHome => 'Restore your home'; + + @override + String get reconnectTitle => 'Let’s reconnect you'; + + @override + String get reconnectHelp => + 'This device needs a new login. If another device is still signed in, use its device login code. Recovery is for lost access and signs out all your other devices.'; + + @override + String get deviceCodeSignIn => 'Sign in with a device login code'; + + @override + String get restoreExistingHome => 'Restoring an existing home?'; + + @override + String get recoveryHelp => + 'Use your recovery code if you have lost access. Recovery signs out all your devices and replaces the recovery code; your partner stays signed in. To add a device, use a device login code instead.'; + + @override + String get recoveryCode => 'Recovery code'; + + @override + String get restoringAccess => 'Restoring access…'; + + @override + String get restoreAccess => 'Restore access'; + + @override + String get invalidRecoveryCode => 'That recovery code is not valid anymore.'; + + @override + String get notPaired => + 'This server does not have a completed couple pairing.'; + + @override + String get couldNotRestore => 'Could not restore this home. Try again.'; + + @override + String get bringHome => 'Bring your home along'; + + @override + String get signInAnotherDevice => 'Sign in on another device'; + + @override + String get deviceSignInHelp => + 'On your signed-in phone, tablet or computer, open My devices and choose Add a device. Enter its server address and login code here. Your other devices will stay signed in.'; + + @override + String get deviceLoginCode => 'Device login code'; + + @override + String get deviceName => 'Device name'; + + @override + String get enterServerAddress => 'Enter your server address.'; + + @override + String get enterLoginCode => 'Enter the login code.'; + + @override + String get deviceNameLength => 'Use a name between 1 and 80 characters.'; + + @override + String get signingIn => 'Signing in…'; + + @override + String get signIn => 'Sign in'; + + @override + String get back => 'Back'; + + @override + String get invalidDeviceCode => + 'This login code expired or was already used. Generate a new one on your signed-in device.'; + + @override + String get couldNotSignIn => + 'Could not sign in. Check your connection and try again.'; + + @override + String get openingHome => 'Opening your home'; + + @override + String get checkingSavedAccess => 'Checking your saved access…'; + + @override + String get findingHome => 'Finding your home'; + + @override + String get chooseSignInMethod => 'Choose a sign-in method'; + + @override + String get ourConversation => 'Our conversation'; + + @override + String get playCorner => 'Play corner'; + + @override + String get ourLittleHome => 'Our little home'; + + @override + String chatUnread(int count) { + return 'Chat, $count unread messages'; + } + + @override + String get viewHome => 'View Home'; + + @override + String get retry => 'Retry'; + + @override + String unreadGoLatest(int count) { + return '$count unread · Go to latest'; + } + + @override + String get messagePartner => 'Message your partner'; + + @override + String get sendMessage => 'Send message'; + + @override + String get retryMessage => 'Retry message'; + + @override + String get myDevices => 'My devices'; + + @override + String signOutDeviceTitle(String name) { + return 'Sign out $name?'; + } + + @override + String get cancel => 'Cancel'; + + @override + String get signOutDevice => 'Sign out device'; + + @override + String get refreshDevices => 'Refresh devices'; + + @override + String get deviceCodeCopied => 'Device login code copied'; + + @override + String get copyLoginCode => 'Copy login code'; + + @override + String get changeLanguage => 'Change language'; + + @override + String get chat => 'Chat'; + + @override + String get play => 'Play'; + + @override + String get home => 'Home'; + + @override + String unreadFromPartner(int count) { + return '$count unread messages from your partner'; + } + + @override + String get openChat => 'Open chat'; + + @override + String get loginNotSaved => + 'This device could not save its login. Open Home for reconnect instructions.'; + + @override + String get saveRecoveryCodeHome => + 'Save your recovery code in Home before leaving.'; + + @override + String get gamesComing => + 'A little corner for playing together.\nGames will arrive here later.'; + + @override + String get youAreHome => 'You are home together'; + + @override + String connectedAsRole(String role) { + return 'This device is connected as the $role.'; + } + + @override + String homeId(String id) { + return 'Home ID: $id'; + } + + @override + String get storageWarningRecovery => + 'This device could not securely save its login. Keep the recovery code below in case the app closes.'; + + @override + String get storageWarningDevice => + 'This device could not securely save its login. If the app closes, generate a new login code on another signed-in device to reconnect.'; + + @override + String get couldNotReachRetry => 'Could not reach your server. Try again.'; + + @override + String get signOutDeviceHelp => + 'This device will need a new login code to reconnect. Your other devices will stay signed in.'; + + @override + String get devicesHelp => + 'Keep your phone, tablet and computer connected to the same home. Only your own devices appear here.'; + + @override + String get thisDevice => 'This device'; + + @override + String addedDate(String date) { + return 'Added $date'; + } + + @override + String get addDeviceAction => 'Add a device'; + + @override + String get newDeviceCodeHelp => + 'On your new device, choose Sign in on another device. Use the server address above and this code. Keep it private: it grants access as you.'; + + @override + String singleUseExpires(String date) { + return 'Single use · Expires $date'; + } + + @override + String get chatError => + 'Chat could not be loaded. Check your connection and try again.'; + + @override + String get firstWords => + 'Your first words together\nSend a little hello to start your shared journal.'; + + @override + String get sending => 'Sending…'; + + @override + String get notSent => 'Not sent'; + + @override + String get loading => 'Loading…'; + + @override + String get loadEarlier => 'Load earlier messages'; + + @override + String get read => 'Read'; + + @override + String get unread => 'Unread'; + + @override + String get messageTooLong => 'Use up to 4000 characters.'; + + @override + String get tryAgain => 'Try again'; + + @override + String get savedAccessError => + 'Could not read or validate saved access on this device.'; + + @override + String get serverAccessError => + 'Could not reach your Pawmate server. Check your connection and retry.'; + + @override + String get sessionRestoreError => 'Could not restore this session.'; + + @override + String get requestFailed => + 'The server could not complete this request. Try again.'; + + @override + String get invalidServerAddress => + 'Enter a valid HTTPS server address. HTTP is available in debug builds only.'; + + @override + String get invitationLinkHint => 'pawmate://pair?server=…&code=…'; + + @override + String get myDeviceName => 'My device'; + + @override + String get uploadAvatar => 'Upload avatar'; + + @override + String get changeAvatar => 'Change avatar'; + + @override + String get avatarImages => 'Avatar images'; + + @override + String get avatarRequired => 'Choose an avatar before continuing.'; + + @override + String get avatarUploadError => + 'Could not read this image. Choose a PNG or JPEG under 10 MB.'; + + @override + String get nickname => 'Nickname'; + + @override + String get nicknameHint => 'What should your partner call you?'; + + @override + String get nicknameInvalid => + 'Use a nickname between 1 and 32 characters, without control characters.'; + + @override + String get profileRejected => + 'The server could not save your profile. Check your nickname and avatar.'; + + @override + String get yourProfile => 'You'; + + @override + String get partnerProfile => 'Your partner'; +} diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart new file mode 100644 index 0000000..1efe325 --- /dev/null +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -0,0 +1,492 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get chooseLanguage => '选择语言'; + + @override + String get english => 'English'; + + @override + String get chinese => '简体中文'; + + @override + String get homeHeadline => '两个人的小家'; + + @override + String get homeIntro => '选择一种方式进入'; + + @override + String get createInvitation => '创建邀请'; + + @override + String get createInvitationDescription => '在你的私人服务器上创建小家'; + + @override + String get acceptInvitation => '接受邀请'; + + @override + String get acceptInvitationDescription => '通过伴侣发送的链接加入'; + + @override + String get restoreData => '恢复数据'; + + @override + String get restoreDataDescription => '无法登录时使用恢复码'; + + @override + String get addDevice => '添加设备'; + + @override + String get addDeviceDescription => '让其他设备也保持登录'; + + @override + String get alreadyPaired => '如果已经配对,可直接添加设备,无需重新创建邀请,小家的数据仍保存在你的服务器上'; + + @override + String get invitePartner => '邀请伴侣'; + + @override + String get chooseHomeAddress => '设置小家的地址'; + + @override + String get trustedServerHelp => '填写你信任的私人服务器网址或域名'; + + @override + String get serverUrlDomain => '服务器网址或域名'; + + @override + String get serverUrl => '服务器网址'; + + @override + String get serverUrlExisting => '已有服务器网址'; + + @override + String get serverUrlHint => 'https://pawmate.example.com'; + + @override + String get enterServerUrl => '请输入服务器网址'; + + @override + String get makingRoom => '正在准备小家…'; + + @override + String get createInvitationAction => '创建邀请'; + + @override + String get invitationCreatedStorageWarning => '邀请已创建,请保存下方恢复码,此设备未能安全保存它'; + + @override + String get serverUnreachable => '无法连接到 Pawmate 服务器'; + + @override + String get serverUnreachableShort => '无法连接到服务器'; + + @override + String get invitationCopied => '邀请链接已复制'; + + @override + String get invitationReady => '邀请已准备好'; + + @override + String get homeConnected => '小家已连接'; + + @override + String get shareInvitation => '把这个一次性链接分享给伴侣'; + + @override + String get connectedMemories => '现在可以和伴侣一起留下回忆了'; + + @override + String get copyInvitation => '复制邀请'; + + @override + String expiresAt(String date) { + return '有效期至 $date'; + } + + @override + String get pairingComplete => '配对完成'; + + @override + String get checkPairingStatus => '检查配对状态'; + + @override + String get waitingForPartner => '等待伴侣接受邀请…'; + + @override + String get recoveryCodeTitle => '你的小家恢复码'; + + @override + String get recoveryCodeHelp => '请妥善保存此恢复码,设备丢失后可用它恢复小家'; + + @override + String get copyCode => '复制代码'; + + @override + String get recoveryCodeCopied => '恢复码已复制'; + + @override + String get anInvitationForTwo => '一份双人邀请'; + + @override + String get invitationPasteHelp => '粘贴伴侣发来的完整链接,加入前可以先核对服务器地址'; + + @override + String get invitationLink => '邀请链接'; + + @override + String get pasteInvitationLinkFirst => '请先复制伴侣发来的邀请链接'; + + @override + String get clipboardUnavailable => '剪贴板不可用,请在下方粘贴或输入链接'; + + @override + String get pasteLink => '粘贴链接'; + + @override + String get reviewInvitation => '查看邀请'; + + @override + String get invalidInvitationLink => '邀请链接无效'; + + @override + String get incompleteInvitation => '邀请链接不完整'; + + @override + String get joinHome => '加入小家'; + + @override + String get invited => '你收到了一份邀请'; + + @override + String get acceptOneTimeHelp => '接受这份一次性邀请后,你的设备将与伴侣完成配对'; + + @override + String get privateServer => '私人服务器'; + + @override + String get joining => '正在加入…'; + + @override + String get couldNotAccept => '无法接受这份邀请'; + + @override + String get incompleteCredentials => '服务器返回的配对凭证不完整'; + + @override + String get restoreHome => '恢复小家'; + + @override + String get reconnectTitle => '重新连接小家'; + + @override + String get reconnectHelp => + '此设备需要重新登录,如果其他设备仍处于登录状态,请使用设备登录码,恢复码适用于无法访问其他设备的情况,并会让其他设备退出登录'; + + @override + String get deviceCodeSignIn => '使用设备登录码登录'; + + @override + String get restoreExistingHome => '需要恢复已有的小家吗'; + + @override + String get recoveryHelp => + '无法登录时使用恢复码,恢复后所有设备都会退出登录,恢复码也会更新,伴侣仍保持登录,添加设备请使用设备登录码'; + + @override + String get recoveryCode => '恢复码'; + + @override + String get restoringAccess => '正在恢复访问…'; + + @override + String get restoreAccess => '恢复访问'; + + @override + String get invalidRecoveryCode => '恢复码已失效'; + + @override + String get notPaired => '此服务器上还没有完成配对的小家'; + + @override + String get couldNotRestore => '无法恢复小家,请重试'; + + @override + String get bringHome => '在新设备上登录'; + + @override + String get signInAnotherDevice => '登录另一台设备'; + + @override + String get deviceSignInHelp => + '在已登录的手机、平板或电脑上打开“我的设备”并选择“添加设备”,然后在这里输入服务器地址和登录码,其他设备会保持登录'; + + @override + String get deviceLoginCode => '设备登录码'; + + @override + String get deviceName => '设备名称'; + + @override + String get enterServerAddress => '请输入服务器地址'; + + @override + String get enterLoginCode => '请输入登录码'; + + @override + String get deviceNameLength => '设备名称长度需为 1 到 80 个字符'; + + @override + String get signingIn => '正在登录…'; + + @override + String get signIn => '登录'; + + @override + String get back => '返回'; + + @override + String get invalidDeviceCode => '登录码已过期或已使用,请在已登录设备上重新生成'; + + @override + String get couldNotSignIn => '无法登录,请检查网络后重试'; + + @override + String get openingHome => '正在打开小家'; + + @override + String get checkingSavedAccess => '正在检查已保存的登录信息…'; + + @override + String get findingHome => '正在寻找小家'; + + @override + String get chooseSignInMethod => '选择登录方式'; + + @override + String get ourConversation => '我们的聊天'; + + @override + String get playCorner => '游戏角'; + + @override + String get ourLittleHome => '我们的小家'; + + @override + String chatUnread(int count) { + return '聊天,有 $count 条未读消息'; + } + + @override + String get viewHome => '查看小家'; + + @override + String get retry => '重试'; + + @override + String unreadGoLatest(int count) { + return '有 $count 条未读 · 查看最新消息'; + } + + @override + String get messagePartner => '给伴侣发消息'; + + @override + String get sendMessage => '发送消息'; + + @override + String get retryMessage => '重试发送'; + + @override + String get myDevices => '我的设备'; + + @override + String signOutDeviceTitle(String name) { + return '让 $name 退出登录'; + } + + @override + String get cancel => '取消'; + + @override + String get signOutDevice => '退出此设备'; + + @override + String get refreshDevices => '刷新设备'; + + @override + String get deviceCodeCopied => '设备登录码已复制'; + + @override + String get copyLoginCode => '复制登录码'; + + @override + String get changeLanguage => '切换语言'; + + @override + String get chat => '聊天'; + + @override + String get play => '游戏'; + + @override + String get home => '小家'; + + @override + String unreadFromPartner(int count) { + return '有 $count 条伴侣发来的未读消息'; + } + + @override + String get openChat => '打开聊天'; + + @override + String get loginNotSaved => '此设备未能保存登录信息,请打开小家查看重新连接说明'; + + @override + String get saveRecoveryCodeHome => '离开前请在小家中保存恢复码'; + + @override + String get gamesComing => '一起玩耍的小角落\n游戏功能即将到来'; + + @override + String get youAreHome => '你们已经回到小家'; + + @override + String connectedAsRole(String role) { + return '此设备以$role身份连接'; + } + + @override + String homeId(String id) { + return '小家编号:$id'; + } + + @override + String get storageWarningRecovery => '此设备未能安全保存登录信息,请保存下方恢复码,以免应用关闭后无法登录'; + + @override + String get storageWarningDevice => + '此设备未能安全保存登录信息,若应用关闭,请在其他已登录设备上生成新的登录码以重新连接'; + + @override + String get couldNotReachRetry => '无法连接服务器,请重试'; + + @override + String get signOutDeviceHelp => '此设备需要新的登录码才能重新连接,其他设备会保持登录'; + + @override + String get devicesHelp => '让手机、平板和电脑连接到同一个小家,这里只会显示你自己的设备'; + + @override + String get thisDevice => '当前设备'; + + @override + String addedDate(String date) { + return '添加于 $date'; + } + + @override + String get addDeviceAction => '添加设备'; + + @override + String get newDeviceCodeHelp => + '在新设备上选择“登录另一台设备”,使用上方服务器地址和此登录码,请妥善保管,它可以访问你的账户'; + + @override + String singleUseExpires(String date) { + return '单次使用 · 有效期至 $date'; + } + + @override + String get chatError => '聊天暂时无法加载,请检查网络后重试'; + + @override + String get firstWords => '你们的第一句话\n发送一声问候,开启两个人的聊天日记'; + + @override + String get sending => '正在发送…'; + + @override + String get notSent => '发送失败'; + + @override + String get loading => '正在加载…'; + + @override + String get loadEarlier => '加载更早的消息'; + + @override + String get read => '已读'; + + @override + String get unread => '未读'; + + @override + String get messageTooLong => '消息最多可输入 4000 个字符'; + + @override + String get tryAgain => '重试'; + + @override + String get savedAccessError => '无法读取或验证此设备上保存的登录信息'; + + @override + String get serverAccessError => '无法连接到 Pawmate 服务器,请检查网络后重试'; + + @override + String get sessionRestoreError => '无法恢复此登录状态'; + + @override + String get requestFailed => '服务器暂时无法完成请求,请重试'; + + @override + String get invalidServerAddress => '请输入有效的 HTTPS 服务器地址。HTTP 仅在调试版本中可用。'; + + @override + String get invitationLinkHint => 'pawmate://pair?server=…&code=…'; + + @override + String get myDeviceName => '我的设备'; + + @override + String get uploadAvatar => '上传头像'; + + @override + String get changeAvatar => '更换头像'; + + @override + String get avatarImages => '头像图片'; + + @override + String get avatarRequired => '请先选择头像'; + + @override + String get avatarUploadError => '无法读取这张图片,请选择小于 10 MB 的 PNG 或 JPEG 图片'; + + @override + String get nickname => '昵称'; + + @override + String get nicknameHint => '想让伴侣怎么称呼你'; + + @override + String get nicknameInvalid => '请输入 1 到 32 个字符的昵称,不要包含控制字符'; + + @override + String get profileRejected => '服务器未能保存个人资料,请检查昵称和头像'; + + @override + String get yourProfile => '你'; + + @override + String get partnerProfile => '你的伴侣'; +} diff --git a/lib/l10n/locale_controller.dart b/lib/l10n/locale_controller.dart new file mode 100644 index 0000000..937ee2b --- /dev/null +++ b/lib/l10n/locale_controller.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Restores and persists the user's interface language preference. +class LocaleController extends ChangeNotifier { + static const _key = 'pawmate.locale'; + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + Locale _locale = const Locale('en'); + + Locale get locale => _locale; + + Future restore() async { + final value = await _storage.read(key: _key); + if (value == 'en' || value == 'zh') { + _locale = Locale(value!); + notifyListeners(); + } + } + + Future setLocale(Locale locale) async { + if (locale.languageCode != 'en' && locale.languageCode != 'zh') return; + _locale = Locale(locale.languageCode); + notifyListeners(); + await _storage.write(key: _key, value: _locale.languageCode); + } +} + +/// Makes the app-wide locale controller available to onboarding screens. +class LocaleControllerScope extends InheritedNotifier { + const LocaleControllerScope({ + required super.notifier, + required super.child, + super.key, + }); + + static LocaleController of(BuildContext context) => context + .dependOnInheritedWidgetOfExactType()! + .notifier!; +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..6c48322 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,121 +1,92 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:app_links/app_links.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'design/theme/pawmate_theme.dart'; +import 'l10n/generated/app_localizations.dart'; +import 'l10n/locale_controller.dart'; +import 'features/pairing/invitee_accept_page.dart'; +import 'features/pairing/invitation_link.dart'; +import 'features/pairing/pairing_session_gate.dart'; void main() { - runApp(const MyApp()); + WidgetsFlutterBinding.ensureInitialized(); + runApp(const PawmateApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +/// Root widget for the Pawmate Flutter application. +class PawmateApp extends StatefulWidget { + const PawmateApp({super.key}); - // This widget is the root of your application. @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } + State createState() => _PawmateAppState(); } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); +class _PawmateAppState extends State { + final _localeController = LocaleController(); + final _navigatorKey = GlobalKey(); + final _appLinks = AppLinks(); + String? _lastOpenedInvite; + StreamSubscription? _linkSubscription; - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". + @override + void initState() { + super.initState(); + _localeController.addListener(_localeChanged); + _localeController.restore(); + _linkSubscription = _appLinks.uriLinkStream.listen(_openInviteLink); + } - final String title; + void _localeChanged() => setState(() {}); @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; + void dispose() { + _linkSubscription?.cancel(); + _localeController + ..removeListener(_localeChanged) + ..dispose(); + super.dispose(); + } - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; + void _openInviteLink(Uri uri) { + try { + InvitationLink.parse(uri.toString()); + } on FormatException { + return; + } + if (_lastOpenedInvite == uri.toString()) { + return; + } + _lastOpenedInvite = uri.toString(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => InviteeAcceptPage(inviteUri: uri), + ), + ); }); } + /// Configures the app theme and restores a session or offers access methods. @override Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), + return MaterialApp( + navigatorKey: _navigatorKey, + title: 'Pawmate', + theme: buildPawmateTheme(), + locale: _localeController.locale, + supportedLocales: const [Locale('en'), Locale('zh')], + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: LocaleControllerScope( + notifier: _localeController, + child: const PairingSessionGate(), ), ); } diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..b5de469 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "pawmate") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.pawmate") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..1a0f06d --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..0601058 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + flutter_secure_storage_linux + gtk +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..1725a80 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "pawmate"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "pawmate"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..a760ca5 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,16 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import file_selector_macos +import flutter_secure_storage_darwin + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..49b815c --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* pawmate.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "pawmate.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* pawmate.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* pawmate.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pawmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pawmate"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pawmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pawmate"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pawmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pawmate"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c9553c5 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..e146039 --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "icons_launcher" + } +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..6cb14d5 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..06cb1c5 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..94b1b25 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..b08d82a Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..8209ae3 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..b3799fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..bd99a77 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..4799662 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = pawmate + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..566fcb1 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..625af03 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock index 582d01a..18e7848 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,54 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + app_links: + dependency: "direct main" + description: + name: app_links + sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea + url: "https://pub.dev" + source: hosted + version: "7.2.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -33,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -41,6 +97,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -57,6 +129,86 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+6" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" flutter: dependency: "direct main" description: flutter @@ -70,11 +222,149 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8 + url: "https://pub.dev" + source: hosted + version: "11.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21 + url: "https://pub.dev" + source: hosted + version: "0.4.3" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea + url: "https://pub.dev" + source: hosted + version: "3.0.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + icons_launcher: + dependency: "direct dev" + description: + name: icons_launcher + sha256: b42b2f9b10e58d6a973f71293f00a1f0572595e5e50d676c53048e464f78cb7d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image: + dependency: transitive + description: + name: image + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 + url: "https://pub.dev" + source: hosted + version: "4.10.1" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" leak_tracker: dependency: transitive description: @@ -107,6 +397,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -131,6 +429,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -139,6 +453,94 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" sky_engine: dependency: transitive description: flutter @@ -192,6 +594,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" vector_math: dependency: transitive description: @@ -204,10 +622,42 @@ packages: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "3.1.4" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index db5b3a8..4aef498 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,14 +30,21 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + http: ^1.5.0 + app_links: ^7.2.1 + flutter_secure_storage: ^11.2.0 + file_selector: ^1.1.0 dev_dependencies: flutter_test: sdk: flutter + icons_launcher: ^3.1.0 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -51,6 +58,7 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in @@ -60,6 +68,22 @@ flutter: # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg + +icons_launcher: + image_path: assets/pawmate.png + platforms: + android: + enable: true + ios: + enable: true + web: + enable: true + linux: + enable: true + macos: + enable: true + windows: + enable: true # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..1b77988 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1 @@ +/pawmate.db \ No newline at end of file diff --git a/server/README.md b/server/README.md index 6202fc7..8ab7b0a 100644 --- a/server/README.md +++ b/server/README.md @@ -1,8 +1,7 @@ # Pawmate server -Gin HTTP API for a private Pawmate instance. The initial endpoints allow the -Flutter client to verify a configured instance URL before registration and -couple pairing are added. +Gin HTTP API for a private Pawmate instance. It exposes instance discovery and +the first two-person pairing flow. Pairing state is persisted in SQLite. ## Run locally @@ -17,15 +16,197 @@ curl http://localhost:8080/healthz curl http://localhost:8080/api/v1/instance ``` +The pairing API exposes: + +```text +POST /api/v1/pairing/invites +GET /api/v1/pairing/invites/status +POST /api/v1/pairing/invites/redeem +POST /api/v1/pairing/recover +GET /api/v1/pairing/session +GET /api/v1/chat/messages +POST /api/v1/chat/messages +POST /api/v1/chat/read +GET /api/v1/pairing/devices +DELETE /api/v1/pairing/devices/:id +POST /api/v1/pairing/devices/login-codes +POST /api/v1/pairing/devices/login-codes/redeem +``` + +Create an invitation by sending the configured server URL: + +```bash +curl -X POST http://localhost:8080/api/v1/pairing/invites \ + -H 'content-type: application/json' \ + -d '{"server_url":"http://localhost:8080"}' +``` + +The response contains a one-time `invite_url`, an `inviter_token` for status +checks, and a `recovery_code`. The invitee opens the custom link in Pawmate and +accepts it; the app redeems the code and receives that member's access token and +recovery code. Invitation codes expire after ten minutes and can be redeemed +only once. + +Pairing state is stored in `pawmate.db` in the server's current working +directory. Override the path with `PAWMATE_DATABASE_PATH`. The database stores +SHA-256 hashes of invitation codes, access tokens, and recovery codes rather +than their raw values. Keep the database on persistent storage and include it +in server backups. Stop the server before copying the database file. + +Each member should save their recovery code outside the app. After reinstall, +enter the same server URL and recovery code in the restore section. The server +revokes all of that member's device sessions and outstanding device login codes, +then issues a new access token and recovery code. The old recovery code can no +longer be used. The partner's sessions remain active. Recovery is for lost access; +use a device login code to add a device while keeping existing devices signed in. +A recovery code belongs to one member; the two members should keep separate codes. + +On app startup, the client calls `GET /api/v1/pairing/session` with its saved +bearer token. The endpoint accepts any active device token and returns +the role, pair ID, and pairing state. Invalid or revoked tokens receive HTTP +401 and should open recovery; network failures should offer retry without +treating the credentials as invalid. + +## Sign in on multiple devices + +Each phone, tablet, computer, or browser installation has its own access token. +The two members share one pair ID, but each member can have multiple sessions. +The server automatically migrates tokens from the previous single-token schema +on startup, so existing installations remain signed in. Migration runs once; +revoked legacy tokens are never re-imported on restart. + +1. On an already signed-in device, open **My devices** on the home page and + choose **Add a device**. +2. On the new device, choose **Sign in on another device** and enter the same + server URL, the device login code, and a device name. +3. The new device saves its own token in platform secure storage. Existing + sessions and the member's recovery code remain unchanged. + +`POST /pairing/devices/login-codes` requires a paired device's bearer token. +It returns `code` and `expires_at`. The code expires after ten minutes and can +be redeemed once. Creating another code on the same device replaces its previous +code. The server stores only the code hash. + +`POST /pairing/devices/login-codes/redeem` accepts +`{"code":"...","device_name":"My tablet"}` without an access token. It returns +`access_token`, `pair_id`, and `role`; it does not disclose the recovery code. +Keep device login codes private: possession grants access as the issuing member. + +`GET /pairing/devices` returns only the authenticated member's devices with +`id`, `name`, `created_at`, and `current`. No tokens or recovery codes are returned. +`DELETE /pairing/devices/:id` removes a session owned by that member and all +login codes it issued. A removed device gets HTTP 401 when it next authenticates. +The Flutter UI allows removing other devices and refreshing the list; it does +not remove the current device. All paths in this section use the `/api/v1` prefix. + +Recovery codes are not synchronized between devices. An additional device stores +no recovery code; retain the member's current recovery code outside the app. +Device revocation is enforced on server requests, not through push notifications. + +## Text messages and read receipts + +The couple space opens on the Chat tab. Text messages and read progress are +persisted in the same SQLite database as pairing and device sessions. Every chat +endpoint requires an active, paired device's Bearer token. The server derives the +pair and sender from that token, never from client-supplied identity fields. + +- `POST /api/v1/chat/messages` accepts `client_id` (1–64 bytes) and `text` + (1–4000 Unicode code points after trimming). Repeating the same member's + `client_id` returns the original message; changing its text returns HTTP 409. +- `GET /api/v1/chat/messages` returns the most recent 50 messages in ascending + server-ID order. Use `before_id` for earlier history or `after_id` for forward + updates; they are mutually exclusive. `limit` must be between 1 and 100. + `has_more` describes the requested direction's additional pages. +- Every history response includes `latest_id`, `read_id`, `partner_read_id` and + `unread_count`. Only messages from the other member count as unread. +- `POST /api/v1/chat/read` accepts `message_id`. The ID must belong to this pair. + A member's read cursor only advances; reading on a tablet also clears the + corresponding unread messages on that member's phone and computer. + +Read receipts are cumulative: acknowledging message N marks earlier messages +read too. The client acknowledges incoming messages only after they are rendered +inside the visible chat viewport while the application is foregrounded. Polling, +opening Home, and receiving a message on a hidden tab do not mark it read. +Outgoing bubbles distinguish sending, failed (retryable), unread and read states. + +The first version synchronizes every two seconds while the app is foregrounded, +including receipt-only updates and this member's messages sent from other devices. +It pauses polling in the background and catches up after resume. Unread alerts +are an in-app Chat badge and a content-free reminder on other tabs. OS/background +push notifications, attachment messages and durable offline outboxes are not yet +implemented. Drafts and failed outgoing messages survive tab switches but are +currently kept in memory; confirmed messages survive app and server restarts. + +Chat responses use `Cache-Control: no-store`. Message text is stored in SQLite; +include it when protecting and backing up the instance database. This is not +end-to-end encrypted messaging. Requests do not put message text or tokens in URLs +and the request logger does not record message bodies. + For the Android emulator, configure the Flutter client with `http://10.0.2.2:8080`; `10.0.2.2` maps to the development machine's loopback interface from inside the emulator. +Release builds require HTTPS instance URLs. Android permits cleartext HTTP only +in debug builds; use it for local development or trusted test networks. HTTP +does not provide transport encryption. Both participants must be able to reach +the HTTPS instance URL used in release invitations. + ## Configuration +### Member nicknames and avatars + +Creating and accepting invitations now requires a `profile` object alongside +the existing request fields: + +```json +{"profile":{"nickname":"Ash","avatar_base64":""}} +``` + +Nicknames are trimmed and contain 1–32 Unicode characters without control +characters. Avatars must be valid PNG/JPEG images, at most 256 KiB decoded from +base64, with dimensions at most 512×512. The client center-crops selected images +to a 256×256 PNG; source files must be under 10 MiB. The server validates images +and re-encodes them to PNG before saving, stripping metadata. + +The additive `member_profiles` table stores one profile per member. Invitation +creation and acceptance save profiles in the same transaction as credentials +and pairing, so rejected uploads cannot consume an invitation. Replacing an +expired, unpaired invitation removes the old inviter identity. Emergency recovery +and device login preserve profiles. Authenticated `GET /api/v1/pairing/session` +returns `profile` and, once paired, `partner`; anonymous callers cannot fetch them. + +Existing paired databases remain usable with absent profiles; this change does +not invent nicknames or overwrite an existing relationship. New invitation +requests from older clients without a profile are rejected. Upgrade the server +and client together. Profile editing for existing couples is not implemented yet. + +### Persistence architecture + +The server uses [GORM](https://gorm.io/docs/) for pairing, device sessions, +device login codes, chat history and read cursors. Storage models live in +`internal/pairing/models.go`, separate from the API response types. Queries bind +client values as parameters; the ORM's SQL logger is disabled to avoid logging +private content or credential digests. + +GORM's official SQLite dialector reuses the existing `modernc.org/sqlite` +connection, so the server remains runnable with `CGO_ENABLED=0`. The dialector +has an indirect `go-sqlite3` dependency, but Pawmate does not open that driver. +SQLite still stores only credential hashes, not recoverable plaintext codes. + +Existing `pawmate.db` files are reused in place: table names, integer timestamp +units, message IDs, unique constraints and cascading device-code deletion are +unchanged. Reviewed additive SQL creates missing tables/indexes; startup does +not run `AutoMigrate` or rebuild existing tables. The legacy-token import marker +is retained so revoked devices cannot reappear on restart. Multi-step operations +use GORM transactions, including invite acceptance, credential recovery, +device-code redemption and monotonic read-cursor updates. + +Back up the database before upgrading. Do not delete it to switch to GORM. + Copy `.env.example` into your process environment before starting the server. -The environment variables control the port and the stable identity shown to -clients. Production instances should set a real HTTPS `PAWMATE_PUBLIC_URL`. +The environment variables control the port, database path, and stable identity +shown to clients. Production instances should set a real HTTPS +`PAWMATE_PUBLIC_URL` and keep the SQLite database on a persistent volume. ## Verify diff --git a/server/cmd/pawmate-server/main.go b/server/cmd/pawmate-server/main.go index edc753d..a8678a4 100644 --- a/server/cmd/pawmate-server/main.go +++ b/server/cmd/pawmate-server/main.go @@ -12,15 +12,22 @@ import ( "pawmate/server/internal/config" "pawmate/server/internal/httpapi" + "pawmate/server/internal/pairing" ) func main() { cfg := config.Load() logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})) + pairingService, err := pairing.OpenService(cfg.DatabasePath) + if err != nil { + logger.Error("could not open pairing database", "error", err) + os.Exit(1) + } + defer pairingService.Close() server := &http.Server{ Addr: ":" + cfg.Port, - Handler: httpapi.NewRouter(cfg, logger), + Handler: httpapi.NewRouter(cfg, logger, pairingService), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, diff --git a/server/go.mod b/server/go.mod index f7f4889..81b1811 100644 --- a/server/go.mod +++ b/server/go.mod @@ -2,12 +2,18 @@ module pawmate/server go 1.26.0 -require github.com/gin-gonic/gin v1.11.0 +require ( + github.com/gin-gonic/gin v1.11.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.2 + modernc.org/sqlite v1.59.0 +) require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -15,25 +21,34 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.9 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect ) diff --git a/server/go.sum b/server/go.sum index a25fff5..ecaed9b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -7,6 +7,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -28,18 +30,32 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -48,6 +64,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -65,24 +83,55 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/server/internal/config/config.go b/server/internal/config/config.go index ce5bd38..80c1b20 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -9,6 +9,7 @@ type Config struct { InstanceName string Port string PublicURL string + DatabasePath string } // Load reads configuration and applies development-friendly defaults. @@ -19,6 +20,7 @@ func Load() Config { InstanceName: envOrDefault("PAWMATE_INSTANCE_NAME", "Our Little Home"), Port: envOrDefault("PAWMATE_PORT", "8080"), PublicURL: os.Getenv("PAWMATE_PUBLIC_URL"), + DatabasePath: envOrDefault("PAWMATE_DATABASE_PATH", "pawmate.db"), } } diff --git a/server/internal/httpapi/chat.go b/server/internal/httpapi/chat.go new file mode 100644 index 0000000..09e599c --- /dev/null +++ b/server/internal/httpapi/chat.go @@ -0,0 +1,83 @@ +package httpapi + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "pawmate/server/internal/pairing" +) + +// chatError returns a safe error code without logging message text or credentials. +func chatError(c *gin.Context, err error) { + switch { + case errors.Is(err, pairing.ErrInvalidMessage): + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_message"}) + case errors.Is(err, pairing.ErrInvalidReadCursor): + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_read_cursor"}) + case errors.Is(err, pairing.ErrMessageConflict): + c.JSON(http.StatusConflict, gin.H{"error": "message_id_conflict"}) + default: + deviceError(c, err) + } +} + +// chatMessagesHandler serves recent history, older pages, or incremental updates. +func chatMessagesHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + after, err1 := strconv.ParseInt(c.DefaultQuery("after_id", "0"), 10, 64) + before, err2 := strconv.ParseInt(c.DefaultQuery("before_id", "0"), 10, 64) + limit, err3 := strconv.Atoi(c.DefaultQuery("limit", "50")) + if err1 != nil || err2 != nil || err3 != nil || after < 0 || before < 0 || (after > 0 && before > 0) || limit < 1 || limit > 100 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_pagination"}) + return + } + page, err := service.Messages(deviceBearerToken(c), after, before, limit) + if err != nil { + chatError(c, err) + return + } + c.JSON(http.StatusOK, page) + } +} + +// sendChatMessageHandler assigns identity from the device token, never the body. +func sendChatMessageHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32*1024) + var request struct { + ClientID string `json:"client_id" binding:"required,max=64"` + Text string `json:"text" binding:"required,max=4000"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_message"}) + return + } + message, err := service.SendMessage(deviceBearerToken(c), request.ClientID, request.Text) + if err != nil { + chatError(c, err) + return + } + c.JSON(http.StatusOK, message) + } +} + +// readChatMessagesHandler records a monotonic member-wide read receipt. +func readChatMessagesHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var request struct { + MessageID int64 `json:"message_id" binding:"required,gt=0"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_read_cursor"}) + return + } + readID, err := service.MarkMessagesRead(deviceBearerToken(c), request.MessageID) + if err != nil { + chatError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"read_id": readID}) + } +} diff --git a/server/internal/httpapi/chat_test.go b/server/internal/httpapi/chat_test.go new file mode 100644 index 0000000..c3b575b --- /dev/null +++ b/server/internal/httpapi/chat_test.go @@ -0,0 +1,68 @@ +package httpapi + +import ( + "log/slog" + "net/http" + "net/url" + "strings" + "testing" + + "pawmate/server/internal/config" + "pawmate/server/internal/pairing" +) + +func TestChatHTTPAuthenticationAndReadFlow(t *testing.T) { + service := pairing.NewService() + defer service.Close() + invite, err := service.CreateInvite("https://home.example.test") + if err != nil { + t.Fatal(err) + } + link, _ := url.Parse(invite.URL) + partner, err := service.RedeemInvite(link.Query().Get("code")) + if err != nil { + t.Fatal(err) + } + router := NewRouter(config.Config{Environment: "test"}, slog.New(slog.NewTextHandler(testLogWriter{}, nil)), service) + unauthorized := performJSONRequest(t, router, http.MethodGet, "/api/v1/chat/messages", "", "") + if unauthorized.Code != http.StatusUnauthorized { + t.Fatal("unauthenticated history is accessible") + } + response := performJSONRequest(t, router, http.MethodPost, "/api/v1/chat/messages", `{"client_id":"first","text":"你好 🐾","sender":"invitee"}`, "Bearer "+invite.InviterToken) + if response.Code != http.StatusOK { + t.Fatalf("send status = %d", response.Code) + } + var message pairing.Message + decodeJSON(t, response, &message) + if message.Sender != "inviter" || message.Text != "你好 🐾" { + t.Fatal("sender can be spoofed or Unicode is damaged") + } + response = performJSONRequest(t, router, http.MethodGet, "/api/v1/chat/messages", "", "Bearer "+partner.InviteeToken) + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatal("private message responses can be cached") + } + var page pairing.MessagePage + decodeJSON(t, response, &page) + if page.UnreadCount != 1 || page.PartnerReadID != 0 { + t.Fatal("unread metadata incorrect") + } + response = performJSONRequest(t, router, http.MethodPost, "/api/v1/chat/read", `{"message_id":1}`, "Bearer "+partner.InviteeToken) + if response.Code != http.StatusOK { + t.Fatal("read acknowledgment failed") + } + response = performJSONRequest(t, router, http.MethodGet, "/api/v1/chat/messages?after_id=1", "", "Bearer "+invite.InviterToken) + decodeJSON(t, response, &page) + if page.PartnerReadID != message.ID || len(page.Messages) != 0 { + t.Fatal("receipt-only update failed") + } + for _, query := range []string{"limit=0", "limit=101", "after_id=-1", "after_id=1&before_id=2", "before_id=oops"} { + response = performJSONRequest(t, router, http.MethodGet, "/api/v1/chat/messages?"+query, "", "Bearer "+invite.InviterToken) + if response.Code != http.StatusBadRequest { + t.Fatal("invalid pagination accepted", query) + } + } + response = performJSONRequest(t, router, http.MethodPost, "/api/v1/chat/messages", `{"client_id":"large","text":"`+strings.Repeat("x", 4001)+`"}`, "Bearer "+invite.InviterToken) + if response.Code != http.StatusBadRequest { + t.Fatal("oversized message accepted") + } +} diff --git a/server/internal/httpapi/devices.go b/server/internal/httpapi/devices.go new file mode 100644 index 0000000..a59491a --- /dev/null +++ b/server/internal/httpapi/devices.go @@ -0,0 +1,91 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "pawmate/server/internal/pairing" +) + +// deviceBearerToken extracts an access token without accepting unrelated schemes. +func deviceBearerToken(c *gin.Context) string { + scheme, token, found := strings.Cut(c.GetHeader("Authorization"), " ") + if !found || !strings.EqualFold(scheme, "Bearer") { + return "" + } + return strings.TrimSpace(token) +} + +// deviceError maps device-service failures to public API errors. +func deviceError(c *gin.Context, err error) { + status, code := http.StatusInternalServerError, "internal_error" + switch { + case errors.Is(err, pairing.ErrInvalidSessionToken): + status, code = http.StatusUnauthorized, "invalid_session_token" + case errors.Is(err, pairing.ErrInvalidDeviceCode): + status, code = http.StatusUnauthorized, "invalid_device_code" + case errors.Is(err, pairing.ErrNotPaired): + status, code = http.StatusConflict, "not_paired" + case errors.Is(err, pairing.ErrDeviceNotFound): + status, code = http.StatusNotFound, "device_not_found" + } + c.JSON(status, gin.H{"error": code}) +} + +// devicesHandler lists this member's sessions without exposing any credentials. +func devicesHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + devices, err := service.Devices(deviceBearerToken(c)) + if err != nil { + deviceError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"devices": devices}) + } +} + +// revokeDeviceHandler revokes only devices owned by the authenticated member. +func revokeDeviceHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + if err := service.RevokeDevice(deviceBearerToken(c), c.Param("id")); err != nil { + deviceError(c, err) + return + } + c.Status(http.StatusNoContent) + } +} + +// deviceLoginCodeHandler issues a short-lived, single-use additional-device code. +func deviceLoginCodeHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + code, err := service.CreateDeviceLoginCode(deviceBearerToken(c)) + if err != nil { + deviceError(c, err) + return + } + c.JSON(http.StatusCreated, code) + } +} + +// redeemDeviceLoginCodeHandler exchanges a code for an independent device token. +func redeemDeviceLoginCodeHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxCredentialRequestBodyBytes) + var request struct { + Code string `json:"code" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + credentials, err := service.RedeemDeviceLoginCode(request.Code, request.DeviceName) + if err != nil { + deviceError(c, err) + return + } + c.JSON(http.StatusOK, credentials) + } +} diff --git a/server/internal/httpapi/pairing.go b/server/internal/httpapi/pairing.go new file mode 100644 index 0000000..426862d --- /dev/null +++ b/server/internal/httpapi/pairing.go @@ -0,0 +1,203 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "pawmate/server/internal/pairing" +) + +const maxCredentialRequestBodyBytes = 8 * 1024 + +type createInviteRequest struct { + Profile *pairing.Profile `json:"profile" binding:"required"` + ServerURL string `json:"server_url" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` +} + +type createInviteResponse struct { + ExpiresAt string `json:"expires_at"` + InviteURL string `json:"invite_url"` + InviterToken string `json:"inviter_token"` + RecoveryCode string `json:"recovery_code"` +} + +type redeemInviteRequest struct { + Profile *pairing.Profile `json:"profile" binding:"required"` + Code string `json:"code" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` +} + +type pairingStatusResponse struct { + InviteeToken string `json:"invitee_token,omitempty"` + RecoveryCode string `json:"recovery_code,omitempty"` + PairID string `json:"pair_id,omitempty"` + Status string `json:"status"` +} + +type recoverPairingRequest struct { + RecoveryCode string `json:"recovery_code" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` +} + +type recoverPairingResponse struct { + AccessToken string `json:"access_token"` + PairID string `json:"pair_id"` + RecoveryCode string `json:"recovery_code"` + Role string `json:"role"` +} + +type pairingSessionResponse struct { + Profile *pairing.Profile `json:"profile,omitempty"` + Partner *pairing.Profile `json:"partner,omitempty"` + PairID string `json:"pair_id,omitempty"` + Role string `json:"role"` + Status string `json:"status"` +} + +// createInviteHandler creates an invitation for the configured server URL. +func createInviteHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 400*1024) + var request createInviteRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + + invite, err := service.CreateInviteWithProfile(request.ServerURL, request.DeviceName, *request.Profile) + if err != nil { + status := http.StatusInternalServerError + code := "internal_error" + switch { + case errors.Is(err, pairing.ErrInvalidProfile): + status, code = http.StatusBadRequest, "invalid_profile" + case errors.Is(err, pairing.ErrInvalidServerURL): + status, code = http.StatusBadRequest, "invalid_server_url" + case errors.Is(err, pairing.ErrAlreadyPaired): + status, code = http.StatusConflict, "already_paired" + case errors.Is(err, pairing.ErrInvitePending): + status, code = http.StatusConflict, "invite_pending" + } + c.JSON(status, gin.H{"error": code}) + return + } + + c.JSON(http.StatusCreated, createInviteResponse{ + ExpiresAt: invite.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z"), + InviteURL: invite.URL, + InviterToken: invite.InviterToken, + RecoveryCode: invite.RecoveryCode, + }) + } +} + +// redeemInviteHandler consumes the one-time code supplied by the invitee. +func redeemInviteHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 400*1024) + var request redeemInviteRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + + status, err := service.RedeemInviteWithProfile(request.Code, request.DeviceName, *request.Profile) + if err != nil { + code := "invalid_invite" + httpStatus := http.StatusConflict + if errors.Is(err, pairing.ErrInvalidProfile) { + code, httpStatus = "invalid_profile", http.StatusBadRequest + } else if errors.Is(err, pairing.ErrExpiredInvite) { + code, httpStatus = "expired_invite", http.StatusGone + } else if !errors.Is(err, pairing.ErrInvalidInvite) { + code, httpStatus = "internal_error", http.StatusInternalServerError + } + c.JSON(httpStatus, gin.H{"error": code}) + return + } + + c.JSON(http.StatusOK, pairingStatusResponse{ + InviteeToken: status.InviteeToken, + RecoveryCode: status.RecoveryCode, + PairID: status.PairID, + Status: status.Status, + }) + } +} + +// recoverPairingHandler rotates a member's credentials using their recovery code. +func recoverPairingHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxCredentialRequestBodyBytes) + var request recoverPairingRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + + credentials, err := service.Recover(request.RecoveryCode, request.DeviceName) + if err != nil { + code := "invalid_recovery_code" + status := http.StatusUnauthorized + if errors.Is(err, pairing.ErrNotPaired) { + code, status = "not_paired", http.StatusConflict + } else if !errors.Is(err, pairing.ErrInvalidRecoveryCode) { + code, status = "internal_error", http.StatusInternalServerError + } + c.JSON(status, gin.H{"error": code}) + return + } + + c.JSON(http.StatusOK, recoverPairingResponse{ + AccessToken: credentials.AccessToken, + PairID: credentials.PairID, + RecoveryCode: credentials.RecoveryCode, + Role: credentials.Role, + }) + } +} + +// pairingSessionHandler validates either member's access token. +func pairingSessionHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) + session, err := service.SessionWithProfiles(token) + if err != nil { + if errors.Is(err, pairing.ErrInvalidSessionToken) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_session_token"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal_error"}) + return + } + c.JSON(http.StatusOK, pairingSessionResponse{ + Profile: session.Profile, + Partner: session.Partner, + PairID: session.PairID, + Role: session.Role, + Status: session.Status, + }) + } +} + +// pairingStatusHandler reports state to the device that created the invite. +func pairingStatusHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) + status, err := service.Status(token) + if err != nil { + if errors.Is(err, pairing.ErrInvalidInviterToken) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_inviter_token"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal_error"}) + return + } + c.JSON(http.StatusOK, pairingStatusResponse{PairID: status.PairID, Status: status.Status}) + } +} diff --git a/server/internal/httpapi/pairing_test.go b/server/internal/httpapi/pairing_test.go new file mode 100644 index 0000000..c721ae3 --- /dev/null +++ b/server/internal/httpapi/pairing_test.go @@ -0,0 +1,216 @@ +package httpapi + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "image" + "image/png" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "pawmate/server/internal/config" + "pawmate/server/internal/pairing" +) + +func TestPairingHTTPFlow(t *testing.T) { + service, err := pairing.OpenService(":memory:") + if err != nil { + t.Fatal(err) + } + defer service.Close() + router := NewRouter(config.Config{ + Environment: "test", + InstanceID: "test-instance", + InstanceName: "Test Home", + Port: "8080", + }, slog.New(slog.NewTextHandler(testLogWriter{}, nil)), service) + + createResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites", profileRequest(t, map[string]string{"server_url": "https://home.example.test"}, "Ash"), "") + if createResponse.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d", createResponse.Code, http.StatusCreated) + } + var invite struct { + InviteURL string `json:"invite_url"` + InviterToken string `json:"inviter_token"` + RecoveryCode string `json:"recovery_code"` + } + decodeJSON(t, createResponse, &invite) + if invite.InviteURL == "" || invite.InviterToken == "" || invite.RecoveryCode == "" { + t.Fatalf("create response did not contain invite credentials: %+v", invite) + } + + statusResponse := performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/invites/status", "", "Bearer "+invite.InviterToken) + if statusResponse.Code != http.StatusOK { + t.Fatalf("pending status = %d, want %d", statusResponse.Code, http.StatusOK) + } + + inviteURL, err := url.Parse(invite.InviteURL) + if err != nil { + t.Fatal(err) + } + code := inviteURL.Query().Get("code") + redeemResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites/redeem", profileRequest(t, map[string]string{"code": code}, "Fern"), "") + if redeemResponse.Code != http.StatusOK { + t.Fatalf("redeem status = %d, want %d", redeemResponse.Code, http.StatusOK) + } + statusResponse = performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/invites/status", "", "Bearer "+invite.InviterToken) + if !strings.Contains(statusResponse.Body.String(), `"status":"paired"`) { + t.Fatalf("paired status = %s", statusResponse.Body.String()) + } + var partner struct { + Token string `json:"invitee_token"` + } + decodeJSON(t, redeemResponse, &partner) + unauthorized := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/devices/login-codes", "", "") + if unauthorized.Code != http.StatusUnauthorized { + t.Fatal("unauthenticated device can issue a login code") + } + loginResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/devices/login-codes", "", "Bearer "+invite.InviterToken) + if loginResponse.Code != http.StatusCreated { + t.Fatalf("create device login status: %d", loginResponse.Code) + } + var login struct { + Code string `json:"code"` + } + decodeJSON(t, loginResponse, &login) + deviceResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/devices/login-codes/redeem", `{"code":"`+login.Code+`","device_name":"Tablet"}`, "") + if deviceResponse.Code != http.StatusOK { + t.Fatalf("device sign-in status: %d", deviceResponse.Code) + } + if strings.Contains(deviceResponse.Body.String(), "recovery_code") { + t.Fatal("device sign-in disclosed recovery secret") + } + var tablet struct { + Token string `json:"access_token"` + } + decodeJSON(t, deviceResponse, &tablet) + for _, token := range []string{invite.InviterToken, tablet.Token, partner.Token} { + response := performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/session", "", "Bearer "+token) + if response.Code != http.StatusOK { + t.Fatal("adding a device invalidated a member session") + } + var session struct { + Profile pairing.Profile `json:"profile"` + Partner pairing.Profile `json:"partner"` + } + decodeJSON(t, response, &session) + if session.Profile.Nickname == "" || session.Partner.Nickname == "" || session.Profile.Nickname == session.Partner.Nickname { + t.Fatal("session did not restore both distinct member identities") + } + } + devicesResponse := performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/devices", "", "Bearer "+tablet.Token) + var devices struct { + Devices []pairing.Device `json:"devices"` + } + decodeJSON(t, devicesResponse, &devices) + if len(devices.Devices) != 2 { + t.Fatal("device list leaked partner's devices or lost own device") + } + if strings.Contains(devicesResponse.Body.String(), "token") || strings.Contains(devicesResponse.Body.String(), "recovery") { + t.Fatal("device list disclosed credentials") + } + var phoneID string + for _, device := range devices.Devices { + if !device.Current { + phoneID = device.ID + } + } + forbidden := performJSONRequest(t, router, http.MethodDelete, "/api/v1/pairing/devices/"+phoneID, "", "Bearer "+partner.Token) + if forbidden.Code != http.StatusNotFound { + t.Fatal("partner could revoke another member's device") + } + revoked := performJSONRequest(t, router, http.MethodDelete, "/api/v1/pairing/devices/"+phoneID, "", "Bearer "+tablet.Token) + if revoked.Code != http.StatusNoContent { + t.Fatalf("revoke status: %d", revoked.Code) + } + rejected := performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/session", "", "Bearer "+invite.InviterToken) + if rejected.Code != http.StatusUnauthorized { + t.Fatal("revoked token still authenticates") + } +} + +// profileRequest creates a real PNG fixture, avoiding a test-only validation bypass. +func profileRequest(t *testing.T, fields map[string]string, nickname string) string { + t.Helper() + var imageBytes bytes.Buffer + if err := png.Encode(&imageBytes, image.NewRGBA(image.Rect(0, 0, 8, 8))); err != nil { + t.Fatal(err) + } + body := map[string]any{"profile": pairing.Profile{Nickname: nickname, AvatarBase64: base64.StdEncoding.EncodeToString(imageBytes.Bytes())}} + for key, value := range fields { + body[key] = value + } + data, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestInvitationRequiresValidProfile(t *testing.T) { + service := pairing.NewService() + defer service.Close() + router := NewRouter(config.Config{Environment: "test"}, slog.New(slog.NewTextHandler(testLogWriter{}, nil)), service) + for _, body := range []string{ + `{"server_url":"https://home.example.test"}`, + `{"server_url":"https://home.example.test","profile":{"nickname":"Ash","avatar_base64":"garbage"}}`, + profileRequest(t, map[string]string{"server_url": "https://home.example.test"}, " "), + } { + response := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites", body, "") + if response.Code != http.StatusBadRequest { + t.Fatalf("invalid profile accepted: %d", response.Code) + } + } + create := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites", profileRequest(t, map[string]string{"server_url": "https://home.example.test"}, "Ash"), "") + if create.Code != http.StatusCreated { + t.Fatal("invalid requests reserved the instance") + } + var invite struct { + URL string `json:"invite_url"` + } + decodeJSON(t, create, &invite) + uri, _ := url.Parse(invite.URL) + code := uri.Query().Get("code") + response := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites/redeem", `{"code":"`+code+`"}`, "") + if response.Code != http.StatusBadRequest { + t.Fatal("redemption without profile accepted") + } + response = performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites/redeem", profileRequest(t, map[string]string{"code": code}, "Fern"), "") + if response.Code != http.StatusOK { + t.Fatal("invalid redemption consumed the invitation") + } + response = performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/session", "", "") + if response.Code != http.StatusUnauthorized || strings.Contains(response.Body.String(), "avatar_base64") { + t.Fatal("profile leaked without credentials") + } +} + +func performJSONRequest(t *testing.T, handler http.Handler, method, path, body, authorization string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + request.Header.Set("Content-Type", "application/json") + } + if authorization != "" { + request.Header.Set("Authorization", authorization) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func decodeJSON(t *testing.T, response *httptest.ResponseRecorder, target any) { + t.Helper() + if err := json.NewDecoder(response.Body).Decode(target); err != nil { + t.Fatal(err) + } +} + +type testLogWriter struct{} + +func (testLogWriter) Write(data []byte) (int, error) { return len(data), nil } diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index e33cd76..8c35cec 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -8,10 +8,11 @@ import ( "github.com/gin-gonic/gin" "pawmate/server/internal/config" + "pawmate/server/internal/pairing" ) // NewRouter creates the versioned HTTP API served by a Pawmate instance. -func NewRouter(cfg config.Config, logger *slog.Logger) *gin.Engine { +func NewRouter(cfg config.Config, logger *slog.Logger, pairingService *pairing.Service) *gin.Engine { if cfg.Environment == "production" { gin.SetMode(gin.ReleaseMode) } @@ -31,10 +32,25 @@ func NewRouter(cfg config.Config, logger *slog.Logger) *gin.Engine { router.GET("/healthz", healthHandler) api := router.Group("/api/v1") api.GET("/instance", instanceHandler(cfg)) + api.POST("/pairing/invites", createInviteHandler(pairingService)) + api.POST("/pairing/invites/redeem", redeemInviteHandler(pairingService)) + api.GET("/pairing/invites/status", pairingStatusHandler(pairingService)) + api.POST("/pairing/recover", recoverPairingHandler(pairingService)) + api.GET("/pairing/session", pairingSessionHandler(pairingService)) + api.GET("/pairing/devices", devicesHandler(pairingService)) + api.DELETE("/pairing/devices/:id", revokeDeviceHandler(pairingService)) + api.POST("/pairing/devices/login-codes", deviceLoginCodeHandler(pairingService)) + api.POST("/pairing/devices/login-codes/redeem", redeemDeviceLoginCodeHandler(pairingService)) + chat := api.Group("/chat") + chat.Use(func(c *gin.Context) { c.Header("Cache-Control", "no-store"); c.Next() }) + chat.GET("/messages", chatMessagesHandler(pairingService)) + chat.POST("/messages", sendChatMessageHandler(pairingService)) + chat.POST("/read", readChatMessagesHandler(pairingService)) return router } +// healthHandler reports that the HTTP process is reachable. func healthHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } diff --git a/server/internal/httpapi/router_test.go b/server/internal/httpapi/router_test.go index b4b5666..bc1ef98 100644 --- a/server/internal/httpapi/router_test.go +++ b/server/internal/httpapi/router_test.go @@ -8,6 +8,7 @@ import ( "testing" "pawmate/server/internal/config" + "pawmate/server/internal/pairing" ) func TestHealthz(t *testing.T) { @@ -36,10 +37,14 @@ func TestInstance(t *testing.T) { } func testRouter() http.Handler { + pairingService, err := pairing.OpenService(":memory:") + if err != nil { + panic(err) + } return NewRouter(config.Config{ Environment: "test", InstanceID: "test-instance", InstanceName: "Test Home", Port: "8080", - }, slog.New(slog.NewTextHandler(io.Discard, nil))) + }, slog.New(slog.NewTextHandler(io.Discard, nil)), pairingService) } diff --git a/server/internal/pairing/devices.go b/server/internal/pairing/devices.go new file mode 100644 index 0000000..39f92fd --- /dev/null +++ b/server/internal/pairing/devices.go @@ -0,0 +1,200 @@ +package pairing + +import ( + "errors" + "strings" + "time" + + "gorm.io/gorm" +) + +var ( + ErrInvalidDeviceCode = errors.New("device login code is invalid or expired") + ErrDeviceNotFound = errors.New("device does not belong to this member") +) + +// Device describes one independently authenticated installation, without its token. +type Device struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + Current bool `json:"current"` +} + +// DeviceLoginCode grants one additional device access to the issuing member. +type DeviceLoginCode struct { + Code string `json:"code"` + ExpiresAt time.Time `json:"expires_at"` +} + +// DeviceCredentials is issued once when a new device redeems a login code. +type DeviceCredentials struct { + AccessToken string `json:"access_token"` + PairID string `json:"pair_id"` + Role string `json:"role"` +} + +// migrateDevices atomically imports legacy tokens once, preserving existing logins. +func (service *Service) migrateDevices() error { + return service.db.Transaction(func(tx *gorm.DB) error { + var migrated int64 + if err := tx.Model(&deviceMigration{}).Where("version = ?", 1).Count(&migrated).Error; err != nil { + return err + } + if migrated != 0 { + return nil + } + var state pairingRecord + err := tx.Take(&state, "id = ?", 1).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + for role, hash := range map[string][]byte{"inviter": state.InviterTokenHash, "invitee": state.InviteeTokenHash} { + if len(hash) == 0 { + continue + } + record := deviceRecord{ID: "legacy-" + role, Role: role, TokenHash: hash, + Name: "Existing device", CreatedAt: service.now().Unix()} + if err := tx.Create(&record).Error; err != nil { + return err + } + } + // A durable marker prevents revoked legacy tokens being re-imported on restart. + return tx.Create(&deviceMigration{Version: 1}).Error + }) +} + +// deviceName bounds the optional display name; it is never used as identity. +func deviceName(names []string) string { + if len(names) == 0 || strings.TrimSpace(names[0]) == "" { + return "Pawmate device" + } + runes := []rune(strings.TrimSpace(names[0])) + if len(runes) > 80 { + runes = runes[:80] + } + return string(runes) +} + +// insertDevice records a unique token hash in the caller's transaction. +func (service *Service) insertDevice(tx *gorm.DB, role, token, name string) (string, error) { + id, err := randomToken(16) + if err != nil { + return "", err + } + hash := tokenHash(token) + err = tx.Create(&deviceRecord{ID: id, Role: role, TokenHash: hash[:], Name: name, CreatedAt: service.now().Unix()}).Error + return id, err +} + +// CreateDeviceLoginCode issues a ten-minute code from an already paired device. +func (service *Service) CreateDeviceLoginCode(accessToken string) (DeviceLoginCode, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.authenticateDevice(accessToken) + if err != nil { + return DeviceLoginCode{}, err + } + if session.Status != "paired" { + return DeviceLoginCode{}, ErrNotPaired + } + code, err := randomToken(24) + if err != nil { + return DeviceLoginCode{}, err + } + expiresAt := service.now().Add(inviteTTL) + hash := tokenHash(code) + err = service.db.Transaction(func(tx *gorm.DB) error { + // Each device has at most one outstanding login code. + if err := tx.Where("issuer_id = ? OR expires_at <= ?", session.DeviceID, service.now().Unix()).Delete(&deviceCodeRecord{}).Error; err != nil { + return err + } + return tx.Create(&deviceCodeRecord{CodeHash: hash[:], Role: session.Role, + IssuerID: session.DeviceID, ExpiresAt: expiresAt.Unix()}).Error + }) + if err != nil { + return DeviceLoginCode{}, err + } + return DeviceLoginCode{Code: code, ExpiresAt: expiresAt}, nil +} + +// RedeemDeviceLoginCode consumes a code and adds a session without revoking others. +func (service *Service) RedeemDeviceLoginCode(code, name string) (DeviceCredentials, error) { + service.mu.Lock() + defer service.mu.Unlock() + hash := tokenHash(strings.TrimSpace(code)) + var credentials DeviceCredentials + err := service.db.Transaction(func(tx *gorm.DB) error { + var record struct { + Role string + PairID string + } + err := tx.Table("device_login_codes AS c").Select("c.role, p.pair_id"). + Joins("JOIN pairing_state AS p ON p.id = 1 AND p.paired = 1"). + Where("c.code_hash = ? AND c.expires_at > ?", hash[:], service.now().Unix()).Take(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrInvalidDeviceCode + } + if err != nil { + return err + } + result := tx.Where("code_hash = ? AND expires_at > ?", hash[:], service.now().Unix()).Delete(&deviceCodeRecord{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrInvalidDeviceCode + } + token, err := randomToken(32) + if err != nil { + return err + } + if _, err := service.insertDevice(tx, record.Role, token, deviceName([]string{name})); err != nil { + return err + } + credentials = DeviceCredentials{AccessToken: token, PairID: record.PairID, Role: record.Role} + return nil + }) + if err != nil { + return DeviceCredentials{}, err + } + return credentials, nil +} + +// Devices lists only the authenticated member's installations. +func (service *Service) Devices(accessToken string) ([]Device, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.authenticateDevice(accessToken) + if err != nil { + return nil, err + } + var records []deviceRecord + if err := service.db.Where("role = ?", session.Role).Order("created_at, id").Find(&records).Error; err != nil { + return nil, err + } + devices := []Device{} + for _, record := range records { + devices = append(devices, Device{ID: record.ID, Name: record.Name, + CreatedAt: time.Unix(record.CreatedAt, 0).UTC(), Current: record.ID == session.DeviceID}) + } + return devices, nil +} + +// RevokeDevice removes one of this member's sessions and its outstanding codes. +func (service *Service) RevokeDevice(accessToken, deviceID string) error { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.authenticateDevice(accessToken) + if err != nil { + return err + } + result := service.db.Where("id = ? AND role = ?", deviceID, session.Role).Delete(&deviceRecord{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrDeviceNotFound + } + return nil +} diff --git a/server/internal/pairing/devices_test.go b/server/internal/pairing/devices_test.go new file mode 100644 index 0000000..5573881 --- /dev/null +++ b/server/internal/pairing/devices_test.go @@ -0,0 +1,206 @@ +package pairing + +import ( + "errors" + "path/filepath" + "sync" + "testing" + "time" +) + +func pairedDevices(t *testing.T, service *Service) (Invite, PairingStatus) { + t.Helper() + invite, err := service.CreateInvite("https://home.example.test", "Phone") + if err != nil { + t.Fatal(err) + } + partner, err := service.RedeemInvite(inviteCode(t, invite.URL), "Partner phone") + if err != nil { + t.Fatal(err) + } + return invite, partner +} + +func TestAdditionalDevicesRemainAuthenticatedAfterRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "pawmate.db") + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + invite, partner := pairedDevices(t, service) + tokens := []string{invite.InviterToken, partner.InviteeToken} + for _, name := range []string{"Tablet", "Computer"} { + code, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + credentials, err := service.RedeemDeviceLoginCode(code.Code, name) + if err != nil { + t.Fatal(err) + } + if credentials.PairID != partner.PairID || credentials.Role != "inviter" { + t.Fatal("new device changed member identity") + } + tokens = append(tokens, credentials.AccessToken) + if _, err := service.RedeemDeviceLoginCode(code.Code, "Again"); !errors.Is(err, ErrInvalidDeviceCode) { + t.Fatalf("replay error: %v", err) + } + } + if err := service.Close(); err != nil { + t.Fatal(err) + } + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + defer service.Close() + for _, token := range tokens { + if session, err := service.Authenticate(token); err != nil || session.Status != "paired" { + t.Fatalf("device lost login: %v", err) + } + } + devices, err := service.Devices(tokens[2]) + if err != nil || len(devices) != 3 { + t.Fatalf("devices = %v, error = %v", devices, err) + } + current := 0 + for _, device := range devices { + if device.Current { + current++ + } + } + if current != 1 { + t.Fatal("device list did not identify the current device") + } + // Adding devices does not consume or rotate the member recovery code. + if _, err := service.Recover(invite.RecoveryCode); err != nil { + t.Fatal(err) + } + for _, token := range []string{tokens[0], tokens[2], tokens[3]} { + if _, err := service.Authenticate(token); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("recovery left an old device active: %v", err) + } + } + if _, err := service.Authenticate(partner.InviteeToken); err != nil { + t.Fatal("recovery affected the partner") + } +} + +func TestDeviceRevocationIsMemberScopedAndInvalidatesIssuedCodes(t *testing.T) { + service := NewService() + defer service.Close() + invite, partner := pairedDevices(t, service) + code, _ := service.CreateDeviceLoginCode(invite.InviterToken) + computer, err := service.RedeemDeviceLoginCode(code.Code, "Computer") + if err != nil { + t.Fatal(err) + } + phone, _ := service.Authenticate(invite.InviterToken) + if err := service.RevokeDevice(partner.InviteeToken, phone.DeviceID); !errors.Is(err, ErrDeviceNotFound) { + t.Fatalf("partner can revoke own member's device: %v", err) + } + pending, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + if err := service.RevokeDevice(computer.AccessToken, phone.DeviceID); err != nil { + t.Fatal(err) + } + if _, err := service.Authenticate(invite.InviterToken); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("revoked token accepted: %v", err) + } + if _, err := service.RedeemDeviceLoginCode(pending.Code, "Tablet"); !errors.Is(err, ErrInvalidDeviceCode) { + t.Fatalf("revoked device's code accepted: %v", err) + } + if _, err := service.Authenticate(computer.AccessToken); err != nil { + t.Fatal(err) + } +} + +func TestDeviceLoginCodeExpiryAndConcurrentConsumption(t *testing.T) { + service := NewService() + defer service.Close() + clock := time.Now() + service.now = func() time.Time { return clock } + invite, _ := pairedDevices(t, service) + code, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + clock = code.ExpiresAt + if _, err := service.RedeemDeviceLoginCode(code.Code, "Tablet"); !errors.Is(err, ErrInvalidDeviceCode) { + t.Fatalf("expired code accepted: %v", err) + } + code, err = service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + results := make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := service.RedeemDeviceLoginCode(code.Code, "Computer") + results <- err + }() + } + wg.Wait() + close(results) + successes := 0 + for err := range results { + if err == nil { + successes++ + } else if !errors.Is(err, ErrInvalidDeviceCode) { + t.Fatal(err) + } + } + if successes != 1 { + t.Fatalf("code redeemed %d times", successes) + } +} + +func TestLegacyTokenMigrationDoesNotRestoreRevokedDevice(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + invite, partner := pairedDevices(t, service) + // Remove only the new tables to reproduce the previous release's database. + for _, table := range []string{"device_login_codes", "device_sessions", "device_migrations"} { + if err := service.db.Exec("DROP TABLE " + table).Error; err != nil { + t.Fatal(err) + } + } + service.Close() + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + for _, token := range []string{invite.InviterToken, partner.InviteeToken} { + if _, err := service.Authenticate(token); err != nil { + t.Fatal("migration rejected existing token", err) + } + } + code, _ := service.CreateDeviceLoginCode(invite.InviterToken) + computer, err := service.RedeemDeviceLoginCode(code.Code, "Computer") + if err != nil { + t.Fatal(err) + } + if err := service.RevokeDevice(computer.AccessToken, "legacy-inviter"); err != nil { + t.Fatal(err) + } + service.Close() + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + defer service.Close() + if _, err := service.Authenticate(invite.InviterToken); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("restart restored revoked legacy token: %v", err) + } + if _, err := service.Authenticate(computer.AccessToken); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/pairing/messages.go b/server/internal/pairing/messages.go new file mode 100644 index 0000000..04f0475 --- /dev/null +++ b/server/internal/pairing/messages.go @@ -0,0 +1,188 @@ +package pairing + +import ( + "errors" + "strings" + "time" + "unicode/utf8" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrInvalidMessage = errors.New("message must contain 1 to 4000 characters and a client id") + ErrMessageConflict = errors.New("client id was already used for different content") + ErrInvalidReadCursor = errors.New("read cursor does not identify a message in this home") +) + +// Message is a persisted text message with a server-assigned sequence number. +type Message struct { + ID int64 `json:"id"` + ClientID string `json:"client_id"` + Sender string `json:"sender"` + Text string `json:"text"` + CreatedAt time.Time `json:"created_at"` +} + +// MessagePage combines history with member-level read progress and unread count. +type MessagePage struct { + Messages []Message `json:"messages"` + HasMore bool `json:"has_more"` + LatestID int64 `json:"latest_id"` + ReadID int64 `json:"read_id"` + PartnerReadID int64 `json:"partner_read_id"` + UnreadCount int `json:"unread_count"` +} + +// chatSession requires an active paired device; the caller holds the service mutex. +func (service *Service) chatSession(token string) (Session, error) { + session, err := service.authenticateDevice(token) + if err != nil { + return Session{}, err + } + if session.Status != "paired" { + return Session{}, ErrNotPaired + } + return session, nil +} + +// SendMessage stores text once per member/client id, including ambiguous retries. +func (service *Service) SendMessage(token, clientID, text string) (Message, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.chatSession(token) + if err != nil { + return Message{}, err + } + clientID, text = strings.TrimSpace(clientID), strings.TrimSpace(text) + if clientID == "" || len(clientID) > 64 || !utf8.ValidString(text) || text == "" || utf8.RuneCountInString(text) > 4000 { + return Message{}, ErrInvalidMessage + } + record := messageRecord{PairID: session.PairID, Sender: session.Role, ClientID: clientID, + Text: text, CreatedAt: service.now().UnixMilli()} + err = service.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "pair_id"}, {Name: "sender"}, {Name: "client_id"}}, DoNothing: true, + }).Create(&record).Error; err != nil { + return err + } + // Reload into a fresh model: retries must not add an implicit ID condition. + var stored messageRecord + if err := tx.Where("pair_id = ? AND sender = ? AND client_id = ?", session.PairID, session.Role, clientID).Take(&stored).Error; err != nil { + return err + } + if stored.Text != text { + return ErrMessageConflict + } + record = stored + return nil + }) + if err != nil { + return Message{}, err + } + return record.message(), nil +} + +// Messages returns bounded history or forward updates with coherent read metadata. +func (service *Service) Messages(token string, afterID, beforeID int64, limit int) (MessagePage, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.chatSession(token) + if err != nil { + return MessagePage{}, err + } + if afterID < 0 || beforeID < 0 || (afterID > 0 && beforeID > 0) || limit < 1 || limit > 100 { + return MessagePage{}, ErrInvalidMessage + } + // One read transaction keeps pagination and receipts consistent across processes. + page := MessagePage{Messages: []Message{}} + err = service.db.Transaction(func(tx *gorm.DB) error { + query := tx.Where("pair_id = ?", session.PairID) + if afterID > 0 { + query = query.Where("id > ?", afterID) + } + if beforeID > 0 { + query = query.Where("id < ?", beforeID) + } + if afterID > 0 { + query = query.Order("id ASC") + } else { + query = query.Order("id DESC") + } + var records []messageRecord + if err := query.Limit(limit + 1).Find(&records).Error; err != nil { + return err + } + page.HasMore = len(records) > limit + if page.HasMore { + records = records[:limit] + } + for _, record := range records { + page.Messages = append(page.Messages, record.message()) + } + if afterID == 0 { + for i, j := 0, len(page.Messages)-1; i < j; i, j = i+1, j-1 { + page.Messages[i], page.Messages[j] = page.Messages[j], page.Messages[i] + } + } + if err := tx.Model(&messageRecord{}).Where("pair_id = ?", session.PairID). + Select("COALESCE(MAX(id), 0)").Scan(&page.LatestID).Error; err != nil { + return err + } + var cursors []readRecord + if err := tx.Where("pair_id = ?", session.PairID).Find(&cursors).Error; err != nil { + return err + } + for _, cursor := range cursors { + if cursor.Role == session.Role { + page.ReadID = cursor.MessageID + } else if cursor.MessageID > page.PartnerReadID { + page.PartnerReadID = cursor.MessageID + } + } + var unread int64 + if err := tx.Model(&messageRecord{}).Where("pair_id = ? AND sender != ? AND id > ?", session.PairID, session.Role, page.ReadID).Count(&unread).Error; err != nil { + return err + } + page.UnreadCount = int(unread) + return nil + }) + if err != nil { + return MessagePage{}, err + } + return page, nil +} + +// MarkMessagesRead advances a shared member cursor without allowing future ids. +func (service *Service) MarkMessagesRead(token string, messageID int64) (int64, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.chatSession(token) + if err != nil { + return 0, err + } + if messageID <= 0 { + return 0, ErrInvalidReadCursor + } + var cursor readRecord + err = service.db.Transaction(func(tx *gorm.DB) error { + var exists int64 + if err := tx.Model(&messageRecord{}).Where("pair_id = ? AND id = ?", session.PairID, messageID).Count(&exists).Error; err != nil { + return err + } + if exists != 1 { + return ErrInvalidReadCursor + } + record := readRecord{PairID: session.PairID, Role: session.Role, MessageID: messageID} + // An atomic upsert prevents an older device from moving the cursor backwards. + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "pair_id"}, {Name: "role"}}, + DoUpdates: clause.Assignments(map[string]any{"message_id": gorm.Expr("MAX(chat_reads.message_id, excluded.message_id)")}), + }).Create(&record).Error; err != nil { + return err + } + return tx.Where("pair_id = ? AND role = ?", session.PairID, session.Role).Take(&cursor).Error + }) + return cursor.MessageID, err +} diff --git a/server/internal/pairing/messages_test.go b/server/internal/pairing/messages_test.go new file mode 100644 index 0000000..8e4cc86 --- /dev/null +++ b/server/internal/pairing/messages_test.go @@ -0,0 +1,153 @@ +package pairing + +import ( + "errors" + "fmt" + "path/filepath" + "sync" + "testing" +) + +func TestMessagesAndSharedReadReceiptsSurviveRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat.db") + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + invite, partner := pairedDevices(t, service) + first, err := service.SendMessage(invite.InviterToken, "phone-1", "你好,一起做饭吧 🐾") + if err != nil { + t.Fatal(err) + } + second, err := service.SendMessage(partner.InviteeToken, "partner-1", "好呀") + if err != nil { + t.Fatal(err) + } + page, err := service.Messages(partner.InviteeToken, 0, 0, 50) + if err != nil || page.UnreadCount != 1 || len(page.Messages) != 2 { + t.Fatalf("unexpected initial history: %+v, %v", page, err) + } + if page.Messages[0].Text != first.Text { + t.Fatal("Unicode message changed") + } + code, _ := service.CreateDeviceLoginCode(partner.InviteeToken) + tablet, err := service.RedeemDeviceLoginCode(code.Code, "Tablet") + if err != nil { + t.Fatal(err) + } + if _, err := service.MarkMessagesRead(tablet.AccessToken, first.ID); err != nil { + t.Fatal(err) + } + page, err = service.Messages(partner.InviteeToken, 0, 0, 50) + if err != nil || page.ReadID != first.ID || page.UnreadCount != 0 { + t.Fatal("read receipt did not synchronize across devices", err) + } + page, _ = service.Messages(invite.InviterToken, 0, 0, 50) + if page.PartnerReadID != first.ID || page.UnreadCount != 1 { + t.Fatal("partner receipt or own unread count is wrong") + } + if _, err := service.MarkMessagesRead(invite.InviterToken, second.ID); err != nil { + t.Fatal(err) + } + if read, err := service.MarkMessagesRead(invite.InviterToken, first.ID); err != nil || read != second.ID { + t.Fatal("read cursor regressed", err) + } + if _, err := service.MarkMessagesRead(invite.InviterToken, second.ID+100); !errors.Is(err, ErrInvalidReadCursor) { + t.Fatal("future cursor accepted", err) + } + service.Close() + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + defer service.Close() + page, err = service.Messages(invite.InviterToken, 0, 0, 50) + if err != nil || len(page.Messages) != 2 || page.UnreadCount != 0 || page.PartnerReadID != first.ID { + t.Fatal("restart lost messages or receipts", err) + } +} + +func TestMessageRetriesAreIdempotentAndMemberScoped(t *testing.T) { + service := NewService() + defer service.Close() + invite, partner := pairedDevices(t, service) + var wg sync.WaitGroup + ids := make(chan int64, 4) + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + message, err := service.SendMessage(invite.InviterToken, "same-client-id", "hello") + if err != nil { + t.Error(err) + return + } + ids <- message.ID + }() + } + wg.Wait() + close(ids) + var firstID int64 + for id := range ids { + if firstID == 0 { + firstID = id + } + if id != firstID { + t.Fatal("retry duplicated a message") + } + } + if _, err := service.SendMessage(invite.InviterToken, "same-client-id", "different"); !errors.Is(err, ErrMessageConflict) { + t.Fatal("client id conflict accepted", err) + } + if _, err := service.SendMessage(partner.InviteeToken, "same-client-id", "hello"); err != nil { + t.Fatal("client ids incorrectly collide across members", err) + } + page, _ := service.Messages(partner.InviteeToken, 0, 0, 50) + if len(page.Messages) != 2 || page.UnreadCount != 1 { + t.Fatal("idempotency changed counts") + } +} + +func TestChatPaginationValidationAndRevokedSessions(t *testing.T) { + service := NewService() + defer service.Close() + invite, _ := pairedDevices(t, service) + for i := 0; i < 7; i++ { + if _, err := service.SendMessage(invite.InviterToken, fmt.Sprint(i), fmt.Sprint("message ", i)); err != nil { + t.Fatal(err) + } + } + recent, err := service.Messages(invite.InviterToken, 0, 0, 3) + if err != nil || !recent.HasMore || len(recent.Messages) != 3 { + t.Fatal("recent pagination failed", err) + } + older, err := service.Messages(invite.InviterToken, 0, recent.Messages[0].ID, 3) + if err != nil || !older.HasMore || older.Messages[2].ID >= recent.Messages[0].ID { + t.Fatal("history pages overlap", err) + } + forward, err := service.Messages(invite.InviterToken, older.Messages[0].ID, 0, 3) + if err != nil || !forward.HasMore || forward.Messages[0].ID <= older.Messages[0].ID { + t.Fatal("forward pagination failed", err) + } + for _, text := range []string{"", " "} { + if _, err := service.SendMessage(invite.InviterToken, "empty", text); !errors.Is(err, ErrInvalidMessage) { + t.Fatal("empty message accepted") + } + } + if _, err := service.Messages(invite.InviterToken, 1, 1, 3); !errors.Is(err, ErrInvalidMessage) { + t.Fatal("ambiguous pagination accepted") + } + session, _ := service.Authenticate(invite.InviterToken) + if err := service.RevokeDevice(invite.InviterToken, session.DeviceID); err != nil { + t.Fatal(err) + } + if _, err := service.SendMessage(invite.InviterToken, "revoked", "no"); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatal("revoked device can send") + } + if _, err := service.Messages(invite.InviterToken, 0, 0, 50); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatal("revoked device can read") + } + if _, err := service.MarkMessagesRead(invite.InviterToken, recent.LatestID); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatal("revoked device can acknowledge") + } +} diff --git a/server/internal/pairing/models.go b/server/internal/pairing/models.go new file mode 100644 index 0000000..3e683b3 --- /dev/null +++ b/server/internal/pairing/models.go @@ -0,0 +1,132 @@ +package pairing + +import ( + "time" + + "gorm.io/gorm" +) + +// pairingRecord maps the existing singleton schema without changing stored hashes. +type pairingRecord struct { + ID int `gorm:"primaryKey;autoIncrement:false"` + ServerURL string + InviteCodeHash []byte + InviteExpiresAt int64 + InviterTokenHash []byte + InviterRecoveryHash []byte + PairID *string + InviteeTokenHash []byte + InviteeRecoveryHash []byte + Paired bool +} + +// TableName preserves the singular table name used by existing installations. +func (pairingRecord) TableName() string { return "pairing_state" } + +// deviceRecord stores a device's hashed token and legacy Unix-second timestamp. +type deviceRecord struct { + ID string `gorm:"primaryKey"` + Role string + TokenHash []byte + Name string + CreatedAt int64 `gorm:"autoCreateTime:false"` +} + +// TableName preserves the existing device-session table. +func (deviceRecord) TableName() string { return "device_sessions" } + +// deviceCodeRecord stores only a digest of a short-lived device login code. +type deviceCodeRecord struct { + CodeHash []byte `gorm:"primaryKey"` + Role string + IssuerID string + ExpiresAt int64 +} + +// TableName preserves the existing login-code table and its cascading foreign key. +func (deviceCodeRecord) TableName() string { return "device_login_codes" } + +// deviceMigration records the completed legacy-token import. +type deviceMigration struct { + Version int `gorm:"primaryKey;autoIncrement:false"` +} + +// TableName preserves the durable marker that prevents revoked token resurrection. +func (deviceMigration) TableName() string { return "device_migrations" } + +// messageRecord separates SQLite millisecond timestamps from the public API model. +type messageRecord struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + PairID string + Sender string + ClientID string + Text string + CreatedAt int64 `gorm:"autoCreateTime:false"` +} + +// TableName preserves the persisted chat history table. +func (messageRecord) TableName() string { return "chat_messages" } + +// message converts storage timestamps without exposing storage-only pair metadata. +func (record messageRecord) message() Message { + return Message{ID: record.ID, ClientID: record.ClientID, Sender: record.Sender, + Text: record.Text, CreatedAt: time.UnixMilli(record.CreatedAt).UTC()} +} + +// readRecord maps the composite member-level read-cursor key. +type readRecord struct { + PairID string `gorm:"primaryKey"` + Role string `gorm:"primaryKey"` + MessageID int64 +} + +// TableName preserves the existing read-receipt table. +func (readRecord) TableName() string { return "chat_reads" } + +// migrateSchema creates only missing tables/indexes; it never rebuilds old tables. +// Explicit DDL preserves singleton checks, idempotency keys, foreign keys and +// INTEGER timestamps. Future schema changes should use reviewed versioned SQL, +// not AutoMigrate on a live private instance. +func migrateSchema(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + for _, schema := range []string{ + `CREATE TABLE IF NOT EXISTS member_profiles ( + role TEXT PRIMARY KEY CHECK(role IN ('inviter','invitee')), + nickname TEXT NOT NULL, avatar_base64 TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS pairing_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), server_url TEXT NOT NULL, + invite_code_hash BLOB NOT NULL, invite_expires_at INTEGER NOT NULL, + inviter_token_hash BLOB NOT NULL, inviter_recovery_hash BLOB NOT NULL, + pair_id TEXT, invitee_token_hash BLOB, invitee_recovery_hash BLOB, + paired INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS device_sessions ( + id TEXT PRIMARY KEY, role TEXT NOT NULL CHECK(role IN ('inviter','invitee')), + token_hash BLOB NOT NULL UNIQUE, name TEXT NOT NULL, created_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS device_login_codes ( + code_hash BLOB PRIMARY KEY, role TEXT NOT NULL, issuer_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY(issuer_id) REFERENCES device_sessions(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS device_migrations (version INTEGER PRIMARY KEY)`, + `CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, pair_id TEXT NOT NULL, + sender TEXT NOT NULL CHECK(sender IN ('inviter','invitee')), + client_id TEXT NOT NULL, text TEXT NOT NULL, created_at INTEGER NOT NULL, + UNIQUE(pair_id, sender, client_id) + )`, + `CREATE INDEX IF NOT EXISTS chat_messages_pair_id ON chat_messages(pair_id, id)`, + `CREATE TABLE IF NOT EXISTS chat_reads ( + pair_id TEXT NOT NULL, role TEXT NOT NULL, + message_id INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(pair_id, role) + )`, + } { + if err := tx.Exec(schema).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/server/internal/pairing/orm_test.go b/server/internal/pairing/orm_test.go new file mode 100644 index 0000000..40b1b7d --- /dev/null +++ b/server/internal/pairing/orm_test.go @@ -0,0 +1,173 @@ +package pairing + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + "time" +) + +// TestORMOpensPreORMDatabase builds a historical fixture without ORM models. +func TestORMOpensPreORMDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "before-gorm.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + // Keep this snapshot independent of migrateSchema so model/schema drift is caught. + for _, schema := range []string{ + `CREATE TABLE pairing_state (id INTEGER PRIMARY KEY CHECK(id = 1), server_url TEXT NOT NULL, + invite_code_hash BLOB NOT NULL, invite_expires_at INTEGER NOT NULL, inviter_token_hash BLOB NOT NULL, + inviter_recovery_hash BLOB NOT NULL, pair_id TEXT, invitee_token_hash BLOB, invitee_recovery_hash BLOB, + paired INTEGER NOT NULL DEFAULT 0)`, + `CREATE TABLE device_sessions (id TEXT PRIMARY KEY, role TEXT NOT NULL CHECK(role IN ('inviter','invitee')), + token_hash BLOB NOT NULL UNIQUE, name TEXT NOT NULL, created_at INTEGER NOT NULL)`, + `CREATE TABLE device_login_codes (code_hash BLOB PRIMARY KEY, role TEXT NOT NULL, issuer_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, FOREIGN KEY(issuer_id) REFERENCES device_sessions(id) ON DELETE CASCADE)`, + `CREATE TABLE device_migrations (version INTEGER PRIMARY KEY)`, + `CREATE TABLE chat_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, pair_id TEXT NOT NULL, + sender TEXT NOT NULL CHECK(sender IN ('inviter','invitee')), client_id TEXT NOT NULL, text TEXT NOT NULL, + created_at INTEGER NOT NULL, UNIQUE(pair_id, sender, client_id))`, + `CREATE INDEX chat_messages_pair_id ON chat_messages(pair_id, id)`, + `CREATE TABLE chat_reads (pair_id TEXT NOT NULL, role TEXT NOT NULL, message_id INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(pair_id, role))`, + } { + if _, err := db.Exec(schema); err != nil { + t.Fatal(err) + } + } + inviterHash, inviteeHash := tokenHash("phone-token"), tokenHash("partner-token") + revokedHash := tokenHash("revoked-legacy-token") + recoveryHash, codeHash := tokenHash("existing-recovery"), tokenHash("existing-login-code") + clock := time.Unix(1800000000, 123000000).UTC() + if _, err := db.Exec(`INSERT INTO pairing_state VALUES(1, ?, ?, ?, ?, ?, ?, ?, ?, 1)`, + "https://home.example.test", codeHash[:], clock.Unix(), revokedHash[:], recoveryHash[:], "existing-pair", inviteeHash[:], recoveryHash[:]); err != nil { + t.Fatal(err) + } + for _, record := range []struct { + id, role, name string + hash []byte + }{ + {"phone", "inviter", "Phone", inviterHash[:]}, {"partner", "invitee", "Partner", inviteeHash[:]}, + } { + if _, err := db.Exec(`INSERT INTO device_sessions VALUES(?, ?, ?, ?, ?)`, record.id, record.role, record.hash, record.name, clock.Unix()); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec(`INSERT INTO device_migrations VALUES(1)`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO device_login_codes VALUES(?, 'inviter', 'phone', ?)`, codeHash[:], clock.Add(inviteTTL).Unix()); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO chat_messages VALUES(41, 'existing-pair', 'invitee', 'old-client-id', '你好', ?)`, clock.UnixMilli()); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO chat_reads VALUES('existing-pair', 'inviter', 41)`); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + defer service.Close() + service.now = func() time.Time { return clock } + for _, token := range []string{"phone-token", "partner-token"} { + session, err := service.Authenticate(token) + if err != nil || session.PairID != "existing-pair" { + t.Fatalf("existing login lost: %v", err) + } + } + if _, err := service.Authenticate("revoked-legacy-token"); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("revoked token restored: %v", err) + } + page, err := service.Messages("phone-token", 0, 0, 20) + if err != nil || len(page.Messages) != 1 || page.ReadID != 41 || page.UnreadCount != 0 { + t.Fatalf("existing history lost: %+v, %v", page, err) + } + if page.Messages[0].Text != "你好" || !page.Messages[0].CreatedAt.Equal(clock) { + t.Fatal("message text/timestamp changed") + } + devices, err := service.Devices("phone-token") + if err != nil || len(devices) != 1 || devices[0].CreatedAt.Unix() != clock.Unix() { + t.Fatalf("device timestamp changed: %v", err) + } + message, err := service.SendMessage("phone-token", "new-client-id", "hello") + if err != nil || message.ID <= 41 { + t.Fatalf("sequence did not continue: %+v, %v", message, err) + } + if _, err := service.RedeemDeviceLoginCode("existing-login-code", "Tablet"); err != nil { + t.Fatal("existing device code lost", err) + } + if _, err := service.Recover("existing-recovery"); err != nil { + t.Fatal("existing recovery code lost", err) + } +} + +// TestORMRecoveryRollsBackDeviceFailure verifies rotation and revocation are atomic. +func TestORMRecoveryRollsBackDeviceFailure(t *testing.T) { + service := NewService() + defer service.Close() + invite, partner := pairedDevices(t, service) + code, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + // Inject a storage failure after hashes rotate and previous devices are removed. + if err := service.db.Exec(`CREATE TRIGGER reject_device BEFORE INSERT ON device_sessions + BEGIN SELECT RAISE(ABORT, 'injected failure'); END`).Error; err != nil { + t.Fatal(err) + } + if _, err := service.Recover(invite.RecoveryCode); err == nil { + t.Fatal("expected storage failure") + } + for _, token := range []string{invite.InviterToken, partner.InviteeToken} { + if _, err := service.Authenticate(token); err != nil { + t.Fatal("rollback lost an existing session", err) + } + } + if err := service.db.Exec(`DROP TRIGGER reject_device`).Error; err != nil { + t.Fatal(err) + } + if _, err := service.RedeemDeviceLoginCode(code.Code, "Tablet"); err != nil { + t.Fatal("rollback lost outstanding code", err) + } + if _, err := service.Recover(invite.RecoveryCode); err != nil { + t.Fatal("rollback consumed recovery code", err) + } +} + +// TestORMExpiredInviteReplacement verifies zero/NULL fields and identity reset. +func TestORMExpiredInviteReplacement(t *testing.T) { + service := NewService() + defer service.Close() + clock := time.Now() + service.now = func() time.Time { return clock } + old, err := service.CreateInvite("http://localhost:8080") + if err != nil { + t.Fatal(err) + } + clock = old.ExpiresAt + fresh, err := service.CreateInvite("http://localhost:8081") + if err != nil { + t.Fatal(err) + } + if _, err := service.Authenticate(old.InviterToken); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("old inviter still active: %v", err) + } + session, err := service.Authenticate(fresh.InviterToken) + if err != nil || session.Status != "pending" || session.PairID != "" { + t.Fatalf("bad pending state: %+v, %v", session, err) + } + if _, err := service.RedeemInvite(inviteCode(t, old.URL)); !errors.Is(err, ErrInvalidInvite) { + t.Fatalf("old link still accepted: %v", err) + } + if _, err := service.RedeemInvite(inviteCode(t, fresh.URL)); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/pairing/profiles.go b/server/internal/pairing/profiles.go new file mode 100644 index 0000000..9106ff2 --- /dev/null +++ b/server/internal/pairing/profiles.go @@ -0,0 +1,85 @@ +package pairing + +import ( + "bytes" + "encoding/base64" + "errors" + "image" + _ "image/jpeg" + "image/png" + "strings" + "unicode" + "unicode/utf8" +) + +var ErrInvalidProfile = errors.New("nickname or avatar is invalid") + +const maxAvatarBytes = 256 * 1024 + +// Profile is a member's shared identity, independent of devices and credentials. +type Profile struct { + Nickname string `json:"nickname"` + AvatarBase64 string `json:"avatar_base64"` +} + +// profileRecord stores the two members without altering legacy pairing columns. +type profileRecord struct { + Role string `gorm:"primaryKey"` + Nickname string + AvatarBase64 string +} + +// TableName identifies the additive member-identity table. +func (profileRecord) TableName() string { return "member_profiles" } + +// normalizeProfile validates bounded images and strips metadata before storage. +func normalizeProfile(profile Profile) (Profile, error) { + profile.Nickname = strings.TrimSpace(profile.Nickname) + if !utf8.ValidString(profile.Nickname) || utf8.RuneCountInString(profile.Nickname) < 1 || utf8.RuneCountInString(profile.Nickname) > 32 || strings.ContainsFunc(profile.Nickname, unicode.IsControl) { + return Profile{}, ErrInvalidProfile + } + if len(profile.AvatarBase64) > base64.StdEncoding.EncodedLen(maxAvatarBytes) { + return Profile{}, ErrInvalidProfile + } + data, err := base64.StdEncoding.Strict().DecodeString(profile.AvatarBase64) + if err != nil || len(data) == 0 || len(data) > maxAvatarBytes { + return Profile{}, ErrInvalidProfile + } + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || (format != "png" && format != "jpeg") || config.Width < 1 || config.Height < 1 || config.Width > 512 || config.Height > 512 { + return Profile{}, ErrInvalidProfile + } + avatar, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return Profile{}, ErrInvalidProfile + } + var encoded bytes.Buffer + if err := png.Encode(&encoded, avatar); err != nil || encoded.Len() > maxAvatarBytes { + return Profile{}, ErrInvalidProfile + } + profile.AvatarBase64 = base64.StdEncoding.EncodeToString(encoded.Bytes()) + return profile, nil +} + +// SessionWithProfiles returns shared profiles only to an authenticated device. +func (service *Service) SessionWithProfiles(token string) (Session, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.authenticateDevice(token) + if err != nil { + return Session{}, err + } + var records []profileRecord + if err := service.db.Find(&records).Error; err != nil { + return Session{}, err + } + for _, record := range records { + profile := &Profile{Nickname: record.Nickname, AvatarBase64: record.AvatarBase64} + if record.Role == session.Role { + session.Profile = profile + } else if session.Status == "paired" { + session.Partner = profile + } + } + return session, nil +} diff --git a/server/internal/pairing/profiles_test.go b/server/internal/pairing/profiles_test.go new file mode 100644 index 0000000..49a1e20 --- /dev/null +++ b/server/internal/pairing/profiles_test.go @@ -0,0 +1,118 @@ +package pairing + +import ( + "bytes" + "encoding/base64" + "errors" + "image" + "image/png" + "net/url" + "path/filepath" + "strings" + "testing" + "time" +) + +func profileFixture(t *testing.T, name string) Profile { + t.Helper() + var buffer bytes.Buffer + if err := png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, 16, 16))); err != nil { + t.Fatal(err) + } + return Profile{Nickname: name, AvatarBase64: base64.StdEncoding.EncodeToString(buffer.Bytes())} +} + +func TestProfilesSurviveRestartRecoveryAndDeviceLogin(t *testing.T) { + path := filepath.Join(t.TempDir(), "pair.db") + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + defer func() { service.Close() }() + inviter := profileFixture(t, " 小灰 🐾 ") + invite, err := service.CreateInviteWithProfile("https://home.example.test", "Phone", inviter) + if err != nil { + t.Fatal(err) + } + uri, _ := url.Parse(invite.URL) + code := uri.Query().Get("code") + if _, err := service.RedeemInviteWithProfile(code, "Tablet", Profile{}); !errors.Is(err, ErrInvalidProfile) { + t.Fatal("invalid profile consumed invitation") + } + partner := profileFixture(t, "小绿") + paired, err := service.RedeemInviteWithProfile(code, "Tablet", partner) + if err != nil { + t.Fatal(err) + } + if err := service.Close(); err != nil { + t.Fatal(err) + } + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + assertProfiles := func(token, own, other string) { + t.Helper() + session, err := service.SessionWithProfiles(token) + if err != nil || session.Profile == nil || session.Partner == nil || session.Profile.Nickname != own || session.Partner.Nickname != other || session.Profile.AvatarBase64 == "" { + t.Fatalf("profiles not restored: %+v, %v", session, err) + } + } + assertProfiles(invite.InviterToken, "小灰 🐾", "小绿") + assertProfiles(paired.InviteeToken, "小绿", "小灰 🐾") + login, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + device, err := service.RedeemDeviceLoginCode(login.Code, "Laptop") + if err != nil { + t.Fatal(err) + } + assertProfiles(device.AccessToken, "小灰 🐾", "小绿") + recovered, err := service.Recover(invite.RecoveryCode) + if err != nil { + t.Fatal(err) + } + assertProfiles(recovered.AccessToken, "小灰 🐾", "小绿") + assertProfiles(paired.InviteeToken, "小绿", "小灰 🐾") +} + +func TestProfileValidationAndExpiredInvitationReplacement(t *testing.T) { + valid := profileFixture(t, "Ash") + for _, name := range []string{"", " ", strings.Repeat("灰", 33), "Ash\nGrey", "Ash\u0085Grey"} { + profile := valid + profile.Nickname = name + if _, err := normalizeProfile(profile); !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("invalid nickname accepted: %q", name) + } + } + for _, avatar := range []string{"", "not-base64", base64.StdEncoding.EncodeToString([]byte("not an image")), strings.Repeat("A", base64.StdEncoding.EncodedLen(maxAvatarBytes)+4)} { + profile := valid + profile.AvatarBase64 = avatar + if _, err := normalizeProfile(profile); !errors.Is(err, ErrInvalidProfile) { + t.Fatal("invalid avatar accepted") + } + } + var buffer bytes.Buffer + png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, 513, 1))) + profile := valid + profile.AvatarBase64 = base64.StdEncoding.EncodeToString(buffer.Bytes()) + if _, err := normalizeProfile(profile); !errors.Is(err, ErrInvalidProfile) { + t.Fatal("oversized dimensions accepted") + } + service := NewService() + defer service.Close() + invite, err := service.CreateInviteWithProfile("https://home.example.test", "Phone", valid) + if err != nil { + t.Fatal(err) + } + service.now = func() time.Time { return invite.ExpiresAt.Add(time.Second) } + replacement, err := service.CreateInviteWithProfile("https://home.example.test", "Phone", profileFixture(t, "Fern")) + if err != nil { + t.Fatal(err) + } + session, err := service.SessionWithProfiles(replacement.InviterToken) + if err != nil || session.Profile.Nickname != "Fern" || session.Partner != nil { + t.Fatal("expired invitation retained old identity") + } +} diff --git a/server/internal/pairing/service.go b/server/internal/pairing/service.go new file mode 100644 index 0000000..78258e1 --- /dev/null +++ b/server/internal/pairing/service.go @@ -0,0 +1,450 @@ +package pairing + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/logger" + _ "modernc.org/sqlite" +) + +var ( + ErrAlreadyPaired = errors.New("instance is already paired") + ErrInvitePending = errors.New("an invitation is already pending") + ErrInvalidInvite = errors.New("invite is invalid") + ErrExpiredInvite = errors.New("invite has expired") + ErrInvalidInviterToken = errors.New("inviter token is invalid") + ErrInvalidSessionToken = errors.New("session token is invalid") + ErrInvalidRecoveryCode = errors.New("recovery code is invalid") + ErrNotPaired = errors.New("instance is not paired") + ErrInvalidServerURL = errors.New("server URL is invalid") +) + +const inviteTTL = 10 * time.Minute + +// Invite is the one-time invitation returned to the inviting client. +type Invite struct { + URL string + InviterToken string + RecoveryCode string + ExpiresAt time.Time +} + +// PairingStatus describes the state visible to a paired client. +type PairingStatus struct { + PairID string + Status string + InviteeToken string + RecoveryCode string +} + +// RecoveredCredentials contains rotated credentials for a reinstalled client. +type RecoveredCredentials struct { + PairID string + Role string + AccessToken string + RecoveryCode string +} + +// Session is the authenticated member and pairing state for a client device. +type Session struct { + PairID string + Role string + Status string + DeviceID string + Profile *Profile + Partner *Profile +} + +// Service stores the single couple relationship for one private instance. +type Service struct { + db *gorm.DB + mu sync.Mutex + now func() time.Time +} + +// OpenService opens or creates the SQLite database used by the pairing flow. +func OpenService(databasePath string) (*Service, error) { + db, err := sql.Open("sqlite", databasePath) + if err != nil { + return nil, fmt.Errorf("open pairing database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + if _, err := db.Exec(`PRAGMA busy_timeout = 5000`); err != nil { + db.Close() + return nil, fmt.Errorf("configure pairing database: %w", err) + } + if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil { + db.Close() + return nil, fmt.Errorf("configure pairing database: %w", err) + } + // Reuse modernc's pure-Go connection rather than the dialector's CGO default. + orm, err := gorm.Open(sqlite.New(sqlite.Config{DriverName: "sqlite", Conn: db}), &gorm.Config{ + // SQL tracing could expose private messages, names or credential digests. + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + db.Close() + return nil, fmt.Errorf("open pairing ORM: %w", err) + } + if err := migrateSchema(orm); err != nil { + db.Close() + return nil, fmt.Errorf("create pairing schema: %w", err) + } + service := &Service{db: orm, now: time.Now} + if err := service.migrateDevices(); err != nil { + db.Close() + return nil, err + } + return service, nil +} + +// NewService creates an in-memory pairing service for isolated tests. +func NewService() *Service { + service, err := OpenService(":memory:") + if err != nil { + panic(err) + } + return service +} + +// Close releases the SQLite database connection. +func (service *Service) Close() error { + db, err := service.db.DB() + if err != nil { + return err + } + return db.Close() +} + +// CreateInvite creates one expiring invitation for the instance. +func (service *Service) CreateInvite(serverURL string, deviceNames ...string) (Invite, error) { + return service.createInvite(serverURL, nil, deviceNames...) +} + +// CreateInviteWithProfile saves the inviter's identity in the invitation transaction. +func (service *Service) CreateInviteWithProfile(serverURL, deviceName string, profile Profile) (Invite, error) { + profile, err := normalizeProfile(profile) + if err != nil { + return Invite{}, err + } + return service.createInvite(serverURL, &profile, deviceName) +} + +// createInvite also supports profile-free legacy service callers. +func (service *Service) createInvite(serverURL string, profile *Profile, deviceNames ...string) (Invite, error) { + baseURL, err := normalizeServerURL(serverURL) + if err != nil { + return Invite{}, err + } + + code, err := randomToken(32) + if err != nil { + return Invite{}, err + } + inviterToken, err := randomToken(32) + if err != nil { + return Invite{}, err + } + inviterRecovery, err := randomToken(24) + if err != nil { + return Invite{}, err + } + expiresAt := service.now().Add(inviteTTL) + codeHash := tokenHash(code) + inviterTokenHash := tokenHash(inviterToken) + inviterRecoveryHash := tokenHash(inviterRecovery) + + service.mu.Lock() + defer service.mu.Unlock() + + err = service.db.Transaction(func(tx *gorm.DB) error { + var existing pairingRecord + err := tx.Take(&existing, "id = ?", 1).Error + switch { + case err == nil && existing.Paired: + return ErrAlreadyPaired + case err == nil && service.now().Unix() < existing.InviteExpiresAt: + return ErrInvitePending + case err != nil && !errors.Is(err, gorm.ErrRecordNotFound): + return fmt.Errorf("read pairing state: %w", err) + } + record := pairingRecord{ID: 1, ServerURL: baseURL, InviteCodeHash: codeHash[:], + InviteExpiresAt: expiresAt.Unix(), InviterTokenHash: inviterTokenHash[:], + InviterRecoveryHash: inviterRecoveryHash[:]} + // Explicit columns include zero/NULL values when replacing an expired invite. + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{"server_url", "invite_code_hash", "invite_expires_at", + "inviter_token_hash", "inviter_recovery_hash", "pair_id", "invitee_token_hash", "invitee_recovery_hash", "paired"}), + }).Create(&record).Error; err != nil { + return fmt.Errorf("save pairing invitation: %w", err) + } + // An expired, unpaired invitation starts a new identity and device set. + if err := tx.Where("1 = 1").Delete(&profileRecord{}).Error; err != nil { + return err + } + if profile != nil { + if err := tx.Create(&profileRecord{Role: "inviter", Nickname: profile.Nickname, AvatarBase64: profile.AvatarBase64}).Error; err != nil { + return err + } + } + if err := tx.Where("1 = 1").Delete(&deviceCodeRecord{}).Error; err != nil { + return err + } + if err := tx.Where("1 = 1").Delete(&deviceRecord{}).Error; err != nil { + return err + } + _, err = service.insertDevice(tx, "inviter", inviterToken, deviceName(deviceNames)) + return err + }) + if err != nil { + return Invite{}, err + } + + return Invite{ + URL: "pawmate://pair?server=" + url.QueryEscape(baseURL) + "&code=" + url.QueryEscape(code), + InviterToken: inviterToken, + RecoveryCode: inviterRecovery, + ExpiresAt: expiresAt, + }, nil +} + +// RedeemInvite atomically consumes an invite and creates the instance pair. +func (service *Service) RedeemInvite(code string, deviceNames ...string) (PairingStatus, error) { + return service.redeemInvite(code, nil, deviceNames...) +} + +// RedeemInviteWithProfile saves the invitee's identity while consuming the code. +func (service *Service) RedeemInviteWithProfile(code, deviceName string, profile Profile) (PairingStatus, error) { + profile, err := normalizeProfile(profile) + if err != nil { + return PairingStatus{}, err + } + return service.redeemInvite(code, &profile, deviceName) +} + +// redeemInvite keeps identity creation and pairing completion atomic. +func (service *Service) redeemInvite(code string, profile *Profile, deviceNames ...string) (PairingStatus, error) { + service.mu.Lock() + defer service.mu.Unlock() + + var state pairingRecord + err := service.db.Take(&state, "id = ?", 1).Error + if errors.Is(err, gorm.ErrRecordNotFound) || state.Paired { + return PairingStatus{}, ErrInvalidInvite + } + if err != nil { + return PairingStatus{}, fmt.Errorf("read pairing invitation: %w", err) + } + if service.now().Unix() >= state.InviteExpiresAt { + return PairingStatus{}, ErrExpiredInvite + } + if !sameHash(state.InviteCodeHash, tokenHash(strings.TrimSpace(code))) { + return PairingStatus{}, ErrInvalidInvite + } + + pairID, err := randomToken(16) + if err != nil { + return PairingStatus{}, err + } + inviteeToken, err := randomToken(32) + if err != nil { + return PairingStatus{}, err + } + inviteeRecovery, err := randomToken(24) + if err != nil { + return PairingStatus{}, err + } + inviteeTokenHash := tokenHash(inviteeToken) + inviteeRecoveryHash := tokenHash(inviteeRecovery) + err = service.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&pairingRecord{}). + Where("id = ? AND paired = ? AND invite_code_hash = ? AND invite_expires_at > ?", 1, false, state.InviteCodeHash, service.now().Unix()). + Updates(map[string]any{"pair_id": pairID, "invitee_token_hash": inviteeTokenHash[:], + "invitee_recovery_hash": inviteeRecoveryHash[:], "paired": true}) + if result.Error != nil { + return fmt.Errorf("complete pairing: %w", result.Error) + } + if result.RowsAffected != 1 { + return ErrInvalidInvite + } + if profile != nil { + if err := tx.Create(&profileRecord{Role: "invitee", Nickname: profile.Nickname, AvatarBase64: profile.AvatarBase64}).Error; err != nil { + return err + } + } + _, err := service.insertDevice(tx, "invitee", inviteeToken, deviceName(deviceNames)) + return err + }) + if err != nil { + return PairingStatus{}, err + } + + return PairingStatus{ + PairID: pairID, + Status: "paired", + InviteeToken: inviteeToken, + RecoveryCode: inviteeRecovery, + }, nil +} + +// Status returns pairing state after validating the inviter's bearer token. +func (service *Service) Status(inviterToken string) (PairingStatus, error) { + session, err := service.Authenticate(inviterToken) + if errors.Is(err, ErrInvalidSessionToken) || (err == nil && session.Role != "inviter") { + return PairingStatus{}, ErrInvalidInviterToken + } + if err != nil { + return PairingStatus{}, err + } + return PairingStatus{PairID: session.PairID, Status: session.Status}, nil +} + +// Authenticate validates either member's access token and returns its session. +func (service *Service) Authenticate(accessToken string) (Session, error) { + service.mu.Lock() + defer service.mu.Unlock() + return service.authenticateDevice(accessToken) +} + +// authenticateDevice reads a device session while the caller holds the mutex. +func (service *Service) authenticateDevice(accessToken string) (Session, error) { + var record struct { + Paired bool + PairID string + DeviceID string + Role string + } + hash := tokenHash(strings.TrimSpace(accessToken)) + err := service.db.Table("device_sessions AS d"). + Select("p.paired, p.pair_id, d.id AS device_id, d.role"). + Joins("JOIN pairing_state AS p ON p.id = 1").Where("d.token_hash = ?", hash[:]).Take(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return Session{}, ErrInvalidSessionToken + } + if err != nil { + return Session{}, fmt.Errorf("read pairing session: %w", err) + } + + session := Session{PairID: record.PairID, DeviceID: record.DeviceID, Role: record.Role, Status: "pending"} + if record.Paired { + session.Status = "paired" + } + return session, nil +} + +// Recover rotates one member's credentials using their single-use recovery code. +func (service *Service) Recover(recoveryCode string, deviceNames ...string) (RecoveredCredentials, error) { + service.mu.Lock() + defer service.mu.Unlock() + + var state pairingRecord + err := service.db.Take(&state, "id = ?", 1).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return RecoveredCredentials{}, ErrNotPaired + } + if err != nil { + return RecoveredCredentials{}, fmt.Errorf("read recovery state: %w", err) + } + if !state.Paired || state.PairID == nil { + return RecoveredCredentials{}, ErrNotPaired + } + + providedHash := tokenHash(strings.TrimSpace(recoveryCode)) + role := "" + if sameHash(state.InviterRecoveryHash, providedHash) { + role = "inviter" + } else if sameHash(state.InviteeRecoveryHash, providedHash) { + role = "invitee" + } else { + return RecoveredCredentials{}, ErrInvalidRecoveryCode + } + + accessToken, err := randomToken(32) + if err != nil { + return RecoveredCredentials{}, err + } + newRecoveryCode, err := randomToken(24) + if err != nil { + return RecoveredCredentials{}, err + } + tokenColumn, recoveryColumn := "inviter_token_hash", "inviter_recovery_hash" + if role == "invitee" { + tokenColumn, recoveryColumn = "invitee_token_hash", "invitee_recovery_hash" + } + accessTokenHash := tokenHash(accessToken) + newRecoveryHash := tokenHash(newRecoveryCode) + err = service.db.Transaction(func(tx *gorm.DB) error { + // Column names are selected internally, never taken from client input. + result := tx.Model(&pairingRecord{}).Where("id = ? AND paired = ?", 1, true). + Where(recoveryColumn+" = ?", providedHash[:]). + Updates(map[string]any{tokenColumn: accessTokenHash[:], recoveryColumn: newRecoveryHash[:]}) + if result.Error != nil { + return fmt.Errorf("rotate recovered credentials: %w", result.Error) + } + if result.RowsAffected != 1 { + return ErrInvalidRecoveryCode + } + // Recovery remains an emergency reset. Normal device login is additive. + if err := tx.Where("role = ?", role).Delete(&deviceRecord{}).Error; err != nil { + return err + } + if err := tx.Where("role = ?", role).Delete(&deviceCodeRecord{}).Error; err != nil { + return err + } + _, err := service.insertDevice(tx, role, accessToken, deviceName(deviceNames)) + return err + }) + if err != nil { + return RecoveredCredentials{}, err + } + return RecoveredCredentials{ + PairID: *state.PairID, + Role: role, + AccessToken: accessToken, + RecoveryCode: newRecoveryCode, + }, nil +} + +// normalizeServerURL validates and canonicalizes the URL embedded in an invite. +func normalizeServerURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", ErrInvalidServerURL + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", ErrInvalidServerURL + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + return strings.TrimRight(parsed.String(), "/"), nil +} + +func tokenHash(token string) [sha256.Size]byte { + return sha256.Sum256([]byte(token)) +} + +func sameHash(stored []byte, provided [sha256.Size]byte) bool { + return len(stored) == len(provided) && subtle.ConstantTimeCompare(stored, provided[:]) == 1 +} + +// randomToken creates an URL-safe cryptographic bearer token. +func randomToken(size int) (string, error) { + data := make([]byte, size) + if _, err := rand.Read(data); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(data), nil +} diff --git a/server/internal/pairing/service_test.go b/server/internal/pairing/service_test.go new file mode 100644 index 0000000..06f6b93 --- /dev/null +++ b/server/internal/pairing/service_test.go @@ -0,0 +1,74 @@ +package pairing + +import ( + "errors" + "net/url" + "testing" + "time" +) + +func TestInviteCanBeRedeemedOnlyOnce(t *testing.T) { + service := NewService() + invite, err := service.CreateInvite("https://home.example.test") + if err != nil { + t.Fatal(err) + } + + code := inviteCode(t, invite.URL) + status, err := service.RedeemInvite(code) + if err != nil { + t.Fatal(err) + } + if status.Status != "paired" || status.PairID == "" || status.InviteeToken == "" { + t.Fatalf("unexpected pairing status: %+v", status) + } + if _, err := service.RedeemInvite(code); !errors.Is(err, ErrInvalidInvite) { + t.Fatalf("second redemption error = %v, want ErrInvalidInvite", err) + } +} + +func TestInstanceAllowsOnlyOnePendingInvite(t *testing.T) { + service := NewService() + if _, err := service.CreateInvite("http://localhost:8080/"); err != nil { + t.Fatal(err) + } + if _, err := service.CreateInvite("http://localhost:8080"); !errors.Is(err, ErrInvitePending) { + t.Fatalf("second invite error = %v, want ErrInvitePending", err) + } +} + +func TestExpiredInviteCannotBeRedeemed(t *testing.T) { + service := NewService() + clock := time.Now() + service.now = func() time.Time { return clock } + invite, err := service.CreateInvite("https://home.example.test") + if err != nil { + t.Fatal(err) + } + clock = invite.ExpiresAt + if _, err := service.RedeemInvite(inviteCode(t, invite.URL)); !errors.Is(err, ErrExpiredInvite) { + t.Fatalf("redemption error = %v, want ErrExpiredInvite", err) + } +} + +func TestServerURLMustNotContainCredentialsOrQuery(t *testing.T) { + service := NewService() + for _, rawURL := range []string{ + "https://user:password@example.test", + "https://example.test?redirect=other", + "ftp://example.test", + } { + if _, err := service.CreateInvite(rawURL); !errors.Is(err, ErrInvalidServerURL) { + t.Fatalf("CreateInvite(%q) error = %v, want ErrInvalidServerURL", rawURL, err) + } + } +} + +func inviteCode(t *testing.T, rawURL string) string { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + return parsed.Query().Get("code") +} diff --git a/snap/gui/pawmate.desktop b/snap/gui/pawmate.desktop new file mode 100644 index 0000000..424c5e3 --- /dev/null +++ b/snap/gui/pawmate.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=pawmate +Comment=Generated by Icons Launcher +Exec=pawmate +Icon=${SNAP}/meta/gui/pawmate.png +Terminal=false +Type=Application +Categories=Entertainment; diff --git a/snap/gui/pawmate.png b/snap/gui/pawmate.png new file mode 100644 index 0000000..b08d82a Binary files /dev/null and b/snap/gui/pawmate.png differ diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..40892e1 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,28 @@ +name: pawmate +version: 1.0.0 +summary: Generated by Icons Launcher +description: Generated by Icons Launcher package for Linux. + +confinement: strict +base: core22 +grade: stable + +slots: + dbus-pawmate: # adjust accordingly to your app name + interface: dbus + bus: session + name: org.bar.pawmate # adjust accordingly to your app name + +apps: + pawmate: + command: pawmate + extensions: [gnome] # gnome includes the libraries required by flutter + plugs: + - network + slots: + - dbus-pawmate +parts: + pawmate: + source: . + plugin: flutter + flutter-target: lib/main.dart # The main entry-point file of the application diff --git a/test/chat_test.dart b/test/chat_test.dart new file mode 100644 index 0000000..68a7fd8 --- /dev/null +++ b/test/chat_test.dart @@ -0,0 +1,331 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; +import 'package:pawmate/features/chat/chat_api.dart'; +import 'package:pawmate/features/chat/chat_controller.dart'; +import 'package:pawmate/features/chat/chat_page.dart'; +import 'package:pawmate/features/pairing/pairing_credentials.dart'; +import 'package:pawmate/features/pairing/paired_home_page.dart'; +import 'package:pawmate/features/pairing/pairing_api.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; + +const credentials = SavedPairingCredentials( + serverURL: 'https://home.example.test', + accessToken: 'phone-token', + recoveryCode: '', + pairID: 'our-home', + role: 'inviter', +); + +Map message( + int id, + String sender, { + String clientID = 'client-id', + String text = '你好 🐾', +}) => { + 'id': id, + 'client_id': clientID, + 'sender': sender, + 'text': text, + 'created_at': '2026-10-04T10:00:00Z', +}; + +http.Response snapshot( + List> messages, { + int read = 0, + int partnerRead = 0, + int unread = 0, + int latest = 0, +}) => http.Response.bytes( + utf8.encode( + jsonEncode({ + 'messages': messages, + 'has_more': false, + 'latest_id': latest, + 'read_id': read, + 'partner_read_id': partnerRead, + 'unread_count': unread, + }), + ), + 200, +); + +void main() { + test( + 'a lost send response retries with the same id and no duplicate bubble', + () async { + var attempts = 0; + final ids = []; + final chat = ChatController( + ChatApi( + credentials, + MockClient((request) async { + expect(request.headers['authorization'], 'Bearer phone-token'); + if (request.method == 'GET') return snapshot([]); + final body = jsonDecode(request.body) as Map; + ids.add(body['client_id'] as String); + if (attempts++ == 0) throw http.ClientException('response lost'); + return http.Response.bytes( + utf8.encode( + jsonEncode(message(1, 'inviter', clientID: ids.last)), + ), + 200, + ); + }), + ), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + await chat.send('你好 🐾'); + expect(chat.outbox.single.sending, isFalse); + await chat.retry(chat.outbox.single); + await Future.delayed(Duration.zero); + expect(ids[0], ids[1]); + expect(chat.outbox, isEmpty); + expect(chat.messages.single.text, '你好 🐾'); + }, + ); + + test( + 'a send response ahead of polling does not skip an intervening partner message', + () async { + final catchup = Completer(); + final afterIDs = []; + final chat = ChatController( + ChatApi( + credentials, + MockClient((request) async { + if (request.method == 'POST') { + final body = jsonDecode(request.body) as Map; + return http.Response.bytes( + utf8.encode( + jsonEncode( + message( + 3, + 'inviter', + clientID: body['client_id'] as String, + ), + ), + ), + 200, + ); + } + afterIDs.add(request.url.queryParameters['after_id']); + if (afterIDs.length == 1) { + return snapshot([message(1, 'inviter')], latest: 1); + } + return catchup.future; + }), + ), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + await chat.send('another message'); + await Future.delayed(Duration.zero); + expect(chat.caughtUp, isFalse); + expect(afterIDs.last, '1'); + catchup.complete( + snapshot( + [message(2, 'invitee'), message(3, 'inviter')], + latest: 3, + unread: 1, + ), + ); + await Future.delayed(Duration.zero); + expect(chat.messages.map((item) => item.id), [1, 2, 3]); + expect(chat.unreadCount, 1); + expect(chat.caughtUp, isTrue); + }, + ); + + test( + 'hidden chat and background application never advance read progress', + () async { + var read = 0; + var readRequests = 0; + final chat = ChatController( + ChatApi( + credentials, + MockClient((request) async { + if (request.method == 'POST') { + readRequests++; + read = + (jsonDecode(request.body) + as Map)['message_id'] + as int; + return http.Response(jsonEncode({'read_id': read}), 200); + } + return snapshot( + [message(7, 'invitee'), message(10, 'invitee')], + latest: 10, + read: read, + unread: read == 0 ? 2 : 1, + ); + }), + ), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + chat.setChatVisible(false); + await chat.markVisibleRead(10); + chat.setChatVisible(true); + chat.setForeground(false); + await chat.markVisibleRead(10); + expect(readRequests, 0); + chat.setForeground(true); + await Future.delayed(Duration.zero); + await chat.markVisibleRead(7); + await Future.delayed(Duration.zero); + expect(read, 7); + expect(chat.unreadCount, 1); + await chat.markVisibleRead(5); + expect(readRequests, 1); + }, + ); + + testWidgets( + 'only a visible foreground chat acknowledges rendered incoming messages', + (tester) async { + var read = 0; + final chat = ChatController( + ChatApi( + credentials, + MockClient((request) async { + if (request.method == 'POST') { + read = + (jsonDecode(request.body) + as Map)['message_id'] + as int; + return http.Response(jsonEncode({'read_id': read}), 200); + } + return snapshot( + [message(1, 'invitee'), message(2, 'inviter')], + latest: 2, + read: read, + partnerRead: 2, + unread: read == 0 ? 1 : 0, + ); + }), + ), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + Widget page(bool active) => MaterialApp( + theme: buildPawmateTheme(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ChatPage(controller: chat, active: active), + ), + ); + await tester.pumpWidget(page(false)); + await tester.pumpAndSettle(); + expect(read, 0); + expect(find.textContaining('Read'), findsOneWidget); + await tester.pumpWidget(page(true)); + await tester.pumpAndSettle(); + expect(read, 1); + expect(chat.unreadCount, 0); + }, + ); + + testWidgets('chat composer fits large accessibility text on a small screen', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final chat = ChatController( + ChatApi(credentials, MockClient((_) async => snapshot([]))), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + await tester.pumpWidget( + MaterialApp( + theme: buildPawmateTheme(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: Scaffold(body: ChatPage(controller: chat, active: true)), + ), + ); + await tester.enterText(find.byType(TextField), 'hello\nfrom a tablet'); + await tester.pump(); + expect(find.byTooltip('Send message'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('couple space opens on Chat and alerts without reading on Home', ( + tester, + ) async { + var newMessage = false; + var read = 0; + final api = ChatApi( + credentials, + MockClient((request) async { + if (request.method == 'POST') { + read = + (jsonDecode(request.body) as Map)['message_id'] + as int; + return http.Response(jsonEncode({'read_id': read}), 200); + } + final after = + int.tryParse(request.url.queryParameters['after_id'] ?? '0') ?? 0; + return snapshot( + [ + if (after < 1) message(1, 'inviter'), + if (newMessage && after < 2) + message(2, 'invitee', clientID: 'partner-message'), + ], + latest: newMessage ? 2 : 1, + read: read, + unread: newMessage && read < 2 ? 1 : 0, + ); + }), + ); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpWidget( + MaterialApp( + theme: buildPawmateTheme(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: PairedHomePage( + credentials: credentials, + session: const PairingSession( + role: 'inviter', + status: 'paired', + pairID: 'our-home', + ), + chatApi: api, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Our conversation'), findsOneWidget); + await tester.tap(find.text('Home')); + await tester.pumpAndSettle(); + newMessage = true; + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(read, 0); + expect(find.text('1'), findsOneWidget); + expect(find.text('1 unread messages from your partner'), findsOneWidget); + await tester.tap(find.text('Chat')); + await tester.pumpAndSettle(); + expect(read, 2); + expect(find.text('1'), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); +} diff --git a/test/device_login_api_test.dart b/test/device_login_api_test.dart new file mode 100644 index 0000000..916725d --- /dev/null +++ b/test/device_login_api_test.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pawmate/features/pairing/pairing_api.dart'; + +void main() { + test( + 'device login sends no existing token and receives no recovery secret', + () async { + final api = PairingApi( + MockClient((request) async { + expect( + request.url.path, + '/api/v1/pairing/devices/login-codes/redeem', + ); + expect(request.headers.containsKey('authorization'), isFalse); + expect(jsonDecode(request.body), { + 'code': 'single-use-code', + 'device_name': 'My tablet', + }); + return http.Response( + jsonEncode({ + 'access_token': 'tablet-token', + 'pair_id': 'our-home', + 'role': 'inviter', + }), + 200, + ); + }), + ); + addTearDown(api.close); + final credentials = await api.redeemDeviceLoginCode( + 'https://home.example.test', + ' single-use-code ', + ' My tablet ', + ); + expect(credentials.accessToken, 'tablet-token'); + expect(credentials.pairID, 'our-home'); + expect(credentials.role, 'inviter'); + }, + ); + + test('device operations authenticate with the installation token', () async { + final requests = []; + final api = PairingApi( + MockClient((request) async { + requests.add(request); + expect(request.headers['authorization'], 'Bearer tablet-token'); + if (request.method == 'DELETE') return http.Response('', 204); + if (request.method == 'POST') { + return http.Response( + jsonEncode({ + 'code': 'new-code', + 'expires_at': '2026-10-04T10:00:00Z', + }), + 201, + ); + } + return http.Response( + jsonEncode({ + 'devices': [ + { + 'id': 'tablet-id', + 'name': 'My tablet', + 'current': true, + 'created_at': '2026-10-04T09:00:00Z', + }, + { + 'id': 'phone-id', + 'name': 'Phone', + 'current': false, + 'created_at': '2026-10-03T09:00:00Z', + }, + ], + }), + 200, + ); + }), + ); + addTearDown(api.close); + final devices = await api.getDevices( + 'https://home.example.test', + 'tablet-token', + ); + expect(devices.where((device) => device.current).single.id, 'tablet-id'); + final code = await api.createDeviceLoginCode( + 'https://home.example.test', + 'tablet-token', + ); + expect(code.code, 'new-code'); + await api.revokeDevice( + 'https://home.example.test', + 'tablet-token', + 'phone-id', + ); + expect(requests.last.url.path, '/api/v1/pairing/devices/phone-id'); + }); + + test( + 'expired codes and revoked sessions preserve their HTTP error status', + () async { + final api = PairingApi( + MockClient( + (request) async => http.Response( + jsonEncode({ + 'error': request.method == 'POST' + ? 'invalid_device_code' + : 'invalid_session_token', + }), + 401, + ), + ), + ); + addTearDown(api.close); + final isUnauthorized = isA().having( + (error) => error.statusCode, + 'status', + 401, + ); + await expectLater( + api.redeemDeviceLoginCode( + 'https://home.example.test', + 'expired', + 'Tablet', + ), + throwsA(isUnauthorized), + ); + await expectLater( + api.getDevices('https://home.example.test', 'revoked'), + throwsA(isUnauthorized), + ); + }, + ); +} diff --git a/test/login_methods_test.dart b/test/login_methods_test.dart new file mode 100644 index 0000000..87e5d66 --- /dev/null +++ b/test/login_methods_test.dart @@ -0,0 +1,222 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:pawmate/design/components/handdrawn_action_card.dart'; +import 'package:pawmate/design/icons/access/access_doodle_icon.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; +import 'package:pawmate/features/pairing/invitation_link.dart'; +import 'package:pawmate/features/pairing/login_methods_page.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; + +const invitation = + 'pawmate://pair?server=http%3A%2F%2F100.64.0.1%3A8080&code=one-time-code'; + +/// Builds the landing screen directly without startup network requests. +Widget landing({double textScale = 1}) => MaterialApp( + theme: buildPawmateTheme(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: const LoginMethodsPage(), +); + +void main() { + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + test('development builds accept HTTP and HTTPS invitation URLs', () { + expect( + InvitationLink.parse(' $invitation ').queryParameters['server'], + 'http://100.64.0.1:8080', + ); + expect( + InvitationLink.parse( + 'pawmate://pair?server=https%3A%2F%2Fhome.example.test&code=abc', + ).host, + 'pair', + ); + for (final value in [ + '', + 'https://example.test', + 'pawmate://pair?code=abc', + 'pawmate://pair?server=http%3A%2F%2Fhost&code=', + '$invitation&code=second-code', + '$invitation&server=http%3A%2F%2Fother', + '$invitation#fragment', + 'pawmate://user:secret@pair?server=http%3A%2F%2Fhost&code=abc', + 'pawmate://pair?server=ftp%3A%2F%2Fhost&code=abc', + 'pawmate://pair?server=https%3A%2F%2Fuser%3Asecret%40host&code=abc', + 'pawmate://pair/wrong?server=http%3A%2F%2Fhost&code=abc', + ]) { + expect( + () => InvitationLink.parse(value), + throwsFormatException, + reason: value, + ); + } + }); + + testWidgets('four access cards occupy two rows and two columns', ( + tester, + ) async { + await tester.pumpWidget(landing()); + final cards = find.byType(HanddrawnActionCard); + expect(cards, findsNWidgets(4)); + expect(find.byType(AccessDoodleIcon), findsNWidgets(4)); + final first = tester.getRect(cards.at(0)); + final second = tester.getRect(cards.at(1)); + final third = tester.getRect(cards.at(2)); + final fourth = tester.getRect(cards.at(3)); + expect(first.top, second.top); + expect(third.top, fourth.top); + expect(first.left, third.left); + expect(second.left, fourth.left); + expect(third.top, greaterThan(first.bottom)); + expect(find.byType(TextFormField), findsNothing); + }); + + testWidgets( + 'small screens preserve the matrix and large text without overflow', + (tester) async { + tester.view.physicalSize = const Size(320, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(landing(textScale: 2)); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('Restore data')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Restore data')); + await tester.pumpAndSettle(); + expect(find.text('Restore your home'), findsOneWidget); + await tester.scrollUntilVisible(find.text('Recovery code'), 300); + await tester.pumpAndSettle(); + expect(find.text('Recovery code'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'invitation input validates before navigating and requires review', + (tester) async { + await tester.pumpWidget(landing()); + await tester.tap(find.text('Accept invitation')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Review invitation')); + await tester.pumpAndSettle(); + expect(find.text('This invitation link is not valid.'), findsOneWidget); + await tester.enterText(find.byType(TextFormField), invitation); + await tester.tap(find.text('Review invitation')); + await tester.pumpAndSettle(); + expect(find.text('You have been invited'), findsOneWidget); + expect(find.text('http://100.64.0.1:8080'), findsOneWidget); + expect(find.text('Accept invitation'), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.text(invitation), findsOneWidget); + }, + ); + + testWidgets('clipboard is read only after pressing Paste link', ( + tester, + ) async { + var reads = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.getData') { + reads++; + return {'text': invitation}; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await tester.pumpWidget(landing()); + await tester.tap(find.text('Accept invitation')); + await tester.pumpAndSettle(); + expect(reads, 0); + await tester.tap(find.text('Paste link')); + await tester.pumpAndSettle(); + expect(reads, 1); + expect(find.text(invitation), findsOneWidget); + expect(find.text('You have been invited'), findsNothing); + }); + + testWidgets('access cards expose button semantics and keyboard activation', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(landing()); + expect(find.bySemanticsLabel('Accept invitation'), findsOneWidget); + final node = tester.getSemantics( + find.bySemanticsLabel('Accept invitation'), + ); + expect(node.getSemanticsData().hasAction(ui.SemanticsAction.tap), isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + // The language control now precedes the access cards in focus order. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(find.text('Server URL or domain'), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('crayon pixels remain identical after rebuilding each purpose', ( + tester, + ) async { + final key = GlobalKey(); + for (final symbol in AccessDoodle.values) { + var generation = 0; + Widget icon() => MaterialApp( + home: Center( + child: RepaintBoundary( + key: key, + child: AccessDoodleIcon(key: ValueKey(generation), symbol: symbol), + ), + ), + ); + await tester.pumpWidget(icon()); + await tester.pumpAndSettle(); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final before = await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = await image.toByteData(); + image.dispose(); + return bytes!.buffer.asUint8List(); + }); + generation++; + await tester.pumpWidget(icon()); + await tester.pumpAndSettle(); + final after = await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = await image.toByteData(); + image.dispose(); + return bytes!.buffer.asUint8List(); + }); + expect(after, before); + } + }); +} diff --git a/test/member_profile_test.dart b/test/member_profile_test.dart new file mode 100644 index 0000000..3044e81 --- /dev/null +++ b/test/member_profile_test.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pawmate/design/components/crayon_avatar_button.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; +import 'package:pawmate/features/pairing/member_profile.dart'; +import 'package:pawmate/features/pairing/pairing_api.dart'; +import 'package:pawmate/features/pairing/widgets/member_profile_editor.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; + +Widget localized(Widget child, {Locale locale = const Locale('en')}) => + MaterialApp( + theme: buildPawmateTheme(), + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: SingleChildScrollView(child: child)), + ); + +Future imageFixture() async { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawColor(Colors.pink, BlendMode.src); + final picture = recorder.endRecording(); + final image = await picture.toImage(64, 32); + try { + final data = await image.toByteData(format: ui.ImageByteFormat.png); + return data!.buffer.asUint8List(); + } finally { + image.dispose(); + picture.dispose(); + } +} + +void main() { + testWidgets( + 'requires avatar and nickname and retains an image when selection is cancelled', + (tester) async { + final bytes = (await tester.runAsync(imageFixture))!; + final form = GlobalKey(); + final editor = GlobalKey(); + var cancel = false; + await tester.pumpWidget( + localized( + Form( + key: form, + child: MemberProfileEditor( + key: editor, + selectImage: () async => cancel + ? null + : XFile.fromData( + bytes, + name: 'portrait.png', + mimeType: 'image/png', + ), + ), + ), + ), + ); + expect(form.currentState!.validate(), isFalse); + await tester.pump(); + expect(find.text('Choose an avatar before continuing.'), findsOneWidget); + await tester.enterText(find.byType(TextFormField), ' 小灰 🐾 '); + await tester.runAsync(() async { + await tester.tap(find.byType(CrayonAvatarButton)); + await Future.delayed(const Duration(milliseconds: 150)); + }); + await tester.pumpAndSettle(); + expect(form.currentState!.validate(), isTrue); + final profile = editor.currentState!.profile!; + expect(profile.nickname, '小灰 🐾'); + final png = ByteData.sublistView(profile.avatar); + expect(png.getUint32(16), 256); + expect(png.getUint32(20), 256); + expect(profile.avatar.length, lessThanOrEqualTo(256 * 1024)); + cancel = true; + await tester.tap(find.byType(CrayonAvatarButton)); + await tester.pumpAndSettle(); + expect(editor.currentState!.profile!.avatar, profile.avatar); + await tester.enterText(find.byType(TextFormField), 'a' * 33); + expect(form.currentState!.validate(), isFalse); + }, + ); + + testWidgets( + 'invalid image displays a translated error without creating a profile', + (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + localized( + Form( + child: MemberProfileEditor( + key: key, + selectImage: () async => XFile.fromData( + Uint8List.fromList([1, 2, 3]), + name: 'broken.png', + ), + ), + ), + locale: const Locale('zh'), + ), + ); + await tester.runAsync(() async { + await tester.tap(find.byType(CrayonAvatarButton)); + await Future.delayed(const Duration(milliseconds: 150)); + }); + await tester.pumpAndSettle(); + expect(key.currentState!.profile, isNull); + expect(find.text('无法读取这张图片,请选择小于 10 MB 的 PNG 或 JPEG 图片'), findsOneWidget); + }, + ); + + test( + 'both invitations send member identity and sessions restore both profiles', + () async { + final profile = MemberProfile( + nickname: 'Ash', + avatar: Uint8List.fromList([1, 2, 3]), + ); + final paths = []; + final api = PairingApi( + MockClient((request) async { + paths.add(request.url.path); + if (request.method == 'GET') { + expect(request.headers['authorization'], 'Bearer token'); + return http.Response( + jsonEncode({ + 'role': 'inviter', + 'status': 'paired', + 'profile': profile.toJson(), + 'partner': profile.toJson(), + }), + 200, + ); + } + final body = jsonDecode(request.body) as Map; + expect(body['profile'], profile.toJson()); + if (request.url.path.endsWith('redeem')) { + return http.Response( + '{"status":"paired","pair_id":"pair","invitee_token":"token","recovery_code":"recovery"}', + 200, + ); + } + return http.Response( + '{"expires_at":"2026-10-05T00:00:00Z","invite_url":"pawmate://pair","inviter_token":"token","recovery_code":"recovery"}', + 201, + ); + }), + ); + addTearDown(api.close); + await api.createInvite('https://home.example.test', profile: profile); + await api.redeemInvite( + 'https://home.example.test', + 'code', + profile: profile, + ); + final session = await api.validateSession( + 'https://home.example.test', + 'token', + ); + expect(session.profile!.nickname, 'Ash'); + expect(session.partner!.avatar, profile.avatar); + expect(paths.length, 3); + }, + ); +} diff --git a/test/onboarding_preview_test.dart b/test/onboarding_preview_test.dart new file mode 100644 index 0000000..f031cca --- /dev/null +++ b/test/onboarding_preview_test.dart @@ -0,0 +1,117 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pawmate/design/components/handdrawn_button.dart'; +import 'package:pawmate/design/layouts/handdrawn_scaffold.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; +import 'package:pawmate/features/pairing/login_methods_page.dart'; +import 'package:pawmate/features/pairing/widgets/pairing_recovery_card.dart'; +import 'package:pawmate/features/pairing/widgets/server_setup_card.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; + +/// Opt-in visual captures using a caller-provided reading font, not test Ahem. +/// Run with PAWMATE_PREVIEW_FONT and --dart-define=PAWMATE_CAPTURE_PREVIEWS=true. +void main() { + testWidgets( + 'capture previous stacked reference and new access matrix', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final fontPath = Platform.environment['PAWMATE_PREVIEW_FONT']; + expect( + fontPath, + isNotNull, + reason: 'Provide a local reading-font TTF for previews.', + ); + await tester.runAsync(() async { + final bytes = await File(fontPath!).readAsBytes(); + final loader = FontLoader('PawmatePreview') + ..addFont(Future.value(ByteData.sublistView(bytes))); + await loader.load(); + final icons = FontLoader('MaterialIcons') + ..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf')); + await icons.load(); + }); + final controllers = List.generate(3, (_) => TextEditingController()); + addTearDown(() { + for (final controller in controllers) { + controller.dispose(); + } + }); + // A non-interactive reconstruction of the former stacked composition. + final before = HanddrawnScaffold( + title: 'Invite your partner', + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + const SizedBox(height: 18), + HanddrawnButton( + label: 'Sign in on another device', + onPressed: () {}, + primary: false, + icon: Icons.devices, + ), + const SizedBox(height: 20), + ServerSetupCard( + formKey: GlobalKey(), + controller: controllers[0], + isLoading: false, + onCreateInvite: () {}, + ), + const SizedBox(height: 18), + PairingRecoveryCard( + serverController: controllers[1], + codeController: controllers[2], + isLoading: false, + onRecover: () {}, + ), + ], + ), + ), + ); + final theme = buildPawmateTheme(); + for (final entry in { + 'login-before': before, + 'login-matrix': const LoginMethodsPage(), + }.entries) { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme.copyWith( + textTheme: theme.textTheme.apply(fontFamily: 'PawmatePreview'), + ), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: RepaintBoundary(key: key, child: entry.value), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.runAsync(() async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + await File( + 'books/src/images/${entry.key}.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + }); + } + }, + skip: !const bool.fromEnvironment('PAWMATE_CAPTURE_PREVIEWS'), + ); +} diff --git a/test/profile_preview_test.dart b/test/profile_preview_test.dart new file mode 100644 index 0000000..acdbe02 --- /dev/null +++ b/test/profile_preview_test.dart @@ -0,0 +1,99 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; +import 'package:pawmate/features/pairing/invitee_accept_page.dart'; +import 'package:pawmate/features/pairing/inviter_setup_page.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; + +/// Opt-in native-widget previews using a real reading font instead of Ahem. +void main() { + testWidgets( + 'capture member identity onboarding in both languages', + (tester) async { + FlutterSecureStorage.setMockInitialValues({}); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.runAsync(() async { + final font = FontLoader('PawmatePreview') + ..addFont( + Future.value( + ByteData.sublistView( + await File( + Platform.environment['PAWMATE_PREVIEW_FONT']!, + ).readAsBytes(), + ), + ), + ); + await font.load(); + final monospace = FontLoader('monospace') + ..addFont( + Future.value( + ByteData.sublistView( + await File( + Platform.environment['PAWMATE_PREVIEW_MONO_FONT'] ?? + Platform.environment['PAWMATE_PREVIEW_FONT']!, + ).readAsBytes(), + ), + ), + ); + await monospace.load(); + final icons = FontLoader('MaterialIcons') + ..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf')); + await icons.load(); + }); + final theme = buildPawmateTheme(); + for (final language in ['en', 'zh']) { + for (final entry in { + 'inviter': const InviterSetupPage(), + 'accept': InviteeAcceptPage( + inviteUri: Uri.parse( + 'pawmate://pair?server=https%3A%2F%2Fhome.example.test&code=preview', + ), + ), + }.entries) { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + locale: Locale(language), + theme: theme.copyWith( + textTheme: theme.textTheme.apply(fontFamily: 'PawmatePreview'), + ), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: RepaintBoundary(key: key, child: entry.value), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.runAsync(() async { + final image = + await (key.currentContext!.findRenderObject()! + as RenderRepaintBoundary) + .toImage(); + try { + final data = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + await File( + 'books/src/images/profile-${entry.key}-$language.png', + ).writeAsBytes(data!.buffer.asUint8List()); + } finally { + image.dispose(); + } + }); + await tester.pumpWidget(const SizedBox()); + } + } + }, + skip: !const bool.fromEnvironment('PAWMATE_CAPTURE_PREVIEWS'), + ); +} diff --git a/test/server_url_test.dart b/test/server_url_test.dart new file mode 100644 index 0000000..210745c --- /dev/null +++ b/test/server_url_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pawmate/features/pairing/pairing_api.dart'; + +void main() { + test('development builds accept HTTP private instance URLs', () { + expect( + PairingApi.parseServerURL(' http://100.64.0.1:8080/ ').toString(), + 'http://100.64.0.1:8080', + ); + }); + + test('HTTPS and instance path prefixes remain supported', () { + expect( + PairingApi.parseServerURL( + 'https://home.example.test/pawmate/', + ).toString(), + 'https://home.example.test/pawmate', + ); + }); + + test('unsafe or unsupported instance URLs remain rejected', () { + for (final value in [ + '', + 'home.example.test', + 'ftp://home.example.test', + 'http://user:secret@home.example.test', + 'http://home.example.test?token=secret', + 'http://home.example.test#secret', + ]) { + expect( + () => PairingApi.parseServerURL(value), + throwsA(isA()), + reason: value, + ); + } + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 7694f19..99a2333 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,67 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:pawmate/main.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; +import 'package:pawmate/features/pairing/device_login_page.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('access landing page opens focused invitation and device flows', ( + WidgetTester tester, + ) async { + FlutterSecureStorage.setMockInitialValues({}); + await tester.pumpWidget(const PawmateApp()); + await tester.pumpAndSettle(); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('Create invitation'), findsOneWidget); + expect(find.text('Accept invitation'), findsOneWidget); + expect(find.text('Restore data'), findsOneWidget); + expect(find.text('Add device'), findsOneWidget); + expect(find.byType(TextFormField), findsNothing); + await tester.tap(find.text('Create invitation')); + await tester.pumpAndSettle(); + expect(find.text('Invite your partner'), findsOneWidget); + expect(find.text('Server URL or domain'), findsOneWidget); + expect(find.text('Create invitation'), findsOneWidget); + expect(find.text('Recovery code'), findsNothing); + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add device')); + await tester.pumpAndSettle(); + expect(find.text('Device login code'), findsOneWidget); + expect(find.text('Device name'), findsOneWidget); }); + + testWidgets( + 'device login validates input and fits a small screen with large text', + (tester) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: buildPawmateTheme(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: const DeviceLoginPage(), + ), + ); + await tester.ensureVisible(find.text('Sign in')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign in')); + await tester.pumpAndSettle(); + expect(find.text('Enter your server address.'), findsOneWidget); + expect(find.text('Enter the login code.'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); } diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..f1273ab Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..996d670 Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..b3799fa Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..996d670 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..b3799fa Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..3b573df --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + pawmate + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..55bede9 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "pawmate", + "short_name": "pawmate", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..423749c --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(pawmate LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "pawmate") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..a44b858 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..7344781 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + app_links + file_selector_windows + flutter_secure_storage_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..97df071 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "pawmate" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "pawmate" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "pawmate.exe" "\0" + VALUE "ProductName", "pawmate" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..f0e77c9 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"pawmate", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..6ba502e Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_