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