feat: Add an example app and misc fixes (#81)

This commit is contained in:
Marcelo Mendonça Soares 2026-06-23 22:53:47 -03:00 • committed by GitHub
commit de7e22d91a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 9138 additions and 61 deletions

45
example/offline_sync_demo/.gitignore vendored Normal file
View 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

View 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'

View 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
```

View file

@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:

View 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),
);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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);
}

View 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();
}

View 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('_');
}

View 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,
),
];
},
),
];
}
}

View 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,
);
}
}

View 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,
};
}

View 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,
];

View file

@ -0,0 +1 @@
flutter/ephemeral

View 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()

View 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}
)

View file

@ -0,0 +1,11 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
void fl_register_plugins(FlPluginRegistry* registry) {
}

View file

@ -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_

View file

@ -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