Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,15 @@ All applications implement the same "Tasks" functionality:
- Shared business logic across platforms
- Task model uses `text` and `isCompleted` fields

#### Flutter
- Layered MVVM + Repository architecture (closest sibling: `kotlin-multiplatform/`)
- Material 3 widgets, `ListenableBuilder` driven by `ChangeNotifier` view models
- `lib/` layout: `data/{services,repositories}/`, `domain/models/`, `ui/{core/widgets,features/<feature>/{view_models,views}}/`
- Environment variables loaded via `flutter_dotenv`
- Parameterized DQL — all CRUD goes through `TasksRepository` with `:name` placeholders, no string interpolation against user input
- Task model uses `title`, `done`, and `deleted` fields (matches `android-kotlin/`)
- Unit tests live in `test/`; integration tests in `integration_test/` (real-device, real-Ditto)

## Key Development Considerations

### Environment Variables
Expand Down
69 changes: 69 additions & 0 deletions flutter_app/lib/data/repositories/tasks_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import 'package:ditto_live/ditto_live.dart';
import 'package:flutter_quickstart/data/services/ditto_service.dart';
import 'package:flutter_quickstart/domain/models/task.dart';

/// Single source of truth for [Task] data. Transforms raw DQL results into
/// [Task] domain models and exposes parameterized CRUD methods so callers
/// never build DQL strings.
///
/// Lifecycle:
/// - The [SyncSubscription] is registered in the constructor. Construct this
/// *before* calling [DittoService.startSync] so the subscription is included
/// in the first sync round-trip with the cloud.
/// - The [StoreObserver] is registered lazily on the first call to
/// [watchTasks]. Observer registration timing does not affect sync; only
/// the subscription does.
class TasksRepository {
TasksRepository(this._service)
: _subscription = _service.ditto.sync.registerSubscription(_tasksQuery);

static const _tasksQuery =
'SELECT * FROM tasks WHERE deleted = false ORDER BY title ASC';

final DittoService _service;
// Held to keep the subscription alive for the lifetime of this repository
// and to cancel it cleanly in [dispose].
final SyncSubscription _subscription;
StoreObserver? _observer;

Stream<List<Task>> watchTasks() {
final observer = _observer ??= _service.ditto.store.registerObserver(
_tasksQuery,
);
return observer.changes.map(
(result) =>
result.items.map((item) => Task.fromJson(item.value)).toList(),
);
}

Future<void> addTask(Task task) => _service.ditto.store.execute(
'INSERT INTO tasks DOCUMENTS (:task)',
arguments: {'task': task.toJson()},
);

Future<void> setDone({required String id, required bool done}) =>
_service.ditto.store.execute(
'UPDATE tasks SET done = :done WHERE _id = :id',
arguments: {'id': id, 'done': done},
);

Future<void> updateTitle({required String id, required String title}) =>
_service.ditto.store.execute(
'UPDATE tasks SET title = :title WHERE _id = :id',
arguments: {'id': id, 'title': title},
);

Future<void> softDelete(String id) => _service.ditto.store.execute(
'UPDATE tasks SET deleted = true WHERE _id = :id',
arguments: {'id': id},
);

Future<void> evictAll() =>
_service.ditto.store.execute('EVICT FROM tasks WHERE true');

void dispose() {
_observer?.cancel();
_subscription.cancel();
_observer = null;
}
}
73 changes: 73 additions & 0 deletions flutter_app/lib/data/services/ditto_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import 'dart:io' show Platform;

import 'package:ditto_live/ditto_live.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';

/// Wraps the [Ditto] SDK lifecycle so the rest of the app can depend on a
/// configured instance without touching SDK init details.
///
/// The caller is responsible for invoking [initialize] before constructing
/// repositories that observe the store, and for calling [startSync] *after*
/// any repositories register their subscriptions (so the very first sync
/// cycle includes those queries).
class DittoService {
Ditto? _ditto;

Ditto get ditto {
final d = _ditto;
if (d == null) {
throw StateError('DittoService.initialize() must be awaited first');
}
return d;
}

bool get isInitialized => _ditto != null;
bool get isSyncActive => _ditto?.isSyncActive ?? false;

Future<void> initialize({
required String appId,
required String playgroundToken,
required String websocketUrl,
String? authUrl,
bool isTestMode = false,
}) async {
if (_ditto != null) return;

final isMobilePlatform = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
if (isMobilePlatform && !isTestMode) {
await [
Permission.bluetoothConnect,
Permission.bluetoothAdvertise,
Permission.nearbyWifiDevices,
Permission.bluetoothScan,
].request();
}

await Ditto.init();

final identity = OnlinePlaygroundIdentity(
appID: appId,
token: playgroundToken,
// Must be false so the configured authUrl/websocketUrl are honored
// instead of the default Ditto Cloud URLs.
enableDittoCloudSync: false,
customAuthUrl: authUrl,
);
final ditto = await Ditto.open(identity: identity);

ditto.updateTransportConfig((config) {
config.setAllPeerToPeerEnabled(true);
config.connect.webSocketUrls.add(websocketUrl);
});

// Disable DQL strict mode for the quickstart's relaxed schema.
// https://docs.ditto.live/dql/strict-mode
await ditto.store.execute('ALTER SYSTEM SET DQL_STRICT_MODE = false');

_ditto = ditto;
}

void startSync() => ditto.startSync();
void stopSync() => ditto.stopSync();
}
File renamed without changes.
Loading
Loading