diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index 4f2b1b8dd..81325b202 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -1,13 +1,14 @@ import 'dart:io' show Platform; +import 'dart:convert'; +import 'dart:async'; import 'package:ditto_live/ditto_live.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_quickstart/dialog.dart'; -import 'package:flutter_quickstart/dql_builder.dart'; -import 'package:flutter_quickstart/task.dart'; import 'package:flutter/material.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_quickstart/models/event.dart'; +import 'package:flutter_quickstart/services/event_generator.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -25,9 +26,22 @@ class DittoExample extends StatefulWidget { class _DittoExampleState extends State { Ditto? _ditto; + EventGenerator? _eventGenerator; + bool _observerActive = false; + + // FIXED TIMESTAMP: Set once at observer start (matches customer's setup) + // Customer observes ALL events from match start - timestamp NEVER changes + int? _matchStartTimestampUs; + + // CUSTOMER PATTERN: Manual observer and StreamController management + StoreObserver? _observer; + SyncSubscription? _subscription; + StreamController>? _eventStreamController; + final appID = dotenv.env['DITTO_APP_ID'] ?? (throw Exception("env not found")); - final token = dotenv.env['DITTO_PLAYGROUND_TOKEN'] ?? + final token = + dotenv.env['DITTO_PLAYGROUND_TOKEN'] ?? (throw Exception("env not found")); final authUrl = dotenv.env['DITTO_AUTH_URL']; final websocketUrl = @@ -53,8 +67,10 @@ class _DittoExampleState extends State { /// 7. Starts sync and updates the app state with the configured Ditto instance Future _initDitto() async { // Skip permissions in test mode - they block integration tests - const isTestMode = - bool.fromEnvironment('INTEGRATION_TEST_MODE', defaultValue: false); + const isTestMode = bool.fromEnvironment( + 'INTEGRATION_TEST_MODE', + defaultValue: false, + ); // Only request permissions on mobile platforms (Android/iOS) // Desktop platforms (macOS, Windows, Linux) don't require these permissions @@ -64,24 +80,27 @@ class _DittoExampleState extends State { Permission.bluetoothConnect, Permission.bluetoothAdvertise, Permission.nearbyWifiDevices, - Permission.bluetoothScan + Permission.bluetoothScan, ].request(); } 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); + 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 + // Enable all peer-to-peer transports (BLE, WiFi Direct, LAN) config.setAllPeerToPeerEnabled(true); + + // Enable cloud sync via WebSocket config.connect.webSocketUrls.add(websocketUrl); }); @@ -91,25 +110,139 @@ class _DittoExampleState extends State { ditto.startSync(); + // Initialize event generator for CPU reproduction test + final eventGenerator = EventGenerator(ditto); + if (mounted) { - setState(() => _ditto = ditto); + setState(() { + _ditto = ditto; + _eventGenerator = eventGenerator; + }); + } + } + + Future _toggleEventGeneration() async { + if (_eventGenerator!.isRunning) { + _eventGenerator!.stop(); + } else { + await _eventGenerator!.start(); } + setState(() {}); } - Future _addTask() async { - final task = await showAddTaskDialog(context); - if (task == null) return; + Future _toggleObserver() async { + if (_observerActive) { + _stopCustomerObserver(); + } else { + _startCustomerObserver(); + } + setState(() { + _observerActive = !_observerActive; + }); + } + + // CUSTOMER'S EXACT OBSERVER PATTERN - Manual StreamController + onChange + void _startCustomerObserver() { + print('DittoStorage: Starting customer observer pattern'); + + // Set timestamp ONCE at match start (customer's pattern) + // This creates a FIXED timestamp that never changes - all events from this point forward accumulate + _matchStartTimestampUs = DateTime.now().microsecondsSinceEpoch; + print('DittoStorage: Fixed timestamp set to: $_matchStartTimestampUs'); + print( + 'DittoStorage: All events from this timestamp forward will accumulate (growing result set)', + ); + + // Create StreamController with onCancel callback + _eventStreamController = StreamController>( + onCancel: () { + print('DittoStorage: streamData cancelled'); + _observer?.cancel(); + }, + ); + + const observerQuery = """ + SELECT * FROM events + USE DIRECTIVES '{ + "#index": ["event_timestamp_index", "eventcourtid_index"], + "#prefer_order": true + }' + WHERE courtId == :courtId + AND timestamp_us >= :threshold + ORDER BY timestamp_us DESC + """; + + const subscriptionQuery = """ + SELECT * FROM events + WHERE courtId == :courtId + AND timestamp_us >= :threshold + """; + + // ⚠ïļ FIXED TIMESTAMP - Set once, NEVER recalculated! + // Customer's pattern: timestamp set at match start, accumulates ALL events over time + final arguments = { + "courtId": "tour-5", + "threshold": _matchStartTimestampUs!, + }; + + // Register observer with onChange callback - CUSTOMER'S EXACT PATTERN + _observer = _ditto!.store.registerObserver( + observerQuery, + arguments: arguments, + onChange: (QueryResult qr) { + final timestamp = DateTime.now().toIso8601String(); + print('🔍 [$timestamp] MATERIALIZING CALLBACK TRIGGERED'); + print(' 📊 Total items in qr.items: ${qr.items.length}'); + + // ⚠ïļ CUSTOMER PATTERN: Iterate through ALL items (even with LIMIT 1) + var itemCount = 0; + for (var item in qr.items) { + itemCount++; + print(' 📄 Materializing item #$itemCount/${qr.items.length}'); - // https://docs.ditto.live/sdk/latest/crud/create - await _ditto!.store.execute( - "INSERT INTO tasks DOCUMENTS (:task)", - arguments: {"task": task.toJson()}, + if (!_eventStreamController!.isClosed) { + _eventStreamController!.add(item.value); // High CPU even if removed + print(' ✅ Added item #$itemCount to StreamController'); + } else { + print(' ⚠ïļ StreamController is closed, cancelling observer'); + _observer?.cancel(); + _observer = null; + } + } + print(' ✅ Materialization complete: $itemCount items processed\n'); + }, + ); + + // Register subscription + _subscription = _ditto!.sync.registerSubscription( + subscriptionQuery, + arguments: arguments, ); + + // Setup error handling and onCancel + _eventStreamController!.stream.handleError((e, st) { + print("DittoStorage handleError ($e) cancel streamData controller. $st"); + if (!_eventStreamController!.isClosed) _eventStreamController!.close(); + if (_observer != null && !_observer!.isCancelled) _observer!.cancel(); + }); } - Future _clearTasks() async { - // https://docs.ditto.live/sdk/latest/crud/delete#evicting-data - await _ditto!.store.execute("EVICT FROM tasks WHERE true"); + void _stopCustomerObserver() { + print('DittoStorage: Stopping customer observer pattern'); + + _observer?.cancel(); + _observer = null; + + _subscription?.cancel(); + _subscription = null; + + if (_eventStreamController != null && !_eventStreamController!.isClosed) { + _eventStreamController!.close(); + } + _eventStreamController = null; + + // Reset timestamp for next observer session + _matchStartTimestampUs = null; } @override @@ -117,124 +250,422 @@ class _DittoExampleState extends State { if (_ditto == null) return _loading; return Scaffold( - appBar: AppBar( - title: const Text("Ditto Tasks"), - actions: [ - IconButton( - icon: const Icon(Icons.clear), - tooltip: "Clear", - onPressed: _clearTasks, - ), - ], + appBar: AppBar(title: const Text("Ditto CPU Reproduction")), + body: SingleChildScrollView( + child: Column( + children: [ + _portalInfo, + _syncTile, + const Divider(), + _reproductionControls, + const Divider(), + if (_observerActive) + Container( + constraints: const BoxConstraints( + minHeight: 400, + maxHeight: 600, + ), + child: _eventObserver, + ), + ], + ), ), - floatingActionButton: _fab, - body: Column( + ); + } + + Widget get _loading => Scaffold( + appBar: AppBar(title: const Text("Ditto Tasks")), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, // Center vertically + crossAxisAlignment: CrossAxisAlignment.center, // Center horizontally children: [ + const CircularProgressIndicator(), + const Text("Ensure your AppID and Token are correct"), _portalInfo, - _syncTile, + ], + ), + ), + ); + + Widget get _portalInfo => + Column(children: [Text("AppID: $appID"), Text("Token: $token")]); + + Widget get _syncTile => SwitchListTile( + title: const Text("Sync Active"), + value: _ditto!.isSyncActive, + onChanged: (value) { + if (value) { + setState(() => _ditto!.startSync()); + } else { + setState(() => _ditto!.stopSync()); + } + }, + ); + + Widget get _reproductionControls => Card( + margin: const EdgeInsets.all(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "SDKS-2280: CPU Reproduction Test", + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + // PRODUCER MODE + const Text( + "Producer Mode:", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 8), + + // Event Size Toggle + Row( + children: [ + const Text( + "Event Size:", + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 16), + ChoiceChip( + label: const Text("Small (~100B)"), + selected: _eventGenerator?.eventSize == EventSize.small, + onSelected: (_) { + _eventGenerator?.setEventSize(EventSize.small); + setState(() {}); + }, + ), + const SizedBox(width: 8), + ChoiceChip( + label: const Text("Large (3.3KB)"), + selected: _eventGenerator?.eventSize == EventSize.large, + onSelected: (_) { + _eventGenerator?.setEventSize(EventSize.large); + setState(() {}); + }, + ), + ], + ), + const SizedBox(height: 8), + + // Continuous Generation + SwitchListTile( + title: Text( + "Continuous Generation (${_eventGenerator?.eventsPerSecond.toStringAsFixed(1) ?? '1.0'}/sec)", + ), + subtitle: Text( + "Generated this session: ${_eventGenerator?.eventCount ?? 0}", + ), + value: _eventGenerator?.isRunning ?? false, + onChanged: (_) => _toggleEventGeneration(), + dense: true, + contentPadding: EdgeInsets.zero, + ), + const SizedBox(height: 8), + + // Event Generation Speed Slider + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Generation Speed:", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + Row( + children: [ + const Text("0.5", style: TextStyle(fontSize: 12)), + Expanded( + child: Slider( + value: _eventGenerator?.eventsPerSecond ?? 1.0, + min: 0.5, + max: 10.0, + divisions: 19, + label: + "${_eventGenerator?.eventsPerSecond.toStringAsFixed(1) ?? '1.0'}/sec", + onChanged: (value) { + setState(() { + _eventGenerator?.setSpeed(value); + }); + }, + ), + ), + const Text("10", style: TextStyle(fontSize: 12)), + ], + ), + Center( + child: Text( + "${_eventGenerator?.eventsPerSecond.toStringAsFixed(1) ?? '1.0'} events/sec", + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + ], + ), + const Divider(), - Expanded(child: _tasksList), + + // CONSUMER MODE + const Text( + "Consumer Mode:", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 8), + + // Observer Toggle + SwitchListTile( + title: const Text("Observer Active"), + subtitle: const Text("Monitor events with production query"), + value: _observerActive, + onChanged: (_) => _toggleObserver(), + dense: true, + contentPadding: EdgeInsets.zero, + ), ], ), - ); - } + ), + ); - Widget get _loading => Scaffold( - appBar: AppBar(title: const Text("Ditto Tasks")), - body: Center( + // CUSTOMER'S EXACT OBSERVER PATTERN - Using StreamBuilder with manual StreamController + Widget get _eventObserver => StreamBuilder>( + stream: _eventStreamController?.stream, + builder: (context, snapshot) { + // Handle no data yet + if (!snapshot.hasData) { + return const Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, // Center vertically - crossAxisAlignment: - CrossAxisAlignment.center, // Center horizontally + mainAxisAlignment: MainAxisAlignment.center, children: [ - const CircularProgressIndicator(), - const Text("Ensure your AppID and Token are correct"), - _portalInfo + Icon(Icons.event_note, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + "No events yet", + style: TextStyle(fontSize: 18, color: Colors.grey), + ), + SizedBox(height: 8), + Text( + "Enable 'Observe Events' and 'Generate Events' to create test events", + style: TextStyle(color: Colors.grey), + ), ], ), - ), - ); + ); + } - Widget get _fab => FloatingActionButton( - onPressed: _addTask, - child: const Icon(Icons.add_task), - ); + // Parse the single event from the stream + final eventData = snapshot.data!; + final event = Event.fromJson(eventData); - Widget get _portalInfo => Column(children: [ - Text("AppID: $appID"), - Text("Token: $token"), - ]); + // 🔍 DETAILED CLI LOGGING + print('\n${'=' * 80}'); + print('📊 OBSERVER UPDATE - ${DateTime.now().toIso8601String()}'); + print('=' * 80); + print('Event received from StreamController'); - Widget get _syncTile => SwitchListTile( - title: const Text("Sync Active"), - value: _ditto!.isSyncActive, - onChanged: (value) { - if (value) { - setState(() => _ditto!.startSync()); - } else { - setState(() => _ditto!.stopSync()); - } - }, - ); - - Widget get _tasksList => DqlBuilder( - ditto: _ditto!, - query: "SELECT * FROM tasks WHERE deleted = false ORDER BY title ASC", - builder: (context, result) { - final tasks = result.items.map((r) => r.value).map(Task.fromJson); - return ListView( - children: tasks.map(_singleTask).toList(), + // Show peer presence and transports + final peers = _ditto?.presence.graph.remotePeers ?? []; + print('Connected peers: ${peers.length}'); + for (var peer in peers.take(3)) { + final connections = peer.connections; + final peerId = peer.deviceName ?? peer.peerKeyString.substring(0, 8); + print(' â€Ē Peer $peerId'); + for (var conn in connections) { + final transport = conn.connectionType; + print( + ' → $transport ${conn.approximateDistanceInMeters != null ? "(~${conn.approximateDistanceInMeters}m)" : ""}', ); - }, - ); + } + } - Widget _singleTask(Task task) => Dismissible( - key: Key("${task.id}-${task.title}"), - onDismissed: (direction) async { - // 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}'", - ); + // Log event with C++ detection + final metadata = event.metadata; + final deviceId = metadata?['deviceId'] ?? 'unknown'; + final sdkVersion = metadata?['sdkVersion'] ?? 'unknown'; + final isCpp = deviceId == 'cpp-client-1'; - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Deleted Task ${task.title}")), - ); - } - }, - background: _dismissibleBackground(true), - secondaryBackground: _dismissibleBackground(false), - child: CheckboxListTile( - title: Text(task.title), - value: task.done, - onChanged: (value) => _ditto!.store.execute( - "UPDATE tasks SET done = $value WHERE _id = '${task.id}'", + print('\n${isCpp ? "ðŸŽŊ" : "📄"} Event${isCpp ? " [C++ CLIENT]" : ""}:'); + print(' â€Ē Court: ${event.courtId}'); + print(' â€Ē Device: $deviceId'); + print(' â€Ē SDK: $sdkVersion'); + print(' â€Ē Payload: ${event.payload != null ? "Yes (~3KB)" : "No"}'); + print(' â€Ē Timestamp: ${event.timestamp_us}'); + print('=' * 80 + '\n'); + + // Calculate event size for verification + final json = jsonEncode(event.toJson()); + final sizeKB = utf8.encode(json).length / 1024; + final hasPayload = event.payload != null; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Latest Event", + style: Theme.of(context).textTheme.titleLarge, + ), + const Divider(), + _eventField("Event Type", event.eventType), + _eventField("Court ID", event.courtId), + _eventField("Timestamp (Ξs)", event.timestamp_us.toString()), + _eventField( + "Time", + DateTime.fromMicrosecondsSinceEpoch( + event.timestamp_us, + ).toString(), + ), + if (event.id != null) _eventField("Event ID", event.id!), + _eventField( + "Document Size", + "${sizeKB.toStringAsFixed(2)} KB ${hasPayload ? '(Large)' : '(Small)'}", + ), + _eventField("Has Payload", hasPayload ? "Yes" : "No"), + ], + ), + ), ), - secondary: IconButton( - icon: const Icon(Icons.edit), - tooltip: "Edit Task", - onPressed: () async { - final newTask = await showAddTaskDialog(context, task); - if (newTask == null) return; - - // https://docs.ditto.live/sdk/latest/crud/update - _ditto!.store.execute( - "UPDATE tasks SET title = '${newTask.title}' where _id = '${task.id}'", - ); - }, + const SizedBox(height: 16), + // Connection Info + Card( + color: Colors.green.shade50, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "🌐 Connection Info", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + "Connected Peers: ${peers.length}", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + if (peers.isEmpty) + const Text( + "No peers connected", + style: TextStyle(color: Colors.grey), + ) + else + ...peers.take(3).map((peer) { + final peerId = + peer.deviceName ?? peer.peerKeyString.substring(0, 8); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "â€Ē Peer: $peerId", + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ...peer.connections.map((conn) { + final distance = conn.approximateDistanceInMeters; + return Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + "→ ${conn.connectionType}${distance != null ? " (~${distance}m)" : ""}", + style: const TextStyle(fontSize: 12), + ), + ); + }), + ], + ), + ); + }), + ], + ), + ), ), - ), + const SizedBox(height: 16), + // Customer Pattern Info + Card( + color: Colors.orange.shade50, + child: const Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "⚠ïļ Customer Pattern Active", + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text("Using manual StreamController + onChange callback"), + Text("â€Ē Iterates through ALL items in qr.items"), + Text("â€Ē Adds each item to StreamController"), + Text("â€Ē Shows only latest event (LIMIT 1)"), + Text("â€Ē This pattern may cause high CPU usage"), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + color: Colors.blue.shade50, + child: const Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "â„đïļ CPU Monitoring", + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + "Use Android Studio Profiler or Xcode Instruments to measure CPU usage:", + ), + SizedBox(height: 4), + Text("â€Ē Baseline: ~5-10% (no events, no observer)"), + Text("â€Ē Events only: ~10-20%"), + Text("â€Ē Observer only: Expected ~50% (idle CPU issue)"), + Text("â€Ē Full load: Expected ~70% (active CPU issue)"), + ], + ), + ), + ), + ], ); + }, + ); - Widget _dismissibleBackground(bool primary) => Container( - color: Colors.red, - child: Align( - alignment: primary ? Alignment.centerLeft : Alignment.centerRight, - child: const Padding( - padding: EdgeInsets.all(8.0), - child: Icon(Icons.delete), + Widget _eventField(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text( + "$label:", + style: const TextStyle(fontWeight: FontWeight.bold), ), ), - ); + Expanded(child: Text(value)), + ], + ), + ); + + @override + void dispose() { + // Clean up customer observer pattern resources + _stopCustomerObserver(); + super.dispose(); + } } diff --git a/flutter_app/lib/services/event_generator.dart b/flutter_app/lib/services/event_generator.dart new file mode 100644 index 000000000..8b0f4616d --- /dev/null +++ b/flutter_app/lib/services/event_generator.dart @@ -0,0 +1,222 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:ditto_live/ditto_live.dart'; +import '../models/event.dart'; + +enum EventSize { small, large } + +class EventGenerator { + final Ditto ditto; + Timer? _timer; + int _eventCount = 0; + bool _isRunning = false; + EventSize _eventSize = EventSize.small; + double _eventsPerSecond = 1.0; // Default: 1 event/sec + + EventGenerator(this.ditto); + + Future start() async { + if (_isRunning) return; + + await _createIndices(); + _isRunning = true; + _startTimer(); + } + + void _startTimer() { + final intervalMs = (1000 / _eventsPerSecond).round(); + _timer = Timer.periodic( + Duration(milliseconds: intervalMs), + (_) => _generateEvent(large: _eventSize == EventSize.large) + ); + } + + Future _createIndices() async { + // Create indices matching the original bug report + await ditto.store.execute( + "CREATE INDEX IF NOT EXISTS event_courtid_index ON events (courtId)" + ); + await ditto.store.execute( + "CREATE INDEX IF NOT EXISTS event_timestamp_index ON events (timestamp_us)" + ); + } + + Future _generateEvent({bool large = false}) async { + try { + final event = Event( + courtId: 'tour-5', + timestamp_us: DateTime.now().microsecondsSinceEpoch, + eventType: 'game_event', + deleted: false, + payload: large ? _generateLargePayload() : null, + metadata: large ? _generateMetadata() : null, + ); + + await ditto.store.execute( + "INSERT INTO events DOCUMENTS (:event)", + arguments: {"event": event.toJson()}, + ); + _eventCount++; + + // Verify event size if large + if (large && _eventCount == 1) { + final json = jsonEncode(event.toJson()); + final sizeKB = utf8.encode(json).length / 1024; + print('📏 Event size: ${sizeKB.toStringAsFixed(2)} KB (target: ~3.3KB)'); + } + + print('✅ Event created: $_eventCount (timestamp: ${event.timestamp_us})'); + } catch (e) { + print('❌ Error generating event: $e'); + } + } + + String _generateLargePayload() { + // Generate realistic tennis match data to reach ~3KB + final data = { + 'points': List.generate(50, (i) => { + 'number': i, + 'score': '${(i ~/ 2)} - ${i - (i ~/ 2)}', + 'timestamp': DateTime.now().millisecondsSinceEpoch - (i * 1000), + 'server': i % 2 == 0 ? 'player1' : 'player2', + 'winner': i % 3 == 0 ? 'player1' : 'player2', + }), + 'players': { + 'player1': { + 'name': 'Player One', + 'ranking': 1, + 'country': 'USA', + 'stats': { + 'aces': 12, + 'doubleFaults': 3, + 'firstServePercentage': 65.5, + 'winOnFirstServe': 78.2, + }, + }, + 'player2': { + 'name': 'Player Two', + 'ranking': 2, + 'country': 'ESP', + 'stats': { + 'aces': 10, + 'doubleFaults': 2, + 'firstServePercentage': 68.3, + 'winOnFirstServe': 75.8, + }, + }, + }, + 'ballTracking': List.generate(100, (i) => { + 'x': i * 0.5, + 'y': (i % 10) * 1.2, + 'z': 0.3, + 'velocity': 120.5 + (i % 20), + 'spin': i * 10, + 'timestamp': i * 10, + }), + 'notes': List.generate(20, (i) => + 'Event annotation number $i with additional descriptive text to increase document size. ' + 'This text is repeated to pad the document to approximately 3.3 kilobytes as required for production testing. ' + 'Additional metadata and context can be included here for realistic data representation.' + ), + }; + + return jsonEncode(data); + } + + Map _generateMetadata() { + return { + 'deviceId': 'flutter-test-device', + 'appVersion': '1.0.0', + 'sdkVersion': '4.13.0', + 'generatedAt': DateTime.now().toIso8601String(), + 'courtInfo': { + 'surface': 'hard', + 'indoor': false, + 'temperature': 22.5, + 'humidity': 45.2, + }, + }; + } + + void stop() { + _timer?.cancel(); + _timer = null; + _isRunning = false; + } + + bool get isRunning => _isRunning; + int get eventCount => _eventCount; + + Future clearEvents() async { + try { + await ditto.store.execute("EVICT FROM events WHERE true"); + _eventCount = 0; + } catch (e) { + print('Error clearing events: $e'); + } + } + + // NEW: Bulk generation method + Future generateBulk(int count, {bool large = true}) async { + print('🚀 Generating $count ${large ? "large (3.3KB)" : "small (~100B)"} events...'); + + await _createIndices(); + final startTime = DateTime.now(); + + for (int i = 0; i < count; i++) { + await _generateEvent(large: large); + + if ((i + 1) % 100 == 0) { + final elapsed = DateTime.now().difference(startTime).inSeconds; + print('📊 Progress: ${i + 1}/$count events (${elapsed}s elapsed)'); + } + } + + final totalTime = DateTime.now().difference(startTime); + print('✅ Generated $count events in ${totalTime.inSeconds}s (${(count / totalTime.inSeconds).toStringAsFixed(1)} events/sec)'); + } + + // NEW: Get total event count + Future getEventCount({String? courtId}) async { + try { + final query = courtId != null + ? "SELECT COUNT(*) as count FROM events WHERE courtId = :courtId" + : "SELECT COUNT(*) as count FROM events"; + + final result = await ditto.store.execute( + query, + arguments: courtId != null ? {"courtId": courtId} : {}, + ); + + if (result.items.isEmpty) return 0; + return result.items.first.value['count'] as int; + } catch (e) { + print('❌ Error getting event count: $e'); + return 0; + } + } + + // NEW: Set event size mode + void setEventSize(EventSize size) { + _eventSize = size; + } + + EventSize get eventSize => _eventSize; + + // NEW: Set generation speed (events per second) + void setSpeed(double eventsPerSecond) { + _eventsPerSecond = eventsPerSecond; + + // Restart timer with new interval if currently running + if (_isRunning) { + _timer?.cancel(); + _startTimer(); + } + } + + double get eventsPerSecond => _eventsPerSecond; + + void dispose() { + stop(); + } +}