From 195f5e400edfab10b88514130895918c2591878d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 11 Aug 2026 23:47:14 +0800 Subject: [PATCH 01/16] docs(book): add book chapters (#1) of server pairing foundation --- books/src/SUMMARY.md | 1 + .../server-pairing-foundation.md | 157 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 books/src/first-vertical-slice/server-pairing-foundation.md diff --git a/books/src/SUMMARY.md b/books/src/SUMMARY.md index 4fff79c..7b7545c 100644 --- a/books/src/SUMMARY.md +++ b/books/src/SUMMARY.md @@ -7,5 +7,6 @@ - [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) + - [Next step: server pairing foundation](first-vertical-slice/server-pairing-foundation.md) - [Implementation plan](first-vertical-slice/implementation.md) - [Verification and review](first-vertical-slice/verification.md) diff --git a/books/src/first-vertical-slice/server-pairing-foundation.md b/books/src/first-vertical-slice/server-pairing-foundation.md new file mode 100644 index 0000000..d8fc53a --- /dev/null +++ b/books/src/first-vertical-slice/server-pairing-foundation.md @@ -0,0 +1,157 @@ +# Next step: server pairing foundation + +This is the first implementation task to take from the book into a feature +branch. It deliberately stops before Flutter UI work: the server must enforce +the relationship rules before the client can safely depend on them. + +## Outcome + +After this task, the Gin server can create and atomically redeem a single-use, +ten-minute pairing invitation for two authenticated users. The task is complete +only when concurrency and invalid-state tests pass. + +## 1. Create the branch and establish a red test + +```bash +git switch -c feat/couple-pairing-foundation +cd server +go test ./... +``` + +Start with a service-level test, not a Gin handler. The test should state the +desired result before a database or HTTP implementation exists: + +```go +func TestRedeemInvitePairsTwoUnpairedUsers(t *testing.T) { + store := newTestStore(t) + creatorID := store.createUser(t, "Ari") + recipientID := store.createUser(t, "Bo") + service := NewPairingService(store, fixedClock) + + invite, err := service.CreateInvite(context.Background(), creatorID) + if err != nil { + t.Fatalf("CreateInvite() error = %v", err) + } + + coupleID, err := service.RedeemInvite(context.Background(), recipientID, invite.Code) + if err != nil { + t.Fatalf("RedeemInvite() error = %v", err) + } + if coupleID == "" { + t.Fatal("RedeemInvite() returned an empty couple ID") + } + store.assertSameCouple(t, creatorID, recipientID, coupleID) +} +``` + +The names and test helper APIs are examples. Preserve the behavior, not these +exact identifiers. + +## 2. Add the smallest durable schema + +Use a membership table so the database can guarantee one couple per user: + +```sql +CREATE TABLE couples ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL +); + +CREATE TABLE couple_members ( + couple_id TEXT NOT NULL REFERENCES couples(id), + user_id TEXT NOT NULL UNIQUE REFERENCES users(id), + PRIMARY KEY (couple_id, user_id) +); + +CREATE TABLE pairing_invites ( + id TEXT PRIMARY KEY, + creator_user_id TEXT NOT NULL REFERENCES users(id), + code_hash BLOB NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + redeemed_at TEXT, + redeemed_by_user_id TEXT REFERENCES users(id) +); +``` + +Use the migration mechanism chosen for the server; do not run schema creation +inside a request handler. Keep a migration test that opens a fresh database and +confirms all expected tables and indexes exist. + +## 3. Define domain errors and service methods + +The HTTP layer should translate typed domain errors into status codes. It should +not contain pairing rules. + +```go +var ( + ErrAlreadyPaired = errors.New("user is already paired") + ErrInvalidInvite = errors.New("invite is invalid") + ErrExpiredInvite = errors.New("invite has expired") + ErrCannotRedeemOwnInvite = errors.New("cannot redeem own invite") +) + +type PairingService interface { + CreateInvite(ctx context.Context, creatorUserID string) (Invite, error) + RedeemInvite(ctx context.Context, recipientUserID, code string) (string, error) +} +``` + +An invitation code is a bearer secret. Generate 32 random bytes with +`crypto/rand`, encode them as Base64 URL text, return that text once, and save +only a SHA-256 hash of it. A short six-digit code is not an equivalent +replacement unless it has strong rate limiting and a much shorter lifetime. + +## 4. Make redemption a transaction + +Inside one database transaction, in this order: + +1. Find an unredeemed invitation by its code hash. +2. Reject a missing or expired invitation. +3. Reject the invitation creator redeeming their own invite. +4. Confirm both users have no row in `couple_members`. +5. Insert one `couples` row and two `couple_members` rows. +6. Mark the invitation redeemed, checking that exactly one row changed. +7. Commit. + +If any step fails, roll back everything. The service must never leave a couple +without two members or consume an invitation without creating a couple. + +## 5. Write the failure tests before HTTP + +| Test | Expected result | +| ----------------------------------- | ---------------------------------------------- | +| Creator redeems own invite | `ErrCannotRedeemOwnInvite` | +| Invite is expired | `ErrExpiredInvite` | +| Invite was redeemed already | `ErrInvalidInvite` | +| Recipient is already paired | `ErrAlreadyPaired` | +| Two redemption requests race | exactly one succeeds | +| Third registered user tries to join | rejected by the private-instance capacity rule | + +Run these throughout the task: + +```bash +gofmt -w . +go vet ./... +go test ./... +``` + +## 6. Only then expose the HTTP API + +After the service tests pass, add authenticated handlers: + +```text +POST /api/v1/pairing-invites +POST /api/v1/pairing-invites/redeem +GET /api/v1/couple +``` + +The authentication middleware supplies the user ID. Do not accept `user_id` or +`couple_id` in either request body. Return a generic response for unknown, +expired, and already-consumed invitation codes so callers cannot learn which +codes were once valid. + +## Review checkpoint + +Before adding Flutter screens, confirm in review that passwords and session +tokens are handled independently from pairing: passwords are slow-hashed, +tokens are stored securely, and production traffic uses HTTPS. From 67e4a596cfc791080c9c711c9ee3246bbc4be25b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 30 Aug 2026 21:54:00 +0800 Subject: [PATCH 02/16] feat(pairing): add inviter server setup and couple pairing flow - Add Flutter inviter setup page for configuring a server URL\n- Generate and display one-time pairing invitation links\n- Add Gin APIs for invite creation, redemption, and status checks\n- Enforce single pending invite and one-time redemption per instance\n- Add pairing service and HTTP integration tests\n- Remove the default server URL from onboarding\n- Add Dart and Go function documentation comments\n- Update the implementation guide and server documentation --- books/src/first-vertical-slice/contract.md | 10 +- books/src/first-vertical-slice/draft-code.md | 36 +++- .../server-pairing-foundation.md | 6 +- .../src/first-vertical-slice/verification.md | 11 ++ lib/features/pairing/inviter_setup_page.dart | 176 ++++++++++++++++++ lib/features/pairing/pairing_api.dart | 127 +++++++++++++ lib/main.dart | 119 ++---------- pubspec.lock | 32 ++++ pubspec.yaml | 1 + server/README.md | 21 +++ server/internal/httpapi/pairing.go | 105 +++++++++++ server/internal/httpapi/pairing_test.go | 79 ++++++++ server/internal/httpapi/router.go | 6 + server/internal/pairing/service.go | 168 +++++++++++++++++ server/internal/pairing/service_test.go | 74 ++++++++ test/widget_test.dart | 31 +-- 16 files changed, 862 insertions(+), 140 deletions(-) create mode 100644 lib/features/pairing/inviter_setup_page.dart create mode 100644 lib/features/pairing/pairing_api.dart create mode 100644 server/internal/httpapi/pairing.go create mode 100644 server/internal/httpapi/pairing_test.go create mode 100644 server/internal/pairing/service.go create mode 100644 server/internal/pairing/service_test.go diff --git a/books/src/first-vertical-slice/contract.md b/books/src/first-vertical-slice/contract.md index 4d452c8..10f7ec8 100644 --- a/books/src/first-vertical-slice/contract.md +++ b/books/src/first-vertical-slice/contract.md @@ -39,8 +39,9 @@ POST /api/v1/auth/logout GET /api/v1/me GET /api/v1/couple -POST /api/v1/pairing-invites -POST /api/v1/pairing-invites/redeem +POST /api/v1/pairing/invites +POST /api/v1/pairing/invites/redeem +GET /api/v1/pairing/invites/status ``` Suggested behavior: @@ -51,8 +52,9 @@ Suggested behavior: | `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. | +| `POST /pairing/invites` | Instance context in the MVP | Creates one expiring, single-use invite and returns an inviter token. | +| `POST /pairing/invites/redeem` | No in the MVP | Atomically consumes the invite and marks the instance paired. | +| `GET /pairing/invites/status` | Bearer inviter token | Lets the inviting device check whether redemption completed. | 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 index 5ffccf8..91974b8 100644 --- a/books/src/first-vertical-slice/draft-code.md +++ b/books/src/first-vertical-slice/draft-code.md @@ -106,7 +106,7 @@ class InstanceSetupPage extends StatefulWidget { class _InstanceSetupPageState extends State { final _formKey = GlobalKey(); - final _urlController = TextEditingController(text: 'http://10.0.2.2:8080'); + final _urlController = TextEditingController(); final _api = InstanceApi(http.Client()); bool _isConnecting = false; String? _errorMessage; @@ -163,7 +163,10 @@ class _InstanceSetupPageState extends State { validator: (value) => value == null || value.trim().isEmpty ? 'Server URL is required.' : null, - decoration: const InputDecoration(labelText: 'Server URL'), + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'https://pawmate.example.com', + ), ), if (_errorMessage != null) ...[ const SizedBox(height: 12), @@ -305,3 +308,32 @@ func (store *Store) RedeemInvite(ctx context.Context, userID, code string) error `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. + +## Current inviter implementation + +The repository now has a process-local implementation in +`server/internal/pairing/service.go` and HTTP adapters in +`server/internal/httpapi/pairing.go`: + +```text +POST /api/v1/pairing/invites + {"server_url":"https://home.example.com"} + -> {"invite_url":"pawmate://pair?...", "inviter_token":"...", "expires_at":"..."} + +GET /api/v1/pairing/invites/status + Authorization: Bearer + +POST /api/v1/pairing/invites/redeem + {"code":"..."} +``` + +The Flutter inviter screen is +`lib/features/pairing/inviter_setup_page.dart`, backed by +`lib/features/pairing/pairing_api.dart`. It validates the configured URL, +creates the invite, displays the link, copies it to the clipboard, and checks +pairing status. + +This is intentionally a development baseline: pairing state is protected by a +mutex but lives only in the Gin process. A restart invalidates the invitation +and pairing state. Before production, replace the service storage with the +SQLite schema and authenticated accounts described earlier in this chapter. diff --git a/books/src/first-vertical-slice/server-pairing-foundation.md b/books/src/first-vertical-slice/server-pairing-foundation.md index d8fc53a..d29b6f5 100644 --- a/books/src/first-vertical-slice/server-pairing-foundation.md +++ b/books/src/first-vertical-slice/server-pairing-foundation.md @@ -140,9 +140,9 @@ go test ./... After the service tests pass, add authenticated handlers: ```text -POST /api/v1/pairing-invites -POST /api/v1/pairing-invites/redeem -GET /api/v1/couple +POST /api/v1/pairing/invites +POST /api/v1/pairing/invites/redeem +GET /api/v1/pairing/invites/status ``` The authentication middleware supplies the user ID. Do not accept `user_id` or diff --git a/books/src/first-vertical-slice/verification.md b/books/src/first-vertical-slice/verification.md index 546b682..4be47d7 100644 --- a/books/src/first-vertical-slice/verification.md +++ b/books/src/first-vertical-slice/verification.md @@ -35,6 +35,17 @@ Manually verify on an Android emulator: correct. 5. Confirm a third account cannot join the private instance. +For the current inviter MVP, manually verify invitation creation with: + +```bash +curl -X POST http://localhost:8080/api/v1/pairing/invites \ + -H 'content-type: application/json' \ + -d '{"server_url":"http://localhost:8080"}' +``` + +Save the returned `invite_url` and `inviter_token` only in a local shell. The +invite URL is a bearer secret; do not paste it into commits, logs, or issues. + ## Pull request review Before merging, verify that: diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart new file mode 100644 index 0000000..c068458 --- /dev/null +++ b/lib/features/pairing/inviter_setup_page.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'pairing_api.dart'; + +class InviterSetupPage extends StatefulWidget { + const InviterSetupPage({super.key}); + + @override + State createState() => _InviterSetupPageState(); +} + +class _InviterSetupPageState extends State { + final _formKey = GlobalKey(); + final _serverURLController = TextEditingController(); + final _api = PairingApi(); + PairingInvite? _invite; + PairingStatus? _status; + String? _errorMessage; + bool _isLoading = false; + + /// Releases controllers and the HTTP client owned by this page. + @override + void dispose() { + _serverURLController.dispose(); + _api.close(); + super.dispose(); + } + + /// Sends the configured server URL and displays the returned invitation. + Future _createInvite() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _errorMessage = null; + _isLoading = true; + _status = null; + }); + try { + final invite = await _api.createInvite(_serverURLController.text); + if (!mounted) return; + setState(() => _invite = invite); + } on PairingApiException catch (error) { + if (mounted) setState(() => _errorMessage = error.message); + } on Object { + if (mounted) { + setState(() => _errorMessage = 'Could not reach the Pawmate server.'); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + /// Refreshes the invitation state shown to the inviter. + Future _refreshStatus() async { + final invite = _invite; + if (invite == null) return; + setState(() => _isLoading = true); + try { + final status = await _api.getStatus( + _serverURLController.text, + invite.inviterToken, + ); + if (mounted) setState(() => _status = status); + } on PairingApiException catch (error) { + if (mounted) setState(() => _errorMessage = error.message); + } on Object { + if (mounted) setState(() => _errorMessage = 'Could not reach the server.'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + /// Copies the bearer invitation link to the platform clipboard. + Future _copyInvite() async { + final invite = _invite; + if (invite == null) return; + await Clipboard.setData(ClipboardData(text: invite.inviteURL)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invitation link copied')), + ); + } + } + + /// Builds the inviter's server configuration and invitation screen. + @override + Widget build(BuildContext context) { + final invite = _invite; + final status = _status; + return Scaffold( + appBar: AppBar(title: const Text('Invite your partner')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + Text( + 'Start your little home', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + const Text( + 'Choose the private server for your home, then create one invitation for your partner.', + ), + const SizedBox(height: 24), + Form( + key: _formKey, + child: TextFormField( + controller: _serverURLController, + autocorrect: false, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Server URL or domain', + hintText: 'https://pawmate.example.com', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Enter your server URL.'; + } + return null; + }, + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _isLoading ? null : _createInvite, + icon: const Icon(Icons.favorite_border), + label: Text(_isLoading ? 'Working…' : 'Create invitation'), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + if (invite != null) ...[ + const SizedBox(height: 28), + const Text( + 'Share this one-time invitation link', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + SelectableText(invite.inviteURL), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _copyInvite, + icon: const Icon(Icons.copy), + label: const Text('Copy invitation link'), + ), + const SizedBox(height: 8), + Text( + 'Expires ${invite.expiresAt.toLocal()}', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 20), + OutlinedButton.icon( + onPressed: _isLoading ? null : _refreshStatus, + icon: const Icon(Icons.refresh), + label: const Text('Check pairing status'), + ), + if (status != null) ...[ + const SizedBox(height: 12), + Text( + status.status == 'paired' + ? 'Your partner is paired!' + : 'Waiting for your partner to accept…', + ), + ], + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/pairing/pairing_api.dart b/lib/features/pairing/pairing_api.dart new file mode 100644 index 0000000..78a53c1 --- /dev/null +++ b/lib/features/pairing/pairing_api.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// A one-time invitation returned by the Pawmate instance. +class PairingInvite { + const PairingInvite({ + required this.expiresAt, + required this.inviteURL, + required this.inviterToken, + }); + + /// Decodes the server's invitation response. + factory PairingInvite.fromJson(Map json) { + return PairingInvite( + expiresAt: DateTime.parse(json['expires_at'] as String), + inviteURL: json['invite_url'] as String, + inviterToken: json['inviter_token'] as String, + ); + } + + final DateTime expiresAt; + final String inviteURL; + final String inviterToken; +} + +/// Describes the pairing state observed by the inviting device. +class PairingStatus { + const PairingStatus({required this.status, this.pairID}); + + /// Decodes a pairing status response. + factory PairingStatus.fromJson(Map json) { + return PairingStatus( + pairID: json['pair_id'] as String?, + status: json['status'] as String, + ); + } + + final String? pairID; + final String status; +} + +/// Represents a user-facing error returned while calling the pairing API. +class PairingApiException implements Exception { + const PairingApiException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// Calls the instance pairing endpoints for the inviting device. +class PairingApi { + PairingApi([http.Client? client]) : _client = client ?? http.Client(); + + final http.Client _client; + + /// Releases the underlying HTTP client. + void close() => _client.close(); + + /// Creates an invitation on the configured server instance. + Future createInvite(String rawServerURL) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .post( + serverURL.resolve('/api/v1/pairing/invites'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({'server_url': serverURL.toString()}), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 201) { + throw PairingApiException(_messageFor(response)); + } + return PairingInvite.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Fetches the invitation state using the inviter's bearer token. + Future getStatus( + String rawServerURL, + String inviterToken, + ) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .get( + serverURL.resolve('/api/v1/pairing/invites/status'), + headers: {'authorization': 'Bearer $inviterToken'}, + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw PairingApiException(_messageFor(response)); + } + return PairingStatus.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Validates and normalizes a server URL before making a request. + Uri _parseServerURL(String rawServerURL) { + final uri = Uri.tryParse(rawServerURL.trim()); + if (uri == null || + uri.host.isEmpty || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment) { + throw const PairingApiException( + 'Use an http:// or https:// server URL without credentials or query parameters.', + ); + } + return uri.replace(path: uri.path.replaceFirst(RegExp(r'/+$'), '')); + } + + /// Extracts a safe error message from a JSON API response. + String _messageFor(http.Response response) { + try { + final body = jsonDecode(response.body) as Map; + return body['error'] as String? ?? 'The server rejected the request.'; + } on Object { + return 'The server rejected the request (${response.statusCode}).'; + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..089f829 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,122 +1,25 @@ import 'package:flutter/material.dart'; +import 'features/pairing/inviter_setup_page.dart'; + void main() { - runApp(const MyApp()); + runApp(const PawmateApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +/// Root widget for the Pawmate Flutter application. +class PawmateApp extends StatelessWidget { + const PawmateApp({super.key}); - // This widget is the root of your application. + /// Configures the app theme and starts the inviter onboarding flow. @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter Demo', + title: 'Pawmate', theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, ), + home: const InviterSetupPage(), ); } } diff --git a/pubspec.lock b/pubspec.lock index 582d01a..0018ada 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -75,6 +75,22 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -192,6 +208,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -208,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.12.2 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index db5b3a8..a7b013f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + http: ^1.5.0 dev_dependencies: flutter_test: diff --git a/server/README.md b/server/README.md index 6202fc7..9448f12 100644 --- a/server/README.md +++ b/server/README.md @@ -17,6 +17,27 @@ curl http://localhost:8080/healthz curl http://localhost:8080/api/v1/instance ``` +The inviter pairing MVP exposes: + +```text +POST /api/v1/pairing/invites +GET /api/v1/pairing/invites/status +POST /api/v1/pairing/invites/redeem +``` + +Create an invitation by sending the configured server URL: + +```bash +curl -X POST http://localhost:8080/api/v1/pairing/invites \ + -H 'content-type: application/json' \ + -d '{"server_url":"http://localhost:8080"}' +``` + +The response contains a one-time `invite_url` and an `inviter_token` for status +checks. This initial implementation stores pairing state in memory for local +development; restarting the process clears it. Add persistent storage before +deploying a real instance. + For the Android emulator, configure the Flutter client with `http://10.0.2.2:8080`; `10.0.2.2` maps to the development machine's loopback interface from inside the emulator. diff --git a/server/internal/httpapi/pairing.go b/server/internal/httpapi/pairing.go new file mode 100644 index 0000000..fd3a3ef --- /dev/null +++ b/server/internal/httpapi/pairing.go @@ -0,0 +1,105 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "pawmate/server/internal/pairing" +) + +type createInviteRequest struct { + ServerURL string `json:"server_url" binding:"required"` +} + +type createInviteResponse struct { + ExpiresAt string `json:"expires_at"` + InviteURL string `json:"invite_url"` + InviterToken string `json:"inviter_token"` +} + +type redeemInviteRequest struct { + Code string `json:"code" binding:"required"` +} + +type pairingStatusResponse struct { + InviteeToken string `json:"invitee_token,omitempty"` + PairID string `json:"pair_id,omitempty"` + Status string `json:"status"` +} + +// createInviteHandler creates an invitation for the configured server URL. +func createInviteHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var request createInviteRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + + invite, err := service.CreateInvite(request.ServerURL) + if err != nil { + status := http.StatusInternalServerError + code := "internal_error" + switch { + case errors.Is(err, pairing.ErrInvalidServerURL): + status, code = http.StatusBadRequest, "invalid_server_url" + case errors.Is(err, pairing.ErrAlreadyPaired): + status, code = http.StatusConflict, "already_paired" + case errors.Is(err, pairing.ErrInvitePending): + status, code = http.StatusConflict, "invite_pending" + } + c.JSON(status, gin.H{"error": code}) + return + } + + c.JSON(http.StatusCreated, createInviteResponse{ + ExpiresAt: invite.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z"), + InviteURL: invite.URL, + InviterToken: invite.InviterToken, + }) + } +} + +// redeemInviteHandler consumes the one-time code supplied by the invitee. +func redeemInviteHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var request redeemInviteRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + + status, err := service.RedeemInvite(request.Code) + if err != nil { + code := "invalid_invite" + httpStatus := http.StatusConflict + if errors.Is(err, pairing.ErrExpiredInvite) { + code, httpStatus = "expired_invite", http.StatusGone + } + c.JSON(httpStatus, gin.H{"error": code}) + return + } + + c.JSON(http.StatusOK, pairingStatusResponse{ + InviteeToken: status.InviteeToken, + PairID: status.PairID, + Status: status.Status, + }) + } +} + +// pairingStatusHandler reports state to the device that created the invite. +func pairingStatusHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) + status, err := service.Status(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_inviter_token"}) + return + } + c.JSON(http.StatusOK, pairingStatusResponse{PairID: status.PairID, Status: status.Status}) + } +} diff --git a/server/internal/httpapi/pairing_test.go b/server/internal/httpapi/pairing_test.go new file mode 100644 index 0000000..b7b9ef3 --- /dev/null +++ b/server/internal/httpapi/pairing_test.go @@ -0,0 +1,79 @@ +package httpapi + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "pawmate/server/internal/config" +) + +func TestPairingHTTPFlow(t *testing.T) { + router := NewRouter(config.Config{ + Environment: "test", + InstanceID: "test-instance", + InstanceName: "Test Home", + Port: "8080", + }, slog.New(slog.NewTextHandler(testLogWriter{}, nil))) + + createResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites", `{"server_url":"https://home.example.test"}`, "") + if createResponse.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d", createResponse.Code, http.StatusCreated) + } + var invite struct { + InviteURL string `json:"invite_url"` + InviterToken string `json:"inviter_token"` + } + decodeJSON(t, createResponse, &invite) + if invite.InviteURL == "" || invite.InviterToken == "" { + t.Fatalf("create response did not contain invite credentials: %+v", invite) + } + + statusResponse := performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/invites/status", "", "Bearer "+invite.InviterToken) + if statusResponse.Code != http.StatusOK { + t.Fatalf("pending status = %d, want %d", statusResponse.Code, http.StatusOK) + } + + inviteURL, err := url.Parse(invite.InviteURL) + if err != nil { + t.Fatal(err) + } + code := inviteURL.Query().Get("code") + redeemResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites/redeem", `{"code":"`+code+`"}`, "") + if redeemResponse.Code != http.StatusOK { + t.Fatalf("redeem status = %d, want %d", redeemResponse.Code, http.StatusOK) + } + statusResponse = performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/invites/status", "", "Bearer "+invite.InviterToken) + if !strings.Contains(statusResponse.Body.String(), `"status":"paired"`) { + t.Fatalf("paired status = %s", statusResponse.Body.String()) + } +} + +func performJSONRequest(t *testing.T, handler http.Handler, method, path, body, authorization string) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + request.Header.Set("Content-Type", "application/json") + } + if authorization != "" { + request.Header.Set("Authorization", authorization) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func decodeJSON(t *testing.T, response *httptest.ResponseRecorder, target any) { + t.Helper() + if err := json.NewDecoder(response.Body).Decode(target); err != nil { + t.Fatal(err) + } +} + +type testLogWriter struct{} + +func (testLogWriter) Write(data []byte) (int, error) { return len(data), nil } diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index e33cd76..05aeaee 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "pawmate/server/internal/config" + "pawmate/server/internal/pairing" ) // NewRouter creates the versioned HTTP API served by a Pawmate instance. @@ -29,12 +30,17 @@ func NewRouter(cfg config.Config, logger *slog.Logger) *gin.Engine { }), gin.Recovery()) router.GET("/healthz", healthHandler) + pairingService := pairing.NewService() api := router.Group("/api/v1") api.GET("/instance", instanceHandler(cfg)) + api.POST("/pairing/invites", createInviteHandler(pairingService)) + api.POST("/pairing/invites/redeem", redeemInviteHandler(pairingService)) + api.GET("/pairing/invites/status", pairingStatusHandler(pairingService)) return router } +// healthHandler reports that the HTTP process is reachable. func healthHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } diff --git a/server/internal/pairing/service.go b/server/internal/pairing/service.go new file mode 100644 index 0000000..36c558f --- /dev/null +++ b/server/internal/pairing/service.go @@ -0,0 +1,168 @@ +package pairing + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "net/url" + "strings" + "sync" + "time" +) + +var ( + ErrAlreadyPaired = errors.New("instance is already paired") + ErrInvitePending = errors.New("an invitation is already pending") + ErrInvalidInvite = errors.New("invite is invalid") + ErrExpiredInvite = errors.New("invite has expired") + ErrInvalidInviterToken = errors.New("inviter token is invalid") + ErrInvalidServerURL = errors.New("server URL is invalid") +) + +const inviteTTL = 10 * time.Minute + +// Invite is the one-time invitation returned to the inviting client. +type Invite struct { + URL string + InviterToken string + ExpiresAt time.Time +} + +// PairingStatus describes the state visible to the inviting client. +type PairingStatus struct { + PairID string + Status string + InviteeToken string +} + +// Service is the development pairing state for one private Pawmate instance. +// It is intentionally in-memory until the SQLite persistence chapter is implemented. +type Service struct { + mu sync.Mutex + now func() time.Time + invite *inviteState + paired bool +} + +type inviteState struct { + codeHash [sha256.Size]byte + expiresAt time.Time + inviterToken string + pairID string + inviteeToken string +} + +// NewService creates process-local pairing state for one server instance. +func NewService() *Service { + // The service is process-local for this first development slice. + return &Service{now: time.Now} +} + +// CreateInvite creates one expiring invite for the instance. +func (service *Service) CreateInvite(serverURL string) (Invite, error) { + baseURL, err := normalizeServerURL(serverURL) + if err != nil { + return Invite{}, err + } + + service.mu.Lock() + defer service.mu.Unlock() + if service.paired { + return Invite{}, ErrAlreadyPaired + } + if service.invite != nil && service.now().Before(service.invite.expiresAt) { + return Invite{}, ErrInvitePending + } + + code, err := randomToken(32) + if err != nil { + return Invite{}, err + } + inviterToken, err := randomToken(32) + if err != nil { + return Invite{}, err + } + expiresAt := service.now().Add(inviteTTL) + state := &inviteState{ + codeHash: sha256.Sum256([]byte(code)), + expiresAt: expiresAt, + inviterToken: inviterToken, + } + service.invite = state + + return Invite{ + URL: "pawmate://pair?server=" + url.QueryEscape(baseURL) + "&code=" + url.QueryEscape(code), + InviterToken: inviterToken, + ExpiresAt: expiresAt, + }, nil +} + +// RedeemInvite atomically consumes an invite and creates the instance pair. +func (service *Service) RedeemInvite(code string) (PairingStatus, error) { + service.mu.Lock() + defer service.mu.Unlock() + + if service.paired || service.invite == nil { + return PairingStatus{}, ErrInvalidInvite + } + if !service.now().Before(service.invite.expiresAt) { + return PairingStatus{}, ErrExpiredInvite + } + if service.invite.codeHash != sha256.Sum256([]byte(strings.TrimSpace(code))) { + return PairingStatus{}, ErrInvalidInvite + } + + pairID, err := randomToken(16) + if err != nil { + return PairingStatus{}, err + } + inviteeToken, err := randomToken(32) + if err != nil { + return PairingStatus{}, err + } + service.invite.pairID = pairID + service.invite.inviteeToken = inviteeToken + service.paired = true + + return PairingStatus{PairID: pairID, Status: "paired", InviteeToken: inviteeToken}, nil +} + +// Status returns pairing state for the inviting client. +func (service *Service) Status(inviterToken string) (PairingStatus, error) { + service.mu.Lock() + defer service.mu.Unlock() + + if service.invite == nil || service.invite.inviterToken != inviterToken { + return PairingStatus{}, ErrInvalidInviterToken + } + if !service.paired { + return PairingStatus{Status: "pending"}, nil + } + return PairingStatus{ + PairID: service.invite.pairID, + Status: "paired", + }, nil +} + +// normalizeServerURL validates and canonicalizes the URL embedded in an invite. +func normalizeServerURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", ErrInvalidServerURL + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", ErrInvalidServerURL + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + return strings.TrimRight(parsed.String(), "/"), nil +} + +// randomToken creates an URL-safe cryptographic bearer token. +func randomToken(size int) (string, error) { + data := make([]byte, size) + if _, err := rand.Read(data); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(data), nil +} diff --git a/server/internal/pairing/service_test.go b/server/internal/pairing/service_test.go new file mode 100644 index 0000000..06f6b93 --- /dev/null +++ b/server/internal/pairing/service_test.go @@ -0,0 +1,74 @@ +package pairing + +import ( + "errors" + "net/url" + "testing" + "time" +) + +func TestInviteCanBeRedeemedOnlyOnce(t *testing.T) { + service := NewService() + invite, err := service.CreateInvite("https://home.example.test") + if err != nil { + t.Fatal(err) + } + + code := inviteCode(t, invite.URL) + status, err := service.RedeemInvite(code) + if err != nil { + t.Fatal(err) + } + if status.Status != "paired" || status.PairID == "" || status.InviteeToken == "" { + t.Fatalf("unexpected pairing status: %+v", status) + } + if _, err := service.RedeemInvite(code); !errors.Is(err, ErrInvalidInvite) { + t.Fatalf("second redemption error = %v, want ErrInvalidInvite", err) + } +} + +func TestInstanceAllowsOnlyOnePendingInvite(t *testing.T) { + service := NewService() + if _, err := service.CreateInvite("http://localhost:8080/"); err != nil { + t.Fatal(err) + } + if _, err := service.CreateInvite("http://localhost:8080"); !errors.Is(err, ErrInvitePending) { + t.Fatalf("second invite error = %v, want ErrInvitePending", err) + } +} + +func TestExpiredInviteCannotBeRedeemed(t *testing.T) { + service := NewService() + clock := time.Now() + service.now = func() time.Time { return clock } + invite, err := service.CreateInvite("https://home.example.test") + if err != nil { + t.Fatal(err) + } + clock = invite.ExpiresAt + if _, err := service.RedeemInvite(inviteCode(t, invite.URL)); !errors.Is(err, ErrExpiredInvite) { + t.Fatalf("redemption error = %v, want ErrExpiredInvite", err) + } +} + +func TestServerURLMustNotContainCredentialsOrQuery(t *testing.T) { + service := NewService() + for _, rawURL := range []string{ + "https://user:password@example.test", + "https://example.test?redirect=other", + "ftp://example.test", + } { + if _, err := service.CreateInvite(rawURL); !errors.Is(err, ErrInvalidServerURL) { + t.Fatalf("CreateInvite(%q) error = %v, want ErrInvalidServerURL", rawURL, err) + } + } +} + +func inviteCode(t *testing.T, rawURL string) string { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + return parsed.Query().Get("code") +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 7694f19..e82ea5d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,15 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pawmate/main.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + testWidgets('inviter setup page accepts a server URL', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const PawmateApp()); + + expect(find.text('Invite your partner'), findsOneWidget); + expect(find.text('Server URL or domain'), findsOneWidget); + expect(find.text('Create invitation'), findsOneWidget); }); } From f587d72ad3da8ef87adf82e5aede75e3868ba44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 30 Aug 2026 22:03:35 +0800 Subject: [PATCH 03/16] style(flutter): apply dart formatter --- lib/features/pairing/inviter_setup_page.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart index c068458..f041711 100644 --- a/lib/features/pairing/inviter_setup_page.dart +++ b/lib/features/pairing/inviter_setup_page.dart @@ -64,7 +64,8 @@ class _InviterSetupPageState extends State { } on PairingApiException catch (error) { if (mounted) setState(() => _errorMessage = error.message); } on Object { - if (mounted) setState(() => _errorMessage = 'Could not reach the server.'); + if (mounted) + setState(() => _errorMessage = 'Could not reach the server.'); } finally { if (mounted) setState(() => _isLoading = false); } @@ -76,9 +77,9 @@ class _InviterSetupPageState extends State { if (invite == null) return; await Clipboard.setData(ClipboardData(text: invite.inviteURL)); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Invitation link copied')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Invitation link copied'))); } } From e761517915611b0257440560225e69cd28d53111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 30 Aug 2026 22:19:57 +0800 Subject: [PATCH 04/16] fix(flutter): satisfy curly braces lint --- lib/features/pairing/inviter_setup_page.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart index f041711..d9f8ee3 100644 --- a/lib/features/pairing/inviter_setup_page.dart +++ b/lib/features/pairing/inviter_setup_page.dart @@ -64,8 +64,9 @@ class _InviterSetupPageState extends State { } on PairingApiException catch (error) { if (mounted) setState(() => _errorMessage = error.message); } on Object { - if (mounted) + if (mounted) { setState(() => _errorMessage = 'Could not reach the server.'); + } } finally { if (mounted) setState(() => _isLoading = false); } From af24de36ec3db6f651abce45ff642b3feec1d377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 27 Sep 2026 17:29:31 +0800 Subject: [PATCH 05/16] feat(pairing): add hand-drawn pairing and persistent recovery - add reusable paper-and-ink UI components for pairing - accept invitations opened through the pawmate://pair deep link - store device credentials securely and restore access with rotating recovery codes - persist pairing state in SQLite and store credential hashes - document recovery and database backup procedures --- AGENTS.md | 178 +++++++++++ android/app/src/main/AndroidManifest.xml | 10 +- books/src/current-foundation.md | 15 +- lib/design/doodle_house.dart | 72 +++++ lib/design/handdrawn_button.dart | 70 +++++ lib/design/handdrawn_card.dart | 36 +++ lib/design/handdrawn_scaffold.dart | 27 ++ lib/design/handdrawn_text_field.dart | 35 +++ lib/design/pawmate_theme.dart | 74 +++++ lib/features/pairing/invitee_accept_page.dart | 163 ++++++++++ lib/features/pairing/inviter_setup_page.dart | 223 +++++++++---- lib/features/pairing/pairing_api.dart | 83 ++++- lib/features/pairing/pairing_credentials.dart | 81 +++++ .../pairing/widgets/invite_result_card.dart | 115 +++++++ .../pairing/widgets/pairing_error_note.dart | 31 ++ .../pairing/widgets/pairing_header.dart | 43 +++ .../widgets/pairing_recovery_card.dart | 71 +++++ .../pairing/widgets/recovery_code_note.dart | 62 ++++ .../pairing/widgets/server_setup_card.dart | 73 +++++ lib/main.dart | 53 +++- pubspec.lock | 295 +++++++++++++++++- pubspec.yaml | 2 + server/README.md | 34 +- server/cmd/pawmate-server/main.go | 9 +- server/go.mod | 28 +- server/go.sum | 73 ++++- server/internal/config/config.go | 2 + server/internal/httpapi/pairing.go | 54 +++- server/internal/httpapi/pairing_test.go | 11 +- server/internal/httpapi/router.go | 4 +- server/internal/httpapi/router_test.go | 7 +- server/internal/pairing/service.go | 269 +++++++++++++--- 32 files changed, 2134 insertions(+), 169 deletions(-) create mode 100644 AGENTS.md create mode 100644 lib/design/doodle_house.dart create mode 100644 lib/design/handdrawn_button.dart create mode 100644 lib/design/handdrawn_card.dart create mode 100644 lib/design/handdrawn_scaffold.dart create mode 100644 lib/design/handdrawn_text_field.dart create mode 100644 lib/design/pawmate_theme.dart create mode 100644 lib/features/pairing/invitee_accept_page.dart create mode 100644 lib/features/pairing/pairing_credentials.dart create mode 100644 lib/features/pairing/widgets/invite_result_card.dart create mode 100644 lib/features/pairing/widgets/pairing_error_note.dart create mode 100644 lib/features/pairing/widgets/pairing_header.dart create mode 100644 lib/features/pairing/widgets/pairing_recovery_card.dart create mode 100644 lib/features/pairing/widgets/recovery_code_note.dart create mode 100644 lib/features/pairing/widgets/server_setup_card.dart diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..727a675 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,178 @@ +# 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/` or the project's +equivalent design-system directory: + +```text +HanddrawnScaffold +HanddrawnCard +HanddrawnButton +HanddrawnTextField +DoodleIcon +Sticker +PaperNote +CoupleAvatar +``` + +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. + +## Stable hand-drawn rendering + +- 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 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/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d6e3814..fc01d26 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ + android:icon="@mipmap/ic_launcher" + android:allowBackup="false"> + + + + + + + diff --git a/books/src/current-foundation.md b/books/src/current-foundation.md index 7ac99e0..3c0b07f 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,12 @@ 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 an invitation opened through the Android +`pawmate://pair` scheme, and restoring a member after reinstall with a recovery +code. The Gin service persists the single couple relationship in SQLite. + +The current implementation is an onboarding foundation. It does not yet +provide chat, media, games, recipes, or a full paired-home dashboard. Recovery +codes are single-use and must be saved by each member outside the app. Production +deployments must mount and back up the configured SQLite database path. diff --git a/lib/design/doodle_house.dart b/lib/design/doodle_house.dart new file mode 100644 index 0000000..5f2f061 --- /dev/null +++ b/lib/design/doodle_house.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import 'pawmate_theme.dart'; + +/// A deterministic house-and-heart doodle used by the pairing header. +class DoodleHouse extends StatelessWidget { + const DoodleHouse({super.key}); + + @override + Widget build(BuildContext context) { + return const SizedBox( + width: 132, + height: 104, + child: CustomPaint(painter: _DoodleHousePainter()), + ); + } +} + +class _DoodleHousePainter extends CustomPainter { + const _DoodleHousePainter(); + + @override + void paint(Canvas canvas, Size size) { + final outline = Paint() + ..color = PawmateColors.ink + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final fill = Paint() + ..color = PawmateColors.sun + ..style = PaintingStyle.fill; + + final house = Path() + ..moveTo(25, 48) + ..lineTo(66, 17) + ..lineTo(108, 48) + ..lineTo(104, 89) + ..quadraticBezierTo(66, 94, 28, 89) + ..close(); + canvas.drawPath(house, fill); + canvas.drawPath(house, outline); + canvas.drawLine(const Offset(66, 18), const Offset(66, 8), outline); + canvas.drawCircle(const Offset(66, 6), 2, outline); + + final door = RRect.fromRectAndRadius( + const Rect.fromLTWH(56, 61, 20, 29), + const Radius.circular(4), + ); + canvas.drawRRect(door, outline); + canvas.drawCircle(const Offset(71, 76), 1.5, outline); + canvas.drawRect(const Rect.fromLTWH(38, 52, 15, 14), outline); + canvas.drawRect(const Rect.fromLTWH(80, 52, 15, 14), outline); + canvas.drawLine(const Offset(45, 52), const Offset(45, 66), outline); + canvas.drawLine(const Offset(38, 59), const Offset(53, 59), outline); + canvas.drawLine(const Offset(87, 52), const Offset(87, 66), outline); + canvas.drawLine(const Offset(80, 59), const Offset(95, 59), outline); + + final heart = Path() + ..moveTo(112, 26) + ..cubicTo(105, 18, 94, 26, 101, 34) + ..lineTo(112, 44) + ..lineTo(123, 34) + ..cubicTo(130, 26, 119, 18, 112, 26) + ..close(); + canvas.drawPath(heart, Paint()..color = PawmateColors.rose); + canvas.drawPath(heart, outline); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/design/handdrawn_button.dart b/lib/design/handdrawn_button.dart new file mode 100644 index 0000000..bfededa --- /dev/null +++ b/lib/design/handdrawn_button.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'pawmate_theme.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), + Text(label), + ], + ); + + return SizedBox( + width: double.infinity, + height: 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, + ), + child: child, + ) + : OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: PawmateColors.ink, + side: const BorderSide( + color: PawmateColors.softBrown, + width: 2, + ), + shape: shape, + ), + child: child, + ), + ); + } +} diff --git a/lib/design/handdrawn_card.dart b/lib/design/handdrawn_card.dart new file mode 100644 index 0000000..624ec33 --- /dev/null +++ b/lib/design/handdrawn_card.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'pawmate_theme.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}); + + final Widget child; + final Color? color; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: color ?? const Color(0xFFFFFCF3), + 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: const EdgeInsets.all(20), + child: child, + ); + } +} diff --git a/lib/design/handdrawn_scaffold.dart b/lib/design/handdrawn_scaffold.dart new file mode 100644 index 0000000..e60e12f --- /dev/null +++ b/lib/design/handdrawn_scaffold.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import 'pawmate_theme.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}); + + final String title; + final Widget body; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + title, + style: const TextStyle( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + body: body, + ); + } +} diff --git a/lib/design/handdrawn_text_field.dart b/lib/design/handdrawn_text_field.dart new file mode 100644 index 0000000..46055fc --- /dev/null +++ b/lib/design/handdrawn_text_field.dart @@ -0,0 +1,35 @@ +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, + }); + + final TextEditingController controller; + final String labelText; + final String? hintText; + final FormFieldValidator? validator; + final IconData? prefixIcon; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + autocorrect: false, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + prefixIcon: prefixIcon == null ? null : Icon(prefixIcon), + ), + validator: validator, + ); + } +} diff --git a/lib/design/pawmate_theme.dart b/lib/design/pawmate_theme.dart new file mode 100644 index 0000000..a07ebbb --- /dev/null +++ b/lib/design/pawmate_theme.dart @@ -0,0 +1,74 @@ +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); +} + +/// 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, + foregroundColor: PawmateColors.ink, + elevation: 0, + centerTitle: false, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFFFFFCF3), + 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/features/pairing/invitee_accept_page.dart b/lib/features/pairing/invitee_accept_page.dart new file mode 100644 index 0000000..d22eb67 --- /dev/null +++ b/lib/features/pairing/invitee_accept_page.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; + +import '../../design/handdrawn_button.dart'; +import '../../design/handdrawn_card.dart'; +import '../../design/handdrawn_scaffold.dart'; +import '../../design/pawmate_theme.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'widgets/pairing_error_note.dart'; +import 'widgets/recovery_code_note.dart'; + +/// Lets the invitee review and accept a one-time link opened from another app. +class InviteeAcceptPage extends StatefulWidget { + const InviteeAcceptPage({required this.inviteUri, super.key}); + + final Uri inviteUri; + + @override + State createState() => _InviteeAcceptPageState(); +} + +class _InviteeAcceptPageState extends State { + final _api = PairingApi(); + final _credentials = PairingCredentials(); + bool _isLoading = false; + String? _errorMessage; + String? _recoveryCode; + + @override + void dispose() { + _api.close(); + super.dispose(); + } + + Future _acceptInvite() async { + final serverURL = widget.inviteUri.queryParameters['server']; + final code = widget.inviteUri.queryParameters['code']; + if (serverURL == null || code == null || code.isEmpty) { + setState(() => _errorMessage = 'This invitation link is incomplete.'); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + try { + final result = await _api.redeemInvite(serverURL, code); + final token = result.inviteeToken; + final recoveryCode = result.recoveryCode; + final pairID = result.pairID; + if (token == null || recoveryCode == null || pairID == null) { + throw const PairingApiException( + 'The server did not return complete pairing credentials.', + ); + } + if (mounted) setState(() => _recoveryCode = recoveryCode); + try { + await _credentials.save( + serverURL: serverURL, + accessToken: token, + recoveryCode: recoveryCode, + pairID: pairID, + role: 'invitee', + ); + } on Object { + if (mounted) { + setState(() { + _errorMessage = + 'You are paired, but this device could not store access securely. Keep the recovery code below safe.'; + }); + } + } + } on PairingApiException catch (error) { + if (mounted) setState(() => _errorMessage = error.message); + } on Object { + if (mounted) { + setState(() => _errorMessage = 'Could not accept this invitation.'); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final serverURL = widget.inviteUri.queryParameters['server'] ?? 'Unknown'; + final accepted = _recoveryCode != null; + return HanddrawnScaffold( + title: 'Join your little home', + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + HanddrawnCard( + color: const Color(0xFFF4F0FF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.mail_outline, + size: 32, + color: PawmateColors.lavender, + ), + const SizedBox(height: 12), + Text( + accepted ? 'You are home!' : 'You have been invited', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + accepted + ? 'Your place in this private home is now connected.' + : 'Accepting this one-time invitation will pair your device with your partner.', + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), + ), + const SizedBox(height: 16), + const Text( + 'Private server', + style: TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + SelectableText( + serverURL, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + ), + ), + if (!accepted) ...[ + const SizedBox(height: 20), + HanddrawnButton( + onPressed: _isLoading ? null : _acceptInvite, + icon: Icons.favorite_border, + label: _isLoading ? 'Joining…' : 'Accept invitation', + ), + ], + ], + ), + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 18), + PairingErrorNote(message: _errorMessage!), + ], + if (_recoveryCode case final code?) ...[ + const SizedBox(height: 18), + RecoveryCodeNote(recoveryCode: code), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart index d9f8ee3..dcb1105 100644 --- a/lib/features/pairing/inviter_setup_page.dart +++ b/lib/features/pairing/inviter_setup_page.dart @@ -1,7 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../design/handdrawn_scaffold.dart'; +import '../../design/pawmate_theme.dart'; +import 'widgets/invite_result_card.dart'; +import 'widgets/pairing_error_note.dart'; +import 'widgets/pairing_header.dart'; +import 'widgets/pairing_recovery_card.dart'; +import 'widgets/recovery_code_note.dart'; +import 'widgets/server_setup_card.dart'; import 'pairing_api.dart'; +import 'pairing_credentials.dart'; class InviterSetupPage extends StatefulWidget { const InviterSetupPage({super.key}); @@ -13,16 +22,54 @@ class InviterSetupPage extends StatefulWidget { class _InviterSetupPageState extends State { final _formKey = GlobalKey(); final _serverURLController = TextEditingController(); + final _recoveryServerController = TextEditingController(); + final _recoveryCodeController = TextEditingController(); final _api = PairingApi(); + final _credentials = PairingCredentials(); PairingInvite? _invite; PairingStatus? _status; String? _errorMessage; bool _isLoading = false; + String? _recoveredCode; + String? _recoveredRole; + + @override + void initState() { + super.initState(); + _restoreSavedInvite(); + } + + Future _restoreSavedInvite() async { + try { + final credentials = await _credentials.read(); + if (!mounted || credentials == null || credentials.role != 'inviter') { + return; + } + _serverURLController.text = credentials.serverURL; + final inviteURL = credentials.inviteURL; + final expiresAt = credentials.expiresAt; + if (inviteURL != null && expiresAt != null) { + setState(() { + _invite = PairingInvite( + inviteURL: inviteURL, + inviterToken: credentials.accessToken, + recoveryCode: credentials.recoveryCode, + expiresAt: expiresAt, + ); + }); + await _refreshStatus(); + } + } on Object { + // A damaged local credential record must not block server setup. + } + } /// Releases controllers and the HTTP client owned by this page. @override void dispose() { _serverURLController.dispose(); + _recoveryServerController.dispose(); + _recoveryCodeController.dispose(); _api.close(); super.dispose(); } @@ -33,12 +80,31 @@ class _InviterSetupPageState extends State { setState(() { _errorMessage = null; _isLoading = true; + _invite = null; _status = null; }); try { final invite = await _api.createInvite(_serverURLController.text); if (!mounted) return; setState(() => _invite = invite); + try { + await _credentials.save( + serverURL: _serverURLController.text.trim(), + accessToken: invite.inviterToken, + recoveryCode: invite.recoveryCode, + pairID: '', + role: 'inviter', + inviteURL: invite.inviteURL, + expiresAt: invite.expiresAt, + ); + } on Object { + if (mounted) { + setState(() { + _errorMessage = + 'Invitation created. Save the recovery code below; this device could not store it securely.'; + }); + } + } } on PairingApiException catch (error) { if (mounted) setState(() => _errorMessage = error.message); } on Object { @@ -50,6 +116,45 @@ class _InviterSetupPageState extends State { } } + /// Restores this member's access after reinstall and rotates the recovery code. + Future _recoverAccess() async { + setState(() { + _errorMessage = null; + _recoveredCode = null; + _recoveredRole = null; + _isLoading = true; + }); + try { + final credentials = await _api.recover( + _recoveryServerController.text, + _recoveryCodeController.text, + ); + await _credentials.save( + serverURL: _recoveryServerController.text.trim(), + accessToken: credentials.accessToken, + recoveryCode: credentials.recoveryCode, + pairID: credentials.pairID, + role: credentials.role, + ); + if (mounted) { + setState(() { + _recoveredCode = credentials.recoveryCode; + _recoveredRole = credentials.role; + }); + } + } on PairingApiException catch (error) { + if (mounted) setState(() => _errorMessage = error.message); + } on Object { + if (mounted) { + setState( + () => _errorMessage = 'Could not restore access to this home.', + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + /// Refreshes the invitation state shown to the inviter. Future _refreshStatus() async { final invite = _invite; @@ -60,6 +165,17 @@ class _InviterSetupPageState extends State { _serverURLController.text, invite.inviterToken, ); + if (status.status == 'paired' && status.pairID != null) { + await _credentials.save( + serverURL: _serverURLController.text.trim(), + accessToken: invite.inviterToken, + recoveryCode: invite.recoveryCode, + pairID: status.pairID!, + role: 'inviter', + inviteURL: invite.inviteURL, + expiresAt: invite.expiresAt, + ); + } if (mounted) setState(() => _status = status); } on PairingApiException catch (error) { if (mounted) setState(() => _errorMessage = error.message); @@ -89,86 +205,55 @@ class _InviterSetupPageState extends State { Widget build(BuildContext context) { final invite = _invite; final status = _status; - return Scaffold( - appBar: AppBar(title: const Text('Invite your partner')), + return HanddrawnScaffold( + title: 'Invite your partner', body: SafeArea( child: ListView( - padding: const EdgeInsets.all(24), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), children: [ - Text( - 'Start your little home', - style: Theme.of(context).textTheme.headlineMedium, + const PairingHeader(), + const SizedBox(height: 20), + ServerSetupCard( + formKey: _formKey, + controller: _serverURLController, + isLoading: _isLoading, + onCreateInvite: () => _createInvite(), ), - const SizedBox(height: 8), - const Text( - 'Choose the private server for your home, then create one invitation for your partner.', - ), - const SizedBox(height: 24), - Form( - key: _formKey, - child: TextFormField( - controller: _serverURLController, - autocorrect: false, - keyboardType: TextInputType.url, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Server URL or domain', - hintText: 'https://pawmate.example.com', - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Enter your server URL.'; - } - return null; - }, - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _isLoading ? null : _createInvite, - icon: const Icon(Icons.favorite_border), - label: Text(_isLoading ? 'Working…' : 'Create invitation'), + const SizedBox(height: 18), + PairingRecoveryCard( + serverController: _recoveryServerController, + codeController: _recoveryCodeController, + isLoading: _isLoading, + onRecover: () => _recoverAccess(), ), if (_errorMessage != null) ...[ - const SizedBox(height: 16), - Text( - _errorMessage!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), + const SizedBox(height: 18), + PairingErrorNote(message: _errorMessage!), ], if (invite != null) ...[ - const SizedBox(height: 28), - const Text( - 'Share this one-time invitation link', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - SelectableText(invite.inviteURL), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _copyInvite, - icon: const Icon(Icons.copy), - label: const Text('Copy invitation link'), + const SizedBox(height: 22), + InviteResultCard( + invite: invite, + recoveryCode: invite.recoveryCode, + status: status, + isLoading: _isLoading, + onCopy: () => _copyInvite(), + onRefresh: () => _refreshStatus(), ), - const SizedBox(height: 8), - Text( - 'Expires ${invite.expiresAt.toLocal()}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 20), - OutlinedButton.icon( - onPressed: _isLoading ? null : _refreshStatus, - icon: const Icon(Icons.refresh), - label: const Text('Check pairing status'), - ), - if (status != null) ...[ - const SizedBox(height: 12), + ], + if (_recoveredCode != null) ...[ + const SizedBox(height: 18), + if (_recoveredRole != null) Text( - status.status == 'paired' - ? 'Your partner is paired!' - : 'Waiting for your partner to accept…', + 'Access restored for the ${_recoveredRole!} member.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), ), - ], + const SizedBox(height: 8), + RecoveryCodeNote(recoveryCode: _recoveredCode!), ], ], ), diff --git a/lib/features/pairing/pairing_api.dart b/lib/features/pairing/pairing_api.dart index 78a53c1..03485fc 100644 --- a/lib/features/pairing/pairing_api.dart +++ b/lib/features/pairing/pairing_api.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; /// A one-time invitation returned by the Pawmate instance. @@ -8,6 +9,7 @@ class PairingInvite { required this.expiresAt, required this.inviteURL, required this.inviterToken, + required this.recoveryCode, }); /// Decodes the server's invitation response. @@ -16,28 +18,63 @@ class PairingInvite { expiresAt: DateTime.parse(json['expires_at'] as String), inviteURL: json['invite_url'] as String, inviterToken: json['inviter_token'] as String, + recoveryCode: json['recovery_code'] as String, ); } final DateTime expiresAt; final String inviteURL; final String inviterToken; + final String recoveryCode; } /// Describes the pairing state observed by the inviting device. class PairingStatus { - const PairingStatus({required this.status, this.pairID}); + const PairingStatus({ + required this.status, + this.pairID, + this.inviteeToken, + this.recoveryCode, + }); /// Decodes a pairing status response. factory PairingStatus.fromJson(Map json) { return PairingStatus( pairID: json['pair_id'] as String?, status: json['status'] as String, + inviteeToken: json['invitee_token'] as String?, + recoveryCode: json['recovery_code'] as String?, ); } final String? pairID; final String status; + final String? inviteeToken; + final String? recoveryCode; +} + +/// Credentials issued when a member restores access with a recovery code. +class RecoveredCredentials { + const RecoveredCredentials({ + required this.accessToken, + required this.pairID, + required this.recoveryCode, + required this.role, + }); + + factory RecoveredCredentials.fromJson(Map json) { + return RecoveredCredentials( + accessToken: json['access_token'] as String, + pairID: json['pair_id'] as String, + recoveryCode: json['recovery_code'] as String, + role: json['role'] as String, + ); + } + + final String accessToken; + final String pairID; + final String recoveryCode; + final String role; } /// Represents a user-facing error returned while calling the pairing API. @@ -99,17 +136,59 @@ class PairingApi { ); } + /// Redeems a one-time invite code on the server encoded in the invite link. + Future redeemInvite(String rawServerURL, String code) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .post( + serverURL.resolve('/api/v1/pairing/invites/redeem'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({'code': code}), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw PairingApiException(_messageFor(response)); + } + return PairingStatus.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Rotates credentials using a member's single-use recovery code. + Future recover( + String rawServerURL, + String recoveryCode, + ) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .post( + serverURL.resolve('/api/v1/pairing/recover'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({'recovery_code': recoveryCode.trim()}), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw PairingApiException(_messageFor(response)); + } + return RecoveredCredentials.fromJson( + jsonDecode(response.body) as Map, + ); + } + /// Validates and normalizes a server URL before making a request. Uri _parseServerURL(String rawServerURL) { final uri = Uri.tryParse(rawServerURL.trim()); if (uri == null || uri.host.isEmpty || (uri.scheme != 'http' && uri.scheme != 'https') || + (uri.scheme == 'http' && !kDebugMode) || uri.userInfo.isNotEmpty || uri.hasQuery || uri.hasFragment) { throw const PairingApiException( - 'Use an http:// or https:// server URL without credentials or query parameters.', + 'Use an HTTPS server URL in release builds. HTTP is allowed only in debug builds.', ); } return uri.replace(path: uri.path.replaceFirst(RegExp(r'/+$'), '')); diff --git a/lib/features/pairing/pairing_credentials.dart b/lib/features/pairing/pairing_credentials.dart new file mode 100644 index 0000000..3d9eea4 --- /dev/null +++ b/lib/features/pairing/pairing_credentials.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Stores the current member credentials in the platform secure storage. +class PairingCredentials { + PairingCredentials({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _storageKey = 'pawmate.pairing.credentials.v1'; + + final FlutterSecureStorage _storage; + + /// Reads credentials saved by this installation, if any. + Future read() async { + final value = await _storage.read(key: _storageKey); + if (value == null) return null; + return SavedPairingCredentials.fromJson( + jsonDecode(value) as Map, + ); + } + + /// Saves credentials issued for one member of the couple. + Future save({ + required String serverURL, + required String accessToken, + required String recoveryCode, + required String pairID, + required String role, + String? inviteURL, + DateTime? expiresAt, + }) async { + await _storage.write( + key: _storageKey, + value: jsonEncode({ + 'server_url': serverURL, + 'access_token': accessToken, + 'recovery_code': recoveryCode, + 'pair_id': pairID, + 'role': role, + 'invite_url': ?inviteURL, + 'expires_at': ?expiresAt?.toIso8601String(), + }), + ); + } +} + +/// Credentials retained locally for this app installation. +class SavedPairingCredentials { + const SavedPairingCredentials({ + required this.serverURL, + required this.accessToken, + required this.recoveryCode, + required this.pairID, + required this.role, + this.inviteURL, + this.expiresAt, + }); + + factory SavedPairingCredentials.fromJson(Map json) { + return SavedPairingCredentials( + serverURL: json['server_url'] as String, + accessToken: json['access_token'] as String, + recoveryCode: json['recovery_code'] as String, + pairID: json['pair_id'] as String, + role: json['role'] as String, + inviteURL: json['invite_url'] as String?, + expiresAt: json['expires_at'] == null + ? null + : DateTime.parse(json['expires_at'] as String), + ); + } + + final String serverURL; + final String accessToken; + final String recoveryCode; + final String pairID; + final String role; + final String? inviteURL; + final DateTime? expiresAt; +} diff --git a/lib/features/pairing/widgets/invite_result_card.dart b/lib/features/pairing/widgets/invite_result_card.dart new file mode 100644 index 0000000..10fc2fd --- /dev/null +++ b/lib/features/pairing/widgets/invite_result_card.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +import '../../../design/handdrawn_button.dart'; +import '../../../design/handdrawn_card.dart'; +import '../../../design/pawmate_theme.dart'; +import '../pairing_api.dart'; +import 'recovery_code_note.dart'; + +/// Displays the one-time invitation and the current partner pairing status. +class InviteResultCard extends StatelessWidget { + const InviteResultCard({ + required this.invite, + required this.recoveryCode, + required this.status, + required this.isLoading, + required this.onCopy, + required this.onRefresh, + super.key, + }); + + final PairingInvite invite; + final String recoveryCode; + final PairingStatus? status; + final bool isLoading; + final VoidCallback onCopy; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + final isPaired = status?.status == 'paired'; + return HanddrawnCard( + color: const Color(0xFFF4F0FF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isPaired ? Icons.favorite : Icons.mail_outline, + color: isPaired ? PawmateColors.rose : PawmateColors.lavender, + size: 28, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + isPaired + ? 'Your home is connected!' + : 'Your invitation is ready', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + isPaired + ? 'You and your partner can start making memories together.' + : 'Share this one-time link with your partner.', + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.35, + ), + ), + if (!isPaired) ...[ + const SizedBox(height: 16), + SelectableText( + invite.inviteURL, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + fontSize: 13, + height: 1.35, + ), + ), + const SizedBox(height: 14), + HanddrawnButton( + onPressed: onCopy, + icon: Icons.copy_outlined, + label: 'Copy invitation', + primary: false, + ), + const SizedBox(height: 10), + Text( + 'Expires ${invite.expiresAt.toLocal()}', + style: const TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, + ), + ), + ], + const SizedBox(height: 16), + RecoveryCodeNote(recoveryCode: recoveryCode), + const SizedBox(height: 18), + HanddrawnButton( + onPressed: isLoading ? null : onRefresh, + icon: isPaired ? Icons.check_circle_outline : Icons.refresh, + label: isPaired ? 'Pairing complete' : 'Check pairing status', + primary: isPaired, + ), + if (!isPaired && status != null) ...[ + const SizedBox(height: 10), + const Text( + 'Waiting for your partner to accept…', + style: TextStyle(color: PawmateColors.softBrown), + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/pairing_error_note.dart b/lib/features/pairing/widgets/pairing_error_note.dart new file mode 100644 index 0000000..cf43a27 --- /dev/null +++ b/lib/features/pairing/widgets/pairing_error_note.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../../../design/handdrawn_card.dart'; +import '../../../design/pawmate_theme.dart'; + +/// Shows a readable network or validation error on a paper-like note. +class PairingErrorNote extends StatelessWidget { + const PairingErrorNote({required this.message, super.key}); + + final String message; + + @override + Widget build(BuildContext context) { + return HanddrawnCard( + color: const Color(0xFFFFE9E4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline, color: PawmateColors.error), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: const TextStyle(color: PawmateColors.ink, height: 1.35), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/pairing_header.dart b/lib/features/pairing/widgets/pairing_header.dart new file mode 100644 index 0000000..a4ff511 --- /dev/null +++ b/lib/features/pairing/widgets/pairing_header.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../../../design/doodle_house.dart'; +import '../../../design/pawmate_theme.dart'; + +/// Introduces the pairing flow with a small shared-home illustration. +class PairingHeader extends StatelessWidget { + const PairingHeader({super.key}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const DoodleHouse(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Start your little home', + style: textTheme.headlineMedium?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + 'Set up a private place, then invite your favorite person in.', + style: textTheme.bodyLarge?.copyWith( + color: PawmateColors.softBrown, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/pairing/widgets/pairing_recovery_card.dart b/lib/features/pairing/widgets/pairing_recovery_card.dart new file mode 100644 index 0000000..ff9488a --- /dev/null +++ b/lib/features/pairing/widgets/pairing_recovery_card.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import '../../../design/handdrawn_button.dart'; +import '../../../design/handdrawn_card.dart'; +import '../../../design/handdrawn_text_field.dart'; +import '../../../design/pawmate_theme.dart'; + +/// Collects the server address and recovery code after an app reinstall. +class PairingRecoveryCard extends StatelessWidget { + const PairingRecoveryCard({ + required this.serverController, + required this.codeController, + required this.isLoading, + required this.onRecover, + super.key, + }); + + final TextEditingController serverController; + final TextEditingController codeController; + final bool isLoading; + final VoidCallback onRecover; + + @override + Widget build(BuildContext context) { + return HanddrawnCard( + color: const Color(0xFFF0F6F0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Restoring an existing home?', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + const Text( + 'Enter the same server address and the recovery code saved for this member.', + style: TextStyle(color: PawmateColors.softBrown, height: 1.35), + ), + const SizedBox(height: 14), + HanddrawnTextField( + controller: serverController, + labelText: 'Existing server URL', + hintText: 'https://pawmate.example.com', + prefixIcon: Icons.link, + ), + const SizedBox(height: 12), + TextFormField( + controller: codeController, + autocorrect: false, + enableSuggestions: false, + textCapitalization: TextCapitalization.none, + decoration: const InputDecoration( + labelText: 'Recovery code', + prefixIcon: Icon(Icons.key_outlined), + ), + ), + const SizedBox(height: 14), + HanddrawnButton( + onPressed: isLoading ? null : onRecover, + icon: Icons.settings_backup_restore, + label: isLoading ? 'Restoring access…' : 'Restore access', + primary: false, + ), + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/recovery_code_note.dart b/lib/features/pairing/widgets/recovery_code_note.dart new file mode 100644 index 0000000..93506fd --- /dev/null +++ b/lib/features/pairing/widgets/recovery_code_note.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../design/handdrawn_card.dart'; +import '../../../design/pawmate_theme.dart'; + +/// Presents a member's one-time recovery code with a copy action. +class RecoveryCodeNote extends StatelessWidget { + const RecoveryCodeNote({required this.recoveryCode, super.key}); + + final String recoveryCode; + + @override + Widget build(BuildContext context) { + return HanddrawnCard( + color: const Color(0xFFFFF3CF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Keep this recovery code somewhere safe', + style: TextStyle( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + const Text( + 'If you reinstall the app, use this code with your server address to restore your place in this home.', + style: TextStyle(color: PawmateColors.softBrown, height: 1.35), + ), + const SizedBox(height: 12), + SelectableText( + recoveryCode, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: recoveryCode)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Recovery code copied')), + ); + } + }, + icon: const Icon(Icons.copy_outlined), + label: const Text('Copy code'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/server_setup_card.dart b/lib/features/pairing/widgets/server_setup_card.dart new file mode 100644 index 0000000..3518031 --- /dev/null +++ b/lib/features/pairing/widgets/server_setup_card.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../../../design/handdrawn_button.dart'; +import '../../../design/handdrawn_card.dart'; +import '../../../design/handdrawn_text_field.dart'; +import '../../../design/pawmate_theme.dart'; + +/// Collects and submits the inviter's private Pawmate server address. +class ServerSetupCard extends StatelessWidget { + const ServerSetupCard({ + required this.formKey, + required this.controller, + required this.isLoading, + required this.onCreateInvite, + super.key, + }); + + final GlobalKey formKey; + final TextEditingController controller; + final bool isLoading; + final VoidCallback onCreateInvite; + + @override + Widget build(BuildContext context) { + return HanddrawnCard( + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.home_work_outlined, color: PawmateColors.ink), + const SizedBox(width: 8), + Text( + 'Choose your home address', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Use the URL or domain of the private server you trust.', + style: TextStyle(color: PawmateColors.softBrown, height: 1.35), + ), + const SizedBox(height: 18), + HanddrawnTextField( + controller: controller, + labelText: 'Server URL or domain', + hintText: 'https://pawmate.example.com', + prefixIcon: Icons.link, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Enter your server URL.'; + } + return null; + }, + ), + const SizedBox(height: 18), + HanddrawnButton( + onPressed: isLoading ? null : onCreateInvite, + icon: Icons.favorite_border, + label: isLoading ? 'Making a little room…' : 'Create invitation', + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 089f829..c8ba0d1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,24 +1,67 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:app_links/app_links.dart'; +import 'design/pawmate_theme.dart'; +import 'features/pairing/invitee_accept_page.dart'; import 'features/pairing/inviter_setup_page.dart'; void main() { + WidgetsFlutterBinding.ensureInitialized(); runApp(const PawmateApp()); } /// Root widget for the Pawmate Flutter application. -class PawmateApp extends StatelessWidget { +class PawmateApp extends StatefulWidget { const PawmateApp({super.key}); + @override + State createState() => _PawmateAppState(); +} + +class _PawmateAppState extends State { + final _navigatorKey = GlobalKey(); + final _appLinks = AppLinks(); + String? _lastOpenedInvite; + StreamSubscription? _linkSubscription; + + @override + void initState() { + super.initState(); + _linkSubscription = _appLinks.uriLinkStream.listen(_openInviteLink); + } + + @override + void dispose() { + _linkSubscription?.cancel(); + super.dispose(); + } + + void _openInviteLink(Uri uri) { + if (uri.scheme != 'pawmate' || uri.host != 'pair') return; + if (uri.queryParameters['server'] == null || + uri.queryParameters['code'] == null || + _lastOpenedInvite == uri.toString()) { + return; + } + _lastOpenedInvite = uri.toString(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => InviteeAcceptPage(inviteUri: uri), + ), + ); + }); + } + /// Configures the app theme and starts the inviter onboarding flow. @override Widget build(BuildContext context) { return MaterialApp( + navigatorKey: _navigatorKey, title: 'Pawmate', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), + theme: buildPawmateTheme(), home: const InviterSetupPage(), ); } diff --git a/pubspec.lock b/pubspec.lock index 0018ada..ed34566 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,46 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + app_links: + dependency: "direct main" + description: + name: app_links + sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea + url: "https://pub.dev" + source: hosted + version: "7.2.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -33,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -41,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -57,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" flutter: dependency: "direct main" description: flutter @@ -70,11 +142,80 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8 + url: "https://pub.dev" + source: hosted + version: "11.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21 + url: "https://pub.dev" + source: hosted + version: "0.4.3" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea + url: "https://pub.dev" + source: hosted + version: "3.0.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http: dependency: "direct main" description: @@ -91,6 +232,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" leak_tracker: dependency: transitive description: @@ -123,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -147,6 +320,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -155,6 +344,86 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" sky_engine: dependency: transitive description: flutter @@ -240,6 +509,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index a7b013f..ed0610c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,8 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 http: ^1.5.0 + app_links: ^7.2.1 + flutter_secure_storage: ^11.2.0 dev_dependencies: flutter_test: diff --git a/server/README.md b/server/README.md index 9448f12..5ac4cbd 100644 --- a/server/README.md +++ b/server/README.md @@ -1,8 +1,7 @@ # Pawmate server -Gin HTTP API for a private Pawmate instance. The initial endpoints allow the -Flutter client to verify a configured instance URL before registration and -couple pairing are added. +Gin HTTP API for a private Pawmate instance. It exposes instance discovery and +the first two-person pairing flow. Pairing state is persisted in SQLite. ## Run locally @@ -17,12 +16,13 @@ curl http://localhost:8080/healthz curl http://localhost:8080/api/v1/instance ``` -The inviter pairing MVP exposes: +The pairing API exposes: ```text POST /api/v1/pairing/invites GET /api/v1/pairing/invites/status POST /api/v1/pairing/invites/redeem +POST /api/v1/pairing/recover ``` Create an invitation by sending the configured server URL: @@ -33,10 +33,23 @@ curl -X POST http://localhost:8080/api/v1/pairing/invites \ -d '{"server_url":"http://localhost:8080"}' ``` -The response contains a one-time `invite_url` and an `inviter_token` for status -checks. This initial implementation stores pairing state in memory for local -development; restarting the process clears it. Add persistent storage before -deploying a real instance. +The response contains a one-time `invite_url`, an `inviter_token` for status +checks, and a `recovery_code`. The invitee opens the custom link in Pawmate and +accepts it; the app redeems the code and receives that member's access token and +recovery code. Invitation codes expire after ten minutes and can be redeemed +only once. + +Pairing state is stored in `pawmate.db` in the server's current working +directory. Override the path with `PAWMATE_DATABASE_PATH`. The database stores +SHA-256 hashes of invitation codes, access tokens, and recovery codes rather +than their raw values. Keep the database on persistent storage and include it +in server backups. Stop the server before copying the database file. + +Each member should save their recovery code outside the app. After reinstall, +enter the same server URL and recovery code in the restore section. The server +rotates the access token and recovery code, so the old recovery code can no +longer be used. A recovery code belongs to one member; the two members should +keep separate codes. For the Android emulator, configure the Flutter client with `http://10.0.2.2:8080`; `10.0.2.2` maps to the development machine's loopback @@ -45,8 +58,9 @@ interface from inside the emulator. ## Configuration Copy `.env.example` into your process environment before starting the server. -The environment variables control the port and the stable identity shown to -clients. Production instances should set a real HTTPS `PAWMATE_PUBLIC_URL`. +The environment variables control the port, database path, and stable identity +shown to clients. Production instances should set a real HTTPS +`PAWMATE_PUBLIC_URL` and keep the SQLite database on a persistent volume. ## Verify diff --git a/server/cmd/pawmate-server/main.go b/server/cmd/pawmate-server/main.go index edc753d..a8678a4 100644 --- a/server/cmd/pawmate-server/main.go +++ b/server/cmd/pawmate-server/main.go @@ -12,15 +12,22 @@ import ( "pawmate/server/internal/config" "pawmate/server/internal/httpapi" + "pawmate/server/internal/pairing" ) func main() { cfg := config.Load() logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})) + pairingService, err := pairing.OpenService(cfg.DatabasePath) + if err != nil { + logger.Error("could not open pairing database", "error", err) + os.Exit(1) + } + defer pairingService.Close() server := &http.Server{ Addr: ":" + cfg.Port, - Handler: httpapi.NewRouter(cfg, logger), + Handler: httpapi.NewRouter(cfg, logger, pairingService), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, diff --git a/server/go.mod b/server/go.mod index f7f4889..7d2842a 100644 --- a/server/go.mod +++ b/server/go.mod @@ -2,12 +2,16 @@ module pawmate/server go 1.26.0 -require github.com/gin-gonic/gin v1.11.0 +require ( + github.com/gin-gonic/gin v1.11.0 + modernc.org/sqlite v1.59.0 +) require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -15,25 +19,31 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.9 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect ) diff --git a/server/go.sum b/server/go.sum index a25fff5..f2af4ac 100644 --- a/server/go.sum +++ b/server/go.sum @@ -7,6 +7,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -28,18 +30,26 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -48,6 +58,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -65,24 +77,51 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/server/internal/config/config.go b/server/internal/config/config.go index ce5bd38..80c1b20 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -9,6 +9,7 @@ type Config struct { InstanceName string Port string PublicURL string + DatabasePath string } // Load reads configuration and applies development-friendly defaults. @@ -19,6 +20,7 @@ func Load() Config { InstanceName: envOrDefault("PAWMATE_INSTANCE_NAME", "Our Little Home"), Port: envOrDefault("PAWMATE_PORT", "8080"), PublicURL: os.Getenv("PAWMATE_PUBLIC_URL"), + DatabasePath: envOrDefault("PAWMATE_DATABASE_PATH", "pawmate.db"), } } diff --git a/server/internal/httpapi/pairing.go b/server/internal/httpapi/pairing.go index fd3a3ef..f0d08ac 100644 --- a/server/internal/httpapi/pairing.go +++ b/server/internal/httpapi/pairing.go @@ -18,6 +18,7 @@ type createInviteResponse struct { ExpiresAt string `json:"expires_at"` InviteURL string `json:"invite_url"` InviterToken string `json:"inviter_token"` + RecoveryCode string `json:"recovery_code"` } type redeemInviteRequest struct { @@ -26,10 +27,22 @@ type redeemInviteRequest struct { type pairingStatusResponse struct { InviteeToken string `json:"invitee_token,omitempty"` + RecoveryCode string `json:"recovery_code,omitempty"` PairID string `json:"pair_id,omitempty"` Status string `json:"status"` } +type recoverPairingRequest struct { + RecoveryCode string `json:"recovery_code" binding:"required"` +} + +type recoverPairingResponse struct { + AccessToken string `json:"access_token"` + PairID string `json:"pair_id"` + RecoveryCode string `json:"recovery_code"` + Role string `json:"role"` +} + // createInviteHandler creates an invitation for the configured server URL. func createInviteHandler(service *pairing.Service) gin.HandlerFunc { return func(c *gin.Context) { @@ -59,6 +72,7 @@ func createInviteHandler(service *pairing.Service) gin.HandlerFunc { ExpiresAt: invite.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z"), InviteURL: invite.URL, InviterToken: invite.InviterToken, + RecoveryCode: invite.RecoveryCode, }) } } @@ -78,6 +92,8 @@ func redeemInviteHandler(service *pairing.Service) gin.HandlerFunc { httpStatus := http.StatusConflict if errors.Is(err, pairing.ErrExpiredInvite) { code, httpStatus = "expired_invite", http.StatusGone + } else if !errors.Is(err, pairing.ErrInvalidInvite) { + code, httpStatus = "internal_error", http.StatusInternalServerError } c.JSON(httpStatus, gin.H{"error": code}) return @@ -85,19 +101,55 @@ func redeemInviteHandler(service *pairing.Service) gin.HandlerFunc { c.JSON(http.StatusOK, pairingStatusResponse{ InviteeToken: status.InviteeToken, + RecoveryCode: status.RecoveryCode, PairID: status.PairID, Status: status.Status, }) } } +// recoverPairingHandler rotates a member's credentials using their recovery code. +func recoverPairingHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var request recoverPairingRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + + credentials, err := service.Recover(request.RecoveryCode) + if err != nil { + code := "invalid_recovery_code" + status := http.StatusUnauthorized + if errors.Is(err, pairing.ErrNotPaired) { + code, status = "not_paired", http.StatusConflict + } else if !errors.Is(err, pairing.ErrInvalidRecoveryCode) { + code, status = "internal_error", http.StatusInternalServerError + } + c.JSON(status, gin.H{"error": code}) + return + } + + c.JSON(http.StatusOK, recoverPairingResponse{ + AccessToken: credentials.AccessToken, + PairID: credentials.PairID, + RecoveryCode: credentials.RecoveryCode, + Role: credentials.Role, + }) + } +} + // pairingStatusHandler reports state to the device that created the invite. func pairingStatusHandler(service *pairing.Service) gin.HandlerFunc { return func(c *gin.Context) { token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) status, err := service.Status(token) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_inviter_token"}) + if errors.Is(err, pairing.ErrInvalidInviterToken) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_inviter_token"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal_error"}) return } c.JSON(http.StatusOK, pairingStatusResponse{PairID: status.PairID, Status: status.Status}) diff --git a/server/internal/httpapi/pairing_test.go b/server/internal/httpapi/pairing_test.go index b7b9ef3..203bdd7 100644 --- a/server/internal/httpapi/pairing_test.go +++ b/server/internal/httpapi/pairing_test.go @@ -10,15 +10,21 @@ import ( "testing" "pawmate/server/internal/config" + "pawmate/server/internal/pairing" ) func TestPairingHTTPFlow(t *testing.T) { + service, err := pairing.OpenService(":memory:") + if err != nil { + t.Fatal(err) + } + defer service.Close() router := NewRouter(config.Config{ Environment: "test", InstanceID: "test-instance", InstanceName: "Test Home", Port: "8080", - }, slog.New(slog.NewTextHandler(testLogWriter{}, nil))) + }, slog.New(slog.NewTextHandler(testLogWriter{}, nil)), service) createResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites", `{"server_url":"https://home.example.test"}`, "") if createResponse.Code != http.StatusCreated { @@ -27,9 +33,10 @@ func TestPairingHTTPFlow(t *testing.T) { var invite struct { InviteURL string `json:"invite_url"` InviterToken string `json:"inviter_token"` + RecoveryCode string `json:"recovery_code"` } decodeJSON(t, createResponse, &invite) - if invite.InviteURL == "" || invite.InviterToken == "" { + if invite.InviteURL == "" || invite.InviterToken == "" || invite.RecoveryCode == "" { t.Fatalf("create response did not contain invite credentials: %+v", invite) } diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index 05aeaee..a49a9a7 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -12,7 +12,7 @@ import ( ) // NewRouter creates the versioned HTTP API served by a Pawmate instance. -func NewRouter(cfg config.Config, logger *slog.Logger) *gin.Engine { +func NewRouter(cfg config.Config, logger *slog.Logger, pairingService *pairing.Service) *gin.Engine { if cfg.Environment == "production" { gin.SetMode(gin.ReleaseMode) } @@ -30,12 +30,12 @@ func NewRouter(cfg config.Config, logger *slog.Logger) *gin.Engine { }), gin.Recovery()) router.GET("/healthz", healthHandler) - pairingService := pairing.NewService() api := router.Group("/api/v1") api.GET("/instance", instanceHandler(cfg)) api.POST("/pairing/invites", createInviteHandler(pairingService)) api.POST("/pairing/invites/redeem", redeemInviteHandler(pairingService)) api.GET("/pairing/invites/status", pairingStatusHandler(pairingService)) + api.POST("/pairing/recover", recoverPairingHandler(pairingService)) return router } diff --git a/server/internal/httpapi/router_test.go b/server/internal/httpapi/router_test.go index b4b5666..bc1ef98 100644 --- a/server/internal/httpapi/router_test.go +++ b/server/internal/httpapi/router_test.go @@ -8,6 +8,7 @@ import ( "testing" "pawmate/server/internal/config" + "pawmate/server/internal/pairing" ) func TestHealthz(t *testing.T) { @@ -36,10 +37,14 @@ func TestInstance(t *testing.T) { } func testRouter() http.Handler { + pairingService, err := pairing.OpenService(":memory:") + if err != nil { + panic(err) + } return NewRouter(config.Config{ Environment: "test", InstanceID: "test-instance", InstanceName: "Test Home", Port: "8080", - }, slog.New(slog.NewTextHandler(io.Discard, nil))) + }, slog.New(slog.NewTextHandler(io.Discard, nil)), pairingService) } diff --git a/server/internal/pairing/service.go b/server/internal/pairing/service.go index 36c558f..b4d4aad 100644 --- a/server/internal/pairing/service.go +++ b/server/internal/pairing/service.go @@ -3,12 +3,17 @@ package pairing import ( "crypto/rand" "crypto/sha256" + "crypto/subtle" + "database/sql" "encoding/base64" "errors" + "fmt" "net/url" "strings" "sync" "time" + + _ "modernc.org/sqlite" ) var ( @@ -17,6 +22,8 @@ var ( ErrInvalidInvite = errors.New("invite is invalid") ErrExpiredInvite = errors.New("invite has expired") ErrInvalidInviterToken = errors.New("inviter token is invalid") + ErrInvalidRecoveryCode = errors.New("recovery code is invalid") + ErrNotPaired = errors.New("instance is not paired") ErrInvalidServerURL = errors.New("server URL is invalid") ) @@ -26,55 +33,89 @@ const inviteTTL = 10 * time.Minute type Invite struct { URL string InviterToken string + RecoveryCode string ExpiresAt time.Time } -// PairingStatus describes the state visible to the inviting client. +// PairingStatus describes the state visible to a paired client. type PairingStatus struct { PairID string Status string InviteeToken string + RecoveryCode string } -// Service is the development pairing state for one private Pawmate instance. -// It is intentionally in-memory until the SQLite persistence chapter is implemented. +// RecoveredCredentials contains rotated credentials for a reinstalled client. +type RecoveredCredentials struct { + PairID string + Role string + AccessToken string + RecoveryCode string +} + +// Service stores the single couple relationship for one private instance. type Service struct { - mu sync.Mutex - now func() time.Time - invite *inviteState - paired bool + db *sql.DB + mu sync.Mutex + now func() time.Time } -type inviteState struct { - codeHash [sha256.Size]byte - expiresAt time.Time - inviterToken string - pairID string - inviteeToken string +// OpenService opens or creates the SQLite database used by the pairing flow. +func OpenService(databasePath string) (*Service, error) { + db, err := sql.Open("sqlite", databasePath) + if err != nil { + return nil, fmt.Errorf("open pairing database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + if _, err := db.Exec(`PRAGMA busy_timeout = 5000`); err != nil { + db.Close() + return nil, fmt.Errorf("configure pairing database: %w", err) + } + if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil { + db.Close() + return nil, fmt.Errorf("configure pairing database: %w", err) + } + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS pairing_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + server_url TEXT NOT NULL, + invite_code_hash BLOB NOT NULL, + invite_expires_at INTEGER NOT NULL, + inviter_token_hash BLOB NOT NULL, + inviter_recovery_hash BLOB NOT NULL, + pair_id TEXT, + invitee_token_hash BLOB, + invitee_recovery_hash BLOB, + paired INTEGER NOT NULL DEFAULT 0 + )`); err != nil { + db.Close() + return nil, fmt.Errorf("create pairing schema: %w", err) + } + return &Service{db: db, now: time.Now}, nil } -// NewService creates process-local pairing state for one server instance. +// NewService creates an in-memory pairing service for isolated tests. func NewService() *Service { - // The service is process-local for this first development slice. - return &Service{now: time.Now} + service, err := OpenService(":memory:") + if err != nil { + panic(err) + } + return service } -// CreateInvite creates one expiring invite for the instance. +// Close releases the SQLite database connection. +func (service *Service) Close() error { + return service.db.Close() +} + +// CreateInvite creates one expiring invitation for the instance. func (service *Service) CreateInvite(serverURL string) (Invite, error) { baseURL, err := normalizeServerURL(serverURL) if err != nil { return Invite{}, err } - service.mu.Lock() - defer service.mu.Unlock() - if service.paired { - return Invite{}, ErrAlreadyPaired - } - if service.invite != nil && service.now().Before(service.invite.expiresAt) { - return Invite{}, ErrInvitePending - } - code, err := randomToken(32) if err != nil { return Invite{}, err @@ -83,17 +124,55 @@ func (service *Service) CreateInvite(serverURL string) (Invite, error) { if err != nil { return Invite{}, err } + inviterRecovery, err := randomToken(24) + if err != nil { + return Invite{}, err + } expiresAt := service.now().Add(inviteTTL) - state := &inviteState{ - codeHash: sha256.Sum256([]byte(code)), - expiresAt: expiresAt, - inviterToken: inviterToken, + codeHash := tokenHash(code) + inviterTokenHash := tokenHash(inviterToken) + inviterRecoveryHash := tokenHash(inviterRecovery) + + service.mu.Lock() + defer service.mu.Unlock() + + var paired bool + var existingExpiry int64 + err = service.db.QueryRow(`SELECT paired, invite_expires_at FROM pairing_state WHERE id = 1`).Scan(&paired, &existingExpiry) + switch { + case err == nil && paired: + return Invite{}, ErrAlreadyPaired + case err == nil && service.now().Unix() < existingExpiry: + return Invite{}, ErrInvitePending + case err != nil && !errors.Is(err, sql.ErrNoRows): + return Invite{}, fmt.Errorf("read pairing state: %w", err) + } + + _, err = service.db.Exec(` + INSERT INTO pairing_state ( + id, server_url, invite_code_hash, invite_expires_at, + inviter_token_hash, inviter_recovery_hash, pair_id, + invitee_token_hash, invitee_recovery_hash, paired + ) VALUES (1, ?, ?, ?, ?, ?, NULL, NULL, NULL, 0) + ON CONFLICT(id) DO UPDATE SET + server_url = excluded.server_url, + invite_code_hash = excluded.invite_code_hash, + invite_expires_at = excluded.invite_expires_at, + inviter_token_hash = excluded.inviter_token_hash, + inviter_recovery_hash = excluded.inviter_recovery_hash, + pair_id = NULL, + invitee_token_hash = NULL, + invitee_recovery_hash = NULL, + paired = 0`, + baseURL, codeHash[:], expiresAt.Unix(), inviterTokenHash[:], inviterRecoveryHash[:]) + if err != nil { + return Invite{}, fmt.Errorf("save pairing invitation: %w", err) } - service.invite = state return Invite{ URL: "pawmate://pair?server=" + url.QueryEscape(baseURL) + "&code=" + url.QueryEscape(code), InviterToken: inviterToken, + RecoveryCode: inviterRecovery, ExpiresAt: expiresAt, }, nil } @@ -103,13 +182,20 @@ func (service *Service) RedeemInvite(code string) (PairingStatus, error) { service.mu.Lock() defer service.mu.Unlock() - if service.paired || service.invite == nil { + var paired bool + var expiresAt int64 + var storedCodeHash []byte + err := service.db.QueryRow(`SELECT paired, invite_expires_at, invite_code_hash FROM pairing_state WHERE id = 1`).Scan(&paired, &expiresAt, &storedCodeHash) + if errors.Is(err, sql.ErrNoRows) || paired { return PairingStatus{}, ErrInvalidInvite } - if !service.now().Before(service.invite.expiresAt) { + if err != nil { + return PairingStatus{}, fmt.Errorf("read pairing invitation: %w", err) + } + if service.now().Unix() >= expiresAt { return PairingStatus{}, ErrExpiredInvite } - if service.invite.codeHash != sha256.Sum256([]byte(strings.TrimSpace(code))) { + if !sameHash(storedCodeHash, tokenHash(strings.TrimSpace(code))) { return PairingStatus{}, ErrInvalidInvite } @@ -121,27 +207,114 @@ func (service *Service) RedeemInvite(code string) (PairingStatus, error) { if err != nil { return PairingStatus{}, err } - service.invite.pairID = pairID - service.invite.inviteeToken = inviteeToken - service.paired = true + inviteeRecovery, err := randomToken(24) + if err != nil { + return PairingStatus{}, err + } + inviteeTokenHash := tokenHash(inviteeToken) + inviteeRecoveryHash := tokenHash(inviteeRecovery) + result, err := service.db.Exec(` + UPDATE pairing_state + SET pair_id = ?, invitee_token_hash = ?, invitee_recovery_hash = ?, paired = 1 + WHERE id = 1 AND paired = 0 AND invite_code_hash = ? AND invite_expires_at > ?`, + pairID, inviteeTokenHash[:], inviteeRecoveryHash[:], storedCodeHash, service.now().Unix()) + if err != nil { + return PairingStatus{}, fmt.Errorf("complete pairing: %w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return PairingStatus{}, fmt.Errorf("check pairing update: %w", err) + } + if changed != 1 { + return PairingStatus{}, ErrInvalidInvite + } - return PairingStatus{PairID: pairID, Status: "paired", InviteeToken: inviteeToken}, nil + return PairingStatus{ + PairID: pairID, + Status: "paired", + InviteeToken: inviteeToken, + RecoveryCode: inviteeRecovery, + }, nil } -// Status returns pairing state for the inviting client. +// Status returns pairing state after validating the inviter's bearer token. func (service *Service) Status(inviterToken string) (PairingStatus, error) { service.mu.Lock() defer service.mu.Unlock() - if service.invite == nil || service.invite.inviterToken != inviterToken { + var paired bool + var pairID sql.NullString + var storedTokenHash []byte + err := service.db.QueryRow(`SELECT paired, pair_id, inviter_token_hash FROM pairing_state WHERE id = 1`).Scan(&paired, &pairID, &storedTokenHash) + if errors.Is(err, sql.ErrNoRows) { return PairingStatus{}, ErrInvalidInviterToken } - if !service.paired { + if err != nil { + return PairingStatus{}, fmt.Errorf("read pairing status: %w", err) + } + if !sameHash(storedTokenHash, tokenHash(strings.TrimSpace(inviterToken))) { + return PairingStatus{}, ErrInvalidInviterToken + } + if !paired { return PairingStatus{Status: "pending"}, nil } - return PairingStatus{ - PairID: service.invite.pairID, - Status: "paired", + return PairingStatus{PairID: pairID.String, Status: "paired"}, nil +} + +// Recover rotates one member's credentials using their single-use recovery code. +func (service *Service) Recover(recoveryCode string) (RecoveredCredentials, error) { + service.mu.Lock() + defer service.mu.Unlock() + + var pairID sql.NullString + var paired bool + var inviterHash, inviteeHash []byte + err := service.db.QueryRow(` + SELECT pair_id, paired, inviter_recovery_hash, invitee_recovery_hash + FROM pairing_state WHERE id = 1`).Scan(&pairID, &paired, &inviterHash, &inviteeHash) + if errors.Is(err, sql.ErrNoRows) { + return RecoveredCredentials{}, ErrNotPaired + } + if err != nil { + return RecoveredCredentials{}, fmt.Errorf("read recovery state: %w", err) + } + if !paired { + return RecoveredCredentials{}, ErrNotPaired + } + + providedHash := tokenHash(strings.TrimSpace(recoveryCode)) + role := "" + if sameHash(inviterHash, providedHash) { + role = "inviter" + } else if sameHash(inviteeHash, providedHash) { + role = "invitee" + } else { + return RecoveredCredentials{}, ErrInvalidRecoveryCode + } + + accessToken, err := randomToken(32) + if err != nil { + return RecoveredCredentials{}, err + } + newRecoveryCode, err := randomToken(24) + if err != nil { + return RecoveredCredentials{}, err + } + tokenColumn, recoveryColumn := "inviter_token_hash", "inviter_recovery_hash" + if role == "invitee" { + tokenColumn, recoveryColumn = "invitee_token_hash", "invitee_recovery_hash" + } + query := `UPDATE pairing_state SET ` + tokenColumn + ` = ?, ` + recoveryColumn + ` = ? WHERE id = 1 AND paired = 1` + accessTokenHash := tokenHash(accessToken) + newRecoveryHash := tokenHash(newRecoveryCode) + if _, err := service.db.Exec(query, accessTokenHash[:], newRecoveryHash[:]); err != nil { + return RecoveredCredentials{}, fmt.Errorf("rotate recovered credentials: %w", err) + } + return RecoveredCredentials{ + PairID: pairID.String, + Role: role, + AccessToken: accessToken, + RecoveryCode: newRecoveryCode, }, nil } @@ -158,6 +331,14 @@ func normalizeServerURL(raw string) (string, error) { return strings.TrimRight(parsed.String(), "/"), nil } +func tokenHash(token string) [sha256.Size]byte { + return sha256.Sum256([]byte(token)) +} + +func sameHash(stored []byte, provided [sha256.Size]byte) bool { + return len(stored) == len(provided) && subtle.ConstantTimeCompare(stored, provided[:]) == 1 +} + // randomToken creates an URL-safe cryptographic bearer token. func randomToken(size int) (string, error) { data := make([]byte, size) From 765a18568d0035a8343e2e3968e7aabcdf67ff5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sat, 3 Oct 2026 23:38:15 +0800 Subject: [PATCH 06/16] feat(pairing): validate sessions and restore couple access - validate saved credentials on app startup and route paired users home - add authenticated pairing session endpoint - provide recovery flow for invalid credentials and retry handling for network errors - navigate invitees and newly paired inviters to the couple home - document session validation and recovery behavior --- books/src/current-foundation.md | 12 +- lib/features/pairing/invitee_accept_page.dart | 60 +++-- lib/features/pairing/inviter_setup_page.dart | 34 ++- lib/features/pairing/paired_home_page.dart | 105 +++++++++ lib/features/pairing/pairing_api.dart | 64 ++++- .../pairing/pairing_recovery_page.dart | 159 +++++++++++++ .../pairing/pairing_session_gate.dart | 222 ++++++++++++++++++ lib/main.dart | 4 +- server/README.md | 7 + server/internal/httpapi/pairing.go | 27 +++ server/internal/httpapi/router.go | 1 + server/internal/pairing/service.go | 40 ++++ 12 files changed, 697 insertions(+), 38 deletions(-) create mode 100644 lib/features/pairing/paired_home_page.dart create mode 100644 lib/features/pairing/pairing_recovery_page.dart create mode 100644 lib/features/pairing/pairing_session_gate.dart diff --git a/books/src/current-foundation.md b/books/src/current-foundation.md index 3c0b07f..3eecbd8 100644 --- a/books/src/current-foundation.md +++ b/books/src/current-foundation.md @@ -21,9 +21,13 @@ public URL when configured, and the currently advertised features. The Flutter client supports the inviter's server setup, invitation creation, status checks, accepting an invitation opened through the Android `pawmate://pair` scheme, and restoring a member after reinstall with a recovery -code. The Gin service persists the single couple relationship in SQLite. +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. The current implementation is an onboarding foundation. It does not yet -provide chat, media, games, recipes, or a full paired-home dashboard. Recovery -codes are single-use and must be saved by each member outside the app. Production -deployments must mount and back up the configured SQLite database path. +provide chat, media, games, recipes, or a feature-rich paired-home dashboard. +Recovery codes are single-use and must be saved by each member outside the app. +Production deployments must mount and back up the configured SQLite database +path. diff --git a/lib/features/pairing/invitee_accept_page.dart b/lib/features/pairing/invitee_accept_page.dart index d22eb67..ed2e776 100644 --- a/lib/features/pairing/invitee_accept_page.dart +++ b/lib/features/pairing/invitee_accept_page.dart @@ -6,8 +6,8 @@ import '../../design/handdrawn_scaffold.dart'; import '../../design/pawmate_theme.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; +import 'paired_home_page.dart'; import 'widgets/pairing_error_note.dart'; -import 'widgets/recovery_code_note.dart'; /// Lets the invitee review and accept a one-time link opened from another app. class InviteeAcceptPage extends StatefulWidget { @@ -24,7 +24,6 @@ class _InviteeAcceptPageState extends State { final _credentials = PairingCredentials(); bool _isLoading = false; String? _errorMessage; - String? _recoveryCode; @override void dispose() { @@ -54,7 +53,7 @@ class _InviteeAcceptPageState extends State { 'The server did not return complete pairing credentials.', ); } - if (mounted) setState(() => _recoveryCode = recoveryCode); + var storageWarning = false; try { await _credentials.save( serverURL: serverURL, @@ -64,13 +63,31 @@ class _InviteeAcceptPageState extends State { role: 'invitee', ); } on Object { - if (mounted) { - setState(() { - _errorMessage = - 'You are paired, but this device could not store access securely. Keep the recovery code below safe.'; - }); - } + storageWarning = true; } + if (!mounted) return; + final credentials = SavedPairingCredentials( + serverURL: serverURL, + accessToken: token, + recoveryCode: recoveryCode, + pairID: pairID, + role: 'invitee', + ); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => PairedHomePage( + credentials: credentials, + session: PairingSession( + pairID: pairID, + role: 'invitee', + status: 'paired', + ), + recoveryCodeToSave: recoveryCode, + storageWarning: storageWarning, + ), + ), + (_) => false, + ); } on PairingApiException catch (error) { if (mounted) setState(() => _errorMessage = error.message); } on Object { @@ -85,7 +102,6 @@ class _InviteeAcceptPageState extends State { @override Widget build(BuildContext context) { final serverURL = widget.inviteUri.queryParameters['server'] ?? 'Unknown'; - final accepted = _recoveryCode != null; return HanddrawnScaffold( title: 'Join your little home', body: SafeArea( @@ -104,7 +120,7 @@ class _InviteeAcceptPageState extends State { ), const SizedBox(height: 12), Text( - accepted ? 'You are home!' : 'You have been invited', + 'You have been invited', style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: PawmateColors.ink, fontWeight: FontWeight.w700, @@ -112,9 +128,7 @@ class _InviteeAcceptPageState extends State { ), const SizedBox(height: 8), Text( - accepted - ? 'Your place in this private home is now connected.' - : 'Accepting this one-time invitation will pair your device with your partner.', + 'Accepting this one-time invitation will pair your device with your partner.', style: const TextStyle( color: PawmateColors.softBrown, height: 1.4, @@ -136,14 +150,12 @@ class _InviteeAcceptPageState extends State { fontFamily: 'monospace', ), ), - if (!accepted) ...[ - const SizedBox(height: 20), - HanddrawnButton( - onPressed: _isLoading ? null : _acceptInvite, - icon: Icons.favorite_border, - label: _isLoading ? 'Joining…' : 'Accept invitation', - ), - ], + const SizedBox(height: 20), + HanddrawnButton( + onPressed: _isLoading ? null : _acceptInvite, + icon: Icons.favorite_border, + label: _isLoading ? 'Joining…' : 'Accept invitation', + ), ], ), ), @@ -151,10 +163,6 @@ class _InviteeAcceptPageState extends State { const SizedBox(height: 18), PairingErrorNote(message: _errorMessage!), ], - if (_recoveryCode case final code?) ...[ - const SizedBox(height: 18), - RecoveryCodeNote(recoveryCode: code), - ], ], ), ), diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart index dcb1105..a0c794d 100644 --- a/lib/features/pairing/inviter_setup_page.dart +++ b/lib/features/pairing/inviter_setup_page.dart @@ -11,6 +11,7 @@ import 'widgets/recovery_code_note.dart'; import 'widgets/server_setup_card.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; +import 'paired_home_page.dart'; class InviterSetupPage extends StatefulWidget { const InviterSetupPage({super.key}); @@ -166,7 +167,22 @@ class _InviterSetupPageState extends State { invite.inviterToken, ); if (status.status == 'paired' && status.pairID != null) { - await _credentials.save( + var storageWarning = false; + try { + await _credentials.save( + serverURL: _serverURLController.text.trim(), + accessToken: invite.inviterToken, + recoveryCode: invite.recoveryCode, + pairID: status.pairID!, + role: 'inviter', + inviteURL: invite.inviteURL, + expiresAt: invite.expiresAt, + ); + } on Object { + storageWarning = true; + } + if (!mounted) return; + final credentials = SavedPairingCredentials( serverURL: _serverURLController.text.trim(), accessToken: invite.inviterToken, recoveryCode: invite.recoveryCode, @@ -175,6 +191,22 @@ class _InviterSetupPageState extends State { inviteURL: invite.inviteURL, expiresAt: invite.expiresAt, ); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => PairedHomePage( + credentials: credentials, + session: PairingSession( + pairID: status.pairID, + role: 'inviter', + status: 'paired', + ), + recoveryCodeToSave: storageWarning ? invite.recoveryCode : null, + storageWarning: storageWarning, + ), + ), + (_) => false, + ); + return; } if (mounted) setState(() => _status = status); } on PairingApiException catch (error) { diff --git a/lib/features/pairing/paired_home_page.dart b/lib/features/pairing/paired_home_page.dart new file mode 100644 index 0000000..137f214 --- /dev/null +++ b/lib/features/pairing/paired_home_page.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import '../../design/handdrawn_card.dart'; +import '../../design/handdrawn_scaffold.dart'; +import '../../design/pawmate_theme.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'widgets/pairing_error_note.dart'; +import 'widgets/recovery_code_note.dart'; + +/// Shows the authenticated couple home after the server validates a member. +class PairedHomePage extends StatelessWidget { + const PairedHomePage({ + required this.credentials, + required this.session, + super.key, + this.recoveryCodeToSave, + this.storageWarning = false, + }); + + final SavedPairingCredentials credentials; + final PairingSession session; + final String? recoveryCodeToSave; + final bool storageWarning; + + @override + Widget build(BuildContext context) { + return HanddrawnScaffold( + title: 'Our little home', + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + HanddrawnCard( + color: const Color(0xFFF4F0FF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.home_rounded, + size: 36, + color: PawmateColors.rose, + ), + const SizedBox(height: 12), + Text( + 'You are home together', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'This device is connected as the ${session.role}.', + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), + ), + const SizedBox(height: 16), + const Text( + 'Private server', + style: TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + SelectableText( + credentials.serverURL, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + ), + ), + if (session.pairID != null) ...[ + const SizedBox(height: 12), + Text( + 'Home ID: ${session.pairID}', + style: const TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, + ), + ), + ], + ], + ), + ), + if (storageWarning) ...[ + const SizedBox(height: 18), + const PairingErrorNote( + message: + 'Access was restored for this session, but this device could not securely save it. Keep the recovery code below and restore again if the app closes.', + ), + ], + if (recoveryCodeToSave != null) ...[ + const SizedBox(height: 18), + RecoveryCodeNote(recoveryCode: recoveryCodeToSave!), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/pairing/pairing_api.dart b/lib/features/pairing/pairing_api.dart index 03485fc..43171cd 100644 --- a/lib/features/pairing/pairing_api.dart +++ b/lib/features/pairing/pairing_api.dart @@ -77,11 +77,29 @@ class RecoveredCredentials { final String role; } +/// Represents the authenticated member's server-side pairing session. +class PairingSession { + const PairingSession({required this.role, required this.status, this.pairID}); + + factory PairingSession.fromJson(Map json) { + return PairingSession( + pairID: json['pair_id'] as String?, + role: json['role'] as String, + status: json['status'] as String, + ); + } + + final String? pairID; + final String role; + final String status; +} + /// Represents a user-facing error returned while calling the pairing API. class PairingApiException implements Exception { - const PairingApiException(this.message); + const PairingApiException(this.message, {this.statusCode}); final String message; + final int? statusCode; @override String toString() => message; @@ -108,7 +126,10 @@ class PairingApi { .timeout(const Duration(seconds: 10)); if (response.statusCode != 201) { - throw PairingApiException(_messageFor(response)); + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); } return PairingInvite.fromJson( jsonDecode(response.body) as Map, @@ -129,13 +150,40 @@ class PairingApi { .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { - throw PairingApiException(_messageFor(response)); + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); } return PairingStatus.fromJson( jsonDecode(response.body) as Map, ); } + /// Validates an access token and returns the member's current pairing state. + Future validateSession( + String rawServerURL, + String accessToken, + ) async { + final serverURL = _parseServerURL(rawServerURL); + final response = await _client + .get( + serverURL.resolve('/api/v1/pairing/session'), + headers: {'authorization': 'Bearer $accessToken'}, + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + return PairingSession.fromJson( + jsonDecode(response.body) as Map, + ); + } + /// Redeems a one-time invite code on the server encoded in the invite link. Future redeemInvite(String rawServerURL, String code) async { final serverURL = _parseServerURL(rawServerURL); @@ -148,7 +196,10 @@ class PairingApi { .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { - throw PairingApiException(_messageFor(response)); + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); } return PairingStatus.fromJson( jsonDecode(response.body) as Map, @@ -170,7 +221,10 @@ class PairingApi { .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { - throw PairingApiException(_messageFor(response)); + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); } return RecoveredCredentials.fromJson( jsonDecode(response.body) as Map, diff --git a/lib/features/pairing/pairing_recovery_page.dart b/lib/features/pairing/pairing_recovery_page.dart new file mode 100644 index 0000000..4734c6b --- /dev/null +++ b/lib/features/pairing/pairing_recovery_page.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; + +import '../../design/handdrawn_card.dart'; +import '../../design/handdrawn_scaffold.dart'; +import '../../design/pawmate_theme.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'paired_home_page.dart'; +import 'widgets/pairing_error_note.dart'; +import 'widgets/pairing_recovery_card.dart'; + +/// Restores an invalid or reinstalled member session with its recovery code. +class PairingRecoveryPage extends StatefulWidget { + const PairingRecoveryPage({super.key, this.initialServerURL = ''}); + + final String initialServerURL; + + @override + State createState() => _PairingRecoveryPageState(); +} + +class _PairingRecoveryPageState extends State { + late final TextEditingController _serverController; + final _codeController = TextEditingController(); + final _api = PairingApi(); + final _credentials = PairingCredentials(); + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _serverController = TextEditingController(text: widget.initialServerURL); + } + + @override + void dispose() { + _serverController.dispose(); + _codeController.dispose(); + _api.close(); + super.dispose(); + } + + Future _recover() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + try { + final result = await _api.recover( + _serverController.text, + _codeController.text, + ); + final saved = SavedPairingCredentials( + serverURL: _serverController.text.trim(), + accessToken: result.accessToken, + recoveryCode: result.recoveryCode, + pairID: result.pairID, + role: result.role, + ); + var storageWarning = false; + try { + await _credentials.save( + serverURL: saved.serverURL, + accessToken: saved.accessToken, + recoveryCode: saved.recoveryCode, + pairID: saved.pairID, + role: saved.role, + ); + } on Object { + storageWarning = true; + } + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => PairedHomePage( + credentials: saved, + session: PairingSession( + pairID: result.pairID, + role: result.role, + status: 'paired', + ), + recoveryCodeToSave: result.recoveryCode, + storageWarning: storageWarning, + ), + ), + (_) => false, + ); + } on PairingApiException catch (error) { + if (mounted) setState(() => _errorMessage = _userMessage(error)); + } on Object { + if (mounted) { + setState( + () => _errorMessage = 'Could not restore this home. Try again.', + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + String _userMessage(PairingApiException error) { + return switch (error.message) { + 'invalid_recovery_code' => 'That recovery code is not valid anymore.', + 'not_paired' => 'This server does not have a completed couple pairing.', + _ => error.message, + }; + } + + @override + Widget build(BuildContext context) { + return HanddrawnScaffold( + title: 'Restore your home', + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + HanddrawnCard( + color: const Color(0xFFFFF3CF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.key_outlined, color: PawmateColors.ink), + const SizedBox(height: 10), + Text( + 'Let’s reconnect you', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + const Text( + 'Your saved access credential was rejected. Use the recovery code for this member to issue a fresh one.', + style: TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 18), + PairingRecoveryCard( + serverController: _serverController, + codeController: _codeController, + isLoading: _isLoading, + onRecover: _recover, + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 18), + PairingErrorNote(message: _errorMessage!), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/pairing/pairing_session_gate.dart b/lib/features/pairing/pairing_session_gate.dart new file mode 100644 index 0000000..1ef743d --- /dev/null +++ b/lib/features/pairing/pairing_session_gate.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; + +import '../../design/handdrawn_button.dart'; +import '../../design/handdrawn_card.dart'; +import '../../design/handdrawn_scaffold.dart'; +import '../../design/pawmate_theme.dart'; +import 'inviter_setup_page.dart'; +import 'paired_home_page.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'pairing_recovery_page.dart'; + +/// Loads local credentials and routes to onboarding, recovery, or the home. +class PairingSessionGate extends StatefulWidget { + const PairingSessionGate({super.key}); + + @override + State createState() => _PairingSessionGateState(); +} + +enum _GateView { loading, setup, recovery, home, error } + +class _PairingSessionGateState extends State { + final _api = PairingApi(); + final _credentials = PairingCredentials(); + _GateView _view = _GateView.loading; + SavedPairingCredentials? _saved; + PairingSession? _session; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _restoreSession(); + } + + @override + void dispose() { + _api.close(); + super.dispose(); + } + + Future _restoreSession() async { + setState(() { + _view = _GateView.loading; + _errorMessage = null; + }); + try { + final credentials = await _credentials.read(); + if (!mounted) return; + if (credentials == null) { + setState(() => _view = _GateView.setup); + return; + } + _saved = credentials; + + final session = await _api.validateSession( + credentials.serverURL, + credentials.accessToken, + ); + if (!mounted) return; + if (session.status == 'paired' && session.pairID != null) { + final saved = SavedPairingCredentials( + serverURL: credentials.serverURL, + accessToken: credentials.accessToken, + recoveryCode: credentials.recoveryCode, + pairID: session.pairID!, + role: session.role, + inviteURL: credentials.inviteURL, + expiresAt: credentials.expiresAt, + ); + _saved = saved; + _session = session; + try { + await _credentials.save( + serverURL: saved.serverURL, + accessToken: saved.accessToken, + recoveryCode: saved.recoveryCode, + pairID: saved.pairID, + role: saved.role, + inviteURL: saved.inviteURL, + expiresAt: saved.expiresAt, + ); + } on Object { + // The authenticated in-memory session can still open the home. + } + if (mounted) setState(() => _view = _GateView.home); + } else if (session.status == 'pending' && session.role == 'inviter') { + setState(() => _view = _GateView.setup); + } else { + setState(() => _view = _GateView.recovery); + } + } on PairingApiException catch (error) { + if (!mounted) return; + if (error.statusCode == 401) { + setState(() => _view = _GateView.recovery); + } else { + setState(() { + _errorMessage = _messageFor(error); + _view = _GateView.error; + }); + } + } on Object { + if (mounted) { + setState(() { + _errorMessage = + 'Could not read or validate saved access on this device.'; + _view = _GateView.error; + }); + } + } + } + + String _messageFor(PairingApiException error) { + if (error.statusCode == null) { + return 'Could not reach your Pawmate server. Check your connection and retry.'; + } + return error.message; + } + + @override + Widget build(BuildContext context) { + return switch (_view) { + _GateView.loading => const _SessionLoadingPage(), + _GateView.setup => const InviterSetupPage(), + _GateView.recovery => PairingRecoveryPage( + initialServerURL: _saved?.serverURL ?? '', + ), + _GateView.home => PairedHomePage( + credentials: _saved!, + session: _session!, + ), + _GateView.error => _SessionErrorPage( + message: _errorMessage ?? 'Could not restore this session.', + onRetry: _restoreSession, + onSetup: () => setState(() => _view = _GateView.setup), + ), + }; + } +} + +class _SessionLoadingPage extends StatelessWidget { + const _SessionLoadingPage(); + + @override + Widget build(BuildContext context) { + return const HanddrawnScaffold( + title: 'Opening your home', + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: PawmateColors.lavender), + SizedBox(height: 16), + Text('Checking your saved access…'), + ], + ), + ), + ); + } +} + +class _SessionErrorPage extends StatelessWidget { + const _SessionErrorPage({ + required this.message, + required this.onRetry, + required this.onSetup, + }); + + final String message; + final VoidCallback onRetry; + final VoidCallback onSetup; + + @override + Widget build(BuildContext context) { + return HanddrawnScaffold( + title: 'Finding your home', + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + HanddrawnCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.cloud_off_outlined, + color: PawmateColors.softBrown, + size: 32, + ), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + color: PawmateColors.ink, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + HanddrawnButton( + label: 'Try again', + icon: Icons.refresh, + onPressed: onRetry, + ), + const SizedBox(height: 10), + TextButton( + onPressed: onSetup, + child: const Text('Set up a server'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index c8ba0d1..3f2c1ba 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,7 @@ import 'package:app_links/app_links.dart'; import 'design/pawmate_theme.dart'; import 'features/pairing/invitee_accept_page.dart'; -import 'features/pairing/inviter_setup_page.dart'; +import 'features/pairing/pairing_session_gate.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -62,7 +62,7 @@ class _PawmateAppState extends State { navigatorKey: _navigatorKey, title: 'Pawmate', theme: buildPawmateTheme(), - home: const InviterSetupPage(), + home: const PairingSessionGate(), ); } } diff --git a/server/README.md b/server/README.md index 5ac4cbd..f668e2e 100644 --- a/server/README.md +++ b/server/README.md @@ -23,6 +23,7 @@ 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 ``` Create an invitation by sending the configured server URL: @@ -51,6 +52,12 @@ rotates the access token and recovery code, so the old recovery code can no longer be used. A recovery code belongs to one member; the two members should keep separate codes. +On app startup, the client calls `GET /api/v1/pairing/session` with its saved +bearer token. The endpoint accepts either member's current token and returns +the role, pair ID, and pairing state. Invalid or rotated tokens receive HTTP +401 and should open recovery; network failures should offer retry without +treating the credentials as invalid. + For the Android emulator, configure the Flutter client with `http://10.0.2.2:8080`; `10.0.2.2` maps to the development machine's loopback interface from inside the emulator. diff --git a/server/internal/httpapi/pairing.go b/server/internal/httpapi/pairing.go index f0d08ac..62487fc 100644 --- a/server/internal/httpapi/pairing.go +++ b/server/internal/httpapi/pairing.go @@ -43,6 +43,12 @@ type recoverPairingResponse struct { Role string `json:"role"` } +type pairingSessionResponse struct { + PairID string `json:"pair_id,omitempty"` + Role string `json:"role"` + Status string `json:"status"` +} + // createInviteHandler creates an invitation for the configured server URL. func createInviteHandler(service *pairing.Service) gin.HandlerFunc { return func(c *gin.Context) { @@ -139,6 +145,27 @@ func recoverPairingHandler(service *pairing.Service) gin.HandlerFunc { } } +// pairingSessionHandler validates either member's access token. +func pairingSessionHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) + session, err := service.Authenticate(token) + if err != nil { + if errors.Is(err, pairing.ErrInvalidSessionToken) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_session_token"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal_error"}) + return + } + c.JSON(http.StatusOK, pairingSessionResponse{ + PairID: session.PairID, + Role: session.Role, + Status: session.Status, + }) + } +} + // pairingStatusHandler reports state to the device that created the invite. func pairingStatusHandler(service *pairing.Service) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index a49a9a7..e381666 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -36,6 +36,7 @@ func NewRouter(cfg config.Config, logger *slog.Logger, pairingService *pairing.S api.POST("/pairing/invites/redeem", redeemInviteHandler(pairingService)) api.GET("/pairing/invites/status", pairingStatusHandler(pairingService)) api.POST("/pairing/recover", recoverPairingHandler(pairingService)) + api.GET("/pairing/session", pairingSessionHandler(pairingService)) return router } diff --git a/server/internal/pairing/service.go b/server/internal/pairing/service.go index b4d4aad..399dfbe 100644 --- a/server/internal/pairing/service.go +++ b/server/internal/pairing/service.go @@ -22,6 +22,7 @@ var ( ErrInvalidInvite = errors.New("invite is invalid") ErrExpiredInvite = errors.New("invite has expired") ErrInvalidInviterToken = errors.New("inviter token is invalid") + ErrInvalidSessionToken = errors.New("session token is invalid") ErrInvalidRecoveryCode = errors.New("recovery code is invalid") ErrNotPaired = errors.New("instance is not paired") ErrInvalidServerURL = errors.New("server URL is invalid") @@ -53,6 +54,13 @@ type RecoveredCredentials struct { RecoveryCode string } +// Session is the authenticated member and pairing state for a client device. +type Session struct { + PairID string + Role string + Status string +} + // Service stores the single couple relationship for one private instance. type Service struct { db *sql.DB @@ -261,6 +269,38 @@ func (service *Service) Status(inviterToken string) (PairingStatus, error) { return PairingStatus{PairID: pairID.String, Status: "paired"}, nil } +// Authenticate validates either member's access token and returns its session. +func (service *Service) Authenticate(accessToken string) (Session, error) { + service.mu.Lock() + defer service.mu.Unlock() + + var paired bool + var pairID sql.NullString + var inviterHash, inviteeHash []byte + err := service.db.QueryRow(` + SELECT paired, pair_id, inviter_token_hash, invitee_token_hash + FROM pairing_state WHERE id = 1`).Scan(&paired, &pairID, &inviterHash, &inviteeHash) + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrInvalidSessionToken + } + if err != nil { + return Session{}, fmt.Errorf("read pairing session: %w", err) + } + + providedHash := tokenHash(strings.TrimSpace(accessToken)) + if sameHash(inviterHash, providedHash) { + status := "pending" + if paired { + status = "paired" + } + return Session{PairID: pairID.String, Role: "inviter", Status: status}, nil + } + if paired && sameHash(inviteeHash, providedHash) { + return Session{PairID: pairID.String, Role: "invitee", Status: "paired"}, nil + } + return Session{}, ErrInvalidSessionToken +} + // Recover rotates one member's credentials using their single-use recovery code. func (service *Service) Recover(recoveryCode string) (RecoveredCredentials, error) { service.mu.Lock() From c3cbcbb63f371f2e4c05900c42d3e908e4a340a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 4 Oct 2026 00:32:28 +0800 Subject: [PATCH 07/16] feat(app): add pawmate icons for all Flutter platforms - generate launcher icons from the Pawmate SVG for Android, iOS, web, Linux, macOS, and Windows - add icon generation configuration and source PNG - add Flutter project scaffolding for the additional platforms --- .metadata | 14 +- .../app/src/main/ic_launcher-playstore.png | Bin 0 -> 13496 bytes .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 544 -> 1960 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 442 -> 1112 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 721 -> 2333 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1031 -> 3803 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 1443 -> 4799 bytes assets/pawmate.png | Bin 0 -> 26948 bytes ios/.gitignore | 34 + ios/Flutter/AppFrameworkInfo.plist | 24 + ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Runner.xcodeproj/project.pbxproj | 644 ++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 119 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 120 +++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 29685 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 919 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 1420 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 1374 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 2124 bytes .../AppIcon.appiconset/Icon-App-38x38@2x.png | Bin 0 -> 2056 bytes .../AppIcon.appiconset/Icon-App-38x38@3x.png | Bin 0 -> 2776 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 1905 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 2932 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 2932 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 4528 bytes .../AppIcon.appiconset/Icon-App-64x64@2x.png | Bin 0 -> 2984 bytes .../AppIcon.appiconset/Icon-App-64x64@3x.png | Bin 0 -> 4799 bytes .../AppIcon.appiconset/Icon-App-68x68@2x.png | Bin 0 -> 3790 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 4307 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 4541 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + ios/Runner/Base.lproj/LaunchScreen.storyboard | 37 + ios/Runner/Base.lproj/Main.storyboard | 26 + ios/Runner/Info.plist | 70 ++ ios/Runner/Runner-Bridging-Header.h | 1 + ios/Runner/SceneDelegate.swift | 6 + ios/RunnerTests/RunnerTests.swift | 12 + linux/.gitignore | 1 + linux/CMakeLists.txt | 128 +++ linux/flutter/CMakeLists.txt | 88 +++ linux/flutter/generated_plugin_registrant.cc | 19 + linux/flutter/generated_plugin_registrant.h | 15 + linux/flutter/generated_plugins.cmake | 26 + linux/runner/CMakeLists.txt | 26 + linux/runner/main.cc | 6 + linux/runner/my_application.cc | 148 ++++ linux/runner/my_application.h | 21 + macos/.gitignore | 7 + macos/Flutter/Flutter-Debug.xcconfig | 1 + macos/Flutter/Flutter-Release.xcconfig | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 14 + macos/Runner.xcodeproj/project.pbxproj | 729 ++++++++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 117 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + macos/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 68 ++ .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 29685 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 2984 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 386 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 6352 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 756 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 13496 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 1509 bytes macos/Runner/Base.lproj/MainMenu.xib | 343 ++++++++ macos/Runner/Configs/AppInfo.xcconfig | 14 + macos/Runner/Configs/Debug.xcconfig | 2 + macos/Runner/Configs/Release.xcconfig | 2 + macos/Runner/Configs/Warnings.xcconfig | 13 + macos/Runner/DebugProfile.entitlements | 12 + macos/Runner/Info.plist | 32 + macos/Runner/MainFlutterWindow.swift | 15 + macos/Runner/Release.entitlements | 8 + macos/RunnerTests/RunnerTests.swift | 12 + pubspec.lock | 48 +- pubspec.yaml | 17 + snap/gui/pawmate.desktop | 8 + snap/gui/pawmate.png | Bin 0 -> 6352 bytes snap/snapcraft.yaml | 28 + web/favicon.png | Bin 0 -> 1112 bytes web/icons/Icon-192.png | Bin 0 -> 4799 bytes web/icons/Icon-512.png | Bin 0 -> 13496 bytes web/icons/Icon-maskable-192.png | Bin 0 -> 4799 bytes web/icons/Icon-maskable-512.png | Bin 0 -> 13496 bytes web/index.html | 46 ++ web/manifest.json | 35 + windows/.gitignore | 17 + windows/CMakeLists.txt | 108 +++ windows/flutter/CMakeLists.txt | 109 +++ .../flutter/generated_plugin_registrant.cc | 17 + windows/flutter/generated_plugin_registrant.h | 15 + windows/flutter/generated_plugins.cmake | 26 + windows/runner/CMakeLists.txt | 40 + windows/runner/Runner.rc | 121 +++ windows/runner/flutter_window.cpp | 71 ++ windows/runner/flutter_window.h | 33 + windows/runner/main.cpp | 43 ++ windows/runner/resource.h | 16 + windows/runner/resources/app_icon.ico | Bin 0 -> 31801 bytes windows/runner/runner.exe.manifest | 14 + windows/runner/utils.cpp | 69 ++ windows/runner/utils.h | 19 + windows/runner/win32_window.cpp | 288 +++++++ windows/runner/win32_window.h | 102 +++ 121 files changed, 4388 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/ic_launcher-playstore.png create mode 100644 assets/pawmate.png create mode 100644 ios/.gitignore create mode 100644 ios/Flutter/AppFrameworkInfo.plist create mode 100644 ios/Flutter/Debug.xcconfig create mode 100644 ios/Flutter/Release.xcconfig create mode 100644 ios/Runner.xcodeproj/project.pbxproj create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ios/Runner/AppDelegate.swift create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-38x38@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-64x64@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-68x68@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/Runner/Base.lproj/Main.storyboard create mode 100644 ios/Runner/Info.plist create mode 100644 ios/Runner/Runner-Bridging-Header.h create mode 100644 ios/Runner/SceneDelegate.swift create mode 100644 ios/RunnerTests/RunnerTests.swift create mode 100644 linux/.gitignore create mode 100644 linux/CMakeLists.txt create mode 100644 linux/flutter/CMakeLists.txt create mode 100644 linux/flutter/generated_plugin_registrant.cc create mode 100644 linux/flutter/generated_plugin_registrant.h create mode 100644 linux/flutter/generated_plugins.cmake create mode 100644 linux/runner/CMakeLists.txt create mode 100644 linux/runner/main.cc create mode 100644 linux/runner/my_application.cc create mode 100644 linux/runner/my_application.h create mode 100644 macos/.gitignore create mode 100644 macos/Flutter/Flutter-Debug.xcconfig create mode 100644 macos/Flutter/Flutter-Release.xcconfig create mode 100644 macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 macos/Runner.xcodeproj/project.pbxproj create mode 100644 macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 macos/Runner/AppDelegate.swift create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 macos/Runner/Base.lproj/MainMenu.xib create mode 100644 macos/Runner/Configs/AppInfo.xcconfig create mode 100644 macos/Runner/Configs/Debug.xcconfig create mode 100644 macos/Runner/Configs/Release.xcconfig create mode 100644 macos/Runner/Configs/Warnings.xcconfig create mode 100644 macos/Runner/DebugProfile.entitlements create mode 100644 macos/Runner/Info.plist create mode 100644 macos/Runner/MainFlutterWindow.swift create mode 100644 macos/Runner/Release.entitlements create mode 100644 macos/RunnerTests/RunnerTests.swift create mode 100644 snap/gui/pawmate.desktop create mode 100644 snap/gui/pawmate.png create mode 100644 snap/snapcraft.yaml create mode 100644 web/favicon.png create mode 100644 web/icons/Icon-192.png create mode 100644 web/icons/Icon-512.png create mode 100644 web/icons/Icon-maskable-192.png create mode 100644 web/icons/Icon-maskable-512.png create mode 100644 web/index.html create mode 100644 web/manifest.json create mode 100644 windows/.gitignore create mode 100644 windows/CMakeLists.txt create mode 100644 windows/flutter/CMakeLists.txt create mode 100644 windows/flutter/generated_plugin_registrant.cc create mode 100644 windows/flutter/generated_plugin_registrant.h create mode 100644 windows/flutter/generated_plugins.cmake create mode 100644 windows/runner/CMakeLists.txt create mode 100644 windows/runner/Runner.rc create mode 100644 windows/runner/flutter_window.cpp create mode 100644 windows/runner/flutter_window.h create mode 100644 windows/runner/main.cpp create mode 100644 windows/runner/resource.h create mode 100644 windows/runner/resources/app_icon.ico create mode 100644 windows/runner/runner.exe.manifest create mode 100644 windows/runner/utils.cpp create mode 100644 windows/runner/utils.h create mode 100644 windows/runner/win32_window.cpp create mode 100644 windows/runner/win32_window.h 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/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..b3799fada538b26db27b4000d2a80c8e31d54c80 GIT binary patch literal 13496 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 WUqX$KWsg8P0F@^iifE)+;Qs?V=K)udH`s&x~e z-0C=Xyw@4D-X?V%dvD$(O_kqEcKxkq=F86SH{bWom~hU)E`K0|LWqOcC(%T}bBa^P zP8tY=u=Bq2_C%Y=Gs5*#@d%3N*Rf}W63;Hjw3fZ{ps`wQZ9Uet9m}>j!bp;FCYLA- z$!Erul*&#L#|)j{`E7mS*5*p33Ag|df{xCf-a=R^%Txc%Uz{x_Q%5oXqxYe;>>G2{ z`CnE|gYR>uCx3)ktG3owD%XEpp1D-`_kTvzY$9^kK@i0;A;j%p*XOQQv>gk$uO%?J z#c$tSTfDXL(rcqHzB~*-zQ1yRX`u!>UFv+Y%gw0e@bzS?JDt~QX{`Ah9?@yM;lz-zCk9y7c`m?*+H9gea^TtlY z{O0q!Z+~!M=u9%MHeCdDOlw(JzPRUj6*mmc{`SkI_dc0c(lo|q1940U@%@$i&J65H zs_-1^x@~Nm-(CLWosTYHf;@&1$`puWw|`xC)FKj*0-9DHHh%tT`IXm4kB(zn%bvSh z@qKJ?+<&}QotZ7IhC z|K5EehK_t4Gju-x%j$rAP=ZEOHmuTol4AViLkOS%yAw%(mdV!}X7ypSwxQ>WF}yS% z@woHbx)U+EABI;5Rl*ZRsjM>h{d58D#T^hN8GjznBaStJhO*|HE zZSF;}p%j7=G+j=PWtls80mQynwEvBDUN<)N+@q%T{}vfb;-YjY;^AZ>m8Qo7{ZLHP zOQR`9VV5&OVB6f#IRn7F-{6}Dok#-`h2CC{tvqNrnMg>SD@xPlXluljK8Tv3`Fl7kS*+hkc{ACXLM+^e&x43uz89BW!0)-Dv&)R}>CfZN^k zUs&8+sX1ln;Y-!gDP8Yl>!`bU0kxdQre48nYpx-stF_y*)yGR1sXVTWe<#6TJJ#o6NZ zAD6ksds-ESu-B{!4G2IwNWab)qJ6y-~j+hd>*-hWNZ2Q6_! z@Wxvcmp{F;(=a z3z$%$KBS^<^9&?2fWkiwSz=_0gHx!u1~>RUbCT^C1ei5P*bC za)pE>lb)Hfe+v`^$u*;IuIEeAf8TwfwxPG?HyfL}ZTkobLQWi*&VR5Vv4scR@YffE z9$?88rS!#YuF^DgZfLe)*j9Uc!Q}u#5L3t~ViN9(*(3DTz5!%d8Z?6rXu?C1Rc$c< zSOP(KxTjP|Nd^=hT*qS&kAr|1k+GzCzcF}IJ{=K60&i8vb4UgkCBA9cbt9J6`hlEP z;O^`4e>koK7IY#l7Jq8O;D>Y)43{L*j{T4PASQwIe8yO;2{Zm<;W(Ed)@H$1< zREBSx_NwMoIfZ`+4=KV0i403_#^oErAKNKzC#p(Qry^|&zGd)jlkeD0jmdsG0zi?I zUnvBu%#Cil>-CXvRHwKx#03fEcZ*P>U5$aj&G2LKqzlJ%ihI|k!|Utxml7upr;eR8 roc;vlq?w-IsUCRT12`-^w;cN$?P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ 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 17987b79bb8a35cc66c3c1fd44f5a5526c1b78be..f1273ab64b277e3249d4a23664e43818188f1883 100644 GIT binary patch delta 1102 zcmV-U1hMt(Q%4-f&u?bVJts}Fxj`=5 zU7HsvbXhD5B7*oL2!elr|5{(fr#>h`i=fa5Dh-J2+rpB?-Dc~?1k%K_xt!~iW29Y^ zIai~tu-~^#X3qKKH^2G*eiK3|<^E41|24p(&Vj;o4iu(%K!3x5akxL~{y82E4AXQm z0#)PJN^*Tut8Qor!IuL%Y>!&a?(u7V`K$Kcu+=&@=%jP{ z^H2Mh>BfEN%YSEYU6)?FSzjfPJ~Hp$Z6~0a>(z^1HZN`M6jua9xyKKVqVE(_&0jk8 zO)XcDR{(T)Fbd7$1tH)R0gJ@T9NVE2(={FX^wII{JB<|pwVK_C>jeyzj957OAO7iR5{V zNrw?ODF+%Vxb2!2i^fZE_unqr z=}u9K$A1|i1`snz=V}z>D0ves`;SwU&VtWd7(lAVGp7m|Hu6#e0!%)NC^cQDKO-f! zVKRX7CAqyfTpq7v#DL)W(qO=Nah21X`JFfhQ0;>Dd-G&@yqso7E?APx*kWopbm{=h znHWRWZ#7lp$GUZP5CNBo=4=a6d=kLbEv8j6hJO%(t)0U2CvVOUsscvw`x{I^QQ1F( zPT7^4J$w6dX}8rs8zg}w>9(?KqMr>?n|)(u0|_F&eyjfQ-oZa33EDqdA$|w;Jy9GW z&vGtiFI_3UeD-$!(RSi>YJ`V;Ik0m1xNA_JUcFIuUHa-pkK?$B`MxAYixc6YR8euD zp?`xI9|!=h-K^*G(r>?ZOxx>k6d~NEWrgI+QZS8obX4|khM(jgc8ayl^wUS3{yS?u z7X*X}WYvF7LPtPK zvX@m;$FqZ}?bUEfy|!FLNac~nM~TYHfIeV(w^S1GVj8Ni{7=s>pmU%wodbpGZw&ca U%D2d5o&W#<07*qoM6N<$f**?;!Ti*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@U}c0I6xpHMcTN&*e-%Jb$d##`3u@VC#Rn3_E5k; z0{5XLFp}B{+@wxbE72pBVp-2xlt_x=V_&mV&`Mp!lDIqjk`Md230m$9znuBa@3qTQ zO1YSm;ZJ)4FFLV64#WaE5DVl$ERX}SKn}zLIS>ovKrE00u|N*Q0yz+cK$Oz`J)^SK z*{kWTrfF!zwkbqVN}!~QGkxN~kSr@Wf|+kDKk5)%)U9MktOd zW9Kqw&!t5Xg*Pi!f)|L|ba|mZ^Y_Z*baA$}I9n@?DnET=c&L;Nbb`zTCQyA>yYr{DM~izw-Ee)S z`G@!BFJ8)DzFv~#Q#(uo7?@J})#uxHKU^nPz_~X{xrblw{Clo`{g>s5m$Jd$+$DYm zBE+7)wK6|b4QM{c&}muk-&}tETJhB@g8+c=209jB0(IKf2RE12SDL}i=S1#(v{A3> zS8t3U1S1&k8Qulzw5>n>cCoV633NWa`1-+)*0Fy6&NxERts{>D5n_LEb16!74p^G2 z-?_bd?M6AW5xodRDV@Hx63xy!%EK=zeR*l>CxvK+^CHk!pKph1l^yx?FKhWiqCAm` zU@T^V>bu(A57)z)j9Vz>rf;ph`+eM3q)lTgjF z+d64z#31zPNlHKac=OG-$3mS9gFxRc)N4MX(B23Tmt1WB0 zX)b?LtLxg731bEY6_;~TKFPZbBLD!hfMfxcvLdl*W44x_%0o4*e4AT= zTKneeca31jE~xnYpse^vk0A(*ZB1XOLlxuUBuoY`lUsq77wYb_UyjHazH~+|sDl4n z5kd`}hyWP#dnhmQB^mVGp{%$SD8R#OG$)=f$Or}&PjyC>~dR zt|M17-mB?;#sC~QHKK&1&HlpMgB- zpu7Yn<}cz(profz@cdwOn+rLH7nHL$Tf!J~me!R(nLcJKTTro|D3lc*I~xJJYS?E8 z>mqU`(7+J$S}2E{=sOJJfy+u6sc3MR%@Vm1NLFwvBQ)xU%bSWk$S0#~HxOJeRZ`Wv z;XaFR?e=SwM%B5QU6&URs6v!QbcQp;v}wx%EZuBw1v+~!%_2~w{d5VI)k+#B3`3x? zb7{l_mj$0}gzY?7fC5W(jPA3AO>V!5Afky2nZ?Z`08ULYZf8O{lX5}`0u{?jX;g9SS&)V3mXDeioL$5tP@MqRlVmd% zSKb)@{d@DhpK^_Ce?iEl`@DoQ)Uw@Q%qGxaDS7cy|HChP70CEVqSLN_pOd_Gn^D4$ zoM1j~F8{2!G*|1<7#@uSB#2N=dvzvQk~ho(NeTYiuSVbh-O`Dphu``E0GBU#FIq5t z&Wpn`aUt{iwc@>xj&EczSr)K2`iBzk!;52J$((ug=w7`tSg+|{|6}LqzcdyT=z+d| zN%oiFc7KF<7YG1w^%o;r+gh5dKliV;?mdC+NIEN|GJcbAfMxZ0j|mWhZ@n{q=l1Hq zzWBkR*3bx(tLmPsd(=zXo3h_y;08gOP~IE?U`*hNVk7S4zzm^fUe!~ zxw8&{jCq&fK*Y-v4Cl z!M!Jh(CwxfNG%M)t~aQraex~ZrrAtbV0j5Fpv%`w7pL-{eZ0B+O^w*pPr;`fg{Fmc zpAhVm89+jiu>u}&;wF=m-h6wkzN_uc){Je9u=_+9!(yIO1m8@ zmT410?WfBMfNr%9@sP(bnx}VAzXV)Vhn^$E+$bJS8uQ;b?g0RJ9vtmZi4FvK_Tp8z z0>LCk{Sv8!)mqI2U-a1A8E$r+FUq8*bEo}m2atKx@3D{>u0Vi?cvLZ$_Cp=T0m$LA zif2zM^!g0Sh_pzojds`rodh0BvHCuTiK2jo1`|{#WXD&dqc`~BB=Q&iMv0{?)cnU z&xrb+7az-ebSs?THtm4NiKgTJ&Gz6stIGMG!+T=+mz$b{hzH(9S#uI@i zGf(HODgXgoN}{}EZMN;I@6!!-<1BtC;0K`&IL&vMK?wy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ 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 d5f1c8d34e7a88e3f88bea192c3a370d44689c3c..58af4b7359aa68ad2cc28eac6d619ea19747f0e7 100644 GIT binary patch literal 3803 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 LWvE%Djzs+j!+S=s literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof 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 4d6372eebdb28e45604e46eeda8dd24651419bc0..996d670de0bad5ef0995664835661c854dc403cc 100644 GIT binary patch literal 4799 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 K8Vza=vHt^-w-xdL literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` diff --git a/assets/pawmate.png b/assets/pawmate.png new file mode 100644 index 0000000000000000000000000000000000000000..5a58f1b921e732934a037efb663e2f78eff3388f GIT binary patch literal 26948 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 + + + + 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 0000000000000000000000000000000000000000..6cb14d56c8cfa8876c498d9f3e52af2e89be72ce GIT binary patch literal 29685 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0703113da17d27bafd0fef8bdad68c538ae24f3c GIT binary patch literal 919 zcmV;I18Dq-P)zbvqVY>ZbQheQ18Ka4qPgDd)vjIhvibi z<6KZ3#|QLO+8 EvEewaPFk~PsRTNSW3C}A@gnRB#Mc;C*Hl;Uwrv!=4s+j!5d#s zBT_F#f=_P`V~+M@!tEWLJE?wN$#g-zKufT)eDHc{+O#4itU~F_$81Pkuf)_)KO>A{ zf}s$y%0;Q#_`Y^L|2)+TxK!}=cZ%WPByDXpVZ==WA@psDVx4CzWYB`xztsV}w;e{+ z37i>bNjtE3kO1Yqb2H~@=@<^JF+VtaSPoK+>G;`W+t5&mIRHSd)-XBYyJvEQk)#uwHRPII zOi#n~Gep5dLskdy*rdDq{zx>hP{oU~4^hwZf<( z>OtbTwEdLQYqo1Dd^or8aCa+T_b$_$bm~FZwlrw>DRNLY6%AMcwjKK7Rr=kV{fprr zrEdcOAOwj)3nA4hRgqF>&G=M&@#UjWZx5ve)qu+OSxydF?a{s5rHTd+n65KV6ER0y zSw1LL8{t%ntyq8BfNipT&BH4l% zYUvo?J?oZ=9~fBW=Pv#vutVhtYaIF8c!(fJBO@$mw+ru)qzMV)2W1eUvhf3hj4&9S zblrsw_v?reo~ejJJxB){n50;chkFFK?CO|~I>Kv;yeH1N^qQ@X2qB~hF@ju*Y%R)A t>G5w7>9{rN+Gb94x!b>~_u&5;{0mHv9}#Lqn+5;?002ovPDHLkV1n$KxlaH9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5baca1a917703274f3ae77432ca3a283c4083f1d GIT binary patch literal 1420 zcmV;71#|j|P)YrI3EHZB;EFi-eFkA#p)M;(~;B#hnBH4(AmhPOU&(=mlvP zA*6zY5Mtrb7PTMKYO%%{8UbQ1d;U2AMe9Qx37T!q!UUeK};2E1Sy*dvgzPl>_;C0{mJrFw_LZ)#+jB41q7J7KG?-EIDM}R&AyWgnR?yR!pu-SLq8(0RT?( zo9+c$tU!v89=uMxrJF2x4`Kw49gtZS+w5$)Y|b~GNWq}I<1LL)@OB>E7mu{_JZO7f zZoAE*MGorX>%674L_m7i2r6X4?i)WmGr6=Bq z2!U>jq`4(ZZK5b~^7Q^&*XG-QugR8_+vrK^yEZoAwygBPq2$nLa$$C7<)n1hKtc$n z(n0&!KX3&fx6Yg|+_?I(A^ftUlF6x^oV9|bjU+vHarpN2`BoYEFHPYzh%tMpcnki6 z@XHKtArwZ;vlj-Yrxv#>g*tOhExhL!bd&Ui%cD?OAcShxj2+1kitkP?FuPSqtv8%p z^j49XqCLH_kR>6EcPtG67#>gkc6sdX*(LQjx?DoK*vxA@c8BlziOzY=aO7vj`BR*CC)o zMwrG;M{l=~LCB!7b^7ldG8ndH)9EX@txg47taLQr5Okqg6Ny%YqKGY2xGlB53F=N; zEGkGRbRyL(TF!DK(v)|(0T~sD5jm(UIjvc$J1ZOFf*4?k)3H{opu4SJIbaO8)cX@| zmAMthEvIz`*?IsBA;mC`kxr07#5qsQfAT^fB8W{YIGJ|H5BXmR--SKt-C|FAx7d^3 aE&c{7I=81BJniTJ0000(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4dce8d26fda7bea5882b8063c0cc6672af1c50e0 GIT binary patch literal 1374 zcmV-k1)=(hP)@Y`9p zsw!0ibSWwew`t=xN*WS7u|0ET1*IYJ|D-~Qew!$6Jm=@}nKR$a6jMsU?<4%H0ix*~ z_bhdedzSiRa4Dh3nsHp!n~kp7B@lvyh*N1ktB6UNoham~;^0=)*7k9+p>03*)qBLRdXt))%wOUry-KPJ~v;$o`*n9tWfALPHw4w;fi9v_OHBGWwR=3ux zAoL_?S#)!)`f027@Nsc|AswG_K%sE;mig-0mxH}_i~xGudj0%s>ArIJK^{UFqlG~Q z0R_3X=-tUYb6Rpk`7d@nanBLTRpWi?(7 zxVQF3y;zi#f)u+2KirDia>mx=Ls(hWu8f}2s02+ zU=rHylBQwTG-G??=yoYPVcd$^3aqcA5?NG8^Gb>nIW#(|D5Z7Xu6=IMltb6&1myzfGxi~bX%VLZXsm0=qJ(ooRA_fxO6bnVdiYlU$|UA90)xU9srHGP zIMD4QgS{*M5p&v|mfA)Vi-I2B9XIf`h#b0>5#nH;#u&a}?l!rFH1r*> z?zjmtU~;}b7oiTgYV>y6A$HZkIlo?T$4aIBii^l14a9A;P?M3TQXXX+~&;Kt&# zR@oc9&SNNYG5u6uqW9{Jd^de=^u)dO_w4((`<6Lu$z+}xGZO?6W7hM5sc|}kjLH*N zl-R|8GMj6|emCJADWp=>BTB5#T;L^` z#K`x(#o6D57$u8R;y~}M#VK*THh_*N^mnU)@^_E%ch7N7u#XI2Ib#koAbe{22+1s( z3NTMGaUn+RWy#!$yl>(S0@*84V8MxhHo;8sG;dq`liBcNugcKFCk$$=lOIMAsO79! z#%aW1y&^H;JoH$&5JG2vfJ2rVw~sMougW1=CvdLj5a&cBvF2`*cB0MCNM@aU4Uq$V zP39O7Ni4UJwl%{#)IkVS0h1W#1T^Kh|L3ZSomxT!@tnY96Xe7s^)C5tQp5hqZ6e1H zAem*Rc$AD=KqrOUlT(FLTyJgY{9}hns7-%(LdUwC{mD;kIdI%0=8pmWF-O?HMCZ6? gsdL=3)Nh9SKaFjrgAbBDDgXcg07*qoM6N<$f@kQUlK=n! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a473c171a6d26ed1491af09d9569cb7d5f9a5b40 GIT binary patch literal 2124 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 ACIA2c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..db06a0999d2ec352536a639f841e44f053da7926 GIT binary patch literal 1905 zcmV-%2afoOP)7J17h>i3^ap1#!W{ZqV}}7RD2mHD;bMD>DW{P?Q6l2oPM~alXH{dFy7K zQm(MP-EwP-yS2q#JxiW{sdVARlA@yIZg7%Fuw!}Gzq)txhx@G8jCQ5na<4DkTfDyY z@~dO#Ul@Ke>pZ^TF(O;?{T2seI2G{Nbq zJ%oDf$|tKAFHN64mzL&ef^U4YnV#B1e4kypy!OBM&W(=iY1oe-c)7MGcHefxbN%^` zR^EMoPR|kH#(4!hmbdWPy71w<(Qdg{udKiEc2&4#UcuMDx|jS76p7{9{+;DUWhO7y zESKQ=j&t*e`(g(^f*?Rp5OerU>QJ9P6vJHm^6q5S5F3zi3I6`tX1rMHL{tn*Ibv$q z$RJ%t2tK06rGC?)4V&&-?#{Noy0l-NGlh-KBiL@cw{F&xj|6~JHsmu!#nf<*Nh1gr zby(ENl%!IhIV(+nA`8WxUg3go|GdYU74%@dAkU6y84@iw3MRkpS+zY?OtTr!=5nfN(R$p75huzF|nJ(wHSDnkN_8wWN$`kOhlv(zAGv#6lNvlb%w z{{O9bb}x+U<+wf_gHlE*Lq{vwXYNWoR<{tP3yJ zoE-L0=B_p6r}B}83lSXE_2Kfk={KeS<} zA5_nYpzA3ZgORcZqn`_$2~*^NVfyL9#doS-TwZ;+HqdlvQ5PVX$Z({r^-dMK0Ww_Y zd7KF8?794Z-#a(|(aKRY(y(drOjymJQ>V3FnP5)Xr57T2Q9n zwcIHw{(Texqp?8)Vhkqjk9`p)QzeEWX6shMTQZUpBDyeka5oj|MJgM_fulW1Y zcue>Ia5+=cTei1hCp^Z0g7Bms^-O-8)W2H-0N_y#6;*H3 zqC1WhLp6c;bOsHpNhbD)C-k8fkRh4O;<83}9a`@&i(`=k2^s_qsjwgmk$T7z{W1*@ zh(|R%sD~ rXCS!W83^uo27>#Yf#81UUnlraL5xj3L@T{B00000NkvXXu0mjf$)lpE literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..77ae8097bde246ae36bbe75e4d295bb8e883d390 GIT binary patch literal 2932 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 O1GJ&K>Q#_85&r@7{jc8u literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..77ae8097bde246ae36bbe75e4d295bb8e883d390 GIT binary patch literal 2932 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 O1GJ&K>Q#_85&r@7{jc8u literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fe49212e4617aa517c51bb4bf16ea919504d9771 GIT binary patch literal 4528 zcmciG^;;7RvAeUFFDN06l!T++ z_ar4R_kXz0{o#AgFXzW|p6BzNa2+ibDspCW002OxrmCoS->d#Nqy+c10v&4y0DwKz z6y*#&)Auv}I7K~?2540%drj5tEEFXq3hnk-i9C)srqof&PeRl zHM=a)#fHmS<>9s8Vn=WcDC`(oHbuX5!2luY0skK-Rx73hj+!@ZiWz?mM7^DGG}JFi zt9LSa^dK7)42aZ;(=l#yFwj}GZd8xpRwNCaqCb}iOz43BTGz01DNQ%wLEG}x{jRqY zP4*3fZ5ED1C^dga z5h5QE(L>{*&8m4uqqK#6c|@du%n$i3ljKDvuMYxXuTDyQGE1fYOl6B{Zz;B^w3n^i3+;8;`GF7Rx7yHARJG&g zHs2xSO6Ph?vzYSZMsm|liNz$Hzsb+%vaitl$IV$8o$|XkJybp%JpM$b@7w3Qi*EWVOHUMX39vJqC|YP+F+nAwQ6 zLcm9MHnwRig_^}#)jw<9tz@$Bt0#^>M@SUBoY8`zZ_Zm5J(qh{U*{=w7ee}U`Rtu< z_Pj`&lx9QfSVZ1T$=rU7G3qJ$cZ1>6P0;H@%d+P}AJ%!+ zb`CS@2T#Daic=_yM!)0r9~#yitwA)^u09t-J|x=O?$2tBT8`2NDtkg3OI3HF39H=( zlf!r>$iLfrW1X=FnSL|75mlu8VgixrS6G7F_MoqJv5Uzdvk5XBi$}(pD?iHO zY%c?FQv!)VjX&UOs=a03oE{FAIn*0Y{wh9rbufgUDwHvkxi&aTlG?)PqEi@#?S3-s z5|Du zq53GMy0z0&l7O)#49#8+u99Ig;=Yv20tG;T9=sR$cXh>P6f?5}>%UdZkps92$1f;B zTAJKgTTdwDplQmU#3Ufs_jNBh7C~X%LTSK~a2ry=G9`_7tAsH!&^+xRWBPK0kI`9n z=S><9h&sw5Xi&W4m)ED3@BLmR&_%%eFnM&%GEW^ZOrNVaWY+}1QxR}o0WgH@m<4H|+)$a=k$@-?dbUgnB~9w_GQ`b9p<>6DDqq}D72AwfoxLPu zpoTo6z`;(`N-?Ck<*Rm4Q^UGO{@|y2o-sE1=6Y?ne}u>APE+5f5ap2YD?1dt$mr=H zKht+56cu~4*7{$>QE}_uG98(4G^7vIQxMhf}rlPP`4L4T0*#(Qf$>LI>_=i+q5xo54 z$+r;S5$Z#rc_k@F15G$wJ*_V?7q*29jvNZ25F6IIr3HC<`NMe-a46N+5GhKrQz{v1 z?nVchtzKO|Pc9Xkbnv$YTmDe1eNpJZ)5{NX>E8&2{_t=2>O#Cg=Rnyl?XluJm8?0~ z;In#5Z!+6zc4Qas$Ku$RN<*O>@9wKLC;ws7jIz|&T$ul;b8;u4ZnAb?K37=t+)0%9 zjqY-JYo_^P`x4F+{g|)Yx3r*DC$2-RF$q`>z1)$;);MN0jLqUjyM367zox}Ah-*7k zXMBi9=W$BUzkL67a^Vr#Oa>;q)^U;iHFN!iT7Cm}%l_|r;vz;wL4-c!6=&D8btTp!=O%LzbZIer$Ln^=MB!)F%&2n*;O_kWK&ryO=|DyYGB>e;}qP{>&g?h55y~i#g zhOoL}g9wnl56eSYf*hNW#t_=^Sgs@xj5xH-f%ik+Tnj9Fkn>y2AZO%C`8>~3DCrKCqxdPK`z@M87HMGG zbjBMWRTE3i95L%CZ_rWy>r|XckMXo_VvTX0A=_}m$l8joRu-KBNMN80N5mGBO~Y9y z$oIG7>FM>g^A1i;LM_vJg$tzrR{del%?vTk27JY%KU>_|rx*oNU5f8IHK``t7TRx! z4s67@rMhKErsnaJe9hLH%^+dVgW8j-&9D~$8TVAid`@qQR5^Kd2b>}>s2@uk3bw$S zmXbdp)m}bk9y$MN?Z4h)?zOuscaTv&LGVF3qC>0M`h{Obgco4#l}}Yz4hvq(MA*6W zUmm{7_ziUpJq4aOH6_o}UzbFF`Ye24h!zRw&G|_XFKtJt2Rlo!o_Ts=F8${>tEsD^ z_*Uu#&-fF7_2y)#y%6bWvdv2PLG+NoM&BY{@-z7{I73!LqLKJ*)Qt z$Hk>bsCGu}s1}Ew%KThIdHIqul-vrW(b2y}xQfrd*}+MmqBVeg%X;rYtyd2mf6QZ{%L z`8v+bNVL0Wo$Qro&lG(q8XT>Y#;A&~V4&gs~bl};3w#^iS+tqmipv>IpuHK{_K0$0?^xvdL zLa{ih;~-9RwZ88m-${fyo7$kFrpqC~RTIXX2HonKcf8>&KJvJOUf%mdYC+}g0tPnX zC!b(x5n-6rS_&xMhEl!VXSN~RcJ_nc&oH5yueO5>aTNmX5aO>8W55k>SH~;?@iLWD zbeb1QLh|kVl?lg}5r1*#Czw>uoKxld9D)Fmn-GVyS{(dn1V~9vNO&0!Jf?NTE<0r znOi(rd~ko?8yA`ujpqvw)>ULu8t>hb|4E%DJ)y~H@`j5f;0)pehg@Xy&jjmME;ER| zm?T@ecMY}FsP92j!^^@?X)-NuMpzF|yJEW>G*q8*-@zHv%6i=#ml(vDpI)tP`dwVi z54NNI{hrr2oeS3$n_x9fj)Vi15-hb__VlVODoF!Imsy(QbLB2A_tyPW(rg`0;c z0mIG8({gF&gZI=Jo+QvZ>6xKS1${!yG(?8i=uWNM>`*?)BvkA%&dLUXuZ z{4ou&e`}i&J|X=x%qpr_d)*!YU}X9p9o6kv8PdM5m!CIPOoF>M`siJ7ppdVilaFOW1V!%bIq;1CX zQQV3c&eK#Abc!i%Lex=vO42!rc<)8NdDZaiAa+P z1oe_Hyv=0Wc5PK{VYv`eKhrw*QiTxJz-O7H#&axQfmr1!cWrohsc_kW>GK<0t)=3tJkk zmMvQ^lURj5>{*%BXeOMac@SX(76APmmkwJn+h-)pV-+?z7lG2ndwXs^>Q?fe z+==e3LdLD$UZ2TM=#;}OsOL8XnPXs;kAuy!#@9$cb^=$Vz4J|4%sCS+Kf~Cgg2C8z zEZ%%uQS3zk`6HUHK@F_&FHiGib6GXAhKAf~6>%fvRcr_^u$#!YRFTgwy$b+bUjF8Y zXBr&qbA<;Kv$MW7HW}$#`7Q%l@doy0nq7E+(J zx_gIu;cBxBHCf5Tn5UaGoEf>f`ORJ)VCUINW&_)#+=0fMX(KD(xZJ=DTmogT?Q}8o zhUqYvqrY1+dedS~cFqUpxRSv`Pen-SRF1w)FG1BQ?S0p(P2LaoU zr81G|QWmPcPF0`+a8)kkza+y6RE|~R=Z5k3MhT%H@EjQ1 zQLK+T9JA0pE|_C>hKnq)Tg;uT(y9F2P(V&!~oQkv=mF_Ex-N`6i2bU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..06cb1c51803278ee67ec68733ac19835c2d19e66 GIT binary patch literal 2984 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..996d670de0bad5ef0995664835661c854dc403cc GIT binary patch literal 4799 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 K8Vza=vHt^-w-xdL literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c2927e9ecb1cb22c98598a559a15133086e8a26a GIT binary patch literal 3790 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 Xa6bArhr_6Ip9+9$8)!9X*hT&aSD{QL literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..84ac32ae7d989f82d5e46a60405adcc8279e8001 GIT binary patch literal 762 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 BMPUE{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2aefcebe4269e1c52d45ae5ad9b084144dbfd320 GIT binary patch literal 4541 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 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/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..b5de469 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "pawmate") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.pawmate") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..c0ebdc8 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..f757925 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux + gtk +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..1725a80 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "pawmate"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "pawmate"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..748f3a4 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import flutter_secure_storage_darwin + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..49b815c --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* pawmate.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "pawmate.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* pawmate.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* pawmate.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pawmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pawmate"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pawmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pawmate"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pawmate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pawmate"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c9553c5 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..e146039 --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "icons_launcher" + } +} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..6cb14d56c8cfa8876c498d9f3e52af2e89be72ce GIT binary patch literal 29685 zcmeEt^Z(m()8`By2AXggkdEvUN-xH48)HKAHcox%f)A8AiZe4neFEI(tXkfbQc(sN6) zUYkiLESFt;wBYM~UTI-7Iac9fZb_`dty9S_fe|-XJA{ych65V-yB7|{--;=g)pPY3 zw$)2bHJYEK7==Fy#5-DK`{HkOY@4g?`Js8Fv<|tLQ^T6SMf{9-%(55|(H4N zVBe5e`kXII>?S_|nHU7<B$o1kYRF~M-CH99q zRGf-mMo9q9F&=<{AlEjX=xjcn(~di@-lWXA8DJ~MpY{3)yCsObP1Ze(H#2jxO2mqb z3XUugoz$f{zgr~+xG=((^yAz8>Sq3Sq4lL(BlfRfSJ58sNUzSgoz52C{XVW!dCXP1 zw*RNSwZ_^^pF>7Gvd_WsJOGhiNH7ukvrG!D6C1je~&m%Dj0cP&O`EZNUwC;l*miZ@d=(ocb#7!>_?{ap`adpfw+(uq6UX(*w!NC3(YF6%tgzhv^{ zXN+uBQQ0$w7?Q0+Rk{T>g-c~AM;j((Qe{(#cM)L72MLIEGnb-eO5q8q#qJqCQfJH% zK+EiH_1Fz z`~f6Z|EvG*qLYSFRb?|Rw%}RioNnxup`N&k#h|gP#g5;F_|7458&EqDIpP{FN4@pJ zTz%5vDYB+?38>G{o08^i2*&p=pl}7x>%N#$<}@=YB`lrBwzu9Gb1BaGB`2 zS)NYtj~0)Oq6MRAW5_`c8(jBFRdzI9e>z4ZER~ZYF&}7{439Mxt3RnK6cP+6JFz43 zk?qNR%}0sEp`g=wnzOV9N5@=WrRC@{#gGhqtuJk!t}fkZ&fhL}FIo>Pccpu9gB#FL zy6X|Wo9$n=s;qF{T4fl1SxEhkt6CSk*feO^AuwX^*1?9oInZ^14$@&wpyOL;{Su3S zjDjkqvud z-9j2a(AuD>V^4$HFjL!uI`)ZO>+o3Pe$dEJU4b$%Wut&f=D!^}e<>n1?uIWF()*!7 zo$Ij})0msx?3h*1&beGPGthj81Qv>!OI3#}BT|iF9-n4UF^^iGFs>h=mDs8Z=O2wT zKT0img1~&)tf#ODTjzz??QyypVK#U^hJ;*VT;oY)zU%4ZQ@cH?QAelh^jr>d5cvVF z*$mHeP(C$$PNIj&G)}Yk;KWe)`5RkBO7rG@v}%^%4=@&xB0 z+*j{^?~%7$J=m&ddBjm_fv5?bilW~ap)tb5=EC(KQ;%c{FeA?zOrs`X+GewuW zUIWwsH>l~I?O*DUmKJr%LP?V2s6+jCp>w0?PRwZQHC}TlIid%)GX*}0Sm)#t0f}W)7Hqid^#!Wz4)^2xZ zsK2T><2(8~)L>#t60om4U5jt=&z6>&mt5H;LFyZ%9_Qm7m)bk1N47)H>&&YGV8KWN zOrO8_370$TZ}<3s_}y>2lYYGl=ShoG^YL*R!Pcf)kaL|L$i>VbG{)*VuO5(e%+{i5 z6k3=vs*k_-?2Smd_cM`+I+B2z9c4iC`k?R-@+K&zM$1NAb8b@`Kl^>YLCMN}8aqlr zcs4SSd>?^%cF^oA79NKm4R~{NiHzLWKR-|hp|`*EXElyR*_jTCAA`WqLpiBZKaRzt z1fbM7?JU;!KDCKBlDJ!Gq+aEOT>klp#Ql+QGCN5-`fjEsZ%+dU$W z*At#}y#N>p!Ezs`odslW9_^Yxq8Rpgo0Fj)O}5U0w$pkO!yL581AE zVP&f;YT}hpcc225iEA9pQSbeuvesn=?r=K=ap>xVsg-ianLfrtY!*1p?g@;k-J|^2 zjs#ER0cz%F+0j2QRp^+YVq?CwNO=jgtDP=MNwqPzyajS>B*37kNZSoU0k6LrJid=k zhoimFJr{cxgwJBYl_2O_Bt6`B7Rg@h5=~q+ZzkNAk90C!$lrvKXz;NrQa1tpiK1tDdnI_9wN({9jeyB}od%{>IhIojFq09uPo zxM~%!cv!e{JcpM$I;g&Qu);(J$-(U=02HNmBX;+5+TNJX;TXK zJRMiU?~JdrFhB8y#AHHZa5~~q*nj==nPt3iAwh12pV(7uyZbbm+b-K0eGCcEgeMg5 zdK6kej}@EFpJu{r4}|SO7Ej;3sRefek+_LXY5d-&_6oNEyDA_OhkdQJ(O|a9J3f|{ z%g>BqRsl4E(4%e$G<@%1x<`hfFUoForhqKxmOZP)f2TY~bjtGj_v+!tO{(zh)b@MVIkbTh>vo_`Gc^n7R-BsO5cVm)=`44dlU^L4sAS-&*;Lmh#9^lYhp-gennQAct?hel; zy#aW84}lAC@4(JYF49@?`BNc5+`mJAre)DRf$B=t6e2**Ndc0lOHU2)wx=si4p5~h zmV&CQw6ja`yH<{N&jfL(M1Z-GwTwPo_T5xvf$Y|NhpwhAZ^R038fV#w_z$gUZFKO^ zhdz#;TVuz7X!IBC0@L*P>85U7CqZQV@c_e^HH|*b*xWYRBCKIdP=G6Y2*}7y% z2PLFKe|YTfULcI!pk3fHBcwImhut=+Y}QT@#6zv`r{H=}4f7evF+drhx@UE&dww1xXxvrl z-urmoJdLRyZj99jAxSV;X&A)|aJ6G=vh={^n-GXP1vpO+8(T?9P@)vz_l7pUi@>14 z6cAuwsf@VMC0pPj{=ya^w zYOU*mF|>a>0%Y34AkNZM4?lL}Jr%&n(u0Cwf2HldZ(O&@sI7W`>Ei+l zY&?L&y}9#DW6q0-3-q9qs!{j(N3MS`F5;FX2ik9uxLt|U!>tyj4&NsTu(~~^n!lOs z*`nh?kdQn#F#VFx)gR~K{E)DA_M`IHkk^Lz&o{bMz{Fj+blr;4<|ejs;waG$V1}W7 zm1CI+aBNWN)=zy-A{)*a0Ph44uNLcJ?|SI20(l8;nJN7x zOs(j#Q(g|2q|t%Q{3b-3o05$u$QreZy}5_$n5|tmQo^VTJrEgxPrW4|Q~y>ESSxnm zEjjxc#@$L(A$qFF!8nAU|JkL39q;v<+w35#X)hF7?tAvry4tFQuaJc+K1iG~55o#{ zJjML?Pl;+Ao;&Q-#(l!C;yuOx140^pxJBUn;ipsA#BU}GTo7+zT(lofhu$BjS|Yvs z78FpE0LAUrI#oN>YhBj?N&$k15?W+uXI+?{oRC25WQA9Oe`8U+iK+mzQGwrW-gtf( z*0BqH5sC+%ha>@F>A4?vn5MH@2H3#>XU7iv z!OlVTQMOP1e@F1^6B5TVC3y;6O7>Sa_z7x&=h_i8+2bRuc=ECZIVm9k90{Jk6r~%T zLD<0{M^M#sax=oMy7VrT(Peo=&2EfDuH)3sD+2M-V=fM_O+`HR`!2!Nyf2V|;exVf z9!qZ@5JoLxJx$ZG;=Mg_FfPWJs=$!3XjWz`V>^2PEKoGj8mp`Rw!ev$Vf9~zkrFu< z1t^+MBjeNvPbaW0o|*YK*2Cy<{It!Ius9tF&~ls#oQ2max$gicC7(+@i%|&5 zyL2%mm>uJj}2_tPhWiKI*P!ftvo`=!^L$ne6C>zwpb9VqG29;V+~A zQ<=%zh?-P*l~&zTBA8v*G_R7Z;K7%!q{W}tB`F9os6L(hY#LcAA+n(Y9}~?6U8ahe zs_bbu3`Il{G`QY_~jZGAUD<;qpOZNo)OLIWatDgi@Az@5B5Jza>=*DM_xTtXtK{0rNgk0lPfVD&HJo=y} zdHxVW2#}p}IfY!F?pL$+*)t*$1JGk+tqp9$h?Dj3Ea-lr`J-ZUs~)>uokK%T2!yQ} z%v(em@c>t#>tX3RKG`07nhb#q>5Kd?MBW_Nwq_#?C&Rd7>$HjeWco5ET$Ypq$S%V8 zS)g6$0EU@GQ2UO0Q>0|kgrIvO0bc81e#^Y0!yk&fqzWx9Q@pNVlEr%ij^YTT9nwN= zxq5(x9J+|3jemQdd3sQ75qA+a@=w_H$~SHk5`YgLtK=$TU zNfM|>v2OOR^Ho94cbK3k^p(Lz0#n>Keg+G82tZ5q#t_!ob#2UQmF9n=1i`wlw-M|C z3~#RGroglT;SvJzhLRp|S7l4@Ed+FoV4;^C*NAm-UTVb8er!$y;MQ9tnB8A~~E%$m(zQEv2+QkxoY z5Of7N7PUN*96zBj#n zSwNefpocu!GGf@7zDxJtAxn6wE+#I7n#`H2-Nzy^roHKF(8pE%3%m){AKnaTB;ZYW z8ip+Jv`sUe^t8%$)=t~kN)}Kc`7YvPba-6swl?9*KajF!VY~7DS^^F!_#kQcTh4~s zt!MxDr4NLRwc}s<==pbu5?T_Jrmnkf-~1vP$a$^vVx5iG%CXf1HL*I()A^Vhqh6NEN!j*S?XJdS9bx<4 zzt2=P49m<+(-Uu>QESfLRvMP<8<|$h4XYJUzBX!-yCQHaAC2%uKQ2zpQW-6hms+t<o*Uvi=~>}f2$DKSQUuLnCPB?Wk;F5+EF1dV zDQt5!s`IJM)i-7jRMq|l3kZsdwgs3E?uTNRj?{*Ehdoo)=$9sQW}Or7c$OJ< zbf^Lt5&p86yJKVUp%D)(zh+%$I|G-IqIJEMWkT1$r7R#7*Kpc{g5pzZ+O2qa-<<%^c zsj`c^-;dfDdJ5Mb|2+DH@7X&EnPp73OqiP!P(wB{6qzjqhTjNU8R1$Qr^1lZ<2p5~ zp2tXsGsPe=fgw`BJR_h@0^X{gm7cpZ1RGE8 zc|QC_#I@)C^iAy3e&<#JgpR6Px3SXDNPqZ0k7{M7{^s5L^*T2O9nIA#V0zcFLodkQ zzCm-7nhb@ez=&7wY`s)&=th`|>Zp}<8(R%6w0$cjHg`)-O*gw?Fhl#HyJtW$INPP= znt95IGrIG)l|#qQxaX@)m@+c&L(^b#l);@Jx^^%bdj(>4tt9PzrG|y_e>r+4E=n|M zW;fF2Lz+B10$<%RXD~@I5(tle*;cxV-F36da$g&pbbjg2m4YBAp}>&GI2#TY%`l6T z#aG@yudH`myvvsB-BhXLybw7Qe)C9ZZt{`v{c!)?r(L>yL&IZ&`n@cl{MJfL;s+lf zhye-8!o36SX+enD--L=3j{TJQ_U7l+c?0Z1z5SbHVcG5rPBPdO3jv0Uui}e#k{ZW6 zUoGyIAE|j7;1&{qcU!3{%fSvoqC=Non|ZO#Dc=|iHF|}mUyPIt4m@YX;^yKnYLcMe zF`sEWIyy71`#f7Ix@ldidvNL-0RzdSiuGXD5rHwkW;moDcI2sjd00GsP`op)Vubhx zp-P?#9=t3aO2j7L*0if+!;3Te zM5Ag}-cNPAr@+{4EC3Ks^U(6FR}^tbB5|C;4sQfaf*iIg*tNbqSRkWI^CEg!HP@Qi z&$Ol9R@xji>GX;JzhIo07)42fl3o(sTX8do@ti4@jw)IHoPxX8whcp2Jq^om(y@8||Op zO}6?qkVT#}u#Tu{Hl%W+wxRQK0|KFP8$s?nEqzM%uA#FxLEA#f>B<-V15s}kwL&zD zU4y?vW4{}ntChB>NLKUgX< z7?Hg&YLkRcNG-Vw?)ZOlVvcro>b1)j8aap4ewKnZTw8na&-ihd3XXT*bNaCUlbaiuM#~7-BM2i$DcJE zE6!dssY>6w_HWGFtP_3`4}*(ek6j$ncqB=18IS4$iN1~O&snpjBMa%~X@g{MUp&PsPp#Ul(>uYJYz*yW(2FsK4+$ry1QgQ#Qo|aRc ztr#D_;gZ*b6T{{AfcPv4YM{NcS~t5Cs!z#G_azup_-4^}$e&k;o!5BiDCriXr=M-) zHeD2Tb0;A=Hi;C}LW?L;UC%pV3ch32AT1ue@^~x7PoI||TMyhY|BJILiV_M)_}X{x z9C+t#u7*=k;wzSN%yr0k-1&^TtD z>(iZ>yr>u4b&F)S(5hqb3O%4Bsi z=RIdbVwTGi!j>6U4%5z6N?8UZ{}m_GCxUSeac=}&0G@2<_us^Q#bP2t^zR21$>@Nba3}SRlvj@0heD-EDdnu>L?!CBwA*+MZ`Ry9U!$D@S_E!i%9o z$Oc<N(mBBlo9WY$^e{)->Z3}_rE2Z56Ld>z{SNMx^%?dRd2eDYf07F|2nA4(40$1G z1nEq+qVXgmkFb3euf=0V)_lKX7SwnhQ29u5 zM@MvJ4PMv@$#jOsy%R^O`mHU;laq6XGu>$hKoe5Jp-M+GybHk&$;Z-fbwSaI^I(bS zR{h>r&0fVK7vt)ADdAUEqe4!v{t{;+adXo=%pCLRKmI|0SZ%r%Yc++@yVEo9irnIM z;YvX{(4GplSUx%Dfieh4W{@q!sH%W>bs9I60q#u`XFx|ls_mJoO&ov5*OfKdn=E!7 z<4!l!M=KbHW7H%hm`yLqK6#s4@tl8-zw0^-AON-OpPU6Dyc zc3IzIg>1A4scHoUh=gXttU3>4KMO^n)X`g!YY%NIn5^$YZUcN-BYK?~1T{8q@!2Vb zA#T?MLg;DuD|g$T7$yGf)lXheBf(cWQ|u)Mg?>HVSKo>?@k;G{S)Mc1G{MkV8$y+t z5FZ;cf>$6EGwlc0ZG}{Hix)Ot!c47kZ&sXdSYst3Pa~&I=feA|$GFu*KwNkH%oh<~ zV@&Dz+w#JL2uUYWOpkNy)9g~;WIl7Nk&4->YRk3eCEAK`@DAQ-C{?F_;t^SQHA20- znnp*@NL+e`On90xumz8`vAFT9uF}Xzgv#?pGK=#hC}wV$X>IwvGu$ZjqMZ_{nWSkW zmCZ_o;pLgevFd4dSyRE+;&b6nq~S3Q?fM5i z6sXJB(M=`YD@H2z#F+aLC7zAXIqUM+dOqv~tg2>|__+@Q+4k(x-H3`(-VBGEY~=eQ^k8r>tXQOj6ii%=OuckbVP(yr zQlL{^eRRh|FVw9$Z2hoLu6E%Ovy_nE-v@}#+#oM1iqG|u6u?nmQ``xP8PtBaej|R= z9l!M&Aol-Bw1{ReHS8Ko@KKhe1NVTdmX3Zq=o+80r*mvl-8NpllBoNcv;oLY@rs{D z{o{IKdyb~86ja9%`tAWq7D4CWZOd?UGs>siHd_|l$zb1IM*uAm1eJ=2G!OcH@ zw+WSG*tAhCt_Ra7G>?1!Mtuk|)H&028(z@+wd*zB6%>tWL=WaYbsD4v?yMvjvlf4~ z;Vo!dau1n@R;9XiuIo+_O}WIr9JJ(P%o!}VI0IzgB2k6!FI>W3?Utl%h>;R%V(z!V zFz#q>P}I!wBMMbnHc!~h{G`}%NR$`_Nc<^aK6WeYmMBbU`#x9GSfpWl>1NS!h_YI1 zH;aj;#r;SE3U3TPu+@D0F)Fip)$96unQENT#bJQVuz6VHT+94QOAzK?QQ5HLMlTKC zQwtzW$di4MavRt>`{z%dbjNlfiv8DWYuHJo`O4!*o$;fk&oHv_s<+a1ssX!I{JqGpb!rf{LX=o?mZz$_#E;*pp_-A%ay>j5G*2Ak6mbbl0PxRxrJb!`ZQo+j;O; zB)L^Ov_x=6x=DTQJgxqykOcmizHh{nCp~h)zlMBR=kN)9rW}@?_+%%>_o_mFt&NBB z`uzoEVDy_5cz;Swa|%c~$`2SMz4NPR#F`0rpX`s;sq^I`bfX*arX<1Krw85vS=XJ` z{`@m`-lg`IFDXVG`)cH5Qr~Jlkr1s|j-k0t4=!X18{2+G^_nPeauSSW)^s>U z7-W@B#PK93cZ-ait8vownnt~9 zN<8+pS)yHmlA+Ru+|8m-gYo3j6gc}^&+%Rps%o>hj8B2V{PQjz5V!|iS|Uv=$R+{= zi7&z}Str|pxky9jJ_vE?9>-`v_yI16S&lfUS}Oi>Nh4wsX?%^tPf9iy`tLv)Rq&P`D} z5q?Q>Lc`7?{VisMcl=O)_`onzEdv50C-KDoEGCIa`)B$GW6G@*ADI7C`2Gzp6zu5O zNKKUtA*NGS#goW7LRd@PL2aFo?6x>Zg1NC1Y2KS;TXf}@kvLQN>yJ>Nn$k*}(t7^8 z+U{=Y$&rzLO#?*4XW&ffQ+XPP?*Ur(d(KP3#4USiP0og?lOdU{<_9+(V$$lL zw%7Aut>KH}g40^-0@TLOf5p%8F=i%#tY6!!o9-*h-y=I=AI`1!NEFi;Ks~4Z;MgA& zkS2e+MJ=cHdk=y4UEl8;e8h&6^hU*lKPk^)ja~4uVx_qT=Y?rsCK{crRsSH=+3~HP zcHxq^b{3UC5URp$yBOIJ1N3TYmg5nt3L9)lxya=$(uu2{y@gy9xhE3uDL|>sX4s>Y znU6uRBSi6T+L(wQ3iG%0h84(~V6bpYH(i?w)>jBzF4k_o2I&I3yVo4;h4(IE3@L&a z$h&PcmNdIN$;45VTZxdWI|$sr!~9&Y6YT-O&4(ysbW8o&;6O!2DD2s$eSLu>PIKV+ zH{96Ga~F-!p2{U&xOmZrh_(6kb;|U9U=s&5Vvvygef$>8TF%#TI;mizfioW`624Y zpLgT;eE()tAaM2}sjl1HdnpoK$&(XR+0{bF-h#`(_BdPpXhI~-b zF^J#?fifVt$@)f7Qk0H4$f}zLQHpkq8D>MB5ow?c5}Ui^?t6!Eu*7ql{8JjxX14-G zzaw~FU%#tpok}5>2o!$j!ixC2thD05lygsxNpjA}lIZig_ZK&u-Mkt@F`i4~1F0BJ z02qp5%I|r6CuBJiBY3M&J-gL3+V5VsQCXX`ljnd@P7<%|NYmjn5LM5Ddt(|+6(+i) zyZhocC1lhh3Ko&`&YX~&cp?TU;*Rida=*7|HWZwUCm;o96fYL+Z*3w)1Aau_OL zJ*1Zf)oef$-ydf-{*v9VJ0e|=+87mjpKn@e?*405E%LdN%K*xp)twvg4L!X zuOmFmB%gZW&oqYyIkMZZwTffPCQ_34LF5qsRRZPw+U1r3GA{zySD!vsw5NfO2ryTo zQ;;{G&CX30q}i<^a@e%XEQ8ngca54x#xF~f!`B9=^J9s3KI)8aR>3k939e>p$)Rqf zB89*IpKPB&q9PQrK`?SifT4%_T+TjO&92R&83j~!hi_)l4=7E^Ap6Y8xLc}fJ4Lx7 zc4)*1O_q-0lWUn&cPvGq6LnB5(S}Gk6DIK#`-fP6*N+ zx*pZJw`UW9uudv*6#Xot*Jqm=IkiifS#QNmb1yyJ(^w=kZx@z$rg#H=F+O%YdYz`% z;KCwmv)90op`O*1@QG>a_%kJ{Z*F8yIs1!x1dA1zUS0zniDrJkZh-<_&a73#0aoy* zIhNPF^joCS6&g(lK<5?JbUs{x8iH9ff8dlv-f8LVt@9!nqc;sNPO~}ox;a~FGaE9}5AskqGaaZdAr$=B z`UG?$f9k#AAGVvzmE2YGxE^x|8;ff8TS(5Lw z*#&FAMOL7xIL&X!A|X6GP#e8KPw>lf&O|uQi|zI0&JT&>cSQOw6n)kxsC0O=JoVgZ zgl3JJ;};7M@lQ=BJ9=?U6wl^N)M?yqA<=h1)w$ZA1%iu$^lb#jZe=a-`%CvSL;;Z- z7;4QZZ-3g$tpAIp=#v()?{B(e@lN8@tkqZQA~?q`dkRds#5O}ESG$6;1&dQO?SmX* z@;2*u^y@qhCQSJTDF*aA38)9E+<*5n3bOZr=shf5^$6x(gZm@Vw-tf%LqL1UurO734>m1oG zD){eqI{JT%ZX04W=+) zkg>bNbx$79=#Uy>QVcpYrbh1R+d=p1Tfg&MyLc%J;>(BApMw!Is+1T?MGdt;BXlxWJ~Vpx&Rw&{hqwlALtLKfhlqO&3(OFYj;ACxHhP_Tv4z^K-IPTzJ{OIRX|=x z3I?684f76OG@+Pu?hw7``JdT_neU-XT}eR~Q8NMlUa zr6PwZ$)`8p{P23C3y6gcVQB1<;eZuS+3Kk+B*cfyei`Xgu>A(}vfM3%=Mx@5o|4W= z#CLssZ!{(SW+WpUF$_`^y%lmD zW-=fb+KxMd$7DEq`@6m01nG#Oq0T4^9$%oqCA%Je$^WuK)F2P}Y~JK#!qGb)v(v9> zjTad>4!9j9ZuM80>>-76R3oewwIT4H~q0^+%w6*QtB@dKCN&+&BPRa2RL_KwJjPjLJq^OG1)CpCMDk76Oy z@HAoQ@b1qGNaj^|{m!Lk_Di%+39xwAaUJ;nW=7CsJbQO|gCeKiA)&7;Je~ZS&LcaF zOM3J33xUnVEmG8v=+LrKFBUX6%v#jbxjN~Eya6hy=1a<4+gPB$!Pp~0b&ZnKDl;`8 zg~8Jms$=!CeWMunj)@34gan zCka4gsGN+sK!G8?W~iwV@`($S{yxy9kj*wd3j5w5-g>!vpQFXnUSs!L9!ipA=#2=I z#&dWYLcSbU;8%@K28&lT1?y{=c7iop)3T+~Xfj5An14gDsJ%L)tl(_!0EvDBha+xg zV!0h`ieP{15LZ-Z{u$XQFhl!j;V~yUS@zVvgT}1fJoz-|RdCErI{V89T`9&6RCuNgX z9~TbJ-$Rgph&h6N9_MdQoPNyS3x<_i+~dxS|wCJh0vi)Nt{>RalFggI%JR5Kxd*@}vadxZ98Oz?{AH>v-Ms|6p@|7|nCMXrZ&e$bp2!Q~b#(3^@8rpOFD zK#*qym3$Ut&O=}%n?#B!4Cb_A_3YGArSDP3Euk(9N4;V;eZFJ7FNh2dN*BEke4o7S zv)vB+JRz53I;qOW@gZS#VECquD{OHS(bM}7z5)UH;Y}cNe4N$63ZM?7i*5BK%STH$LcKgpE+M7#L3vTjZy3_^jsiE4s-!@YXt zN5#h=TA0l@l59=r`uFSc3%H4@K*s?1!}a;ELyQUC=ZV=jfk>KbBaXx!ZGQBzNkCeng(tpK&_T5ESQKnMxEaGIR1vaTUe$ay?$1O% zn>3rw=4ry;Zufg|4|%6%%Qg9R1~z1-JS;e#$V<^Zs8n}= zOE}u6mUJS6Kc;3}zyIZ))-+6C!cwWlhXl5Y(xd(5mP9Z87D~mY-{0~Pm@5%Kn`$}E z&#ST3?=XbDea}XM+ORB33P;k&1vcWfUnBl-)E%;mNa8YHm7pmlJZs*HiAS!nDP-}f z!=3uX%_EF#SrPuE)xR2k zZNL_e+RwwTx~{J6b`mrw1<7yuq?n88%?En>!5Wj&6C2Zv9F$Kl*ui!^*e(n=FNEvxeP;Nz@JNv-{Bp}x796gd6J zzHfNgkT_GX0w$q25}s>UFYd+`>*>G_O^t-tX20}_X+W(|DRU2c^Lj;D#=hULbp-_# z@N2PMfU@f7Fpto>Iq{|YW~R*=Y(7m&e<;t@gSKJw994_ZJpYs)XWcyalFVvN_GB%6 z0kxm}wF!E(cPaU)wHXT9pER5tC@^rfJIiYV=VBzPmbR%dLa*-w1Pyv zd-4KO5>!Fi5p6m3OglCh)~z)rppoQ0y-*jh$${*j*P=vH@WZyK(VhCs6gZ2g+D=g7 z(;EU7w9~|O5Q2=8%7yBrgtmLGb=}_h?A=7A4}NikxJIju4zP{^Z$gLH+G%&^30jIv zl0-J-V=gg#HBxGM32&G$4ab?_IDAKVE+|!?J?tLYZLfrk)cXj4x-Xr;rK-J``#LMz zx$2k8d4u0fxA#3@=MS0l8I(_QQu1gYY;@00OlpM9(g-<pWvx7e zA%+8P%*6ES?rd@-QV7BuLKKXra%E(poA*P~Fu0xKch`=G^R|CdAA9cEr%HVi&3Jh2 zMMz+bxW1q`DK)|?@Iid7^TK1rkW}n)bqZ6PnX5zJ((m8jp48MjGQVUYs#OJskNKLy z9@;&)@j!I!;Bk3>saiuAUue4JsN0@|8&)=>Pe&~!WGQ#ObiQib3BA5OCg!%)`mg6x zcBqa*rf2~`_+$;{opf9}1Jq{O67G-ferZIXV$s@;425BCNWfZlkYYS|O)&TH-XMcD ziWqlM!n6Nx#E~&Oey%s~Y+CmHsWZU($571Q-s+VXU3N^*MbChpt?6}%&ELgYj9cyx zww?h%6}pC9lk3WV`GXS?uN$VWwO45~Ihn1;QywhYJQZ9*$}BDyv&d&Pa5_&#sY9x* zh{UW`tg^FVXIR3?h$2pNW}g5eA8v2cv0b#&&jqH-dnXxY#JHgdEsNt5_7?{k0=nGg z!9z`S!}yxXh=>-|;!k#seq(|&x(aGn!QU>eafg-KY@Lyzm2e$}%4EV~*MjO+Dps+( zb;>Omk~6^Juf;X=R!|al>qzOpIj-Dq>x7C2KE9F6VfU-b06YrdM_M($FSPog`E0m= zC7cv!RCR&K=Ss`;;$X!J&kqVOOa@^R#~CYZ$EK3!iuU09zY24dz?yq5JhJu!(a~=@ z+(TNhv^*x(8YeDu(n@Fkis>nnC4!Xdc79BOR8?udmET*Se0%Jw+3G0TX(_3CccSwk z_U>nM1d{4GdDk!7e7FxIKSqrM{2l5xTUJC@92W|p^&Br328$C@vgRxfN_UJN*H>*3 z$NLfE%G`$0lBaFel?8!=SxQZ$(N6NmxTCKlHdRPWm(ge{3}P{#hIV ztUus+uXuboQK#g;&{W_&0Iu&x^(w>$3g$@;0c9Hyi2$^|N)H;2P+xR=q1-aRA`n+cp@TbJ2f z{Df9_yd3)Kgqqd6el{$>niX|Y3%A(NAt)6*W%rfI3@BH{2ODe`2TU@b48?9`M*p|S zn5eU_Q#C&8JNprPWfqy|QO6kKu=T8Gthc)U>>(Ir-ZW zce;=*Gq7$>Jfzfzukk!{SyS^(s18Y?atZlQKVqO;`^$Dab6xsyK28FjUnn%A9KAV4 zmKRwEK6NxJdTO6R4X<0vCw7;aT3)7htFJl=4$fpGyZ4eJTH7xicU-<6jref|fzx1Y zoRq1yK13fMdU?IgcE{PAzf({$RFDKl&cM+^_vD>Er%Ok_Ohl2ZpMKp16G3hKB3she z%PGt_O!YJ23Q-52omf4#x-vSX4X$*`a#J({oK&~zwfyZ!toXC{m;0b_t%Wbkp-<2; z2Yc1K`c5Xwj{I~TQry;DcUbwei=-dS1iv3J&w2;Bs$01;-H1K>ZGv5I$~zn39HfG9 zdTBFQs8@4J1DxMr*ZEsM$+*wvTIuqa2cI#aCK-w zI(cY8$s6>B*CVAEEw=@$8(}(syf28O&yD0kdlHU;>LK^pzXmFqzJTXe+{n(cT@+K? zI7G<@-A)wDC892u1hPy|Or)JD)S#%`KF@BVPyY;#AMg0El9o6?HOu@sZF*oTwE*XM zKmU&^nMgge)4v_#JO8sAx}BiDA^>A4>5kq1~Qe;bm zlC;@I_B~OuMZ{q2S!e7!GxHwb_x%^%-@d=jJ@=gFoM-!dp6A{>si*;AZ#sGh^Q4#* ziL*G`Q{4A>JTQ=@TOOLBH0%}w2iI;E(4L+Yh%^T-bU_2{ zyo)@P zF7}&*9e_8vsDrf4-(_+fj17-YdcY#mu9X(w`$8UvPszJ>LARbmjo}L>9t|tq%anq# z;9w8ihW7SN<`yR_7?UhR&7_9S`M;)5t(;#=imx3#^sKP*%HEJM_!uQF@q$fy$Zl9Q z#aV8^g!OXI(qQ(8F#S~L7RH*S#M^k zu%x{^SB7UhApwRIm{vQHIC;MDM110gG-OcR;Y-aPn0?yr%zC$Ps(pLq;5>-7WvS|u zKv(&)8N6V#DNrNbFX1-$L20P&mN2JdO|2RFtc$ZT8&{vztLXkIoD;8Lvcxp~MUd}B zzH|j{8PnG8i|!|2%hmSW25#1q3n56APuh9Y>c?+{R6K+nn2@7N|dNQ!EQ0^A0ivr zO7ES`7&A?}oKrLWLdxqFG!t36oNtT}$FT0f$Emu}mCuf~NVNFB!MS6pxgZ@zFsSQf zcAr)IRY%``8$%oxp|bY>$r&SOz%UaiM?{?Ca2SQUGNNz!tMDK4 z?okNo1+@F{-N~n>&TB`c|9%3qsi3$=X>Uri4+e8VtSuwiaCEyD#XdQW*Nl+$E=x|y zwV>V7i#?46kN844CPu&-^tTU2F~J+Fe*KaR?AR8IBF}&PEJ)Sdewxx}jO^)Y)QWeZ z0$oIsq*pKv+Z{eRrn9y&<>NMR@2XlzbLw5b52@~pdkbTa;-182rpB7jlp62i<*B=v z@a1L7OfKR=NC6O{xfP9RE6YKOcUs`RHxVrwKz0l9r_nc2LH+slL+VtE01^=K>+5H6p_)GitOV(qjeK2iw6>P)IFFo?3Je;U>?ZI{!(yjD7dS|%+BNG{834HOs3|jh81d<-@JttdRm5A z$(8GLUo&jj|L$+~k;}jTyi>Y{xQ(5cY4^0-03EY}Zzw$yMGs9aZTdNMtnF;mrlu$k z_giO+$n5cN6*ak}uCVm1^`M9O!uu|=Tw+81djNN#AK8!DaN9DL?H=%Vcx$O#1fDs;ox4Rb?I z^zW>#bpE#8bE)~jCK0jFxEB?tRPlRf9K0pQBN{gSZMPHeLtq{#Xa3P%NLN`^`{K(f zKj}Ck(i`S$D`Jt|t(5#u85cHgxwPNshYOv2acEHag+h#sQw9A-me0OEa$xb6Vu4i0 z13DhJ{a+}eIc8B84-xO#AXgQZ4>G4`Bb?>0ce}H-E9~VxyNxCKG|r~C@l381ZfDzQ z_U?^HXTKU*&7oW>DOXPS-4lAO#Pb$7eaCB5cHax0Mpft4rgtHKY*RgEgtDnSRk%2E zUKL&vY7AyYeZBXNh-bKFFKJc?eLk{`GAaJE-Su!~Y=jnvq5UkABZA$8}0ic&Sloe~M?qL*GQzs0LR%FhlSk!eE5p%2g0>)alAm2I{q z;)bmjQ|#Lds%r_3q~PZ0doVAn^FzLaYmeStk<%PC@sKA-%%g4I&d07V$^7*3SQkRl<&m z*`@zZB@boxG)N?Yx-6bXTrTp7Hr6wA%K560{XF9JK=m2pNaBI0RXt&lipN z8o=*yKpaWsqQS?S*nfwBV~QSVc(6Lx)^!*^X0wU%HK}QfoVlO1q6qKd1{z!sBpfab zb)6INZTx(cta9%d8fO)N6j(RzOgva%aHJ;B)*tM8#4FXvkyBz~W@YvJ$jgr<_EXn2D-Fr|@3?sCl@O{E?6tzKO2*7OAARZnP z$6r>%OMds6&kDT!Ms62g?e&)P+gXbo=2uVpy-{YSMGrldFDSpT(J)F&!-P*5XNQ)L z!DnwZvMtt`v5iJgs6ADq3J8}5s8O90Z!^1l8-X`nCp8XM06QWI&(P+05ZdcILlXl2 z&IK6ni4~en`mNk&w0?`!#T{&2JP|?~(?hl2M{Z9s(ApR4_wr^NRtW1@ZLzt@kO4(x zB_B$p^Q_P&Ly#;?JPS=<`HT??uHwRqJ{(>M^LtI3Lg-R{sw;>NjcLiejTf%#_wo^{ z%m7NT^Lqa=0aIy~OU7iHmcamF%Y4;_0obY=P@I75hu&oGZGUPnn-5t16YLmUd*`#r zs$#?jtuGaV)C0DtRc1lm4|mtQsV&)#GBL~-q&K2{>gGI;S3N&c-@S21t)cJf=FcW- z9N2*+m?K6$ag3V9bBKEavO^YfW*_@*W78`3*6%L=T(M7Z$5-~M{NvfO)W#jDnCVY} zpo;0Aab1Stn~}^7=v4%5e7-Y*+C;fBR{xg=8n4!14PZNl4Vw^ytta4(b5!V|5j4qZ>n(gR^tKmzb;asbjXdPj@3o{PLgD-I3e+-JBxNgS*_#!a(gORH$yk2(w>Db767>nCtCv6kfAEJh z2kNw~o2VaW#t-^&`>)89GaRh|C1g6@dFOuooA@>UyR-oup#JW@Z&vFa>-P`2d`q{^ zeu1Cevn4U6*A2aOetB;ETzr4MrPqz$@9VR&Lsy{xD+))DOnH zn=xq>OzU?Y-`qmxrgXs7UY&*e$u!MTi$;_ys@JJ(%#$EMF^B)Fm#)9R$(9|8_SR4RU1h!m$-VJ`5NEbK?unRX)p%ia8-+>4!{B%Rx;KRy5XO%Um{XhS`w)E$T<@Y zV~n8a>YY7!`K{)>jaZ_y2uc7YnLsAZaRw;ex}4xvP@cg`pW zJor>6*lLcSu3=SYfspul!T~5R?Yb;q*jgEYQ~CuI>_VLPLuvCV1=Jxlrh48uMJdJI zIYccxt8;4b@9C#Szpve9zPG~xKO)HXK;i)a5o4fMhR{Ki0WVk?R4<|uuYBvao4?@y zJx$gnzvmjL5SdE0Q2mqHn#*qV67Zfkv7cs9!s1c5LWdoKllf?4UH)}CJ_TL{X*d{FK7G`M2}6aZ->WjL48(V8>X zsXxq=2^%J-q}OCjl6n?&H{IHUSRuvw!tEnjzXfY|h_>{FW7R_$T`^I&16=w}ek~&;!svV*b5jD}8x%ZGvk#@u+DKM`; zY!g}`>ZZ0R2kds(jV^Pl5A53t*O3b%1L#!;JSEAdLm9t%JE}#!#qCuo$(B-3BP?!q zz48Tnknep*-Rpj^$flgZp}D)h8x?B%(bSxw*r^W$(_!4#{+=<;O>QA0?b#i8kqH#R zn*2oti|`xSYEadbevaszzH*#=hqDkH>0rtG;k^o5!Io#wn0Nln?nNC*xoX-2z_(4( zU8@M8Xyu{BW03gePK(|CoJ+)k(m?-r_^~u{o!l^rc&%QNw zZ8?3o{sD#(QGI3P!rPlf#D&L)kDIr|Cp?#Svuv^gsp7Pxp)E5nRBy>Lv6mbv<9+!? z-}14F3ML}1Y0F~F-i2B2vNgZ@E2Gh?uR39Vdk!2UpEhFWvul}v(@`;k%WYFa^(6CF#8FEbv0>G zcXE2~3Fhk@=gd0f7N_NiB?&l`GR2Ioh#I5QfOW`c%v>UkrGew zf{KHa6>pTMh6qPY?lh7;zGpp(s2f1>ym#Yr%eMASLCRX_DaFTJVawb)-*fr)H>b*t z&h2@i1S#&?d&gT)B1tk;tL^?_J$`YVPxCh_moUBn`y`*u>eQD$D>W_Ncm@beQ}FZ_szc7r`y93n*pmhssbVDOwkTs$e*BiJi{F7zm*NYLY#HnefGymSRD z;bn9+F(?F4m`E$|sTKJ^9q5L9So4;<`$OJ>QnEvYA)Buoc=g6k0G zFMz6-EHs$AaVww1{ek!J*Q{R#-E-k)x-_+huZ29a`Dq%+WLbT~&kBe% z64OH%b0OnD=Xe8|urSwr%_SzLbou#@rdLPy+b{4yISNU;t^U3q8zLY?a&^16JXYGT8Ta{RLu`Lky^xSe5y&OL9-KFm_~)v5-}mUBt0njSZ))os);7TLI*8K z+TT3}55g|if%66-45wm$e;LjU5aLjqDT1sWd_{t*G5zR`kdRMcrgLB>X_e#+&(E0f z(!-gK5aLWn@Oo3wg9n#R<{1t(%hgT97>GrzatHNZUgu;63B%^-fN-RO;a&vPik%VV zh(4^x+8Cj~{L%6|&DB_WHL*;U=Fd|5Gp9Z&x1hv#Z2pv{&R}YW3@(MLvm6ssML4U-k)Xc0-n#jRaEHH163;y*aUTN|l zOKrJpmGzyiefXU;dzQw>Miw@0H$e}1ToTBU3d`4QCO7$aMmj~<@*&&^ZKj}Y0&rU6 zSyu7sAjGnEVA))?xZrqMw04&PGp4bX=f zq1t1Mq){DW=(G(X8dNVyOY^_)O~GBRfsG-~WeP}ZQasdtDKI*jxaDiM**T#3Rz88d z%JU3uZUp_tj3UJ%(tz6Au6+gqx-)_vSW+@>V#`0cz!n^o3oNVirVB<85X54E+&34u zO_sGE<_)UwvWQiq?oJYwL5a2KIs|Qb2en?=ej|4UlzYxVl57dE8V@Ph`|xw%11(O= zdi=B7WcP^rrksmm(Sa(G1kjrRjxq9LG1GzQXCEJP4%7<7e=TKjT-Vpl5Tu9v^Cv&b z>namKz4v<*j-H!nfWyg_jLDzp6P?f#FIv7}drzh8D zRuWxA^;HBqDbE0$>|Ky7!VWdtJzZ{n3slFgj=u0X+KF zWu3nYHAe>}zy%MWn4XjD@EVxlhxe{;UJzBy(R{^0;2dbc0_%SKWlBRI9&0waOFJFh zR4D<(7VA|uYbh3VSQPGzrI%kC!``>h7q? z046}N8`DhzNfj%3d7fz2;u5X(En+oIUIoZ~qxc{nv%F=LIrl~@}ps>KV ze!Y}e(-#R6t&w7Y^5XQtgF4zE&oPT|(?K%U+S#+)om;2sEHw(REH3xO zrfDJTgJs)A)0e{+y-Sp3YWQ;u^d#Drajc#1NP6Q);AK`U36&_?o$Vf<=7exU8C1p< zgePUqKW_o1fd!V_YVg0t>Qjd|rju33>crQ0=uH;F7eJ(FH4<4H=-kR064YLPr8u7R zuD!-G%=*9#REZSPcr)DZYkPf8B z?wU$V$wOfON_^d}yOaQ;VIe=!8HfV7Gz#5qGE%-qM+XVIyh%|xmA;r|D_p3>&rSk} z)6L`9K_q}nPPOD`Obgj#bC}^0CnvbbZgd(5-z5TXH!e$iYw)v=yypU5A!+>V;F~cp z6<5KNY`&6p0X|o{UqJr}E8Sg>pXG7XzAifIbo;xRHMP-hS;>$-VjyQ;)K?FJq}n`< z4~M>2p)_}bBYQ{C;pWoxqQ%{>V1(euv80Y$=RnVdLl9Kh6kjCm8jP6MBs$2IGr0}W zKaH+Tc{UXO%7_r%c!<-C_=Iw8=>eADR9Jdx&xXXimb}1a{oP`yI9!M@hoS{)CvRpC z{pMg)<-?LTZsBvt%!feIq}H|M1j3CNF!1C$`63dIlJ4yC+>aav>ByW*$cWlFGG0|c zEzJaLiX^$_W#Cl6eVGt|gDm0zV3EIWf4Auy;9xZ8$7^Wm@O94s{qh(=kgQ}z`;>ae zc9xx}!+feeo4?p3L0e1bUlM#!nGQg>=Pkg?qT+{5ne_X=i))RVTG)c7PcpEKPxE_i zq;+m`({}e(_OJ+!eG3LjS;m4818^=$Gv_oTlr!0z zOAc08)+uqgv(a97r1CCGtJ4&LEl7tW&RMw=Dy;0y{`Bpv>)H89*#l#fQo|`T9G54jRpc1T9ejS0$ym z6YgJhV}&k~VV?_J-}HofyZArIC)q@#U7cCmxFtQUMxB7b<+E-mG+ML1cTTs=ArR|k z$#7Xbu{7CiwYEKMq7hsVlMHxpPI(N2E~SwR@IH|?#%Y=JZKk|fJX@p<7Zec^uOIT3 zr&~P)k->9RQqX?+{=h9y+vklFtD8(*Ex50%Ktq#gT1MyFd8Z%84TE%rch`gYPJ7T$ZRzB=|Ead+`L2S?BM4-tOBwjDP2mmsU-( zaHPkn=XH@nbl`CyJ-N2!%#(KWTiK&BXePHb2EOA$MrxY(T)H@4nm|ynTbtdfZucmG zAf{RMC-tdfgEh}%rmt$*-@RfJ?!?Xlp0|*FtoX{b^7ou>1)-t%pA$4v$fZ|10sxx6 zP#Um{C?JMSQqPCqu2ucI4N8^MYfhEsZF&*_4j&^^6&=7Iq1bW@q0rlbAzz81XqVtB zTl9Iwzmpkv0-Iyzsfwu&-RJ?f-5oDgZZT-s?ub#kv_b)CO=t93eknko4#3F$hYf<# zM%4j)BkR_j55y>vj^b3bMxS}G??$4e0fxJwojF^&yJF+V%kWp{SWf+<=_`@D(gCSd z#S>tCEp8A%I!OL?W4c9WVuwmLutp%ZlcIvXE6#Xss5rS)7M_V+h|llcu#(Q|S>#MM z1b^J3UV~tmi4mqWt=mS$GsWDz-e#92;?-gX<^&ujB8)$%cc&vTe- z^W3$fXl#Vt_Sxj~#C+r4g%bDFQ87stnVXOC)t-T-j^@@H1d`}}7brqP^y@22XbvoXnmzCR@I7aIF_ExtKkVGWDXYhB|t&q3MAz~}v&?WPylsK9b&eB>8eu}I_19%LzHV2(z z;kAuwY9Tkp4__JMq1$r2I^}vHQ6O{mr7G0-5*`kCfK8j{y}8w{@n2mllU*y`MKUnD zC9BvZg0_PvsP|t@*6jo4ub8U$vSYchpQ%NL)ow}X)UVO+W(3c6%%#tqhi0cZn)F;= zExE;g=p+YxnO$Lt{UrA||6{bK&|KAT|1kIp_-~^|SC$8_zmzBSTcC8n92^6oZUQZX zCr*;<>X-ba4g>KSI>_l;tv0J2nPVWtAoj1Fb_%GOoXf_>+}B+Z0-843B*K0Ra}=SO zEo)lJ9F(|@cuR2P2Go^WYiBMlGy8%vyW*-IzhgtQs_fgIvfGwuz}Z^L*=cE;DnMfdu>&DK7pbzc*O+IwJ51xoIw=(Z;WGrlfH?USB&|@=$+1OUz3ECm^rC z$#~ak@pVY{$y=*s&cgfgW%U8g$&!HdyH~X3Clg4_Tk&8RO8l`RuH_G?1GVJHs2M0Z zZ8Y~xDDl3n#69~<`SUA|~ z(}bRWPk?#D+9sFaOfCMUx+5fk%%}->KNaN9sYop@z1UZ%kv2WKI4$cD)3<{c{u7TP zmXjavX#HR^c;d;+pvK%>aH*tU)R*(^Xz}#XAGSXydro1rjupVZ8fa03LL*ki4`Yj- z$}7D;ZB*X_*BGU~rA-HfVvBM%b?&jh!M$LFd%)qJo9 z`}NH;vEdC$^aEhc5mCI@-zE;5YeT22k2A$w=@B)E`XI<2gt>V+>a8GukN<|h^G5sW zYN}T5*g+OXXK&Q8bt2T3Y&fN=FL;0IvFEl+?4->1)wx+C6`Oz3tDD(RpLFQ`-=F_`CjT{v|7UNY=~kH*Tm`nUjW&jM2apC_-@G!YO?Dq zDY}T0P+u=UI_7o*Sru+-p~L5gI#eH72dSlizj1MdH~fPumUem_-fnIk?P z-)_{L>foejrhCi4&d61khD*~i;DGhElq&zYS>V>9fEjXdxLn4(l6 zps3s$GxWiBTz<90uAlBM$gc5pXo~3x4yQW#pzwm(bx0qn7JM7ZkF6 zG}$9CPtv@Qbd;G3zs#F`nfdq%DhgMsbwC+*zRHyEV9Ro>b#}QFx-ycPdB^+m%)GdD z4o}?=ipE@G*qf(H0G5zS!$R_GkQWXY9i&wm7U%|>=&nD65h%iNvuDR$ryCo=e<3li zwOV~RRxFbH{3+_6lKMkmdU6IZ^^khgNgR%mPwr~^nbU!21h4QcsW0=spZ~Rj=fRkN zf)|4qCx=XAKUVfu$j;)PJ$r~M8*C8-QIe_85#_2TBvr)J+M2Ai0wxW0TP3Q{o7Pir zllDi2;w|kRPS2mzwenoQ*Z%iY9waH1g~Y+VIdF?46JrB<7M7hcOiiPMh|6ZMq!49^ zm*;d&03T;H-Q7SnZ7*uBk1>w$O#pH=H}`j-~~*g-Fy6& zgA<6I4Q`>aMLOB<&F7*rl3s0^vTzYMGodV3<7!Sc#i*mchF|m$WN)IL$b5fZk}8&xh0%ty z)U*v+p-U6)A>K)yMqZ#1bEIc}FqZ!$-$1KuEU`!vK? z3+otJq?bBANiu^oE=h;}Pk3v?jPp3Bb2;|ROEEL7U^+lvuw*1WVNrIbyUWAa)qiso z6xi5I?w3{EvAIF|V}G;nTwH73%Lo8*yDfoR(cUj$>SB9}J(w?YX#LkCHAhxeByg)NGb9Wk&h-8>0y4qHS7ON73jy z59xgl;=i6AWL3Iq6uZy~ecx{JkGwG)#-T#k*? z+@Y#E*4P_zO$NY++N#hFr~46PZ=@D3cvjCxXBbOAWV1tym@CO^b!ZGLX$am)Kevh1 z$Prkwre?K67mf9;O0Aiysmz!~gX3M|t;tk*pSD=IOcmyrX>WWON^eLjfgjR@o=sQc z(v+;uPUARIa1uA+iE7;*L86NKbfjA;WqPZ+%yy7M5nUP=xbR~*;dbOOh%G{D%e!%P zg)&}a*+;$mtHP9<8Jw_N&aZcMrwLy^B5uHvZ(|Mw z0>@AlD%xyh6P|U}l3?!CSVB6xX#*D27)+Cus6c7OECD_DnJHWo8m=@LcsWB4`?uEF zaM@CGw(U$I&pAR@`H|(;cCQ}!?>jC@YB1=&-+JO0McM(IrNpm7p4#jf@63PDO6x}X zk)QYlRR@adQ_A!QmkpcPrSA&tB00-6LK{&eMAN}dFb$k=LM7}=l{kJ$(FN%d&q8Ch zD}Vl8P91p_%br3%`Fw*L*t6P_dcP!CC-NsB9<)?lprHV$arM`yI9Hc-At<_l_wGQN{_( z7mvtdM;5E|`5&A$Dh%3gr&%bPs*D+%Xe34hK>OFsdu^vjo$X$fcXM*TnZ^ro@?k!6 z-Ee(ctVFj_c7XqgnUBN-qCQ_!g*zHh0x@H>CP}!xUw*bbplI-Z+U`>6yVtUEd~bgc zc5ml@(6XBJ7z&GkcaDwrC0GEnC8g2Xq%$clce z;XLVgl>hn=N8xiu_aXA@@0`YeU(zJux;o0;Qq8|@`V3uu^4+l-$T2MUdS9MUynL{( z@tRplCH}|9!W?|5Dq`X4jp3dIka}#s{*y6xsmx&s(15(lM8#{)Y4>a6I2u2b0gIhV zt5*;7@Z*ypl53#DYvtJpg7oE0#kwNrd1uNE|t zy_mvZI!!gAp|a65PYj3=wmxE~lEDNhya%C$iV}A!Ma{xq3So=LLsW)tyL%eQLYJTqfB%-wG?wy=mKH@1;h|6O&%rN+($Fy!E!EJJt6~BzBLzCM8%a1KG=CX zgbW8vBtNJ7u>$(0ck)Q8>JLAs`&WSIJgfQg7y!Ux5^5Akl2#M>3Z<3xC_ zBWo{fHhqE&T3PWBJr}bLljffd3d(v05GJs;a#bNRxry^P)fBt^R;Ezc|6H z=0+N(CLa-7EWQYUwUN;6t5s$(_b?4_Hs8KohkAY;O3|@FOADd|sabKz2*0X9=YzmB s!{wMN=2@o;Jqlij|EpC0x1c>?693a>j^n0}xl-VO0Sp1H(s7FZ4{z13WdHyG literal 0 HcmV?d00001 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..94b1b2572af8526baae27ac570692f5cc853debf GIT binary patch literal 386 zcmV-|0e$|7P)@5XN^l*$u}M|27i5LO4t#7Gi1TJNN>=+daVbwhA_akV5dV6jUUdD4N8~9x+CZ zm&Rp_nPq;%_w9@-r6lhh^{wg@fGDTqwKdr~wkLFEHcRbJg)y29Aj+Tb-^FYnr{_~R z8Y~yHU9WF)P00YJWH#`EWUR?|VH zDZsJ4IA|E+v#32AyVyQKm(_D|iyI(xc0PrI(`tb~3jl~Nx&bYzN0MKguhf3f|m^oKV1P-x=qbv0N`C+MOZJXiCt+?{^~K&7mnLnpvPKxCwZCNs$fu3rgC gp2vUk|Gd4efAr~ZXkAUKWB>pF07*qoM6N<$f=9lqZvX%Q literal 0 HcmV?d00001 diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000000000000000000000000000000000000..b08d82ae9f5ce768eea6ba7802859d7c03949345 GIT binary patch literal 6352 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 WUqX$KWsg8P0F@^iifE)+;Qs?000H7Nkl)C7 zwWYV!t*i=}EJ+IO*CHRm6$ z-nus{N+`axU;*oA(j^`aD>yA7S%5r(fa6?`n2u*y zUVYEmZaJmbTR;Cg9%l*~fyGjdC0g|n3W1&#^o*F65XH)Emo&c^Zqr7o=w6jZ;QiZb zSaA?=lR0TBC-EpaTJZ=BBh)BWwY-9L1*%bK_!8KuTi>X8PA`UJbV1iNF`RSUGaRL9 zTgweJD&v9zU^u_zOW@0=W+*{=MmRUF!jRK*9M^6gSgBiSdAqhSod$e}T*H^Zx6M6v zDU-*f(_?Zpr|#IE=aDXRVmQ`HLzqbefpr;u2{g7XMiF{Oh^&1Vj_nEG4Ud?vwYnqB z4grQ;L?h5{IV|IcWOQy^jTulq?{9&Uv|M{_m!C?pi1j5vyx@r;;4bJ|1iSBg;- z<8dWG4Zj!JAm#L|z!I$j0RV?ke4IJ#drg}@oW}nA z#SuRON&S9FK|Iyn5z`|s0X~}~fI#7drcb8IYkiU;nZe1jAVW2eE{Np<+27sM2Vc3S zKf1r%dkU3^|B+QJQ8&N=99lseFn+9{&RrTUzN-B2Jihru$Q>I=AdCe6fXi148|%9p z>&D@SZtSDM+^CEh(`M>d!2}S7H||V7ez0=5x;JfxRLLL1BO-fqj$R%xbp#ZRzj=3N z{^4r1a!{igKJkGC!#Od{9t0ua;uGkkMrq;Jz1fBNwGT^mj^pZV$@Y)Rz>ejFYK%fP zAmD`P$%qoVetYWuo7#(o@^;IyT`!a`l2v?cL}Io3?||u`oHCR+0KnO~+^I8}H-Bt3 z%f>KUG?NsR$%;%Kvj;FJP(%?bO8BQ=#@(jfC{_0ir`>X$o;?jfXT>B1s~S&_wG_ZK z4}(H}^tK*K@mgL{_Hz}_Bi-7ZBc195VSqX*X6(joKA;$gd&q}%#aeFc?@k~jQJNhF z%m|R7V;746RCzQiyOnVLFD4+sA7?sHi+jw93yQaE6C;XOrGTH*7+XVe2>=L$>9n=d zAZC;{a9`jF6){J;1WxJ!4~3Z_YqddI32?v@Djt?@Cs1kmO|#007QOC?mLAZD*^+NCT#M zoL5*pW?v+KTLoZ<#}zatdpnj_x4foJdQ(>jpo%+zHY*0kS~~uz>o%QuoDsVQO$z@TwNV9+=sFlhV + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..4799662 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = pawmate + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.pawmate + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock index ed34566..9d32b8f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" + url: "https://pub.dev" + source: hosted + version: "4.3.0" args: dependency: transitive description: @@ -232,14 +240,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + icons_launcher: + dependency: "direct dev" + description: + name: icons_launcher + sha256: b42b2f9b10e58d6a973f71293f00a1f0572595e5e50d676c53048e464f78cb7d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image: + dependency: transitive + description: + name: image + sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 + url: "https://pub.dev" + source: hosted + version: "4.10.1" jni: dependency: transitive description: name: jni - sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.1.0" jni_flutter: dependency: transitive description: @@ -408,6 +432,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" pub_semver: dependency: transitive description: @@ -485,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" vector_math: dependency: transitive description: @@ -497,10 +537,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "15.3.0" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ed0610c..70b4dee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + icons_launcher: ^3.1.0 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -63,6 +64,22 @@ flutter: # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg + +icons_launcher: + image_path: assets/pawmate.png + platforms: + android: + enable: true + ios: + enable: true + web: + enable: true + linux: + enable: true + macos: + enable: true + windows: + enable: true # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see diff --git a/snap/gui/pawmate.desktop b/snap/gui/pawmate.desktop new file mode 100644 index 0000000..424c5e3 --- /dev/null +++ b/snap/gui/pawmate.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=pawmate +Comment=Generated by Icons Launcher +Exec=pawmate +Icon=${SNAP}/meta/gui/pawmate.png +Terminal=false +Type=Application +Categories=Entertainment; diff --git a/snap/gui/pawmate.png b/snap/gui/pawmate.png new file mode 100644 index 0000000000000000000000000000000000000000..b08d82ae9f5ce768eea6ba7802859d7c03949345 GIT binary patch literal 6352 zcmd5>fQ|qF01&CED(L|Lz=tjnfDd|TCSY;`007M= zHKiB&zBvcE*B%W0UWk&`t=X*Hc0{ilEFW-8W>@j+i?t~$XUzWW#|^~>J32Nf9JsG_ zso7a`9$Vg(E7p28?0MOCX>uX04Fc#U8{+{0JU}3z;Q!8Cw2N$O?Eh;{_h)gSJTq*z zHB$F(-<{s8ws}2MkS2OhXcIH=#;~@S^c8z#nbIrP44ri~k+~|bqNkB%wX&3OZh3?@ z2W4e%^XZz|^a0$>^IxV;53m-ALo%3`%lTt+wuHo=?aiEhR=VbI)_-*>(|Ok)xErxGuIE$5rn zm$120%e~3%H2X3+!#nO?fnL}>4cPi5@bZ>8Gb*8ocq#p34}T0J2)Z)sx%;(&1Y7HF z*RrD9*R8WVJWR1=|Ixu>gfOx9sIBA{^)0Ou-e(mP1p`%9O)g;;Pos`(|1M>bN1x0Rc>NFJ)xFZomqjeYl^$~%$4U=NY1CGWx^j89?O1bH&U+Vk7aVi z-9oVUQr4AE$wE;CNm+bte)g-o?_#`|rGBN_vz-a0UI#th++w0*piQhJ=J*$`gXnD@ zZ#Js2xBpm)Ge>SzlNNlkOntdb8nB{qi+l08@+XwHfGj_$ZX_=c8R(ks#q4Zz=zdZn zmo0r)HW@}j(wj=R_g+C&75v`(sHeGTxxK@2zH-J#vaA27=9TuQ+^rD~`{B`3!AgpT zRDS$gaU)60-Tn}oZckn^+GfH78{oCJnEpI?y=#R(F8c4wTF86Go6j>;d^zT)O_u-O zKH{GFZ}i^x^cMzm8Dymssq#V!zZSqfQ&E$XV{B3Wo%olLw6bk^hG^Xr=U1uyAg$qYETG|()n!<*~uK7waYo@RY^ zX=lrxx;l;NW|yBZ%KKFpnB&tvH8Rg6hr5nAoO-D6L_ay!n%cydL|t9CXR73H5#f2WPgL1)l7b`Ny}u6J_GqtaJ#GzDBe6vxGU8 zfE5utF$-l5CFUw|y(8wjHO-@#e``jaBBI{HgOVA{qyQjaaP7s=AT5&HC^UM{_c_LM z_aQK*dwuG?Hz1rNkl7s^y^$*PXU`}UR5Z>lhfID3WpE^|hGgaqw7Z`Qnqj1RzK%Z+ z2{z6Uh>I}!U0>=MW-LYmPsVU6aaJihz*j4|JhsgZxtF63cu}tg z=-YW2ptbJpH-9x~r**?qA5MypP`Ne0N+D3F4Sz-aJUvyYXY;`-RG86aVU0{6GWWC{k(9`@M^7=P$`ol5E21p>W z*I4uXd;AOG;M<$EvDVggwb_c7*3Y}>JF}zGA0yq~`CpvhJxxsfP8|N|4Z#DTZ>})M z=O=`))T1h<&Khd_pK%cJfE%u#UmuOk8P&*MV18D9g_8&Yt2HS%`S4o9{PMFtbJ`a_ z`f2m!NUlp0(Ky0WD=9o$3RV^&Gx%1$7OnY&Ko4q&p8|bCWA&^6Eq69u@z{LS!)(-B z=68`uf5n{3)EB$hjJ@g&m(J?D=`08g5f3=4>#Z;f{o$kGfU-@S+(={T_mW^@G5N2I zKsALw*%tct^Buw~ipoi=V0W{8#H6=uxw_sl_=hK2-EBDobc4NabWY!sE^)L3@qx9CbnsQy{~uYLj%&0LBMvTi9P;JUkWaa5Em@`@K> zGeLr7Rv(NlSWD(}<|r$>V`X_7?r-g+t(pEY!K>)m<&L<*8@ygXC#30o%p|vSv3y=@ zD5-xuc`0)|<|OE|TYHb@m|68|IM?Ogn_9k!;77-+QrxnS#~fO|o_1TbOVJhnF4eF7 zlc{i)k=~fZ`8y0ZCN_ohUj<;}OKuk5m7kKF+V($HWHK?FB*moHi}5cH)tP!i(6^O7 z3P0or9A7@EULzo1P(9U98#X#L;hZ)MDgwaCd&Ti7C;qyi-qAGGFGnxEfMdKzTI|>e zK{9hEokPN`M)yjkFTjfy4?XUx$2s4~raB=~k=OVS7X~Ut}ixPin9^ld!@C$FTumIbljylW1AUwhzs}a3!WUZ-!ty)JxywwG)n1 z^gO)JRkLR!92`|tL(js6x1Zk6>s7tO2KQC8Osh!P{d%^OWUGDkOuh`hVYqwGP@%~D zY*7SG=o9fTs*|$6Sd2=su_twZj*NHiw3aq>HAx!;ZeOlaN5zl|*@eHSYDzX4j?d<` zb8ecGtZLcyW#C+}^V`Qqg5PA59N#3CB?qWzDiWQ!`=8?#DYn zq79b)V(Dh;_%_qO(Wp5w#+3cMVv#ShHR4Vn!E<_!aofnlu$>VG%N0~_wRrZ&lgkX| z@u~Z$7IOO%U$JyKiTdV}K-z9T&r-48jPwWO+15Pwz#9R|f$#d7^O!m0!A9U_(cL|;AvhIZ7U58j)Nm}7Wbs*A=?3up=x8Kc)-+l z6d?O$xn0LDFyR8y@yRr*D_6rZKSjn;SLOO|QXe(Yv!hUZ*Or+@{c=6m{qyGggRtu9 zx0c|uNy&KP$fop{Nb>a;TQ2{#|u>l zSE72yR!A%ogLemG?wjykhy4j^R64wK(Hc=u<^j4Kv<`nvWAQl=25i8Lcdk}Y+!FPU z9(p31QGh`gi;qhEEA2<#10DL`#5D{I7=SHTP5XqDm!~I1XK`O{SOCMO^_!=)&x^Dx zDB~ zUVfHoe%=ok?3_rDDqpU;+}fzMF?4q;*tc56gX-+1i7XV=iFA{^f{AhKN`JkU;_#^x z(FN1kav{`p#McXX;^Nqsq>$)?_hLx`yB`%+e_tpO;x3c% z{>{@MZAC-nAkj8)cgtMj&O`k7Zy~u6fRvd`D9oFC@;nKrMxsN#pvtyqIlg1K?+xmkE4MdchSqk~37_j2_Q$6ch|pbAd|Yw!Td< zDj}p+43ar0EqhScHJaC#>f&|-N!U8xmKS%HfJJ;>0eZ;w0p{EKVlY8cc(p4p%&uKi z^LGLVh@nlWXoq&0u9#gOanMC0zxfQFq_GqHPqut7}>7z8< zlM~Bd!)I0-UqtR)V@ziXN3jlUbaz|V z69(51&Q-cHeg>bE$(XwiIV|Y5yqoDwU{d&GglXf5Ib5KPW&Bx8>;w5f&Aj z=>CIWb;j!ws-eXG?)$Q6w@rOA!|$4v56_7)=PKm6w}F%y2vPQ4m6~jN-+<-(0Haz8 zI6UNhprn;;+4KOl#a;QSv6+=FHHZ(p$n;W*Q!p4`_UaflsZ_0aTROyect)oKOVL{l zT)d~(1{k%m%b)T7alrboEL0dtoveb#S6o!SXq=T`S4ZIW5wP30sF>SZwk31(W<%#& zcAxiksa;=6Gm#Bgxu2sv*F?3{2-sbbE5p@Xh*mv@9&wAUD!ld7I{P_ihGoD<%7~X}|C;-V8Y1j&^PG23{+^$pG|u$S zVSH(hGb%tR&X7l(4BieGY)|5Bs0hhe_ouVm>c>*zx}xgIYk*o*3FbZ z$~s+=8?rxliQuls-#OekY?yI|r#1HEKKNi6VttqOSc}3vz?s^nInJW`eHhPIi;<}N z5b)qJ`0C;18o1tyhs2!Nw*M%hn9JrpCkNP+`JmwQM}=PD+m9wCcVsbR?ET2wL1hOL zwNR0$S05!vm?xWVf)-EgT$x{bcxpi$_jlY?_PMeA7vn+`t%oJ1cKWuzaAYu&DpMA` z2%7XGHwf}!M%TW; zP1d!{pAK{ZeE?J?9Zn#xy2q%ISr!BJe|0#F?4~`(#U2vMhzk3@6k^KXdf4`YEdM%T z|J%`H$usxj-oS!}kL!MwWJH**IPS&vWcjweMbMu^PDXxUb~9PX+4hHStFxKsc0Y!5 ztEDV@-4*AaQ53vPV8M9B%o7WzyDQ;gs>OfJpaE_ZxtgpC`x$ho2;`(=yy&g(^@Gu7QwpV9D?YqW$d6?gP zw_C(2$T1Za-1*byt{Dc$JszdGITi6<&-5RZcdbqil03f!Yi2))Vu^Ef z1RiUcf}kkz`QXJNY*`M#hC(EL`-U4dQ-IDkt!Oq}Y}ifv@%9G-swYzEq%@(b#BRN` z@2bqSvUc%22OwSye_dQOPXUF|(zA8wYHznp{wTGn!EX6huQ&4*O@7rsEo~G)r#7wkSAb z4-%%0XxiT1yNFguC;=*y z=yK=KX>s1Fgct6^oSP)eG&A{&5@1!xz;7kGPcj5djrxN$B{f29zRNt{b1}WNhqy$X z?}-o1EL`F$JVJ?MUxaP)(%^d$7m{a{h*^rUv;g8ipd9~X4|IRjQRzw%q+9nmo>1}x z(sd^DasMoqnRn{c&G$;PB0n?#+voMZ4TD)3$kA zf3E@ZN$&h3ZOdx02tWw#^hTe@inRp@;*FOwc_O|=z8nu`(^>8kP^OKYpMboF_cN=} z#;(gB=t4mYxGDNYW0~y*%}-DSLyQ7FFDj6XK+pnH%6Or`U>;|WM?Nd+W@@>&X>5xR z02H-b1cGe}4vZgPgAbRM!Qfef6#cP+a1jH9SHUZm_>FaV{H+)W^bRM1b2mBMtBkq( z_JgUslg+BQ{C~2IWGkp3oW1Yf!f#(ux6O-CkWo=M^U&hiwTB4FBQoR(3`tmXH0OpD zI+7;;oFzwn{op&(D#9#ZAHwf@+>YZ*b6fB27Ric8|l> z=+zzo1kcKdmzVmmEf$s6y@4K70>o~vlVoLV#qQ}5fB+E7WRq-*NI(Z!hed3Y#_LGm zefz10)kG2cUlRHpEq`UzgnvB}PT)M14QrIm%LRZP>ElVqR>8b)MikOBLd$`Ic7CXp z2TD-^0{;>wR5+OxvCbQpkLOJFbgn>hbT)!znv7*yalqU7v-*>6y%nt^=tnM_Rqz&jkyH-|B*GNhwu$L=qX&s6Vf4*LUy3#}N8 z+|0V&Al-fG^Z+0Y0{;nTm986`THdR-gT;3J72@@4s#>3!hy;5g;|OV0=mS!EhJ%IV z0e#Gn<)nh0Fe8ulmObOiI&67q#Iw1a>y%@pU6VOiqph&tw@hZv`Q$gh`Tl+rLMi3`Pa^*{z@pB9!gLN4 zrg%WZfpNG$>i#(%4GhzCF#=WN*Gh7IQ>$)h2*H;FI&6{yG#=7lp07-T*2(z^1HZN`M z6jua9xyKKVqVE(_&0jk8O)XcDR{(T)Fbd7$1tH)R0gJ@T9NVE2(={FX^wII{JB<|p zwVK_C>jeyzj95a_C8F(`rb*!*IGfn6Zkc;SQdMzL3iSd8Q1^Hoye6xV zAJz{(?V1*g#!GPb-!9qdPEm@-86gG`GfC%a6yzv*6D<3WQU;?3K%x> zQUU@@K8h$cU8g@ICAMKQfbu1|y*FGQuVlo4;Q7*Ez<6<$)0_F7I0jJdg7$m!WO=-t zW=AeqlFZm*YB+T20Lz&eL)C9JRpZCHb#@Q|mx<~B`a%s2KKN}>0B?ZOxx>k6d~NEWrgI+QZS8obX4|khM(jg zc8ayl^wUS3{yS?u7X*X}WY^CdOa+#3Q1^Zq88 za8W}3EkisYq!3b7?M_Hb86c1`sl;s1f`y9`H5{+^OF$zap^^5>o`gUEKx!(7!0Aro zEtIiG6J{Jq3OIn*7bA_gk4|Z{yEZOLq@o7LCOOcDcYGtD@3T&S=JaONaM+WOcfxU5 z3CQA=0ePW9KuWTgRa3{agQ@M+a7w+lTtrCak;X@f%F2K~V0pJx67gahs;~S{&o7{J epfH^Sh3Rh$`B}=h$Yh=X0000ZjFeDPluike7zoJ51WAd}jg*Qo z>2QeD%l9w%p7Wmfhx@sIxbJhG=UjI@*SV76hT7DW?34fifEuQwVRGxc|DTZqZ_ky_ z7jOW8ArYpbYWl2jujnF>Q!}c&8ne|Z)3Wr&+)7yti5d%e9`l7&hsdplGvlYQ_KK=W z$7NPVwbp z+0lR3I51~bJW7QJTsovGq^^#(w|V2y(xLl5^|9#vpodMF&%>T>jHH5!i}Fs= zD`Bc`w6K33)-+L(q?`+NR4diOMV-Bz+;JLlr0rt?pS+ZhlDj-T!KPn^ZH-YVB}%~S zJ+W6yuRHK`K&8Gf4TqCt*|RP?c$Vg#2WtNRnq2SytAldmXMWRX>k7EvcW6{wHZ{kX+{jW>~} zo6$>rx{|vnI6o(HKufnPEO17auVZ~lNnSwM4=Z;pf77Hm;GuQ*c~h;|$kWJxdKb^m z*C3M`I`QN1mY`IG9lMf-U1wwK7E7Nw0^xxSKx^PIdT#ra`}D5aEZ zgBRt{w$1DPtDVzVJRZ4vbBozBMx4o)-udOwEu-=NXZutjRR$zEa{h;ti`hP6_fzbi zgIA!0=UDOE>-~x_WwyQNv{t~y+=%@ds&{c@>EgYnYO;5n1$DB^gjK`L3LktD_g(Pq z4XCTaeDfDJh^XEFZ>-3CQSNbQz^{ntMV7DkOG(6#i4u3z8}I#zBTq;Za0!;GNQRLN zS?7I#lFPDrNDoI9*{4f2So_xQi_U#O%!@-FXNrKZ60q{Te?dJ zL4V0=LM4;NjdGl5kF8~}g?W9R;h%6OMRBOH?JHyY?G}~MsfVJ}5W4WGfM_j)durRy zwpMaMWzT=PmS@nN2&b*b=XXL~r`fKZGXFXz6C))vo*Z~tyl#q<8c8+KO#G$B$XdQo zU)Co0z#AelF)NYlUGQfia5aMv>VfXj$oHs6Y|tT`6W2%T^W;g3u)@w08nhVgM*yPhkw;gSEg{|Wmv;p>)+Y6NDYMM`#9}* zF5-j`T|{(&Q|%DZiJuRcv(3o@hT+u$(uHK(=-9=e11rkCI|aV8xun@}H`J%Jzg#cs zLk}2$WEW`!!T9h3YB#J^_&5L2je95y7DYy&lLZkBDLHT=6C6u+nw-|{L_A~~+Yb&E za}A6W#}k~HtQ!{Uq#h*koo|XW7vKh%!~37yt4O9j!&4+r!p5!S?tG0X#Lc1bI>e-k znO<2yoS_*7H;y(;Jv`_QktNzhzE64uGg>jfE}rjN!G8dgYVNr&1b^C~dg#6^Py{8r z;B_4z20{J3MRhr7Ip(u!#zgn~~tjAD)xgPO+aENxF$IYxry7r~?!-hq|; z2foCPT}gL^;M?a(YzArHvUfs6M-06GCHXy)&7qOk&@d_0eKguYAq}KG`#sq@SirYz zU5D*^Fj!op6X&?UqN)!W3^_nyIDGiG>lYa5!-zV#$MQeUzl?sj01{@vsEIt`7nsPE zi{KmMyV5fKu6*Xe0Sr4Tt7Xr)OIhnijFO0yair3v`k7;M2Z7^=ZC?%@VQ&Q3#yZL- z+!KKRhh@wXX8)ZAD>^5vsbgY;eJr_kNq8y+1mJ?UI}p0tvi=vPhrZX8u1{)%D#D`| zxK3L&d{^lPj@@dqUw=?zZmRZ@6zg?R>|s!3qf}zgG%Wp6$Z!a&{eDIiPvYfmxmrLY zt!AJPfzc@NquaaZB0O#YYxZ{=6vv+Tj{GC1L;y)TB{Wtu-VS>S05j)83Hn@{v)#UG z{3NmDpO#acy_v}4uQn3KwpCr(9XiJJ{aZW`Kq5d|rKz1XjO&n_>gUYu^ut=g&DCD3 z27(#SX|rSq&bl;E2zmU)lGChED3dY&>9;Sj%1^TYQAulK?(>uAZsh<7miw@gviU5N z*mbqO0$r9^K@mP7cmyQ-=2~CLPelZn86l2|eR9gl8`2#N5rQ|wp`nSfCdsU0QC2eU z&D39u5?pV;^iz|yrnfk>39k<2#vpaWBj4HBoH(xAE8G#nbvexLoA-DHPb;06!as8* z^q{Bm{4`(?RXPQ;ye3}k2-|6Yn)2`K?Ou5 zg0jCtXhQS^hT)w)jLbD}tfCsoRr!T;+LQRzbrpFYf^mWrITG%kz)2lHoIAp%dVKXX z)9MGP-P@j&m@QeveV##u_?0z{5)A?H>oyxER}#SYH62ue+-$BF{T zwK!~%Hu+2zru*Jx+_>mV6?yVGkG|4hUdjw;orvlCHeJ>?N^0=)BxBQ$5q|$}sYxY` zd!N)rHOb!Ao2FxgS(6RYa8bNoVfv z=~VqBPC9;k_A=c7f3a8D{6#>{Sfsr?ie4$Ia_4vp+&WB%D-#T9=czC-iF@hogLyid z2)|*?7gxR|8qwIO;AQO>c`D_g=JW0yV9jg<=~J$6XGN#CJV>uZ3QKM3@G(^2tH5}Y z9#np6D{4YT#&X4u4=>41J6SHqM|OFLCTW$_f%0f&O{S`FUv7>k9?iYUHngG2VtPwa z%R`O*xV_$xIL3XI$MVnclJL(wdXlozk=O?UYhp{e4w_H>RL#)*jq~YcI;qUw@1dJw^kBLrO@n0vcS4w~Mrn&RI20F57UK%z zVw|N(SDt>YKQy?;);>2LAw17TAjx7{m}GM8+-iXD*^b9U?dA`Y0&FAyp>8_cRzHTL zT!T?7MSQO_r}YV4u>&-8?(ej#{|(ovyBX?72Oz?Iy6}kCD`6i6k%Tdghkh`5U=8_1 zXJE^pyt^PHrDbe{{lB;hrxTgA6xm#kR~{JYh%3vNazX_bdANxf4|Kav8^|+L!ae$C z8p|?{iw+M6={Pm`fyXy@h>XBf=i(DhEE8}XWnzB1y_J%;*OVFLJStO>>yww&)vn@&dG_xW&18PIz7<3U zna^fVZii@I5PH#2vD3=a(bUMKVqqHv`ws@@y;Dl74maP9xtWXiklYk)wW zWo%Pr)szcQ-;93~m5}pd8liU1Hiso@nMg13FDc8uAf68BYf~Tx#FSd;e3kCw@~roq z&#e!~RXJwy_r{C=PV6()57Pr3UZxa7S6`gG@0b7qwi7Ek7zP%+(YQn>Ijl-9-q&x- zDn6$2S^Y0(5+PxPSbnEj4A4Vj-CDy4@-`>t?)OF!CL%yeiunN}tJB~>F5W74XQAPT z!{8S?UD402_nIvPsIf7OYJg<;9VEXxOly=hpe<>E3w|AZ3i6CpoYOgPxB&woy3ia4 ze@tRC8JwQ#eUL&HuuN`o=<7a$4#THKk20e5u4RQGJ7K;ht2WYjvt2Eg1%Wcanu4|& z1j3hNQRMR)Jy6iywu}u%LyWYTQF?*Q`M}mOd1Fwkh~xs7Ca)-6MyYJZ$A;BNIV>>| z<(-ST1^2TMUeS!uFYjFBs~>7wDduQ`JtSelUQHMVA7m%(gx@DK4(*oxBuGsL`QMFM z-rFq;s{B&V&bO&yk@k(U0`_F2kjM^J5dbxFUUO+#q#3#+yXD0=ie=ntB zDrjhV9e(i^fV_lqV23xUlaryV9BFpc1!IO_BRyi$y~w-+1AUO^BYtXD*NS+Lec!%2 zh<0ZEu>tG=-9CN@NSAX~IXWp#m^6DR`~sW*AGzE2%X-InWk3(WqrWWEq>~HLSIkN| z<0&X~;CsUPzW>BhAfyFhq(tJ`3d%{EB52(6RrTD)ov4+ZeKS-dv7(j)GA%ygIO9nn zEpJEAhT>Xt7bs`ccBf*f>W@-+&s0~rs-eeo% zMlf(#*iIr|r*;~l4W0q&z(t=)kbQfng5+6P%Z3d9TwN%;qy$0c--%=91URk$4U0MBQ z3X7FQ#t5hx#3$;sx_KV@Bj@^eexQyUSN|C`Vv;t|!DRAW-W{=<2oA7Fy^62c=KAnh z7CC?(7`B-n3stLZHzEQ1Bjr-%dtcM1%wP4Y{NJlE|6d*F=qAu*bFLF>!h9=C0brVj K8Vza=vHt^-w-xdL literal 0 HcmV?d00001 diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..b3799fada538b26db27b4000d2a80c8e31d54c80 GIT binary patch literal 13496 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 K8Vza=vHt^-w-xdL literal 0 HcmV?d00001 diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..b3799fada538b26db27b4000d2a80c8e31d54c80 GIT binary patch literal 13496 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 WUqX$KWsg8P0F@^iifE)+;Qs? + + + + + + + + + + + + + + + + + + + pawmate + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..55bede9 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "pawmate", + "short_name": "pawmate", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..423749c --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(pawmate LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "pawmate") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..d40a0c7 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..5bd1707 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + app_links + flutter_secure_storage_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..97df071 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "pawmate" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "pawmate" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "pawmate.exe" "\0" + VALUE "ProductName", "pawmate" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..f0e77c9 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"pawmate", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6ba502eabf89e31298685f28709acf27fca47a7f GIT binary patch literal 31801 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 literal 0 HcmV?d00001 diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ From fb81b22dfdf4e7f35f84f79acb97c41f20df8da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 4 Oct 2026 20:35:01 +0800 Subject: [PATCH 08/16] feat(chat): add synchronized couple messaging Add persisted text messaging with paginated history, retry-safe sends, cross-device synchronization, unread reminders, and member-level read receipts. Open the couple space on the Chat tab and retain drafts and scroll state between tabs. --- android/app/build.gradle.kts | 6 +- android/app/src/main/AndroidManifest.xml | 8 +- .../pawmate/app}/MainActivity.kt | 2 +- books/src/current-foundation.md | 54 ++- books/src/images/basic-chat.png | Bin 0 -> 73354 bytes lib/design/chat_message_bubble.dart | 70 ++++ lib/design/handdrawn_button.dart | 62 ++-- lib/design/handdrawn_scaffold.dart | 9 +- lib/design/handdrawn_text_field.dart | 4 +- lib/features/chat/chat_api.dart | 135 ++++++++ lib/features/chat/chat_controller.dart | 242 +++++++++++++ lib/features/chat/chat_page.dart | 246 +++++++++++++ lib/features/pairing/device_login_page.dart | 180 ++++++++++ lib/features/pairing/inviter_setup_page.dart | 68 ++-- lib/features/pairing/paired_home_page.dart | 309 +++++++++++++---- lib/features/pairing/pairing_api.dart | 168 ++++++++- lib/features/pairing/pairing_credentials.dart | 10 +- .../pairing/pairing_recovery_page.dart | 15 +- .../pairing/widgets/devices_card.dart | 199 +++++++++++ .../widgets/pairing_recovery_card.dart | 2 +- .../pairing/widgets/recovery_code_note.dart | 2 +- macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Release.entitlements | 2 + server/.gitignore | 1 + server/README.md | 122 ++++++- server/go.mod | 5 + server/go.sum | 10 + server/internal/httpapi/chat.go | 83 +++++ server/internal/httpapi/chat_test.go | 68 ++++ server/internal/httpapi/devices.go | 90 +++++ server/internal/httpapi/pairing.go | 13 +- server/internal/httpapi/pairing_test.go | 62 ++++ server/internal/httpapi/router.go | 9 + server/internal/pairing/devices.go | 200 +++++++++++ server/internal/pairing/devices_test.go | 206 +++++++++++ server/internal/pairing/messages.go | 188 ++++++++++ server/internal/pairing/messages_test.go | 153 +++++++++ server/internal/pairing/models.go | 128 +++++++ server/internal/pairing/orm_test.go | 173 ++++++++++ server/internal/pairing/service.go | 258 +++++++------- test/chat_test.dart | 324 ++++++++++++++++++ test/device_login_api_test.dart | 136 ++++++++ test/server_url_test.dart | 37 ++ test/widget_test.dart | 39 +++ 44 files changed, 3833 insertions(+), 267 deletions(-) rename android/app/src/main/kotlin/{com/example/pawmate => dev/pawmate/app}/MainActivity.kt (76%) create mode 100644 books/src/images/basic-chat.png create mode 100644 lib/design/chat_message_bubble.dart create mode 100644 lib/features/chat/chat_api.dart create mode 100644 lib/features/chat/chat_controller.dart create mode 100644 lib/features/chat/chat_page.dart create mode 100644 lib/features/pairing/device_login_page.dart create mode 100644 lib/features/pairing/widgets/devices_card.dart create mode 100644 server/.gitignore create mode 100644 server/internal/httpapi/chat.go create mode 100644 server/internal/httpapi/chat_test.go create mode 100644 server/internal/httpapi/devices.go create mode 100644 server/internal/pairing/devices.go create mode 100644 server/internal/pairing/devices_test.go create mode 100644 server/internal/pairing/messages.go create mode 100644 server/internal/pairing/messages_test.go create mode 100644 server/internal/pairing/models.go create mode 100644 server/internal/pairing/orm_test.go create mode 100644 test/chat_test.dart create mode 100644 test/device_login_api_test.dart create mode 100644 test/server_url_test.dart 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/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fc01d26..fcb62e7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,13 @@ + + + + android:allowBackup="false" + android:usesCleartextTraffic="true"> )2z7JwdmY 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

b<-)NNHnB}2xRY74mEpCmkGtHJQ%G4G86tuFgB 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 literal 0 HcmV?d00001 diff --git a/lib/design/chat_message_bubble.dart b/lib/design/chat_message_bubble.dart new file mode 100644 index 0000000..3fd5d99 --- /dev/null +++ b/lib/design/chat_message_bubble.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'handdrawn_card.dart'; +import 'pawmate_theme.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: const Text('Retry message'), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/design/handdrawn_button.dart b/lib/design/handdrawn_button.dart index bfededa..3244b70 100644 --- a/lib/design/handdrawn_button.dart +++ b/lib/design/handdrawn_button.dart @@ -33,38 +33,50 @@ class HanddrawnButton extends StatelessWidget { children: [ Icon(icon, size: 20), const SizedBox(width: 8), - Text(label), + Flexible(child: Text(label, textAlign: TextAlign.center)), ], ); return SizedBox( width: double.infinity, - height: 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, - ), - child: child, - ) - : OutlinedButton( - onPressed: onPressed, - style: OutlinedButton.styleFrom( - foregroundColor: PawmateColors.ink, - side: const BorderSide( - color: PawmateColors.softBrown, - width: 2, + 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, + ), ), - shape: shape, + child: child, ), - child: child, - ), + ), ); } } diff --git a/lib/design/handdrawn_scaffold.dart b/lib/design/handdrawn_scaffold.dart index e60e12f..be9e423 100644 --- a/lib/design/handdrawn_scaffold.dart +++ b/lib/design/handdrawn_scaffold.dart @@ -4,10 +4,16 @@ import 'pawmate_theme.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}); + const HanddrawnScaffold({ + required this.title, + required this.body, + super.key, + this.bottomNavigationBar, + }); final String title; final Widget body; + final Widget? bottomNavigationBar; @override Widget build(BuildContext context) { @@ -22,6 +28,7 @@ class HanddrawnScaffold extends StatelessWidget { ), ), body: body, + bottomNavigationBar: bottomNavigationBar, ); } } diff --git a/lib/design/handdrawn_text_field.dart b/lib/design/handdrawn_text_field.dart index 46055fc..c2d667f 100644 --- a/lib/design/handdrawn_text_field.dart +++ b/lib/design/handdrawn_text_field.dart @@ -9,6 +9,7 @@ class HanddrawnTextField extends StatelessWidget { this.hintText, this.validator, this.prefixIcon, + this.keyboardType = TextInputType.url, }); final TextEditingController controller; @@ -16,13 +17,14 @@ class HanddrawnTextField extends StatelessWidget { final String? hintText; final FormFieldValidator? validator; final IconData? prefixIcon; + final TextInputType keyboardType; @override Widget build(BuildContext context) { return TextFormField( controller: controller, autocorrect: false, - keyboardType: TextInputType.url, + keyboardType: keyboardType, textInputAction: TextInputAction.done, decoration: InputDecoration( labelText: labelText, diff --git a/lib/features/chat/chat_api.dart b/lib/features/chat/chat_api.dart new file mode 100644 index 0000000..1a69682 --- /dev/null +++ b/lib/features/chat/chat_api.dart @@ -0,0 +1,135 @@ +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.parseServerURL(credentials.serverURL) + .resolve('/api/v1/chat/messages') + .replace( + queryParameters: { + if (afterID > 0) 'after_id': '$afterID', + if (beforeID > 0) 'before_id': '$beforeID', + 'limit': '50', + }, + ); + final response = await _client + .get(uri, headers: _headers) + .timeout(const Duration(seconds: 10)); + return ChatSnapshot.fromJson(_decode(response)); + } + + /// Sends text with a stable client id so a lost response can be safely retried. + Future send(String clientID, String text) async => + ChatMessage.fromJson( + await _post('/messages', {'client_id': clientID, 'text': text}), + ); + + /// Advances this member's read cursor; receipts are shared across devices. + Future markRead(int messageID) async => + (await _post('/read', {'message_id': messageID}))['read_id'] as int; + + Map get _headers => { + 'authorization': 'Bearer ${credentials.accessToken}', + 'content-type': 'application/json', + }; + + /// Sends a JSON request without placing message text or credentials in a URL. + Future> _post( + String path, + Map body, + ) async { + final response = await _client + .post( + PairingApi.parseServerURL( + credentials.serverURL, + ).resolve('/api/v1/chat$path'), + headers: _headers, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 10)); + return _decode(response); + } + + /// Retains HTTP status so revoked devices return to the existing session gate. + Map _decode(http.Response response) { + if (response.statusCode != 200) { + throw PairingApiException( + 'Could not synchronize messages (${response.statusCode}).', + statusCode: response.statusCode, + ); + } + return jsonDecode(utf8.decode(response.bodyBytes)) as Map; + } +} diff --git a/lib/features/chat/chat_controller.dart b/lib/features/chat/chat_controller.dart new file mode 100644 index 0000000..930bc64 --- /dev/null +++ b/lib/features/chat/chat_controller.dart @@ -0,0 +1,242 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import '../pairing/pairing_api.dart'; +import 'chat_api.dart'; + +/// A local outgoing message keeps the same id during ambiguous network retries. +class OutgoingMessage { + OutgoingMessage(this.clientID, this.text); + final String clientID; + final String text; + bool sending = true; +} + +/// Shares messages, receipts and an in-memory outbox across the couple-space tabs. +class ChatController extends ChangeNotifier { + ChatController(this.api); + + final ChatApi api; + final List _messages = []; + final List _outbox = []; + Timer? _timer; + bool _disposed = false; + bool _syncing = false; + bool _reading = false; + bool _historyLoading = false; + bool _foreground = true; + bool _chatVisible = true; + DateTime? _readRetryAt; + int _knownLatestID = 0; + int _syncAfter = 0; + int _requestedReadID = 0; + int _readID = 0; + int partnerReadID = 0; + int unreadCount = 0; + bool loaded = false; + bool hasOlder = false; + bool unauthorized = false; + String? error; + + List get messages => List.unmodifiable(_messages); + List get outbox => List.unmodifiable(_outbox); + bool get loading => !loaded && _syncing; + bool get historyLoading => _historyLoading; + bool get foreground => _foreground; + bool get caughtUp => loaded && _syncAfter >= _knownLatestID; + + /// Stops queued read advances as soon as another tab becomes visible. + void setChatVisible(bool visible) { + _chatVisible = visible; + } + + /// Polls only while the application is foregrounded; history stays in memory. + void start() { + unawaited(synchronize()); + _timer ??= Timer.periodic(const Duration(seconds: 2), (_) { + if (_foreground) unawaited(synchronize()); + }); + } + + /// Resumes synchronization after backgrounding without generating read receipts. + void setForeground(bool foreground) { + _foreground = foreground; + if (foreground) unawaited(synchronize()); + _emit(); + } + + /// Fetches every forward page; sending locally never advances the fetch cursor. + Future synchronize() async { + if (_disposed || _syncing || unauthorized) return; + _syncing = true; + _emit(); + try { + bool more; + do { + final previous = _syncAfter; + final snapshot = await api.messages(afterID: _syncAfter); + if (_disposed) return; + _merge(snapshot.messages); + if (_syncAfter == 0) hasOlder = snapshot.hasMore; + if (snapshot.messages.isNotEmpty) { + _syncAfter = snapshot.messages.last.id; + } + _applyReceipts(snapshot); + loaded = true; + error = null; + // Initial history is the most recent window, not an ascending delta page. + more = previous > 0 && snapshot.hasMore && _syncAfter > previous; + _emit(); + } while (more && !_disposed); + } catch (failure) { + _handleError(failure); + } finally { + _syncing = false; + _emit(); + } + } + + /// Prepends older history without changing the independent forward cursor. + Future loadOlder() async { + if (_disposed || _historyLoading || !hasOlder || _messages.isEmpty) return; + _historyLoading = true; + _emit(); + try { + final snapshot = await api.messages(beforeID: _messages.first.id); + if (_disposed) return; + _merge(snapshot.messages); + hasOlder = snapshot.hasMore; + error = null; + } catch (failure) { + _handleError(failure); + } finally { + _historyLoading = false; + _emit(); + } + } + + /// Queues a new message using a cryptographically random, retry-safe client id. + Future send(String text) async { + text = text.trim(); + if (_disposed || text.isEmpty || text.runes.length > 4000) return; + final random = Random.secure(); + final id = List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + final outgoing = OutgoingMessage(id, text); + _outbox.add(outgoing); + _emit(); + await _send(outgoing); + } + + /// Retries failed text with its original client id to prevent duplicate messages. + Future retry(OutgoingMessage outgoing) async { + if (_disposed || outgoing.sending || !_outbox.contains(outgoing)) return; + outgoing.sending = true; + _emit(); + await _send(outgoing); + } + + /// Reconciles the response with messages already observed through polling. + Future _send(OutgoingMessage outgoing) async { + try { + final message = await api.send(outgoing.clientID, outgoing.text); + if (_disposed) return; + _merge([message]); + _outbox.remove(outgoing); + unawaited(synchronize()); + } catch (failure) { + if (!_disposed) outgoing.sending = false; + _handleError(failure); + } + _emit(); + } + + /// Marks only the rendered cursor supplied by the visible, foreground chat page. + Future markVisibleRead(int id) async { + if (_disposed || + !_foreground || + !_chatVisible || + !caughtUp || + id <= _readID || + unauthorized || + (_readRetryAt != null && DateTime.now().isBefore(_readRetryAt!))) { + return; + } + _requestedReadID = max(_requestedReadID, id); + if (_reading) return; + _reading = true; + try { + while (_requestedReadID > _readID && + _foreground && + _chatVisible && + !_disposed) { + final read = await api.markRead(_requestedReadID); + if (_disposed) return; + _readID = max(_readID, read); + _readRetryAt = null; + _emit(); + } + // Only a fresh server snapshot can count messages arriving during the receipt. + unawaited(synchronize()); + } catch (failure) { + _readRetryAt = DateTime.now().add(const Duration(seconds: 2)); + _handleError(failure); + } finally { + _reading = false; + _emit(); + } + } + + /// Merges by server id and removes outbox echoes sent by this installation. + void _merge(List incoming) { + final byID = {for (final message in _messages) message.id: message}; + for (final message in incoming) { + _knownLatestID = max(_knownLatestID, message.id); + byID[message.id] = message; + if (message.sender == api.credentials.role) { + _outbox.removeWhere( + (outgoing) => outgoing.clientID == message.clientID, + ); + } + } + _messages + ..clear() + ..addAll(byID.values); + _messages.sort((a, b) => a.id.compareTo(b.id)); + } + + /// Never regresses read receipts when an older in-flight snapshot completes. + void _applyReceipts(ChatSnapshot snapshot) { + _knownLatestID = max(_knownLatestID, snapshot.latestID); + partnerReadID = max(partnerReadID, snapshot.partnerReadID); + _readID = max(_readID, snapshot.readID); + // Ignore stale unread metadata after a newer local read acknowledgment. + if (snapshot.readID >= _readID) unreadCount = snapshot.unreadCount; + } + + /// Reports connectivity failures without exposing private message content. + void _handleError(Object failure) { + if (_disposed) return; + if (failure is PairingApiException && failure.statusCode == 401) { + unauthorized = true; + } + error = 'Messages could not synchronize. Check your connection and retry.'; + } + + /// Guards against asynchronous completions after leaving the couple space. + void _emit() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _timer?.cancel(); + api.close(); + super.dispose(); + } +} diff --git a/lib/features/chat/chat_page.dart b/lib/features/chat/chat_page.dart new file mode 100644 index 0000000..864498a --- /dev/null +++ b/lib/features/chat/chat_page.dart @@ -0,0 +1,246 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../design/chat_message_bubble.dart'; +import '../../design/handdrawn_card.dart'; +import 'chat_controller.dart'; + +/// The default couple-space tab, with text history and visibility-based receipts. +class ChatPage extends StatefulWidget { + const ChatPage({required this.controller, required this.active, super.key}); + + final ChatController controller; + final bool active; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + final _draft = TextEditingController(); + final _scroll = ScrollController(); + final _viewportKey = GlobalKey(); + final Map _incomingKeys = {}; + bool _atLatest = true; + + @override + void initState() { + super.initState(); + _scroll.addListener(_onScroll); + } + + /// Reads incoming messages inside the rendered viewport, never on fetch alone. + void _scheduleRead() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + widget.active && + widget.controller.foreground && + _scroll.hasClients) { + final viewport = _viewportKey.currentContext?.findRenderObject(); + if (viewport is! RenderBox || !viewport.hasSize) return; + final bounds = viewport.localToGlobal(Offset.zero) & viewport.size; + var visibleID = 0; + for (final entry in _incomingKeys.entries) { + final bubble = entry.value.currentContext?.findRenderObject(); + if (bubble is RenderBox && bubble.hasSize) { + final messageBounds = + bubble.localToGlobal(Offset.zero) & bubble.size; + if (messageBounds.overlaps(bounds) && entry.key > visibleID) { + visibleID = entry.key; + } + } + } + if (visibleID > 0) { + unawaited(widget.controller.markVisibleRead(visibleID)); + } + } + }); + } + + /// Keeps unread messages unread while browsing earlier history. + void _onScroll() { + final atLatest = _scroll.offset <= 24; + if (_atLatest != atLatest) setState(() => _atLatest = atLatest); + if (widget.controller.messages.isNotEmpty) { + _scheduleRead(); + } + } + + /// Jumps to the latest message using the user's animation preference. + void _showLatest() { + if (!_scroll.hasClients) return; + if (MediaQuery.disableAnimationsOf(context)) { + _scroll.jumpTo(0); + } else { + unawaited( + _scroll.animateTo( + 0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + ), + ); + } + } + + /// Sends a valid draft while retaining independent failed messages for retry. + void _send() { + final text = _draft.text.trim(); + if (text.isEmpty || text.runes.length > 4000) return; + _draft.clear(); + unawaited(widget.controller.send(text)); + _showLatest(); + } + + @override + void dispose() { + _draft.dispose(); + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final chat = widget.controller; + final messages = chat.messages.reversed.toList(); + final pending = chat.outbox.reversed.toList(); + if (messages.isNotEmpty && widget.active) { + _scheduleRead(); + } + return Column( + children: [ + if (chat.error != null) + MaterialBanner( + content: Text(chat.error!), + actions: [ + TextButton( + onPressed: () => chat.synchronize(), + child: const Text('Retry'), + ), + ], + ), + Expanded( + child: SizedBox.expand( + key: _viewportKey, + child: chat.loading + ? const Center(child: CircularProgressIndicator()) + : messages.isEmpty && pending.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: HanddrawnCard( + child: Text( + 'Your first words together\nSend a little hello to start your shared journal.', + ), + ), + ), + ) + : ListView.builder( + controller: _scroll, + reverse: true, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + itemCount: + pending.length + + messages.length + + (chat.hasOlder ? 1 : 0), + itemBuilder: (context, index) { + if (index < pending.length) { + final outgoing = pending[index]; + return ChatMessageBubble( + key: ValueKey('pending-${outgoing.clientID}'), + text: outgoing.text, + own: true, + status: outgoing.sending ? 'Sending…' : 'Not sent', + onRetry: outgoing.sending + ? null + : () => chat.retry(outgoing), + ); + } + final messageIndex = index - pending.length; + if (messageIndex >= messages.length) { + return TextButton( + onPressed: chat.historyLoading + ? null + : () => chat.loadOlder(), + child: Text( + chat.historyLoading + ? 'Loading…' + : 'Load earlier messages', + ), + ); + } + final message = messages[messageIndex]; + final own = message.sender == chat.api.credentials.role; + return ChatMessageBubble( + key: own + ? ValueKey(message.id) + : _incomingKeys.putIfAbsent( + message.id, + () => GlobalKey(), + ), + text: message.text, + own: own, + time: message.createdAt, + status: own + ? (message.id <= chat.partnerReadID + ? 'Read' + : 'Unread') + : '', + ); + }, + ), + ), + ), + if (!_atLatest && chat.unreadCount > 0) + TextButton.icon( + onPressed: _showLatest, + icon: const Icon(Icons.arrow_downward), + label: Text('${chat.unreadCount} unread · Go to latest'), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: ValueListenableBuilder( + valueListenable: _draft, + builder: (context, draft, _) => Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _draft, + minLines: 1, + maxLines: 4, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + labelText: 'Message your partner', + errorText: draft.text.runes.length > 4000 + ? 'Use up to 4000 characters.' + : null, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + tooltip: 'Send message', + onPressed: + draft.text.trim().isEmpty || + draft.text.runes.length > 4000 || + chat.unauthorized + ? null + : _send, + icon: const Icon(Icons.send), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ); +} diff --git a/lib/features/pairing/device_login_page.dart b/lib/features/pairing/device_login_page.dart new file mode 100644 index 0000000..46bf5eb --- /dev/null +++ b/lib/features/pairing/device_login_page.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; + +import '../../design/handdrawn_button.dart'; +import '../../design/handdrawn_card.dart'; +import '../../design/handdrawn_scaffold.dart'; +import '../../design/handdrawn_text_field.dart'; +import 'paired_home_page.dart'; +import 'pairing_api.dart'; +import 'pairing_credentials.dart'; +import 'widgets/pairing_error_note.dart'; + +/// Adds this installation to an existing member using a one-time login code. +class DeviceLoginPage extends StatefulWidget { + const DeviceLoginPage({super.key}); + + @override + State createState() => _DeviceLoginPageState(); +} + +class _DeviceLoginPageState extends State { + final _formKey = GlobalKey(); + final _server = TextEditingController(); + final _code = TextEditingController(); + final _name = TextEditingController(text: PairingApi.defaultDeviceName); + final _api = PairingApi(); + final _credentials = PairingCredentials(); + bool _busy = false; + String? _error; + + @override + void dispose() { + _server.dispose(); + _code.dispose(); + _name.dispose(); + _api.close(); + super.dispose(); + } + + /// Consumes a login code and saves only this device's independent credentials. + Future _signIn() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final result = await _api.redeemDeviceLoginCode( + _server.text, + _code.text, + _name.text, + ); + final saved = SavedPairingCredentials( + serverURL: _server.text.trim(), + accessToken: result.accessToken, + // A device login grants a session, never the member's recovery secret. + recoveryCode: '', + pairID: result.pairID, + role: result.role, + ); + var storageWarning = false; + try { + await _credentials.save( + serverURL: saved.serverURL, + accessToken: saved.accessToken, + recoveryCode: saved.recoveryCode, + pairID: saved.pairID, + role: saved.role, + ); + } on Object { + storageWarning = true; + } + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => PairedHomePage( + credentials: saved, + session: PairingSession( + role: saved.role, + status: 'paired', + pairID: saved.pairID, + ), + storageWarning: storageWarning, + ), + ), + (_) => false, + ); + } on PairingApiException catch (error) { + if (mounted) { + setState( + () => _error = error.message == 'invalid_device_code' + ? 'This login code expired or was already used. Generate a new one on your signed-in device.' + : error.message, + ); + } + } on Object { + if (mounted) { + setState( + () => _error = + 'Could not sign in. Check your connection and try again.', + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => HanddrawnScaffold( + title: 'Bring your home along', + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + HanddrawnCard( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sign in on another device', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + const Text( + 'On your signed-in phone, tablet or computer, open My devices and choose Add a device. Enter its server address and login code here. Your other devices will stay signed in.', + ), + const SizedBox(height: 20), + HanddrawnTextField( + controller: _server, + labelText: 'Server URL', + hintText: 'https://pawmate.example.com', + validator: (value) => value == null || value.trim().isEmpty + ? 'Enter your server address.' + : null, + ), + const SizedBox(height: 12), + HanddrawnTextField( + controller: _code, + labelText: 'Device login code', + keyboardType: TextInputType.text, + validator: (value) => value == null || value.trim().isEmpty + ? 'Enter the login code.' + : null, + ), + const SizedBox(height: 12), + HanddrawnTextField( + controller: _name, + labelText: 'Device name', + keyboardType: TextInputType.text, + validator: (value) => + value == null || + value.trim().isEmpty || + value.trim().runes.length > 80 + ? 'Use a name between 1 and 80 characters.' + : null, + ), + const SizedBox(height: 20), + HanddrawnButton( + label: _busy ? 'Signing in…' : 'Sign in', + icon: Icons.devices, + onPressed: _busy ? null : _signIn, + ), + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: const Text('Back'), + ), + ], + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 16), + PairingErrorNote(message: _error!), + ], + ], + ), + ), + ); +} diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart index a0c794d..6d69877 100644 --- a/lib/features/pairing/inviter_setup_page.dart +++ b/lib/features/pairing/inviter_setup_page.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../design/handdrawn_scaffold.dart'; -import '../../design/pawmate_theme.dart'; import 'widgets/invite_result_card.dart'; import 'widgets/pairing_error_note.dart'; import 'widgets/pairing_header.dart'; import 'widgets/pairing_recovery_card.dart'; -import 'widgets/recovery_code_note.dart'; import 'widgets/server_setup_card.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; +import '../../design/handdrawn_button.dart'; +import 'device_login_page.dart'; import 'paired_home_page.dart'; class InviterSetupPage extends StatefulWidget { @@ -31,8 +31,6 @@ class _InviterSetupPageState extends State { PairingStatus? _status; String? _errorMessage; bool _isLoading = false; - String? _recoveredCode; - String? _recoveredRole; @override void initState() { @@ -121,8 +119,6 @@ class _InviterSetupPageState extends State { Future _recoverAccess() async { setState(() { _errorMessage = null; - _recoveredCode = null; - _recoveredRole = null; _isLoading = true; }); try { @@ -130,19 +126,41 @@ class _InviterSetupPageState extends State { _recoveryServerController.text, _recoveryCodeController.text, ); - await _credentials.save( + final saved = SavedPairingCredentials( serverURL: _recoveryServerController.text.trim(), accessToken: credentials.accessToken, recoveryCode: credentials.recoveryCode, pairID: credentials.pairID, role: credentials.role, ); - if (mounted) { - setState(() { - _recoveredCode = credentials.recoveryCode; - _recoveredRole = credentials.role; - }); + var storageWarning = false; + try { + await _credentials.save( + serverURL: saved.serverURL, + accessToken: saved.accessToken, + recoveryCode: saved.recoveryCode, + pairID: saved.pairID, + role: saved.role, + ); + } on Object { + storageWarning = true; } + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (_) => PairedHomePage( + credentials: saved, + session: PairingSession( + role: credentials.role, + status: 'paired', + pairID: credentials.pairID, + ), + recoveryCodeToSave: credentials.recoveryCode, + storageWarning: storageWarning, + ), + ), + (_) => false, + ); } on PairingApiException catch (error) { if (mounted) setState(() => _errorMessage = error.message); } on Object { @@ -245,6 +263,19 @@ class _InviterSetupPageState extends State { padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), children: [ const PairingHeader(), + const SizedBox(height: 18), + HanddrawnButton( + label: 'Sign in on another device', + icon: Icons.devices, + primary: false, + onPressed: _isLoading + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DeviceLoginPage(), + ), + ), + ), const SizedBox(height: 20), ServerSetupCard( formKey: _formKey, @@ -274,19 +305,6 @@ class _InviterSetupPageState extends State { onRefresh: () => _refreshStatus(), ), ], - if (_recoveredCode != null) ...[ - const SizedBox(height: 18), - if (_recoveredRole != null) - Text( - 'Access restored for the ${_recoveredRole!} member.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: PawmateColors.ink, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - RecoveryCodeNote(recoveryCode: _recoveredCode!), - ], ], ), ), diff --git a/lib/features/pairing/paired_home_page.dart b/lib/features/pairing/paired_home_page.dart index 137f214..0be3086 100644 --- a/lib/features/pairing/paired_home_page.dart +++ b/lib/features/pairing/paired_home_page.dart @@ -7,15 +7,194 @@ import 'pairing_api.dart'; import 'pairing_credentials.dart'; import 'widgets/pairing_error_note.dart'; import 'widgets/recovery_code_note.dart'; +import 'widgets/devices_card.dart'; +import '../chat/chat_api.dart'; +import '../chat/chat_controller.dart'; +import '../chat/chat_page.dart'; +import 'pairing_session_gate.dart'; -/// Shows the authenticated couple home after the server validates a member. -class PairedHomePage extends StatelessWidget { +/// Opens the couple space on chat, retaining drafts and scroll state between tabs. +class PairedHomePage extends StatefulWidget { const PairedHomePage({ required this.credentials, required this.session, super.key, this.recoveryCodeToSave, this.storageWarning = false, + this.chatApi, + }); + + final SavedPairingCredentials credentials; + final PairingSession session; + final String? recoveryCodeToSave; + final bool storageWarning; + final ChatApi? chatApi; + + @override + State createState() => _PairedHomePageState(); +} + +class _PairedHomePageState extends State + with WidgetsBindingObserver { + late final ChatController _chat; + int _tab = 0; + int _lastUnread = 0; + bool _loaded = false; + bool _redirecting = false; + bool _homeNotice = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _chat = ChatController(widget.chatApi ?? ChatApi(widget.credentials)) + ..addListener(_changed); + _chat.setForeground( + WidgetsBinding.instance.lifecycleState == null || + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed, + ); + _chat.start(); + } + + /// Pauses polling and read receipts when the application leaves the foreground. + @override + void didChangeAppLifecycleState(AppLifecycleState state) => + _chat.setForeground(state == AppLifecycleState.resumed); + + /// Shows content-free unread reminders and redirects revoked device sessions. + void _changed() { + if (!mounted) return; + if (_chat.unauthorized && !_redirecting) { + _redirecting = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const PairingSessionGate()), + (_) => false, + ); + }); + } + if (_loaded && + _chat.foreground && + _tab != 0 && + _chat.unreadCount > _lastUnread) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + '${_chat.unreadCount} unread messages from your partner', + ), + action: SnackBarAction( + label: 'Open chat', + onPressed: () => _selectTab(0), + ), + ), + ); + } + _lastUnread = _chat.unreadCount; + _loaded = _chat.loaded; + setState(() {}); + } + + /// Changes the active tab without acknowledging unseen messages. + void _selectTab(int index) { + _chat.setChatVisible(index == 0); + setState(() { + _tab = index; + if (index == 2) _homeNotice = false; + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _chat.removeListener(_changed); + _chat.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => HanddrawnScaffold( + title: ['Our conversation', 'Play corner', 'Our little home'][_tab], + bottomNavigationBar: NavigationBar( + selectedIndex: _tab, + onDestinationSelected: _selectTab, + destinations: [ + NavigationDestination( + icon: Badge( + isLabelVisible: _chat.unreadCount > 0, + label: Text( + _chat.unreadCount > 99 ? '99+' : '${_chat.unreadCount}', + ), + child: const Icon(Icons.chat_bubble_outline), + ), + label: 'Chat', + tooltip: 'Chat, ${_chat.unreadCount} unread messages', + ), + const NavigationDestination( + icon: Icon(Icons.sports_esports_outlined), + label: 'Play', + ), + const NavigationDestination( + icon: Icon(Icons.home_outlined), + label: 'Home', + ), + ], + ), + body: Column( + children: [ + if (_homeNotice && + (widget.recoveryCodeToSave != null || widget.storageWarning)) + MaterialBanner( + content: Text( + widget.storageWarning + ? 'This device could not save its login. Open Home for reconnect instructions.' + : 'Save your recovery code in Home before leaving.', + ), + actions: [ + TextButton( + onPressed: () => _selectTab(2), + child: const Text('View Home'), + ), + ], + ), + Expanded( + child: IndexedStack( + index: _tab, + children: [ + ChatPage(controller: _chat, active: _tab == 0), + const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: HanddrawnCard( + child: Text( + 'A little corner for playing together.\nGames will arrive here later.', + ), + ), + ), + ), + _HomeDetails( + credentials: widget.credentials, + session: widget.session, + recoveryCodeToSave: widget.recoveryCodeToSave, + storageWarning: widget.storageWarning, + ), + ], + ), + ), + ], + ), + ); +} + +/// Keeps pairing details, device management and recovery notes in the Home tab. +class _HomeDetails extends StatelessWidget { + const _HomeDetails({ + required this.credentials, + required this.session, + this.recoveryCodeToSave, + this.storageWarning = false, }); final SavedPairingCredentials credentials; @@ -25,80 +204,80 @@ class PairedHomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return HanddrawnScaffold( - title: 'Our little home', - body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), - children: [ - HanddrawnCard( - color: const Color(0xFFF4F0FF), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon( - Icons.home_rounded, - size: 36, - color: PawmateColors.rose, + return SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + HanddrawnCard( + color: const Color(0xFFF4F0FF), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.home_rounded, + size: 36, + color: PawmateColors.rose, + ), + const SizedBox(height: 12), + Text( + 'You are home together', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, ), - const SizedBox(height: 12), - Text( - 'You are home together', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: PawmateColors.ink, - fontWeight: FontWeight.w700, - ), + ), + const SizedBox(height: 8), + Text( + 'This device is connected as the ${session.role}.', + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), + ), + const SizedBox(height: 16), + const Text( + 'Private server', + style: TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, ), - const SizedBox(height: 8), + ), + const SizedBox(height: 4), + SelectableText( + credentials.serverURL, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + ), + ), + if (session.pairID != null) ...[ + const SizedBox(height: 12), Text( - 'This device is connected as the ${session.role}.', + 'Home ID: ${session.pairID}', style: const TextStyle( - color: PawmateColors.softBrown, - height: 1.4, - ), - ), - const SizedBox(height: 16), - const Text( - 'Private server', - style: TextStyle( color: PawmateColors.softBrown, fontSize: 12, ), ), - const SizedBox(height: 4), - SelectableText( - credentials.serverURL, - style: const TextStyle( - color: PawmateColors.ink, - fontFamily: 'monospace', - ), - ), - if (session.pairID != null) ...[ - const SizedBox(height: 12), - Text( - 'Home ID: ${session.pairID}', - style: const TextStyle( - color: PawmateColors.softBrown, - fontSize: 12, - ), - ), - ], ], - ), + ], + ), + ), + if (storageWarning) ...[ + const SizedBox(height: 18), + PairingErrorNote( + message: recoveryCodeToSave != null + ? 'This device could not securely save its login. Keep the recovery code below in case the app closes.' + : 'This device could not securely save its login. If the app closes, generate a new login code on another signed-in device to reconnect.', ), - if (storageWarning) ...[ - const SizedBox(height: 18), - const PairingErrorNote( - message: - 'Access was restored for this session, but this device could not securely save it. Keep the recovery code below and restore again if the app closes.', - ), - ], - if (recoveryCodeToSave != null) ...[ - const SizedBox(height: 18), - RecoveryCodeNote(recoveryCode: recoveryCodeToSave!), - ], ], - ), + if (recoveryCodeToSave != null) ...[ + const SizedBox(height: 18), + RecoveryCodeNote(recoveryCode: recoveryCodeToSave!), + ], + const SizedBox(height: 18), + DevicesCard(credentials: credentials), + ], ), ); } diff --git a/lib/features/pairing/pairing_api.dart b/lib/features/pairing/pairing_api.dart index 43171cd..1df8063 100644 --- a/lib/features/pairing/pairing_api.dart +++ b/lib/features/pairing/pairing_api.dart @@ -94,6 +94,50 @@ class PairingSession { final String status; } +/// A short-lived code created by an already authenticated device. +class DeviceLoginCode { + const DeviceLoginCode({required this.code, required this.expiresAt}); + + final String code; + final DateTime expiresAt; +} + +/// Credentials for an additional device; recovery secrets are not transferred. +class DeviceLoginCredentials { + const DeviceLoginCredentials({ + required this.accessToken, + required this.pairID, + required this.role, + }); + + final String accessToken; + final String pairID; + final String role; +} + +/// Public metadata for one of the authenticated member's devices. +class PairingDevice { + const PairingDevice({ + required this.id, + required this.name, + required this.createdAt, + required this.current, + }); + + /// Decodes device metadata without access tokens or recovery codes. + factory PairingDevice.fromJson(Map json) => PairingDevice( + id: json['id'] as String, + name: json['name'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + current: json['current'] as bool, + ); + + final String id; + final String name; + final DateTime createdAt; + final bool current; +} + /// Represents a user-facing error returned while calling the pairing API. class PairingApiException implements Exception { const PairingApiException(this.message, {this.statusCode}); @@ -111,6 +155,18 @@ class PairingApi { final http.Client _client; + /// Provides an editable display label without collecting a device identifier. + static String get defaultDeviceName => kIsWeb + ? 'Web browser' + : switch (defaultTargetPlatform) { + TargetPlatform.android => 'Android device', + TargetPlatform.iOS => 'iOS device', + TargetPlatform.macOS => 'Mac', + TargetPlatform.windows => 'Windows computer', + TargetPlatform.linux => 'Linux computer', + TargetPlatform.fuchsia => 'Fuchsia device', + }; + /// Releases the underlying HTTP client. void close() => _client.close(); @@ -121,7 +177,10 @@ class PairingApi { .post( serverURL.resolve('/api/v1/pairing/invites'), headers: const {'content-type': 'application/json'}, - body: jsonEncode({'server_url': serverURL.toString()}), + body: jsonEncode({ + 'server_url': serverURL.toString(), + 'device_name': defaultDeviceName, + }), ) .timeout(const Duration(seconds: 10)); @@ -191,7 +250,7 @@ class PairingApi { .post( serverURL.resolve('/api/v1/pairing/invites/redeem'), headers: const {'content-type': 'application/json'}, - body: jsonEncode({'code': code}), + body: jsonEncode({'code': code, 'device_name': defaultDeviceName}), ) .timeout(const Duration(seconds: 10)); @@ -216,7 +275,10 @@ class PairingApi { .post( serverURL.resolve('/api/v1/pairing/recover'), headers: const {'content-type': 'application/json'}, - body: jsonEncode({'recovery_code': recoveryCode.trim()}), + body: jsonEncode({ + 'recovery_code': recoveryCode.trim(), + 'device_name': defaultDeviceName, + }), ) .timeout(const Duration(seconds: 10)); @@ -231,23 +293,115 @@ class PairingApi { ); } - /// Validates and normalizes a server URL before making a request. - Uri _parseServerURL(String rawServerURL) { + /// Lists the member's devices, identifying the token used for this request. + Future> getDevices(String server, String token) async { + final response = await _client + .get( + _parseServerURL(server).resolve('/api/v1/pairing/devices'), + headers: {'authorization': 'Bearer $token'}, + ) + .timeout(const Duration(seconds: 10)); + final body = _deviceResponse(response, 200); + return (body['devices'] as List) + .map((item) => PairingDevice.fromJson(item as Map)) + .toList(); + } + + /// Issues a one-time login code while keeping all device sessions active. + Future createDeviceLoginCode( + String server, + String token, + ) async { + final response = await _client + .post( + _parseServerURL( + server, + ).resolve('/api/v1/pairing/devices/login-codes'), + headers: {'authorization': 'Bearer $token'}, + ) + .timeout(const Duration(seconds: 10)); + final body = _deviceResponse(response, 201); + return DeviceLoginCode( + code: body['code'] as String, + expiresAt: DateTime.parse(body['expires_at'] as String), + ); + } + + /// Exchanges a code for this installation's independent access token. + Future redeemDeviceLoginCode( + String server, + String code, + String deviceName, + ) async { + final response = await _client + .post( + _parseServerURL( + server, + ).resolve('/api/v1/pairing/devices/login-codes/redeem'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({ + 'code': code.trim(), + 'device_name': deviceName.trim(), + }), + ) + .timeout(const Duration(seconds: 10)); + final body = _deviceResponse(response, 200); + return DeviceLoginCredentials( + accessToken: body['access_token'] as String, + pairID: body['pair_id'] as String, + role: body['role'] as String, + ); + } + + /// Removes one device owned by this member, leaving other tokens active. + Future revokeDevice(String server, String token, String id) async { + final response = await _client + .delete( + _parseServerURL( + server, + ).resolve('/api/v1/pairing/devices/${Uri.encodeComponent(id)}'), + headers: {'authorization': 'Bearer $token'}, + ) + .timeout(const Duration(seconds: 10)); + if (response.statusCode != 204) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + } + + /// Checks a device endpoint's status before decoding its successful response. + Map _deviceResponse(http.Response response, int status) { + if (response.statusCode != status) { + throw PairingApiException( + _messageFor(response), + statusCode: response.statusCode, + ); + } + return jsonDecode(response.body) as Map; + } + + /// Validates and normalizes HTTP or HTTPS instance URLs in every build mode. + static Uri _parseServerURL(String rawServerURL) { final uri = Uri.tryParse(rawServerURL.trim()); if (uri == null || uri.host.isEmpty || (uri.scheme != 'http' && uri.scheme != 'https') || - (uri.scheme == 'http' && !kDebugMode) || uri.userInfo.isNotEmpty || uri.hasQuery || uri.hasFragment) { throw const PairingApiException( - 'Use an HTTPS server URL in release builds. HTTP is allowed only in debug builds.', + 'Use an HTTP or HTTPS server URL without credentials, a query, or a fragment.', ); } return uri.replace(path: uri.path.replaceFirst(RegExp(r'/+$'), '')); } + /// Shares the instance URL policy with other authenticated feature clients. + static Uri parseServerURL(String rawServerURL) => + _parseServerURL(rawServerURL); + /// Extracts a safe error message from a JSON API response. String _messageFor(http.Response response) { try { diff --git a/lib/features/pairing/pairing_credentials.dart b/lib/features/pairing/pairing_credentials.dart index 3d9eea4..e8aaea1 100644 --- a/lib/features/pairing/pairing_credentials.dart +++ b/lib/features/pairing/pairing_credentials.dart @@ -5,7 +5,13 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; /// Stores the current member credentials in the platform secure storage. class PairingCredentials { PairingCredentials({FlutterSecureStorage? storage}) - : _storage = storage ?? const FlutterSecureStorage(); + : _storage = + storage ?? + const FlutterSecureStorage( + // This private client stores local Keychain items without cross-app sharing. + // macOS therefore needs no Keychain Sharing provisioning profile. + mOptions: MacOsOptions(usesDataProtectionKeychain: false), + ); static const _storageKey = 'pawmate.pairing.credentials.v1'; @@ -73,6 +79,8 @@ class SavedPairingCredentials { final String serverURL; final String accessToken; + + /// Empty on additional devices: device sign-in never transfers this secret. final String recoveryCode; final String pairID; final String role; diff --git a/lib/features/pairing/pairing_recovery_page.dart b/lib/features/pairing/pairing_recovery_page.dart index 4734c6b..cb5c5fb 100644 --- a/lib/features/pairing/pairing_recovery_page.dart +++ b/lib/features/pairing/pairing_recovery_page.dart @@ -8,6 +8,7 @@ import 'pairing_credentials.dart'; import 'paired_home_page.dart'; import 'widgets/pairing_error_note.dart'; import 'widgets/pairing_recovery_card.dart'; +import 'device_login_page.dart'; /// Restores an invalid or reinstalled member session with its recovery code. class PairingRecoveryPage extends StatefulWidget { @@ -131,7 +132,7 @@ class _PairingRecoveryPageState extends State { ), const SizedBox(height: 6), const Text( - 'Your saved access credential was rejected. Use the recovery code for this member to issue a fresh one.', + 'This device needs a new login. If another device is still signed in, use its device login code. Recovery is for lost access and signs out all your other devices.', style: TextStyle( color: PawmateColors.softBrown, height: 1.4, @@ -141,6 +142,18 @@ class _PairingRecoveryPageState extends State { ), ), const SizedBox(height: 18), + TextButton.icon( + onPressed: _isLoading + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DeviceLoginPage(), + ), + ), + icon: const Icon(Icons.devices), + label: const Text('Sign in with a device login code'), + ), + const SizedBox(height: 18), PairingRecoveryCard( serverController: _serverController, codeController: _codeController, diff --git a/lib/features/pairing/widgets/devices_card.dart b/lib/features/pairing/widgets/devices_card.dart new file mode 100644 index 0000000..75f09a2 --- /dev/null +++ b/lib/features/pairing/widgets/devices_card.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../design/handdrawn_button.dart'; +import '../../../design/handdrawn_card.dart'; +import '../pairing_api.dart'; +import '../pairing_credentials.dart'; +import '../pairing_session_gate.dart'; +import 'pairing_error_note.dart'; + +/// Lists the current member's devices and authorizes additional installations. +class DevicesCard extends StatefulWidget { + const DevicesCard({required this.credentials, super.key}); + + final SavedPairingCredentials credentials; + + @override + State createState() => _DevicesCardState(); +} + +class _DevicesCardState extends State { + final _api = PairingApi(); + List? _devices; + DeviceLoginCode? _code; + String? _error; + bool _busy = false; + + @override + void initState() { + super.initState(); + _refresh(); + } + + @override + void dispose() { + _api.close(); + super.dispose(); + } + + /// Applies a device operation, showing retryable failures and expired sessions. + Future _perform(Future Function() operation) async { + setState(() { + _busy = true; + _error = null; + }); + try { + await operation(); + } on PairingApiException catch (error) { + if (!mounted) return; + if (error.statusCode == 401) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const PairingSessionGate()), + (_) => false, + ); + } else { + setState(() => _error = error.message); + } + } on Object { + if (mounted) { + setState(() => _error = 'Could not reach your server. Try again.'); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// Refreshes the list without replacing any member credentials. + Future _refresh() => _perform(() async { + final saved = widget.credentials; + final devices = await _api.getDevices(saved.serverURL, saved.accessToken); + if (mounted) setState(() => _devices = devices); + }); + + /// Creates a code for this member, invalidating this device's previous code. + Future _addDevice() => _perform(() async { + final saved = widget.credentials; + final code = await _api.createDeviceLoginCode( + saved.serverURL, + saved.accessToken, + ); + if (mounted) setState(() => _code = code); + }); + + /// Confirms and removes a remote device without signing out this installation. + Future _removeDevice(PairingDevice device) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Sign out ${device.name}?'), + content: const Text( + 'This device will need a new login code to reconnect. Your other devices will stay signed in.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Sign out device'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await _perform(() async { + final saved = widget.credentials; + await _api.revokeDevice(saved.serverURL, saved.accessToken, device.id); + final devices = await _api.getDevices(saved.serverURL, saved.accessToken); + if (mounted) setState(() => _devices = devices); + }); + } + + @override + Widget build(BuildContext context) { + final code = _code; + return HanddrawnCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('My devices', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + const Text( + 'Keep your phone, tablet and computer connected to the same home. Only your own devices appear here.', + ), + if (_devices != null) ...[ + for (final device in _devices!) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.name, + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + device.current + ? 'This device' + : 'Added ${device.createdAt.toLocal().toString().split(' ').first}', + ), + if (!device.current) + TextButton.icon( + onPressed: _busy ? null : () => _removeDevice(device), + icon: const Icon(Icons.logout), + label: const Text('Sign out device'), + ), + ], + ), + ), + ], + if (_busy) + const Padding( + padding: EdgeInsets.all(12), + child: LinearProgressIndicator(), + ), + TextButton.icon( + onPressed: _busy ? null : _refresh, + icon: const Icon(Icons.refresh), + label: const Text('Refresh devices'), + ), + HanddrawnButton( + label: 'Add a device', + icon: Icons.add_to_queue, + onPressed: _busy ? null : _addDevice, + ), + if (code != null) ...[ + const SizedBox(height: 16), + const Text( + 'On your new device, choose Sign in on another device. Use the server address above and this code. Keep it private: it grants access as you.', + ), + const SizedBox(height: 8), + SelectableText( + code.code, + style: const TextStyle(fontFamily: 'monospace'), + ), + Text('Single use · Expires ${code.expiresAt.toLocal()}'), + TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: code.code)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Device login code copied')), + ); + } + }, + icon: const Icon(Icons.copy), + label: const Text('Copy login code'), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 12), + PairingErrorNote(message: _error!), + ], + ], + ), + ); + } +} diff --git a/lib/features/pairing/widgets/pairing_recovery_card.dart b/lib/features/pairing/widgets/pairing_recovery_card.dart index ff9488a..67117ca 100644 --- a/lib/features/pairing/widgets/pairing_recovery_card.dart +++ b/lib/features/pairing/widgets/pairing_recovery_card.dart @@ -36,7 +36,7 @@ class PairingRecoveryCard extends StatelessWidget { ), const SizedBox(height: 8), const Text( - 'Enter the same server address and the recovery code saved for this member.', + 'Use your recovery code if you have lost access. Recovery signs out all your devices and replaces the recovery code; your partner stays signed in. To add a device, use a device login code instead.', style: TextStyle(color: PawmateColors.softBrown, height: 1.35), ), const SizedBox(height: 14), diff --git a/lib/features/pairing/widgets/recovery_code_note.dart b/lib/features/pairing/widgets/recovery_code_note.dart index 93506fd..acb5e81 100644 --- a/lib/features/pairing/widgets/recovery_code_note.dart +++ b/lib/features/pairing/widgets/recovery_code_note.dart @@ -26,7 +26,7 @@ class RecoveryCodeNote extends StatelessWidget { ), const SizedBox(height: 6), const Text( - 'If you reinstall the app, use this code with your server address to restore your place in this home.', + 'Save this outside the app. Recovery signs out all your devices and issues a new code. For another phone, tablet or computer, use Add a device instead.', style: TextStyle(color: PawmateColors.softBrown, height: 1.35), ), const SizedBox(height: 12), diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..08c3ab1 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.server + com.apple.security.network.client + diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..ee95ab7 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..1b77988 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1 @@ +/pawmate.db \ No newline at end of file diff --git a/server/README.md b/server/README.md index f668e2e..ef687db 100644 --- a/server/README.md +++ b/server/README.md @@ -24,6 +24,13 @@ GET /api/v1/pairing/invites/status POST /api/v1/pairing/invites/redeem POST /api/v1/pairing/recover GET /api/v1/pairing/session +GET /api/v1/chat/messages +POST /api/v1/chat/messages +POST /api/v1/chat/read +GET /api/v1/pairing/devices +DELETE /api/v1/pairing/devices/:id +POST /api/v1/pairing/devices/login-codes +POST /api/v1/pairing/devices/login-codes/redeem ``` Create an invitation by sending the configured server URL: @@ -48,22 +55,127 @@ in server backups. Stop the server before copying the database file. Each member should save their recovery code outside the app. After reinstall, enter the same server URL and recovery code in the restore section. The server -rotates the access token and recovery code, so the old recovery code can no -longer be used. A recovery code belongs to one member; the two members should -keep separate codes. +revokes all of that member's device sessions and outstanding device login codes, +then issues a new access token and recovery code. The old recovery code can no +longer be used. The partner's sessions remain active. Recovery is for lost access; +use a device login code to add a device while keeping existing devices signed in. +A recovery code belongs to one member; the two members should keep separate codes. On app startup, the client calls `GET /api/v1/pairing/session` with its saved -bearer token. The endpoint accepts either member's current token and returns -the role, pair ID, and pairing state. Invalid or rotated tokens receive HTTP +bearer token. The endpoint accepts any active device token and returns +the role, pair ID, and pairing state. Invalid or revoked tokens receive HTTP 401 and should open recovery; network failures should offer retry without treating the credentials as invalid. +## Sign in on multiple devices + +Each phone, tablet, computer, or browser installation has its own access token. +The two members share one pair ID, but each member can have multiple sessions. +The server automatically migrates tokens from the previous single-token schema +on startup, so existing installations remain signed in. Migration runs once; +revoked legacy tokens are never re-imported on restart. + +1. On an already signed-in device, open **My devices** on the home page and + choose **Add a device**. +2. On the new device, choose **Sign in on another device** and enter the same + server URL, the device login code, and a device name. +3. The new device saves its own token in platform secure storage. Existing + sessions and the member's recovery code remain unchanged. + +`POST /pairing/devices/login-codes` requires a paired device's bearer token. +It returns `code` and `expires_at`. The code expires after ten minutes and can +be redeemed once. Creating another code on the same device replaces its previous +code. The server stores only the code hash. + +`POST /pairing/devices/login-codes/redeem` accepts +`{"code":"...","device_name":"My tablet"}` without an access token. It returns +`access_token`, `pair_id`, and `role`; it does not disclose the recovery code. +Keep device login codes private: possession grants access as the issuing member. + +`GET /pairing/devices` returns only the authenticated member's devices with +`id`, `name`, `created_at`, and `current`. No tokens or recovery codes are returned. +`DELETE /pairing/devices/:id` removes a session owned by that member and all +login codes it issued. A removed device gets HTTP 401 when it next authenticates. +The Flutter UI allows removing other devices and refreshing the list; it does +not remove the current device. All paths in this section use the `/api/v1` prefix. + +Recovery codes are not synchronized between devices. An additional device stores +no recovery code; retain the member's current recovery code outside the app. +Device revocation is enforced on server requests, not through push notifications. + +## Text messages and read receipts + +The couple space opens on the Chat tab. Text messages and read progress are +persisted in the same SQLite database as pairing and device sessions. Every chat +endpoint requires an active, paired device's Bearer token. The server derives the +pair and sender from that token, never from client-supplied identity fields. + +- `POST /api/v1/chat/messages` accepts `client_id` (1–64 bytes) and `text` + (1–4000 Unicode code points after trimming). Repeating the same member's + `client_id` returns the original message; changing its text returns HTTP 409. +- `GET /api/v1/chat/messages` returns the most recent 50 messages in ascending + server-ID order. Use `before_id` for earlier history or `after_id` for forward + updates; they are mutually exclusive. `limit` must be between 1 and 100. + `has_more` describes the requested direction's additional pages. +- Every history response includes `latest_id`, `read_id`, `partner_read_id` and + `unread_count`. Only messages from the other member count as unread. +- `POST /api/v1/chat/read` accepts `message_id`. The ID must belong to this pair. + A member's read cursor only advances; reading on a tablet also clears the + corresponding unread messages on that member's phone and computer. + +Read receipts are cumulative: acknowledging message N marks earlier messages +read too. The client acknowledges incoming messages only after they are rendered +inside the visible chat viewport while the application is foregrounded. Polling, +opening Home, and receiving a message on a hidden tab do not mark it read. +Outgoing bubbles distinguish sending, failed (retryable), unread and read states. + +The first version synchronizes every two seconds while the app is foregrounded, +including receipt-only updates and this member's messages sent from other devices. +It pauses polling in the background and catches up after resume. Unread alerts +are an in-app Chat badge and a content-free reminder on other tabs. OS/background +push notifications, attachment messages and durable offline outboxes are not yet +implemented. Drafts and failed outgoing messages survive tab switches but are +currently kept in memory; confirmed messages survive app and server restarts. + +Chat responses use `Cache-Control: no-store`. Message text is stored in SQLite; +include it when protecting and backing up the instance database. This is not +end-to-end encrypted messaging. Requests do not put message text or tokens in URLs +and the request logger does not record message bodies. + For the Android emulator, configure the Flutter client with `http://10.0.2.2:8080`; `10.0.2.2` maps to the development machine's loopback interface from inside the emulator. +The Android client accepts HTTP instance URLs in debug and release builds. +HTTP does not provide application-layer transport encryption: use it only on +trusted private networks or through Tailscale. Prefer HTTPS for public instances. +Both participants must be able to reach the instance URL used in invitations. + ## Configuration +### Persistence architecture + +The server uses [GORM](https://gorm.io/docs/) for pairing, device sessions, +device login codes, chat history and read cursors. Storage models live in +`internal/pairing/models.go`, separate from the API response types. Queries bind +client values as parameters; the ORM's SQL logger is disabled to avoid logging +private content or credential digests. + +GORM's official SQLite dialector reuses the existing `modernc.org/sqlite` +connection, so the server remains runnable with `CGO_ENABLED=0`. The dialector +has an indirect `go-sqlite3` dependency, but Pawmate does not open that driver. +SQLite still stores only credential hashes, not recoverable plaintext codes. + +Existing `pawmate.db` files are reused in place: table names, integer timestamp +units, message IDs, unique constraints and cascading device-code deletion are +unchanged. Reviewed additive SQL creates missing tables/indexes; startup does +not run `AutoMigrate` or rebuild existing tables. The legacy-token import marker +is retained so revoked devices cannot reappear on restart. Multi-step operations +use GORM transactions, including invite acceptance, credential recovery, +device-code redemption and monotonic read-cursor updates. + +Back up the database before upgrading. Do not delete it to switch to GORM. + Copy `.env.example` into your process environment before starting the server. The environment variables control the port, database path, and stable identity shown to clients. Production instances should set a real HTTPS diff --git a/server/go.mod b/server/go.mod index 7d2842a..81b1811 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,6 +4,8 @@ go 1.26.0 require ( github.com/gin-gonic/gin v1.11.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.2 modernc.org/sqlite v1.59.0 ) @@ -20,10 +22,13 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/server/go.sum b/server/go.sum index f2af4ac..ecaed9b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -36,6 +36,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -44,6 +48,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= @@ -97,6 +103,10 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= diff --git a/server/internal/httpapi/chat.go b/server/internal/httpapi/chat.go new file mode 100644 index 0000000..09e599c --- /dev/null +++ b/server/internal/httpapi/chat.go @@ -0,0 +1,83 @@ +package httpapi + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "pawmate/server/internal/pairing" +) + +// chatError returns a safe error code without logging message text or credentials. +func chatError(c *gin.Context, err error) { + switch { + case errors.Is(err, pairing.ErrInvalidMessage): + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_message"}) + case errors.Is(err, pairing.ErrInvalidReadCursor): + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_read_cursor"}) + case errors.Is(err, pairing.ErrMessageConflict): + c.JSON(http.StatusConflict, gin.H{"error": "message_id_conflict"}) + default: + deviceError(c, err) + } +} + +// chatMessagesHandler serves recent history, older pages, or incremental updates. +func chatMessagesHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + after, err1 := strconv.ParseInt(c.DefaultQuery("after_id", "0"), 10, 64) + before, err2 := strconv.ParseInt(c.DefaultQuery("before_id", "0"), 10, 64) + limit, err3 := strconv.Atoi(c.DefaultQuery("limit", "50")) + if err1 != nil || err2 != nil || err3 != nil || after < 0 || before < 0 || (after > 0 && before > 0) || limit < 1 || limit > 100 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_pagination"}) + return + } + page, err := service.Messages(deviceBearerToken(c), after, before, limit) + if err != nil { + chatError(c, err) + return + } + c.JSON(http.StatusOK, page) + } +} + +// sendChatMessageHandler assigns identity from the device token, never the body. +func sendChatMessageHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32*1024) + var request struct { + ClientID string `json:"client_id" binding:"required,max=64"` + Text string `json:"text" binding:"required,max=4000"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_message"}) + return + } + message, err := service.SendMessage(deviceBearerToken(c), request.ClientID, request.Text) + if err != nil { + chatError(c, err) + return + } + c.JSON(http.StatusOK, message) + } +} + +// readChatMessagesHandler records a monotonic member-wide read receipt. +func readChatMessagesHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var request struct { + MessageID int64 `json:"message_id" binding:"required,gt=0"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_read_cursor"}) + return + } + readID, err := service.MarkMessagesRead(deviceBearerToken(c), request.MessageID) + if err != nil { + chatError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"read_id": readID}) + } +} diff --git a/server/internal/httpapi/chat_test.go b/server/internal/httpapi/chat_test.go new file mode 100644 index 0000000..c3b575b --- /dev/null +++ b/server/internal/httpapi/chat_test.go @@ -0,0 +1,68 @@ +package httpapi + +import ( + "log/slog" + "net/http" + "net/url" + "strings" + "testing" + + "pawmate/server/internal/config" + "pawmate/server/internal/pairing" +) + +func TestChatHTTPAuthenticationAndReadFlow(t *testing.T) { + service := pairing.NewService() + defer service.Close() + invite, err := service.CreateInvite("https://home.example.test") + if err != nil { + t.Fatal(err) + } + link, _ := url.Parse(invite.URL) + partner, err := service.RedeemInvite(link.Query().Get("code")) + if err != nil { + t.Fatal(err) + } + router := NewRouter(config.Config{Environment: "test"}, slog.New(slog.NewTextHandler(testLogWriter{}, nil)), service) + unauthorized := performJSONRequest(t, router, http.MethodGet, "/api/v1/chat/messages", "", "") + if unauthorized.Code != http.StatusUnauthorized { + t.Fatal("unauthenticated history is accessible") + } + response := performJSONRequest(t, router, http.MethodPost, "/api/v1/chat/messages", `{"client_id":"first","text":"你好 🐾","sender":"invitee"}`, "Bearer "+invite.InviterToken) + if response.Code != http.StatusOK { + t.Fatalf("send status = %d", response.Code) + } + var message pairing.Message + decodeJSON(t, response, &message) + if message.Sender != "inviter" || message.Text != "你好 🐾" { + t.Fatal("sender can be spoofed or Unicode is damaged") + } + response = performJSONRequest(t, router, http.MethodGet, "/api/v1/chat/messages", "", "Bearer "+partner.InviteeToken) + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatal("private message responses can be cached") + } + var page pairing.MessagePage + decodeJSON(t, response, &page) + if page.UnreadCount != 1 || page.PartnerReadID != 0 { + t.Fatal("unread metadata incorrect") + } + response = performJSONRequest(t, router, http.MethodPost, "/api/v1/chat/read", `{"message_id":1}`, "Bearer "+partner.InviteeToken) + if response.Code != http.StatusOK { + t.Fatal("read acknowledgment failed") + } + response = performJSONRequest(t, router, http.MethodGet, "/api/v1/chat/messages?after_id=1", "", "Bearer "+invite.InviterToken) + decodeJSON(t, response, &page) + if page.PartnerReadID != message.ID || len(page.Messages) != 0 { + t.Fatal("receipt-only update failed") + } + for _, query := range []string{"limit=0", "limit=101", "after_id=-1", "after_id=1&before_id=2", "before_id=oops"} { + response = performJSONRequest(t, router, http.MethodGet, "/api/v1/chat/messages?"+query, "", "Bearer "+invite.InviterToken) + if response.Code != http.StatusBadRequest { + t.Fatal("invalid pagination accepted", query) + } + } + response = performJSONRequest(t, router, http.MethodPost, "/api/v1/chat/messages", `{"client_id":"large","text":"`+strings.Repeat("x", 4001)+`"}`, "Bearer "+invite.InviterToken) + if response.Code != http.StatusBadRequest { + t.Fatal("oversized message accepted") + } +} diff --git a/server/internal/httpapi/devices.go b/server/internal/httpapi/devices.go new file mode 100644 index 0000000..a919885 --- /dev/null +++ b/server/internal/httpapi/devices.go @@ -0,0 +1,90 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "pawmate/server/internal/pairing" +) + +// deviceBearerToken extracts an access token without accepting unrelated schemes. +func deviceBearerToken(c *gin.Context) string { + scheme, token, found := strings.Cut(c.GetHeader("Authorization"), " ") + if !found || !strings.EqualFold(scheme, "Bearer") { + return "" + } + return strings.TrimSpace(token) +} + +// deviceError maps device-service failures to public API errors. +func deviceError(c *gin.Context, err error) { + status, code := http.StatusInternalServerError, "internal_error" + switch { + case errors.Is(err, pairing.ErrInvalidSessionToken): + status, code = http.StatusUnauthorized, "invalid_session_token" + case errors.Is(err, pairing.ErrInvalidDeviceCode): + status, code = http.StatusUnauthorized, "invalid_device_code" + case errors.Is(err, pairing.ErrNotPaired): + status, code = http.StatusConflict, "not_paired" + case errors.Is(err, pairing.ErrDeviceNotFound): + status, code = http.StatusNotFound, "device_not_found" + } + c.JSON(status, gin.H{"error": code}) +} + +// devicesHandler lists this member's sessions without exposing any credentials. +func devicesHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + devices, err := service.Devices(deviceBearerToken(c)) + if err != nil { + deviceError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"devices": devices}) + } +} + +// revokeDeviceHandler revokes only devices owned by the authenticated member. +func revokeDeviceHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + if err := service.RevokeDevice(deviceBearerToken(c), c.Param("id")); err != nil { + deviceError(c, err) + return + } + c.Status(http.StatusNoContent) + } +} + +// deviceLoginCodeHandler issues a short-lived, single-use additional-device code. +func deviceLoginCodeHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + code, err := service.CreateDeviceLoginCode(deviceBearerToken(c)) + if err != nil { + deviceError(c, err) + return + } + c.JSON(http.StatusCreated, code) + } +} + +// redeemDeviceLoginCodeHandler exchanges a code for an independent device token. +func redeemDeviceLoginCodeHandler(service *pairing.Service) gin.HandlerFunc { + return func(c *gin.Context) { + var request struct { + Code string `json:"code" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + credentials, err := service.RedeemDeviceLoginCode(request.Code, request.DeviceName) + if err != nil { + deviceError(c, err) + return + } + c.JSON(http.StatusOK, credentials) + } +} diff --git a/server/internal/httpapi/pairing.go b/server/internal/httpapi/pairing.go index 62487fc..d7a5dae 100644 --- a/server/internal/httpapi/pairing.go +++ b/server/internal/httpapi/pairing.go @@ -11,7 +11,8 @@ import ( ) type createInviteRequest struct { - ServerURL string `json:"server_url" binding:"required"` + ServerURL string `json:"server_url" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` } type createInviteResponse struct { @@ -22,7 +23,8 @@ type createInviteResponse struct { } type redeemInviteRequest struct { - Code string `json:"code" binding:"required"` + Code string `json:"code" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` } type pairingStatusResponse struct { @@ -34,6 +36,7 @@ type pairingStatusResponse struct { type recoverPairingRequest struct { RecoveryCode string `json:"recovery_code" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` } type recoverPairingResponse struct { @@ -58,7 +61,7 @@ func createInviteHandler(service *pairing.Service) gin.HandlerFunc { return } - invite, err := service.CreateInvite(request.ServerURL) + invite, err := service.CreateInvite(request.ServerURL, request.DeviceName) if err != nil { status := http.StatusInternalServerError code := "internal_error" @@ -92,7 +95,7 @@ func redeemInviteHandler(service *pairing.Service) gin.HandlerFunc { return } - status, err := service.RedeemInvite(request.Code) + status, err := service.RedeemInvite(request.Code, request.DeviceName) if err != nil { code := "invalid_invite" httpStatus := http.StatusConflict @@ -123,7 +126,7 @@ func recoverPairingHandler(service *pairing.Service) gin.HandlerFunc { return } - credentials, err := service.Recover(request.RecoveryCode) + credentials, err := service.Recover(request.RecoveryCode, request.DeviceName) if err != nil { code := "invalid_recovery_code" status := http.StatusUnauthorized diff --git a/server/internal/httpapi/pairing_test.go b/server/internal/httpapi/pairing_test.go index 203bdd7..77ccac5 100644 --- a/server/internal/httpapi/pairing_test.go +++ b/server/internal/httpapi/pairing_test.go @@ -58,6 +58,68 @@ func TestPairingHTTPFlow(t *testing.T) { if !strings.Contains(statusResponse.Body.String(), `"status":"paired"`) { t.Fatalf("paired status = %s", statusResponse.Body.String()) } + var partner struct { + Token string `json:"invitee_token"` + } + decodeJSON(t, redeemResponse, &partner) + unauthorized := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/devices/login-codes", "", "") + if unauthorized.Code != http.StatusUnauthorized { + t.Fatal("unauthenticated device can issue a login code") + } + loginResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/devices/login-codes", "", "Bearer "+invite.InviterToken) + if loginResponse.Code != http.StatusCreated { + t.Fatalf("create device login status: %d", loginResponse.Code) + } + var login struct { + Code string `json:"code"` + } + decodeJSON(t, loginResponse, &login) + deviceResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/devices/login-codes/redeem", `{"code":"`+login.Code+`","device_name":"Tablet"}`, "") + if deviceResponse.Code != http.StatusOK { + t.Fatalf("device sign-in status: %d", deviceResponse.Code) + } + if strings.Contains(deviceResponse.Body.String(), "recovery_code") { + t.Fatal("device sign-in disclosed recovery secret") + } + var tablet struct { + Token string `json:"access_token"` + } + decodeJSON(t, deviceResponse, &tablet) + for _, token := range []string{invite.InviterToken, tablet.Token, partner.Token} { + response := performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/session", "", "Bearer "+token) + if response.Code != http.StatusOK { + t.Fatal("adding a device invalidated a member session") + } + } + devicesResponse := performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/devices", "", "Bearer "+tablet.Token) + var devices struct { + Devices []pairing.Device `json:"devices"` + } + decodeJSON(t, devicesResponse, &devices) + if len(devices.Devices) != 2 { + t.Fatal("device list leaked partner's devices or lost own device") + } + if strings.Contains(devicesResponse.Body.String(), "token") || strings.Contains(devicesResponse.Body.String(), "recovery") { + t.Fatal("device list disclosed credentials") + } + var phoneID string + for _, device := range devices.Devices { + if !device.Current { + phoneID = device.ID + } + } + forbidden := performJSONRequest(t, router, http.MethodDelete, "/api/v1/pairing/devices/"+phoneID, "", "Bearer "+partner.Token) + if forbidden.Code != http.StatusNotFound { + t.Fatal("partner could revoke another member's device") + } + revoked := performJSONRequest(t, router, http.MethodDelete, "/api/v1/pairing/devices/"+phoneID, "", "Bearer "+tablet.Token) + if revoked.Code != http.StatusNoContent { + t.Fatalf("revoke status: %d", revoked.Code) + } + rejected := performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/session", "", "Bearer "+invite.InviterToken) + if rejected.Code != http.StatusUnauthorized { + t.Fatal("revoked token still authenticates") + } } func performJSONRequest(t *testing.T, handler http.Handler, method, path, body, authorization string) *httptest.ResponseRecorder { diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index e381666..8c35cec 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -37,6 +37,15 @@ func NewRouter(cfg config.Config, logger *slog.Logger, pairingService *pairing.S api.GET("/pairing/invites/status", pairingStatusHandler(pairingService)) api.POST("/pairing/recover", recoverPairingHandler(pairingService)) api.GET("/pairing/session", pairingSessionHandler(pairingService)) + api.GET("/pairing/devices", devicesHandler(pairingService)) + api.DELETE("/pairing/devices/:id", revokeDeviceHandler(pairingService)) + api.POST("/pairing/devices/login-codes", deviceLoginCodeHandler(pairingService)) + api.POST("/pairing/devices/login-codes/redeem", redeemDeviceLoginCodeHandler(pairingService)) + chat := api.Group("/chat") + chat.Use(func(c *gin.Context) { c.Header("Cache-Control", "no-store"); c.Next() }) + chat.GET("/messages", chatMessagesHandler(pairingService)) + chat.POST("/messages", sendChatMessageHandler(pairingService)) + chat.POST("/read", readChatMessagesHandler(pairingService)) return router } diff --git a/server/internal/pairing/devices.go b/server/internal/pairing/devices.go new file mode 100644 index 0000000..39f92fd --- /dev/null +++ b/server/internal/pairing/devices.go @@ -0,0 +1,200 @@ +package pairing + +import ( + "errors" + "strings" + "time" + + "gorm.io/gorm" +) + +var ( + ErrInvalidDeviceCode = errors.New("device login code is invalid or expired") + ErrDeviceNotFound = errors.New("device does not belong to this member") +) + +// Device describes one independently authenticated installation, without its token. +type Device struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + Current bool `json:"current"` +} + +// DeviceLoginCode grants one additional device access to the issuing member. +type DeviceLoginCode struct { + Code string `json:"code"` + ExpiresAt time.Time `json:"expires_at"` +} + +// DeviceCredentials is issued once when a new device redeems a login code. +type DeviceCredentials struct { + AccessToken string `json:"access_token"` + PairID string `json:"pair_id"` + Role string `json:"role"` +} + +// migrateDevices atomically imports legacy tokens once, preserving existing logins. +func (service *Service) migrateDevices() error { + return service.db.Transaction(func(tx *gorm.DB) error { + var migrated int64 + if err := tx.Model(&deviceMigration{}).Where("version = ?", 1).Count(&migrated).Error; err != nil { + return err + } + if migrated != 0 { + return nil + } + var state pairingRecord + err := tx.Take(&state, "id = ?", 1).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + for role, hash := range map[string][]byte{"inviter": state.InviterTokenHash, "invitee": state.InviteeTokenHash} { + if len(hash) == 0 { + continue + } + record := deviceRecord{ID: "legacy-" + role, Role: role, TokenHash: hash, + Name: "Existing device", CreatedAt: service.now().Unix()} + if err := tx.Create(&record).Error; err != nil { + return err + } + } + // A durable marker prevents revoked legacy tokens being re-imported on restart. + return tx.Create(&deviceMigration{Version: 1}).Error + }) +} + +// deviceName bounds the optional display name; it is never used as identity. +func deviceName(names []string) string { + if len(names) == 0 || strings.TrimSpace(names[0]) == "" { + return "Pawmate device" + } + runes := []rune(strings.TrimSpace(names[0])) + if len(runes) > 80 { + runes = runes[:80] + } + return string(runes) +} + +// insertDevice records a unique token hash in the caller's transaction. +func (service *Service) insertDevice(tx *gorm.DB, role, token, name string) (string, error) { + id, err := randomToken(16) + if err != nil { + return "", err + } + hash := tokenHash(token) + err = tx.Create(&deviceRecord{ID: id, Role: role, TokenHash: hash[:], Name: name, CreatedAt: service.now().Unix()}).Error + return id, err +} + +// CreateDeviceLoginCode issues a ten-minute code from an already paired device. +func (service *Service) CreateDeviceLoginCode(accessToken string) (DeviceLoginCode, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.authenticateDevice(accessToken) + if err != nil { + return DeviceLoginCode{}, err + } + if session.Status != "paired" { + return DeviceLoginCode{}, ErrNotPaired + } + code, err := randomToken(24) + if err != nil { + return DeviceLoginCode{}, err + } + expiresAt := service.now().Add(inviteTTL) + hash := tokenHash(code) + err = service.db.Transaction(func(tx *gorm.DB) error { + // Each device has at most one outstanding login code. + if err := tx.Where("issuer_id = ? OR expires_at <= ?", session.DeviceID, service.now().Unix()).Delete(&deviceCodeRecord{}).Error; err != nil { + return err + } + return tx.Create(&deviceCodeRecord{CodeHash: hash[:], Role: session.Role, + IssuerID: session.DeviceID, ExpiresAt: expiresAt.Unix()}).Error + }) + if err != nil { + return DeviceLoginCode{}, err + } + return DeviceLoginCode{Code: code, ExpiresAt: expiresAt}, nil +} + +// RedeemDeviceLoginCode consumes a code and adds a session without revoking others. +func (service *Service) RedeemDeviceLoginCode(code, name string) (DeviceCredentials, error) { + service.mu.Lock() + defer service.mu.Unlock() + hash := tokenHash(strings.TrimSpace(code)) + var credentials DeviceCredentials + err := service.db.Transaction(func(tx *gorm.DB) error { + var record struct { + Role string + PairID string + } + err := tx.Table("device_login_codes AS c").Select("c.role, p.pair_id"). + Joins("JOIN pairing_state AS p ON p.id = 1 AND p.paired = 1"). + Where("c.code_hash = ? AND c.expires_at > ?", hash[:], service.now().Unix()).Take(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrInvalidDeviceCode + } + if err != nil { + return err + } + result := tx.Where("code_hash = ? AND expires_at > ?", hash[:], service.now().Unix()).Delete(&deviceCodeRecord{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrInvalidDeviceCode + } + token, err := randomToken(32) + if err != nil { + return err + } + if _, err := service.insertDevice(tx, record.Role, token, deviceName([]string{name})); err != nil { + return err + } + credentials = DeviceCredentials{AccessToken: token, PairID: record.PairID, Role: record.Role} + return nil + }) + if err != nil { + return DeviceCredentials{}, err + } + return credentials, nil +} + +// Devices lists only the authenticated member's installations. +func (service *Service) Devices(accessToken string) ([]Device, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.authenticateDevice(accessToken) + if err != nil { + return nil, err + } + var records []deviceRecord + if err := service.db.Where("role = ?", session.Role).Order("created_at, id").Find(&records).Error; err != nil { + return nil, err + } + devices := []Device{} + for _, record := range records { + devices = append(devices, Device{ID: record.ID, Name: record.Name, + CreatedAt: time.Unix(record.CreatedAt, 0).UTC(), Current: record.ID == session.DeviceID}) + } + return devices, nil +} + +// RevokeDevice removes one of this member's sessions and its outstanding codes. +func (service *Service) RevokeDevice(accessToken, deviceID string) error { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.authenticateDevice(accessToken) + if err != nil { + return err + } + result := service.db.Where("id = ? AND role = ?", deviceID, session.Role).Delete(&deviceRecord{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrDeviceNotFound + } + return nil +} diff --git a/server/internal/pairing/devices_test.go b/server/internal/pairing/devices_test.go new file mode 100644 index 0000000..5573881 --- /dev/null +++ b/server/internal/pairing/devices_test.go @@ -0,0 +1,206 @@ +package pairing + +import ( + "errors" + "path/filepath" + "sync" + "testing" + "time" +) + +func pairedDevices(t *testing.T, service *Service) (Invite, PairingStatus) { + t.Helper() + invite, err := service.CreateInvite("https://home.example.test", "Phone") + if err != nil { + t.Fatal(err) + } + partner, err := service.RedeemInvite(inviteCode(t, invite.URL), "Partner phone") + if err != nil { + t.Fatal(err) + } + return invite, partner +} + +func TestAdditionalDevicesRemainAuthenticatedAfterRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "pawmate.db") + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + invite, partner := pairedDevices(t, service) + tokens := []string{invite.InviterToken, partner.InviteeToken} + for _, name := range []string{"Tablet", "Computer"} { + code, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + credentials, err := service.RedeemDeviceLoginCode(code.Code, name) + if err != nil { + t.Fatal(err) + } + if credentials.PairID != partner.PairID || credentials.Role != "inviter" { + t.Fatal("new device changed member identity") + } + tokens = append(tokens, credentials.AccessToken) + if _, err := service.RedeemDeviceLoginCode(code.Code, "Again"); !errors.Is(err, ErrInvalidDeviceCode) { + t.Fatalf("replay error: %v", err) + } + } + if err := service.Close(); err != nil { + t.Fatal(err) + } + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + defer service.Close() + for _, token := range tokens { + if session, err := service.Authenticate(token); err != nil || session.Status != "paired" { + t.Fatalf("device lost login: %v", err) + } + } + devices, err := service.Devices(tokens[2]) + if err != nil || len(devices) != 3 { + t.Fatalf("devices = %v, error = %v", devices, err) + } + current := 0 + for _, device := range devices { + if device.Current { + current++ + } + } + if current != 1 { + t.Fatal("device list did not identify the current device") + } + // Adding devices does not consume or rotate the member recovery code. + if _, err := service.Recover(invite.RecoveryCode); err != nil { + t.Fatal(err) + } + for _, token := range []string{tokens[0], tokens[2], tokens[3]} { + if _, err := service.Authenticate(token); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("recovery left an old device active: %v", err) + } + } + if _, err := service.Authenticate(partner.InviteeToken); err != nil { + t.Fatal("recovery affected the partner") + } +} + +func TestDeviceRevocationIsMemberScopedAndInvalidatesIssuedCodes(t *testing.T) { + service := NewService() + defer service.Close() + invite, partner := pairedDevices(t, service) + code, _ := service.CreateDeviceLoginCode(invite.InviterToken) + computer, err := service.RedeemDeviceLoginCode(code.Code, "Computer") + if err != nil { + t.Fatal(err) + } + phone, _ := service.Authenticate(invite.InviterToken) + if err := service.RevokeDevice(partner.InviteeToken, phone.DeviceID); !errors.Is(err, ErrDeviceNotFound) { + t.Fatalf("partner can revoke own member's device: %v", err) + } + pending, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + if err := service.RevokeDevice(computer.AccessToken, phone.DeviceID); err != nil { + t.Fatal(err) + } + if _, err := service.Authenticate(invite.InviterToken); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("revoked token accepted: %v", err) + } + if _, err := service.RedeemDeviceLoginCode(pending.Code, "Tablet"); !errors.Is(err, ErrInvalidDeviceCode) { + t.Fatalf("revoked device's code accepted: %v", err) + } + if _, err := service.Authenticate(computer.AccessToken); err != nil { + t.Fatal(err) + } +} + +func TestDeviceLoginCodeExpiryAndConcurrentConsumption(t *testing.T) { + service := NewService() + defer service.Close() + clock := time.Now() + service.now = func() time.Time { return clock } + invite, _ := pairedDevices(t, service) + code, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + clock = code.ExpiresAt + if _, err := service.RedeemDeviceLoginCode(code.Code, "Tablet"); !errors.Is(err, ErrInvalidDeviceCode) { + t.Fatalf("expired code accepted: %v", err) + } + code, err = service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + results := make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := service.RedeemDeviceLoginCode(code.Code, "Computer") + results <- err + }() + } + wg.Wait() + close(results) + successes := 0 + for err := range results { + if err == nil { + successes++ + } else if !errors.Is(err, ErrInvalidDeviceCode) { + t.Fatal(err) + } + } + if successes != 1 { + t.Fatalf("code redeemed %d times", successes) + } +} + +func TestLegacyTokenMigrationDoesNotRestoreRevokedDevice(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + invite, partner := pairedDevices(t, service) + // Remove only the new tables to reproduce the previous release's database. + for _, table := range []string{"device_login_codes", "device_sessions", "device_migrations"} { + if err := service.db.Exec("DROP TABLE " + table).Error; err != nil { + t.Fatal(err) + } + } + service.Close() + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + for _, token := range []string{invite.InviterToken, partner.InviteeToken} { + if _, err := service.Authenticate(token); err != nil { + t.Fatal("migration rejected existing token", err) + } + } + code, _ := service.CreateDeviceLoginCode(invite.InviterToken) + computer, err := service.RedeemDeviceLoginCode(code.Code, "Computer") + if err != nil { + t.Fatal(err) + } + if err := service.RevokeDevice(computer.AccessToken, "legacy-inviter"); err != nil { + t.Fatal(err) + } + service.Close() + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + defer service.Close() + if _, err := service.Authenticate(invite.InviterToken); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("restart restored revoked legacy token: %v", err) + } + if _, err := service.Authenticate(computer.AccessToken); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/pairing/messages.go b/server/internal/pairing/messages.go new file mode 100644 index 0000000..04f0475 --- /dev/null +++ b/server/internal/pairing/messages.go @@ -0,0 +1,188 @@ +package pairing + +import ( + "errors" + "strings" + "time" + "unicode/utf8" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrInvalidMessage = errors.New("message must contain 1 to 4000 characters and a client id") + ErrMessageConflict = errors.New("client id was already used for different content") + ErrInvalidReadCursor = errors.New("read cursor does not identify a message in this home") +) + +// Message is a persisted text message with a server-assigned sequence number. +type Message struct { + ID int64 `json:"id"` + ClientID string `json:"client_id"` + Sender string `json:"sender"` + Text string `json:"text"` + CreatedAt time.Time `json:"created_at"` +} + +// MessagePage combines history with member-level read progress and unread count. +type MessagePage struct { + Messages []Message `json:"messages"` + HasMore bool `json:"has_more"` + LatestID int64 `json:"latest_id"` + ReadID int64 `json:"read_id"` + PartnerReadID int64 `json:"partner_read_id"` + UnreadCount int `json:"unread_count"` +} + +// chatSession requires an active paired device; the caller holds the service mutex. +func (service *Service) chatSession(token string) (Session, error) { + session, err := service.authenticateDevice(token) + if err != nil { + return Session{}, err + } + if session.Status != "paired" { + return Session{}, ErrNotPaired + } + return session, nil +} + +// SendMessage stores text once per member/client id, including ambiguous retries. +func (service *Service) SendMessage(token, clientID, text string) (Message, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.chatSession(token) + if err != nil { + return Message{}, err + } + clientID, text = strings.TrimSpace(clientID), strings.TrimSpace(text) + if clientID == "" || len(clientID) > 64 || !utf8.ValidString(text) || text == "" || utf8.RuneCountInString(text) > 4000 { + return Message{}, ErrInvalidMessage + } + record := messageRecord{PairID: session.PairID, Sender: session.Role, ClientID: clientID, + Text: text, CreatedAt: service.now().UnixMilli()} + err = service.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "pair_id"}, {Name: "sender"}, {Name: "client_id"}}, DoNothing: true, + }).Create(&record).Error; err != nil { + return err + } + // Reload into a fresh model: retries must not add an implicit ID condition. + var stored messageRecord + if err := tx.Where("pair_id = ? AND sender = ? AND client_id = ?", session.PairID, session.Role, clientID).Take(&stored).Error; err != nil { + return err + } + if stored.Text != text { + return ErrMessageConflict + } + record = stored + return nil + }) + if err != nil { + return Message{}, err + } + return record.message(), nil +} + +// Messages returns bounded history or forward updates with coherent read metadata. +func (service *Service) Messages(token string, afterID, beforeID int64, limit int) (MessagePage, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.chatSession(token) + if err != nil { + return MessagePage{}, err + } + if afterID < 0 || beforeID < 0 || (afterID > 0 && beforeID > 0) || limit < 1 || limit > 100 { + return MessagePage{}, ErrInvalidMessage + } + // One read transaction keeps pagination and receipts consistent across processes. + page := MessagePage{Messages: []Message{}} + err = service.db.Transaction(func(tx *gorm.DB) error { + query := tx.Where("pair_id = ?", session.PairID) + if afterID > 0 { + query = query.Where("id > ?", afterID) + } + if beforeID > 0 { + query = query.Where("id < ?", beforeID) + } + if afterID > 0 { + query = query.Order("id ASC") + } else { + query = query.Order("id DESC") + } + var records []messageRecord + if err := query.Limit(limit + 1).Find(&records).Error; err != nil { + return err + } + page.HasMore = len(records) > limit + if page.HasMore { + records = records[:limit] + } + for _, record := range records { + page.Messages = append(page.Messages, record.message()) + } + if afterID == 0 { + for i, j := 0, len(page.Messages)-1; i < j; i, j = i+1, j-1 { + page.Messages[i], page.Messages[j] = page.Messages[j], page.Messages[i] + } + } + if err := tx.Model(&messageRecord{}).Where("pair_id = ?", session.PairID). + Select("COALESCE(MAX(id), 0)").Scan(&page.LatestID).Error; err != nil { + return err + } + var cursors []readRecord + if err := tx.Where("pair_id = ?", session.PairID).Find(&cursors).Error; err != nil { + return err + } + for _, cursor := range cursors { + if cursor.Role == session.Role { + page.ReadID = cursor.MessageID + } else if cursor.MessageID > page.PartnerReadID { + page.PartnerReadID = cursor.MessageID + } + } + var unread int64 + if err := tx.Model(&messageRecord{}).Where("pair_id = ? AND sender != ? AND id > ?", session.PairID, session.Role, page.ReadID).Count(&unread).Error; err != nil { + return err + } + page.UnreadCount = int(unread) + return nil + }) + if err != nil { + return MessagePage{}, err + } + return page, nil +} + +// MarkMessagesRead advances a shared member cursor without allowing future ids. +func (service *Service) MarkMessagesRead(token string, messageID int64) (int64, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.chatSession(token) + if err != nil { + return 0, err + } + if messageID <= 0 { + return 0, ErrInvalidReadCursor + } + var cursor readRecord + err = service.db.Transaction(func(tx *gorm.DB) error { + var exists int64 + if err := tx.Model(&messageRecord{}).Where("pair_id = ? AND id = ?", session.PairID, messageID).Count(&exists).Error; err != nil { + return err + } + if exists != 1 { + return ErrInvalidReadCursor + } + record := readRecord{PairID: session.PairID, Role: session.Role, MessageID: messageID} + // An atomic upsert prevents an older device from moving the cursor backwards. + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "pair_id"}, {Name: "role"}}, + DoUpdates: clause.Assignments(map[string]any{"message_id": gorm.Expr("MAX(chat_reads.message_id, excluded.message_id)")}), + }).Create(&record).Error; err != nil { + return err + } + return tx.Where("pair_id = ? AND role = ?", session.PairID, session.Role).Take(&cursor).Error + }) + return cursor.MessageID, err +} diff --git a/server/internal/pairing/messages_test.go b/server/internal/pairing/messages_test.go new file mode 100644 index 0000000..8e4cc86 --- /dev/null +++ b/server/internal/pairing/messages_test.go @@ -0,0 +1,153 @@ +package pairing + +import ( + "errors" + "fmt" + "path/filepath" + "sync" + "testing" +) + +func TestMessagesAndSharedReadReceiptsSurviveRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "chat.db") + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + invite, partner := pairedDevices(t, service) + first, err := service.SendMessage(invite.InviterToken, "phone-1", "你好,一起做饭吧 🐾") + if err != nil { + t.Fatal(err) + } + second, err := service.SendMessage(partner.InviteeToken, "partner-1", "好呀") + if err != nil { + t.Fatal(err) + } + page, err := service.Messages(partner.InviteeToken, 0, 0, 50) + if err != nil || page.UnreadCount != 1 || len(page.Messages) != 2 { + t.Fatalf("unexpected initial history: %+v, %v", page, err) + } + if page.Messages[0].Text != first.Text { + t.Fatal("Unicode message changed") + } + code, _ := service.CreateDeviceLoginCode(partner.InviteeToken) + tablet, err := service.RedeemDeviceLoginCode(code.Code, "Tablet") + if err != nil { + t.Fatal(err) + } + if _, err := service.MarkMessagesRead(tablet.AccessToken, first.ID); err != nil { + t.Fatal(err) + } + page, err = service.Messages(partner.InviteeToken, 0, 0, 50) + if err != nil || page.ReadID != first.ID || page.UnreadCount != 0 { + t.Fatal("read receipt did not synchronize across devices", err) + } + page, _ = service.Messages(invite.InviterToken, 0, 0, 50) + if page.PartnerReadID != first.ID || page.UnreadCount != 1 { + t.Fatal("partner receipt or own unread count is wrong") + } + if _, err := service.MarkMessagesRead(invite.InviterToken, second.ID); err != nil { + t.Fatal(err) + } + if read, err := service.MarkMessagesRead(invite.InviterToken, first.ID); err != nil || read != second.ID { + t.Fatal("read cursor regressed", err) + } + if _, err := service.MarkMessagesRead(invite.InviterToken, second.ID+100); !errors.Is(err, ErrInvalidReadCursor) { + t.Fatal("future cursor accepted", err) + } + service.Close() + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + defer service.Close() + page, err = service.Messages(invite.InviterToken, 0, 0, 50) + if err != nil || len(page.Messages) != 2 || page.UnreadCount != 0 || page.PartnerReadID != first.ID { + t.Fatal("restart lost messages or receipts", err) + } +} + +func TestMessageRetriesAreIdempotentAndMemberScoped(t *testing.T) { + service := NewService() + defer service.Close() + invite, partner := pairedDevices(t, service) + var wg sync.WaitGroup + ids := make(chan int64, 4) + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + message, err := service.SendMessage(invite.InviterToken, "same-client-id", "hello") + if err != nil { + t.Error(err) + return + } + ids <- message.ID + }() + } + wg.Wait() + close(ids) + var firstID int64 + for id := range ids { + if firstID == 0 { + firstID = id + } + if id != firstID { + t.Fatal("retry duplicated a message") + } + } + if _, err := service.SendMessage(invite.InviterToken, "same-client-id", "different"); !errors.Is(err, ErrMessageConflict) { + t.Fatal("client id conflict accepted", err) + } + if _, err := service.SendMessage(partner.InviteeToken, "same-client-id", "hello"); err != nil { + t.Fatal("client ids incorrectly collide across members", err) + } + page, _ := service.Messages(partner.InviteeToken, 0, 0, 50) + if len(page.Messages) != 2 || page.UnreadCount != 1 { + t.Fatal("idempotency changed counts") + } +} + +func TestChatPaginationValidationAndRevokedSessions(t *testing.T) { + service := NewService() + defer service.Close() + invite, _ := pairedDevices(t, service) + for i := 0; i < 7; i++ { + if _, err := service.SendMessage(invite.InviterToken, fmt.Sprint(i), fmt.Sprint("message ", i)); err != nil { + t.Fatal(err) + } + } + recent, err := service.Messages(invite.InviterToken, 0, 0, 3) + if err != nil || !recent.HasMore || len(recent.Messages) != 3 { + t.Fatal("recent pagination failed", err) + } + older, err := service.Messages(invite.InviterToken, 0, recent.Messages[0].ID, 3) + if err != nil || !older.HasMore || older.Messages[2].ID >= recent.Messages[0].ID { + t.Fatal("history pages overlap", err) + } + forward, err := service.Messages(invite.InviterToken, older.Messages[0].ID, 0, 3) + if err != nil || !forward.HasMore || forward.Messages[0].ID <= older.Messages[0].ID { + t.Fatal("forward pagination failed", err) + } + for _, text := range []string{"", " "} { + if _, err := service.SendMessage(invite.InviterToken, "empty", text); !errors.Is(err, ErrInvalidMessage) { + t.Fatal("empty message accepted") + } + } + if _, err := service.Messages(invite.InviterToken, 1, 1, 3); !errors.Is(err, ErrInvalidMessage) { + t.Fatal("ambiguous pagination accepted") + } + session, _ := service.Authenticate(invite.InviterToken) + if err := service.RevokeDevice(invite.InviterToken, session.DeviceID); err != nil { + t.Fatal(err) + } + if _, err := service.SendMessage(invite.InviterToken, "revoked", "no"); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatal("revoked device can send") + } + if _, err := service.Messages(invite.InviterToken, 0, 0, 50); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatal("revoked device can read") + } + if _, err := service.MarkMessagesRead(invite.InviterToken, recent.LatestID); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatal("revoked device can acknowledge") + } +} diff --git a/server/internal/pairing/models.go b/server/internal/pairing/models.go new file mode 100644 index 0000000..d0623d6 --- /dev/null +++ b/server/internal/pairing/models.go @@ -0,0 +1,128 @@ +package pairing + +import ( + "time" + + "gorm.io/gorm" +) + +// pairingRecord maps the existing singleton schema without changing stored hashes. +type pairingRecord struct { + ID int `gorm:"primaryKey;autoIncrement:false"` + ServerURL string + InviteCodeHash []byte + InviteExpiresAt int64 + InviterTokenHash []byte + InviterRecoveryHash []byte + PairID *string + InviteeTokenHash []byte + InviteeRecoveryHash []byte + Paired bool +} + +// TableName preserves the singular table name used by existing installations. +func (pairingRecord) TableName() string { return "pairing_state" } + +// deviceRecord stores a device's hashed token and legacy Unix-second timestamp. +type deviceRecord struct { + ID string `gorm:"primaryKey"` + Role string + TokenHash []byte + Name string + CreatedAt int64 `gorm:"autoCreateTime:false"` +} + +// TableName preserves the existing device-session table. +func (deviceRecord) TableName() string { return "device_sessions" } + +// deviceCodeRecord stores only a digest of a short-lived device login code. +type deviceCodeRecord struct { + CodeHash []byte `gorm:"primaryKey"` + Role string + IssuerID string + ExpiresAt int64 +} + +// TableName preserves the existing login-code table and its cascading foreign key. +func (deviceCodeRecord) TableName() string { return "device_login_codes" } + +// deviceMigration records the completed legacy-token import. +type deviceMigration struct { + Version int `gorm:"primaryKey;autoIncrement:false"` +} + +// TableName preserves the durable marker that prevents revoked token resurrection. +func (deviceMigration) TableName() string { return "device_migrations" } + +// messageRecord separates SQLite millisecond timestamps from the public API model. +type messageRecord struct { + ID int64 `gorm:"primaryKey;autoIncrement"` + PairID string + Sender string + ClientID string + Text string + CreatedAt int64 `gorm:"autoCreateTime:false"` +} + +// TableName preserves the persisted chat history table. +func (messageRecord) TableName() string { return "chat_messages" } + +// message converts storage timestamps without exposing storage-only pair metadata. +func (record messageRecord) message() Message { + return Message{ID: record.ID, ClientID: record.ClientID, Sender: record.Sender, + Text: record.Text, CreatedAt: time.UnixMilli(record.CreatedAt).UTC()} +} + +// readRecord maps the composite member-level read-cursor key. +type readRecord struct { + PairID string `gorm:"primaryKey"` + Role string `gorm:"primaryKey"` + MessageID int64 +} + +// TableName preserves the existing read-receipt table. +func (readRecord) TableName() string { return "chat_reads" } + +// migrateSchema creates only missing tables/indexes; it never rebuilds old tables. +// Explicit DDL preserves singleton checks, idempotency keys, foreign keys and +// INTEGER timestamps. Future schema changes should use reviewed versioned SQL, +// not AutoMigrate on a live private instance. +func migrateSchema(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + for _, schema := range []string{ + `CREATE TABLE IF NOT EXISTS pairing_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), server_url TEXT NOT NULL, + invite_code_hash BLOB NOT NULL, invite_expires_at INTEGER NOT NULL, + inviter_token_hash BLOB NOT NULL, inviter_recovery_hash BLOB NOT NULL, + pair_id TEXT, invitee_token_hash BLOB, invitee_recovery_hash BLOB, + paired INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS device_sessions ( + id TEXT PRIMARY KEY, role TEXT NOT NULL CHECK(role IN ('inviter','invitee')), + token_hash BLOB NOT NULL UNIQUE, name TEXT NOT NULL, created_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS device_login_codes ( + code_hash BLOB PRIMARY KEY, role TEXT NOT NULL, issuer_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY(issuer_id) REFERENCES device_sessions(id) ON DELETE CASCADE + )`, + `CREATE TABLE IF NOT EXISTS device_migrations (version INTEGER PRIMARY KEY)`, + `CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, pair_id TEXT NOT NULL, + sender TEXT NOT NULL CHECK(sender IN ('inviter','invitee')), + client_id TEXT NOT NULL, text TEXT NOT NULL, created_at INTEGER NOT NULL, + UNIQUE(pair_id, sender, client_id) + )`, + `CREATE INDEX IF NOT EXISTS chat_messages_pair_id ON chat_messages(pair_id, id)`, + `CREATE TABLE IF NOT EXISTS chat_reads ( + pair_id TEXT NOT NULL, role TEXT NOT NULL, + message_id INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(pair_id, role) + )`, + } { + if err := tx.Exec(schema).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/server/internal/pairing/orm_test.go b/server/internal/pairing/orm_test.go new file mode 100644 index 0000000..40b1b7d --- /dev/null +++ b/server/internal/pairing/orm_test.go @@ -0,0 +1,173 @@ +package pairing + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + "time" +) + +// TestORMOpensPreORMDatabase builds a historical fixture without ORM models. +func TestORMOpensPreORMDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "before-gorm.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + // Keep this snapshot independent of migrateSchema so model/schema drift is caught. + for _, schema := range []string{ + `CREATE TABLE pairing_state (id INTEGER PRIMARY KEY CHECK(id = 1), server_url TEXT NOT NULL, + invite_code_hash BLOB NOT NULL, invite_expires_at INTEGER NOT NULL, inviter_token_hash BLOB NOT NULL, + inviter_recovery_hash BLOB NOT NULL, pair_id TEXT, invitee_token_hash BLOB, invitee_recovery_hash BLOB, + paired INTEGER NOT NULL DEFAULT 0)`, + `CREATE TABLE device_sessions (id TEXT PRIMARY KEY, role TEXT NOT NULL CHECK(role IN ('inviter','invitee')), + token_hash BLOB NOT NULL UNIQUE, name TEXT NOT NULL, created_at INTEGER NOT NULL)`, + `CREATE TABLE device_login_codes (code_hash BLOB PRIMARY KEY, role TEXT NOT NULL, issuer_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, FOREIGN KEY(issuer_id) REFERENCES device_sessions(id) ON DELETE CASCADE)`, + `CREATE TABLE device_migrations (version INTEGER PRIMARY KEY)`, + `CREATE TABLE chat_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, pair_id TEXT NOT NULL, + sender TEXT NOT NULL CHECK(sender IN ('inviter','invitee')), client_id TEXT NOT NULL, text TEXT NOT NULL, + created_at INTEGER NOT NULL, UNIQUE(pair_id, sender, client_id))`, + `CREATE INDEX chat_messages_pair_id ON chat_messages(pair_id, id)`, + `CREATE TABLE chat_reads (pair_id TEXT NOT NULL, role TEXT NOT NULL, message_id INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(pair_id, role))`, + } { + if _, err := db.Exec(schema); err != nil { + t.Fatal(err) + } + } + inviterHash, inviteeHash := tokenHash("phone-token"), tokenHash("partner-token") + revokedHash := tokenHash("revoked-legacy-token") + recoveryHash, codeHash := tokenHash("existing-recovery"), tokenHash("existing-login-code") + clock := time.Unix(1800000000, 123000000).UTC() + if _, err := db.Exec(`INSERT INTO pairing_state VALUES(1, ?, ?, ?, ?, ?, ?, ?, ?, 1)`, + "https://home.example.test", codeHash[:], clock.Unix(), revokedHash[:], recoveryHash[:], "existing-pair", inviteeHash[:], recoveryHash[:]); err != nil { + t.Fatal(err) + } + for _, record := range []struct { + id, role, name string + hash []byte + }{ + {"phone", "inviter", "Phone", inviterHash[:]}, {"partner", "invitee", "Partner", inviteeHash[:]}, + } { + if _, err := db.Exec(`INSERT INTO device_sessions VALUES(?, ?, ?, ?, ?)`, record.id, record.role, record.hash, record.name, clock.Unix()); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec(`INSERT INTO device_migrations VALUES(1)`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO device_login_codes VALUES(?, 'inviter', 'phone', ?)`, codeHash[:], clock.Add(inviteTTL).Unix()); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO chat_messages VALUES(41, 'existing-pair', 'invitee', 'old-client-id', '你好', ?)`, clock.UnixMilli()); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO chat_reads VALUES('existing-pair', 'inviter', 41)`); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + defer service.Close() + service.now = func() time.Time { return clock } + for _, token := range []string{"phone-token", "partner-token"} { + session, err := service.Authenticate(token) + if err != nil || session.PairID != "existing-pair" { + t.Fatalf("existing login lost: %v", err) + } + } + if _, err := service.Authenticate("revoked-legacy-token"); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("revoked token restored: %v", err) + } + page, err := service.Messages("phone-token", 0, 0, 20) + if err != nil || len(page.Messages) != 1 || page.ReadID != 41 || page.UnreadCount != 0 { + t.Fatalf("existing history lost: %+v, %v", page, err) + } + if page.Messages[0].Text != "你好" || !page.Messages[0].CreatedAt.Equal(clock) { + t.Fatal("message text/timestamp changed") + } + devices, err := service.Devices("phone-token") + if err != nil || len(devices) != 1 || devices[0].CreatedAt.Unix() != clock.Unix() { + t.Fatalf("device timestamp changed: %v", err) + } + message, err := service.SendMessage("phone-token", "new-client-id", "hello") + if err != nil || message.ID <= 41 { + t.Fatalf("sequence did not continue: %+v, %v", message, err) + } + if _, err := service.RedeemDeviceLoginCode("existing-login-code", "Tablet"); err != nil { + t.Fatal("existing device code lost", err) + } + if _, err := service.Recover("existing-recovery"); err != nil { + t.Fatal("existing recovery code lost", err) + } +} + +// TestORMRecoveryRollsBackDeviceFailure verifies rotation and revocation are atomic. +func TestORMRecoveryRollsBackDeviceFailure(t *testing.T) { + service := NewService() + defer service.Close() + invite, partner := pairedDevices(t, service) + code, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + // Inject a storage failure after hashes rotate and previous devices are removed. + if err := service.db.Exec(`CREATE TRIGGER reject_device BEFORE INSERT ON device_sessions + BEGIN SELECT RAISE(ABORT, 'injected failure'); END`).Error; err != nil { + t.Fatal(err) + } + if _, err := service.Recover(invite.RecoveryCode); err == nil { + t.Fatal("expected storage failure") + } + for _, token := range []string{invite.InviterToken, partner.InviteeToken} { + if _, err := service.Authenticate(token); err != nil { + t.Fatal("rollback lost an existing session", err) + } + } + if err := service.db.Exec(`DROP TRIGGER reject_device`).Error; err != nil { + t.Fatal(err) + } + if _, err := service.RedeemDeviceLoginCode(code.Code, "Tablet"); err != nil { + t.Fatal("rollback lost outstanding code", err) + } + if _, err := service.Recover(invite.RecoveryCode); err != nil { + t.Fatal("rollback consumed recovery code", err) + } +} + +// TestORMExpiredInviteReplacement verifies zero/NULL fields and identity reset. +func TestORMExpiredInviteReplacement(t *testing.T) { + service := NewService() + defer service.Close() + clock := time.Now() + service.now = func() time.Time { return clock } + old, err := service.CreateInvite("http://localhost:8080") + if err != nil { + t.Fatal(err) + } + clock = old.ExpiresAt + fresh, err := service.CreateInvite("http://localhost:8081") + if err != nil { + t.Fatal(err) + } + if _, err := service.Authenticate(old.InviterToken); !errors.Is(err, ErrInvalidSessionToken) { + t.Fatalf("old inviter still active: %v", err) + } + session, err := service.Authenticate(fresh.InviterToken) + if err != nil || session.Status != "pending" || session.PairID != "" { + t.Fatalf("bad pending state: %+v, %v", session, err) + } + if _, err := service.RedeemInvite(inviteCode(t, old.URL)); !errors.Is(err, ErrInvalidInvite) { + t.Fatalf("old link still accepted: %v", err) + } + if _, err := service.RedeemInvite(inviteCode(t, fresh.URL)); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/pairing/service.go b/server/internal/pairing/service.go index 399dfbe..787da33 100644 --- a/server/internal/pairing/service.go +++ b/server/internal/pairing/service.go @@ -13,6 +13,10 @@ import ( "sync" "time" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/logger" _ "modernc.org/sqlite" ) @@ -56,14 +60,15 @@ type RecoveredCredentials struct { // Session is the authenticated member and pairing state for a client device. type Session struct { - PairID string - Role string - Status string + PairID string + Role string + Status string + DeviceID string } // Service stores the single couple relationship for one private instance. type Service struct { - db *sql.DB + db *gorm.DB mu sync.Mutex now func() time.Time } @@ -84,23 +89,25 @@ func OpenService(databasePath string) (*Service, error) { db.Close() return nil, fmt.Errorf("configure pairing database: %w", err) } - if _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS pairing_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - server_url TEXT NOT NULL, - invite_code_hash BLOB NOT NULL, - invite_expires_at INTEGER NOT NULL, - inviter_token_hash BLOB NOT NULL, - inviter_recovery_hash BLOB NOT NULL, - pair_id TEXT, - invitee_token_hash BLOB, - invitee_recovery_hash BLOB, - paired INTEGER NOT NULL DEFAULT 0 - )`); err != nil { + // Reuse modernc's pure-Go connection rather than the dialector's CGO default. + orm, err := gorm.Open(sqlite.New(sqlite.Config{DriverName: "sqlite", Conn: db}), &gorm.Config{ + // SQL tracing could expose private messages, names or credential digests. + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + db.Close() + return nil, fmt.Errorf("open pairing ORM: %w", err) + } + if err := migrateSchema(orm); err != nil { db.Close() return nil, fmt.Errorf("create pairing schema: %w", err) } - return &Service{db: db, now: time.Now}, nil + service := &Service{db: orm, now: time.Now} + if err := service.migrateDevices(); err != nil { + db.Close() + return nil, err + } + return service, nil } // NewService creates an in-memory pairing service for isolated tests. @@ -114,11 +121,15 @@ func NewService() *Service { // Close releases the SQLite database connection. func (service *Service) Close() error { - return service.db.Close() + db, err := service.db.DB() + if err != nil { + return err + } + return db.Close() } // CreateInvite creates one expiring invitation for the instance. -func (service *Service) CreateInvite(serverURL string) (Invite, error) { +func (service *Service) CreateInvite(serverURL string, deviceNames ...string) (Invite, error) { baseURL, err := normalizeServerURL(serverURL) if err != nil { return Invite{}, err @@ -144,37 +155,39 @@ func (service *Service) CreateInvite(serverURL string) (Invite, error) { service.mu.Lock() defer service.mu.Unlock() - var paired bool - var existingExpiry int64 - err = service.db.QueryRow(`SELECT paired, invite_expires_at FROM pairing_state WHERE id = 1`).Scan(&paired, &existingExpiry) - switch { - case err == nil && paired: - return Invite{}, ErrAlreadyPaired - case err == nil && service.now().Unix() < existingExpiry: - return Invite{}, ErrInvitePending - case err != nil && !errors.Is(err, sql.ErrNoRows): - return Invite{}, fmt.Errorf("read pairing state: %w", err) - } - - _, err = service.db.Exec(` - INSERT INTO pairing_state ( - id, server_url, invite_code_hash, invite_expires_at, - inviter_token_hash, inviter_recovery_hash, pair_id, - invitee_token_hash, invitee_recovery_hash, paired - ) VALUES (1, ?, ?, ?, ?, ?, NULL, NULL, NULL, 0) - ON CONFLICT(id) DO UPDATE SET - server_url = excluded.server_url, - invite_code_hash = excluded.invite_code_hash, - invite_expires_at = excluded.invite_expires_at, - inviter_token_hash = excluded.inviter_token_hash, - inviter_recovery_hash = excluded.inviter_recovery_hash, - pair_id = NULL, - invitee_token_hash = NULL, - invitee_recovery_hash = NULL, - paired = 0`, - baseURL, codeHash[:], expiresAt.Unix(), inviterTokenHash[:], inviterRecoveryHash[:]) + err = service.db.Transaction(func(tx *gorm.DB) error { + var existing pairingRecord + err := tx.Take(&existing, "id = ?", 1).Error + switch { + case err == nil && existing.Paired: + return ErrAlreadyPaired + case err == nil && service.now().Unix() < existing.InviteExpiresAt: + return ErrInvitePending + case err != nil && !errors.Is(err, gorm.ErrRecordNotFound): + return fmt.Errorf("read pairing state: %w", err) + } + record := pairingRecord{ID: 1, ServerURL: baseURL, InviteCodeHash: codeHash[:], + InviteExpiresAt: expiresAt.Unix(), InviterTokenHash: inviterTokenHash[:], + InviterRecoveryHash: inviterRecoveryHash[:]} + // Explicit columns include zero/NULL values when replacing an expired invite. + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{"server_url", "invite_code_hash", "invite_expires_at", + "inviter_token_hash", "inviter_recovery_hash", "pair_id", "invitee_token_hash", "invitee_recovery_hash", "paired"}), + }).Create(&record).Error; err != nil { + return fmt.Errorf("save pairing invitation: %w", err) + } + // An expired, unpaired invitation starts a new identity and device set. + if err := tx.Where("1 = 1").Delete(&deviceCodeRecord{}).Error; err != nil { + return err + } + if err := tx.Where("1 = 1").Delete(&deviceRecord{}).Error; err != nil { + return err + } + _, err = service.insertDevice(tx, "inviter", inviterToken, deviceName(deviceNames)) + return err + }) if err != nil { - return Invite{}, fmt.Errorf("save pairing invitation: %w", err) + return Invite{}, err } return Invite{ @@ -186,24 +199,22 @@ func (service *Service) CreateInvite(serverURL string) (Invite, error) { } // RedeemInvite atomically consumes an invite and creates the instance pair. -func (service *Service) RedeemInvite(code string) (PairingStatus, error) { +func (service *Service) RedeemInvite(code string, deviceNames ...string) (PairingStatus, error) { service.mu.Lock() defer service.mu.Unlock() - var paired bool - var expiresAt int64 - var storedCodeHash []byte - err := service.db.QueryRow(`SELECT paired, invite_expires_at, invite_code_hash FROM pairing_state WHERE id = 1`).Scan(&paired, &expiresAt, &storedCodeHash) - if errors.Is(err, sql.ErrNoRows) || paired { + var state pairingRecord + err := service.db.Take(&state, "id = ?", 1).Error + if errors.Is(err, gorm.ErrRecordNotFound) || state.Paired { return PairingStatus{}, ErrInvalidInvite } if err != nil { return PairingStatus{}, fmt.Errorf("read pairing invitation: %w", err) } - if service.now().Unix() >= expiresAt { + if service.now().Unix() >= state.InviteExpiresAt { return PairingStatus{}, ErrExpiredInvite } - if !sameHash(storedCodeHash, tokenHash(strings.TrimSpace(code))) { + if !sameHash(state.InviteCodeHash, tokenHash(strings.TrimSpace(code))) { return PairingStatus{}, ErrInvalidInvite } @@ -221,20 +232,22 @@ func (service *Service) RedeemInvite(code string) (PairingStatus, error) { } inviteeTokenHash := tokenHash(inviteeToken) inviteeRecoveryHash := tokenHash(inviteeRecovery) - result, err := service.db.Exec(` - UPDATE pairing_state - SET pair_id = ?, invitee_token_hash = ?, invitee_recovery_hash = ?, paired = 1 - WHERE id = 1 AND paired = 0 AND invite_code_hash = ? AND invite_expires_at > ?`, - pairID, inviteeTokenHash[:], inviteeRecoveryHash[:], storedCodeHash, service.now().Unix()) - if err != nil { - return PairingStatus{}, fmt.Errorf("complete pairing: %w", err) - } - changed, err := result.RowsAffected() + err = service.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&pairingRecord{}). + Where("id = ? AND paired = ? AND invite_code_hash = ? AND invite_expires_at > ?", 1, false, state.InviteCodeHash, service.now().Unix()). + Updates(map[string]any{"pair_id": pairID, "invitee_token_hash": inviteeTokenHash[:], + "invitee_recovery_hash": inviteeRecoveryHash[:], "paired": true}) + if result.Error != nil { + return fmt.Errorf("complete pairing: %w", result.Error) + } + if result.RowsAffected != 1 { + return ErrInvalidInvite + } + _, err := service.insertDevice(tx, "invitee", inviteeToken, deviceName(deviceNames)) + return err + }) if err != nil { - return PairingStatus{}, fmt.Errorf("check pairing update: %w", err) - } - if changed != 1 { - return PairingStatus{}, ErrInvalidInvite + return PairingStatus{}, err } return PairingStatus{ @@ -247,86 +260,71 @@ func (service *Service) RedeemInvite(code string) (PairingStatus, error) { // Status returns pairing state after validating the inviter's bearer token. func (service *Service) Status(inviterToken string) (PairingStatus, error) { - service.mu.Lock() - defer service.mu.Unlock() - - var paired bool - var pairID sql.NullString - var storedTokenHash []byte - err := service.db.QueryRow(`SELECT paired, pair_id, inviter_token_hash FROM pairing_state WHERE id = 1`).Scan(&paired, &pairID, &storedTokenHash) - if errors.Is(err, sql.ErrNoRows) { + session, err := service.Authenticate(inviterToken) + if errors.Is(err, ErrInvalidSessionToken) || (err == nil && session.Role != "inviter") { return PairingStatus{}, ErrInvalidInviterToken } if err != nil { - return PairingStatus{}, fmt.Errorf("read pairing status: %w", err) - } - if !sameHash(storedTokenHash, tokenHash(strings.TrimSpace(inviterToken))) { - return PairingStatus{}, ErrInvalidInviterToken - } - if !paired { - return PairingStatus{Status: "pending"}, nil + return PairingStatus{}, err } - return PairingStatus{PairID: pairID.String, Status: "paired"}, nil + return PairingStatus{PairID: session.PairID, Status: session.Status}, nil } // Authenticate validates either member's access token and returns its session. func (service *Service) Authenticate(accessToken string) (Session, error) { service.mu.Lock() defer service.mu.Unlock() + return service.authenticateDevice(accessToken) +} - var paired bool - var pairID sql.NullString - var inviterHash, inviteeHash []byte - err := service.db.QueryRow(` - SELECT paired, pair_id, inviter_token_hash, invitee_token_hash - FROM pairing_state WHERE id = 1`).Scan(&paired, &pairID, &inviterHash, &inviteeHash) - if errors.Is(err, sql.ErrNoRows) { +// authenticateDevice reads a device session while the caller holds the mutex. +func (service *Service) authenticateDevice(accessToken string) (Session, error) { + var record struct { + Paired bool + PairID string + DeviceID string + Role string + } + hash := tokenHash(strings.TrimSpace(accessToken)) + err := service.db.Table("device_sessions AS d"). + Select("p.paired, p.pair_id, d.id AS device_id, d.role"). + Joins("JOIN pairing_state AS p ON p.id = 1").Where("d.token_hash = ?", hash[:]).Take(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { return Session{}, ErrInvalidSessionToken } if err != nil { return Session{}, fmt.Errorf("read pairing session: %w", err) } - providedHash := tokenHash(strings.TrimSpace(accessToken)) - if sameHash(inviterHash, providedHash) { - status := "pending" - if paired { - status = "paired" - } - return Session{PairID: pairID.String, Role: "inviter", Status: status}, nil - } - if paired && sameHash(inviteeHash, providedHash) { - return Session{PairID: pairID.String, Role: "invitee", Status: "paired"}, nil + session := Session{PairID: record.PairID, DeviceID: record.DeviceID, Role: record.Role, Status: "pending"} + if record.Paired { + session.Status = "paired" } - return Session{}, ErrInvalidSessionToken + return session, nil } // Recover rotates one member's credentials using their single-use recovery code. -func (service *Service) Recover(recoveryCode string) (RecoveredCredentials, error) { +func (service *Service) Recover(recoveryCode string, deviceNames ...string) (RecoveredCredentials, error) { service.mu.Lock() defer service.mu.Unlock() - var pairID sql.NullString - var paired bool - var inviterHash, inviteeHash []byte - err := service.db.QueryRow(` - SELECT pair_id, paired, inviter_recovery_hash, invitee_recovery_hash - FROM pairing_state WHERE id = 1`).Scan(&pairID, &paired, &inviterHash, &inviteeHash) - if errors.Is(err, sql.ErrNoRows) { + var state pairingRecord + err := service.db.Take(&state, "id = ?", 1).Error + if errors.Is(err, gorm.ErrRecordNotFound) { return RecoveredCredentials{}, ErrNotPaired } if err != nil { return RecoveredCredentials{}, fmt.Errorf("read recovery state: %w", err) } - if !paired { + if !state.Paired || state.PairID == nil { return RecoveredCredentials{}, ErrNotPaired } providedHash := tokenHash(strings.TrimSpace(recoveryCode)) role := "" - if sameHash(inviterHash, providedHash) { + if sameHash(state.InviterRecoveryHash, providedHash) { role = "inviter" - } else if sameHash(inviteeHash, providedHash) { + } else if sameHash(state.InviteeRecoveryHash, providedHash) { role = "invitee" } else { return RecoveredCredentials{}, ErrInvalidRecoveryCode @@ -344,14 +342,34 @@ func (service *Service) Recover(recoveryCode string) (RecoveredCredentials, erro if role == "invitee" { tokenColumn, recoveryColumn = "invitee_token_hash", "invitee_recovery_hash" } - query := `UPDATE pairing_state SET ` + tokenColumn + ` = ?, ` + recoveryColumn + ` = ? WHERE id = 1 AND paired = 1` accessTokenHash := tokenHash(accessToken) newRecoveryHash := tokenHash(newRecoveryCode) - if _, err := service.db.Exec(query, accessTokenHash[:], newRecoveryHash[:]); err != nil { - return RecoveredCredentials{}, fmt.Errorf("rotate recovered credentials: %w", err) + err = service.db.Transaction(func(tx *gorm.DB) error { + // Column names are selected internally, never taken from client input. + result := tx.Model(&pairingRecord{}).Where("id = ? AND paired = ?", 1, true). + Where(recoveryColumn+" = ?", providedHash[:]). + Updates(map[string]any{tokenColumn: accessTokenHash[:], recoveryColumn: newRecoveryHash[:]}) + if result.Error != nil { + return fmt.Errorf("rotate recovered credentials: %w", result.Error) + } + if result.RowsAffected != 1 { + return ErrInvalidRecoveryCode + } + // Recovery remains an emergency reset. Normal device login is additive. + if err := tx.Where("role = ?", role).Delete(&deviceRecord{}).Error; err != nil { + return err + } + if err := tx.Where("role = ?", role).Delete(&deviceCodeRecord{}).Error; err != nil { + return err + } + _, err := service.insertDevice(tx, role, accessToken, deviceName(deviceNames)) + return err + }) + if err != nil { + return RecoveredCredentials{}, err } return RecoveredCredentials{ - PairID: pairID.String, + PairID: *state.PairID, Role: role, AccessToken: accessToken, RecoveryCode: newRecoveryCode, diff --git a/test/chat_test.dart b/test/chat_test.dart new file mode 100644 index 0000000..d34b413 --- /dev/null +++ b/test/chat_test.dart @@ -0,0 +1,324 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pawmate/design/pawmate_theme.dart'; +import 'package:pawmate/features/chat/chat_api.dart'; +import 'package:pawmate/features/chat/chat_controller.dart'; +import 'package:pawmate/features/chat/chat_page.dart'; +import 'package:pawmate/features/pairing/pairing_credentials.dart'; +import 'package:pawmate/features/pairing/paired_home_page.dart'; +import 'package:pawmate/features/pairing/pairing_api.dart'; + +const credentials = SavedPairingCredentials( + serverURL: 'https://home.example.test', + accessToken: 'phone-token', + recoveryCode: '', + pairID: 'our-home', + role: 'inviter', +); + +Map message( + int id, + String sender, { + String clientID = 'client-id', + String text = '你好 🐾', +}) => { + 'id': id, + 'client_id': clientID, + 'sender': sender, + 'text': text, + 'created_at': '2026-10-04T10:00:00Z', +}; + +http.Response snapshot( + List> messages, { + int read = 0, + int partnerRead = 0, + int unread = 0, + int latest = 0, +}) => http.Response.bytes( + utf8.encode( + jsonEncode({ + 'messages': messages, + 'has_more': false, + 'latest_id': latest, + 'read_id': read, + 'partner_read_id': partnerRead, + 'unread_count': unread, + }), + ), + 200, +); + +void main() { + test( + 'a lost send response retries with the same id and no duplicate bubble', + () async { + var attempts = 0; + final ids = []; + final chat = ChatController( + ChatApi( + credentials, + MockClient((request) async { + expect(request.headers['authorization'], 'Bearer phone-token'); + if (request.method == 'GET') return snapshot([]); + final body = jsonDecode(request.body) as Map; + ids.add(body['client_id'] as String); + if (attempts++ == 0) throw http.ClientException('response lost'); + return http.Response.bytes( + utf8.encode( + jsonEncode(message(1, 'inviter', clientID: ids.last)), + ), + 200, + ); + }), + ), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + await chat.send('你好 🐾'); + expect(chat.outbox.single.sending, isFalse); + await chat.retry(chat.outbox.single); + await Future.delayed(Duration.zero); + expect(ids[0], ids[1]); + expect(chat.outbox, isEmpty); + expect(chat.messages.single.text, '你好 🐾'); + }, + ); + + test( + 'a send response ahead of polling does not skip an intervening partner message', + () async { + final catchup = Completer(); + final afterIDs = []; + final chat = ChatController( + ChatApi( + credentials, + MockClient((request) async { + if (request.method == 'POST') { + final body = jsonDecode(request.body) as Map; + return http.Response.bytes( + utf8.encode( + jsonEncode( + message( + 3, + 'inviter', + clientID: body['client_id'] as String, + ), + ), + ), + 200, + ); + } + afterIDs.add(request.url.queryParameters['after_id']); + if (afterIDs.length == 1) { + return snapshot([message(1, 'inviter')], latest: 1); + } + return catchup.future; + }), + ), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + await chat.send('another message'); + await Future.delayed(Duration.zero); + expect(chat.caughtUp, isFalse); + expect(afterIDs.last, '1'); + catchup.complete( + snapshot( + [message(2, 'invitee'), message(3, 'inviter')], + latest: 3, + unread: 1, + ), + ); + await Future.delayed(Duration.zero); + expect(chat.messages.map((item) => item.id), [1, 2, 3]); + expect(chat.unreadCount, 1); + expect(chat.caughtUp, isTrue); + }, + ); + + test( + 'hidden chat and background application never advance read progress', + () async { + var read = 0; + var readRequests = 0; + final chat = ChatController( + ChatApi( + credentials, + MockClient((request) async { + if (request.method == 'POST') { + readRequests++; + read = + (jsonDecode(request.body) + as Map)['message_id'] + as int; + return http.Response(jsonEncode({'read_id': read}), 200); + } + return snapshot( + [message(7, 'invitee'), message(10, 'invitee')], + latest: 10, + read: read, + unread: read == 0 ? 2 : 1, + ); + }), + ), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + chat.setChatVisible(false); + await chat.markVisibleRead(10); + chat.setChatVisible(true); + chat.setForeground(false); + await chat.markVisibleRead(10); + expect(readRequests, 0); + chat.setForeground(true); + await Future.delayed(Duration.zero); + await chat.markVisibleRead(7); + await Future.delayed(Duration.zero); + expect(read, 7); + expect(chat.unreadCount, 1); + await chat.markVisibleRead(5); + expect(readRequests, 1); + }, + ); + + testWidgets( + 'only a visible foreground chat acknowledges rendered incoming messages', + (tester) async { + var read = 0; + final chat = ChatController( + ChatApi( + credentials, + MockClient((request) async { + if (request.method == 'POST') { + read = + (jsonDecode(request.body) + as Map)['message_id'] + as int; + return http.Response(jsonEncode({'read_id': read}), 200); + } + return snapshot( + [message(1, 'invitee'), message(2, 'inviter')], + latest: 2, + read: read, + partnerRead: 2, + unread: read == 0 ? 1 : 0, + ); + }), + ), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + Widget page(bool active) => MaterialApp( + theme: buildPawmateTheme(), + home: Scaffold( + body: ChatPage(controller: chat, active: active), + ), + ); + await tester.pumpWidget(page(false)); + await tester.pumpAndSettle(); + expect(read, 0); + expect(find.textContaining('Read'), findsOneWidget); + await tester.pumpWidget(page(true)); + await tester.pumpAndSettle(); + expect(read, 1); + expect(chat.unreadCount, 0); + }, + ); + + testWidgets('chat composer fits large accessibility text on a small screen', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final chat = ChatController( + ChatApi(credentials, MockClient((_) async => snapshot([]))), + ); + addTearDown(chat.dispose); + await chat.synchronize(); + await tester.pumpWidget( + MaterialApp( + theme: buildPawmateTheme(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: Scaffold(body: ChatPage(controller: chat, active: true)), + ), + ); + await tester.enterText(find.byType(TextField), 'hello\nfrom a tablet'); + await tester.pump(); + expect(find.byTooltip('Send message'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('couple space opens on Chat and alerts without reading on Home', ( + tester, + ) async { + var newMessage = false; + var read = 0; + final api = ChatApi( + credentials, + MockClient((request) async { + if (request.method == 'POST') { + read = + (jsonDecode(request.body) as Map)['message_id'] + as int; + return http.Response(jsonEncode({'read_id': read}), 200); + } + final after = + int.tryParse(request.url.queryParameters['after_id'] ?? '0') ?? 0; + return snapshot( + [ + if (after < 1) message(1, 'inviter'), + if (newMessage && after < 2) + message(2, 'invitee', clientID: 'partner-message'), + ], + latest: newMessage ? 2 : 1, + read: read, + unread: newMessage && read < 2 ? 1 : 0, + ); + }), + ); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pumpWidget( + MaterialApp( + theme: buildPawmateTheme(), + home: PairedHomePage( + credentials: credentials, + session: const PairingSession( + role: 'inviter', + status: 'paired', + pairID: 'our-home', + ), + chatApi: api, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Our conversation'), findsOneWidget); + await tester.tap(find.text('Home')); + await tester.pumpAndSettle(); + newMessage = true; + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + expect(read, 0); + expect(find.text('1'), findsOneWidget); + expect(find.text('1 unread messages from your partner'), findsOneWidget); + await tester.tap(find.text('Chat')); + await tester.pumpAndSettle(); + expect(read, 2); + expect(find.text('1'), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + }); +} diff --git a/test/device_login_api_test.dart b/test/device_login_api_test.dart new file mode 100644 index 0000000..916725d --- /dev/null +++ b/test/device_login_api_test.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pawmate/features/pairing/pairing_api.dart'; + +void main() { + test( + 'device login sends no existing token and receives no recovery secret', + () async { + final api = PairingApi( + MockClient((request) async { + expect( + request.url.path, + '/api/v1/pairing/devices/login-codes/redeem', + ); + expect(request.headers.containsKey('authorization'), isFalse); + expect(jsonDecode(request.body), { + 'code': 'single-use-code', + 'device_name': 'My tablet', + }); + return http.Response( + jsonEncode({ + 'access_token': 'tablet-token', + 'pair_id': 'our-home', + 'role': 'inviter', + }), + 200, + ); + }), + ); + addTearDown(api.close); + final credentials = await api.redeemDeviceLoginCode( + 'https://home.example.test', + ' single-use-code ', + ' My tablet ', + ); + expect(credentials.accessToken, 'tablet-token'); + expect(credentials.pairID, 'our-home'); + expect(credentials.role, 'inviter'); + }, + ); + + test('device operations authenticate with the installation token', () async { + final requests = []; + final api = PairingApi( + MockClient((request) async { + requests.add(request); + expect(request.headers['authorization'], 'Bearer tablet-token'); + if (request.method == 'DELETE') return http.Response('', 204); + if (request.method == 'POST') { + return http.Response( + jsonEncode({ + 'code': 'new-code', + 'expires_at': '2026-10-04T10:00:00Z', + }), + 201, + ); + } + return http.Response( + jsonEncode({ + 'devices': [ + { + 'id': 'tablet-id', + 'name': 'My tablet', + 'current': true, + 'created_at': '2026-10-04T09:00:00Z', + }, + { + 'id': 'phone-id', + 'name': 'Phone', + 'current': false, + 'created_at': '2026-10-03T09:00:00Z', + }, + ], + }), + 200, + ); + }), + ); + addTearDown(api.close); + final devices = await api.getDevices( + 'https://home.example.test', + 'tablet-token', + ); + expect(devices.where((device) => device.current).single.id, 'tablet-id'); + final code = await api.createDeviceLoginCode( + 'https://home.example.test', + 'tablet-token', + ); + expect(code.code, 'new-code'); + await api.revokeDevice( + 'https://home.example.test', + 'tablet-token', + 'phone-id', + ); + expect(requests.last.url.path, '/api/v1/pairing/devices/phone-id'); + }); + + test( + 'expired codes and revoked sessions preserve their HTTP error status', + () async { + final api = PairingApi( + MockClient( + (request) async => http.Response( + jsonEncode({ + 'error': request.method == 'POST' + ? 'invalid_device_code' + : 'invalid_session_token', + }), + 401, + ), + ), + ); + addTearDown(api.close); + final isUnauthorized = isA().having( + (error) => error.statusCode, + 'status', + 401, + ); + await expectLater( + api.redeemDeviceLoginCode( + 'https://home.example.test', + 'expired', + 'Tablet', + ), + throwsA(isUnauthorized), + ); + await expectLater( + api.getDevices('https://home.example.test', 'revoked'), + throwsA(isUnauthorized), + ); + }, + ); +} diff --git a/test/server_url_test.dart b/test/server_url_test.dart new file mode 100644 index 0000000..3f606cb --- /dev/null +++ b/test/server_url_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pawmate/features/pairing/pairing_api.dart'; + +void main() { + test('HTTP private instance URLs are accepted without a build-mode gate', () { + expect( + PairingApi.parseServerURL(' http://100.64.0.1:8080/ ').toString(), + 'http://100.64.0.1:8080', + ); + }); + + test('HTTPS and instance path prefixes remain supported', () { + expect( + PairingApi.parseServerURL( + 'https://home.example.test/pawmate/', + ).toString(), + 'https://home.example.test/pawmate', + ); + }); + + test('unsafe or unsupported instance URLs remain rejected', () { + for (final value in [ + '', + 'home.example.test', + 'ftp://home.example.test', + 'http://user:secret@home.example.test', + 'http://home.example.test?token=secret', + 'http://home.example.test#secret', + ]) { + expect( + () => PairingApi.parseServerURL(value), + throwsA(isA()), + reason: value, + ); + } + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index e82ea5d..aa6c83b 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,15 +1,54 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:pawmate/main.dart'; +import 'package:pawmate/features/pairing/device_login_page.dart'; +import 'package:pawmate/design/pawmate_theme.dart'; void main() { testWidgets('inviter setup page accepts a server URL', ( WidgetTester tester, ) async { + FlutterSecureStorage.setMockInitialValues({}); await tester.pumpWidget(const PawmateApp()); + await tester.pumpAndSettle(); expect(find.text('Invite your partner'), findsOneWidget); expect(find.text('Server URL or domain'), findsOneWidget); expect(find.text('Create invitation'), findsOneWidget); + await tester.tap(find.text('Sign in on another device')); + await tester.pumpAndSettle(); + expect(find.text('Device login code'), findsOneWidget); + expect(find.text('Device name'), findsOneWidget); }); + + testWidgets( + 'device login validates input and fits a small screen with large text', + (tester) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: buildPawmateTheme(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: const DeviceLoginPage(), + ), + ); + await tester.ensureVisible(find.text('Sign in')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign in')); + await tester.pumpAndSettle(); + expect(find.text('Enter your server address.'), findsOneWidget); + expect(find.text('Enter the login code.'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); } From d6b51cdb48f917f6157bdf4b467e77cceb0bedfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Sun, 4 Oct 2026 23:56:03 +0800 Subject: [PATCH 09/16] feat(onboarding): add localized login flows and refresh the design system Add invitation-link entry and a login-method chooser, and refine pairing, device login, recovery, and chat screens. Split the hand-drawn UI into reusable components, layouts, theme tokens, and icons, with English and Chinese localizations. Update the foundation guide with onboarding previews and add tests for the login methods and onboarding screens. --- AGENTS.md | 42 +- books/src/current-foundation.md | 44 +- books/src/images/login-before.png | Bin 0 -> 72804 bytes books/src/images/login-matrix.png | Bin 0 -> 73461 bytes l10n.yaml | 4 + lib/design/README.md | 27 + .../{ => components}/chat_message_bubble.dart | 5 +- .../components/handdrawn_action_card.dart | 70 ++ .../{ => components}/handdrawn_button.dart | 2 +- .../{ => components}/handdrawn_card.dart | 14 +- .../handdrawn_text_field.dart | 0 lib/design/doodle_house.dart | 72 -- lib/design/icons/README.md | 30 + .../icons/access/access_doodle_icon.dart | 233 +++++ lib/design/icons/crayon_strokes.dart | 74 ++ .../{ => layouts}/handdrawn_scaffold.dart | 23 +- lib/design/theme/colors.dart | 17 + lib/design/{ => theme}/pawmate_theme.dart | 17 +- lib/design/theme/spacing.dart | 9 + lib/features/chat/chat_page.dart | 38 +- lib/features/pairing/device_login_page.dart | 73 +- lib/features/pairing/invitation_link.dart | 36 + .../pairing/invite_link_entry_page.dart | 137 +++ lib/features/pairing/invitee_accept_page.dart | 58 +- lib/features/pairing/inviter_setup_page.dart | 125 +-- lib/features/pairing/login_methods_page.dart | 164 +++ lib/features/pairing/paired_home_page.dart | 157 +-- .../pairing/pairing_recovery_page.dart | 42 +- .../pairing/pairing_session_gate.dart | 40 +- .../pairing/widgets/devices_card.dart | 59 +- .../pairing/widgets/invite_result_card.dart | 30 +- .../pairing/widgets/pairing_error_note.dart | 4 +- .../pairing/widgets/pairing_header.dart | 43 - .../widgets/pairing_recovery_card.dart | 26 +- .../pairing/widgets/recovery_code_note.dart | 18 +- .../pairing/widgets/server_setup_card.dart | 46 +- lib/l10n/app_en.arb | 154 +++ lib/l10n/app_zh.arb | 154 +++ lib/l10n/generated/app_localizations.dart | 986 ++++++++++++++++++ lib/l10n/generated/app_localizations_en.dart | 486 +++++++++ lib/l10n/generated/app_localizations_zh.dart | 459 ++++++++ lib/l10n/locale_controller.dart | 39 + lib/main.dart | 39 +- pubspec.lock | 13 + pubspec.yaml | 3 + test/chat_test.dart | 2 +- test/login_methods_test.dart | 222 ++++ test/onboarding_preview_test.dart | 117 +++ test/widget_test.dart | 16 +- 49 files changed, 3948 insertions(+), 521 deletions(-) create mode 100644 books/src/images/login-before.png create mode 100644 books/src/images/login-matrix.png create mode 100644 l10n.yaml create mode 100644 lib/design/README.md rename lib/design/{ => components}/chat_message_bubble.dart (93%) create mode 100644 lib/design/components/handdrawn_action_card.dart rename lib/design/{ => components}/handdrawn_button.dart (98%) rename lib/design/{ => components}/handdrawn_card.dart (75%) rename lib/design/{ => components}/handdrawn_text_field.dart (100%) delete mode 100644 lib/design/doodle_house.dart create mode 100644 lib/design/icons/README.md create mode 100644 lib/design/icons/access/access_doodle_icon.dart create mode 100644 lib/design/icons/crayon_strokes.dart rename lib/design/{ => layouts}/handdrawn_scaffold.dart (58%) create mode 100644 lib/design/theme/colors.dart rename lib/design/{ => theme}/pawmate_theme.dart (80%) create mode 100644 lib/design/theme/spacing.dart create mode 100644 lib/features/pairing/invitation_link.dart create mode 100644 lib/features/pairing/invite_link_entry_page.dart create mode 100644 lib/features/pairing/login_methods_page.dart delete mode 100644 lib/features/pairing/widgets/pairing_header.dart create mode 100644 lib/l10n/app_en.arb create mode 100644 lib/l10n/app_zh.arb create mode 100644 lib/l10n/generated/app_localizations.dart create mode 100644 lib/l10n/generated/app_localizations_en.dart create mode 100644 lib/l10n/generated/app_localizations_zh.dart create mode 100644 lib/l10n/locale_controller.dart create mode 100644 test/login_methods_test.dart create mode 100644 test/onboarding_preview_test.dart diff --git a/AGENTS.md b/AGENTS.md index 727a675..37c5230 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,18 +58,16 @@ const sky = Color(0xFF9DC6D8); ## Component architecture -Build visual behavior into reusable widgets under `lib/design/` or the project's -equivalent design-system directory: +Build visual behavior into reusable widgets under `lib/design/`, organized by +responsibility: ```text -HanddrawnScaffold -HanddrawnCard -HanddrawnButton -HanddrawnTextField -DoodleIcon -Sticker -PaperNote -CoupleAvatar +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 @@ -77,8 +75,28 @@ 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 @@ -161,6 +179,10 @@ backups are sensitive in different ways: - 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. diff --git a/books/src/current-foundation.md b/books/src/current-foundation.md index cb18a13..8e68f03 100644 --- a/books/src/current-foundation.md +++ b/books/src/current-foundation.md @@ -19,7 +19,7 @@ Pawmate instance. It reports an instance identifier, display name, API version, public URL when configured, and the currently advertised features. The Flutter client supports the inviter's server setup, invitation creation, -status checks, accepting an invitation opened through the Android +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 @@ -28,6 +28,48 @@ 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. +## Choose an access method + +New installations open a **2 × 2 access matrix**, not a long page of unrelated +forms. The four cards are Create invitation, Accept invitation, Restore data, +and Add device. Each opens a focused page with back navigation. An existing +pending invitation still reopens its invitation status page; valid paired +sessions still open Chat, and invalid saved sessions still 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 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`. + +Previous stacked-layout reference (reconstructed from the shared components): + +![Previous stacked onboarding reference](images/login-before.png) + +Current two-by-two access landing page (Flutter widget preview, not a device capture): + +![Four hand-drawn access methods](images/login-matrix.png) + +## 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 diff --git a/books/src/images/login-before.png b/books/src/images/login-before.png new file mode 100644 index 0000000000000000000000000000000000000000..9a8c15000fd710705932fa8c3f62bc7b5116f25e GIT binary patch literal 72804 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 literal 0 HcmV?d00001 diff --git a/books/src/images/login-matrix.png b/books/src/images/login-matrix.png new file mode 100644 index 0000000000000000000000000000000000000000..71e70af5461b9e5ee382106d7ad41a2030526669 GIT binary patch literal 73461 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 DiJ0^| literal 0 HcmV?d00001 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/chat_message_bubble.dart b/lib/design/components/chat_message_bubble.dart similarity index 93% rename from lib/design/chat_message_bubble.dart rename to lib/design/components/chat_message_bubble.dart index 3fd5d99..90b768b 100644 --- a/lib/design/chat_message_bubble.dart +++ b/lib/design/components/chat_message_bubble.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'handdrawn_card.dart'; -import 'pawmate_theme.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 { @@ -58,7 +59,7 @@ class ChatMessageBubble extends StatelessWidget { TextButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), - label: const Text('Retry message'), + label: Text(AppLocalizations.of(context)!.retryMessage), ), ], ), 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/handdrawn_button.dart b/lib/design/components/handdrawn_button.dart similarity index 98% rename from lib/design/handdrawn_button.dart rename to lib/design/components/handdrawn_button.dart index 3244b70..e92d4e7 100644 --- a/lib/design/handdrawn_button.dart +++ b/lib/design/components/handdrawn_button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'pawmate_theme.dart'; +import '../theme/colors.dart'; /// A semantic Material button styled as a small hand-drawn paper label. class HanddrawnButton extends StatelessWidget { diff --git a/lib/design/handdrawn_card.dart b/lib/design/components/handdrawn_card.dart similarity index 75% rename from lib/design/handdrawn_card.dart rename to lib/design/components/handdrawn_card.dart index 624ec33..f435d70 100644 --- a/lib/design/handdrawn_card.dart +++ b/lib/design/components/handdrawn_card.dart @@ -1,19 +1,25 @@ import 'package:flutter/material.dart'; -import 'pawmate_theme.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}); + 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 ?? const Color(0xFFFFFCF3), + color: color ?? PawmateColors.card, border: Border.all(color: PawmateColors.ink, width: 2), borderRadius: const BorderRadius.only( topLeft: Radius.circular(18), @@ -29,7 +35,7 @@ class HanddrawnCard extends StatelessWidget { ), ], ), - padding: const EdgeInsets.all(20), + padding: padding, child: child, ); } diff --git a/lib/design/handdrawn_text_field.dart b/lib/design/components/handdrawn_text_field.dart similarity index 100% rename from lib/design/handdrawn_text_field.dart rename to lib/design/components/handdrawn_text_field.dart diff --git a/lib/design/doodle_house.dart b/lib/design/doodle_house.dart deleted file mode 100644 index 5f2f061..0000000 --- a/lib/design/doodle_house.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'pawmate_theme.dart'; - -/// A deterministic house-and-heart doodle used by the pairing header. -class DoodleHouse extends StatelessWidget { - const DoodleHouse({super.key}); - - @override - Widget build(BuildContext context) { - return const SizedBox( - width: 132, - height: 104, - child: CustomPaint(painter: _DoodleHousePainter()), - ); - } -} - -class _DoodleHousePainter extends CustomPainter { - const _DoodleHousePainter(); - - @override - void paint(Canvas canvas, Size size) { - final outline = Paint() - ..color = PawmateColors.ink - ..style = PaintingStyle.stroke - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round; - final fill = Paint() - ..color = PawmateColors.sun - ..style = PaintingStyle.fill; - - final house = Path() - ..moveTo(25, 48) - ..lineTo(66, 17) - ..lineTo(108, 48) - ..lineTo(104, 89) - ..quadraticBezierTo(66, 94, 28, 89) - ..close(); - canvas.drawPath(house, fill); - canvas.drawPath(house, outline); - canvas.drawLine(const Offset(66, 18), const Offset(66, 8), outline); - canvas.drawCircle(const Offset(66, 6), 2, outline); - - final door = RRect.fromRectAndRadius( - const Rect.fromLTWH(56, 61, 20, 29), - const Radius.circular(4), - ); - canvas.drawRRect(door, outline); - canvas.drawCircle(const Offset(71, 76), 1.5, outline); - canvas.drawRect(const Rect.fromLTWH(38, 52, 15, 14), outline); - canvas.drawRect(const Rect.fromLTWH(80, 52, 15, 14), outline); - canvas.drawLine(const Offset(45, 52), const Offset(45, 66), outline); - canvas.drawLine(const Offset(38, 59), const Offset(53, 59), outline); - canvas.drawLine(const Offset(87, 52), const Offset(87, 66), outline); - canvas.drawLine(const Offset(80, 59), const Offset(95, 59), outline); - - final heart = Path() - ..moveTo(112, 26) - ..cubicTo(105, 18, 94, 26, 101, 34) - ..lineTo(112, 44) - ..lineTo(123, 34) - ..cubicTo(130, 26, 119, 18, 112, 26) - ..close(); - canvas.drawPath(heart, Paint()..color = PawmateColors.rose); - canvas.drawPath(heart, outline); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} diff --git a/lib/design/icons/README.md b/lib/design/icons/README.md new file mode 100644 index 0000000..11cf387 --- /dev/null +++ b/lib/design/icons/README.md @@ -0,0 +1,30 @@ +# Pawmate crayon icons + +Organize native Dart illustrations by purpose, not by individual feature pages: + +- `access/`: invitations, recovery and additional-device login. +- 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. 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/handdrawn_scaffold.dart b/lib/design/layouts/handdrawn_scaffold.dart similarity index 58% rename from lib/design/handdrawn_scaffold.dart rename to lib/design/layouts/handdrawn_scaffold.dart index be9e423..6591125 100644 --- a/lib/design/handdrawn_scaffold.dart +++ b/lib/design/layouts/handdrawn_scaffold.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'pawmate_theme.dart'; +import '../theme/colors.dart'; /// Provides the paper surface and restrained app bar shared by Pawmate pages. class HanddrawnScaffold extends StatelessWidget { @@ -9,23 +9,28 @@ class HanddrawnScaffold extends StatelessWidget { required this.body, super.key, this.bottomNavigationBar, + this.actions, }); - final String title; + final String? title; final Widget body; final Widget? bottomNavigationBar; + final List? actions; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text( - title, - style: const TextStyle( - color: PawmateColors.ink, - fontWeight: FontWeight.w700, - ), - ), + 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/pawmate_theme.dart b/lib/design/theme/pawmate_theme.dart similarity index 80% rename from lib/design/pawmate_theme.dart rename to lib/design/theme/pawmate_theme.dart index a07ebbb..60298bd 100644 --- a/lib/design/pawmate_theme.dart +++ b/lib/design/theme/pawmate_theme.dart @@ -1,17 +1,6 @@ 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); -} +import 'colors.dart'; /// Creates the Material theme adapted to Pawmate's paper and ink surfaces. ThemeData buildPawmateTheme() { @@ -36,13 +25,15 @@ ThemeData buildPawmateTheme() { 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: const Color(0xFFFFFCF3), + fillColor: PawmateColors.card, labelStyle: const TextStyle(color: PawmateColors.softBrown), hintStyle: TextStyle(color: PawmateColors.softBrown.withAlpha(170)), border: OutlineInputBorder( 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_page.dart b/lib/features/chat/chat_page.dart index 864498a..48fb5bc 100644 --- a/lib/features/chat/chat_page.dart +++ b/lib/features/chat/chat_page.dart @@ -2,9 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../design/chat_message_bubble.dart'; -import '../../design/handdrawn_card.dart'; +import '../../design/components/chat_message_bubble.dart'; +import '../../design/components/handdrawn_card.dart'; import 'chat_controller.dart'; +import '../../l10n/generated/app_localizations.dart'; /// The default couple-space tab, with text history and visibility-based receipts. class ChatPage extends StatefulWidget { @@ -103,6 +104,7 @@ class _ChatPageState extends State { Widget build(BuildContext context) => ListenableBuilder( listenable: widget.controller, builder: (context, _) { + final l10n = AppLocalizations.of(context)!; final chat = widget.controller; final messages = chat.messages.reversed.toList(); final pending = chat.outbox.reversed.toList(); @@ -113,11 +115,11 @@ class _ChatPageState extends State { children: [ if (chat.error != null) MaterialBanner( - content: Text(chat.error!), + content: Text(l10n.chatError), actions: [ TextButton( onPressed: () => chat.synchronize(), - child: const Text('Retry'), + child: Text(l10n.retry), ), ], ), @@ -127,14 +129,10 @@ class _ChatPageState extends State { child: chat.loading ? const Center(child: CircularProgressIndicator()) : messages.isEmpty && pending.isEmpty - ? const Center( + ? Center( child: Padding( padding: EdgeInsets.all(24), - child: HanddrawnCard( - child: Text( - 'Your first words together\nSend a little hello to start your shared journal.', - ), - ), + child: HanddrawnCard(child: Text(l10n.firstWords)), ), ) : ListView.builder( @@ -152,7 +150,9 @@ class _ChatPageState extends State { key: ValueKey('pending-${outgoing.clientID}'), text: outgoing.text, own: true, - status: outgoing.sending ? 'Sending…' : 'Not sent', + status: outgoing.sending + ? l10n.sending + : l10n.notSent, onRetry: outgoing.sending ? null : () => chat.retry(outgoing), @@ -166,8 +166,8 @@ class _ChatPageState extends State { : () => chat.loadOlder(), child: Text( chat.historyLoading - ? 'Loading…' - : 'Load earlier messages', + ? l10n.loading + : l10n.loadEarlier, ), ); } @@ -185,8 +185,8 @@ class _ChatPageState extends State { time: message.createdAt, status: own ? (message.id <= chat.partnerReadID - ? 'Read' - : 'Unread') + ? l10n.read + : l10n.unread) : '', ); }, @@ -197,7 +197,7 @@ class _ChatPageState extends State { TextButton.icon( onPressed: _showLatest, icon: const Icon(Icons.arrow_downward), - label: Text('${chat.unreadCount} unread · Go to latest'), + label: Text(l10n.unreadGoLatest(chat.unreadCount)), ), SafeArea( top: false, @@ -216,16 +216,16 @@ class _ChatPageState extends State { keyboardType: TextInputType.multiline, textInputAction: TextInputAction.newline, decoration: InputDecoration( - labelText: 'Message your partner', + labelText: l10n.messagePartner, errorText: draft.text.runes.length > 4000 - ? 'Use up to 4000 characters.' + ? l10n.messageTooLong : null, ), ), ), const SizedBox(width: 8), IconButton.filled( - tooltip: 'Send message', + tooltip: l10n.sendMessage, onPressed: draft.text.trim().isEmpty || draft.text.runes.length > 4000 || diff --git a/lib/features/pairing/device_login_page.dart b/lib/features/pairing/device_login_page.dart index 46bf5eb..53c5dda 100644 --- a/lib/features/pairing/device_login_page.dart +++ b/lib/features/pairing/device_login_page.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; -import '../../design/handdrawn_button.dart'; -import '../../design/handdrawn_card.dart'; -import '../../design/handdrawn_scaffold.dart'; -import '../../design/handdrawn_text_field.dart'; +import '../../design/components/handdrawn_button.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/components/handdrawn_text_field.dart'; +import '../../design/icons/access/access_doodle_icon.dart'; +import '../../design/theme/colors.dart'; +import '../../design/theme/spacing.dart'; import 'paired_home_page.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; import 'widgets/pairing_error_note.dart'; +import '../../l10n/generated/app_localizations.dart'; /// Adds this installation to an existing member using a one-time login code. class DeviceLoginPage extends StatefulWidget { @@ -21,12 +25,18 @@ class _DeviceLoginPageState extends State { final _formKey = GlobalKey(); final _server = TextEditingController(); final _code = TextEditingController(); - final _name = TextEditingController(text: PairingApi.defaultDeviceName); + final _name = TextEditingController(); final _api = PairingApi(); final _credentials = PairingCredentials(); bool _busy = false; String? _error; + @override + void initState() { + super.initState(); + _name.text = AppLocalizations.of(context)!.myDeviceName; + } + @override void dispose() { _server.dispose(); @@ -88,16 +98,13 @@ class _DeviceLoginPageState extends State { if (mounted) { setState( () => _error = error.message == 'invalid_device_code' - ? 'This login code expired or was already used. Generate a new one on your signed-in device.' - : error.message, + ? AppLocalizations.of(context)!.invalidDeviceCode + : AppLocalizations.of(context)!.requestFailed, ); } } on Object { if (mounted) { - setState( - () => _error = - 'Could not sign in. Check your connection and try again.', - ); + setState(() => _error = AppLocalizations.of(context)!.couldNotSignIn); } } finally { if (mounted) setState(() => _busy = false); @@ -106,7 +113,7 @@ class _DeviceLoginPageState extends State { @override Widget build(BuildContext context) => HanddrawnScaffold( - title: 'Bring your home along', + title: AppLocalizations.of(context)!.bringHome, body: SafeArea( child: ListView( padding: const EdgeInsets.all(20), @@ -117,53 +124,69 @@ class _DeviceLoginPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Center( + child: AccessDoodleIcon( + symbol: AccessDoodle.addDevice, + color: PawmateColors.lavender, + ), + ), + const SizedBox(height: PawmateSpace.large), Text( - 'Sign in on another device', - style: Theme.of(context).textTheme.titleLarge, + AppLocalizations.of(context)!.signInAnotherDevice, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 12), - const Text( - 'On your signed-in phone, tablet or computer, open My devices and choose Add a device. Enter its server address and login code here. Your other devices will stay signed in.', + Text( + AppLocalizations.of(context)!.deviceSignInHelp, + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), ), const SizedBox(height: 20), HanddrawnTextField( controller: _server, - labelText: 'Server URL', - hintText: 'https://pawmate.example.com', + labelText: AppLocalizations.of(context)!.serverUrl, + hintText: AppLocalizations.of(context)!.serverUrlHint, validator: (value) => value == null || value.trim().isEmpty - ? 'Enter your server address.' + ? AppLocalizations.of(context)!.enterServerAddress : null, ), const SizedBox(height: 12), HanddrawnTextField( controller: _code, - labelText: 'Device login code', + labelText: AppLocalizations.of(context)!.deviceLoginCode, keyboardType: TextInputType.text, validator: (value) => value == null || value.trim().isEmpty - ? 'Enter the login code.' + ? AppLocalizations.of(context)!.enterLoginCode : null, ), const SizedBox(height: 12), HanddrawnTextField( controller: _name, - labelText: 'Device name', + labelText: AppLocalizations.of(context)!.deviceName, keyboardType: TextInputType.text, validator: (value) => value == null || value.trim().isEmpty || value.trim().runes.length > 80 - ? 'Use a name between 1 and 80 characters.' + ? AppLocalizations.of(context)!.deviceNameLength : null, ), const SizedBox(height: 20), HanddrawnButton( - label: _busy ? 'Signing in…' : 'Sign in', + label: _busy + ? AppLocalizations.of(context)!.signingIn + : AppLocalizations.of(context)!.signIn, icon: Icons.devices, onPressed: _busy ? null : _signIn, ), TextButton( onPressed: _busy ? null : () => Navigator.of(context).pop(), - child: const Text('Back'), + child: Text(AppLocalizations.of(context)!.back), ), ], ), diff --git a/lib/features/pairing/invitation_link.dart b/lib/features/pairing/invitation_link.dart new file mode 100644 index 0000000..cac243f --- /dev/null +++ b/lib/features/pairing/invitation_link.dart @@ -0,0 +1,36 @@ +import 'pairing_api.dart'; + +/// Validates the same private invitation contract for pasted and external links. +abstract final class InvitationLink { + /// Parses a complete one-time link without contacting or logging its server. + static Uri parse(String value) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || + uri.scheme != 'pawmate' || + uri.host != 'pair' || + uri.userInfo.isNotEmpty || + uri.hasPort || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/')) { + throw const FormatException( + 'Paste the complete pawmate://pair invitation link.', + ); + } + final values = uri.queryParametersAll; + if (values['server']?.length != 1 || + values['code']?.length != 1 || + values['code']!.single.trim().isEmpty) { + throw const FormatException( + 'This invitation needs one server address and one invitation code.', + ); + } + try { + PairingApi.parseServerURL(values['server']!.single); + } on PairingApiException { + throw const FormatException( + 'The invitation contains an invalid server address.', + ); + } + return uri; + } +} diff --git a/lib/features/pairing/invite_link_entry_page.dart b/lib/features/pairing/invite_link_entry_page.dart new file mode 100644 index 0000000..b224966 --- /dev/null +++ b/lib/features/pairing/invite_link_entry_page.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../design/components/handdrawn_button.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/components/handdrawn_text_field.dart'; +import '../../design/icons/access/access_doodle_icon.dart'; +import '../../design/theme/spacing.dart'; +import 'invitation_link.dart'; +import 'invitee_accept_page.dart'; +import 'widgets/pairing_error_note.dart'; +import '../../l10n/generated/app_localizations.dart'; + +/// Receives an explicitly pasted link before showing the server/accept review. +class InviteLinkEntryPage extends StatefulWidget { + const InviteLinkEntryPage({super.key}); + + @override + State createState() => _InviteLinkEntryPageState(); +} + +class _InviteLinkEntryPageState extends State { + final _formKey = GlobalKey(); + final _link = TextEditingController(); + String? _clipboardError; + + @override + void dispose() { + _link.dispose(); + super.dispose(); + } + + /// Reads the clipboard only after a user gesture, never automatically at startup. + Future _paste() async { + final l10n = AppLocalizations.of(context)!; + try { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (!mounted) return; + setState(() { + if (data?.text?.trim().isNotEmpty == true) { + _link.text = data!.text!.trim(); + _clipboardError = null; + } else { + _clipboardError = l10n.pasteInvitationLinkFirst; + } + }); + } on Object { + if (mounted) { + setState(() => _clipboardError = l10n.clipboardUnavailable); + } + } + } + + /// Opens a review page; simply pasting a link never accepts the invitation. + void _review() { + if (!_formKey.currentState!.validate()) return; + final uri = InvitationLink.parse(_link.text); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => InviteeAcceptPage(inviteUri: uri)), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnScaffold( + title: l10n.acceptInvitation, + body: SafeArea( + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.all(PawmateSpace.page), + children: [ + HanddrawnCard( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center( + child: AccessDoodleIcon( + symbol: AccessDoodle.acceptInvitation, + ), + ), + const SizedBox(height: PawmateSpace.large), + Text( + l10n.anInvitationForTwo, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: PawmateSpace.medium), + Text( + l10n.invitationPasteHelp, + style: TextStyle(height: 1.4), + ), + const SizedBox(height: PawmateSpace.page), + HanddrawnTextField( + controller: _link, + labelText: l10n.invitationLink, + hintText: l10n.invitationLinkHint, + validator: (value) { + try { + InvitationLink.parse(value ?? ''); + return null; + } on FormatException { + return l10n.invalidInvitationLink; + } + }, + ), + const SizedBox(height: PawmateSpace.medium), + HanddrawnButton( + label: l10n.pasteLink, + onPressed: _paste, + icon: Icons.content_paste, + primary: false, + ), + const SizedBox(height: PawmateSpace.large), + HanddrawnButton( + label: l10n.reviewInvitation, + onPressed: _review, + ), + ], + ), + ), + ), + if (_clipboardError != null) ...[ + const SizedBox(height: PawmateSpace.large), + PairingErrorNote(message: _clipboardError!), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/pairing/invitee_accept_page.dart b/lib/features/pairing/invitee_accept_page.dart index ed2e776..bb955b6 100644 --- a/lib/features/pairing/invitee_accept_page.dart +++ b/lib/features/pairing/invitee_accept_page.dart @@ -1,13 +1,15 @@ import 'package:flutter/material.dart'; -import '../../design/handdrawn_button.dart'; -import '../../design/handdrawn_card.dart'; -import '../../design/handdrawn_scaffold.dart'; -import '../../design/pawmate_theme.dart'; +import '../../design/components/handdrawn_button.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/theme/colors.dart'; +import '../../design/icons/access/access_doodle_icon.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; import 'paired_home_page.dart'; import 'widgets/pairing_error_note.dart'; +import '../../l10n/generated/app_localizations.dart'; /// Lets the invitee review and accept a one-time link opened from another app. class InviteeAcceptPage extends StatefulWidget { @@ -32,10 +34,14 @@ class _InviteeAcceptPageState extends State { } Future _acceptInvite() async { + final l10n = AppLocalizations.of(context)!; final serverURL = widget.inviteUri.queryParameters['server']; final code = widget.inviteUri.queryParameters['code']; if (serverURL == null || code == null || code.isEmpty) { - setState(() => _errorMessage = 'This invitation link is incomplete.'); + setState( + () => + _errorMessage = AppLocalizations.of(context)!.incompleteInvitation, + ); return; } @@ -49,9 +55,7 @@ class _InviteeAcceptPageState extends State { final recoveryCode = result.recoveryCode; final pairID = result.pairID; if (token == null || recoveryCode == null || pairID == null) { - throw const PairingApiException( - 'The server did not return complete pairing credentials.', - ); + throw PairingApiException(l10n.incompleteCredentials); } var storageWarning = false; try { @@ -88,11 +92,17 @@ class _InviteeAcceptPageState extends State { ), (_) => false, ); - } on PairingApiException catch (error) { - if (mounted) setState(() => _errorMessage = error.message); + } on PairingApiException { + if (mounted) { + setState( + () => _errorMessage = AppLocalizations.of(context)!.requestFailed, + ); + } } on Object { if (mounted) { - setState(() => _errorMessage = 'Could not accept this invitation.'); + setState( + () => _errorMessage = AppLocalizations.of(context)!.couldNotAccept, + ); } } finally { if (mounted) setState(() => _isLoading = false); @@ -103,7 +113,7 @@ class _InviteeAcceptPageState extends State { Widget build(BuildContext context) { final serverURL = widget.inviteUri.queryParameters['server'] ?? 'Unknown'; return HanddrawnScaffold( - title: 'Join your little home', + title: AppLocalizations.of(context)!.joinHome, body: SafeArea( child: ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), @@ -111,16 +121,17 @@ class _InviteeAcceptPageState extends State { HanddrawnCard( color: const Color(0xFFF4F0FF), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - const Icon( - Icons.mail_outline, - size: 32, - color: PawmateColors.lavender, + const Center( + child: AccessDoodleIcon( + symbol: AccessDoodle.acceptInvitation, + ), ), const SizedBox(height: 12), Text( - 'You have been invited', + AppLocalizations.of(context)!.invited, + textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: PawmateColors.ink, fontWeight: FontWeight.w700, @@ -128,15 +139,16 @@ class _InviteeAcceptPageState extends State { ), const SizedBox(height: 8), Text( - 'Accepting this one-time invitation will pair your device with your partner.', + AppLocalizations.of(context)!.acceptOneTimeHelp, + textAlign: TextAlign.center, style: const TextStyle( color: PawmateColors.softBrown, height: 1.4, ), ), const SizedBox(height: 16), - const Text( - 'Private server', + Text( + AppLocalizations.of(context)!.privateServer, style: TextStyle( color: PawmateColors.softBrown, fontSize: 12, @@ -154,7 +166,9 @@ class _InviteeAcceptPageState extends State { HanddrawnButton( onPressed: _isLoading ? null : _acceptInvite, icon: Icons.favorite_border, - label: _isLoading ? 'Joining…' : 'Accept invitation', + label: _isLoading + ? AppLocalizations.of(context)!.joining + : AppLocalizations.of(context)!.acceptInvitation, ), ], ), diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart index 6d69877..7ee0a90 100644 --- a/lib/features/pairing/inviter_setup_page.dart +++ b/lib/features/pairing/inviter_setup_page.dart @@ -1,17 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../design/handdrawn_scaffold.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; import 'widgets/invite_result_card.dart'; import 'widgets/pairing_error_note.dart'; -import 'widgets/pairing_header.dart'; -import 'widgets/pairing_recovery_card.dart'; import 'widgets/server_setup_card.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; -import '../../design/handdrawn_button.dart'; -import 'device_login_page.dart'; import 'paired_home_page.dart'; +import '../../l10n/generated/app_localizations.dart'; class InviterSetupPage extends StatefulWidget { const InviterSetupPage({super.key}); @@ -23,8 +20,6 @@ class InviterSetupPage extends StatefulWidget { class _InviterSetupPageState extends State { final _formKey = GlobalKey(); final _serverURLController = TextEditingController(); - final _recoveryServerController = TextEditingController(); - final _recoveryCodeController = TextEditingController(); final _api = PairingApi(); final _credentials = PairingCredentials(); PairingInvite? _invite; @@ -67,8 +62,6 @@ class _InviterSetupPageState extends State { @override void dispose() { _serverURLController.dispose(); - _recoveryServerController.dispose(); - _recoveryCodeController.dispose(); _api.close(); super.dispose(); } @@ -99,74 +92,27 @@ class _InviterSetupPageState extends State { } on Object { if (mounted) { setState(() { - _errorMessage = - 'Invitation created. Save the recovery code below; this device could not store it securely.'; + _errorMessage = AppLocalizations.of( + context, + )!.invitationCreatedStorageWarning; }); } } } on PairingApiException catch (error) { - if (mounted) setState(() => _errorMessage = error.message); - } on Object { if (mounted) { - setState(() => _errorMessage = 'Could not reach the Pawmate server.'); - } - } finally { - if (mounted) setState(() => _isLoading = false); - } - } - - /// Restores this member's access after reinstall and rotates the recovery code. - Future _recoverAccess() async { - setState(() { - _errorMessage = null; - _isLoading = true; - }); - try { - final credentials = await _api.recover( - _recoveryServerController.text, - _recoveryCodeController.text, - ); - final saved = SavedPairingCredentials( - serverURL: _recoveryServerController.text.trim(), - accessToken: credentials.accessToken, - recoveryCode: credentials.recoveryCode, - pairID: credentials.pairID, - role: credentials.role, - ); - var storageWarning = false; - try { - await _credentials.save( - serverURL: saved.serverURL, - accessToken: saved.accessToken, - recoveryCode: saved.recoveryCode, - pairID: saved.pairID, - role: saved.role, + final l10n = AppLocalizations.of(context)!; + setState( + () => _errorMessage = error.message.startsWith('Use an HTTP') + ? l10n.invalidServerAddress + : error.statusCode == null + ? l10n.serverUnreachable + : l10n.requestFailed, ); - } on Object { - storageWarning = true; } - if (!mounted) return; - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (_) => PairedHomePage( - credentials: saved, - session: PairingSession( - role: credentials.role, - status: 'paired', - pairID: credentials.pairID, - ), - recoveryCodeToSave: credentials.recoveryCode, - storageWarning: storageWarning, - ), - ), - (_) => false, - ); - } on PairingApiException catch (error) { - if (mounted) setState(() => _errorMessage = error.message); } on Object { if (mounted) { setState( - () => _errorMessage = 'Could not restore access to this home.', + () => _errorMessage = AppLocalizations.of(context)!.serverUnreachable, ); } } finally { @@ -227,11 +173,19 @@ class _InviterSetupPageState extends State { return; } if (mounted) setState(() => _status = status); - } on PairingApiException catch (error) { - if (mounted) setState(() => _errorMessage = error.message); + } on PairingApiException { + if (mounted) { + setState( + () => _errorMessage = AppLocalizations.of(context)!.requestFailed, + ); + } } on Object { if (mounted) { - setState(() => _errorMessage = 'Could not reach the server.'); + setState( + () => _errorMessage = AppLocalizations.of( + context, + )!.serverUnreachableShort, + ); } } finally { if (mounted) setState(() => _isLoading = false); @@ -244,9 +198,9 @@ class _InviterSetupPageState extends State { if (invite == null) return; await Clipboard.setData(ClipboardData(text: invite.inviteURL)); if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Invitation link copied'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.invitationCopied)), + ); } } @@ -256,40 +210,19 @@ class _InviterSetupPageState extends State { final invite = _invite; final status = _status; return HanddrawnScaffold( - title: 'Invite your partner', + title: AppLocalizations.of(context)!.invitePartner, body: SafeArea( child: ListView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), children: [ - const PairingHeader(), - const SizedBox(height: 18), - HanddrawnButton( - label: 'Sign in on another device', - icon: Icons.devices, - primary: false, - onPressed: _isLoading - ? null - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const DeviceLoginPage(), - ), - ), - ), - const SizedBox(height: 20), + const SizedBox(height: 6), ServerSetupCard( formKey: _formKey, controller: _serverURLController, isLoading: _isLoading, onCreateInvite: () => _createInvite(), ), - const SizedBox(height: 18), - PairingRecoveryCard( - serverController: _recoveryServerController, - codeController: _recoveryCodeController, - isLoading: _isLoading, - onRecover: () => _recoverAccess(), - ), if (_errorMessage != null) ...[ const SizedBox(height: 18), PairingErrorNote(message: _errorMessage!), diff --git a/lib/features/pairing/login_methods_page.dart b/lib/features/pairing/login_methods_page.dart new file mode 100644 index 0000000..cb04d04 --- /dev/null +++ b/lib/features/pairing/login_methods_page.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +import '../../design/components/handdrawn_action_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/icons/access/access_doodle_icon.dart'; +import '../../design/theme/colors.dart'; +import '../../design/theme/spacing.dart'; +import '../../l10n/generated/app_localizations.dart'; +import '../../l10n/locale_controller.dart'; +import 'device_login_page.dart'; +import 'invite_link_entry_page.dart'; +import 'inviter_setup_page.dart'; +import 'pairing_recovery_page.dart'; + +/// Offers four distinct access paths before asking for sensitive form values. +class LoginMethodsPage extends StatelessWidget { + const LoginMethodsPage({super.key}); + + /// Opens one focused flow; returning restores the same two-by-two landing page. + void _open(BuildContext context, Widget page) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + } + + Future _chooseLanguage(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + final selected = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text(l10n.chooseLanguage), + children: [ + SimpleDialogOption( + onPressed: () => Navigator.pop(context, 'en'), + child: Text(l10n.english), + ), + SimpleDialogOption( + onPressed: () => Navigator.pop(context, 'zh'), + child: Text(l10n.chinese), + ), + ], + ), + ); + if (selected != null && context.mounted) { + await LocaleControllerScope.of(context).setLocale(Locale(selected)); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnScaffold( + title: null, + actions: [ + IconButton( + tooltip: l10n.changeLanguage, + onPressed: () => _chooseLanguage(context), + icon: const Icon(Icons.language), + ), + ], + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(PawmateSpace.page), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: PawmateSpace.contentWidth, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.homeHeadline, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: PawmateSpace.medium), + Text( + l10n.homeIntro, + style: TextStyle(color: PawmateColors.ink, height: 1.4), + ), + const SizedBox(height: PawmateSpace.page), + // Content-driven rows keep two columns even with large text. + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: HanddrawnActionCard( + label: l10n.createInvitation, + description: l10n.createInvitationDescription, + illustration: const AccessDoodleIcon( + symbol: AccessDoodle.createInvitation, + ), + color: PawmateColors.rosePaper, + onPressed: () => + _open(context, const InviterSetupPage()), + ), + ), + const SizedBox(width: PawmateSpace.large), + Expanded( + child: HanddrawnActionCard( + label: l10n.acceptInvitation, + description: l10n.acceptInvitationDescription, + illustration: const AccessDoodleIcon( + symbol: AccessDoodle.acceptInvitation, + ), + color: PawmateColors.rosePaper, + onPressed: () => + _open(context, const InviteLinkEntryPage()), + ), + ), + ], + ), + ), + const SizedBox(height: PawmateSpace.large), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: HanddrawnActionCard( + label: l10n.restoreData, + description: l10n.restoreDataDescription, + illustration: const AccessDoodleIcon( + symbol: AccessDoodle.restoreHome, + color: PawmateColors.lavender, + ), + color: PawmateColors.lavenderPaper, + onPressed: () => + _open(context, const PairingRecoveryPage()), + ), + ), + const SizedBox(width: PawmateSpace.large), + Expanded( + child: HanddrawnActionCard( + label: l10n.addDevice, + description: l10n.addDeviceDescription, + illustration: const AccessDoodleIcon( + symbol: AccessDoodle.addDevice, + color: PawmateColors.lavender, + ), + color: PawmateColors.lavenderPaper, + onPressed: () => + _open(context, const DeviceLoginPage()), + ), + ), + ], + ), + ), + const SizedBox(height: PawmateSpace.page), + Text( + l10n.alreadyPaired, + style: TextStyle(color: PawmateColors.ink, height: 1.5), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/pairing/paired_home_page.dart b/lib/features/pairing/paired_home_page.dart index 0be3086..d52b5ad 100644 --- a/lib/features/pairing/paired_home_page.dart +++ b/lib/features/pairing/paired_home_page.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import '../../design/handdrawn_card.dart'; -import '../../design/handdrawn_scaffold.dart'; -import '../../design/pawmate_theme.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/theme/colors.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; import 'widgets/pairing_error_note.dart'; @@ -12,6 +12,7 @@ import '../chat/chat_api.dart'; import '../chat/chat_controller.dart'; import '../chat/chat_page.dart'; import 'pairing_session_gate.dart'; +import '../../l10n/generated/app_localizations.dart'; /// Opens the couple space on chat, retaining drafts and scroll state between tabs. class PairedHomePage extends StatefulWidget { @@ -83,10 +84,12 @@ class _PairedHomePageState extends State ..showSnackBar( SnackBar( content: Text( - '${_chat.unreadCount} unread messages from your partner', + AppLocalizations.of( + context, + )!.unreadFromPartner(_chat.unreadCount), ), action: SnackBarAction( - label: 'Open chat', + label: AppLocalizations.of(context)!.openChat, onPressed: () => _selectTab(0), ), ), @@ -115,77 +118,76 @@ class _PairedHomePageState extends State } @override - Widget build(BuildContext context) => HanddrawnScaffold( - title: ['Our conversation', 'Play corner', 'Our little home'][_tab], - bottomNavigationBar: NavigationBar( - selectedIndex: _tab, - onDestinationSelected: _selectTab, - destinations: [ - NavigationDestination( - icon: Badge( - isLabelVisible: _chat.unreadCount > 0, - label: Text( - _chat.unreadCount > 99 ? '99+' : '${_chat.unreadCount}', + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return HanddrawnScaffold( + title: [l10n.ourConversation, l10n.playCorner, l10n.ourLittleHome][_tab], + bottomNavigationBar: NavigationBar( + selectedIndex: _tab, + onDestinationSelected: _selectTab, + destinations: [ + NavigationDestination( + icon: Badge( + isLabelVisible: _chat.unreadCount > 0, + label: Text( + _chat.unreadCount > 99 ? '99+' : '${_chat.unreadCount}', + ), + child: const Icon(Icons.chat_bubble_outline), ), - child: const Icon(Icons.chat_bubble_outline), + label: l10n.chat, + tooltip: l10n.chatUnread(_chat.unreadCount), ), - label: 'Chat', - tooltip: 'Chat, ${_chat.unreadCount} unread messages', - ), - const NavigationDestination( - icon: Icon(Icons.sports_esports_outlined), - label: 'Play', - ), - const NavigationDestination( - icon: Icon(Icons.home_outlined), - label: 'Home', - ), - ], - ), - body: Column( - children: [ - if (_homeNotice && - (widget.recoveryCodeToSave != null || widget.storageWarning)) - MaterialBanner( - content: Text( - widget.storageWarning - ? 'This device could not save its login. Open Home for reconnect instructions.' - : 'Save your recovery code in Home before leaving.', - ), - actions: [ - TextButton( - onPressed: () => _selectTab(2), - child: const Text('View Home'), - ), - ], + NavigationDestination( + icon: Icon(Icons.sports_esports_outlined), + label: l10n.play, ), - Expanded( - child: IndexedStack( - index: _tab, - children: [ - ChatPage(controller: _chat, active: _tab == 0), - const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: HanddrawnCard( - child: Text( - 'A little corner for playing together.\nGames will arrive here later.', - ), + NavigationDestination( + icon: Icon(Icons.home_outlined), + label: l10n.home, + ), + ], + ), + body: Column( + children: [ + if (_homeNotice && + (widget.recoveryCodeToSave != null || widget.storageWarning)) + MaterialBanner( + content: Text( + widget.storageWarning + ? l10n.loginNotSaved + : l10n.saveRecoveryCodeHome, + ), + actions: [ + TextButton( + onPressed: () => _selectTab(2), + child: Text(l10n.viewHome), + ), + ], + ), + Expanded( + child: IndexedStack( + index: _tab, + children: [ + ChatPage(controller: _chat, active: _tab == 0), + Center( + child: Padding( + padding: EdgeInsets.all(24), + child: HanddrawnCard(child: Text(l10n.gamesComing)), ), ), - ), - _HomeDetails( - credentials: widget.credentials, - session: widget.session, - recoveryCodeToSave: widget.recoveryCodeToSave, - storageWarning: widget.storageWarning, - ), - ], + _HomeDetails( + credentials: widget.credentials, + session: widget.session, + recoveryCodeToSave: widget.recoveryCodeToSave, + storageWarning: widget.storageWarning, + ), + ], + ), ), - ), - ], - ), - ); + ], + ), + ); + } } /// Keeps pairing details, device management and recovery notes in the Home tab. @@ -204,6 +206,7 @@ class _HomeDetails extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return SafeArea( child: ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), @@ -220,7 +223,7 @@ class _HomeDetails extends StatelessWidget { ), const SizedBox(height: 12), Text( - 'You are home together', + l10n.youAreHome, style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: PawmateColors.ink, fontWeight: FontWeight.w700, @@ -228,15 +231,15 @@ class _HomeDetails extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'This device is connected as the ${session.role}.', + l10n.connectedAsRole(session.role), style: const TextStyle( color: PawmateColors.softBrown, height: 1.4, ), ), const SizedBox(height: 16), - const Text( - 'Private server', + Text( + l10n.privateServer, style: TextStyle( color: PawmateColors.softBrown, fontSize: 12, @@ -253,7 +256,7 @@ class _HomeDetails extends StatelessWidget { if (session.pairID != null) ...[ const SizedBox(height: 12), Text( - 'Home ID: ${session.pairID}', + l10n.homeId(session.pairID!), style: const TextStyle( color: PawmateColors.softBrown, fontSize: 12, @@ -267,8 +270,8 @@ class _HomeDetails extends StatelessWidget { const SizedBox(height: 18), PairingErrorNote( message: recoveryCodeToSave != null - ? 'This device could not securely save its login. Keep the recovery code below in case the app closes.' - : 'This device could not securely save its login. If the app closes, generate a new login code on another signed-in device to reconnect.', + ? l10n.storageWarningRecovery + : l10n.storageWarningDevice, ), ], if (recoveryCodeToSave != null) ...[ diff --git a/lib/features/pairing/pairing_recovery_page.dart b/lib/features/pairing/pairing_recovery_page.dart index cb5c5fb..f8c5269 100644 --- a/lib/features/pairing/pairing_recovery_page.dart +++ b/lib/features/pairing/pairing_recovery_page.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; -import '../../design/handdrawn_card.dart'; -import '../../design/handdrawn_scaffold.dart'; -import '../../design/pawmate_theme.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/theme/colors.dart'; +import '../../design/icons/access/access_doodle_icon.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; import 'paired_home_page.dart'; import 'widgets/pairing_error_note.dart'; import 'widgets/pairing_recovery_card.dart'; import 'device_login_page.dart'; +import '../../l10n/generated/app_localizations.dart'; /// Restores an invalid or reinstalled member session with its recovery code. class PairingRecoveryPage extends StatefulWidget { @@ -88,11 +90,13 @@ class _PairingRecoveryPageState extends State { (_) => false, ); } on PairingApiException catch (error) { - if (mounted) setState(() => _errorMessage = _userMessage(error)); + if (mounted) { + setState(() => _errorMessage = _userMessage(context, error)); + } } on Object { if (mounted) { setState( - () => _errorMessage = 'Could not restore this home. Try again.', + () => _errorMessage = AppLocalizations.of(context)!.couldNotRestore, ); } } finally { @@ -100,18 +104,19 @@ class _PairingRecoveryPageState extends State { } } - String _userMessage(PairingApiException error) { + String _userMessage(BuildContext context, PairingApiException error) { + final l10n = AppLocalizations.of(context)!; return switch (error.message) { - 'invalid_recovery_code' => 'That recovery code is not valid anymore.', - 'not_paired' => 'This server does not have a completed couple pairing.', - _ => error.message, + 'invalid_recovery_code' => l10n.invalidRecoveryCode, + 'not_paired' => l10n.notPaired, + _ => l10n.requestFailed, }; } @override Widget build(BuildContext context) { return HanddrawnScaffold( - title: 'Restore your home', + title: AppLocalizations.of(context)!.restoreHome, body: SafeArea( child: ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), @@ -121,18 +126,25 @@ class _PairingRecoveryPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.key_outlined, color: PawmateColors.ink), + const Center( + child: AccessDoodleIcon( + symbol: AccessDoodle.restoreHome, + color: PawmateColors.lavender, + ), + ), const SizedBox(height: 10), Text( - 'Let’s reconnect you', + AppLocalizations.of(context)!.reconnectTitle, + textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: PawmateColors.ink, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 6), - const Text( - 'This device needs a new login. If another device is still signed in, use its device login code. Recovery is for lost access and signs out all your other devices.', + Text( + AppLocalizations.of(context)!.reconnectHelp, + textAlign: TextAlign.start, style: TextStyle( color: PawmateColors.softBrown, height: 1.4, @@ -151,7 +163,7 @@ class _PairingRecoveryPageState extends State { ), ), icon: const Icon(Icons.devices), - label: const Text('Sign in with a device login code'), + label: Text(AppLocalizations.of(context)!.deviceCodeSignIn), ), const SizedBox(height: 18), PairingRecoveryCard( diff --git a/lib/features/pairing/pairing_session_gate.dart b/lib/features/pairing/pairing_session_gate.dart index 1ef743d..b5e7e51 100644 --- a/lib/features/pairing/pairing_session_gate.dart +++ b/lib/features/pairing/pairing_session_gate.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; -import '../../design/handdrawn_button.dart'; -import '../../design/handdrawn_card.dart'; -import '../../design/handdrawn_scaffold.dart'; -import '../../design/pawmate_theme.dart'; +import '../../design/components/handdrawn_button.dart'; +import '../../design/components/handdrawn_card.dart'; +import '../../design/layouts/handdrawn_scaffold.dart'; +import '../../design/theme/colors.dart'; import 'inviter_setup_page.dart'; +import 'login_methods_page.dart'; import 'paired_home_page.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; import 'pairing_recovery_page.dart'; +import '../../l10n/generated/app_localizations.dart'; /// Loads local credentials and routes to onboarding, recovery, or the home. class PairingSessionGate extends StatefulWidget { @@ -18,7 +20,7 @@ class PairingSessionGate extends StatefulWidget { State createState() => _PairingSessionGateState(); } -enum _GateView { loading, setup, recovery, home, error } +enum _GateView { loading, setup, invitation, recovery, home, error } class _PairingSessionGateState extends State { final _api = PairingApi(); @@ -86,7 +88,7 @@ class _PairingSessionGateState extends State { } if (mounted) setState(() => _view = _GateView.home); } else if (session.status == 'pending' && session.role == 'inviter') { - setState(() => _view = _GateView.setup); + setState(() => _view = _GateView.invitation); } else { setState(() => _view = _GateView.recovery); } @@ -103,8 +105,7 @@ class _PairingSessionGateState extends State { } on Object { if (mounted) { setState(() { - _errorMessage = - 'Could not read or validate saved access on this device.'; + _errorMessage = AppLocalizations.of(context)!.savedAccessError; _view = _GateView.error; }); } @@ -113,16 +114,17 @@ class _PairingSessionGateState extends State { String _messageFor(PairingApiException error) { if (error.statusCode == null) { - return 'Could not reach your Pawmate server. Check your connection and retry.'; + return AppLocalizations.of(context)!.serverAccessError; } - return error.message; + return AppLocalizations.of(context)!.serverAccessError; } @override Widget build(BuildContext context) { return switch (_view) { _GateView.loading => const _SessionLoadingPage(), - _GateView.setup => const InviterSetupPage(), + _GateView.setup => const LoginMethodsPage(), + _GateView.invitation => const InviterSetupPage(), _GateView.recovery => PairingRecoveryPage( initialServerURL: _saved?.serverURL ?? '', ), @@ -131,7 +133,8 @@ class _PairingSessionGateState extends State { session: _session!, ), _GateView.error => _SessionErrorPage( - message: _errorMessage ?? 'Could not restore this session.', + message: + _errorMessage ?? AppLocalizations.of(context)!.sessionRestoreError, onRetry: _restoreSession, onSetup: () => setState(() => _view = _GateView.setup), ), @@ -144,15 +147,16 @@ class _SessionLoadingPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const HanddrawnScaffold( - title: 'Opening your home', + final l10n = AppLocalizations.of(context)!; + return HanddrawnScaffold( + title: l10n.openingHome, body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: PawmateColors.lavender), SizedBox(height: 16), - Text('Checking your saved access…'), + Text(l10n.checkingSavedAccess), ], ), ), @@ -174,7 +178,7 @@ class _SessionErrorPage extends StatelessWidget { @override Widget build(BuildContext context) { return HanddrawnScaffold( - title: 'Finding your home', + title: AppLocalizations.of(context)!.findingHome, body: SafeArea( child: Padding( padding: const EdgeInsets.all(20), @@ -204,14 +208,14 @@ class _SessionErrorPage extends StatelessWidget { ), const SizedBox(height: 16), HanddrawnButton( - label: 'Try again', + label: AppLocalizations.of(context)!.tryAgain, icon: Icons.refresh, onPressed: onRetry, ), const SizedBox(height: 10), TextButton( onPressed: onSetup, - child: const Text('Set up a server'), + child: Text(AppLocalizations.of(context)!.chooseSignInMethod), ), ], ), diff --git a/lib/features/pairing/widgets/devices_card.dart b/lib/features/pairing/widgets/devices_card.dart index 75f09a2..9fdef68 100644 --- a/lib/features/pairing/widgets/devices_card.dart +++ b/lib/features/pairing/widgets/devices_card.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../../design/handdrawn_button.dart'; -import '../../../design/handdrawn_card.dart'; +import '../../../design/components/handdrawn_button.dart'; +import '../../../design/components/handdrawn_card.dart'; import '../pairing_api.dart'; import '../pairing_credentials.dart'; import '../pairing_session_gate.dart'; import 'pairing_error_note.dart'; +import '../../../l10n/generated/app_localizations.dart'; /// Lists the current member's devices and authorizes additional installations. class DevicesCard extends StatefulWidget { @@ -53,11 +54,13 @@ class _DevicesCardState extends State { (_) => false, ); } else { - setState(() => _error = error.message); + setState(() => _error = AppLocalizations.of(context)!.requestFailed); } } on Object { if (mounted) { - setState(() => _error = 'Could not reach your server. Try again.'); + setState( + () => _error = AppLocalizations.of(context)!.couldNotReachRetry, + ); } } finally { if (mounted) setState(() => _busy = false); @@ -83,21 +86,20 @@ class _DevicesCardState extends State { /// Confirms and removes a remote device without signing out this installation. Future _removeDevice(PairingDevice device) async { + final l10n = AppLocalizations.of(context)!; final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: Text('Sign out ${device.name}?'), - content: const Text( - 'This device will need a new login code to reconnect. Your other devices will stay signed in.', - ), + title: Text(l10n.signOutDeviceTitle(device.name)), + content: Text(l10n.signOutDeviceHelp), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), + child: Text(l10n.cancel), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: const Text('Sign out device'), + child: Text(l10n.signOutDevice), ), ], ), @@ -113,16 +115,15 @@ class _DevicesCardState extends State { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final code = _code; return HanddrawnCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('My devices', style: Theme.of(context).textTheme.titleLarge), + Text(l10n.myDevices, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), - const Text( - 'Keep your phone, tablet and computer connected to the same home. Only your own devices appear here.', - ), + Text(l10n.devicesHelp), if (_devices != null) ...[ for (final device in _devices!) Padding( @@ -136,14 +137,18 @@ class _DevicesCardState extends State { ), Text( device.current - ? 'This device' - : 'Added ${device.createdAt.toLocal().toString().split(' ').first}', + ? l10n.thisDevice + : l10n.addedDate( + MaterialLocalizations.of( + context, + ).formatShortDate(device.createdAt.toLocal()), + ), ), if (!device.current) TextButton.icon( onPressed: _busy ? null : () => _removeDevice(device), icon: const Icon(Icons.logout), - label: const Text('Sign out device'), + label: Text(l10n.signOutDevice), ), ], ), @@ -157,35 +162,39 @@ class _DevicesCardState extends State { TextButton.icon( onPressed: _busy ? null : _refresh, icon: const Icon(Icons.refresh), - label: const Text('Refresh devices'), + label: Text(l10n.refreshDevices), ), HanddrawnButton( - label: 'Add a device', + label: l10n.addDeviceAction, icon: Icons.add_to_queue, onPressed: _busy ? null : _addDevice, ), if (code != null) ...[ const SizedBox(height: 16), - const Text( - 'On your new device, choose Sign in on another device. Use the server address above and this code. Keep it private: it grants access as you.', - ), + Text(l10n.newDeviceCodeHelp), const SizedBox(height: 8), SelectableText( code.code, style: const TextStyle(fontFamily: 'monospace'), ), - Text('Single use · Expires ${code.expiresAt.toLocal()}'), + Text( + l10n.singleUseExpires( + MaterialLocalizations.of( + context, + ).formatMediumDate(code.expiresAt.toLocal()), + ), + ), TextButton.icon( onPressed: () async { await Clipboard.setData(ClipboardData(text: code.code)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Device login code copied')), + SnackBar(content: Text(l10n.deviceCodeCopied)), ); } }, icon: const Icon(Icons.copy), - label: const Text('Copy login code'), + label: Text(l10n.copyLoginCode), ), ], if (_error != null) ...[ diff --git a/lib/features/pairing/widgets/invite_result_card.dart b/lib/features/pairing/widgets/invite_result_card.dart index 10fc2fd..1fc8ecb 100644 --- a/lib/features/pairing/widgets/invite_result_card.dart +++ b/lib/features/pairing/widgets/invite_result_card.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; -import '../../../design/handdrawn_button.dart'; -import '../../../design/handdrawn_card.dart'; -import '../../../design/pawmate_theme.dart'; +import '../../../design/components/handdrawn_button.dart'; +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/theme/colors.dart'; import '../pairing_api.dart'; import 'recovery_code_note.dart'; +import '../../../l10n/generated/app_localizations.dart'; /// Displays the one-time invitation and the current partner pairing status. class InviteResultCard extends StatelessWidget { @@ -27,6 +28,7 @@ class InviteResultCard extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final isPaired = status?.status == 'paired'; return HanddrawnCard( color: const Color(0xFFF4F0FF), @@ -44,9 +46,7 @@ class InviteResultCard extends StatelessWidget { const SizedBox(width: 10), Expanded( child: Text( - isPaired - ? 'Your home is connected!' - : 'Your invitation is ready', + isPaired ? l10n.homeConnected : l10n.invitationReady, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: PawmateColors.ink, fontWeight: FontWeight.w700, @@ -57,9 +57,7 @@ class InviteResultCard extends StatelessWidget { ), const SizedBox(height: 8), Text( - isPaired - ? 'You and your partner can start making memories together.' - : 'Share this one-time link with your partner.', + isPaired ? l10n.connectedMemories : l10n.shareInvitation, style: const TextStyle( color: PawmateColors.softBrown, height: 1.35, @@ -80,12 +78,16 @@ class InviteResultCard extends StatelessWidget { HanddrawnButton( onPressed: onCopy, icon: Icons.copy_outlined, - label: 'Copy invitation', + label: l10n.copyInvitation, primary: false, ), const SizedBox(height: 10), Text( - 'Expires ${invite.expiresAt.toLocal()}', + l10n.expiresAt( + MaterialLocalizations.of( + context, + ).formatMediumDate(invite.expiresAt.toLocal()), + ), style: const TextStyle( color: PawmateColors.softBrown, fontSize: 12, @@ -98,13 +100,13 @@ class InviteResultCard extends StatelessWidget { HanddrawnButton( onPressed: isLoading ? null : onRefresh, icon: isPaired ? Icons.check_circle_outline : Icons.refresh, - label: isPaired ? 'Pairing complete' : 'Check pairing status', + label: isPaired ? l10n.pairingComplete : l10n.checkPairingStatus, primary: isPaired, ), if (!isPaired && status != null) ...[ const SizedBox(height: 10), - const Text( - 'Waiting for your partner to accept…', + Text( + l10n.waitingForPartner, style: TextStyle(color: PawmateColors.softBrown), ), ], diff --git a/lib/features/pairing/widgets/pairing_error_note.dart b/lib/features/pairing/widgets/pairing_error_note.dart index cf43a27..cc747d8 100644 --- a/lib/features/pairing/widgets/pairing_error_note.dart +++ b/lib/features/pairing/widgets/pairing_error_note.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import '../../../design/handdrawn_card.dart'; -import '../../../design/pawmate_theme.dart'; +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/theme/colors.dart'; /// Shows a readable network or validation error on a paper-like note. class PairingErrorNote extends StatelessWidget { diff --git a/lib/features/pairing/widgets/pairing_header.dart b/lib/features/pairing/widgets/pairing_header.dart deleted file mode 100644 index a4ff511..0000000 --- a/lib/features/pairing/widgets/pairing_header.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../design/doodle_house.dart'; -import '../../../design/pawmate_theme.dart'; - -/// Introduces the pairing flow with a small shared-home illustration. -class PairingHeader extends StatelessWidget { - const PairingHeader({super.key}); - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const DoodleHouse(), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Start your little home', - style: textTheme.headlineMedium?.copyWith( - color: PawmateColors.ink, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 6), - Text( - 'Set up a private place, then invite your favorite person in.', - style: textTheme.bodyLarge?.copyWith( - color: PawmateColors.softBrown, - height: 1.35, - ), - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/features/pairing/widgets/pairing_recovery_card.dart b/lib/features/pairing/widgets/pairing_recovery_card.dart index 67117ca..feef901 100644 --- a/lib/features/pairing/widgets/pairing_recovery_card.dart +++ b/lib/features/pairing/widgets/pairing_recovery_card.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import '../../../design/handdrawn_button.dart'; -import '../../../design/handdrawn_card.dart'; -import '../../../design/handdrawn_text_field.dart'; -import '../../../design/pawmate_theme.dart'; +import '../../../design/components/handdrawn_button.dart'; +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/components/handdrawn_text_field.dart'; +import '../../../design/theme/colors.dart'; +import '../../../l10n/generated/app_localizations.dart'; /// Collects the server address and recovery code after an app reinstall. class PairingRecoveryCard extends StatelessWidget { @@ -22,28 +23,29 @@ class PairingRecoveryCard extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return HanddrawnCard( color: const Color(0xFFF0F6F0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Restoring an existing home?', + l10n.restoreExistingHome, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: PawmateColors.ink, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), - const Text( - 'Use your recovery code if you have lost access. Recovery signs out all your devices and replaces the recovery code; your partner stays signed in. To add a device, use a device login code instead.', + Text( + l10n.recoveryHelp, style: TextStyle(color: PawmateColors.softBrown, height: 1.35), ), const SizedBox(height: 14), HanddrawnTextField( controller: serverController, - labelText: 'Existing server URL', - hintText: 'https://pawmate.example.com', + labelText: l10n.serverUrlExisting, + hintText: l10n.serverUrlHint, prefixIcon: Icons.link, ), const SizedBox(height: 12), @@ -52,8 +54,8 @@ class PairingRecoveryCard extends StatelessWidget { autocorrect: false, enableSuggestions: false, textCapitalization: TextCapitalization.none, - decoration: const InputDecoration( - labelText: 'Recovery code', + decoration: InputDecoration( + labelText: l10n.recoveryCode, prefixIcon: Icon(Icons.key_outlined), ), ), @@ -61,7 +63,7 @@ class PairingRecoveryCard extends StatelessWidget { HanddrawnButton( onPressed: isLoading ? null : onRecover, icon: Icons.settings_backup_restore, - label: isLoading ? 'Restoring access…' : 'Restore access', + label: isLoading ? l10n.restoringAccess : l10n.restoreAccess, primary: false, ), ], diff --git a/lib/features/pairing/widgets/recovery_code_note.dart b/lib/features/pairing/widgets/recovery_code_note.dart index acb5e81..569bc64 100644 --- a/lib/features/pairing/widgets/recovery_code_note.dart +++ b/lib/features/pairing/widgets/recovery_code_note.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../../design/handdrawn_card.dart'; -import '../../../design/pawmate_theme.dart'; +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/theme/colors.dart'; +import '../../../l10n/generated/app_localizations.dart'; /// Presents a member's one-time recovery code with a copy action. class RecoveryCodeNote extends StatelessWidget { @@ -12,21 +13,22 @@ class RecoveryCodeNote extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return HanddrawnCard( color: const Color(0xFFFFF3CF), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Keep this recovery code somewhere safe', + Text( + l10n.recoveryCodeTitle, style: TextStyle( color: PawmateColors.ink, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 6), - const Text( - 'Save this outside the app. Recovery signs out all your devices and issues a new code. For another phone, tablet or computer, use Add a device instead.', + Text( + l10n.recoveryCodeHelp, style: TextStyle(color: PawmateColors.softBrown, height: 1.35), ), const SizedBox(height: 12), @@ -47,12 +49,12 @@ class RecoveryCodeNote extends StatelessWidget { await Clipboard.setData(ClipboardData(text: recoveryCode)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Recovery code copied')), + SnackBar(content: Text(l10n.recoveryCodeCopied)), ); } }, icon: const Icon(Icons.copy_outlined), - label: const Text('Copy code'), + label: Text(l10n.copyCode), ), ), ], diff --git a/lib/features/pairing/widgets/server_setup_card.dart b/lib/features/pairing/widgets/server_setup_card.dart index 3518031..9f6327a 100644 --- a/lib/features/pairing/widgets/server_setup_card.dart +++ b/lib/features/pairing/widgets/server_setup_card.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; -import '../../../design/handdrawn_button.dart'; -import '../../../design/handdrawn_card.dart'; -import '../../../design/handdrawn_text_field.dart'; -import '../../../design/pawmate_theme.dart'; +import '../../../design/components/handdrawn_button.dart'; +import '../../../design/components/handdrawn_card.dart'; +import '../../../design/components/handdrawn_text_field.dart'; +import '../../../design/theme/colors.dart'; +import '../../../design/icons/access/access_doodle_icon.dart'; +import '../../../l10n/generated/app_localizations.dart'; /// Collects and submits the inviter's private Pawmate server address. class ServerSetupCard extends StatelessWidget { @@ -22,39 +24,39 @@ class ServerSetupCard extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return HanddrawnCard( child: Form( key: formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - const Icon(Icons.home_work_outlined, color: PawmateColors.ink), - const SizedBox(width: 8), - Text( - 'Choose your home address', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: PawmateColors.ink, - fontWeight: FontWeight.w700, - ), - ), - ], + const Center( + child: AccessDoodleIcon(symbol: AccessDoodle.createInvitation), + ), + const SizedBox(height: 12), + Text( + l10n.chooseHomeAddress, + textAlign: TextAlign.start, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 8), - const Text( - 'Use the URL or domain of the private server you trust.', + Text( + l10n.trustedServerHelp, style: TextStyle(color: PawmateColors.softBrown, height: 1.35), ), const SizedBox(height: 18), HanddrawnTextField( controller: controller, - labelText: 'Server URL or domain', - hintText: 'https://pawmate.example.com', + labelText: l10n.serverUrlDomain, + hintText: l10n.serverUrlHint, prefixIcon: Icons.link, validator: (value) { if (value == null || value.trim().isEmpty) { - return 'Enter your server URL.'; + return l10n.enterServerUrl; } return null; }, @@ -63,7 +65,7 @@ class ServerSetupCard extends StatelessWidget { HanddrawnButton( onPressed: isLoading ? null : onCreateInvite, icon: Icons.favorite_border, - label: isLoading ? 'Making a little room…' : 'Create invitation', + label: isLoading ? l10n.makingRoom : l10n.createInvitationAction, ), ], ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..97c00d4 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,154 @@ +{ + "@@locale": "en", + "chooseLanguage": "Language", + "english": "English", + "chinese": "简体中文", + "homeHeadline": "Your little home, together", + "homeIntro": "Choose how you’d like to come in.", + "createInvitation": "Create invitation", + "createInvitationDescription": "Start a home on your private server.", + "acceptInvitation": "Accept invitation", + "acceptInvitationDescription": "Join your partner using their link.", + "restoreData": "Restore data", + "restoreDataDescription": "Lost access? Use your recovery code.", + "addDevice": "Add device", + "addDeviceDescription": "Keep your other devices signed in.", + "alreadyPaired": "Already paired? Add a device instead of creating a new invitation. Your home’s data stays on your server.", + "invitePartner": "Invite your partner", + "chooseHomeAddress": "Choose your home address", + "trustedServerHelp": "Use the URL or domain of the private server you trust.", + "serverUrlDomain": "Server URL or domain", + "serverUrl": "Server URL", + "serverUrlExisting": "Existing server URL", + "serverUrlHint": "https://pawmate.example.com", + "enterServerUrl": "Enter your server URL.", + "makingRoom": "Making a little room…", + "createInvitationAction": "Create invitation", + "invitationCreatedStorageWarning": "Invitation created. Save the recovery code below; this device could not store it securely.", + "serverUnreachable": "Could not reach the Pawmate server.", + "serverUnreachableShort": "Could not reach the server.", + "invitationCopied": "Invitation link copied", + "invitationReady": "Your invitation is ready", + "homeConnected": "Your home is connected!", + "shareInvitation": "Share this one-time link with your partner.", + "connectedMemories": "You and your partner can start making memories together.", + "copyInvitation": "Copy invitation", + "expiresAt": "Expires {date}", + "@expiresAt": {"placeholders": {"date": {"type": "String"}}}, + "pairingComplete": "Pairing complete", + "checkPairingStatus": "Check pairing status", + "waitingForPartner": "Waiting for your partner to accept…", + "recoveryCodeTitle": "Your recovery code", + "recoveryCodeHelp": "Keep this code somewhere safe. It can restore your home after losing a device.", + "copyCode": "Copy code", + "recoveryCodeCopied": "Recovery code copied", + "anInvitationForTwo": "An invitation for two", + "invitationPasteHelp": "Paste the complete link your partner sent. You’ll review the server address before joining.", + "invitationLink": "Invitation link", + "pasteInvitationLinkFirst": "Copy your partner’s invitation link first.", + "clipboardUnavailable": "Clipboard unavailable. Paste or type the link into the field.", + "pasteLink": "Paste link", + "reviewInvitation": "Review invitation", + "invalidInvitationLink": "This invitation link is not valid.", + "incompleteInvitation": "This invitation link is incomplete.", + "joinHome": "Join your little home", + "invited": "You have been invited", + "acceptOneTimeHelp": "Accepting this one-time invitation will pair your device with your partner.", + "privateServer": "Private server", + "joining": "Joining…", + "couldNotAccept": "Could not accept this invitation.", + "incompleteCredentials": "The server did not return complete pairing credentials.", + "restoreHome": "Restore your home", + "reconnectTitle": "Let’s reconnect you", + "reconnectHelp": "This device needs a new login. If another device is still signed in, use its device login code. Recovery is for lost access and signs out all your other devices.", + "deviceCodeSignIn": "Sign in with a device login code", + "restoreExistingHome": "Restoring an existing home?", + "recoveryHelp": "Use your recovery code if you have lost access. Recovery signs out all your devices and replaces the recovery code; your partner stays signed in. To add a device, use a device login code instead.", + "recoveryCode": "Recovery code", + "restoringAccess": "Restoring access…", + "restoreAccess": "Restore access", + "invalidRecoveryCode": "That recovery code is not valid anymore.", + "notPaired": "This server does not have a completed couple pairing.", + "couldNotRestore": "Could not restore this home. Try again.", + "bringHome": "Bring your home along", + "signInAnotherDevice": "Sign in on another device", + "deviceSignInHelp": "On your signed-in phone, tablet or computer, open My devices and choose Add a device. Enter its server address and login code here. Your other devices will stay signed in.", + "deviceLoginCode": "Device login code", + "deviceName": "Device name", + "enterServerAddress": "Enter your server address.", + "enterLoginCode": "Enter the login code.", + "deviceNameLength": "Use a name between 1 and 80 characters.", + "signingIn": "Signing in…", + "signIn": "Sign in", + "back": "Back", + "invalidDeviceCode": "This login code expired or was already used. Generate a new one on your signed-in device.", + "couldNotSignIn": "Could not sign in. Check your connection and try again.", + "openingHome": "Opening your home", + "checkingSavedAccess": "Checking your saved access…", + "findingHome": "Finding your home", + "chooseSignInMethod": "Choose a sign-in method", + "ourConversation": "Our conversation", + "playCorner": "Play corner", + "ourLittleHome": "Our little home", + "chatUnread": "Chat, {count} unread messages", + "@chatUnread": {"placeholders": {"count": {"type": "int"}}}, + "viewHome": "View Home", + "retry": "Retry", + "unreadGoLatest": "{count} unread · Go to latest", + "@unreadGoLatest": {"placeholders": {"count": {"type": "int"}}}, + "messagePartner": "Message your partner", + "sendMessage": "Send message", + "retryMessage": "Retry message", + "myDevices": "My devices", + "signOutDeviceTitle": "Sign out {name}?", + "@signOutDeviceTitle": {"placeholders": {"name": {"type": "String"}}}, + "cancel": "Cancel", + "signOutDevice": "Sign out device", + "refreshDevices": "Refresh devices", + "deviceCodeCopied": "Device login code copied", + "copyLoginCode": "Copy login code", + "changeLanguage": "Change language", + "chat": "Chat", + "play": "Play", + "home": "Home", + "unreadFromPartner": "{count} unread messages from your partner", + "@unreadFromPartner": {"placeholders": {"count": {"type": "int"}}}, + "openChat": "Open chat", + "loginNotSaved": "This device could not save its login. Open Home for reconnect instructions.", + "saveRecoveryCodeHome": "Save your recovery code in Home before leaving.", + "gamesComing": "A little corner for playing together.\nGames will arrive here later.", + "youAreHome": "You are home together", + "connectedAsRole": "This device is connected as the {role}.", + "@connectedAsRole": {"placeholders": {"role": {"type": "String"}}}, + "homeId": "Home ID: {id}", + "@homeId": {"placeholders": {"id": {"type": "String"}}}, + "storageWarningRecovery": "This device could not securely save its login. Keep the recovery code below in case the app closes.", + "storageWarningDevice": "This device could not securely save its login. If the app closes, generate a new login code on another signed-in device to reconnect.", + "couldNotReachRetry": "Could not reach your server. Try again.", + "signOutDeviceHelp": "This device will need a new login code to reconnect. Your other devices will stay signed in.", + "devicesHelp": "Keep your phone, tablet and computer connected to the same home. Only your own devices appear here.", + "thisDevice": "This device", + "addedDate": "Added {date}", + "@addedDate": {"placeholders": {"date": {"type": "String"}}}, + "addDeviceAction": "Add a device", + "newDeviceCodeHelp": "On your new device, choose Sign in on another device. Use the server address above and this code. Keep it private: it grants access as you.", + "singleUseExpires": "Single use · Expires {date}", + "@singleUseExpires": {"placeholders": {"date": {"type": "String"}}}, + "chatError": "Chat could not be loaded. Check your connection and try again.", + "firstWords": "Your first words together\nSend a little hello to start your shared journal.", + "sending": "Sending…", + "notSent": "Not sent", + "loading": "Loading…", + "loadEarlier": "Load earlier messages", + "read": "Read", + "unread": "Unread", + "messageTooLong": "Use up to 4000 characters.", + "tryAgain": "Try again", + "savedAccessError": "Could not read or validate saved access on this device.", + "serverAccessError": "Could not reach your Pawmate server. Check your connection and retry.", + "sessionRestoreError": "Could not restore this session.", + "requestFailed": "The server could not complete this request. Try again.", + "invalidServerAddress": "Enter a valid HTTP or HTTPS server address.", + "invitationLinkHint": "pawmate://pair?server=…&code=…", + "myDeviceName": "My device" +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 0000000..85aeeb2 --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,154 @@ +{ + "@@locale": "zh", + "chooseLanguage": "选择语言", + "english": "English", + "chinese": "简体中文", + "homeHeadline": "两个人的小家", + "homeIntro": "选择一种方式进入", + "createInvitation": "创建邀请", + "createInvitationDescription": "在你的私人服务器上创建小家", + "acceptInvitation": "接受邀请", + "acceptInvitationDescription": "通过伴侣发送的链接加入", + "restoreData": "恢复数据", + "restoreDataDescription": "无法登录时使用恢复码", + "addDevice": "添加设备", + "addDeviceDescription": "让其他设备也保持登录", + "alreadyPaired": "如果已经配对,可直接添加设备,无需重新创建邀请,小家的数据仍保存在你的服务器上", + "invitePartner": "邀请伴侣", + "chooseHomeAddress": "设置小家的地址", + "trustedServerHelp": "填写你信任的私人服务器网址或域名", + "serverUrlDomain": "服务器网址或域名", + "serverUrl": "服务器网址", + "serverUrlExisting": "已有服务器网址", + "serverUrlHint": "https://pawmate.example.com", + "enterServerUrl": "请输入服务器网址", + "makingRoom": "正在准备小家…", + "createInvitationAction": "创建邀请", + "invitationCreatedStorageWarning": "邀请已创建,请保存下方恢复码,此设备未能安全保存它", + "serverUnreachable": "无法连接到 Pawmate 服务器", + "serverUnreachableShort": "无法连接到服务器", + "invitationCopied": "邀请链接已复制", + "invitationReady": "邀请已准备好", + "homeConnected": "小家已连接", + "shareInvitation": "把这个一次性链接分享给伴侣", + "connectedMemories": "现在可以和伴侣一起留下回忆了", + "copyInvitation": "复制邀请", + "expiresAt": "有效期至 {date}", + "@expiresAt": {"placeholders": {"date": {"type": "String"}}}, + "pairingComplete": "配对完成", + "checkPairingStatus": "检查配对状态", + "waitingForPartner": "等待伴侣接受邀请…", + "recoveryCodeTitle": "你的小家恢复码", + "recoveryCodeHelp": "请妥善保存此恢复码,设备丢失后可用它恢复小家", + "copyCode": "复制代码", + "recoveryCodeCopied": "恢复码已复制", + "anInvitationForTwo": "一份双人邀请", + "invitationPasteHelp": "粘贴伴侣发来的完整链接,加入前可以先核对服务器地址", + "invitationLink": "邀请链接", + "pasteInvitationLinkFirst": "请先复制伴侣发来的邀请链接", + "clipboardUnavailable": "剪贴板不可用,请在下方粘贴或输入链接", + "pasteLink": "粘贴链接", + "reviewInvitation": "查看邀请", + "invalidInvitationLink": "邀请链接无效", + "incompleteInvitation": "邀请链接不完整", + "joinHome": "加入小家", + "invited": "你收到了一份邀请", + "acceptOneTimeHelp": "接受这份一次性邀请后,你的设备将与伴侣完成配对", + "privateServer": "私人服务器", + "joining": "正在加入…", + "couldNotAccept": "无法接受这份邀请", + "incompleteCredentials": "服务器返回的配对凭证不完整", + "restoreHome": "恢复小家", + "reconnectTitle": "重新连接小家", + "reconnectHelp": "此设备需要重新登录,如果其他设备仍处于登录状态,请使用设备登录码,恢复码适用于无法访问其他设备的情况,并会让其他设备退出登录", + "deviceCodeSignIn": "使用设备登录码登录", + "restoreExistingHome": "需要恢复已有的小家吗", + "recoveryHelp": "无法登录时使用恢复码,恢复后所有设备都会退出登录,恢复码也会更新,伴侣仍保持登录,添加设备请使用设备登录码", + "recoveryCode": "恢复码", + "restoringAccess": "正在恢复访问…", + "restoreAccess": "恢复访问", + "invalidRecoveryCode": "恢复码已失效", + "notPaired": "此服务器上还没有完成配对的小家", + "couldNotRestore": "无法恢复小家,请重试", + "bringHome": "在新设备上登录", + "signInAnotherDevice": "登录另一台设备", + "deviceSignInHelp": "在已登录的手机、平板或电脑上打开“我的设备”并选择“添加设备”,然后在这里输入服务器地址和登录码,其他设备会保持登录", + "deviceLoginCode": "设备登录码", + "deviceName": "设备名称", + "enterServerAddress": "请输入服务器地址", + "enterLoginCode": "请输入登录码", + "deviceNameLength": "设备名称长度需为 1 到 80 个字符", + "signingIn": "正在登录…", + "signIn": "登录", + "back": "返回", + "invalidDeviceCode": "登录码已过期或已使用,请在已登录设备上重新生成", + "couldNotSignIn": "无法登录,请检查网络后重试", + "openingHome": "正在打开小家", + "checkingSavedAccess": "正在检查已保存的登录信息…", + "findingHome": "正在寻找小家", + "chooseSignInMethod": "选择登录方式", + "ourConversation": "我们的聊天", + "playCorner": "游戏角", + "ourLittleHome": "我们的小家", + "chatUnread": "聊天,有 {count} 条未读消息", + "@chatUnread": {"placeholders": {"count": {"type": "int"}}}, + "viewHome": "查看小家", + "retry": "重试", + "unreadGoLatest": "有 {count} 条未读 · 查看最新消息", + "@unreadGoLatest": {"placeholders": {"count": {"type": "int"}}}, + "messagePartner": "给伴侣发消息", + "sendMessage": "发送消息", + "retryMessage": "重试发送", + "myDevices": "我的设备", + "signOutDeviceTitle": "让 {name} 退出登录", + "@signOutDeviceTitle": {"placeholders": {"name": {"type": "String"}}}, + "cancel": "取消", + "signOutDevice": "退出此设备", + "refreshDevices": "刷新设备", + "deviceCodeCopied": "设备登录码已复制", + "copyLoginCode": "复制登录码", + "changeLanguage": "切换语言", + "chat": "聊天", + "play": "游戏", + "home": "小家", + "unreadFromPartner": "有 {count} 条伴侣发来的未读消息", + "@unreadFromPartner": {"placeholders": {"count": {"type": "int"}}}, + "openChat": "打开聊天", + "loginNotSaved": "此设备未能保存登录信息,请打开小家查看重新连接说明", + "saveRecoveryCodeHome": "离开前请在小家中保存恢复码", + "gamesComing": "一起玩耍的小角落\n游戏功能即将到来", + "youAreHome": "你们已经回到小家", + "connectedAsRole": "此设备以{role}身份连接", + "@connectedAsRole": {"placeholders": {"role": {"type": "String"}}}, + "homeId": "小家编号:{id}", + "@homeId": {"placeholders": {"id": {"type": "String"}}}, + "storageWarningRecovery": "此设备未能安全保存登录信息,请保存下方恢复码,以免应用关闭后无法登录", + "storageWarningDevice": "此设备未能安全保存登录信息,若应用关闭,请在其他已登录设备上生成新的登录码以重新连接", + "couldNotReachRetry": "无法连接服务器,请重试", + "signOutDeviceHelp": "此设备需要新的登录码才能重新连接,其他设备会保持登录", + "devicesHelp": "让手机、平板和电脑连接到同一个小家,这里只会显示你自己的设备", + "thisDevice": "当前设备", + "addedDate": "添加于 {date}", + "@addedDate": {"placeholders": {"date": {"type": "String"}}}, + "addDeviceAction": "添加设备", + "newDeviceCodeHelp": "在新设备上选择“登录另一台设备”,使用上方服务器地址和此登录码,请妥善保管,它可以访问你的账户", + "singleUseExpires": "单次使用 · 有效期至 {date}", + "@singleUseExpires": {"placeholders": {"date": {"type": "String"}}}, + "chatError": "聊天暂时无法加载,请检查网络后重试", + "firstWords": "你们的第一句话\n发送一声问候,开启两个人的聊天日记", + "sending": "正在发送…", + "notSent": "发送失败", + "loading": "正在加载…", + "loadEarlier": "加载更早的消息", + "read": "已读", + "unread": "未读", + "messageTooLong": "消息最多可输入 4000 个字符", + "tryAgain": "重试", + "savedAccessError": "无法读取或验证此设备上保存的登录信息", + "serverAccessError": "无法连接到 Pawmate 服务器,请检查网络后重试", + "sessionRestoreError": "无法恢复此登录状态", + "requestFailed": "服务器暂时无法完成请求,请重试", + "invalidServerAddress": "请输入有效的 HTTP 或 HTTPS 服务器地址", + "invitationLinkHint": "pawmate://pair?server=…&code=…", + "myDeviceName": "我的设备" +} diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart new file mode 100644 index 0000000..516061e --- /dev/null +++ b/lib/l10n/generated/app_localizations.dart @@ -0,0 +1,986 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'generated/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('zh'), + ]; + + /// No description provided for @chooseLanguage. + /// + /// In en, this message translates to: + /// **'Language'** + String get chooseLanguage; + + /// No description provided for @english. + /// + /// In en, this message translates to: + /// **'English'** + String get english; + + /// No description provided for @chinese. + /// + /// In en, this message translates to: + /// **'简体中文'** + String get chinese; + + /// No description provided for @homeHeadline. + /// + /// In en, this message translates to: + /// **'Your little home, together'** + String get homeHeadline; + + /// No description provided for @homeIntro. + /// + /// In en, this message translates to: + /// **'Choose how you’d like to come in.'** + String get homeIntro; + + /// No description provided for @createInvitation. + /// + /// In en, this message translates to: + /// **'Create invitation'** + String get createInvitation; + + /// No description provided for @createInvitationDescription. + /// + /// In en, this message translates to: + /// **'Start a home on your private server.'** + String get createInvitationDescription; + + /// No description provided for @acceptInvitation. + /// + /// In en, this message translates to: + /// **'Accept invitation'** + String get acceptInvitation; + + /// No description provided for @acceptInvitationDescription. + /// + /// In en, this message translates to: + /// **'Join your partner using their link.'** + String get acceptInvitationDescription; + + /// No description provided for @restoreData. + /// + /// In en, this message translates to: + /// **'Restore data'** + String get restoreData; + + /// No description provided for @restoreDataDescription. + /// + /// In en, this message translates to: + /// **'Lost access? Use your recovery code.'** + String get restoreDataDescription; + + /// No description provided for @addDevice. + /// + /// In en, this message translates to: + /// **'Add device'** + String get addDevice; + + /// No description provided for @addDeviceDescription. + /// + /// In en, this message translates to: + /// **'Keep your other devices signed in.'** + String get addDeviceDescription; + + /// No description provided for @alreadyPaired. + /// + /// In en, this message translates to: + /// **'Already paired? Add a device instead of creating a new invitation. Your home’s data stays on your server.'** + String get alreadyPaired; + + /// No description provided for @invitePartner. + /// + /// In en, this message translates to: + /// **'Invite your partner'** + String get invitePartner; + + /// No description provided for @chooseHomeAddress. + /// + /// In en, this message translates to: + /// **'Choose your home address'** + String get chooseHomeAddress; + + /// No description provided for @trustedServerHelp. + /// + /// In en, this message translates to: + /// **'Use the URL or domain of the private server you trust.'** + String get trustedServerHelp; + + /// No description provided for @serverUrlDomain. + /// + /// In en, this message translates to: + /// **'Server URL or domain'** + String get serverUrlDomain; + + /// No description provided for @serverUrl. + /// + /// In en, this message translates to: + /// **'Server URL'** + String get serverUrl; + + /// No description provided for @serverUrlExisting. + /// + /// In en, this message translates to: + /// **'Existing server URL'** + String get serverUrlExisting; + + /// No description provided for @serverUrlHint. + /// + /// In en, this message translates to: + /// **'https://pawmate.example.com'** + String get serverUrlHint; + + /// No description provided for @enterServerUrl. + /// + /// In en, this message translates to: + /// **'Enter your server URL.'** + String get enterServerUrl; + + /// No description provided for @makingRoom. + /// + /// In en, this message translates to: + /// **'Making a little room…'** + String get makingRoom; + + /// No description provided for @createInvitationAction. + /// + /// In en, this message translates to: + /// **'Create invitation'** + String get createInvitationAction; + + /// No description provided for @invitationCreatedStorageWarning. + /// + /// In en, this message translates to: + /// **'Invitation created. Save the recovery code below; this device could not store it securely.'** + String get invitationCreatedStorageWarning; + + /// No description provided for @serverUnreachable. + /// + /// In en, this message translates to: + /// **'Could not reach the Pawmate server.'** + String get serverUnreachable; + + /// No description provided for @serverUnreachableShort. + /// + /// In en, this message translates to: + /// **'Could not reach the server.'** + String get serverUnreachableShort; + + /// No description provided for @invitationCopied. + /// + /// In en, this message translates to: + /// **'Invitation link copied'** + String get invitationCopied; + + /// No description provided for @invitationReady. + /// + /// In en, this message translates to: + /// **'Your invitation is ready'** + String get invitationReady; + + /// No description provided for @homeConnected. + /// + /// In en, this message translates to: + /// **'Your home is connected!'** + String get homeConnected; + + /// No description provided for @shareInvitation. + /// + /// In en, this message translates to: + /// **'Share this one-time link with your partner.'** + String get shareInvitation; + + /// No description provided for @connectedMemories. + /// + /// In en, this message translates to: + /// **'You and your partner can start making memories together.'** + String get connectedMemories; + + /// No description provided for @copyInvitation. + /// + /// In en, this message translates to: + /// **'Copy invitation'** + String get copyInvitation; + + /// No description provided for @expiresAt. + /// + /// In en, this message translates to: + /// **'Expires {date}'** + String expiresAt(String date); + + /// No description provided for @pairingComplete. + /// + /// In en, this message translates to: + /// **'Pairing complete'** + String get pairingComplete; + + /// No description provided for @checkPairingStatus. + /// + /// In en, this message translates to: + /// **'Check pairing status'** + String get checkPairingStatus; + + /// No description provided for @waitingForPartner. + /// + /// In en, this message translates to: + /// **'Waiting for your partner to accept…'** + String get waitingForPartner; + + /// No description provided for @recoveryCodeTitle. + /// + /// In en, this message translates to: + /// **'Your recovery code'** + String get recoveryCodeTitle; + + /// No description provided for @recoveryCodeHelp. + /// + /// In en, this message translates to: + /// **'Keep this code somewhere safe. It can restore your home after losing a device.'** + String get recoveryCodeHelp; + + /// No description provided for @copyCode. + /// + /// In en, this message translates to: + /// **'Copy code'** + String get copyCode; + + /// No description provided for @recoveryCodeCopied. + /// + /// In en, this message translates to: + /// **'Recovery code copied'** + String get recoveryCodeCopied; + + /// No description provided for @anInvitationForTwo. + /// + /// In en, this message translates to: + /// **'An invitation for two'** + String get anInvitationForTwo; + + /// No description provided for @invitationPasteHelp. + /// + /// In en, this message translates to: + /// **'Paste the complete link your partner sent. You’ll review the server address before joining.'** + String get invitationPasteHelp; + + /// No description provided for @invitationLink. + /// + /// In en, this message translates to: + /// **'Invitation link'** + String get invitationLink; + + /// No description provided for @pasteInvitationLinkFirst. + /// + /// In en, this message translates to: + /// **'Copy your partner’s invitation link first.'** + String get pasteInvitationLinkFirst; + + /// No description provided for @clipboardUnavailable. + /// + /// In en, this message translates to: + /// **'Clipboard unavailable. Paste or type the link into the field.'** + String get clipboardUnavailable; + + /// No description provided for @pasteLink. + /// + /// In en, this message translates to: + /// **'Paste link'** + String get pasteLink; + + /// No description provided for @reviewInvitation. + /// + /// In en, this message translates to: + /// **'Review invitation'** + String get reviewInvitation; + + /// No description provided for @invalidInvitationLink. + /// + /// In en, this message translates to: + /// **'This invitation link is not valid.'** + String get invalidInvitationLink; + + /// No description provided for @incompleteInvitation. + /// + /// In en, this message translates to: + /// **'This invitation link is incomplete.'** + String get incompleteInvitation; + + /// No description provided for @joinHome. + /// + /// In en, this message translates to: + /// **'Join your little home'** + String get joinHome; + + /// No description provided for @invited. + /// + /// In en, this message translates to: + /// **'You have been invited'** + String get invited; + + /// No description provided for @acceptOneTimeHelp. + /// + /// In en, this message translates to: + /// **'Accepting this one-time invitation will pair your device with your partner.'** + String get acceptOneTimeHelp; + + /// No description provided for @privateServer. + /// + /// In en, this message translates to: + /// **'Private server'** + String get privateServer; + + /// No description provided for @joining. + /// + /// In en, this message translates to: + /// **'Joining…'** + String get joining; + + /// No description provided for @couldNotAccept. + /// + /// In en, this message translates to: + /// **'Could not accept this invitation.'** + String get couldNotAccept; + + /// No description provided for @incompleteCredentials. + /// + /// In en, this message translates to: + /// **'The server did not return complete pairing credentials.'** + String get incompleteCredentials; + + /// No description provided for @restoreHome. + /// + /// In en, this message translates to: + /// **'Restore your home'** + String get restoreHome; + + /// No description provided for @reconnectTitle. + /// + /// In en, this message translates to: + /// **'Let’s reconnect you'** + String get reconnectTitle; + + /// No description provided for @reconnectHelp. + /// + /// In en, this message translates to: + /// **'This device needs a new login. If another device is still signed in, use its device login code. Recovery is for lost access and signs out all your other devices.'** + String get reconnectHelp; + + /// No description provided for @deviceCodeSignIn. + /// + /// In en, this message translates to: + /// **'Sign in with a device login code'** + String get deviceCodeSignIn; + + /// No description provided for @restoreExistingHome. + /// + /// In en, this message translates to: + /// **'Restoring an existing home?'** + String get restoreExistingHome; + + /// No description provided for @recoveryHelp. + /// + /// In en, this message translates to: + /// **'Use your recovery code if you have lost access. Recovery signs out all your devices and replaces the recovery code; your partner stays signed in. To add a device, use a device login code instead.'** + String get recoveryHelp; + + /// No description provided for @recoveryCode. + /// + /// In en, this message translates to: + /// **'Recovery code'** + String get recoveryCode; + + /// No description provided for @restoringAccess. + /// + /// In en, this message translates to: + /// **'Restoring access…'** + String get restoringAccess; + + /// No description provided for @restoreAccess. + /// + /// In en, this message translates to: + /// **'Restore access'** + String get restoreAccess; + + /// No description provided for @invalidRecoveryCode. + /// + /// In en, this message translates to: + /// **'That recovery code is not valid anymore.'** + String get invalidRecoveryCode; + + /// No description provided for @notPaired. + /// + /// In en, this message translates to: + /// **'This server does not have a completed couple pairing.'** + String get notPaired; + + /// No description provided for @couldNotRestore. + /// + /// In en, this message translates to: + /// **'Could not restore this home. Try again.'** + String get couldNotRestore; + + /// No description provided for @bringHome. + /// + /// In en, this message translates to: + /// **'Bring your home along'** + String get bringHome; + + /// No description provided for @signInAnotherDevice. + /// + /// In en, this message translates to: + /// **'Sign in on another device'** + String get signInAnotherDevice; + + /// No description provided for @deviceSignInHelp. + /// + /// In en, this message translates to: + /// **'On your signed-in phone, tablet or computer, open My devices and choose Add a device. Enter its server address and login code here. Your other devices will stay signed in.'** + String get deviceSignInHelp; + + /// No description provided for @deviceLoginCode. + /// + /// In en, this message translates to: + /// **'Device login code'** + String get deviceLoginCode; + + /// No description provided for @deviceName. + /// + /// In en, this message translates to: + /// **'Device name'** + String get deviceName; + + /// No description provided for @enterServerAddress. + /// + /// In en, this message translates to: + /// **'Enter your server address.'** + String get enterServerAddress; + + /// No description provided for @enterLoginCode. + /// + /// In en, this message translates to: + /// **'Enter the login code.'** + String get enterLoginCode; + + /// No description provided for @deviceNameLength. + /// + /// In en, this message translates to: + /// **'Use a name between 1 and 80 characters.'** + String get deviceNameLength; + + /// No description provided for @signingIn. + /// + /// In en, this message translates to: + /// **'Signing in…'** + String get signingIn; + + /// No description provided for @signIn. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get signIn; + + /// No description provided for @back. + /// + /// In en, this message translates to: + /// **'Back'** + String get back; + + /// No description provided for @invalidDeviceCode. + /// + /// In en, this message translates to: + /// **'This login code expired or was already used. Generate a new one on your signed-in device.'** + String get invalidDeviceCode; + + /// No description provided for @couldNotSignIn. + /// + /// In en, this message translates to: + /// **'Could not sign in. Check your connection and try again.'** + String get couldNotSignIn; + + /// No description provided for @openingHome. + /// + /// In en, this message translates to: + /// **'Opening your home'** + String get openingHome; + + /// No description provided for @checkingSavedAccess. + /// + /// In en, this message translates to: + /// **'Checking your saved access…'** + String get checkingSavedAccess; + + /// No description provided for @findingHome. + /// + /// In en, this message translates to: + /// **'Finding your home'** + String get findingHome; + + /// No description provided for @chooseSignInMethod. + /// + /// In en, this message translates to: + /// **'Choose a sign-in method'** + String get chooseSignInMethod; + + /// No description provided for @ourConversation. + /// + /// In en, this message translates to: + /// **'Our conversation'** + String get ourConversation; + + /// No description provided for @playCorner. + /// + /// In en, this message translates to: + /// **'Play corner'** + String get playCorner; + + /// No description provided for @ourLittleHome. + /// + /// In en, this message translates to: + /// **'Our little home'** + String get ourLittleHome; + + /// No description provided for @chatUnread. + /// + /// In en, this message translates to: + /// **'Chat, {count} unread messages'** + String chatUnread(int count); + + /// No description provided for @viewHome. + /// + /// In en, this message translates to: + /// **'View Home'** + String get viewHome; + + /// No description provided for @retry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// No description provided for @unreadGoLatest. + /// + /// In en, this message translates to: + /// **'{count} unread · Go to latest'** + String unreadGoLatest(int count); + + /// No description provided for @messagePartner. + /// + /// In en, this message translates to: + /// **'Message your partner'** + String get messagePartner; + + /// No description provided for @sendMessage. + /// + /// In en, this message translates to: + /// **'Send message'** + String get sendMessage; + + /// No description provided for @retryMessage. + /// + /// In en, this message translates to: + /// **'Retry message'** + String get retryMessage; + + /// No description provided for @myDevices. + /// + /// In en, this message translates to: + /// **'My devices'** + String get myDevices; + + /// No description provided for @signOutDeviceTitle. + /// + /// In en, this message translates to: + /// **'Sign out {name}?'** + String signOutDeviceTitle(String name); + + /// No description provided for @cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// No description provided for @signOutDevice. + /// + /// In en, this message translates to: + /// **'Sign out device'** + String get signOutDevice; + + /// No description provided for @refreshDevices. + /// + /// In en, this message translates to: + /// **'Refresh devices'** + String get refreshDevices; + + /// No description provided for @deviceCodeCopied. + /// + /// In en, this message translates to: + /// **'Device login code copied'** + String get deviceCodeCopied; + + /// No description provided for @copyLoginCode. + /// + /// In en, this message translates to: + /// **'Copy login code'** + String get copyLoginCode; + + /// No description provided for @changeLanguage. + /// + /// In en, this message translates to: + /// **'Change language'** + String get changeLanguage; + + /// No description provided for @chat. + /// + /// In en, this message translates to: + /// **'Chat'** + String get chat; + + /// No description provided for @play. + /// + /// In en, this message translates to: + /// **'Play'** + String get play; + + /// No description provided for @home. + /// + /// In en, this message translates to: + /// **'Home'** + String get home; + + /// No description provided for @unreadFromPartner. + /// + /// In en, this message translates to: + /// **'{count} unread messages from your partner'** + String unreadFromPartner(int count); + + /// No description provided for @openChat. + /// + /// In en, this message translates to: + /// **'Open chat'** + String get openChat; + + /// No description provided for @loginNotSaved. + /// + /// In en, this message translates to: + /// **'This device could not save its login. Open Home for reconnect instructions.'** + String get loginNotSaved; + + /// No description provided for @saveRecoveryCodeHome. + /// + /// In en, this message translates to: + /// **'Save your recovery code in Home before leaving.'** + String get saveRecoveryCodeHome; + + /// No description provided for @gamesComing. + /// + /// In en, this message translates to: + /// **'A little corner for playing together.\nGames will arrive here later.'** + String get gamesComing; + + /// No description provided for @youAreHome. + /// + /// In en, this message translates to: + /// **'You are home together'** + String get youAreHome; + + /// No description provided for @connectedAsRole. + /// + /// In en, this message translates to: + /// **'This device is connected as the {role}.'** + String connectedAsRole(String role); + + /// No description provided for @homeId. + /// + /// In en, this message translates to: + /// **'Home ID: {id}'** + String homeId(String id); + + /// No description provided for @storageWarningRecovery. + /// + /// In en, this message translates to: + /// **'This device could not securely save its login. Keep the recovery code below in case the app closes.'** + String get storageWarningRecovery; + + /// No description provided for @storageWarningDevice. + /// + /// In en, this message translates to: + /// **'This device could not securely save its login. If the app closes, generate a new login code on another signed-in device to reconnect.'** + String get storageWarningDevice; + + /// No description provided for @couldNotReachRetry. + /// + /// In en, this message translates to: + /// **'Could not reach your server. Try again.'** + String get couldNotReachRetry; + + /// No description provided for @signOutDeviceHelp. + /// + /// In en, this message translates to: + /// **'This device will need a new login code to reconnect. Your other devices will stay signed in.'** + String get signOutDeviceHelp; + + /// No description provided for @devicesHelp. + /// + /// In en, this message translates to: + /// **'Keep your phone, tablet and computer connected to the same home. Only your own devices appear here.'** + String get devicesHelp; + + /// No description provided for @thisDevice. + /// + /// In en, this message translates to: + /// **'This device'** + String get thisDevice; + + /// No description provided for @addedDate. + /// + /// In en, this message translates to: + /// **'Added {date}'** + String addedDate(String date); + + /// No description provided for @addDeviceAction. + /// + /// In en, this message translates to: + /// **'Add a device'** + String get addDeviceAction; + + /// No description provided for @newDeviceCodeHelp. + /// + /// In en, this message translates to: + /// **'On your new device, choose Sign in on another device. Use the server address above and this code. Keep it private: it grants access as you.'** + String get newDeviceCodeHelp; + + /// No description provided for @singleUseExpires. + /// + /// In en, this message translates to: + /// **'Single use · Expires {date}'** + String singleUseExpires(String date); + + /// No description provided for @chatError. + /// + /// In en, this message translates to: + /// **'Chat could not be loaded. Check your connection and try again.'** + String get chatError; + + /// No description provided for @firstWords. + /// + /// In en, this message translates to: + /// **'Your first words together\nSend a little hello to start your shared journal.'** + String get firstWords; + + /// No description provided for @sending. + /// + /// In en, this message translates to: + /// **'Sending…'** + String get sending; + + /// No description provided for @notSent. + /// + /// In en, this message translates to: + /// **'Not sent'** + String get notSent; + + /// No description provided for @loading. + /// + /// In en, this message translates to: + /// **'Loading…'** + String get loading; + + /// No description provided for @loadEarlier. + /// + /// In en, this message translates to: + /// **'Load earlier messages'** + String get loadEarlier; + + /// No description provided for @read. + /// + /// In en, this message translates to: + /// **'Read'** + String get read; + + /// No description provided for @unread. + /// + /// In en, this message translates to: + /// **'Unread'** + String get unread; + + /// No description provided for @messageTooLong. + /// + /// In en, this message translates to: + /// **'Use up to 4000 characters.'** + String get messageTooLong; + + /// No description provided for @tryAgain. + /// + /// In en, this message translates to: + /// **'Try again'** + String get tryAgain; + + /// No description provided for @savedAccessError. + /// + /// In en, this message translates to: + /// **'Could not read or validate saved access on this device.'** + String get savedAccessError; + + /// No description provided for @serverAccessError. + /// + /// In en, this message translates to: + /// **'Could not reach your Pawmate server. Check your connection and retry.'** + String get serverAccessError; + + /// No description provided for @sessionRestoreError. + /// + /// In en, this message translates to: + /// **'Could not restore this session.'** + String get sessionRestoreError; + + /// No description provided for @requestFailed. + /// + /// In en, this message translates to: + /// **'The server could not complete this request. Try again.'** + String get requestFailed; + + /// No description provided for @invalidServerAddress. + /// + /// In en, this message translates to: + /// **'Enter a valid HTTP or HTTPS server address.'** + String get invalidServerAddress; + + /// No description provided for @invitationLinkHint. + /// + /// In en, this message translates to: + /// **'pawmate://pair?server=…&code=…'** + String get invitationLinkHint; + + /// No description provided for @myDeviceName. + /// + /// In en, this message translates to: + /// **'My device'** + String get myDeviceName; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart new file mode 100644 index 0000000..74c70b5 --- /dev/null +++ b/lib/l10n/generated/app_localizations_en.dart @@ -0,0 +1,486 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get chooseLanguage => 'Language'; + + @override + String get english => 'English'; + + @override + String get chinese => '简体中文'; + + @override + String get homeHeadline => 'Your little home, together'; + + @override + String get homeIntro => 'Choose how you’d like to come in.'; + + @override + String get createInvitation => 'Create invitation'; + + @override + String get createInvitationDescription => + 'Start a home on your private server.'; + + @override + String get acceptInvitation => 'Accept invitation'; + + @override + String get acceptInvitationDescription => + 'Join your partner using their link.'; + + @override + String get restoreData => 'Restore data'; + + @override + String get restoreDataDescription => 'Lost access? Use your recovery code.'; + + @override + String get addDevice => 'Add device'; + + @override + String get addDeviceDescription => 'Keep your other devices signed in.'; + + @override + String get alreadyPaired => + 'Already paired? Add a device instead of creating a new invitation. Your home’s data stays on your server.'; + + @override + String get invitePartner => 'Invite your partner'; + + @override + String get chooseHomeAddress => 'Choose your home address'; + + @override + String get trustedServerHelp => + 'Use the URL or domain of the private server you trust.'; + + @override + String get serverUrlDomain => 'Server URL or domain'; + + @override + String get serverUrl => 'Server URL'; + + @override + String get serverUrlExisting => 'Existing server URL'; + + @override + String get serverUrlHint => 'https://pawmate.example.com'; + + @override + String get enterServerUrl => 'Enter your server URL.'; + + @override + String get makingRoom => 'Making a little room…'; + + @override + String get createInvitationAction => 'Create invitation'; + + @override + String get invitationCreatedStorageWarning => + 'Invitation created. Save the recovery code below; this device could not store it securely.'; + + @override + String get serverUnreachable => 'Could not reach the Pawmate server.'; + + @override + String get serverUnreachableShort => 'Could not reach the server.'; + + @override + String get invitationCopied => 'Invitation link copied'; + + @override + String get invitationReady => 'Your invitation is ready'; + + @override + String get homeConnected => 'Your home is connected!'; + + @override + String get shareInvitation => 'Share this one-time link with your partner.'; + + @override + String get connectedMemories => + 'You and your partner can start making memories together.'; + + @override + String get copyInvitation => 'Copy invitation'; + + @override + String expiresAt(String date) { + return 'Expires $date'; + } + + @override + String get pairingComplete => 'Pairing complete'; + + @override + String get checkPairingStatus => 'Check pairing status'; + + @override + String get waitingForPartner => 'Waiting for your partner to accept…'; + + @override + String get recoveryCodeTitle => 'Your recovery code'; + + @override + String get recoveryCodeHelp => + 'Keep this code somewhere safe. It can restore your home after losing a device.'; + + @override + String get copyCode => 'Copy code'; + + @override + String get recoveryCodeCopied => 'Recovery code copied'; + + @override + String get anInvitationForTwo => 'An invitation for two'; + + @override + String get invitationPasteHelp => + 'Paste the complete link your partner sent. You’ll review the server address before joining.'; + + @override + String get invitationLink => 'Invitation link'; + + @override + String get pasteInvitationLinkFirst => + 'Copy your partner’s invitation link first.'; + + @override + String get clipboardUnavailable => + 'Clipboard unavailable. Paste or type the link into the field.'; + + @override + String get pasteLink => 'Paste link'; + + @override + String get reviewInvitation => 'Review invitation'; + + @override + String get invalidInvitationLink => 'This invitation link is not valid.'; + + @override + String get incompleteInvitation => 'This invitation link is incomplete.'; + + @override + String get joinHome => 'Join your little home'; + + @override + String get invited => 'You have been invited'; + + @override + String get acceptOneTimeHelp => + 'Accepting this one-time invitation will pair your device with your partner.'; + + @override + String get privateServer => 'Private server'; + + @override + String get joining => 'Joining…'; + + @override + String get couldNotAccept => 'Could not accept this invitation.'; + + @override + String get incompleteCredentials => + 'The server did not return complete pairing credentials.'; + + @override + String get restoreHome => 'Restore your home'; + + @override + String get reconnectTitle => 'Let’s reconnect you'; + + @override + String get reconnectHelp => + 'This device needs a new login. If another device is still signed in, use its device login code. Recovery is for lost access and signs out all your other devices.'; + + @override + String get deviceCodeSignIn => 'Sign in with a device login code'; + + @override + String get restoreExistingHome => 'Restoring an existing home?'; + + @override + String get recoveryHelp => + 'Use your recovery code if you have lost access. Recovery signs out all your devices and replaces the recovery code; your partner stays signed in. To add a device, use a device login code instead.'; + + @override + String get recoveryCode => 'Recovery code'; + + @override + String get restoringAccess => 'Restoring access…'; + + @override + String get restoreAccess => 'Restore access'; + + @override + String get invalidRecoveryCode => 'That recovery code is not valid anymore.'; + + @override + String get notPaired => + 'This server does not have a completed couple pairing.'; + + @override + String get couldNotRestore => 'Could not restore this home. Try again.'; + + @override + String get bringHome => 'Bring your home along'; + + @override + String get signInAnotherDevice => 'Sign in on another device'; + + @override + String get deviceSignInHelp => + 'On your signed-in phone, tablet or computer, open My devices and choose Add a device. Enter its server address and login code here. Your other devices will stay signed in.'; + + @override + String get deviceLoginCode => 'Device login code'; + + @override + String get deviceName => 'Device name'; + + @override + String get enterServerAddress => 'Enter your server address.'; + + @override + String get enterLoginCode => 'Enter the login code.'; + + @override + String get deviceNameLength => 'Use a name between 1 and 80 characters.'; + + @override + String get signingIn => 'Signing in…'; + + @override + String get signIn => 'Sign in'; + + @override + String get back => 'Back'; + + @override + String get invalidDeviceCode => + 'This login code expired or was already used. Generate a new one on your signed-in device.'; + + @override + String get couldNotSignIn => + 'Could not sign in. Check your connection and try again.'; + + @override + String get openingHome => 'Opening your home'; + + @override + String get checkingSavedAccess => 'Checking your saved access…'; + + @override + String get findingHome => 'Finding your home'; + + @override + String get chooseSignInMethod => 'Choose a sign-in method'; + + @override + String get ourConversation => 'Our conversation'; + + @override + String get playCorner => 'Play corner'; + + @override + String get ourLittleHome => 'Our little home'; + + @override + String chatUnread(int count) { + return 'Chat, $count unread messages'; + } + + @override + String get viewHome => 'View Home'; + + @override + String get retry => 'Retry'; + + @override + String unreadGoLatest(int count) { + return '$count unread · Go to latest'; + } + + @override + String get messagePartner => 'Message your partner'; + + @override + String get sendMessage => 'Send message'; + + @override + String get retryMessage => 'Retry message'; + + @override + String get myDevices => 'My devices'; + + @override + String signOutDeviceTitle(String name) { + return 'Sign out $name?'; + } + + @override + String get cancel => 'Cancel'; + + @override + String get signOutDevice => 'Sign out device'; + + @override + String get refreshDevices => 'Refresh devices'; + + @override + String get deviceCodeCopied => 'Device login code copied'; + + @override + String get copyLoginCode => 'Copy login code'; + + @override + String get changeLanguage => 'Change language'; + + @override + String get chat => 'Chat'; + + @override + String get play => 'Play'; + + @override + String get home => 'Home'; + + @override + String unreadFromPartner(int count) { + return '$count unread messages from your partner'; + } + + @override + String get openChat => 'Open chat'; + + @override + String get loginNotSaved => + 'This device could not save its login. Open Home for reconnect instructions.'; + + @override + String get saveRecoveryCodeHome => + 'Save your recovery code in Home before leaving.'; + + @override + String get gamesComing => + 'A little corner for playing together.\nGames will arrive here later.'; + + @override + String get youAreHome => 'You are home together'; + + @override + String connectedAsRole(String role) { + return 'This device is connected as the $role.'; + } + + @override + String homeId(String id) { + return 'Home ID: $id'; + } + + @override + String get storageWarningRecovery => + 'This device could not securely save its login. Keep the recovery code below in case the app closes.'; + + @override + String get storageWarningDevice => + 'This device could not securely save its login. If the app closes, generate a new login code on another signed-in device to reconnect.'; + + @override + String get couldNotReachRetry => 'Could not reach your server. Try again.'; + + @override + String get signOutDeviceHelp => + 'This device will need a new login code to reconnect. Your other devices will stay signed in.'; + + @override + String get devicesHelp => + 'Keep your phone, tablet and computer connected to the same home. Only your own devices appear here.'; + + @override + String get thisDevice => 'This device'; + + @override + String addedDate(String date) { + return 'Added $date'; + } + + @override + String get addDeviceAction => 'Add a device'; + + @override + String get newDeviceCodeHelp => + 'On your new device, choose Sign in on another device. Use the server address above and this code. Keep it private: it grants access as you.'; + + @override + String singleUseExpires(String date) { + return 'Single use · Expires $date'; + } + + @override + String get chatError => + 'Chat could not be loaded. Check your connection and try again.'; + + @override + String get firstWords => + 'Your first words together\nSend a little hello to start your shared journal.'; + + @override + String get sending => 'Sending…'; + + @override + String get notSent => 'Not sent'; + + @override + String get loading => 'Loading…'; + + @override + String get loadEarlier => 'Load earlier messages'; + + @override + String get read => 'Read'; + + @override + String get unread => 'Unread'; + + @override + String get messageTooLong => 'Use up to 4000 characters.'; + + @override + String get tryAgain => 'Try again'; + + @override + String get savedAccessError => + 'Could not read or validate saved access on this device.'; + + @override + String get serverAccessError => + 'Could not reach your Pawmate server. Check your connection and retry.'; + + @override + String get sessionRestoreError => 'Could not restore this session.'; + + @override + String get requestFailed => + 'The server could not complete this request. Try again.'; + + @override + String get invalidServerAddress => + 'Enter a valid HTTP or HTTPS server address.'; + + @override + String get invitationLinkHint => 'pawmate://pair?server=…&code=…'; + + @override + String get myDeviceName => 'My device'; +} diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart new file mode 100644 index 0000000..c6339a8 --- /dev/null +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -0,0 +1,459 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get chooseLanguage => '选择语言'; + + @override + String get english => 'English'; + + @override + String get chinese => '简体中文'; + + @override + String get homeHeadline => '两个人的小家'; + + @override + String get homeIntro => '选择一种方式进入'; + + @override + String get createInvitation => '创建邀请'; + + @override + String get createInvitationDescription => '在你的私人服务器上创建小家'; + + @override + String get acceptInvitation => '接受邀请'; + + @override + String get acceptInvitationDescription => '通过伴侣发送的链接加入'; + + @override + String get restoreData => '恢复数据'; + + @override + String get restoreDataDescription => '无法登录时使用恢复码'; + + @override + String get addDevice => '添加设备'; + + @override + String get addDeviceDescription => '让其他设备也保持登录'; + + @override + String get alreadyPaired => '如果已经配对,可直接添加设备,无需重新创建邀请,小家的数据仍保存在你的服务器上'; + + @override + String get invitePartner => '邀请伴侣'; + + @override + String get chooseHomeAddress => '设置小家的地址'; + + @override + String get trustedServerHelp => '填写你信任的私人服务器网址或域名'; + + @override + String get serverUrlDomain => '服务器网址或域名'; + + @override + String get serverUrl => '服务器网址'; + + @override + String get serverUrlExisting => '已有服务器网址'; + + @override + String get serverUrlHint => 'https://pawmate.example.com'; + + @override + String get enterServerUrl => '请输入服务器网址'; + + @override + String get makingRoom => '正在准备小家…'; + + @override + String get createInvitationAction => '创建邀请'; + + @override + String get invitationCreatedStorageWarning => '邀请已创建,请保存下方恢复码,此设备未能安全保存它'; + + @override + String get serverUnreachable => '无法连接到 Pawmate 服务器'; + + @override + String get serverUnreachableShort => '无法连接到服务器'; + + @override + String get invitationCopied => '邀请链接已复制'; + + @override + String get invitationReady => '邀请已准备好'; + + @override + String get homeConnected => '小家已连接'; + + @override + String get shareInvitation => '把这个一次性链接分享给伴侣'; + + @override + String get connectedMemories => '现在可以和伴侣一起留下回忆了'; + + @override + String get copyInvitation => '复制邀请'; + + @override + String expiresAt(String date) { + return '有效期至 $date'; + } + + @override + String get pairingComplete => '配对完成'; + + @override + String get checkPairingStatus => '检查配对状态'; + + @override + String get waitingForPartner => '等待伴侣接受邀请…'; + + @override + String get recoveryCodeTitle => '你的小家恢复码'; + + @override + String get recoveryCodeHelp => '请妥善保存此恢复码,设备丢失后可用它恢复小家'; + + @override + String get copyCode => '复制代码'; + + @override + String get recoveryCodeCopied => '恢复码已复制'; + + @override + String get anInvitationForTwo => '一份双人邀请'; + + @override + String get invitationPasteHelp => '粘贴伴侣发来的完整链接,加入前可以先核对服务器地址'; + + @override + String get invitationLink => '邀请链接'; + + @override + String get pasteInvitationLinkFirst => '请先复制伴侣发来的邀请链接'; + + @override + String get clipboardUnavailable => '剪贴板不可用,请在下方粘贴或输入链接'; + + @override + String get pasteLink => '粘贴链接'; + + @override + String get reviewInvitation => '查看邀请'; + + @override + String get invalidInvitationLink => '邀请链接无效'; + + @override + String get incompleteInvitation => '邀请链接不完整'; + + @override + String get joinHome => '加入小家'; + + @override + String get invited => '你收到了一份邀请'; + + @override + String get acceptOneTimeHelp => '接受这份一次性邀请后,你的设备将与伴侣完成配对'; + + @override + String get privateServer => '私人服务器'; + + @override + String get joining => '正在加入…'; + + @override + String get couldNotAccept => '无法接受这份邀请'; + + @override + String get incompleteCredentials => '服务器返回的配对凭证不完整'; + + @override + String get restoreHome => '恢复小家'; + + @override + String get reconnectTitle => '重新连接小家'; + + @override + String get reconnectHelp => + '此设备需要重新登录,如果其他设备仍处于登录状态,请使用设备登录码,恢复码适用于无法访问其他设备的情况,并会让其他设备退出登录'; + + @override + String get deviceCodeSignIn => '使用设备登录码登录'; + + @override + String get restoreExistingHome => '需要恢复已有的小家吗'; + + @override + String get recoveryHelp => + '无法登录时使用恢复码,恢复后所有设备都会退出登录,恢复码也会更新,伴侣仍保持登录,添加设备请使用设备登录码'; + + @override + String get recoveryCode => '恢复码'; + + @override + String get restoringAccess => '正在恢复访问…'; + + @override + String get restoreAccess => '恢复访问'; + + @override + String get invalidRecoveryCode => '恢复码已失效'; + + @override + String get notPaired => '此服务器上还没有完成配对的小家'; + + @override + String get couldNotRestore => '无法恢复小家,请重试'; + + @override + String get bringHome => '在新设备上登录'; + + @override + String get signInAnotherDevice => '登录另一台设备'; + + @override + String get deviceSignInHelp => + '在已登录的手机、平板或电脑上打开“我的设备”并选择“添加设备”,然后在这里输入服务器地址和登录码,其他设备会保持登录'; + + @override + String get deviceLoginCode => '设备登录码'; + + @override + String get deviceName => '设备名称'; + + @override + String get enterServerAddress => '请输入服务器地址'; + + @override + String get enterLoginCode => '请输入登录码'; + + @override + String get deviceNameLength => '设备名称长度需为 1 到 80 个字符'; + + @override + String get signingIn => '正在登录…'; + + @override + String get signIn => '登录'; + + @override + String get back => '返回'; + + @override + String get invalidDeviceCode => '登录码已过期或已使用,请在已登录设备上重新生成'; + + @override + String get couldNotSignIn => '无法登录,请检查网络后重试'; + + @override + String get openingHome => '正在打开小家'; + + @override + String get checkingSavedAccess => '正在检查已保存的登录信息…'; + + @override + String get findingHome => '正在寻找小家'; + + @override + String get chooseSignInMethod => '选择登录方式'; + + @override + String get ourConversation => '我们的聊天'; + + @override + String get playCorner => '游戏角'; + + @override + String get ourLittleHome => '我们的小家'; + + @override + String chatUnread(int count) { + return '聊天,有 $count 条未读消息'; + } + + @override + String get viewHome => '查看小家'; + + @override + String get retry => '重试'; + + @override + String unreadGoLatest(int count) { + return '有 $count 条未读 · 查看最新消息'; + } + + @override + String get messagePartner => '给伴侣发消息'; + + @override + String get sendMessage => '发送消息'; + + @override + String get retryMessage => '重试发送'; + + @override + String get myDevices => '我的设备'; + + @override + String signOutDeviceTitle(String name) { + return '让 $name 退出登录'; + } + + @override + String get cancel => '取消'; + + @override + String get signOutDevice => '退出此设备'; + + @override + String get refreshDevices => '刷新设备'; + + @override + String get deviceCodeCopied => '设备登录码已复制'; + + @override + String get copyLoginCode => '复制登录码'; + + @override + String get changeLanguage => '切换语言'; + + @override + String get chat => '聊天'; + + @override + String get play => '游戏'; + + @override + String get home => '小家'; + + @override + String unreadFromPartner(int count) { + return '有 $count 条伴侣发来的未读消息'; + } + + @override + String get openChat => '打开聊天'; + + @override + String get loginNotSaved => '此设备未能保存登录信息,请打开小家查看重新连接说明'; + + @override + String get saveRecoveryCodeHome => '离开前请在小家中保存恢复码'; + + @override + String get gamesComing => '一起玩耍的小角落\n游戏功能即将到来'; + + @override + String get youAreHome => '你们已经回到小家'; + + @override + String connectedAsRole(String role) { + return '此设备以$role身份连接'; + } + + @override + String homeId(String id) { + return '小家编号:$id'; + } + + @override + String get storageWarningRecovery => '此设备未能安全保存登录信息,请保存下方恢复码,以免应用关闭后无法登录'; + + @override + String get storageWarningDevice => + '此设备未能安全保存登录信息,若应用关闭,请在其他已登录设备上生成新的登录码以重新连接'; + + @override + String get couldNotReachRetry => '无法连接服务器,请重试'; + + @override + String get signOutDeviceHelp => '此设备需要新的登录码才能重新连接,其他设备会保持登录'; + + @override + String get devicesHelp => '让手机、平板和电脑连接到同一个小家,这里只会显示你自己的设备'; + + @override + String get thisDevice => '当前设备'; + + @override + String addedDate(String date) { + return '添加于 $date'; + } + + @override + String get addDeviceAction => '添加设备'; + + @override + String get newDeviceCodeHelp => + '在新设备上选择“登录另一台设备”,使用上方服务器地址和此登录码,请妥善保管,它可以访问你的账户'; + + @override + String singleUseExpires(String date) { + return '单次使用 · 有效期至 $date'; + } + + @override + String get chatError => '聊天暂时无法加载,请检查网络后重试'; + + @override + String get firstWords => '你们的第一句话\n发送一声问候,开启两个人的聊天日记'; + + @override + String get sending => '正在发送…'; + + @override + String get notSent => '发送失败'; + + @override + String get loading => '正在加载…'; + + @override + String get loadEarlier => '加载更早的消息'; + + @override + String get read => '已读'; + + @override + String get unread => '未读'; + + @override + String get messageTooLong => '消息最多可输入 4000 个字符'; + + @override + String get tryAgain => '重试'; + + @override + String get savedAccessError => '无法读取或验证此设备上保存的登录信息'; + + @override + String get serverAccessError => '无法连接到 Pawmate 服务器,请检查网络后重试'; + + @override + String get sessionRestoreError => '无法恢复此登录状态'; + + @override + String get requestFailed => '服务器暂时无法完成请求,请重试'; + + @override + String get invalidServerAddress => '请输入有效的 HTTP 或 HTTPS 服务器地址'; + + @override + String get invitationLinkHint => 'pawmate://pair?server=…&code=…'; + + @override + String get myDeviceName => '我的设备'; +} diff --git a/lib/l10n/locale_controller.dart b/lib/l10n/locale_controller.dart new file mode 100644 index 0000000..937ee2b --- /dev/null +++ b/lib/l10n/locale_controller.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Restores and persists the user's interface language preference. +class LocaleController extends ChangeNotifier { + static const _key = 'pawmate.locale'; + final FlutterSecureStorage _storage = const FlutterSecureStorage(); + Locale _locale = const Locale('en'); + + Locale get locale => _locale; + + Future restore() async { + final value = await _storage.read(key: _key); + if (value == 'en' || value == 'zh') { + _locale = Locale(value!); + notifyListeners(); + } + } + + Future setLocale(Locale locale) async { + if (locale.languageCode != 'en' && locale.languageCode != 'zh') return; + _locale = Locale(locale.languageCode); + notifyListeners(); + await _storage.write(key: _key, value: _locale.languageCode); + } +} + +/// Makes the app-wide locale controller available to onboarding screens. +class LocaleControllerScope extends InheritedNotifier { + const LocaleControllerScope({ + required super.notifier, + required super.child, + super.key, + }); + + static LocaleController of(BuildContext context) => context + .dependOnInheritedWidgetOfExactType()! + .notifier!; +} diff --git a/lib/main.dart b/lib/main.dart index 3f2c1ba..6c48322 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,9 +2,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:app_links/app_links.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; -import 'design/pawmate_theme.dart'; +import 'design/theme/pawmate_theme.dart'; +import 'l10n/generated/app_localizations.dart'; +import 'l10n/locale_controller.dart'; import 'features/pairing/invitee_accept_page.dart'; +import 'features/pairing/invitation_link.dart'; import 'features/pairing/pairing_session_gate.dart'; void main() { @@ -21,6 +25,7 @@ class PawmateApp extends StatefulWidget { } class _PawmateAppState extends State { + final _localeController = LocaleController(); final _navigatorKey = GlobalKey(); final _appLinks = AppLinks(); String? _lastOpenedInvite; @@ -29,20 +34,29 @@ class _PawmateAppState extends State { @override void initState() { super.initState(); + _localeController.addListener(_localeChanged); + _localeController.restore(); _linkSubscription = _appLinks.uriLinkStream.listen(_openInviteLink); } + void _localeChanged() => setState(() {}); + @override void dispose() { _linkSubscription?.cancel(); + _localeController + ..removeListener(_localeChanged) + ..dispose(); super.dispose(); } void _openInviteLink(Uri uri) { - if (uri.scheme != 'pawmate' || uri.host != 'pair') return; - if (uri.queryParameters['server'] == null || - uri.queryParameters['code'] == null || - _lastOpenedInvite == uri.toString()) { + try { + InvitationLink.parse(uri.toString()); + } on FormatException { + return; + } + if (_lastOpenedInvite == uri.toString()) { return; } _lastOpenedInvite = uri.toString(); @@ -55,14 +69,25 @@ class _PawmateAppState extends State { }); } - /// Configures the app theme and starts the inviter onboarding flow. + /// Configures the app theme and restores a session or offers access methods. @override Widget build(BuildContext context) { return MaterialApp( navigatorKey: _navigatorKey, title: 'Pawmate', theme: buildPawmateTheme(), - home: const PairingSessionGate(), + locale: _localeController.locale, + supportedLocales: const [Locale('en'), Locale('zh')], + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + home: LocaleControllerScope( + notifier: _localeController, + child: const PairingSessionGate(), + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index 9d32b8f..4c28f1a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -150,6 +150,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_secure_storage: dependency: "direct main" description: @@ -256,6 +261,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.10.1" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" jni: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 70b4dee..e32975e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -55,6 +57,7 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in diff --git a/test/chat_test.dart b/test/chat_test.dart index d34b413..0a5af2f 100644 --- a/test/chat_test.dart +++ b/test/chat_test.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; -import 'package:pawmate/design/pawmate_theme.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; import 'package:pawmate/features/chat/chat_api.dart'; import 'package:pawmate/features/chat/chat_controller.dart'; import 'package:pawmate/features/chat/chat_page.dart'; diff --git a/test/login_methods_test.dart b/test/login_methods_test.dart new file mode 100644 index 0000000..4ff72b4 --- /dev/null +++ b/test/login_methods_test.dart @@ -0,0 +1,222 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:pawmate/design/components/handdrawn_action_card.dart'; +import 'package:pawmate/design/icons/access/access_doodle_icon.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; +import 'package:pawmate/features/pairing/invitation_link.dart'; +import 'package:pawmate/features/pairing/login_methods_page.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; + +const invitation = + 'pawmate://pair?server=http%3A%2F%2F100.64.0.1%3A8080&code=one-time-code'; + +/// Builds the landing screen directly without startup network requests. +Widget landing({double textScale = 1}) => MaterialApp( + theme: buildPawmateTheme(), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: const LoginMethodsPage(), +); + +void main() { + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + + test('pasted invitations accept HTTP and HTTPS with encoded server URLs', () { + expect( + InvitationLink.parse(' $invitation ').queryParameters['server'], + 'http://100.64.0.1:8080', + ); + expect( + InvitationLink.parse( + 'pawmate://pair?server=https%3A%2F%2Fhome.example.test&code=abc', + ).host, + 'pair', + ); + for (final value in [ + '', + 'https://example.test', + 'pawmate://pair?code=abc', + 'pawmate://pair?server=http%3A%2F%2Fhost&code=', + '$invitation&code=second-code', + '$invitation&server=http%3A%2F%2Fother', + '$invitation#fragment', + 'pawmate://user:secret@pair?server=http%3A%2F%2Fhost&code=abc', + 'pawmate://pair?server=ftp%3A%2F%2Fhost&code=abc', + 'pawmate://pair?server=https%3A%2F%2Fuser%3Asecret%40host&code=abc', + 'pawmate://pair/wrong?server=http%3A%2F%2Fhost&code=abc', + ]) { + expect( + () => InvitationLink.parse(value), + throwsFormatException, + reason: value, + ); + } + }); + + testWidgets('four access cards occupy two rows and two columns', ( + tester, + ) async { + await tester.pumpWidget(landing()); + final cards = find.byType(HanddrawnActionCard); + expect(cards, findsNWidgets(4)); + expect(find.byType(AccessDoodleIcon), findsNWidgets(4)); + final first = tester.getRect(cards.at(0)); + final second = tester.getRect(cards.at(1)); + final third = tester.getRect(cards.at(2)); + final fourth = tester.getRect(cards.at(3)); + expect(first.top, second.top); + expect(third.top, fourth.top); + expect(first.left, third.left); + expect(second.left, fourth.left); + expect(third.top, greaterThan(first.bottom)); + expect(find.byType(TextFormField), findsNothing); + }); + + testWidgets( + 'small screens preserve the matrix and large text without overflow', + (tester) async { + tester.view.physicalSize = const Size(320, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(landing(textScale: 2)); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('Restore data')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Restore data')); + await tester.pumpAndSettle(); + expect(find.text('Restore your home'), findsOneWidget); + await tester.scrollUntilVisible(find.text('Recovery code'), 300); + await tester.pumpAndSettle(); + expect(find.text('Recovery code'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'invitation input validates before navigating and requires review', + (tester) async { + await tester.pumpWidget(landing()); + await tester.tap(find.text('Accept invitation')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Review invitation')); + await tester.pumpAndSettle(); + expect( + find.text('Paste the complete pawmate://pair invitation link.'), + findsOneWidget, + ); + await tester.enterText(find.byType(TextFormField), invitation); + await tester.tap(find.text('Review invitation')); + await tester.pumpAndSettle(); + expect(find.text('You have been invited'), findsOneWidget); + expect(find.text('http://100.64.0.1:8080'), findsOneWidget); + expect(find.text('Accept invitation'), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.text(invitation), findsOneWidget); + }, + ); + + testWidgets('clipboard is read only after pressing Paste link', ( + tester, + ) async { + var reads = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.getData') { + reads++; + return {'text': invitation}; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await tester.pumpWidget(landing()); + await tester.tap(find.text('Accept invitation')); + await tester.pumpAndSettle(); + expect(reads, 0); + await tester.tap(find.text('Paste link')); + await tester.pumpAndSettle(); + expect(reads, 1); + expect(find.text(invitation), findsOneWidget); + expect(find.text('You have been invited'), findsNothing); + }); + + testWidgets('access cards expose button semantics and keyboard activation', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget(landing()); + expect(find.bySemanticsLabel('Accept invitation'), findsOneWidget); + final node = tester.getSemantics( + find.bySemanticsLabel('Accept invitation'), + ); + expect(node.getSemanticsData().hasAction(ui.SemanticsAction.tap), isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(find.text('Server URL or domain'), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('crayon pixels remain identical after rebuilding each purpose', ( + tester, + ) async { + final key = GlobalKey(); + for (final symbol in AccessDoodle.values) { + var generation = 0; + Widget icon() => MaterialApp( + home: Center( + child: RepaintBoundary( + key: key, + child: AccessDoodleIcon(key: ValueKey(generation), symbol: symbol), + ), + ), + ); + await tester.pumpWidget(icon()); + await tester.pumpAndSettle(); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final before = await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = await image.toByteData(); + image.dispose(); + return bytes!.buffer.asUint8List(); + }); + generation++; + await tester.pumpWidget(icon()); + await tester.pumpAndSettle(); + final after = await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = await image.toByteData(); + image.dispose(); + return bytes!.buffer.asUint8List(); + }); + expect(after, before); + } + }); +} diff --git a/test/onboarding_preview_test.dart b/test/onboarding_preview_test.dart new file mode 100644 index 0000000..f031cca --- /dev/null +++ b/test/onboarding_preview_test.dart @@ -0,0 +1,117 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pawmate/design/components/handdrawn_button.dart'; +import 'package:pawmate/design/layouts/handdrawn_scaffold.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; +import 'package:pawmate/features/pairing/login_methods_page.dart'; +import 'package:pawmate/features/pairing/widgets/pairing_recovery_card.dart'; +import 'package:pawmate/features/pairing/widgets/server_setup_card.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; + +/// Opt-in visual captures using a caller-provided reading font, not test Ahem. +/// Run with PAWMATE_PREVIEW_FONT and --dart-define=PAWMATE_CAPTURE_PREVIEWS=true. +void main() { + testWidgets( + 'capture previous stacked reference and new access matrix', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final fontPath = Platform.environment['PAWMATE_PREVIEW_FONT']; + expect( + fontPath, + isNotNull, + reason: 'Provide a local reading-font TTF for previews.', + ); + await tester.runAsync(() async { + final bytes = await File(fontPath!).readAsBytes(); + final loader = FontLoader('PawmatePreview') + ..addFont(Future.value(ByteData.sublistView(bytes))); + await loader.load(); + final icons = FontLoader('MaterialIcons') + ..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf')); + await icons.load(); + }); + final controllers = List.generate(3, (_) => TextEditingController()); + addTearDown(() { + for (final controller in controllers) { + controller.dispose(); + } + }); + // A non-interactive reconstruction of the former stacked composition. + final before = HanddrawnScaffold( + title: 'Invite your partner', + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + const SizedBox(height: 18), + HanddrawnButton( + label: 'Sign in on another device', + onPressed: () {}, + primary: false, + icon: Icons.devices, + ), + const SizedBox(height: 20), + ServerSetupCard( + formKey: GlobalKey(), + controller: controllers[0], + isLoading: false, + onCreateInvite: () {}, + ), + const SizedBox(height: 18), + PairingRecoveryCard( + serverController: controllers[1], + codeController: controllers[2], + isLoading: false, + onRecover: () {}, + ), + ], + ), + ), + ); + final theme = buildPawmateTheme(); + for (final entry in { + 'login-before': before, + 'login-matrix': const LoginMethodsPage(), + }.entries) { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme.copyWith( + textTheme: theme.textTheme.apply(fontFamily: 'PawmatePreview'), + ), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: RepaintBoundary(key: key, child: entry.value), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.runAsync(() async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + await File( + 'books/src/images/${entry.key}.png', + ).writeAsBytes(bytes!.buffer.asUint8List()); + }); + } + }, + skip: !const bool.fromEnvironment('PAWMATE_CAPTURE_PREVIEWS'), + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index aa6c83b..3829f96 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -4,20 +4,30 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:pawmate/main.dart'; import 'package:pawmate/features/pairing/device_login_page.dart'; -import 'package:pawmate/design/pawmate_theme.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; void main() { - testWidgets('inviter setup page accepts a server URL', ( + testWidgets('access landing page opens focused invitation and device flows', ( WidgetTester tester, ) async { FlutterSecureStorage.setMockInitialValues({}); await tester.pumpWidget(const PawmateApp()); await tester.pumpAndSettle(); + expect(find.text('Create invitation'), findsOneWidget); + expect(find.text('Accept invitation'), findsOneWidget); + expect(find.text('Restore data'), findsOneWidget); + expect(find.text('Add device'), findsOneWidget); + expect(find.byType(TextFormField), findsNothing); + await tester.tap(find.text('Create invitation')); + await tester.pumpAndSettle(); expect(find.text('Invite your partner'), findsOneWidget); expect(find.text('Server URL or domain'), findsOneWidget); expect(find.text('Create invitation'), findsOneWidget); - await tester.tap(find.text('Sign in on another device')); + expect(find.text('Recovery code'), findsNothing); + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add device')); await tester.pumpAndSettle(); expect(find.text('Device login code'), findsOneWidget); expect(find.text('Device name'), findsOneWidget); From 6050041097c9874713626263179eaa82ff0d06f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 5 Oct 2026 22:22:24 +0800 Subject: [PATCH 10/16] feat(pairing): add member profiles and document current behavior Collect a nickname and optional avatar during invitation creation and acceptance, validate and persist profiles with pairing, and show both members' identities in Home. Localize profile flows and add profile-focused UI and service coverage. Replace the implementation guide with reference documentation for onboarding, pairing, API contracts, and persistence. Remove obsolete draft instructions and book figures. --- books/book.toml | 4 +- books/src/README.md | 23 +- books/src/SUMMARY.md | 15 +- books/src/current-foundation.md | 36 +- books/src/first-vertical-slice/README.md | 17 - books/src/first-vertical-slice/contract.md | 60 ---- books/src/first-vertical-slice/draft-code.md | 339 ------------------ .../first-vertical-slice/implementation.md | 62 ---- .../src/first-vertical-slice/requirements.md | 42 --- .../server-pairing-foundation.md | 157 -------- .../src/first-vertical-slice/verification.md | 57 --- books/src/human-in-the-loop.md | 17 - books/src/images/basic-chat.png | Bin 73354 -> 0 bytes books/src/images/login-before.png | Bin 72804 -> 0 bytes books/src/images/login-matrix.png | Bin 73461 -> 0 bytes books/src/reference/client-and-api.md | 78 ++++ books/src/reference/pairing-and-identity.md | 43 +++ books/src/reference/pairing-and-storage.md | 41 +++ lib/design/components/couple_avatar.dart | 56 +++ .../components/crayon_avatar_button.dart | 53 +++ .../components/handdrawn_text_field.dart | 3 + lib/design/icons/README.md | 6 + .../icons/profile/profile_doodle_icon.dart | 50 +++ lib/features/pairing/device_login_page.dart | 10 +- lib/features/pairing/invitee_accept_page.dart | 107 +++--- lib/features/pairing/inviter_setup_page.dart | 26 +- lib/features/pairing/member_profile.dart | 22 ++ lib/features/pairing/paired_home_page.dart | 50 ++- lib/features/pairing/pairing_api.dart | 35 +- .../widgets/member_profile_editor.dart | 187 ++++++++++ .../pairing/widgets/server_setup_card.dart | 11 +- lib/l10n/app_en.arb | 13 +- lib/l10n/app_zh.arb | 13 +- lib/l10n/generated/app_localizations.dart | 66 ++++ lib/l10n/generated/app_localizations_en.dart | 36 ++ lib/l10n/generated/app_localizations_zh.dart | 33 ++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Runner/DebugProfile.entitlements | 2 + macos/Runner/Release.entitlements | 2 + pubspec.lock | 72 ++++ pubspec.yaml | 1 + server/README.md | 27 ++ server/internal/httpapi/pairing.go | 41 ++- server/internal/httpapi/pairing_test.go | 72 +++- server/internal/pairing/models.go | 4 + server/internal/pairing/profiles.go | 85 +++++ server/internal/pairing/profiles_test.go | 118 ++++++ server/internal/pairing/service.go | 43 +++ test/chat_test.dart | 7 + test/login_methods_test.dart | 8 +- test/member_profile_test.dart | 171 +++++++++ test/profile_preview_test.dart | 99 +++++ test/widget_test.dart | 3 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 57 files changed, 1652 insertions(+), 882 deletions(-) delete mode 100644 books/src/first-vertical-slice/README.md delete mode 100644 books/src/first-vertical-slice/contract.md delete mode 100644 books/src/first-vertical-slice/draft-code.md delete mode 100644 books/src/first-vertical-slice/implementation.md delete mode 100644 books/src/first-vertical-slice/requirements.md delete mode 100644 books/src/first-vertical-slice/server-pairing-foundation.md delete mode 100644 books/src/first-vertical-slice/verification.md delete mode 100644 books/src/human-in-the-loop.md delete mode 100644 books/src/images/basic-chat.png delete mode 100644 books/src/images/login-before.png delete mode 100644 books/src/images/login-matrix.png create mode 100644 books/src/reference/client-and-api.md create mode 100644 books/src/reference/pairing-and-identity.md create mode 100644 books/src/reference/pairing-and-storage.md create mode 100644 lib/design/components/couple_avatar.dart create mode 100644 lib/design/components/crayon_avatar_button.dart create mode 100644 lib/design/icons/profile/profile_doodle_icon.dart create mode 100644 lib/features/pairing/member_profile.dart create mode 100644 lib/features/pairing/widgets/member_profile_editor.dart create mode 100644 server/internal/pairing/profiles.go create mode 100644 server/internal/pairing/profiles_test.go create mode 100644 test/member_profile_test.dart create mode 100644 test/profile_preview_test.dart 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 7b7545c..1377647 100644 --- a/books/src/SUMMARY.md +++ b/books/src/SUMMARY.md @@ -1,12 +1,7 @@ -# 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) - - [Next step: server pairing foundation](first-vertical-slice/server-pairing-foundation.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) diff --git a/books/src/current-foundation.md b/books/src/current-foundation.md index 8e68f03..55a48b6 100644 --- a/books/src/current-foundation.md +++ b/books/src/current-foundation.md @@ -28,13 +28,13 @@ 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. -## Choose an access method +## Access and onboarding -New installations open a **2 × 2 access matrix**, not a long page of unrelated -forms. The four cards are Create invitation, Accept invitation, Restore data, -and Add device. Each opens a focused page with back navigation. An existing -pending invitation still reopens its invitation status page; valid paired -sessions still open Chat, and invalid saved sessions still lead to recovery. +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 @@ -47,7 +47,7 @@ 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 icon family lives in `lib/design/icons/access/`, with shared dry-wax rendering +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 @@ -60,13 +60,24 @@ color and spacing tokens plus the app theme, `layouts/` contains the scaffold, `illustrations/` contains decorative compositions. Import token files directly; design widgets do not depend on feature services. See `lib/design/README.md`. -Previous stacked-layout reference (reconstructed from the shared components): +## Member identity during pairing -![Previous stacked onboarding reference](images/login-before.png) +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. -Current two-by-two access landing page (Flutter widget preview, not a device capture): +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. -![Four hand-drawn access methods](images/login-matrix.png) +`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 @@ -111,9 +122,6 @@ 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. -The following widget-rendered preview uses sample messages, not private data: - -![Basic chat with paper bubbles, a read receipt and the Chat-first navigation](images/basic-chat.png) 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 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 10f7ec8..0000000 --- a/books/src/first-vertical-slice/contract.md +++ /dev/null @@ -1,60 +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 -GET /api/v1/pairing/invites/status -``` - -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` | Instance context in the MVP | Creates one expiring, single-use invite and returns an inviter token. | -| `POST /pairing/invites/redeem` | No in the MVP | Atomically consumes the invite and marks the instance paired. | -| `GET /pairing/invites/status` | Bearer inviter token | Lets the inviting device check whether redemption completed. | - -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 91974b8..0000000 --- a/books/src/first-vertical-slice/draft-code.md +++ /dev/null @@ -1,339 +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(); - 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', - hintText: 'https://pawmate.example.com', - ), - ), - 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. - -## Current inviter implementation - -The repository now has a process-local implementation in -`server/internal/pairing/service.go` and HTTP adapters in -`server/internal/httpapi/pairing.go`: - -```text -POST /api/v1/pairing/invites - {"server_url":"https://home.example.com"} - -> {"invite_url":"pawmate://pair?...", "inviter_token":"...", "expires_at":"..."} - -GET /api/v1/pairing/invites/status - Authorization: Bearer - -POST /api/v1/pairing/invites/redeem - {"code":"..."} -``` - -The Flutter inviter screen is -`lib/features/pairing/inviter_setup_page.dart`, backed by -`lib/features/pairing/pairing_api.dart`. It validates the configured URL, -creates the invite, displays the link, copies it to the clipboard, and checks -pairing status. - -This is intentionally a development baseline: pairing state is protected by a -mutex but lives only in the Gin process. A restart invalidates the invitation -and pairing state. Before production, replace the service storage with the -SQLite schema and authenticated accounts described earlier in this chapter. 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/server-pairing-foundation.md b/books/src/first-vertical-slice/server-pairing-foundation.md deleted file mode 100644 index d29b6f5..0000000 --- a/books/src/first-vertical-slice/server-pairing-foundation.md +++ /dev/null @@ -1,157 +0,0 @@ -# Next step: server pairing foundation - -This is the first implementation task to take from the book into a feature -branch. It deliberately stops before Flutter UI work: the server must enforce -the relationship rules before the client can safely depend on them. - -## Outcome - -After this task, the Gin server can create and atomically redeem a single-use, -ten-minute pairing invitation for two authenticated users. The task is complete -only when concurrency and invalid-state tests pass. - -## 1. Create the branch and establish a red test - -```bash -git switch -c feat/couple-pairing-foundation -cd server -go test ./... -``` - -Start with a service-level test, not a Gin handler. The test should state the -desired result before a database or HTTP implementation exists: - -```go -func TestRedeemInvitePairsTwoUnpairedUsers(t *testing.T) { - store := newTestStore(t) - creatorID := store.createUser(t, "Ari") - recipientID := store.createUser(t, "Bo") - service := NewPairingService(store, fixedClock) - - invite, err := service.CreateInvite(context.Background(), creatorID) - if err != nil { - t.Fatalf("CreateInvite() error = %v", err) - } - - coupleID, err := service.RedeemInvite(context.Background(), recipientID, invite.Code) - if err != nil { - t.Fatalf("RedeemInvite() error = %v", err) - } - if coupleID == "" { - t.Fatal("RedeemInvite() returned an empty couple ID") - } - store.assertSameCouple(t, creatorID, recipientID, coupleID) -} -``` - -The names and test helper APIs are examples. Preserve the behavior, not these -exact identifiers. - -## 2. Add the smallest durable schema - -Use a membership table so the database can guarantee one couple per user: - -```sql -CREATE TABLE couples ( - id TEXT PRIMARY KEY, - created_at TEXT NOT NULL -); - -CREATE TABLE couple_members ( - couple_id TEXT NOT NULL REFERENCES couples(id), - user_id TEXT NOT NULL UNIQUE REFERENCES users(id), - PRIMARY KEY (couple_id, user_id) -); - -CREATE TABLE pairing_invites ( - id TEXT PRIMARY KEY, - creator_user_id TEXT NOT NULL REFERENCES users(id), - code_hash BLOB NOT NULL UNIQUE, - expires_at TEXT NOT NULL, - redeemed_at TEXT, - redeemed_by_user_id TEXT REFERENCES users(id) -); -``` - -Use the migration mechanism chosen for the server; do not run schema creation -inside a request handler. Keep a migration test that opens a fresh database and -confirms all expected tables and indexes exist. - -## 3. Define domain errors and service methods - -The HTTP layer should translate typed domain errors into status codes. It should -not contain pairing rules. - -```go -var ( - ErrAlreadyPaired = errors.New("user is already paired") - ErrInvalidInvite = errors.New("invite is invalid") - ErrExpiredInvite = errors.New("invite has expired") - ErrCannotRedeemOwnInvite = errors.New("cannot redeem own invite") -) - -type PairingService interface { - CreateInvite(ctx context.Context, creatorUserID string) (Invite, error) - RedeemInvite(ctx context.Context, recipientUserID, code string) (string, error) -} -``` - -An invitation code is a bearer secret. Generate 32 random bytes with -`crypto/rand`, encode them as Base64 URL text, return that text once, and save -only a SHA-256 hash of it. A short six-digit code is not an equivalent -replacement unless it has strong rate limiting and a much shorter lifetime. - -## 4. Make redemption a transaction - -Inside one database transaction, in this order: - -1. Find an unredeemed invitation by its code hash. -2. Reject a missing or expired invitation. -3. Reject the invitation creator redeeming their own invite. -4. Confirm both users have no row in `couple_members`. -5. Insert one `couples` row and two `couple_members` rows. -6. Mark the invitation redeemed, checking that exactly one row changed. -7. Commit. - -If any step fails, roll back everything. The service must never leave a couple -without two members or consume an invitation without creating a couple. - -## 5. Write the failure tests before HTTP - -| Test | Expected result | -| ----------------------------------- | ---------------------------------------------- | -| Creator redeems own invite | `ErrCannotRedeemOwnInvite` | -| Invite is expired | `ErrExpiredInvite` | -| Invite was redeemed already | `ErrInvalidInvite` | -| Recipient is already paired | `ErrAlreadyPaired` | -| Two redemption requests race | exactly one succeeds | -| Third registered user tries to join | rejected by the private-instance capacity rule | - -Run these throughout the task: - -```bash -gofmt -w . -go vet ./... -go test ./... -``` - -## 6. Only then expose the HTTP API - -After the service tests pass, add authenticated handlers: - -```text -POST /api/v1/pairing/invites -POST /api/v1/pairing/invites/redeem -GET /api/v1/pairing/invites/status -``` - -The authentication middleware supplies the user ID. Do not accept `user_id` or -`couple_id` in either request body. Return a generic response for unknown, -expired, and already-consumed invitation codes so callers cannot learn which -codes were once valid. - -## Review checkpoint - -Before adding Flutter screens, confirm in review that passwords and session -tokens are handled independently from pairing: passwords are slow-hashed, -tokens are stored securely, and production traffic uses HTTPS. diff --git a/books/src/first-vertical-slice/verification.md b/books/src/first-vertical-slice/verification.md deleted file mode 100644 index 4be47d7..0000000 --- a/books/src/first-vertical-slice/verification.md +++ /dev/null @@ -1,57 +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. - -For the current inviter MVP, manually verify invitation creation with: - -```bash -curl -X POST http://localhost:8080/api/v1/pairing/invites \ - -H 'content-type: application/json' \ - -d '{"server_url":"http://localhost:8080"}' -``` - -Save the returned `invite_url` and `inviter_token` only in a local shell. The -invite URL is a bearer secret; do not paste it into commits, logs, or issues. - -## 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/images/basic-chat.png b/books/src/images/basic-chat.png deleted file mode 100644 index d1a6c9b2f4f96f02084149c93c0a1396e7abce21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 73354 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

b<-)NNHnB}2xRY74mEpCmkGtHJQ%G4G86tuFgB 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 diff --git a/books/src/images/login-before.png b/books/src/images/login-before.png deleted file mode 100644 index 9a8c15000fd710705932fa8c3f62bc7b5116f25e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72804 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 diff --git a/books/src/images/login-matrix.png b/books/src/images/login-matrix.png deleted file mode 100644 index 71e70af5461b9e5ee382106d7ad41a2030526669..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 73461 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 DiJ0^| 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/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_text_field.dart b/lib/design/components/handdrawn_text_field.dart index c2d667f..2f670c5 100644 --- a/lib/design/components/handdrawn_text_field.dart +++ b/lib/design/components/handdrawn_text_field.dart @@ -10,6 +10,7 @@ class HanddrawnTextField extends StatelessWidget { this.validator, this.prefixIcon, this.keyboardType = TextInputType.url, + this.enabled = true, }); final TextEditingController controller; @@ -18,11 +19,13 @@ class HanddrawnTextField extends StatelessWidget { 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, diff --git a/lib/design/icons/README.md b/lib/design/icons/README.md index 11cf387..d7a4fbd 100644 --- a/lib/design/icons/README.md +++ b/lib/design/icons/README.md @@ -3,6 +3,7 @@ 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. @@ -28,3 +29,8 @@ PAWMATE_PREVIEW_FONT=/path/to/reading-font.ttf flutter test \ 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/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/features/pairing/device_login_page.dart b/lib/features/pairing/device_login_page.dart index 53c5dda..3c92ea6 100644 --- a/lib/features/pairing/device_login_page.dart +++ b/lib/features/pairing/device_login_page.dart @@ -29,12 +29,16 @@ class _DeviceLoginPageState extends State { final _api = PairingApi(); final _credentials = PairingCredentials(); bool _busy = false; + bool _initializedName = false; String? _error; @override - void initState() { - super.initState(); - _name.text = AppLocalizations.of(context)!.myDeviceName; + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initializedName) { + _name.text = AppLocalizations.of(context)!.myDeviceName; + _initializedName = true; + } } @override diff --git a/lib/features/pairing/invitee_accept_page.dart b/lib/features/pairing/invitee_accept_page.dart index bb955b6..88971cb 100644 --- a/lib/features/pairing/invitee_accept_page.dart +++ b/lib/features/pairing/invitee_accept_page.dart @@ -4,11 +4,11 @@ import '../../design/components/handdrawn_button.dart'; import '../../design/components/handdrawn_card.dart'; import '../../design/layouts/handdrawn_scaffold.dart'; import '../../design/theme/colors.dart'; -import '../../design/icons/access/access_doodle_icon.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; import 'paired_home_page.dart'; import 'widgets/pairing_error_note.dart'; +import 'widgets/member_profile_editor.dart'; import '../../l10n/generated/app_localizations.dart'; /// Lets the invitee review and accept a one-time link opened from another app. @@ -22,6 +22,8 @@ class InviteeAcceptPage extends StatefulWidget { } class _InviteeAcceptPageState extends State { + final _formKey = GlobalKey(); + final _profileKey = GlobalKey(); final _api = PairingApi(); final _credentials = PairingCredentials(); bool _isLoading = false; @@ -34,6 +36,8 @@ class _InviteeAcceptPageState extends State { } Future _acceptInvite() async { + if (!_formKey.currentState!.validate()) return; + final profile = _profileKey.currentState!.profile!; final l10n = AppLocalizations.of(context)!; final serverURL = widget.inviteUri.queryParameters['server']; final code = widget.inviteUri.queryParameters['code']; @@ -50,7 +54,7 @@ class _InviteeAcceptPageState extends State { _errorMessage = null; }); try { - final result = await _api.redeemInvite(serverURL, code); + final result = await _api.redeemInvite(serverURL, code, profile: profile); final token = result.inviteeToken; final recoveryCode = result.recoveryCode; final pairID = result.pairID; @@ -85,6 +89,7 @@ class _InviteeAcceptPageState extends State { pairID: pairID, role: 'invitee', status: 'paired', + profile: profile, ), recoveryCodeToSave: recoveryCode, storageWarning: storageWarning, @@ -92,10 +97,12 @@ class _InviteeAcceptPageState extends State { ), (_) => false, ); - } on PairingApiException { + } on PairingApiException catch (error) { if (mounted) { setState( - () => _errorMessage = AppLocalizations.of(context)!.requestFailed, + () => _errorMessage = error.message == 'invalid_profile' + ? l10n.profileRejected + : l10n.requestFailed, ); } } on Object { @@ -120,57 +127,57 @@ class _InviteeAcceptPageState extends State { children: [ HanddrawnCard( color: const Color(0xFFF4F0FF), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Center( - child: AccessDoodleIcon( - symbol: AccessDoodle.acceptInvitation, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + MemberProfileEditor(key: _profileKey, enabled: !_isLoading), + const SizedBox(height: 12), + Text( + AppLocalizations.of(context)!.invited, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + color: PawmateColors.ink, + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 12), - Text( - AppLocalizations.of(context)!.invited, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: PawmateColors.ink, - fontWeight: FontWeight.w700, + const SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.acceptOneTimeHelp, + textAlign: TextAlign.center, + style: const TextStyle( + color: PawmateColors.softBrown, + height: 1.4, + ), ), - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.acceptOneTimeHelp, - textAlign: TextAlign.center, - style: const TextStyle( - color: PawmateColors.softBrown, - height: 1.4, + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.privateServer, + style: TextStyle( + color: PawmateColors.softBrown, + fontSize: 12, + ), ), - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context)!.privateServer, - style: TextStyle( - color: PawmateColors.softBrown, - fontSize: 12, + const SizedBox(height: 4), + SelectableText( + serverURL, + style: const TextStyle( + color: PawmateColors.ink, + fontFamily: 'monospace', + ), ), - ), - const SizedBox(height: 4), - SelectableText( - serverURL, - style: const TextStyle( - color: PawmateColors.ink, - fontFamily: 'monospace', + const SizedBox(height: 20), + HanddrawnButton( + onPressed: _isLoading ? null : _acceptInvite, + icon: Icons.favorite_border, + label: _isLoading + ? AppLocalizations.of(context)!.joining + : AppLocalizations.of(context)!.acceptInvitation, ), - ), - const SizedBox(height: 20), - HanddrawnButton( - onPressed: _isLoading ? null : _acceptInvite, - icon: Icons.favorite_border, - label: _isLoading - ? AppLocalizations.of(context)!.joining - : AppLocalizations.of(context)!.acceptInvitation, - ), - ], + ], + ), ), ), if (_errorMessage != null) ...[ diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart index 7ee0a90..371eac3 100644 --- a/lib/features/pairing/inviter_setup_page.dart +++ b/lib/features/pairing/inviter_setup_page.dart @@ -5,6 +5,7 @@ import '../../design/layouts/handdrawn_scaffold.dart'; import 'widgets/invite_result_card.dart'; import 'widgets/pairing_error_note.dart'; import 'widgets/server_setup_card.dart'; +import 'widgets/member_profile_editor.dart'; import 'pairing_api.dart'; import 'pairing_credentials.dart'; import 'paired_home_page.dart'; @@ -19,6 +20,7 @@ class InviterSetupPage extends StatefulWidget { class _InviterSetupPageState extends State { final _formKey = GlobalKey(); + final _profileKey = GlobalKey(); final _serverURLController = TextEditingController(); final _api = PairingApi(); final _credentials = PairingCredentials(); @@ -76,7 +78,10 @@ class _InviterSetupPageState extends State { _status = null; }); try { - final invite = await _api.createInvite(_serverURLController.text); + final invite = await _api.createInvite( + _serverURLController.text, + profile: _profileKey.currentState!.profile!, + ); if (!mounted) return; setState(() => _invite = invite); try { @@ -104,6 +109,8 @@ class _InviterSetupPageState extends State { setState( () => _errorMessage = error.message.startsWith('Use an HTTP') ? l10n.invalidServerAddress + : error.message == 'invalid_profile' + ? l10n.profileRejected : error.statusCode == null ? l10n.serverUnreachable : l10n.requestFailed, @@ -217,12 +224,17 @@ class _InviterSetupPageState extends State { padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), children: [ const SizedBox(height: 6), - ServerSetupCard( - formKey: _formKey, - controller: _serverURLController, - isLoading: _isLoading, - onCreateInvite: () => _createInvite(), - ), + if (invite == null || !invite.expiresAt.isAfter(DateTime.now())) + ServerSetupCard( + formKey: _formKey, + controller: _serverURLController, + isLoading: _isLoading, + onCreateInvite: () => _createInvite(), + profileEditor: MemberProfileEditor( + key: _profileKey, + enabled: !_isLoading, + ), + ), if (_errorMessage != null) ...[ const SizedBox(height: 18), PairingErrorNote(message: _errorMessage!), diff --git a/lib/features/pairing/member_profile.dart b/lib/features/pairing/member_profile.dart new file mode 100644 index 0000000..6137561 --- /dev/null +++ b/lib/features/pairing/member_profile.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// A server-owned member identity that is shared by all of their devices. +class MemberProfile { + const MemberProfile({required this.nickname, required this.avatar}); + + final String nickname; + final Uint8List avatar; + + /// Decodes a bounded portrait returned by the authenticated instance. + factory MemberProfile.fromJson(Map json) => MemberProfile( + nickname: json['nickname'] as String, + avatar: base64Decode(json['avatar_base64'] as String), + ); + + /// Encodes identity alongside the invitation request, never in the link URL. + Map toJson() => { + 'nickname': nickname, + 'avatar_base64': base64Encode(avatar), + }; +} diff --git a/lib/features/pairing/paired_home_page.dart b/lib/features/pairing/paired_home_page.dart index d52b5ad..52f7cf3 100644 --- a/lib/features/pairing/paired_home_page.dart +++ b/lib/features/pairing/paired_home_page.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../design/components/handdrawn_card.dart'; +import '../../design/components/couple_avatar.dart'; import '../../design/layouts/handdrawn_scaffold.dart'; import '../../design/theme/colors.dart'; import 'pairing_api.dart'; @@ -38,6 +41,8 @@ class PairedHomePage extends StatefulWidget { class _PairedHomePageState extends State with WidgetsBindingObserver { late final ChatController _chat; + final _profileApi = PairingApi(); + late PairingSession _profileSession; int _tab = 0; int _lastUnread = 0; bool _loaded = false; @@ -47,6 +52,10 @@ class _PairedHomePageState extends State @override void initState() { super.initState(); + _profileSession = widget.session; + if (widget.session.profile == null || widget.session.partner == null) { + unawaited(_loadProfiles()); + } WidgetsBinding.instance.addObserver(this); _chat = ChatController(widget.chatApi ?? ChatApi(widget.credentials)) ..addListener(_changed); @@ -57,6 +66,19 @@ class _PairedHomePageState extends State _chat.start(); } + /// Reads server-owned identities after invitation, recovery or device login. + Future _loadProfiles() async { + try { + final session = await _profileApi.validateSession( + widget.credentials.serverURL, + widget.credentials.accessToken, + ); + if (mounted) setState(() => _profileSession = session); + } on Object { + // A profile lookup must not interrupt an otherwise usable chat session. + } + } + /// Pauses polling and read receipts when the application leaves the foreground. @override void didChangeAppLifecycleState(AppLifecycleState state) => @@ -111,6 +133,7 @@ class _PairedHomePageState extends State @override void dispose() { + _profileApi.close(); WidgetsBinding.instance.removeObserver(this); _chat.removeListener(_changed); _chat.dispose(); @@ -177,7 +200,7 @@ class _PairedHomePageState extends State ), _HomeDetails( credentials: widget.credentials, - session: widget.session, + session: _profileSession, recoveryCodeToSave: widget.recoveryCodeToSave, storageWarning: widget.storageWarning, ), @@ -216,6 +239,31 @@ class _HomeDetails extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (session.profile != null || session.partner != null) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (session.profile case final profile?) + Expanded( + child: CoupleAvatar( + avatar: profile.avatar, + nickname: profile.nickname, + label: l10n.yourProfile, + ), + ), + const SizedBox(width: 16), + if (session.partner case final partner?) + Expanded( + child: CoupleAvatar( + avatar: partner.avatar, + nickname: partner.nickname, + label: l10n.partnerProfile, + ), + ), + ], + ), + const SizedBox(height: 16), + ], const Icon( Icons.home_rounded, size: 36, diff --git a/lib/features/pairing/pairing_api.dart b/lib/features/pairing/pairing_api.dart index 1df8063..5419dee 100644 --- a/lib/features/pairing/pairing_api.dart +++ b/lib/features/pairing/pairing_api.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'member_profile.dart'; /// A one-time invitation returned by the Pawmate instance. class PairingInvite { @@ -79,19 +80,33 @@ class RecoveredCredentials { /// Represents the authenticated member's server-side pairing session. class PairingSession { - const PairingSession({required this.role, required this.status, this.pairID}); + const PairingSession({ + required this.role, + required this.status, + this.pairID, + this.profile, + this.partner, + }); factory PairingSession.fromJson(Map json) { return PairingSession( pairID: json['pair_id'] as String?, role: json['role'] as String, status: json['status'] as String, + profile: json['profile'] == null + ? null + : MemberProfile.fromJson(json['profile'] as Map), + partner: json['partner'] == null + ? null + : MemberProfile.fromJson(json['partner'] as Map), ); } final String? pairID; final String role; final String status; + final MemberProfile? profile; + final MemberProfile? partner; } /// A short-lived code created by an already authenticated device. @@ -171,7 +186,10 @@ class PairingApi { void close() => _client.close(); /// Creates an invitation on the configured server instance. - Future createInvite(String rawServerURL) async { + Future createInvite( + String rawServerURL, { + required MemberProfile profile, + }) async { final serverURL = _parseServerURL(rawServerURL); final response = await _client .post( @@ -179,6 +197,7 @@ class PairingApi { headers: const {'content-type': 'application/json'}, body: jsonEncode({ 'server_url': serverURL.toString(), + 'profile': profile.toJson(), 'device_name': defaultDeviceName, }), ) @@ -244,13 +263,21 @@ class PairingApi { } /// Redeems a one-time invite code on the server encoded in the invite link. - Future redeemInvite(String rawServerURL, String code) async { + Future redeemInvite( + String rawServerURL, + String code, { + required MemberProfile profile, + }) async { final serverURL = _parseServerURL(rawServerURL); final response = await _client .post( serverURL.resolve('/api/v1/pairing/invites/redeem'), headers: const {'content-type': 'application/json'}, - body: jsonEncode({'code': code, 'device_name': defaultDeviceName}), + body: jsonEncode({ + 'code': code, + 'device_name': defaultDeviceName, + 'profile': profile.toJson(), + }), ) .timeout(const Duration(seconds: 10)); diff --git a/lib/features/pairing/widgets/member_profile_editor.dart b/lib/features/pairing/widgets/member_profile_editor.dart new file mode 100644 index 0000000..29d065d --- /dev/null +++ b/lib/features/pairing/widgets/member_profile_editor.dart @@ -0,0 +1,187 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; + +import '../../../design/components/crayon_avatar_button.dart'; +import '../../../design/components/handdrawn_text_field.dart'; +import '../../../design/theme/colors.dart'; +import '../../../design/theme/spacing.dart'; +import '../../../l10n/generated/app_localizations.dart'; +import '../member_profile.dart'; + +/// Collects required member identity inside the enclosing onboarding form. +class MemberProfileEditor extends StatefulWidget { + const MemberProfileEditor({super.key, this.enabled = true, this.selectImage}); + + final bool enabled; + final Future Function()? selectImage; + + @override + State createState() => MemberProfileEditorState(); +} + +class MemberProfileEditorState extends State { + final _nickname = TextEditingController(); + Uint8List? _avatar; + bool _picking = false; + String? _pickerError; + + /// Returns submitted identity only after the enclosing form is validated. + MemberProfile? get profile => _avatar == null + ? null + : MemberProfile(nickname: _nickname.text.trim(), avatar: _avatar!); + + /// Selects an image explicitly, then crops it locally to a bounded square PNG. + Future _pickAvatar(FormFieldState field) async { + if (_picking) return; + final l10n = AppLocalizations.of(context)!; + setState(() { + _picking = true; + _pickerError = null; + }); + try { + final file = widget.selectImage != null + ? await widget.selectImage!() + : await openFile( + acceptedTypeGroups: [ + XTypeGroup( + label: l10n.avatarImages, + extensions: const ['png', 'jpg', 'jpeg'], + mimeTypes: const ['image/png', 'image/jpeg'], + uniformTypeIdentifiers: const ['public.png', 'public.jpeg'], + ), + ], + ); + if (file == null) return; + if (await file.length() > 10 * 1024 * 1024) { + throw const FormatException('avatar_file_too_large'); + } + final buffer = await ui.ImmutableBuffer.fromUint8List( + await file.readAsBytes(), + ); + ui.ImageDescriptor? descriptor; + ui.Codec? codec; + ui.Image? image; + ui.Picture? picture; + ui.Image? resized; + try { + descriptor = await ui.ImageDescriptor.encoded(buffer); + if (descriptor.width < 1 || + descriptor.height < 1 || + descriptor.width * descriptor.height > 40000000) { + throw const FormatException('invalid_avatar'); + } + final scale = + 512 / + (descriptor.width > descriptor.height + ? descriptor.width + : descriptor.height); + codec = await descriptor.instantiateCodec( + targetWidth: (descriptor.width * scale).round().clamp(1, 512), + targetHeight: (descriptor.height * scale).round().clamp(1, 512), + ); + image = (await codec.getNextFrame()).image; + final side = (image.width < image.height ? image.width : image.height) + .toDouble(); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawImageRect( + image, + Rect.fromLTWH( + (image.width - side) / 2, + (image.height - side) / 2, + side, + side, + ), + const Rect.fromLTWH(0, 0, 256, 256), + Paint()..filterQuality = FilterQuality.high, + ); + picture = recorder.endRecording(); + resized = await picture.toImage(256, 256); + final data = await resized.toByteData(format: ui.ImageByteFormat.png); + if (data == null || data.lengthInBytes > 256 * 1024) { + throw const FormatException('invalid_avatar'); + } + if (!mounted) return; + final bytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + setState(() => _avatar = bytes); + field.didChange(bytes); + } finally { + resized?.dispose(); + picture?.dispose(); + image?.dispose(); + codec?.dispose(); + descriptor?.dispose(); + buffer.dispose(); + } + } on Object { + if (mounted) setState(() => _pickerError = l10n.avatarUploadError); + } finally { + if (mounted) setState(() => _picking = false); + } + } + + @override + void dispose() { + _nickname.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FormField( + validator: (value) => value == null ? l10n.avatarRequired : null, + builder: (field) => Column( + children: [ + CrayonAvatarButton( + label: _avatar == null ? l10n.uploadAvatar : l10n.changeAvatar, + avatar: _avatar, + onPressed: widget.enabled && !_picking + ? () => _pickAvatar(field) + : null, + ), + const SizedBox(height: PawmateSpace.small), + Text( + _avatar == null ? l10n.uploadAvatar : l10n.changeAvatar, + style: const TextStyle(color: PawmateColors.softBrown), + ), + if (_pickerError != null || field.hasError) ...[ + const SizedBox(height: PawmateSpace.small), + Text( + _pickerError ?? field.errorText!, + textAlign: TextAlign.center, + style: const TextStyle(color: PawmateColors.error), + ), + ], + ], + ), + ), + const SizedBox(height: PawmateSpace.page), + HanddrawnTextField( + controller: _nickname, + labelText: l10n.nickname, + hintText: l10n.nicknameHint, + keyboardType: TextInputType.text, + enabled: widget.enabled, + validator: (value) { + final name = value?.trim() ?? ''; + return name.isEmpty || + name.runes.length > 32 || + RegExp(r'[\x00-\x1F\x7F-\x9F]').hasMatch(name) + ? l10n.nicknameInvalid + : null; + }, + ), + ], + ); + } +} diff --git a/lib/features/pairing/widgets/server_setup_card.dart b/lib/features/pairing/widgets/server_setup_card.dart index 9f6327a..22eb255 100644 --- a/lib/features/pairing/widgets/server_setup_card.dart +++ b/lib/features/pairing/widgets/server_setup_card.dart @@ -15,12 +15,14 @@ class ServerSetupCard extends StatelessWidget { required this.isLoading, required this.onCreateInvite, super.key, + this.profileEditor, }); final GlobalKey formKey; final TextEditingController controller; final bool isLoading; final VoidCallback onCreateInvite; + final Widget? profileEditor; @override Widget build(BuildContext context) { @@ -31,9 +33,12 @@ class ServerSetupCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Center( - child: AccessDoodleIcon(symbol: AccessDoodle.createInvitation), - ), + if (profileEditor != null) + profileEditor! + else + const Center( + child: AccessDoodleIcon(symbol: AccessDoodle.createInvitation), + ), const SizedBox(height: 12), Text( l10n.chooseHomeAddress, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 97c00d4..9e0a050 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -150,5 +150,16 @@ "requestFailed": "The server could not complete this request. Try again.", "invalidServerAddress": "Enter a valid HTTP or HTTPS server address.", "invitationLinkHint": "pawmate://pair?server=…&code=…", - "myDeviceName": "My device" + "myDeviceName": "My device", + "uploadAvatar": "Upload avatar", + "changeAvatar": "Change avatar", + "avatarImages": "Avatar images", + "avatarRequired": "Choose an avatar before continuing.", + "avatarUploadError": "Could not read this image. Choose a PNG or JPEG under 10 MB.", + "nickname": "Nickname", + "nicknameHint": "What should your partner call you?", + "nicknameInvalid": "Use a nickname between 1 and 32 characters, without control characters.", + "profileRejected": "The server could not save your profile. Check your nickname and avatar.", + "yourProfile": "You", + "partnerProfile": "Your partner" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 85aeeb2..517beed 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -150,5 +150,16 @@ "requestFailed": "服务器暂时无法完成请求,请重试", "invalidServerAddress": "请输入有效的 HTTP 或 HTTPS 服务器地址", "invitationLinkHint": "pawmate://pair?server=…&code=…", - "myDeviceName": "我的设备" + "myDeviceName": "我的设备", + "uploadAvatar": "上传头像", + "changeAvatar": "更换头像", + "avatarImages": "头像图片", + "avatarRequired": "请先选择头像", + "avatarUploadError": "无法读取这张图片,请选择小于 10 MB 的 PNG 或 JPEG 图片", + "nickname": "昵称", + "nicknameHint": "想让伴侣怎么称呼你", + "nicknameInvalid": "请输入 1 到 32 个字符的昵称,不要包含控制字符", + "profileRejected": "服务器未能保存个人资料,请检查昵称和头像", + "yourProfile": "你", + "partnerProfile": "你的伴侣" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 516061e..317128b 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -949,6 +949,72 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'My device'** String get myDeviceName; + + /// No description provided for @uploadAvatar. + /// + /// In en, this message translates to: + /// **'Upload avatar'** + String get uploadAvatar; + + /// No description provided for @changeAvatar. + /// + /// In en, this message translates to: + /// **'Change avatar'** + String get changeAvatar; + + /// No description provided for @avatarImages. + /// + /// In en, this message translates to: + /// **'Avatar images'** + String get avatarImages; + + /// No description provided for @avatarRequired. + /// + /// In en, this message translates to: + /// **'Choose an avatar before continuing.'** + String get avatarRequired; + + /// No description provided for @avatarUploadError. + /// + /// In en, this message translates to: + /// **'Could not read this image. Choose a PNG or JPEG under 10 MB.'** + String get avatarUploadError; + + /// No description provided for @nickname. + /// + /// In en, this message translates to: + /// **'Nickname'** + String get nickname; + + /// No description provided for @nicknameHint. + /// + /// In en, this message translates to: + /// **'What should your partner call you?'** + String get nicknameHint; + + /// No description provided for @nicknameInvalid. + /// + /// In en, this message translates to: + /// **'Use a nickname between 1 and 32 characters, without control characters.'** + String get nicknameInvalid; + + /// No description provided for @profileRejected. + /// + /// In en, this message translates to: + /// **'The server could not save your profile. Check your nickname and avatar.'** + String get profileRejected; + + /// No description provided for @yourProfile. + /// + /// In en, this message translates to: + /// **'You'** + String get yourProfile; + + /// No description provided for @partnerProfile. + /// + /// In en, this message translates to: + /// **'Your partner'** + String get partnerProfile; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 74c70b5..0b3a9cb 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -483,4 +483,40 @@ class AppLocalizationsEn extends AppLocalizations { @override String get myDeviceName => 'My device'; + + @override + String get uploadAvatar => 'Upload avatar'; + + @override + String get changeAvatar => 'Change avatar'; + + @override + String get avatarImages => 'Avatar images'; + + @override + String get avatarRequired => 'Choose an avatar before continuing.'; + + @override + String get avatarUploadError => + 'Could not read this image. Choose a PNG or JPEG under 10 MB.'; + + @override + String get nickname => 'Nickname'; + + @override + String get nicknameHint => 'What should your partner call you?'; + + @override + String get nicknameInvalid => + 'Use a nickname between 1 and 32 characters, without control characters.'; + + @override + String get profileRejected => + 'The server could not save your profile. Check your nickname and avatar.'; + + @override + String get yourProfile => 'You'; + + @override + String get partnerProfile => 'Your partner'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index c6339a8..77d6895 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -456,4 +456,37 @@ class AppLocalizationsZh extends AppLocalizations { @override String get myDeviceName => '我的设备'; + + @override + String get uploadAvatar => '上传头像'; + + @override + String get changeAvatar => '更换头像'; + + @override + String get avatarImages => '头像图片'; + + @override + String get avatarRequired => '请先选择头像'; + + @override + String get avatarUploadError => '无法读取这张图片,请选择小于 10 MB 的 PNG 或 JPEG 图片'; + + @override + String get nickname => '昵称'; + + @override + String get nicknameHint => '想让伴侣怎么称呼你'; + + @override + String get nicknameInvalid => '请输入 1 到 32 个字符的昵称,不要包含控制字符'; + + @override + String get profileRejected => '服务器未能保存个人资料,请检查昵称和头像'; + + @override + String get yourProfile => '你'; + + @override + String get partnerProfile => '你的伴侣'; } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index c0ebdc8..1a0f06d 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f757925..0601058 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux flutter_secure_storage_linux gtk ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 748f3a4..a760ca5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import app_links +import file_selector_macos import flutter_secure_storage_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) } diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 08c3ab1..566fcb1 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.files.user-selected.read-only + com.apple.security.cs.allow-jit com.apple.security.network.server diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index ee95ab7..625af03 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.files.user-selected.read-only + com.apple.security.network.client diff --git a/pubspec.lock b/pubspec.lock index 4c28f1a..18e7848 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -137,6 +145,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+6" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index e32975e..4aef498 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: http: ^1.5.0 app_links: ^7.2.1 flutter_secure_storage: ^11.2.0 + file_selector: ^1.1.0 dev_dependencies: flutter_test: diff --git a/server/README.md b/server/README.md index ef687db..5615ff7 100644 --- a/server/README.md +++ b/server/README.md @@ -153,6 +153,33 @@ Both participants must be able to reach the instance URL used in invitations. ## Configuration +### Member nicknames and avatars + +Creating and accepting invitations now requires a `profile` object alongside +the existing request fields: + +```json +{"profile":{"nickname":"Ash","avatar_base64":""}} +``` + +Nicknames are trimmed and contain 1–32 Unicode characters without control +characters. Avatars must be valid PNG/JPEG images, at most 256 KiB decoded from +base64, with dimensions at most 512×512. The client center-crops selected images +to a 256×256 PNG; source files must be under 10 MiB. The server validates images +and re-encodes them to PNG before saving, stripping metadata. + +The additive `member_profiles` table stores one profile per member. Invitation +creation and acceptance save profiles in the same transaction as credentials +and pairing, so rejected uploads cannot consume an invitation. Replacing an +expired, unpaired invitation removes the old inviter identity. Emergency recovery +and device login preserve profiles. Authenticated `GET /api/v1/pairing/session` +returns `profile` and, once paired, `partner`; anonymous callers cannot fetch them. + +Existing paired databases remain usable with absent profiles; this change does +not invent nicknames or overwrite an existing relationship. New invitation +requests from older clients without a profile are rejected. Upgrade the server +and client together. Profile editing for existing couples is not implemented yet. + ### Persistence architecture The server uses [GORM](https://gorm.io/docs/) for pairing, device sessions, diff --git a/server/internal/httpapi/pairing.go b/server/internal/httpapi/pairing.go index d7a5dae..05013f3 100644 --- a/server/internal/httpapi/pairing.go +++ b/server/internal/httpapi/pairing.go @@ -11,8 +11,9 @@ import ( ) type createInviteRequest struct { - ServerURL string `json:"server_url" binding:"required"` - DeviceName string `json:"device_name" binding:"max=80"` + Profile *pairing.Profile `json:"profile" binding:"required"` + ServerURL string `json:"server_url" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` } type createInviteResponse struct { @@ -23,8 +24,9 @@ type createInviteResponse struct { } type redeemInviteRequest struct { - Code string `json:"code" binding:"required"` - DeviceName string `json:"device_name" binding:"max=80"` + Profile *pairing.Profile `json:"profile" binding:"required"` + Code string `json:"code" binding:"required"` + DeviceName string `json:"device_name" binding:"max=80"` } type pairingStatusResponse struct { @@ -47,25 +49,30 @@ type recoverPairingResponse struct { } type pairingSessionResponse struct { - PairID string `json:"pair_id,omitempty"` - Role string `json:"role"` - Status string `json:"status"` + Profile *pairing.Profile `json:"profile,omitempty"` + Partner *pairing.Profile `json:"partner,omitempty"` + PairID string `json:"pair_id,omitempty"` + Role string `json:"role"` + Status string `json:"status"` } // createInviteHandler creates an invitation for the configured server URL. func createInviteHandler(service *pairing.Service) gin.HandlerFunc { return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 400*1024) var request createInviteRequest if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) return } - invite, err := service.CreateInvite(request.ServerURL, request.DeviceName) + invite, err := service.CreateInviteWithProfile(request.ServerURL, request.DeviceName, *request.Profile) if err != nil { status := http.StatusInternalServerError code := "internal_error" switch { + case errors.Is(err, pairing.ErrInvalidProfile): + status, code = http.StatusBadRequest, "invalid_profile" case errors.Is(err, pairing.ErrInvalidServerURL): status, code = http.StatusBadRequest, "invalid_server_url" case errors.Is(err, pairing.ErrAlreadyPaired): @@ -89,17 +96,20 @@ func createInviteHandler(service *pairing.Service) gin.HandlerFunc { // redeemInviteHandler consumes the one-time code supplied by the invitee. func redeemInviteHandler(service *pairing.Service) gin.HandlerFunc { return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 400*1024) var request redeemInviteRequest if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) return } - status, err := service.RedeemInvite(request.Code, request.DeviceName) + status, err := service.RedeemInviteWithProfile(request.Code, request.DeviceName, *request.Profile) if err != nil { code := "invalid_invite" httpStatus := http.StatusConflict - if errors.Is(err, pairing.ErrExpiredInvite) { + if errors.Is(err, pairing.ErrInvalidProfile) { + code, httpStatus = "invalid_profile", http.StatusBadRequest + } else if errors.Is(err, pairing.ErrExpiredInvite) { code, httpStatus = "expired_invite", http.StatusGone } else if !errors.Is(err, pairing.ErrInvalidInvite) { code, httpStatus = "internal_error", http.StatusInternalServerError @@ -151,8 +161,9 @@ func recoverPairingHandler(service *pairing.Service) gin.HandlerFunc { // pairingSessionHandler validates either member's access token. func pairingSessionHandler(service *pairing.Service) gin.HandlerFunc { return func(c *gin.Context) { + c.Header("Cache-Control", "no-store") token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")) - session, err := service.Authenticate(token) + session, err := service.SessionWithProfiles(token) if err != nil { if errors.Is(err, pairing.ErrInvalidSessionToken) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_session_token"}) @@ -162,9 +173,11 @@ func pairingSessionHandler(service *pairing.Service) gin.HandlerFunc { return } c.JSON(http.StatusOK, pairingSessionResponse{ - PairID: session.PairID, - Role: session.Role, - Status: session.Status, + Profile: session.Profile, + Partner: session.Partner, + PairID: session.PairID, + Role: session.Role, + Status: session.Status, }) } } diff --git a/server/internal/httpapi/pairing_test.go b/server/internal/httpapi/pairing_test.go index 77ccac5..c721ae3 100644 --- a/server/internal/httpapi/pairing_test.go +++ b/server/internal/httpapi/pairing_test.go @@ -1,7 +1,11 @@ package httpapi import ( + "bytes" + "encoding/base64" "encoding/json" + "image" + "image/png" "log/slog" "net/http" "net/http/httptest" @@ -26,7 +30,7 @@ func TestPairingHTTPFlow(t *testing.T) { Port: "8080", }, slog.New(slog.NewTextHandler(testLogWriter{}, nil)), service) - createResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites", `{"server_url":"https://home.example.test"}`, "") + createResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites", profileRequest(t, map[string]string{"server_url": "https://home.example.test"}, "Ash"), "") if createResponse.Code != http.StatusCreated { t.Fatalf("create status = %d, want %d", createResponse.Code, http.StatusCreated) } @@ -50,7 +54,7 @@ func TestPairingHTTPFlow(t *testing.T) { t.Fatal(err) } code := inviteURL.Query().Get("code") - redeemResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites/redeem", `{"code":"`+code+`"}`, "") + redeemResponse := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites/redeem", profileRequest(t, map[string]string{"code": code}, "Fern"), "") if redeemResponse.Code != http.StatusOK { t.Fatalf("redeem status = %d, want %d", redeemResponse.Code, http.StatusOK) } @@ -90,6 +94,14 @@ func TestPairingHTTPFlow(t *testing.T) { if response.Code != http.StatusOK { t.Fatal("adding a device invalidated a member session") } + var session struct { + Profile pairing.Profile `json:"profile"` + Partner pairing.Profile `json:"partner"` + } + decodeJSON(t, response, &session) + if session.Profile.Nickname == "" || session.Partner.Nickname == "" || session.Profile.Nickname == session.Partner.Nickname { + t.Fatal("session did not restore both distinct member identities") + } } devicesResponse := performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/devices", "", "Bearer "+tablet.Token) var devices struct { @@ -122,6 +134,62 @@ func TestPairingHTTPFlow(t *testing.T) { } } +// profileRequest creates a real PNG fixture, avoiding a test-only validation bypass. +func profileRequest(t *testing.T, fields map[string]string, nickname string) string { + t.Helper() + var imageBytes bytes.Buffer + if err := png.Encode(&imageBytes, image.NewRGBA(image.Rect(0, 0, 8, 8))); err != nil { + t.Fatal(err) + } + body := map[string]any{"profile": pairing.Profile{Nickname: nickname, AvatarBase64: base64.StdEncoding.EncodeToString(imageBytes.Bytes())}} + for key, value := range fields { + body[key] = value + } + data, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestInvitationRequiresValidProfile(t *testing.T) { + service := pairing.NewService() + defer service.Close() + router := NewRouter(config.Config{Environment: "test"}, slog.New(slog.NewTextHandler(testLogWriter{}, nil)), service) + for _, body := range []string{ + `{"server_url":"https://home.example.test"}`, + `{"server_url":"https://home.example.test","profile":{"nickname":"Ash","avatar_base64":"garbage"}}`, + profileRequest(t, map[string]string{"server_url": "https://home.example.test"}, " "), + } { + response := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites", body, "") + if response.Code != http.StatusBadRequest { + t.Fatalf("invalid profile accepted: %d", response.Code) + } + } + create := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites", profileRequest(t, map[string]string{"server_url": "https://home.example.test"}, "Ash"), "") + if create.Code != http.StatusCreated { + t.Fatal("invalid requests reserved the instance") + } + var invite struct { + URL string `json:"invite_url"` + } + decodeJSON(t, create, &invite) + uri, _ := url.Parse(invite.URL) + code := uri.Query().Get("code") + response := performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites/redeem", `{"code":"`+code+`"}`, "") + if response.Code != http.StatusBadRequest { + t.Fatal("redemption without profile accepted") + } + response = performJSONRequest(t, router, http.MethodPost, "/api/v1/pairing/invites/redeem", profileRequest(t, map[string]string{"code": code}, "Fern"), "") + if response.Code != http.StatusOK { + t.Fatal("invalid redemption consumed the invitation") + } + response = performJSONRequest(t, router, http.MethodGet, "/api/v1/pairing/session", "", "") + if response.Code != http.StatusUnauthorized || strings.Contains(response.Body.String(), "avatar_base64") { + t.Fatal("profile leaked without credentials") + } +} + func performJSONRequest(t *testing.T, handler http.Handler, method, path, body, authorization string) *httptest.ResponseRecorder { t.Helper() request := httptest.NewRequest(method, path, strings.NewReader(body)) diff --git a/server/internal/pairing/models.go b/server/internal/pairing/models.go index d0623d6..3e683b3 100644 --- a/server/internal/pairing/models.go +++ b/server/internal/pairing/models.go @@ -90,6 +90,10 @@ func (readRecord) TableName() string { return "chat_reads" } func migrateSchema(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { for _, schema := range []string{ + `CREATE TABLE IF NOT EXISTS member_profiles ( + role TEXT PRIMARY KEY CHECK(role IN ('inviter','invitee')), + nickname TEXT NOT NULL, avatar_base64 TEXT NOT NULL + )`, `CREATE TABLE IF NOT EXISTS pairing_state ( id INTEGER PRIMARY KEY CHECK (id = 1), server_url TEXT NOT NULL, invite_code_hash BLOB NOT NULL, invite_expires_at INTEGER NOT NULL, diff --git a/server/internal/pairing/profiles.go b/server/internal/pairing/profiles.go new file mode 100644 index 0000000..9106ff2 --- /dev/null +++ b/server/internal/pairing/profiles.go @@ -0,0 +1,85 @@ +package pairing + +import ( + "bytes" + "encoding/base64" + "errors" + "image" + _ "image/jpeg" + "image/png" + "strings" + "unicode" + "unicode/utf8" +) + +var ErrInvalidProfile = errors.New("nickname or avatar is invalid") + +const maxAvatarBytes = 256 * 1024 + +// Profile is a member's shared identity, independent of devices and credentials. +type Profile struct { + Nickname string `json:"nickname"` + AvatarBase64 string `json:"avatar_base64"` +} + +// profileRecord stores the two members without altering legacy pairing columns. +type profileRecord struct { + Role string `gorm:"primaryKey"` + Nickname string + AvatarBase64 string +} + +// TableName identifies the additive member-identity table. +func (profileRecord) TableName() string { return "member_profiles" } + +// normalizeProfile validates bounded images and strips metadata before storage. +func normalizeProfile(profile Profile) (Profile, error) { + profile.Nickname = strings.TrimSpace(profile.Nickname) + if !utf8.ValidString(profile.Nickname) || utf8.RuneCountInString(profile.Nickname) < 1 || utf8.RuneCountInString(profile.Nickname) > 32 || strings.ContainsFunc(profile.Nickname, unicode.IsControl) { + return Profile{}, ErrInvalidProfile + } + if len(profile.AvatarBase64) > base64.StdEncoding.EncodedLen(maxAvatarBytes) { + return Profile{}, ErrInvalidProfile + } + data, err := base64.StdEncoding.Strict().DecodeString(profile.AvatarBase64) + if err != nil || len(data) == 0 || len(data) > maxAvatarBytes { + return Profile{}, ErrInvalidProfile + } + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || (format != "png" && format != "jpeg") || config.Width < 1 || config.Height < 1 || config.Width > 512 || config.Height > 512 { + return Profile{}, ErrInvalidProfile + } + avatar, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return Profile{}, ErrInvalidProfile + } + var encoded bytes.Buffer + if err := png.Encode(&encoded, avatar); err != nil || encoded.Len() > maxAvatarBytes { + return Profile{}, ErrInvalidProfile + } + profile.AvatarBase64 = base64.StdEncoding.EncodeToString(encoded.Bytes()) + return profile, nil +} + +// SessionWithProfiles returns shared profiles only to an authenticated device. +func (service *Service) SessionWithProfiles(token string) (Session, error) { + service.mu.Lock() + defer service.mu.Unlock() + session, err := service.authenticateDevice(token) + if err != nil { + return Session{}, err + } + var records []profileRecord + if err := service.db.Find(&records).Error; err != nil { + return Session{}, err + } + for _, record := range records { + profile := &Profile{Nickname: record.Nickname, AvatarBase64: record.AvatarBase64} + if record.Role == session.Role { + session.Profile = profile + } else if session.Status == "paired" { + session.Partner = profile + } + } + return session, nil +} diff --git a/server/internal/pairing/profiles_test.go b/server/internal/pairing/profiles_test.go new file mode 100644 index 0000000..49a1e20 --- /dev/null +++ b/server/internal/pairing/profiles_test.go @@ -0,0 +1,118 @@ +package pairing + +import ( + "bytes" + "encoding/base64" + "errors" + "image" + "image/png" + "net/url" + "path/filepath" + "strings" + "testing" + "time" +) + +func profileFixture(t *testing.T, name string) Profile { + t.Helper() + var buffer bytes.Buffer + if err := png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, 16, 16))); err != nil { + t.Fatal(err) + } + return Profile{Nickname: name, AvatarBase64: base64.StdEncoding.EncodeToString(buffer.Bytes())} +} + +func TestProfilesSurviveRestartRecoveryAndDeviceLogin(t *testing.T) { + path := filepath.Join(t.TempDir(), "pair.db") + service, err := OpenService(path) + if err != nil { + t.Fatal(err) + } + defer func() { service.Close() }() + inviter := profileFixture(t, " 小灰 🐾 ") + invite, err := service.CreateInviteWithProfile("https://home.example.test", "Phone", inviter) + if err != nil { + t.Fatal(err) + } + uri, _ := url.Parse(invite.URL) + code := uri.Query().Get("code") + if _, err := service.RedeemInviteWithProfile(code, "Tablet", Profile{}); !errors.Is(err, ErrInvalidProfile) { + t.Fatal("invalid profile consumed invitation") + } + partner := profileFixture(t, "小绿") + paired, err := service.RedeemInviteWithProfile(code, "Tablet", partner) + if err != nil { + t.Fatal(err) + } + if err := service.Close(); err != nil { + t.Fatal(err) + } + service, err = OpenService(path) + if err != nil { + t.Fatal(err) + } + assertProfiles := func(token, own, other string) { + t.Helper() + session, err := service.SessionWithProfiles(token) + if err != nil || session.Profile == nil || session.Partner == nil || session.Profile.Nickname != own || session.Partner.Nickname != other || session.Profile.AvatarBase64 == "" { + t.Fatalf("profiles not restored: %+v, %v", session, err) + } + } + assertProfiles(invite.InviterToken, "小灰 🐾", "小绿") + assertProfiles(paired.InviteeToken, "小绿", "小灰 🐾") + login, err := service.CreateDeviceLoginCode(invite.InviterToken) + if err != nil { + t.Fatal(err) + } + device, err := service.RedeemDeviceLoginCode(login.Code, "Laptop") + if err != nil { + t.Fatal(err) + } + assertProfiles(device.AccessToken, "小灰 🐾", "小绿") + recovered, err := service.Recover(invite.RecoveryCode) + if err != nil { + t.Fatal(err) + } + assertProfiles(recovered.AccessToken, "小灰 🐾", "小绿") + assertProfiles(paired.InviteeToken, "小绿", "小灰 🐾") +} + +func TestProfileValidationAndExpiredInvitationReplacement(t *testing.T) { + valid := profileFixture(t, "Ash") + for _, name := range []string{"", " ", strings.Repeat("灰", 33), "Ash\nGrey", "Ash\u0085Grey"} { + profile := valid + profile.Nickname = name + if _, err := normalizeProfile(profile); !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("invalid nickname accepted: %q", name) + } + } + for _, avatar := range []string{"", "not-base64", base64.StdEncoding.EncodeToString([]byte("not an image")), strings.Repeat("A", base64.StdEncoding.EncodedLen(maxAvatarBytes)+4)} { + profile := valid + profile.AvatarBase64 = avatar + if _, err := normalizeProfile(profile); !errors.Is(err, ErrInvalidProfile) { + t.Fatal("invalid avatar accepted") + } + } + var buffer bytes.Buffer + png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, 513, 1))) + profile := valid + profile.AvatarBase64 = base64.StdEncoding.EncodeToString(buffer.Bytes()) + if _, err := normalizeProfile(profile); !errors.Is(err, ErrInvalidProfile) { + t.Fatal("oversized dimensions accepted") + } + service := NewService() + defer service.Close() + invite, err := service.CreateInviteWithProfile("https://home.example.test", "Phone", valid) + if err != nil { + t.Fatal(err) + } + service.now = func() time.Time { return invite.ExpiresAt.Add(time.Second) } + replacement, err := service.CreateInviteWithProfile("https://home.example.test", "Phone", profileFixture(t, "Fern")) + if err != nil { + t.Fatal(err) + } + session, err := service.SessionWithProfiles(replacement.InviterToken) + if err != nil || session.Profile.Nickname != "Fern" || session.Partner != nil { + t.Fatal("expired invitation retained old identity") + } +} diff --git a/server/internal/pairing/service.go b/server/internal/pairing/service.go index 787da33..78258e1 100644 --- a/server/internal/pairing/service.go +++ b/server/internal/pairing/service.go @@ -64,6 +64,8 @@ type Session struct { Role string Status string DeviceID string + Profile *Profile + Partner *Profile } // Service stores the single couple relationship for one private instance. @@ -130,6 +132,20 @@ func (service *Service) Close() error { // CreateInvite creates one expiring invitation for the instance. func (service *Service) CreateInvite(serverURL string, deviceNames ...string) (Invite, error) { + return service.createInvite(serverURL, nil, deviceNames...) +} + +// CreateInviteWithProfile saves the inviter's identity in the invitation transaction. +func (service *Service) CreateInviteWithProfile(serverURL, deviceName string, profile Profile) (Invite, error) { + profile, err := normalizeProfile(profile) + if err != nil { + return Invite{}, err + } + return service.createInvite(serverURL, &profile, deviceName) +} + +// createInvite also supports profile-free legacy service callers. +func (service *Service) createInvite(serverURL string, profile *Profile, deviceNames ...string) (Invite, error) { baseURL, err := normalizeServerURL(serverURL) if err != nil { return Invite{}, err @@ -177,6 +193,14 @@ func (service *Service) CreateInvite(serverURL string, deviceNames ...string) (I return fmt.Errorf("save pairing invitation: %w", err) } // An expired, unpaired invitation starts a new identity and device set. + if err := tx.Where("1 = 1").Delete(&profileRecord{}).Error; err != nil { + return err + } + if profile != nil { + if err := tx.Create(&profileRecord{Role: "inviter", Nickname: profile.Nickname, AvatarBase64: profile.AvatarBase64}).Error; err != nil { + return err + } + } if err := tx.Where("1 = 1").Delete(&deviceCodeRecord{}).Error; err != nil { return err } @@ -200,6 +224,20 @@ func (service *Service) CreateInvite(serverURL string, deviceNames ...string) (I // RedeemInvite atomically consumes an invite and creates the instance pair. func (service *Service) RedeemInvite(code string, deviceNames ...string) (PairingStatus, error) { + return service.redeemInvite(code, nil, deviceNames...) +} + +// RedeemInviteWithProfile saves the invitee's identity while consuming the code. +func (service *Service) RedeemInviteWithProfile(code, deviceName string, profile Profile) (PairingStatus, error) { + profile, err := normalizeProfile(profile) + if err != nil { + return PairingStatus{}, err + } + return service.redeemInvite(code, &profile, deviceName) +} + +// redeemInvite keeps identity creation and pairing completion atomic. +func (service *Service) redeemInvite(code string, profile *Profile, deviceNames ...string) (PairingStatus, error) { service.mu.Lock() defer service.mu.Unlock() @@ -243,6 +281,11 @@ func (service *Service) RedeemInvite(code string, deviceNames ...string) (Pairin if result.RowsAffected != 1 { return ErrInvalidInvite } + if profile != nil { + if err := tx.Create(&profileRecord{Role: "invitee", Nickname: profile.Nickname, AvatarBase64: profile.AvatarBase64}).Error; err != nil { + return err + } + } _, err := service.insertDevice(tx, "invitee", inviteeToken, deviceName(deviceNames)) return err }) diff --git a/test/chat_test.dart b/test/chat_test.dart index 0a5af2f..68a7fd8 100644 --- a/test/chat_test.dart +++ b/test/chat_test.dart @@ -12,6 +12,7 @@ import 'package:pawmate/features/chat/chat_page.dart'; import 'package:pawmate/features/pairing/pairing_credentials.dart'; import 'package:pawmate/features/pairing/paired_home_page.dart'; import 'package:pawmate/features/pairing/pairing_api.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; const credentials = SavedPairingCredentials( serverURL: 'https://home.example.test', @@ -216,6 +217,8 @@ void main() { await chat.synchronize(); Widget page(bool active) => MaterialApp( theme: buildPawmateTheme(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: ChatPage(controller: chat, active: active), ), @@ -246,6 +249,8 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildPawmateTheme(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) => MediaQuery( data: MediaQuery.of( context, @@ -293,6 +298,8 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildPawmateTheme(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: PairedHomePage( credentials: credentials, session: const PairingSession( diff --git a/test/login_methods_test.dart b/test/login_methods_test.dart index 4ff72b4..eea77b7 100644 --- a/test/login_methods_test.dart +++ b/test/login_methods_test.dart @@ -118,10 +118,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Review invitation')); await tester.pumpAndSettle(); - expect( - find.text('Paste the complete pawmate://pair invitation link.'), - findsOneWidget, - ); + expect(find.text('This invitation link is not valid.'), findsOneWidget); await tester.enterText(find.byType(TextFormField), invitation); await tester.tap(find.text('Review invitation')); await tester.pumpAndSettle(); @@ -177,6 +174,9 @@ void main() { expect(node.getSemanticsData().hasAction(ui.SemanticsAction.tap), isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); + // The language control now precedes the access cards in focus order. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pumpAndSettle(); expect(find.text('Server URL or domain'), findsOneWidget); diff --git a/test/member_profile_test.dart b/test/member_profile_test.dart new file mode 100644 index 0000000..3044e81 --- /dev/null +++ b/test/member_profile_test.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pawmate/design/components/crayon_avatar_button.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; +import 'package:pawmate/features/pairing/member_profile.dart'; +import 'package:pawmate/features/pairing/pairing_api.dart'; +import 'package:pawmate/features/pairing/widgets/member_profile_editor.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; + +Widget localized(Widget child, {Locale locale = const Locale('en')}) => + MaterialApp( + theme: buildPawmateTheme(), + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: SingleChildScrollView(child: child)), + ); + +Future imageFixture() async { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawColor(Colors.pink, BlendMode.src); + final picture = recorder.endRecording(); + final image = await picture.toImage(64, 32); + try { + final data = await image.toByteData(format: ui.ImageByteFormat.png); + return data!.buffer.asUint8List(); + } finally { + image.dispose(); + picture.dispose(); + } +} + +void main() { + testWidgets( + 'requires avatar and nickname and retains an image when selection is cancelled', + (tester) async { + final bytes = (await tester.runAsync(imageFixture))!; + final form = GlobalKey(); + final editor = GlobalKey(); + var cancel = false; + await tester.pumpWidget( + localized( + Form( + key: form, + child: MemberProfileEditor( + key: editor, + selectImage: () async => cancel + ? null + : XFile.fromData( + bytes, + name: 'portrait.png', + mimeType: 'image/png', + ), + ), + ), + ), + ); + expect(form.currentState!.validate(), isFalse); + await tester.pump(); + expect(find.text('Choose an avatar before continuing.'), findsOneWidget); + await tester.enterText(find.byType(TextFormField), ' 小灰 🐾 '); + await tester.runAsync(() async { + await tester.tap(find.byType(CrayonAvatarButton)); + await Future.delayed(const Duration(milliseconds: 150)); + }); + await tester.pumpAndSettle(); + expect(form.currentState!.validate(), isTrue); + final profile = editor.currentState!.profile!; + expect(profile.nickname, '小灰 🐾'); + final png = ByteData.sublistView(profile.avatar); + expect(png.getUint32(16), 256); + expect(png.getUint32(20), 256); + expect(profile.avatar.length, lessThanOrEqualTo(256 * 1024)); + cancel = true; + await tester.tap(find.byType(CrayonAvatarButton)); + await tester.pumpAndSettle(); + expect(editor.currentState!.profile!.avatar, profile.avatar); + await tester.enterText(find.byType(TextFormField), 'a' * 33); + expect(form.currentState!.validate(), isFalse); + }, + ); + + testWidgets( + 'invalid image displays a translated error without creating a profile', + (tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + localized( + Form( + child: MemberProfileEditor( + key: key, + selectImage: () async => XFile.fromData( + Uint8List.fromList([1, 2, 3]), + name: 'broken.png', + ), + ), + ), + locale: const Locale('zh'), + ), + ); + await tester.runAsync(() async { + await tester.tap(find.byType(CrayonAvatarButton)); + await Future.delayed(const Duration(milliseconds: 150)); + }); + await tester.pumpAndSettle(); + expect(key.currentState!.profile, isNull); + expect(find.text('无法读取这张图片,请选择小于 10 MB 的 PNG 或 JPEG 图片'), findsOneWidget); + }, + ); + + test( + 'both invitations send member identity and sessions restore both profiles', + () async { + final profile = MemberProfile( + nickname: 'Ash', + avatar: Uint8List.fromList([1, 2, 3]), + ); + final paths = []; + final api = PairingApi( + MockClient((request) async { + paths.add(request.url.path); + if (request.method == 'GET') { + expect(request.headers['authorization'], 'Bearer token'); + return http.Response( + jsonEncode({ + 'role': 'inviter', + 'status': 'paired', + 'profile': profile.toJson(), + 'partner': profile.toJson(), + }), + 200, + ); + } + final body = jsonDecode(request.body) as Map; + expect(body['profile'], profile.toJson()); + if (request.url.path.endsWith('redeem')) { + return http.Response( + '{"status":"paired","pair_id":"pair","invitee_token":"token","recovery_code":"recovery"}', + 200, + ); + } + return http.Response( + '{"expires_at":"2026-10-05T00:00:00Z","invite_url":"pawmate://pair","inviter_token":"token","recovery_code":"recovery"}', + 201, + ); + }), + ); + addTearDown(api.close); + await api.createInvite('https://home.example.test', profile: profile); + await api.redeemInvite( + 'https://home.example.test', + 'code', + profile: profile, + ); + final session = await api.validateSession( + 'https://home.example.test', + 'token', + ); + expect(session.profile!.nickname, 'Ash'); + expect(session.partner!.avatar, profile.avatar); + expect(paths.length, 3); + }, + ); +} diff --git a/test/profile_preview_test.dart b/test/profile_preview_test.dart new file mode 100644 index 0000000..acdbe02 --- /dev/null +++ b/test/profile_preview_test.dart @@ -0,0 +1,99 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pawmate/design/theme/pawmate_theme.dart'; +import 'package:pawmate/features/pairing/invitee_accept_page.dart'; +import 'package:pawmate/features/pairing/inviter_setup_page.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; + +/// Opt-in native-widget previews using a real reading font instead of Ahem. +void main() { + testWidgets( + 'capture member identity onboarding in both languages', + (tester) async { + FlutterSecureStorage.setMockInitialValues({}); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.runAsync(() async { + final font = FontLoader('PawmatePreview') + ..addFont( + Future.value( + ByteData.sublistView( + await File( + Platform.environment['PAWMATE_PREVIEW_FONT']!, + ).readAsBytes(), + ), + ), + ); + await font.load(); + final monospace = FontLoader('monospace') + ..addFont( + Future.value( + ByteData.sublistView( + await File( + Platform.environment['PAWMATE_PREVIEW_MONO_FONT'] ?? + Platform.environment['PAWMATE_PREVIEW_FONT']!, + ).readAsBytes(), + ), + ), + ); + await monospace.load(); + final icons = FontLoader('MaterialIcons') + ..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf')); + await icons.load(); + }); + final theme = buildPawmateTheme(); + for (final language in ['en', 'zh']) { + for (final entry in { + 'inviter': const InviterSetupPage(), + 'accept': InviteeAcceptPage( + inviteUri: Uri.parse( + 'pawmate://pair?server=https%3A%2F%2Fhome.example.test&code=preview', + ), + ), + }.entries) { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + debugShowCheckedModeBanner: false, + locale: Locale(language), + theme: theme.copyWith( + textTheme: theme.textTheme.apply(fontFamily: 'PawmatePreview'), + ), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: RepaintBoundary(key: key, child: entry.value), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.runAsync(() async { + final image = + await (key.currentContext!.findRenderObject()! + as RenderRepaintBoundary) + .toImage(); + try { + final data = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + await File( + 'books/src/images/profile-${entry.key}-$language.png', + ).writeAsBytes(data!.buffer.asUint8List()); + } finally { + image.dispose(); + } + }); + await tester.pumpWidget(const SizedBox()); + } + } + }, + skip: !const bool.fromEnvironment('PAWMATE_CAPTURE_PREVIEWS'), + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 3829f96..99a2333 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:pawmate/main.dart'; +import 'package:pawmate/l10n/generated/app_localizations.dart'; import 'package:pawmate/features/pairing/device_login_page.dart'; import 'package:pawmate/design/theme/pawmate_theme.dart'; @@ -43,6 +44,8 @@ void main() { await tester.pumpWidget( MaterialApp( theme: buildPawmateTheme(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) => MediaQuery( data: MediaQuery.of( context, diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index d40a0c7..a44b858 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 5bd1707..7344781 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + file_selector_windows flutter_secure_storage_windows ) From f31b3f9341b229342688d2c64933bb2a39fdd779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 5 Oct 2026 22:41:13 +0800 Subject: [PATCH 11/16] ci(release): publish weekly client and server snapshots Add a scheduled and manually triggered workflow that runs CI, builds platform-specific Flutter clients and Gin server packages from the same commit, and publishes checksum-verified draft prereleases. Document supported artifacts, installation caveats, and release behavior in the README and project book. --- .github/weekly-release-notes.md | 25 +++ .github/workflows/ci.yml | 7 + .github/workflows/weekly-release.yml | 263 +++++++++++++++++++++++++ README.md | 8 + books/src/SUMMARY.md | 1 + books/src/reference/weekly-releases.md | 83 ++++++++ 6 files changed, 387 insertions(+) create mode 100644 .github/weekly-release-notes.md create mode 100644 .github/workflows/weekly-release.yml create mode 100644 books/src/reference/weekly-releases.md 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..e7c9a91 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: Optional SDK pin for release checks + type: string + default: '' permissions: contents: read @@ -18,6 +24,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version: ${{ inputs.flutter-version }} 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/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/books/src/SUMMARY.md b/books/src/SUMMARY.md index 1377647..2bba8c0 100644 --- a/books/src/SUMMARY.md +++ b/books/src/SUMMARY.md @@ -5,3 +5,4 @@ - [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/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. From cc14f7669ea020548209f84df8ce3317c3ec8938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 5 Oct 2026 23:12:43 +0800 Subject: [PATCH 12/16] fix(ci): pin flutter SDK for reproducible formatting checks --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7c9a91..c77a7e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,9 @@ on: workflow_call: inputs: flutter-version: - description: Optional SDK pin for release checks + description: Flutter SDK version used for CI and release checks type: string - default: '' + default: '3.44.8' permissions: contents: read @@ -24,7 +24,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: ${{ inputs.flutter-version }} + flutter-version: ${{ inputs.flutter-version || '3.44.8' }} channel: stable cache: true From 0899efd17ff91ea80355d3d7907c909689171ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 5 Oct 2026 23:51:15 +0800 Subject: [PATCH 13/16] fix(api): preserve server path prefixes in requests Resolve pairing, device, and chat endpoints relative to the configured instance URL so deployments hosted under paths such as `/pawmate` keep that prefix. --- lib/features/chat/chat_api.dart | 13 +++++--- lib/features/pairing/pairing_api.dart | 45 ++++++++++++++++++--------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/lib/features/chat/chat_api.dart b/lib/features/chat/chat_api.dart index 1a69682..c8c4f76 100644 --- a/lib/features/chat/chat_api.dart +++ b/lib/features/chat/chat_api.dart @@ -75,8 +75,10 @@ class ChatApi { /// Retrieves recent history, incremental messages, or an older history page. Future messages({int afterID = 0, int beforeID = 0}) async { - final uri = PairingApi.parseServerURL(credentials.serverURL) - .resolve('/api/v1/chat/messages') + final uri = PairingApi.resolveEndpoint( + PairingApi.parseServerURL(credentials.serverURL), + '/api/v1/chat/messages', + ) .replace( queryParameters: { if (afterID > 0) 'after_id': '$afterID', @@ -112,9 +114,10 @@ class ChatApi { ) async { final response = await _client .post( - PairingApi.parseServerURL( - credentials.serverURL, - ).resolve('/api/v1/chat$path'), + PairingApi.resolveEndpoint( + PairingApi.parseServerURL(credentials.serverURL), + '/api/v1/chat$path', + ), headers: _headers, body: jsonEncode(body), ) diff --git a/lib/features/pairing/pairing_api.dart b/lib/features/pairing/pairing_api.dart index 5419dee..04a92b3 100644 --- a/lib/features/pairing/pairing_api.dart +++ b/lib/features/pairing/pairing_api.dart @@ -193,7 +193,7 @@ class PairingApi { final serverURL = _parseServerURL(rawServerURL); final response = await _client .post( - serverURL.resolve('/api/v1/pairing/invites'), + resolveEndpoint(serverURL, '/api/v1/pairing/invites'), headers: const {'content-type': 'application/json'}, body: jsonEncode({ 'server_url': serverURL.toString(), @@ -222,7 +222,7 @@ class PairingApi { final serverURL = _parseServerURL(rawServerURL); final response = await _client .get( - serverURL.resolve('/api/v1/pairing/invites/status'), + resolveEndpoint(serverURL, '/api/v1/pairing/invites/status'), headers: {'authorization': 'Bearer $inviterToken'}, ) .timeout(const Duration(seconds: 10)); @@ -246,7 +246,7 @@ class PairingApi { final serverURL = _parseServerURL(rawServerURL); final response = await _client .get( - serverURL.resolve('/api/v1/pairing/session'), + resolveEndpoint(serverURL, '/api/v1/pairing/session'), headers: {'authorization': 'Bearer $accessToken'}, ) .timeout(const Duration(seconds: 10)); @@ -271,7 +271,7 @@ class PairingApi { final serverURL = _parseServerURL(rawServerURL); final response = await _client .post( - serverURL.resolve('/api/v1/pairing/invites/redeem'), + resolveEndpoint(serverURL, '/api/v1/pairing/invites/redeem'), headers: const {'content-type': 'application/json'}, body: jsonEncode({ 'code': code, @@ -300,7 +300,7 @@ class PairingApi { final serverURL = _parseServerURL(rawServerURL); final response = await _client .post( - serverURL.resolve('/api/v1/pairing/recover'), + resolveEndpoint(serverURL, '/api/v1/pairing/recover'), headers: const {'content-type': 'application/json'}, body: jsonEncode({ 'recovery_code': recoveryCode.trim(), @@ -324,7 +324,10 @@ class PairingApi { Future> getDevices(String server, String token) async { final response = await _client .get( - _parseServerURL(server).resolve('/api/v1/pairing/devices'), + resolveEndpoint( + _parseServerURL(server), + '/api/v1/pairing/devices', + ), headers: {'authorization': 'Bearer $token'}, ) .timeout(const Duration(seconds: 10)); @@ -341,9 +344,10 @@ class PairingApi { ) async { final response = await _client .post( - _parseServerURL( - server, - ).resolve('/api/v1/pairing/devices/login-codes'), + resolveEndpoint( + _parseServerURL(server), + '/api/v1/pairing/devices/login-codes', + ), headers: {'authorization': 'Bearer $token'}, ) .timeout(const Duration(seconds: 10)); @@ -362,9 +366,10 @@ class PairingApi { ) async { final response = await _client .post( - _parseServerURL( - server, - ).resolve('/api/v1/pairing/devices/login-codes/redeem'), + resolveEndpoint( + _parseServerURL(server), + '/api/v1/pairing/devices/login-codes/redeem', + ), headers: const {'content-type': 'application/json'}, body: jsonEncode({ 'code': code.trim(), @@ -384,9 +389,10 @@ class PairingApi { Future revokeDevice(String server, String token, String id) async { final response = await _client .delete( - _parseServerURL( - server, - ).resolve('/api/v1/pairing/devices/${Uri.encodeComponent(id)}'), + resolveEndpoint( + _parseServerURL(server), + '/api/v1/pairing/devices/${Uri.encodeComponent(id)}', + ), headers: {'authorization': 'Bearer $token'}, ) .timeout(const Duration(seconds: 10)); @@ -429,6 +435,15 @@ class PairingApi { static Uri parseServerURL(String rawServerURL) => _parseServerURL(rawServerURL); + /// Resolves an API path under the configured instance prefix. + static Uri resolveEndpoint(Uri serverURL, String endpointPath) { + final basePath = serverURL.path.endsWith('/') + ? serverURL.path + : '${serverURL.path}/'; + final relativePath = endpointPath.replaceFirst(RegExp(r'^/+'), ''); + return serverURL.replace(path: basePath).resolve(relativePath); + } + /// Extracts a safe error message from a JSON API response. String _messageFor(http.Response response) { try { From 87763927840ba971ecace0cb84b0c6069e0b7af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Mon, 5 Oct 2026 23:57:08 +0800 Subject: [PATCH 14/16] fix(ci): fix dart format --- lib/features/chat/chat_api.dart | 10 +++++----- lib/features/pairing/pairing_api.dart | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/features/chat/chat_api.dart b/lib/features/chat/chat_api.dart index c8c4f76..eff5239 100644 --- a/lib/features/chat/chat_api.dart +++ b/lib/features/chat/chat_api.dart @@ -75,11 +75,11 @@ class ChatApi { /// 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( + 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', diff --git a/lib/features/pairing/pairing_api.dart b/lib/features/pairing/pairing_api.dart index 04a92b3..48509c3 100644 --- a/lib/features/pairing/pairing_api.dart +++ b/lib/features/pairing/pairing_api.dart @@ -324,10 +324,7 @@ class PairingApi { Future> getDevices(String server, String token) async { final response = await _client .get( - resolveEndpoint( - _parseServerURL(server), - '/api/v1/pairing/devices', - ), + resolveEndpoint(_parseServerURL(server), '/api/v1/pairing/devices'), headers: {'authorization': 'Bearer $token'}, ) .timeout(const Duration(seconds: 10)); From 6ec0e394e6439672e7cf8c9615739d5f046b4ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 6 Oct 2026 00:11:24 +0800 Subject: [PATCH 15/16] fix(security): require HTTPS for release server connections Reject HTTP instance URLs in release builds and disable Android cleartext traffic by default. Allow HTTP only in the debug manifest, and update localized errors, tests, and server documentation to reflect the policy. --- android/app/src/debug/AndroidManifest.xml | 1 + android/app/src/main/AndroidManifest.xml | 3 +-- lib/features/pairing/device_login_page.dart | 12 +++++++++--- lib/features/pairing/invitee_accept_page.dart | 8 +++++--- lib/features/pairing/inviter_setup_page.dart | 4 +++- lib/features/pairing/pairing_api.dart | 5 ++++- lib/features/pairing/pairing_recovery_page.dart | 1 + lib/features/pairing/pairing_session_gate.dart | 3 +++ lib/l10n/app_en.arb | 2 +- lib/l10n/app_zh.arb | 2 +- lib/l10n/generated/app_localizations.dart | 2 +- lib/l10n/generated/app_localizations_en.dart | 2 +- lib/l10n/generated/app_localizations_zh.dart | 2 +- server/README.md | 8 ++++---- test/login_methods_test.dart | 2 +- test/server_url_test.dart | 2 +- 16 files changed, 38 insertions(+), 21 deletions(-) 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 fcb62e7..ee3662a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,12 @@ - + android:usesCleartextTraffic="false"> { } on PairingApiException catch (error) { if (mounted) { setState( - () => _error = error.message == 'invalid_device_code' - ? AppLocalizations.of(context)!.invalidDeviceCode - : AppLocalizations.of(context)!.requestFailed, + () => _error = switch (error.message) { + 'invalid_device_code' => AppLocalizations.of( + context, + )!.invalidDeviceCode, + 'https_required' => AppLocalizations.of( + context, + )!.invalidServerAddress, + _ => AppLocalizations.of(context)!.requestFailed, + }, ); } } on Object { diff --git a/lib/features/pairing/invitee_accept_page.dart b/lib/features/pairing/invitee_accept_page.dart index 88971cb..8fde532 100644 --- a/lib/features/pairing/invitee_accept_page.dart +++ b/lib/features/pairing/invitee_accept_page.dart @@ -100,9 +100,11 @@ class _InviteeAcceptPageState extends State { } on PairingApiException catch (error) { if (mounted) { setState( - () => _errorMessage = error.message == 'invalid_profile' - ? l10n.profileRejected - : l10n.requestFailed, + () => _errorMessage = switch (error.message) { + 'invalid_profile' => l10n.profileRejected, + 'https_required' => l10n.invalidServerAddress, + _ => l10n.requestFailed, + }, ); } } on Object { diff --git a/lib/features/pairing/inviter_setup_page.dart b/lib/features/pairing/inviter_setup_page.dart index 371eac3..0120ab1 100644 --- a/lib/features/pairing/inviter_setup_page.dart +++ b/lib/features/pairing/inviter_setup_page.dart @@ -107,7 +107,9 @@ class _InviterSetupPageState extends State { if (mounted) { final l10n = AppLocalizations.of(context)!; setState( - () => _errorMessage = error.message.startsWith('Use an HTTP') + () => _errorMessage = + error.message == 'https_required' || + error.message.startsWith('Use an HTTP') ? l10n.invalidServerAddress : error.message == 'invalid_profile' ? l10n.profileRejected diff --git a/lib/features/pairing/pairing_api.dart b/lib/features/pairing/pairing_api.dart index 48509c3..eda925c 100644 --- a/lib/features/pairing/pairing_api.dart +++ b/lib/features/pairing/pairing_api.dart @@ -412,9 +412,12 @@ class PairingApi { return jsonDecode(response.body) as Map; } - /// Validates and normalizes HTTP or HTTPS instance URLs in every build mode. + /// Validates URLs, requiring HTTPS in release builds. static Uri _parseServerURL(String rawServerURL) { final uri = Uri.tryParse(rawServerURL.trim()); + if (kReleaseMode && uri?.scheme == 'http') { + throw const PairingApiException('https_required'); + } if (uri == null || uri.host.isEmpty || (uri.scheme != 'http' && uri.scheme != 'https') || diff --git a/lib/features/pairing/pairing_recovery_page.dart b/lib/features/pairing/pairing_recovery_page.dart index f8c5269..667ffac 100644 --- a/lib/features/pairing/pairing_recovery_page.dart +++ b/lib/features/pairing/pairing_recovery_page.dart @@ -109,6 +109,7 @@ class _PairingRecoveryPageState extends State { return switch (error.message) { 'invalid_recovery_code' => l10n.invalidRecoveryCode, 'not_paired' => l10n.notPaired, + 'https_required' => l10n.invalidServerAddress, _ => l10n.requestFailed, }; } diff --git a/lib/features/pairing/pairing_session_gate.dart b/lib/features/pairing/pairing_session_gate.dart index b5e7e51..c0f6848 100644 --- a/lib/features/pairing/pairing_session_gate.dart +++ b/lib/features/pairing/pairing_session_gate.dart @@ -113,6 +113,9 @@ class _PairingSessionGateState extends State { } String _messageFor(PairingApiException error) { + if (error.message == 'https_required') { + return AppLocalizations.of(context)!.invalidServerAddress; + } if (error.statusCode == null) { return AppLocalizations.of(context)!.serverAccessError; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9e0a050..4713b97 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -148,7 +148,7 @@ "serverAccessError": "Could not reach your Pawmate server. Check your connection and retry.", "sessionRestoreError": "Could not restore this session.", "requestFailed": "The server could not complete this request. Try again.", - "invalidServerAddress": "Enter a valid HTTP or HTTPS server address.", + "invalidServerAddress": "Enter a valid HTTPS server address. HTTP is available in debug builds only.", "invitationLinkHint": "pawmate://pair?server=…&code=…", "myDeviceName": "My device", "uploadAvatar": "Upload avatar", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 517beed..e943ce1 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -148,7 +148,7 @@ "serverAccessError": "无法连接到 Pawmate 服务器,请检查网络后重试", "sessionRestoreError": "无法恢复此登录状态", "requestFailed": "服务器暂时无法完成请求,请重试", - "invalidServerAddress": "请输入有效的 HTTP 或 HTTPS 服务器地址", + "invalidServerAddress": "请输入有效的 HTTPS 服务器地址。HTTP 仅在调试版本中可用。", "invitationLinkHint": "pawmate://pair?server=…&code=…", "myDeviceName": "我的设备", "uploadAvatar": "上传头像", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 317128b..2698a2c 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -935,7 +935,7 @@ abstract class AppLocalizations { /// No description provided for @invalidServerAddress. /// /// In en, this message translates to: - /// **'Enter a valid HTTP or HTTPS server address.'** + /// **'Enter a valid HTTPS server address. HTTP is available in debug builds only.'** String get invalidServerAddress; /// No description provided for @invitationLinkHint. diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 0b3a9cb..e5b0f1b 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -476,7 +476,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get invalidServerAddress => - 'Enter a valid HTTP or HTTPS server address.'; + 'Enter a valid HTTPS server address. HTTP is available in debug builds only.'; @override String get invitationLinkHint => 'pawmate://pair?server=…&code=…'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 77d6895..1efe325 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -449,7 +449,7 @@ class AppLocalizationsZh extends AppLocalizations { String get requestFailed => '服务器暂时无法完成请求,请重试'; @override - String get invalidServerAddress => '请输入有效的 HTTP 或 HTTPS 服务器地址'; + String get invalidServerAddress => '请输入有效的 HTTPS 服务器地址。HTTP 仅在调试版本中可用。'; @override String get invitationLinkHint => 'pawmate://pair?server=…&code=…'; diff --git a/server/README.md b/server/README.md index 5615ff7..8ab7b0a 100644 --- a/server/README.md +++ b/server/README.md @@ -146,10 +146,10 @@ For the Android emulator, configure the Flutter client with `http://10.0.2.2:8080`; `10.0.2.2` maps to the development machine's loopback interface from inside the emulator. -The Android client accepts HTTP instance URLs in debug and release builds. -HTTP does not provide application-layer transport encryption: use it only on -trusted private networks or through Tailscale. Prefer HTTPS for public instances. -Both participants must be able to reach the instance URL used in invitations. +Release builds require HTTPS instance URLs. Android permits cleartext HTTP only +in debug builds; use it for local development or trusted test networks. HTTP +does not provide transport encryption. Both participants must be able to reach +the HTTPS instance URL used in release invitations. ## Configuration diff --git a/test/login_methods_test.dart b/test/login_methods_test.dart index eea77b7..87e5d66 100644 --- a/test/login_methods_test.dart +++ b/test/login_methods_test.dart @@ -38,7 +38,7 @@ Widget landing({double textScale = 1}) => MaterialApp( void main() { setUp(() => FlutterSecureStorage.setMockInitialValues({})); - test('pasted invitations accept HTTP and HTTPS with encoded server URLs', () { + test('development builds accept HTTP and HTTPS invitation URLs', () { expect( InvitationLink.parse(' $invitation ').queryParameters['server'], 'http://100.64.0.1:8080', diff --git a/test/server_url_test.dart b/test/server_url_test.dart index 3f606cb..210745c 100644 --- a/test/server_url_test.dart +++ b/test/server_url_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pawmate/features/pairing/pairing_api.dart'; void main() { - test('HTTP private instance URLs are accepted without a build-mode gate', () { + test('development builds accept HTTP private instance URLs', () { expect( PairingApi.parseServerURL(' http://100.64.0.1:8080/ ').toString(), 'http://100.64.0.1:8080', From f4501441586ecf36fbb3cbe3d7e1e802c7af7f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?AshGrey=F0=9F=A5=95?= Date: Tue, 6 Oct 2026 00:18:46 +0800 Subject: [PATCH 16/16] fix(server): limit unauthenticated (#1) credential request bodies Cap recovery and device login code redemption payloads at 8 KiB to prevent oversized requests from consuming excessive memory. --- server/internal/httpapi/devices.go | 1 + server/internal/httpapi/pairing.go | 3 +++ 2 files changed, 4 insertions(+) diff --git a/server/internal/httpapi/devices.go b/server/internal/httpapi/devices.go index a919885..a59491a 100644 --- a/server/internal/httpapi/devices.go +++ b/server/internal/httpapi/devices.go @@ -72,6 +72,7 @@ func deviceLoginCodeHandler(service *pairing.Service) gin.HandlerFunc { // redeemDeviceLoginCodeHandler exchanges a code for an independent device token. func redeemDeviceLoginCodeHandler(service *pairing.Service) gin.HandlerFunc { return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxCredentialRequestBodyBytes) var request struct { Code string `json:"code" binding:"required"` DeviceName string `json:"device_name" binding:"max=80"` diff --git a/server/internal/httpapi/pairing.go b/server/internal/httpapi/pairing.go index 05013f3..426862d 100644 --- a/server/internal/httpapi/pairing.go +++ b/server/internal/httpapi/pairing.go @@ -10,6 +10,8 @@ import ( "pawmate/server/internal/pairing" ) +const maxCredentialRequestBodyBytes = 8 * 1024 + type createInviteRequest struct { Profile *pairing.Profile `json:"profile" binding:"required"` ServerURL string `json:"server_url" binding:"required"` @@ -130,6 +132,7 @@ func redeemInviteHandler(service *pairing.Service) gin.HandlerFunc { // recoverPairingHandler rotates a member's credentials using their recovery code. func recoverPairingHandler(service *pairing.Service) gin.HandlerFunc { return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxCredentialRequestBodyBytes) var request recoverPairingRequest if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})