feat: Add an example app and misc fixes (#81)
This commit is contained in:
parent
5a8f9c91a0
commit
de7e22d91a
78 changed files with 9138 additions and 61 deletions
45
example/offline_sync_demo/.gitignore
vendored
Normal file
45
example/offline_sync_demo/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
39
example/offline_sync_demo/.metadata
Normal file
39
example/offline_sync_demo/.metadata
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: linux
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: macos
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: web
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: windows
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
98
example/offline_sync_demo/README.md
Normal file
98
example/offline_sync_demo/README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Offline Sync Demo
|
||||
|
||||
Desktop Flutter demo for `serverpod_offline_sync`. It runs two independent local
|
||||
replicas of one user's data side by side, plus a view of the server's merged
|
||||
truth, so you can drive `A → server` and `B → server` independently and watch
|
||||
them converge.
|
||||
|
||||
## How the package is wired
|
||||
|
||||
If you are here to learn the package, read **`lib/src/offline_replica.dart`**
|
||||
first — it is the entire integration in ~60 lines, deliberately free of demo and
|
||||
UI code. Every replica follows the same four steps:
|
||||
|
||||
1. **Open a local database.** `client.createSession(path)` opens a Serverpod
|
||||
client database (a local SQLite file) and runs the client migrations.
|
||||
2. **Wrap it for CRDT sync.** `CrdtDatabaseSession.wraps(raw, syncTables: …,
|
||||
persistentUserId: …)`, then `crdtSession.db.initialize()` to establish this
|
||||
device's CRDT node so it can take part in sync.
|
||||
3. **Read and write generated models** against that session, e.g.
|
||||
`Person.db.insertRow(session, person)` or `session.db.find<Person>()`. (The
|
||||
demo also routes these through a generic `TableOps` registry so its
|
||||
metadata-driven UI can touch any table without a per-type switch — that part
|
||||
is demo-only; the `seed*` methods in `lib/src/demo_controller.dart` show the
|
||||
plain form.)
|
||||
4. **Synchronize through a Serverpod client.**
|
||||
`client.crdt.syncOnce(session, onMergeSuccess: …)` for a one-shot push/pull,
|
||||
or `client.crdt.syncContinuously(session, …)` to stream until cancelled.
|
||||
|
||||
Connectivity is just *which client you sync through*: the demo swaps
|
||||
`Client.httpClientOverride` between a real transport and a failing one to
|
||||
simulate going offline — the local database is untouched either way.
|
||||
|
||||
The rest of `lib/src` layers a UI on top: `demo_controller.dart` (a
|
||||
`ChangeNotifier` orchestrating users, replicas, and the server panel),
|
||||
`snapshot.dart` / `relationships.dart` / `table_ops.dart` (the metadata-driven
|
||||
view of the synced schema), `demo_view.dart` / `sheets.dart` (widgets), and
|
||||
`scenarios.dart` (guided scripts).
|
||||
|
||||
## What the UI shows
|
||||
|
||||
Each demo user owns two replicas (`<user>-a.db`, `<user>-b.db`) shown side by
|
||||
side. A replica is a local SQLite store with its own CRDT node id but the same
|
||||
sync scope; replicas share nothing locally and only converge by syncing through
|
||||
the server — like two phones on one account (see the "i" button in the toolbar).
|
||||
|
||||
The layout is a **scenario rail**, **Replica A**, **Replica B**, and a **Server**
|
||||
panel, with a global toolbar on top and a status line at the bottom.
|
||||
|
||||
**Toolbar**
|
||||
|
||||
- **Connectivity** toggles every client between a real and a failing HTTP
|
||||
transport (the offline switch).
|
||||
- **Show hidden rows** reveals locally hidden / soft-deleted (tombstoned) rows,
|
||||
shown struck-through and muted alongside the visible "user view".
|
||||
- **Refresh** reloads all three panels; **Reset all** wipes both replicas and the
|
||||
server scope at once.
|
||||
- The **i** button explains replica isolation.
|
||||
- The app bar carries a light/dark toggle and an avatar menu for switching
|
||||
between or creating demo users.
|
||||
|
||||
**Per replica**
|
||||
|
||||
- **Sync** runs one push/pull; **Stream** keeps syncing continuously until
|
||||
toggled off — drive A and B independently and watch them (and the Server
|
||||
panel) converge.
|
||||
- **Add data** creates a root row or attaches a child through a foreign key; the
|
||||
same menu also offers ready-made seeds (a city/person/address/town graph, a
|
||||
typed row, an FK chain). Each row carries inline **attach child** and
|
||||
**re-parent / detach** actions, and every edge shows its `onDelete` action
|
||||
(restrict / cascade / set null / …).
|
||||
- **Reset** wipes just that replica's local database (a fresh device); syncing
|
||||
afterwards re-pulls whatever the server still holds.
|
||||
- Click any row to open its full record, edit fields, or delete it.
|
||||
|
||||
**Server panel**
|
||||
|
||||
- Shows the server's merged truth for the current scope, fetched through the
|
||||
`demoDebug.fetchScopeSnapshot` endpoint (reads run in the caller's scope via
|
||||
`transactionForUser`). It refreshes after each sync and via its own refresh
|
||||
button.
|
||||
- **Reset server scope** hard-clears every row and CRDT metadata record in the
|
||||
scope; the seed buttons write a graph straight into the scope via
|
||||
`demoDebug.seedScope`, so you can reset a replica and sync to exercise the
|
||||
fetch-from-scratch flow.
|
||||
|
||||
**Scenario rail**
|
||||
|
||||
- Guided scripts (restrict, set-null, cascade, unique-conflict, …) that run a
|
||||
sequence of these same actions step by step. Every step is also doable by hand.
|
||||
|
||||
## Running
|
||||
|
||||
Simply run the serverpod server and the app will be started together:
|
||||
|
||||
```sh
|
||||
cd test/serverpod_offline_sync_test_server
|
||||
serverpod start
|
||||
```
|
||||
4
example/offline_sync_demo/analysis_options.yaml
Normal file
4
example/offline_sync_demo/analysis_options.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
104
example/offline_sync_demo/lib/main.dart
Normal file
104
example/offline_sync_demo/lib/main.dart
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_driver/driver_extension.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import 'src/demo_controller.dart';
|
||||
import 'src/demo_view.dart';
|
||||
|
||||
const _defaultServerUrl = String.fromEnvironment(
|
||||
'SERVERPOD_URL',
|
||||
defaultValue: 'http://localhost:8080/',
|
||||
);
|
||||
|
||||
const _lightDemoColorScheme = ColorSchemes.lightNeutral;
|
||||
const _darkDemoColorScheme = ColorSchemes.darkNeutral;
|
||||
|
||||
ThemeData _demoTheme(ColorScheme colorScheme) {
|
||||
return ThemeData(colorScheme: colorScheme, radius: 0.5);
|
||||
}
|
||||
|
||||
void main() {
|
||||
enableFlutterDriverExtension(enableTextEntryEmulation: false);
|
||||
runApp(const DemoRoot());
|
||||
}
|
||||
|
||||
/// Hosts the [ShadcnApp] and owns the light/dark theme selection.
|
||||
class DemoRoot extends StatefulWidget {
|
||||
const DemoRoot({super.key});
|
||||
|
||||
@override
|
||||
State<DemoRoot> createState() => _DemoRootState();
|
||||
}
|
||||
|
||||
class _DemoRootState extends State<DemoRoot> {
|
||||
bool _isDark = true;
|
||||
|
||||
void _toggleTheme() => setState(() => _isDark = !_isDark);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ShadcnApp(
|
||||
title: 'Offline Sync Demo',
|
||||
theme: _demoTheme(_lightDemoColorScheme),
|
||||
darkTheme: _demoTheme(_darkDemoColorScheme),
|
||||
themeMode: _isDark ? ThemeMode.dark : ThemeMode.light,
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: DemoHome(isDark: _isDark, onToggleTheme: _toggleTheme),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The main screen: builds the controller and the dashboard scaffold.
|
||||
class DemoHome extends StatefulWidget {
|
||||
const DemoHome({
|
||||
super.key,
|
||||
required this.isDark,
|
||||
required this.onToggleTheme,
|
||||
});
|
||||
|
||||
final bool isDark;
|
||||
final VoidCallback onToggleTheme;
|
||||
|
||||
@override
|
||||
State<DemoHome> createState() => _DemoHomeState();
|
||||
}
|
||||
|
||||
class _DemoHomeState extends State<DemoHome> {
|
||||
late final DemoController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = DemoController(serverUrl: _defaultServerUrl);
|
||||
unawaited(controller.initialize());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
title: const Text('Serverpod Offline Sync Demo'),
|
||||
subtitle: const Text(_defaultServerUrl),
|
||||
trailing: [
|
||||
ThemeToggleButton(
|
||||
isDark: widget.isDark,
|
||||
onToggle: widget.onToggleTheme,
|
||||
),
|
||||
const Gap(8),
|
||||
UserAvatarMenu(controller: controller),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
child: DemoDashboard(controller: controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
1347
example/offline_sync_demo/lib/src/demo_controller.dart
Normal file
1347
example/offline_sync_demo/lib/src/demo_controller.dart
Normal file
File diff suppressed because it is too large
Load diff
1388
example/offline_sync_demo/lib/src/demo_view.dart
Normal file
1388
example/offline_sync_demo/lib/src/demo_view.dart
Normal file
File diff suppressed because it is too large
Load diff
121
example/offline_sync_demo/lib/src/models.dart
Normal file
121
example/offline_sync_demo/lib/src/models.dart
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import 'package:http/http.dart' as http;
|
||||
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart'
|
||||
as protocol;
|
||||
import 'package:uuid/uuid.dart' as uuid;
|
||||
|
||||
/// One of the two independent local replicas held side by side.
|
||||
enum ReplicaSlot {
|
||||
a('Replica A'),
|
||||
b('Replica B');
|
||||
|
||||
const ReplicaSlot(this.label);
|
||||
|
||||
/// Human readable name shown in the UI.
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// A demo user backed by a deterministic auth-user id.
|
||||
class DemoUser {
|
||||
DemoUser({
|
||||
required this.username,
|
||||
required this.authUserId,
|
||||
required this.token,
|
||||
});
|
||||
|
||||
final String username;
|
||||
protocol.UuidValue authUserId;
|
||||
String token;
|
||||
|
||||
/// Up to two uppercase initials for the avatar.
|
||||
String get initials {
|
||||
final trimmed = username.trim();
|
||||
if (trimmed.isEmpty) return '?';
|
||||
return trimmed.substring(0, trimmed.length >= 2 ? 2 : 1).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies a domain row by its demo table name and UUID.
|
||||
class DemoRowRef {
|
||||
const DemoRowRef({required this.tableName, required this.id});
|
||||
|
||||
final String tableName;
|
||||
final protocol.UuidValue id;
|
||||
}
|
||||
|
||||
/// Stores the current demo bearer token for outgoing requests.
|
||||
class DemoAuthKeyProvider implements protocol.ClientAuthKeyProvider {
|
||||
String? token;
|
||||
|
||||
@override
|
||||
Future<String?> get authHeaderValue async {
|
||||
final value = token;
|
||||
if (value == null) return null;
|
||||
return 'Bearer $value';
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client that fails every request, simulating an offline device.
|
||||
class OfflineHttpClient extends http.BaseClient {
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||
throw http.ClientException(
|
||||
'Connectivity is disabled by the demo httpClientOverride.',
|
||||
request.url,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic counter used to label seeded rows per scenario.
|
||||
class ScenarioCounter {
|
||||
final _values = <String, int>{};
|
||||
|
||||
int next(String key) {
|
||||
final value = (_values[key] ?? 0) + 1;
|
||||
_values[key] = value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes a demo username so the same person maps to one scope.
|
||||
String normalizeUsername(String username) {
|
||||
return username.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/// Deterministic auth-user id for [username], matching the server algorithm.
|
||||
protocol.UuidValue demoAuthUserIdForUsername(String username) {
|
||||
final id = const uuid.Uuid().v5(
|
||||
uuid.Namespace.url.value,
|
||||
'serverpod-offline-sync/demo-user/${normalizeUsername(username)}',
|
||||
);
|
||||
return protocol.UuidValue.withValidation(id);
|
||||
}
|
||||
|
||||
/// Bearer token accepted by the demo authentication handler.
|
||||
String demoTokenForAuthUserId(protocol.UuidValue authUserId) {
|
||||
return 'offline-sync-demo:${authUserId.uuid}';
|
||||
}
|
||||
|
||||
/// Extracts the auth-user id from a demo bearer [token].
|
||||
protocol.UuidValue authUserIdFromToken(String token) {
|
||||
const prefix = 'offline-sync-demo:';
|
||||
if (!token.startsWith(prefix)) {
|
||||
throw FormatException('Unexpected demo auth token.', token);
|
||||
}
|
||||
return protocol.UuidValue.withValidation(token.substring(prefix.length));
|
||||
}
|
||||
|
||||
/// Builds a fresh time-ordered UUID for a new row.
|
||||
protocol.UuidValue newId() {
|
||||
return const protocol.Uuid().v7obj();
|
||||
}
|
||||
|
||||
/// Short 8-character form of a UUID for compact display.
|
||||
///
|
||||
/// Uses the last 8 characters so v7 UUIDs show the random suffix rather than
|
||||
/// the timestamp prefix.
|
||||
String shortId(protocol.UuidValue? value) {
|
||||
if (value == null) return 'null';
|
||||
final uuid = value.uuid;
|
||||
if (uuid.length <= 8) return uuid;
|
||||
return uuid.substring(uuid.length - 8);
|
||||
}
|
||||
126
example/offline_sync_demo/lib/src/offline_replica.dart
Normal file
126
example/offline_sync_demo/lib/src/offline_replica.dart
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:serverpod_database/serverpod_database.dart'
|
||||
show ClientDatabaseSession;
|
||||
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
|
||||
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
||||
|
||||
import 'table_ops.dart' show demoSyncTables;
|
||||
|
||||
/// One local replica: a Serverpod client database wrapped with the
|
||||
/// `serverpod_offline_sync` CRDT layer, plus the calls that synchronize it.
|
||||
///
|
||||
/// This class is the entire surface a real integration needs — read it first to
|
||||
/// understand the package. The four steps are:
|
||||
///
|
||||
/// 1. [open] opens a local SQLite database ([Client.createSession])
|
||||
/// and wraps it in a [CrdtDatabaseSession]. The rest of the app
|
||||
/// reads and writes generated models through [session].
|
||||
/// 2. CRUD happens against [session] with the generated model APIs (see
|
||||
/// the `seed*` methods in `DemoController` for the plain, recommended form,
|
||||
/// e.g. `Person.db.insertRow(crdtSession, person)`).
|
||||
/// 3. [syncOnce] / [syncContinuously] push local changes and merge remote
|
||||
/// ones through a Serverpod [Client].
|
||||
/// 4. [reset] / [close] tear the replica down.
|
||||
///
|
||||
/// It deliberately holds **no UI state**: the demo's `DemoController` layers
|
||||
/// busy/error/projection bookkeeping on top of these calls so that this file
|
||||
/// stays a clean reference for the package itself.
|
||||
class OfflineReplica {
|
||||
OfflineReplica._(this._rawSession, this.session, this.persistentUserId);
|
||||
|
||||
/// Owns the underlying SQLite connection; closed by [close].
|
||||
final ClientDatabaseSession _rawSession;
|
||||
|
||||
/// CRDT-aware session used for every model read and write. By default it only
|
||||
/// exposes visible (non-tombstoned) rows; queries opt into hidden rows with
|
||||
/// the `includeHiddenRows` where-clause.
|
||||
final CrdtDatabaseSession session;
|
||||
|
||||
/// The signed-in user every local write on this replica is attributed to.
|
||||
final UuidValue persistentUserId;
|
||||
|
||||
/// Opens [databasePath] through [client] and wraps it for CRDT sync.
|
||||
///
|
||||
/// [Client.createSession] opens the local client database (running
|
||||
/// client migrations); [CrdtDatabaseSession.wraps] layers CRDT
|
||||
/// tracking over it; and `db.initialize()` establishes this device's CRDT
|
||||
/// node so it can take part in sync.
|
||||
static Future<OfflineReplica> open({
|
||||
required Client client,
|
||||
required String databasePath,
|
||||
required UuidValue persistentUserId,
|
||||
}) async {
|
||||
final rawSession = await client.createSession(
|
||||
databasePath,
|
||||
isDebugMode: kDebugMode,
|
||||
);
|
||||
final crdtSession = CrdtDatabaseSession.wraps(
|
||||
rawSession,
|
||||
syncTables: demoSyncTables,
|
||||
persistentUserId: persistentUserId,
|
||||
);
|
||||
await crdtSession.db.initialize();
|
||||
return OfflineReplica._(rawSession, crdtSession, persistentUserId);
|
||||
}
|
||||
|
||||
/// Pushes local pending changes through [client] and merges remote ones once.
|
||||
///
|
||||
/// Connectivity is expressed entirely by [client]: pass an online client to
|
||||
/// reach the server, an offline one (failing transport) to simulate no
|
||||
/// network. The replica's local database is the same either way.
|
||||
Future<void> syncOnce(
|
||||
Client client, {
|
||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||
}) {
|
||||
return client.crdt.syncOnce(session, onMergeSuccess: onMergeSuccess);
|
||||
}
|
||||
|
||||
/// Streams changes through [client] until the returned session is cancelled
|
||||
/// (via [CrdtSyncSession.cancel]) or the remote stream closes.
|
||||
CrdtSyncSession syncContinuously(
|
||||
Client client, {
|
||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||
}) {
|
||||
return client.crdt.syncContinuously(
|
||||
session,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
/// Wipes this replica back to an empty device by deleting its local CRDT
|
||||
/// scope row. Every synced table cascades on `scopeId` -> `crdt_scopes`, so
|
||||
/// the domain rows and CRDT metadata go with it — no need to drop and recreate
|
||||
/// the database file. A fresh scope/node is established lazily on the next
|
||||
/// write.
|
||||
///
|
||||
/// The delete runs with `defer_foreign_keys` on: the scopeId cascade fans out
|
||||
/// across the CRDT metadata diamond (`crdt_data_rows`/`crdt_data_fields`/
|
||||
/// `crdt_data_tombstone` reference `crdt_nodes` with NO ACTION while both
|
||||
/// sides cascade off `crdt_scopes`), and SQLite's cascade order can
|
||||
/// transiently violate those immediate checks. Deferring them to commit lets
|
||||
/// the whole cascade complete first.
|
||||
Future<void> reset() async {
|
||||
final scope = await CrdtScope.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.uuidScopeId.equals(persistentUserId),
|
||||
);
|
||||
final scopeId = scope?.id;
|
||||
if (scopeId != null) {
|
||||
await session.db.transaction((transaction) async {
|
||||
await session.db.unsafeExecute(
|
||||
'PRAGMA defer_foreign_keys = ON',
|
||||
transaction: transaction,
|
||||
);
|
||||
await CrdtScope.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.equals(scopeId),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
}
|
||||
await session.db.initialize();
|
||||
}
|
||||
|
||||
/// Closes the underlying database connection.
|
||||
Future<void> close() => _rawSession.close();
|
||||
}
|
||||
188
example/offline_sync_demo/lib/src/relationships.dart
Normal file
188
example/offline_sync_demo/lib/src/relationships.dart
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import 'package:serverpod_database/serverpod_database.dart'
|
||||
show ColumnType, ForeignKeyAction, TableDefinition, TableRow;
|
||||
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart'
|
||||
as protocol;
|
||||
|
||||
import 'models.dart';
|
||||
import 'table_ops.dart';
|
||||
|
||||
/// The referential action a foreign key takes when its parent is deleted.
|
||||
///
|
||||
/// This is the whole point of the FK-conflict demo, so it is surfaced directly
|
||||
/// on every relationship edge.
|
||||
enum FkAction {
|
||||
restrict('Restrict', 'blocks the delete'),
|
||||
cascade('Cascade', 'deletes children'),
|
||||
setNull('SetNull', 'nulls the reference'),
|
||||
setDefault('SetDefault', 'resets to default'),
|
||||
noAction('NoAction', 'no enforced action');
|
||||
|
||||
const FkAction(this.label, this.summary);
|
||||
|
||||
final String label;
|
||||
final String summary;
|
||||
|
||||
static FkAction from(ForeignKeyAction? action) => switch (action) {
|
||||
ForeignKeyAction.cascade => FkAction.cascade,
|
||||
ForeignKeyAction.restrict => FkAction.restrict,
|
||||
ForeignKeyAction.setNull => FkAction.setNull,
|
||||
ForeignKeyAction.setDefault => FkAction.setDefault,
|
||||
_ => FkAction.noAction,
|
||||
};
|
||||
}
|
||||
|
||||
/// A single foreign-key edge: [childTable].[fkColumn] → [parentTable], with the
|
||||
/// [onDelete] action that fires when the parent row is removed.
|
||||
class Relationship {
|
||||
Relationship({
|
||||
required this.childTable,
|
||||
required this.fkColumn,
|
||||
required this.parentTable,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final String childTable;
|
||||
final String fkColumn;
|
||||
final String parentTable;
|
||||
final FkAction onDelete;
|
||||
}
|
||||
|
||||
/// Metadata-driven view of the synced schema.
|
||||
///
|
||||
/// Everything here is computed once from [protocol.Protocol.targetTableDefinitions]
|
||||
/// (the same table definitions the client already ships), so the relationship
|
||||
/// menus and edge badges stay in sync with the generated model.
|
||||
class RelationshipCatalog {
|
||||
RelationshipCatalog._(this._byName, this._inbound, this._outbound);
|
||||
|
||||
final Map<String, TableDefinition> _byName;
|
||||
final Map<String, List<Relationship>> _inbound;
|
||||
final Map<String, List<Relationship>> _outbound;
|
||||
|
||||
static RelationshipCatalog build(Set<String> syncedTables) {
|
||||
final byName = <String, TableDefinition>{
|
||||
for (final table in protocol.Protocol.targetTableDefinitions)
|
||||
if (syncedTables.contains(table.name)) table.name: table,
|
||||
};
|
||||
|
||||
final outbound = <String, List<Relationship>>{};
|
||||
final inbound = <String, List<Relationship>>{};
|
||||
for (final table in byName.values) {
|
||||
outbound[table.name] = [];
|
||||
inbound.putIfAbsent(table.name, () => []);
|
||||
}
|
||||
|
||||
for (final table in byName.values) {
|
||||
for (final fk in table.foreignKeys) {
|
||||
// Only simple, user-visible relationships: single column, not one of
|
||||
// the generated `_relationBackref` columns, and pointing at a table we
|
||||
// actually render.
|
||||
if (fk.columns.length != 1) continue;
|
||||
final column = fk.columns.single;
|
||||
if (column.startsWith('_')) continue;
|
||||
if (!byName.containsKey(fk.referenceTable)) continue;
|
||||
|
||||
final relationship = Relationship(
|
||||
childTable: table.name,
|
||||
fkColumn: column,
|
||||
parentTable: fk.referenceTable,
|
||||
onDelete: FkAction.from(fk.onDelete),
|
||||
);
|
||||
outbound[table.name]!.add(relationship);
|
||||
inbound[fk.referenceTable]!.add(relationship);
|
||||
}
|
||||
}
|
||||
|
||||
return RelationshipCatalog._(byName, inbound, outbound);
|
||||
}
|
||||
|
||||
Iterable<String> get tables => _byName.keys;
|
||||
|
||||
/// Relationships whose parent is [table] — i.e. the kinds of child rows that
|
||||
/// can be attached to it. Drives the "+ child" menu.
|
||||
List<Relationship> childRelationshipsOf(String table) =>
|
||||
_inbound[table] ?? const [];
|
||||
|
||||
/// This table's own foreign keys — the references that can be re-pointed or
|
||||
/// detached. Drives the "move" picker and tree nesting.
|
||||
List<Relationship> parentRelationshipsOf(String table) =>
|
||||
_outbound[table] ?? const [];
|
||||
|
||||
/// Tables listed in the "Add data" menu, as declared by
|
||||
/// [demoTableOps.canCreateRoot].
|
||||
List<String> get creatableRootTables {
|
||||
final result = <String>[];
|
||||
for (final table in _byName.keys) {
|
||||
if (demoTableOps[table]?.canCreateRoot ?? false) result.add(table);
|
||||
}
|
||||
result.sort();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// The column used as a row's human label (e.g. `name`, `street`, `value`).
|
||||
String displayColumn(String table) {
|
||||
final definition = _byName[table];
|
||||
if (definition == null) return 'id';
|
||||
const preferred = ['name', 'street', 'aText', 'value', 'title', 'label'];
|
||||
for (final candidate in preferred) {
|
||||
if (definition.columns.any((c) => c.name == candidate)) return candidate;
|
||||
}
|
||||
for (final column in definition.columns) {
|
||||
if (_isSystemColumn(column.name)) continue;
|
||||
if (column.columnType == ColumnType.text) return column.name;
|
||||
}
|
||||
for (final column in definition.columns) {
|
||||
if (_isSystemColumn(column.name)) continue;
|
||||
if (column.columnType == ColumnType.uuid) return column.name;
|
||||
}
|
||||
return 'id';
|
||||
}
|
||||
|
||||
/// A short human label for [row].
|
||||
String displayLabel(TableRow<protocol.UuidValue?> row) {
|
||||
final table = row.table.tableName;
|
||||
final json = row.toJson() as Map<String, dynamic>;
|
||||
final column = displayColumn(table);
|
||||
final value = json[column];
|
||||
if (value == null) return '(${shortId(row.id)})';
|
||||
final definition = _byName[table];
|
||||
final isUuid =
|
||||
definition?.columns
|
||||
.firstWhere(
|
||||
(c) => c.name == column,
|
||||
orElse: () => definition.columns.first,
|
||||
)
|
||||
.columnType ==
|
||||
ColumnType.uuid;
|
||||
if (isUuid && value is protocol.UuidValue) return shortId(value);
|
||||
final text = '$value';
|
||||
if (isUuid && text.length >= 8) return text.substring(text.length - 8);
|
||||
return text;
|
||||
}
|
||||
|
||||
/// Builds a generated row for [table], optionally assigning one foreign key.
|
||||
/// Required fields and defaults remain owned by the generated constructors.
|
||||
TableRow<protocol.UuidValue?>? buildRow(
|
||||
String table, {
|
||||
String? fkColumn,
|
||||
protocol.UuidValue? fkValue,
|
||||
String? label,
|
||||
}) {
|
||||
final create = demoTableOps[table]?.create;
|
||||
if (create == null) return null;
|
||||
return create(newId(), label ?? _defaultLabel(table), {
|
||||
if (fkColumn != null && fkValue != null) fkColumn: fkValue,
|
||||
});
|
||||
}
|
||||
|
||||
String _defaultLabel(String table) {
|
||||
final words = table.split('_').map((w) {
|
||||
if (w.isEmpty) return w;
|
||||
return '${w[0].toUpperCase()}${w.substring(1)}';
|
||||
});
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
static bool _isSystemColumn(String name) =>
|
||||
name == 'id' || name == 'scopeId' || name.startsWith('_');
|
||||
}
|
||||
640
example/offline_sync_demo/lib/src/scenarios.dart
Normal file
640
example/offline_sync_demo/lib/src/scenarios.dart
Normal file
|
|
@ -0,0 +1,640 @@
|
|||
part of 'demo_controller.dart';
|
||||
|
||||
/// One step of a guided scenario, bound to a concrete replica and rows.
|
||||
class ScenarioStep {
|
||||
ScenarioStep(this.label, this.run, {this.replica});
|
||||
|
||||
final String label;
|
||||
final ReplicaSlot? replica;
|
||||
final Future<void> Function() run;
|
||||
}
|
||||
|
||||
/// A guided, stateful recipe that scripts the free-form primitives. Optional —
|
||||
/// every step it runs can also be done by hand directly on the tree.
|
||||
class Scenario {
|
||||
Scenario({required this.title, required this.summary, required this.build});
|
||||
|
||||
final String title;
|
||||
final String summary;
|
||||
final List<ScenarioStep> Function(DemoController controller) build;
|
||||
}
|
||||
|
||||
extension on DemoController {
|
||||
List<Scenario> _buildScenarios() {
|
||||
Relationship? childRelation(String parent, String child) {
|
||||
for (final relation in catalog.childRelationshipsOf(parent)) {
|
||||
if (relation.childTable == child) return relation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
Scenario(
|
||||
title: 'Restrict blocks delete',
|
||||
summary:
|
||||
'A Restrict child added on B blocks the parent delete made on A.',
|
||||
build: (c) {
|
||||
final ctx = <String, DemoRowRef?>{};
|
||||
final relation = childRelation('person', 'restrict_child');
|
||||
return [
|
||||
ScenarioStep(
|
||||
'Seed parent Person on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['parent'] = await c.createRoot(
|
||||
ReplicaSlot.a,
|
||||
'person',
|
||||
label: 'Restricted parent',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Attach Restrict child on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() async {
|
||||
final parent = ctx['parent'];
|
||||
if (parent != null && relation != null) {
|
||||
await c.createChildFor(ReplicaSlot.b, parent, relation);
|
||||
}
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Delete parent on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async {
|
||||
final parent = ctx['parent'];
|
||||
if (parent != null) await c.deleteRow(parent, ReplicaSlot.a);
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A ← server (converge)',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
Scenario(
|
||||
title: 'SetNull projection',
|
||||
summary:
|
||||
'A Town mayor reference on B is nulled when the mayor is deleted on A.',
|
||||
build: (c) {
|
||||
final ctx = <String, DemoRowRef?>{};
|
||||
final relation = childRelation('person', 'town');
|
||||
return [
|
||||
ScenarioStep(
|
||||
'Seed mayor Person on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['mayor'] = await c.createRoot(
|
||||
ReplicaSlot.a,
|
||||
'person',
|
||||
label: 'Mayor candidate',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Add Town with that mayor on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() async {
|
||||
final mayor = ctx['mayor'];
|
||||
if (mayor != null && relation != null) {
|
||||
await c.createChildFor(ReplicaSlot.b, mayor, relation);
|
||||
}
|
||||
},
|
||||
),
|
||||
ScenarioStep('Delete mayor on A', replica: ReplicaSlot.a, () async {
|
||||
final mayor = ctx['mayor'];
|
||||
if (mayor != null) await c.deleteRow(mayor, ReplicaSlot.a);
|
||||
}),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A ← server (converge)',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
Scenario(
|
||||
title: 'Cascade hides children',
|
||||
summary:
|
||||
'Deleting an Organization on A cascade-hides the Person attached on B.',
|
||||
build: (c) {
|
||||
final ctx = <String, DemoRowRef?>{};
|
||||
final relation = childRelation('organization', 'person');
|
||||
return [
|
||||
ScenarioStep(
|
||||
'Seed Organization on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['org'] = await c.createRoot(
|
||||
ReplicaSlot.a,
|
||||
'organization',
|
||||
label: 'Cascade org',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Attach Person to org on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() async {
|
||||
final org = ctx['org'];
|
||||
if (org != null && relation != null) {
|
||||
await c.createChildFor(ReplicaSlot.b, org, relation);
|
||||
}
|
||||
},
|
||||
),
|
||||
ScenarioStep('Delete org on A', replica: ReplicaSlot.a, () async {
|
||||
final org = ctx['org'];
|
||||
if (org != null) await c.deleteRow(org, ReplicaSlot.a);
|
||||
}),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A ← server (converge)',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
Scenario(
|
||||
title: 'Concurrent unique insert',
|
||||
summary:
|
||||
'Both replicas insert a Unique row with the same name; one loses on merge.',
|
||||
build: (c) {
|
||||
final name = 'shared-${c._counter.next('unique')}';
|
||||
return [
|
||||
ScenarioStep(
|
||||
'Insert Unique "$name" on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() => c.createUnique(ReplicaSlot.a, name),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Insert Unique "$name" on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() => c.createUnique(ReplicaSlot.b, name),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B → server (conflict resolves)',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A ← server (converge)',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
Scenario(
|
||||
title: 'Concurrent field edits',
|
||||
summary:
|
||||
'A and B edit different Person columns offline; both values survive merge.',
|
||||
build: (c) {
|
||||
final ctx = <String, DemoRowRef?>{};
|
||||
return [
|
||||
ScenarioStep(
|
||||
'Seed Person on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['person'] = await c.createRoot(
|
||||
ReplicaSlot.a,
|
||||
'person',
|
||||
label: 'Conflict person',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep('Edit name on A', replica: ReplicaSlot.a, () async {
|
||||
final person = ctx['person'];
|
||||
if (person != null) {
|
||||
await c.updateRowFields(person, ReplicaSlot.a, {
|
||||
'name': 'Name from A',
|
||||
});
|
||||
}
|
||||
}),
|
||||
ScenarioStep('Edit surname on B', replica: ReplicaSlot.b, () async {
|
||||
final person = ctx['person'];
|
||||
if (person != null) {
|
||||
await c.updateRowFields(person, ReplicaSlot.b, {
|
||||
'surname': 'Surname from B',
|
||||
});
|
||||
}
|
||||
}),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B → server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A ← server (converge)',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
Scenario(
|
||||
title: 'Unique update conflict',
|
||||
summary:
|
||||
'Two Unique rows are renamed to the same value; the older claim keeps it.',
|
||||
build: (c) {
|
||||
final ctx = <String, DemoRowRef?>{};
|
||||
final shared = 'shared-${c._counter.next('unique-update')}';
|
||||
return [
|
||||
ScenarioStep(
|
||||
'Insert Unique "first" on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['first'] = await c.createUnique(
|
||||
ReplicaSlot.a,
|
||||
'first-$shared',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Insert Unique "second" on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['second'] = await c.createUnique(
|
||||
ReplicaSlot.a,
|
||||
'second-$shared',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Rename "first" to "$shared" on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async {
|
||||
final first = ctx['first'];
|
||||
if (first != null) {
|
||||
await c.updateRowFields(first, ReplicaSlot.a, {
|
||||
'name': shared,
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Rename "second" to "$shared" on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() async {
|
||||
final second = ctx['second'];
|
||||
if (second != null) {
|
||||
await c.updateRowFields(second, ReplicaSlot.b, {
|
||||
'name': shared,
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B → server (newer update first)',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server (older update loses)',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server (converge)',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
Scenario(
|
||||
title: 'Delete and restore',
|
||||
summary:
|
||||
'A deletes a Person; B reinserts it with a newer restore that wins on merge.',
|
||||
build: (c) {
|
||||
final ctx = <String, DemoRowRef?>{};
|
||||
return [
|
||||
ScenarioStep(
|
||||
'Seed Person on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['person'] = await c.createRoot(
|
||||
ReplicaSlot.a,
|
||||
'person',
|
||||
label: 'Restorable person',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Delete Person on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async {
|
||||
final person = ctx['person'];
|
||||
if (person != null) await c.deleteRow(person, ReplicaSlot.a);
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server (tombstone arrives)',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Reinsert Person on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() async {
|
||||
final person = ctx['person'];
|
||||
if (person != null) await c.reinsertRow(person, ReplicaSlot.b);
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B → server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A ← server (converge)',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
Scenario(
|
||||
title: 'SetDefault projection',
|
||||
summary:
|
||||
'Deleting a Town on A resets a Company on B to the schema default town.',
|
||||
build: (c) {
|
||||
final ctx = <String, DemoRowRef?>{};
|
||||
const defaultTownId = protocol.UuidValue.raw(
|
||||
'550e8400-e29b-41d4-a716-446655440000',
|
||||
);
|
||||
final townRelation = childRelation('town', 'company');
|
||||
return [
|
||||
ScenarioStep(
|
||||
'Seed default Town on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['defaultTown'] = await c.createTown(
|
||||
ReplicaSlot.a,
|
||||
id: defaultTownId,
|
||||
label: 'Default town',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Seed linked Town on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['linkedTown'] = await c.createTown(
|
||||
ReplicaSlot.a,
|
||||
label: 'Linked town',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Attach Company to linked Town on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() async {
|
||||
final town = ctx['linkedTown'];
|
||||
if (town != null && townRelation != null) {
|
||||
ctx['company'] = await c.createChildFor(
|
||||
ReplicaSlot.b,
|
||||
town,
|
||||
townRelation,
|
||||
);
|
||||
} else if (town != null) {
|
||||
ctx['company'] = await c.createCompanyForTown(
|
||||
ReplicaSlot.b,
|
||||
town,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Delete linked Town on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async {
|
||||
final town = ctx['linkedTown'];
|
||||
if (town != null) await c.deleteRow(town, ReplicaSlot.a);
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A ← server (converge)',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
Scenario(
|
||||
title: 'FK chain cascade',
|
||||
summary:
|
||||
'Deleting the chain root on A cascade-hides grandchildren built on B.',
|
||||
build: (c) {
|
||||
final ctx = <String, DemoRowRef?>{};
|
||||
final middleRelation = childRelation(
|
||||
'fk_chain_root',
|
||||
'fk_chain_cascade_middle',
|
||||
);
|
||||
final blockerRelation = childRelation(
|
||||
'fk_chain_cascade_middle',
|
||||
'fk_chain_restrict_blocker',
|
||||
);
|
||||
final cascadeChildRelation = childRelation(
|
||||
'fk_chain_restrict_blocker',
|
||||
'fk_chain_middle_cascade_child',
|
||||
);
|
||||
return [
|
||||
ScenarioStep(
|
||||
'Seed FK chain root on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async => ctx['root'] = await c.createRoot(
|
||||
ReplicaSlot.a,
|
||||
'fk_chain_root',
|
||||
label: 'Chain root',
|
||||
),
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Attach cascade middle on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() async {
|
||||
final root = ctx['root'];
|
||||
if (root != null && middleRelation != null) {
|
||||
ctx['middle'] = await c.createChildFor(
|
||||
ReplicaSlot.b,
|
||||
root,
|
||||
middleRelation,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Attach restrict blocker on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() async {
|
||||
final middle = ctx['middle'];
|
||||
if (middle != null && blockerRelation != null) {
|
||||
ctx['blocker'] = await c.createChildFor(
|
||||
ReplicaSlot.b,
|
||||
middle,
|
||||
blockerRelation,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Attach cascade grandchild on B',
|
||||
replica: ReplicaSlot.b,
|
||||
() async {
|
||||
final blocker = ctx['blocker'];
|
||||
if (blocker != null && cascadeChildRelation != null) {
|
||||
await c.createChildFor(
|
||||
ReplicaSlot.b,
|
||||
blocker,
|
||||
cascadeChildRelation,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Delete chain root on A',
|
||||
replica: ReplicaSlot.a,
|
||||
() async {
|
||||
final root = ctx['root'];
|
||||
if (root != null) await c.deleteRow(root, ReplicaSlot.a);
|
||||
},
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A → server',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync B ← server',
|
||||
() => c.syncReplica(ReplicaSlot.b),
|
||||
replica: ReplicaSlot.b,
|
||||
),
|
||||
ScenarioStep(
|
||||
'Sync A ← server (converge)',
|
||||
() => c.syncReplica(ReplicaSlot.a),
|
||||
replica: ReplicaSlot.a,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
378
example/offline_sync_demo/lib/src/sheets.dart
Normal file
378
example/offline_sync_demo/lib/src/sheets.dart
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
part of 'demo_view.dart';
|
||||
|
||||
/// Opens the right-hand sheet that creates and switches to a new demo user.
|
||||
Future<void> showCreateUserSheet(
|
||||
BuildContext context,
|
||||
DemoController controller,
|
||||
) {
|
||||
return openSheet(
|
||||
context: context,
|
||||
position: OverlayPosition.right,
|
||||
builder: (context) => _CreateUserSheet(controller: controller),
|
||||
);
|
||||
}
|
||||
|
||||
class _CreateUserSheet extends StatefulWidget {
|
||||
const _CreateUserSheet({required this.controller});
|
||||
|
||||
final DemoController controller;
|
||||
|
||||
@override
|
||||
State<_CreateUserSheet> createState() => _CreateUserSheetState();
|
||||
}
|
||||
|
||||
class _CreateUserSheetState extends State<_CreateUserSheet> {
|
||||
final _textController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final username = _textController.text;
|
||||
closeOverlay(context);
|
||||
unawaited(widget.controller.createOrSwitchUser(username));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 320,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Create demo user').large().semiBold(),
|
||||
const Gap(4),
|
||||
const Text(
|
||||
'Authenticates by username only and opens its replicas.',
|
||||
).small().muted(),
|
||||
const Gap(12),
|
||||
const Text('Username').small().muted(),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _textController,
|
||||
autofocus: true,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
const Gap(12),
|
||||
PrimaryButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Create & switch'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the right-hand sheet showing the complete record for [ref].
|
||||
Future<void> showRowDetailSheet(
|
||||
BuildContext context,
|
||||
DemoController controller,
|
||||
DemoRowRef ref,
|
||||
ReplicaSlot slot,
|
||||
) {
|
||||
return openSheet(
|
||||
context: context,
|
||||
position: OverlayPosition.right,
|
||||
builder: (context) =>
|
||||
_RowDetailSheet(controller: controller, ref: ref, slot: slot),
|
||||
);
|
||||
}
|
||||
|
||||
class _RowDetailSheet extends StatefulWidget {
|
||||
const _RowDetailSheet({
|
||||
required this.controller,
|
||||
required this.ref,
|
||||
required this.slot,
|
||||
});
|
||||
|
||||
final DemoController controller;
|
||||
final DemoRowRef ref;
|
||||
final ReplicaSlot slot;
|
||||
|
||||
@override
|
||||
State<_RowDetailSheet> createState() => _RowDetailSheetState();
|
||||
}
|
||||
|
||||
class _RowDetailSheetState extends State<_RowDetailSheet> {
|
||||
final _controllers = <String, TextEditingController>{};
|
||||
RowDetail? _detail;
|
||||
bool _loading = true;
|
||||
bool _saveFailed = false;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final detail = await widget.controller.loadDetail(widget.ref, widget.slot);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_detail = detail;
|
||||
_loading = false;
|
||||
if (detail != null) {
|
||||
for (final field in detail.editable) {
|
||||
_controllers[field.key] = TextEditingController(text: field.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _controllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
if (_saving) return;
|
||||
unawaited(_saveAsync());
|
||||
}
|
||||
|
||||
Future<void> _saveAsync() async {
|
||||
setState(() {
|
||||
_saveFailed = false;
|
||||
_saving = true;
|
||||
});
|
||||
|
||||
final values = {
|
||||
for (final entry in _controllers.entries) entry.key: entry.value.text,
|
||||
};
|
||||
final saved = await widget.controller.applyEdit(
|
||||
widget.ref,
|
||||
widget.slot,
|
||||
values,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (saved) {
|
||||
closeOverlay(context);
|
||||
return;
|
||||
}
|
||||
|
||||
final detail = _detail;
|
||||
if (detail != null) {
|
||||
for (final field in detail.editable) {
|
||||
_controllers[field.key]?.text = field.value;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saveFailed = true;
|
||||
_saving = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _delete() {
|
||||
closeOverlay(context);
|
||||
unawaited(widget.controller.deleteRow(widget.ref, widget.slot));
|
||||
}
|
||||
|
||||
void _copyValue(String value) {
|
||||
unawaited(Clipboard.setData(ClipboardData(text: value)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 400,
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _detail == null
|
||||
? const Center(child: Text('Row not found on this replica.'))
|
||||
: _buildDetail(context, _detail!),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetail(BuildContext context, RowDetail detail) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(tableLabel(detail.tableName)).large().semiBold(),
|
||||
const Gap(8),
|
||||
_StatusPill(
|
||||
text: detail.visible ? 'visible' : 'hidden',
|
||||
warn: !detail.visible,
|
||||
),
|
||||
const Spacer(),
|
||||
Tooltip(
|
||||
tooltip: (context) =>
|
||||
const TooltipContainer(child: Text('Close details')),
|
||||
child: IconButton.ghost(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () => closeOverlay(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(4),
|
||||
Text(detail.slot.label).small().muted(),
|
||||
const Gap(12),
|
||||
_kv(context, 'Row UUID', detail.uuid),
|
||||
_kv(context, 'Scope id', detail.scopeId ?? '—'),
|
||||
_kv(
|
||||
context,
|
||||
'Visibility',
|
||||
detail.visible ? 'visible' : 'hidden on this replica',
|
||||
),
|
||||
const Gap(16),
|
||||
if (_controllers.isNotEmpty) ...[
|
||||
const Text('Edit fields').semiBold(),
|
||||
const Gap(8),
|
||||
for (final field in detail.editable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(field.key).small().muted(),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _controllers[field.key],
|
||||
onTap: () => _copyValue(_controllers[field.key]!.text),
|
||||
onChanged: (_) {
|
||||
if (_saveFailed) {
|
||||
setState(() => _saveFailed = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SaveButton(
|
||||
failed: _saveFailed,
|
||||
saving: _saving,
|
||||
onPressed: _save,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
_SolidDestructiveButton(
|
||||
onPressed: _delete,
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else
|
||||
_SolidDestructiveButton(
|
||||
onPressed: _delete,
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
const Gap(16),
|
||||
const Text('Full record').semiBold(),
|
||||
const Gap(8),
|
||||
for (final entry in detail.fields.entries)
|
||||
_kv(context, entry.key, '${entry.value}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kv(BuildContext context, String label, String value) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _copyValue(value),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 96, child: Text(label).xSmall().muted()),
|
||||
Expanded(child: Text(value).xSmall()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveButton extends StatelessWidget {
|
||||
const _SaveButton({
|
||||
required this.failed,
|
||||
required this.saving,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final bool failed;
|
||||
final bool saving;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = Text(saving ? 'Saving' : 'Save');
|
||||
if (failed) {
|
||||
return _SolidDestructiveButton(onPressed: onPressed, child: child);
|
||||
}
|
||||
|
||||
return PrimaryButton(onPressed: onPressed, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
class _SolidDestructiveButton extends StatelessWidget {
|
||||
const _SolidDestructiveButton({required this.onPressed, required this.child});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ButtonStyleOverride.inherit(
|
||||
decoration: (context, states, value) {
|
||||
if (states.contains(WidgetState.disabled)) return value;
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final color = scheme.brightness == Brightness.dark
|
||||
? const Color(0xFFDC2626)
|
||||
: scheme.destructive;
|
||||
|
||||
return BoxDecoration(
|
||||
color: states.contains(WidgetState.hovered)
|
||||
? const Color(0xFFB91C1C)
|
||||
: color,
|
||||
borderRadius: BorderRadius.circular(theme.radiusMd),
|
||||
);
|
||||
},
|
||||
child: DestructiveButton(onPressed: onPressed, child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusPill extends StatelessWidget {
|
||||
const _StatusPill({required this.text, required this.warn});
|
||||
|
||||
final String text;
|
||||
final bool warn;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return _Badge(
|
||||
text: text,
|
||||
background: warn ? scheme.destructive : scheme.primary,
|
||||
foreground: warn ? Colors.white : scheme.primaryForeground,
|
||||
);
|
||||
}
|
||||
}
|
||||
329
example/offline_sync_demo/lib/src/snapshot.dart
Normal file
329
example/offline_sync_demo/lib/src/snapshot.dart
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import 'package:serverpod_database/serverpod_database.dart' as db show TableRow;
|
||||
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'
|
||||
as offline;
|
||||
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart'
|
||||
as protocol;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import 'models.dart';
|
||||
import 'relationships.dart';
|
||||
import 'table_ops.dart';
|
||||
|
||||
/// A generated domain row together with its visibility for one replica.
|
||||
class RowView {
|
||||
RowView({required this.model, required this.visible});
|
||||
|
||||
final db.TableRow<protocol.UuidValue?> model;
|
||||
final bool visible;
|
||||
|
||||
String get table => model.table.tableName;
|
||||
protocol.UuidValue get id => model.id!;
|
||||
Map<String, dynamic> get json => model.toJson() as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// The data shown in one tree node.
|
||||
class DemoTreeItem {
|
||||
DemoTreeItem._({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
this.detail,
|
||||
this.ref,
|
||||
this.relation,
|
||||
this.hidden = false,
|
||||
this.dangling = false,
|
||||
this.metadata = false,
|
||||
});
|
||||
|
||||
factory DemoTreeItem.group(String title, String detail) {
|
||||
return DemoTreeItem._(
|
||||
title: title,
|
||||
detail: detail,
|
||||
icon: Icons.folder_open,
|
||||
);
|
||||
}
|
||||
|
||||
factory DemoTreeItem.row(
|
||||
String title,
|
||||
String detail,
|
||||
DemoRowRef ref, {
|
||||
required bool hidden,
|
||||
Relationship? relation,
|
||||
bool dangling = false,
|
||||
}) {
|
||||
return DemoTreeItem._(
|
||||
title: title,
|
||||
detail: detail,
|
||||
ref: ref,
|
||||
icon: iconForTable(ref.tableName, hidden: hidden),
|
||||
hidden: hidden,
|
||||
relation: relation,
|
||||
dangling: dangling,
|
||||
);
|
||||
}
|
||||
|
||||
factory DemoTreeItem.metadata(String title, String detail) {
|
||||
return DemoTreeItem._(
|
||||
title: title,
|
||||
detail: detail,
|
||||
icon: Icons.info_outline,
|
||||
metadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
final String title;
|
||||
final String? detail;
|
||||
final IconData icon;
|
||||
final DemoRowRef? ref;
|
||||
|
||||
/// The foreign-key edge that attaches this node to its parent (null for roots
|
||||
/// and groups). Drives the onDelete badge.
|
||||
final Relationship? relation;
|
||||
final bool hidden;
|
||||
|
||||
/// This row carries a non-null foreign key whose parent is not present here.
|
||||
final bool dangling;
|
||||
final bool metadata;
|
||||
}
|
||||
|
||||
/// A projected tree plus the visible/hidden row counts behind it.
|
||||
class DemoProjection {
|
||||
DemoProjection({
|
||||
required this.nodes,
|
||||
required this.visibleRowCount,
|
||||
required this.hiddenRowCount,
|
||||
});
|
||||
|
||||
factory DemoProjection.empty() {
|
||||
return DemoProjection(
|
||||
nodes: const [],
|
||||
visibleRowCount: 0,
|
||||
hiddenRowCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
final List<TreeNode<DemoTreeItem>> nodes;
|
||||
final int visibleRowCount;
|
||||
final int hiddenRowCount;
|
||||
}
|
||||
|
||||
/// An immutable view of one replica's domain rows and CRDT metadata.
|
||||
class DemoSnapshot {
|
||||
DemoSnapshot({
|
||||
required this.catalog,
|
||||
required this.rows,
|
||||
required this.foreignKeys,
|
||||
required this.tombstones,
|
||||
});
|
||||
|
||||
final RelationshipCatalog catalog;
|
||||
final List<RowView> rows;
|
||||
final List<offline.CrdtDataForeignKey> foreignKeys;
|
||||
final List<offline.CrdtDataDeleted> tombstones;
|
||||
|
||||
/// Loads the visible rows and the full set (visible + CRDT-hidden, via
|
||||
/// `includeHidden`) for every synced table through the one CRDT session, and
|
||||
/// classifies each row's visibility.
|
||||
static Future<DemoSnapshot> load(
|
||||
RelationshipCatalog catalog,
|
||||
offline.CrdtDatabaseSession crdt,
|
||||
) async {
|
||||
final rows = <RowView>[];
|
||||
for (final table in catalog.tables) {
|
||||
final ops = demoTableOps[table];
|
||||
if (ops == null) continue;
|
||||
final visible = await ops.findAll(crdt);
|
||||
final full = await ops.findAll(crdt, includeHidden: true);
|
||||
final visibleIds = {
|
||||
for (final row in visible)
|
||||
if (row.id != null) row.id!.uuid,
|
||||
};
|
||||
for (final model in full) {
|
||||
final id = model.id;
|
||||
if (id == null) continue;
|
||||
rows.add(RowView(model: model, visible: visibleIds.contains(id.uuid)));
|
||||
}
|
||||
}
|
||||
|
||||
return DemoSnapshot(
|
||||
catalog: catalog,
|
||||
rows: rows,
|
||||
foreignKeys: await offline.CrdtDataForeignKey.db.find(crdt),
|
||||
tombstones: await offline.CrdtDataDeleted.db.find(crdt),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a snapshot from the server's merged truth. [serverRows] is the flat
|
||||
/// `List<dynamic>` returned by `fetchScopeSnapshot`: each element is a typed
|
||||
/// domain model, deserialized by Serverpod from its `__className__` tag, so
|
||||
/// the table name and JSON come straight off the model. Rows whose id is in
|
||||
/// [hiddenIds] are flagged hidden; there is no local CRDT metadata to show.
|
||||
factory DemoSnapshot.fromServer(
|
||||
RelationshipCatalog catalog,
|
||||
List<dynamic> serverRows, {
|
||||
Set<String> hiddenIds = const {},
|
||||
}) {
|
||||
final rows = <RowView>[];
|
||||
for (final row in serverRows) {
|
||||
final model = row as db.TableRow<protocol.UuidValue?>;
|
||||
final id = model.id;
|
||||
if (id == null) continue;
|
||||
rows.add(RowView(model: model, visible: !hiddenIds.contains(id.uuid)));
|
||||
}
|
||||
|
||||
return DemoSnapshot(
|
||||
catalog: catalog,
|
||||
rows: rows,
|
||||
foreignKeys: const [],
|
||||
tombstones: const [],
|
||||
);
|
||||
}
|
||||
|
||||
int get visibleRowCount => rows.where((r) => r.visible).length;
|
||||
int get hiddenRowCount => rows.where((r) => !r.visible).length;
|
||||
|
||||
/// Stable ordering for tree siblings: table name, then id.
|
||||
int _compareRows(RowView a, RowView b) {
|
||||
final byTable = a.table.compareTo(b.table);
|
||||
if (byTable != 0) return byTable;
|
||||
return a.id.uuid.compareTo(b.id.uuid);
|
||||
}
|
||||
|
||||
/// Builds the FK forest for this snapshot. When [showHidden] is false only
|
||||
/// visible rows are shown; otherwise hidden rows are included and flagged.
|
||||
DemoProjection project({required bool showHidden}) {
|
||||
final included = [
|
||||
for (final row in rows)
|
||||
if (row.visible || showHidden) row,
|
||||
];
|
||||
final byId = {for (final row in included) row.id.uuid: row};
|
||||
|
||||
// Resolve each row's primary parent: the first of its foreign keys that is
|
||||
// set and resolves to a row present in this view.
|
||||
final childrenOf = <String, List<RowView>>{};
|
||||
final edgeOf = <String, Relationship>{};
|
||||
final danglingIds = <String>{};
|
||||
final roots = <RowView>[];
|
||||
|
||||
for (final row in included) {
|
||||
Relationship? parentEdge;
|
||||
String? parentId;
|
||||
var hasUnresolvedRef = false;
|
||||
for (final relation in catalog.parentRelationshipsOf(row.table)) {
|
||||
final value = row.json[relation.fkColumn];
|
||||
if (value == null) continue;
|
||||
final candidate = byId['$value'];
|
||||
if (candidate != null && candidate.id.uuid != row.id.uuid) {
|
||||
parentEdge = relation;
|
||||
parentId = candidate.id.uuid;
|
||||
break;
|
||||
}
|
||||
hasUnresolvedRef = true;
|
||||
}
|
||||
|
||||
if (parentEdge != null && parentId != null) {
|
||||
childrenOf.putIfAbsent(parentId, () => []).add(row);
|
||||
edgeOf[row.id.uuid] = parentEdge;
|
||||
} else {
|
||||
if (hasUnresolvedRef) danglingIds.add(row.id.uuid);
|
||||
roots.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
TreeNode<DemoTreeItem> nodeFor(RowView row, Set<String> seen) {
|
||||
seen.add(row.id.uuid);
|
||||
final childRows = List<RowView>.of(
|
||||
childrenOf[row.id.uuid] ?? const <RowView>[],
|
||||
)..sort(_compareRows);
|
||||
final children = [
|
||||
for (final child in childRows)
|
||||
if (!seen.contains(child.id.uuid)) nodeFor(child, seen),
|
||||
];
|
||||
final relation = edgeOf[row.id.uuid];
|
||||
final detail = relation != null
|
||||
? '${relationLabel(relation.fkColumn)} · ${shortId(row.id)}'
|
||||
: '${tableLabel(row.table)} · ${shortId(row.id)}';
|
||||
return TreeItem(
|
||||
data: DemoTreeItem.row(
|
||||
catalog.displayLabel(row.model),
|
||||
detail,
|
||||
DemoRowRef(tableName: row.table, id: row.id),
|
||||
hidden: !row.visible,
|
||||
relation: relation,
|
||||
dangling: danglingIds.contains(row.id.uuid),
|
||||
),
|
||||
expanded: true,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
roots.sort(_compareRows);
|
||||
|
||||
final seen = <String>{};
|
||||
final forest = [for (final root in roots) nodeFor(root, seen)];
|
||||
|
||||
final nodes = <TreeNode<DemoTreeItem>>[
|
||||
...forest,
|
||||
if (foreignKeys.isNotEmpty || tombstones.isNotEmpty)
|
||||
TreeItem(
|
||||
data: DemoTreeItem.group(
|
||||
'CRDT metadata',
|
||||
'${foreignKeys.length} FK projections · ${tombstones.length} tombstones',
|
||||
),
|
||||
expanded: false,
|
||||
children: [
|
||||
for (final fk in foreignKeys)
|
||||
TreeItem(
|
||||
data: DemoTreeItem.metadata(
|
||||
'field ${fk.fieldId}',
|
||||
'attempted ${shortId(fk.attemptedValue)} '
|
||||
'visible ${shortId(fk.visibleValue)} '
|
||||
'reason ${fk.overrideReason?.name ?? 'none'}',
|
||||
),
|
||||
),
|
||||
for (final tombstone in tombstones)
|
||||
TreeItem(
|
||||
data: DemoTreeItem.metadata(
|
||||
'tombstone row ${tombstone.rowId}',
|
||||
'clFlag ${tombstone.clFlag}, reason ${tombstone.reason.name}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
].expandAll();
|
||||
|
||||
return DemoProjection(
|
||||
nodes: nodes,
|
||||
visibleRowCount: visibleRowCount,
|
||||
hiddenRowCount: hiddenRowCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Friendly name for a foreign-key column, e.g. `inhabitantId` → `inhabitant`.
|
||||
String relationLabel(String fkColumn) {
|
||||
if (fkColumn.endsWith('Id')) {
|
||||
return fkColumn.substring(0, fkColumn.length - 2);
|
||||
}
|
||||
return fkColumn;
|
||||
}
|
||||
|
||||
/// Friendly name for a table, e.g. `fk_chain_root` → `fk chain root`.
|
||||
String tableLabel(String table) => table.replaceAll('_', ' ');
|
||||
|
||||
/// A small icon per table family, falling back to a generic row icon.
|
||||
IconData iconForTable(String table, {required bool hidden}) {
|
||||
if (hidden) return Icons.visibility_off;
|
||||
return switch (table) {
|
||||
'person' => Icons.person,
|
||||
'address' => Icons.home,
|
||||
'city' => Icons.location_city,
|
||||
'town' => Icons.map,
|
||||
'organization' => Icons.apartment,
|
||||
'company' => Icons.business,
|
||||
'restrict_child' || 'required_set_null_child' => Icons.link,
|
||||
'unique' || 'unique_uuid' || 'unique_composite' => Icons.key,
|
||||
'types' => Icons.data_object,
|
||||
_ when table.startsWith('fk_chain') => Icons.account_tree,
|
||||
_ => Icons.table_rows,
|
||||
};
|
||||
}
|
||||
264
example/offline_sync_demo/lib/src/table_ops.dart
Normal file
264
example/offline_sync_demo/lib/src/table_ops.dart
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import 'package:serverpod_database/serverpod_database.dart'
|
||||
show DatabaseSession, Table, TableRow;
|
||||
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'
|
||||
show IncludeTombstonedRows;
|
||||
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
|
||||
|
||||
import 'models.dart';
|
||||
|
||||
/// Generic, table-name keyed CRUD over the generated Serverpod models.
|
||||
///
|
||||
/// This indirection exists **only** so the demo's metadata-driven UI can act on
|
||||
/// any of the synced tables without a per-type switch. A real integration does
|
||||
/// not need it: you call the generated model APIs directly against the CRDT
|
||||
/// session — e.g. `Person.db.insertRow(session, person)` or
|
||||
/// `session.db.find<Person>()` — exactly as the `seed*` methods in
|
||||
/// `DemoController` do. Read those for the plain, recommended form.
|
||||
///
|
||||
/// Database operations retain generated model instances. The only
|
||||
/// serialization boundary is [updateFields], where a generic UI edit has to be
|
||||
/// turned back into the concrete generated type; that conversion is delegated
|
||||
/// to [Protocol].
|
||||
class TableOps {
|
||||
TableOps({
|
||||
required this.table,
|
||||
required this.canCreateRoot,
|
||||
required this.create,
|
||||
required this.findAll,
|
||||
required this.findById,
|
||||
required this.insertRow,
|
||||
required this.updateFields,
|
||||
required this.deleteById,
|
||||
});
|
||||
|
||||
/// The generated table metadata for this row type.
|
||||
final Table table;
|
||||
|
||||
/// Database table name, e.g. `person`.
|
||||
String get name => table.tableName;
|
||||
|
||||
/// Whether the generated constructor can build this row without a parent.
|
||||
final bool canCreateRoot;
|
||||
|
||||
/// Builds a generated model from an [id], a [label] for its display column,
|
||||
/// and any [foreignKeys] to assign. Null when this table has no UI factory.
|
||||
final TableRow<UuidValue?>? Function(
|
||||
UuidValue id,
|
||||
String label,
|
||||
Map<String, UuidValue> foreignKeys,
|
||||
)?
|
||||
create;
|
||||
|
||||
/// Every row in the table. When [includeHidden] is true, CRDT-tombstoned
|
||||
/// (soft-deleted) rows are returned too.
|
||||
final Future<List<TableRow<UuidValue?>>> Function(
|
||||
DatabaseSession session, {
|
||||
bool includeHidden,
|
||||
})
|
||||
findAll;
|
||||
|
||||
/// A single visible row by id, or null.
|
||||
final Future<TableRow<UuidValue?>?> Function(
|
||||
DatabaseSession session,
|
||||
UuidValue id,
|
||||
)
|
||||
findById;
|
||||
|
||||
/// Inserts a generated row.
|
||||
final Future<void> Function(DatabaseSession session, TableRow<UuidValue?> row)
|
||||
insertRow;
|
||||
|
||||
/// Applies JSON-shaped field values to a generated row and updates it.
|
||||
final Future<void> Function(
|
||||
DatabaseSession session,
|
||||
TableRow<UuidValue?> row,
|
||||
Map<String, dynamic> fields,
|
||||
)
|
||||
updateFields;
|
||||
|
||||
/// Deletes the row with [id]; returns whether a row was found.
|
||||
final Future<bool> Function(DatabaseSession session, UuidValue id) deleteById;
|
||||
}
|
||||
|
||||
TableOps _ops<T extends TableRow<UuidValue?>>({
|
||||
T? Function(UuidValue id, String label, Map<String, UuidValue> foreignKeys)?
|
||||
create,
|
||||
bool canCreateRoot = true,
|
||||
}) {
|
||||
final table = Protocol().getTableForType(T);
|
||||
if (table == null) {
|
||||
throw StateError('No table is registered for type $T.');
|
||||
}
|
||||
return TableOps(
|
||||
table: table,
|
||||
create: create,
|
||||
canCreateRoot: create != null && canCreateRoot,
|
||||
findAll: (session, {includeHidden = false}) async {
|
||||
return session.db.find<T>(
|
||||
where: includeHidden ? table.includeHiddenRows : null,
|
||||
);
|
||||
},
|
||||
findById: (session, id) => session.db.findById<T>(id),
|
||||
insertRow: (session, row) async {
|
||||
await session.db.insertRow<T>(row as T);
|
||||
},
|
||||
updateFields: (session, row, fields) async {
|
||||
final json = Map<String, dynamic>.of(row.toJson() as Map<String, dynamic>)
|
||||
..addAll(fields);
|
||||
await session.db.updateRow<T>(Protocol().deserialize<T>(json));
|
||||
},
|
||||
deleteById: (session, id) async {
|
||||
final row = await session.db.findById<T>(id);
|
||||
if (row == null) return false;
|
||||
await session.db.deleteRow<T>(row);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Every synced demo table, keyed by its database table name. The generated
|
||||
/// constructors keep required fields, defaults, and Dart types in generated
|
||||
/// code instead of reconstructing those rules from table metadata.
|
||||
final Map<String, TableOps> demoTableOps = {
|
||||
for (final ops in <TableOps>[
|
||||
_ops<Person>(
|
||||
create: (id, label, foreignKeys) => Person(
|
||||
id: id,
|
||||
name: label,
|
||||
organizationId: foreignKeys['organizationId'],
|
||||
oldCompanyId: foreignKeys['oldCompanyId'],
|
||||
),
|
||||
),
|
||||
_ops<Address>(
|
||||
create: (id, label, foreignKeys) => Address(
|
||||
id: id,
|
||||
street: label,
|
||||
inhabitantId: foreignKeys['inhabitantId'],
|
||||
),
|
||||
),
|
||||
_ops<City>(
|
||||
create: (id, label, foreignKeys) => City(id: id, name: label),
|
||||
),
|
||||
_ops<Town>(
|
||||
create: (id, label, foreignKeys) => Town(
|
||||
id: id,
|
||||
name: label,
|
||||
cityId: foreignKeys['cityId'],
|
||||
mayorId: foreignKeys['mayorId'],
|
||||
),
|
||||
),
|
||||
_ops<Organization>(
|
||||
create: (id, label, foreignKeys) =>
|
||||
Organization(id: id, name: label, cityId: foreignKeys['cityId']),
|
||||
),
|
||||
_ops<Company>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) =>
|
||||
Company(id: id, name: label, townId: foreignKeys['townId']),
|
||||
),
|
||||
_ops<RestrictChild>(
|
||||
create: (id, label, foreignKeys) =>
|
||||
RestrictChild(id: id, name: label, parentId: foreignKeys['parentId']),
|
||||
),
|
||||
_ops<RequiredSetNullChild>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) {
|
||||
final parentId = foreignKeys['parentId'];
|
||||
if (parentId == null) return null;
|
||||
return RequiredSetNullChild(id: id, name: label, parentId: parentId);
|
||||
},
|
||||
),
|
||||
_ops<Unique>(
|
||||
create: (id, label, foreignKeys) => Unique(id: id, name: label),
|
||||
),
|
||||
_ops<UniqueUuid>(
|
||||
create: (id, label, foreignKeys) => UniqueUuid(id: id, value: newId()),
|
||||
),
|
||||
_ops<UniqueComposite>(
|
||||
create: (id, label, foreignKeys) =>
|
||||
UniqueComposite(id: id, scope: label, value: label),
|
||||
),
|
||||
_ops<UniqueSetNullChild>(
|
||||
create: (id, label, foreignKeys) => UniqueSetNullChild(
|
||||
id: id,
|
||||
name: label,
|
||||
parentId: foreignKeys['parentId'],
|
||||
),
|
||||
),
|
||||
_ops<Types>(canCreateRoot: false),
|
||||
_ops<FkChainRoot>(
|
||||
create: (id, label, foreignKeys) => FkChainRoot(id: id, name: label),
|
||||
),
|
||||
_ops<FkChainCascadeMiddle>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) => FkChainCascadeMiddle(
|
||||
id: id,
|
||||
name: label,
|
||||
rootId: foreignKeys['rootId'],
|
||||
),
|
||||
),
|
||||
_ops<FkChainRestrictBlocker>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) => FkChainRestrictBlocker(
|
||||
id: id,
|
||||
name: label,
|
||||
cascadeMiddleId: foreignKeys['cascadeMiddleId'],
|
||||
),
|
||||
),
|
||||
_ops<FkChainMiddleSetNullChild>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) => FkChainMiddleSetNullChild(
|
||||
id: id,
|
||||
name: label,
|
||||
restrictBlockerId: foreignKeys['restrictBlockerId'],
|
||||
),
|
||||
),
|
||||
_ops<FkChainMiddleCascadeChild>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) => FkChainMiddleCascadeChild(
|
||||
id: id,
|
||||
name: label,
|
||||
restrictBlockerId: foreignKeys['restrictBlockerId'],
|
||||
),
|
||||
),
|
||||
_ops<FkChainSetNullMiddle>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) => FkChainSetNullMiddle(
|
||||
id: id,
|
||||
name: label,
|
||||
cascadeMiddleId: foreignKeys['cascadeMiddleId'],
|
||||
),
|
||||
),
|
||||
_ops<FkChainSetNullCascadeChild>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) => FkChainSetNullCascadeChild(
|
||||
id: id,
|
||||
name: label,
|
||||
setNullMiddleId: foreignKeys['setNullMiddleId'],
|
||||
),
|
||||
),
|
||||
_ops<FkChainSetNullRestrictChild>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) => FkChainSetNullRestrictChild(
|
||||
id: id,
|
||||
name: label,
|
||||
setNullMiddleId: foreignKeys['setNullMiddleId'],
|
||||
),
|
||||
),
|
||||
_ops<FkChainSetNullSetNullChild>(
|
||||
canCreateRoot: false,
|
||||
create: (id, label, foreignKeys) => FkChainSetNullSetNullChild(
|
||||
id: id,
|
||||
name: label,
|
||||
setNullMiddleId: foreignKeys['setNullMiddleId'],
|
||||
),
|
||||
),
|
||||
])
|
||||
ops.name: ops,
|
||||
};
|
||||
|
||||
/// Tables synchronized by the demo, derived from [demoTableOps]. Mirrors the
|
||||
/// server `demoSyncTables`.
|
||||
final List<Table> demoSyncTables = [
|
||||
for (final ops in demoTableOps.values) ops.table,
|
||||
];
|
||||
1
example/offline_sync_demo/linux/.gitignore
vendored
Normal file
1
example/offline_sync_demo/linux/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
flutter/ephemeral
|
||||
128
example/offline_sync_demo/linux/CMakeLists.txt
Normal file
128
example/offline_sync_demo/linux/CMakeLists.txt
Normal file
|
|
@ -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 "offline_sync_demo")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "com.example.offline_sync_demo")
|
||||
|
||||
# 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 "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>: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()
|
||||
88
example/offline_sync_demo/linux/flutter/CMakeLists.txt
Normal file
88
example/offline_sync_demo/linux/flutter/CMakeLists.txt
Normal file
|
|
@ -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}
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GENERATED_PLUGIN_REGISTRANT_
|
||||
#define GENERATED_PLUGIN_REGISTRANT_
|
||||
|
||||
#include <flutter_linux/flutter_linux.h>
|
||||
|
||||
// Registers Flutter plugins.
|
||||
void fl_register_plugins(FlPluginRegistry* registry);
|
||||
|
||||
#endif // GENERATED_PLUGIN_REGISTRANT_
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
)
|
||||
|
||||
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 $<TARGET_FILE:${plugin}_plugin>)
|
||||
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)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue