Skip to content
Open
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
3 changes: 3 additions & 0 deletions .fvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"flutter": "3.32.8"
}
2 changes: 1 addition & 1 deletion .github/browserstack-devices.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ flutter:
- "Google Pixel 6-12.0" # Android 12 support
ios:
devices:
- "iPhone 13-15" # iOS 15 support
- "iPhone 13-16" # iOS 16 support (min deployment target is 15.6)
- "iPhone 14-16" # iOS 16 current
- "iPhone 12-17" # iOS 17 latest

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ packages/
Pods/
target/
xcuserdata

# FVM Version Cache
.fvm/
17 changes: 14 additions & 3 deletions flutter_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,25 @@ This is a basic task application that demonstrates how to use Ditto's peer-to-pe
## Prerequisites

- Dart SDK installed
- Flutter SDK installed (tested on 3.24)
- Java Virtual Machine (JVM) 11 installed
- Flutter SDK installed (tested on 3.29/3.32)
- Java Virtual Machine (JVM) 11 or greater installed
- Git command line installed (Windows requirement)
- XCode installed (for iOS development)
- Android Studio installed (for managing the Android SDK)
- Android SDK installed
- IDE of choice (Android Studio, VS Code, etc)

### Flutter Version Manager
If you need to test on multiple versions of Flutter, it's recommended to use `fvm` (Flutter Version Manager). You can find documentation [here](https://fvm.app/documentation/getting-started/overview).

### MacOS Development
This has been tested with XCode 26.2 on MacOS 26.6 with Flutter 3.29.3 && 3.32.8.

### Windows Development
To build the Windows version of this Flutter app requires Visual Studio 2022 specifically be
installed and configured with C++ and CMake installed from the Visual Studio Installer. This has
been tested with Flutter version 3.29.3 on Windows.

## Getting Started

### 1. Clone the Repository
Expand Down Expand Up @@ -78,7 +89,7 @@ Please choose one (or "q" to quit):
> If you are going to use a physical iPhone, you will need to update the Team under Signing & Capabilities in XCode. You can open the ios/Runner.xcodeproj file in XCode and then set your team from the Runner Target -> Signing & Capabilities tab.
>

- Ensure that cocoapods is up to date
- Ensure that cocoapods is up to date (or you can use Homebrew with `brew install cocoapods`)

```bash
gem install cocoapods
Expand Down
48 changes: 27 additions & 21 deletions flutter_app/integration_test/app_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,15 @@ void main() {
(WidgetTester tester) async {
// Initialize app
await app.main();
await tester.pumpAndSettle(const Duration(seconds: 5));
// Allow up to 10 seconds for Ditto to initialise and the first sync
// exchange to complete. pumpAndSettle returns as soon as the UI is
// idle, so on fast devices this resolves much sooner.
await tester.pumpAndSettle(const Duration(seconds: 10));

// Tap "OK" button if Bluetooth permission dialog appears (iOS)
final okButton = find.text('OK');
if (okButton.evaluate().isNotEmpty) {
await tester.tap(okButton);
await tester.pumpAndSettle(const Duration(seconds: 2));
}

// Tap "Allow" button if local network permission dialog appears (iOS)
final allowButton = find.text('Allow');
if (allowButton.evaluate().isNotEmpty) {
await tester.tap(allowButton);
await tester.pumpAndSettle(const Duration(seconds: 2));
}
// NOTE: iOS system permission dialogs (Bluetooth, Local Network) are
// native UIAlertControllers and cannot be found or tapped via Flutter's
// widget-tree finders. They are handled at the XCTest layer in
// ios/RunnerTests/RunnerTests.m via addUIInterruptionMonitor.

// Verify app title is present
expect(find.text('Ditto Tasks'), findsOneWidget);
Expand All @@ -45,21 +39,33 @@ void main() {
// Verify clear button is present
expect(find.byIcon(Icons.clear), findsOneWidget);

// Wait for sync to complete
await Future.delayed(const Duration(seconds: 5));
await tester.pumpAndSettle();

// Look for the test document that should be synced from Ditto cloud
// Look for the test document that should be synced from Ditto cloud.
// The playground can accumulate many documents from previous CI runs,
// so we poll rather than waiting a fixed amount of time.
const testTitle = String.fromEnvironment('TASK_TO_FIND');

if (testTitle.isEmpty) {
throw Exception('TASK_TO_FIND environment variable must be set. '
'Build with: --dart-define=TASK_TO_FIND=<task_title>');
}

expect(find.text(testTitle), findsOneWidget,
// Poll every 500 ms for up to 45 seconds to give the cloud sync
// enough time to deliver and write all documents to the local store.
const syncTimeout = Duration(seconds: 45);
final deadline = DateTime.now().add(syncTimeout);
bool taskFound = false;

while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 500));
if (find.text(testTitle).evaluate().isNotEmpty) {
taskFound = true;
break;
}
}

expect(taskFound, isTrue,
reason:
'Should find test document with title: $testTitle synced from Ditto cloud');
'Should find test document with title: $testTitle synced from Ditto cloud within ${syncTimeout.inSeconds}s');
});
});
}
12 changes: 6 additions & 6 deletions flutter_app/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
PODS:
- ditto_live (4.14.3):
- DittoFlutter (= 4.14.3)
- ditto_live (5.0.0):
- DittoFlutter (= 5.0.0)
- Flutter
- DittoFlutter (4.14.3)
- DittoFlutter (5.0.0)
- Flutter (1.0.0)
- integration_test (0.0.1):
- Flutter
Expand Down Expand Up @@ -36,9 +36,9 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"

SPEC CHECKSUMS:
ditto_live: 01cde75b8b98f45b65c13dfad5b4667e1bcf0306
DittoFlutter: 1745a5b3152fb758f1604f5f9a26fb3d64362dba
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
ditto_live: f038d915f2ad9206d180d3fd8b231d149b71e3f2
DittoFlutter: 90c4bcdf128b0a14bf8f6f923369e7a6ddccd27d
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
Expand Down
3 changes: 3 additions & 0 deletions flutter_app/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@
DEVELOPMENT_TEAM = 3T2VMFZPPQ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand Down Expand Up @@ -685,6 +686,7 @@
DEVELOPMENT_TEAM = 3T2VMFZPPQ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand All @@ -710,6 +712,7 @@
DEVELOPMENT_TEAM = 3T2VMFZPPQ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
Expand All @@ -51,9 +52,10 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
Expand Down
8 changes: 7 additions & 1 deletion flutter_app/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@
<string>Uses WiFi to connect and sync with nearby devices.</string>
<key>NSBonjourServices</key>
<array>
<string>_http-alt._tcp.</string>
<string>_http-alt._tcp.</string>
</array>
<!-- Required by the Ditto SDK for BLE sync to operate in the background -->
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
</dict>
</plist>
47 changes: 45 additions & 2 deletions flutter_app/ios/RunnerTests/RunnerTests.m
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
@import XCTest;
@import integration_test;
INTEGRATION_TEST_IOS_RUNNER(RunnerTests)
@import integration_test;

// This file replaces the one-liner INTEGRATION_TEST_IOS_RUNNER macro so we
// can add a UIInterruptionMonitor. Without it, native iOS system permission
// dialogs (Bluetooth, Local Network, etc.) block the test and can never be
// dismissed by Flutter's find.text() which only searches Flutter widgets.
//
// The interruption monitor fires whenever a springboard-level alert appears
// during the test and automatically taps the first "accept" button it finds.

@interface RunnerTests : XCTestCase
@end

@implementation RunnerTests

+ (XCTestSuite *)defaultTestSuite {
return [IntegrationTestIosRunner defaultTestSuiteForIntegrationTestRunner:self];
}

- (void)setUp {
[super setUp];

// Automatically accept system permission dialogs so that Ditto's
// Bluetooth and Local Network transports can initialise during tests.
// "OK" – Bluetooth usage alert
// "Allow" / "Allow While Using App" – Local Network usage alert
[self addUIInterruptionMonitorWithDescription:@"System Permission Alert"
handler:^BOOL(XCUIElement *alert) {
NSArray<NSString *> *acceptLabels = @[
@"OK",
@"Allow",
@"Allow While Using App"
];
for (NSString *label in acceptLabels) {
XCUIElement *button = alert.buttons[label];
if (button.exists) {
[button tap];
return YES;
}
}
return NO;
}];
}

@end
6 changes: 6 additions & 0 deletions flutter_app/lib/dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ class _DialogState extends State<_Dialog> {
late final _name = TextEditingController(text: widget.taskToEdit?.title);
late var _done = widget.taskToEdit?.done ?? false;

@override
void dispose() {
_name.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) => AlertDialog(
icon: const Icon(Icons.add_task),
Expand Down
64 changes: 37 additions & 27 deletions flutter_app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ class _DittoExampleState extends State<DittoExample> {
dotenv.env['DITTO_APP_ID'] ?? (throw Exception("env not found"));
final token = dotenv.env['DITTO_PLAYGROUND_TOKEN'] ??
(throw Exception("env not found"));
final authUrl = dotenv.env['DITTO_AUTH_URL'];
final websocketUrl =
dotenv.env['DITTO_WEBSOCKET_URL'] ?? (throw Exception("env not found"));
final authUrl =
dotenv.env['DITTO_AUTH_URL'] ?? (throw Exception("env not found"));

@override
void initState() {
Expand Down Expand Up @@ -70,26 +69,31 @@ class _DittoExampleState extends State<DittoExample> {

await Ditto.init();

final identity = OnlinePlaygroundIdentity(
appID: appID,
token: token,
enableDittoCloudSync:
false, // This is required to be set to false to use the correct URLs
customAuthUrl: authUrl);

final ditto = await Ditto.open(identity: identity);

ditto.updateTransportConfig((config) {
// Note: this will not enable peer-to-peer sync on the web platform
config.setAllPeerToPeerEnabled(true);
config.connect.webSocketUrls.add(websocketUrl);
DittoLogger.isEnabled = true;
DittoLogger.minimumLogLevel = LogLevel.debug;

//new configuration - https://docs.ditto.live/sdk/latest/ditto-config
final config = DittoConfig(
databaseID: appID, connect: DittoConfigConnectServer(url: authUrl));
final ditto = await Ditto.open(config);
await ditto.auth.setExpirationHandler((ditto, timeUntilExpiration) async {
final authResult = await ditto.auth
.login(token: token, provider: Authenticator.developmentProvider);
if (authResult.exception != null) {
throw authResult.exception!;
}
});

// Disable DQL strict mode
// https://docs.ditto.live/dql/strict-mode
await ditto.store.execute("ALTER SYSTEM SET DQL_STRICT_MODE = false");
// Register the tasks subscription before starting sync so it is
// included in the very first sync exchange with the cloud.
// Without this, the subscription is registered later when the
// DqlBuilder widget builds, causing a 5–10 second delay on the
// first sync cycle.
ditto.sync.registerSubscription(
"SELECT * FROM tasks WHERE deleted = false",
);

ditto.startSync();
ditto.sync.start();

if (mounted) {
setState(() => _ditto = ditto);
Expand Down Expand Up @@ -167,19 +171,22 @@ class _DittoExampleState extends State<DittoExample> {

Widget get _syncTile => SwitchListTile(
title: const Text("Sync Active"),
value: _ditto!.isSyncActive,
value: _ditto!.sync.isActive,
onChanged: (value) {
if (value) {
setState(() => _ditto!.startSync());
setState(() => _ditto!.sync.start());
} else {
setState(() => _ditto!.stopSync());
setState(() => _ditto!.sync.stop());
}
},
);

// Ordering is intentionally omitted here because this screen currently uses
// a single DqlBuilder query, and reintroducing title-based ordering would
// require a different query approach.
Widget get _tasksList => DqlBuilder(
ditto: _ditto!,
query: "SELECT * FROM tasks WHERE deleted = false ORDER BY title ASC",
query: "SELECT * FROM tasks WHERE deleted = false",
builder: (context, result) {
final tasks = result.items.map((r) => r.value).map(Task.fromJson);
return ListView(
Expand All @@ -194,7 +201,8 @@ class _DittoExampleState extends State<DittoExample> {
// Use the Soft-Delete pattern
// https://docs.ditto.live/sdk/latest/crud/delete#soft-delete-pattern
await _ditto!.store.execute(
"UPDATE tasks SET deleted = true WHERE _id = '${task.id}'",
"UPDATE tasks SET deleted = true WHERE _id = :id",
arguments: {"id": task.id},
);

if (mounted) {
Expand All @@ -209,7 +217,8 @@ class _DittoExampleState extends State<DittoExample> {
title: Text(task.title),
value: task.done,
onChanged: (value) => _ditto!.store.execute(
"UPDATE tasks SET done = $value WHERE _id = '${task.id}'",
"UPDATE tasks SET done = :done WHERE _id = :id",
arguments: {"done": value, "id": task.id},
),
secondary: IconButton(
icon: const Icon(Icons.edit),
Expand All @@ -220,7 +229,8 @@ class _DittoExampleState extends State<DittoExample> {

// https://docs.ditto.live/sdk/latest/crud/update
_ditto!.store.execute(
"UPDATE tasks SET title = '${newTask.title}' where _id = '${task.id}'",
"UPDATE tasks SET title = :title WHERE _id = :id",
arguments: {"title": newTask.title, "id": task.id},
);
},
),
Expand Down
2 changes: 1 addition & 1 deletion flutter_app/macos/Podfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
platform :osx, '10.14'
platform :osx, '12.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
Expand Down
Loading
Loading