From e9e3ff495762593360c9a7a20773c4c1bec25204 Mon Sep 17 00:00:00 2001 From: Husamettin ARABACI Date: Thu, 30 Oct 2025 06:14:32 +0300 Subject: [PATCH] release: release/v1.0.0 (#34) * feat(multicore): fixed multicore Fixed channel for multicore tasks * feat(multicore): fixed multicore Fixed channel for multicore tasks * release: v1.0.0 --- hexagenapp/lib/l10n/app_en.arb | 1 + hexagenapp/lib/l10n/app_localizations.dart | 6 + hexagenapp/lib/l10n/app_localizations_en.dart | 3 + hexagenapp/lib/l10n/app_localizations_tr.dart | 3 + hexagenapp/lib/l10n/app_tr.arb | 1 + hexagenapp/lib/main.dart | 38 -- hexagenapp/lib/src/app.dart | 2 +- hexagenapp/lib/src/core/at/at.dart | 94 ++++- hexagenapp/lib/src/core/device/device.dart | 46 ++- hexagenapp/lib/src/core/error/error.dart | 5 + .../lib/src/core/service/device_service.dart | 127 ++++++- .../core/service/notification_service.dart | 230 ------------ .../lib/src/pages/{main.dart => layout.dart} | 328 +++++++++++++++--- hexagenapp/lib/src/pages/settings.dart | 23 +- hexagenapp/pubspec.yaml | 2 - 15 files changed, 549 insertions(+), 360 deletions(-) delete mode 100644 hexagenapp/lib/src/core/service/notification_service.dart rename hexagenapp/lib/src/pages/{main.dart => layout.dart} (57%) diff --git a/hexagenapp/lib/l10n/app_en.arb b/hexagenapp/lib/l10n/app_en.arb index f968263..7dcd5d5 100644 --- a/hexagenapp/lib/l10n/app_en.arb +++ b/hexagenapp/lib/l10n/app_en.arb @@ -65,6 +65,7 @@ "paramValue": "Param value", "notAQuery": "Not a query", "unknownCommand": "Unknown command", + "operationStepsFull": "Operation steps full (max 64)", "deviceNoDeviceConnected": "No Device Connected", "devicePleaseConnect": "Please connect a hexaTune device", diff --git a/hexagenapp/lib/l10n/app_localizations.dart b/hexagenapp/lib/l10n/app_localizations.dart index 26a24db..8d79759 100644 --- a/hexagenapp/lib/l10n/app_localizations.dart +++ b/hexagenapp/lib/l10n/app_localizations.dart @@ -332,6 +332,12 @@ abstract class AppLocalizations { /// **'Unknown command'** String get unknownCommand; + /// No description provided for @operationStepsFull. + /// + /// In en, this message translates to: + /// **'Operation steps full (max 64)'** + String get operationStepsFull; + /// No description provided for @deviceNoDeviceConnected. /// /// In en, this message translates to: diff --git a/hexagenapp/lib/l10n/app_localizations_en.dart b/hexagenapp/lib/l10n/app_localizations_en.dart index c66e169..b9ab92e 100644 --- a/hexagenapp/lib/l10n/app_localizations_en.dart +++ b/hexagenapp/lib/l10n/app_localizations_en.dart @@ -133,6 +133,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get unknownCommand => 'Unknown command'; + @override + String get operationStepsFull => 'Operation steps full (max 64)'; + @override String get deviceNoDeviceConnected => 'No Device Connected'; diff --git a/hexagenapp/lib/l10n/app_localizations_tr.dart b/hexagenapp/lib/l10n/app_localizations_tr.dart index 3245ff5..fe1907e 100644 --- a/hexagenapp/lib/l10n/app_localizations_tr.dart +++ b/hexagenapp/lib/l10n/app_localizations_tr.dart @@ -133,6 +133,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get unknownCommand => 'Bilinmeyen komut'; + @override + String get operationStepsFull => 'İşlem adımları dolu (maks 64)'; + @override String get deviceNoDeviceConnected => 'Cihaz Bağlı Değil'; diff --git a/hexagenapp/lib/l10n/app_tr.arb b/hexagenapp/lib/l10n/app_tr.arb index bf20c04..64f18f7 100644 --- a/hexagenapp/lib/l10n/app_tr.arb +++ b/hexagenapp/lib/l10n/app_tr.arb @@ -65,6 +65,7 @@ "paramValue": "Parametre değeri hatası", "notAQuery": "Sorgu değil", "unknownCommand": "Bilinmeyen komut", + "operationStepsFull": "İşlem adımları dolu (maks 64)", "deviceNoDeviceConnected": "Cihaz Bağlı Değil", "devicePleaseConnect": "Lütfen bir hexaTune cihazı bağlayın", diff --git a/hexagenapp/lib/main.dart b/hexagenapp/lib/main.dart index 7f82576..b09499e 100644 --- a/hexagenapp/lib/main.dart +++ b/hexagenapp/lib/main.dart @@ -1,48 +1,10 @@ // SPDX-FileCopyrightText: 2025 hexaTune LLC // SPDX-License-Identifier: MIT -import 'dart:async'; -import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:hexagenapp/src/app.dart'; -import 'package:hexagenapp/src/core/service/notification_service.dart'; - -@pragma('vm:entry-point') -void onStart(ServiceInstance service) async { - DartPluginRegistrant.ensureInitialized(); -} - -Future initializeService() async { - final service = FlutterBackgroundService(); - - await service.configure( - androidConfiguration: AndroidConfiguration( - onStart: onStart, - autoStart: false, - isForegroundMode: true, - notificationChannelId: 'hexaGen_channel', - initialNotificationTitle: 'hexaGen', - initialNotificationContent: 'Frequency generation in progress', - foregroundServiceNotificationId: 888, - ), - iosConfiguration: IosConfiguration( - autoStart: false, - onForeground: onStart, - onBackground: onIosBackground, - ), - ); -} - -@pragma('vm:entry-point') -Future onIosBackground(ServiceInstance service) async { - return true; -} void main() async { WidgetsFlutterBinding.ensureInitialized(); - await initializeService(); - await NotificationService().initialize(); - FlutterBackgroundService().startService(); runApp(const HexaGenApp()); } diff --git a/hexagenapp/lib/src/app.dart b/hexagenapp/lib/src/app.dart index fdb5ed2..be8aa2f 100644 --- a/hexagenapp/lib/src/app.dart +++ b/hexagenapp/lib/src/app.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:hexagenapp/src/core/theme/freq.dart'; import 'package:hexagenapp/src/core/utils/theme.dart'; -import 'package:hexagenapp/src/pages/main.dart'; +import 'package:hexagenapp/src/pages/layout.dart'; import 'package:hexagenapp/l10n/app_localizations.dart'; import 'package:hexagenapp/src/core/service/device_service.dart'; import 'package:hexagenapp/src/core/service/storage_service.dart'; diff --git a/hexagenapp/lib/src/core/at/at.dart b/hexagenapp/lib/src/core/at/at.dart index 336abb0..79b5c02 100644 --- a/hexagenapp/lib/src/core/at/at.dart +++ b/hexagenapp/lib/src/core/at/at.dart @@ -5,22 +5,32 @@ import 'dart:typed_data'; import 'package:hexagenapp/src/core/sysex/sysex.dart'; /// AT Command types -enum ATCommandType { version, freq, setRgb, reset, fwUpdate } +enum ATCommandType { version, freq, setRgb, reset, fwUpdate, operation } /// AT Command class for building commands generically class ATCommand { final int id; final ATCommandType type; final List params; + final bool isQuery; - ATCommand(this.id, this.type, this.params); + ATCommand(this.id, this.type, this.params, {this.isQuery = false}); /// Compile the command to string + /// Query format: AT+COMMAND? + /// Command with params: AT+COMMAND=id#PARAM1#PARAM2... + /// Command without params: AT+COMMAND=id String compile() { final name = type.name.toUpperCase(); - if (type == ATCommandType.version) { + + if (isQuery) { + // Query format: AT+COMMAND? return 'AT+$name?'; + } else if (params.isEmpty) { + // Command without params: AT+COMMAND=id + return 'AT+$name=$id'; } else { + // Command with params: AT+COMMAND=id#PARAM1#PARAM2... final paramStr = [id.toString(), ...params].join('#'); return 'AT+$name=$paramStr'; } @@ -33,7 +43,22 @@ class ATCommand { /// Factory for version query factory ATCommand.version() { - return ATCommand(0, ATCommandType.version, []); + return ATCommand(0, ATCommandType.version, [], isQuery: true); + } + + /// Factory for operation query (AT+OPERATION?) + factory ATCommand.operationQuery() { + return ATCommand(0, ATCommandType.operation, [], isQuery: true); + } + + /// Factory for operation prepare (AT+OPERATION=id#PREPARE) + factory ATCommand.operationPrepare(int id) { + return ATCommand(id, ATCommandType.operation, ['PREPARE']); + } + + /// Factory for operation generate (AT+OPERATION=id#GENERATE) + factory ATCommand.operationGenerate(int id) { + return ATCommand(id, ATCommandType.operation, ['GENERATE']); } /// Factory for freq command @@ -53,12 +78,12 @@ class ATCommand { ]); } - /// Factory for reset command + /// Factory for reset command (AT+RESET=id) factory ATCommand.reset(int id) { return ATCommand(id, ATCommandType.reset, []); } - /// Factory for fwUpdate command + /// Factory for fwUpdate command (AT+FWUPDATE=id) factory ATCommand.fwUpdate(int id) { return ATCommand(id, ATCommandType.fwUpdate, []); } @@ -75,8 +100,12 @@ ATResponse? extractAndParseATResponse(Uint8List data) { /// Formats: /// AT+VERSION=0#version /// AT+ERROR=id#error_code -/// AT+DONE=id (firmware'de DONE response var, ama command yok) +/// AT+DONE=id (firmware has DONE response, but no DONE command) /// AT+STATUS=0#AVAILABLE or GENERATING +/// AT+OPERATION=id#PREPARE#COMPLETED +/// AT+OPERATION=id#GENERATE#COMPLETED +/// AT+OPERATION=id#GENERATING#stepId +/// AT+FREQ=stepId#freq#timeMs#COMPLETED ATResponse? parseATResponse(String message) { final trimmed = message.trim(); if (!trimmed.startsWith('AT+')) return null; @@ -96,12 +125,10 @@ ATResponse? parseATResponse(String message) { switch (name) { case 'VERSION': return ATResponse(type: ATResponseType.version, id: id, params: params); + case 'OPERATION': + return ATResponse(type: ATResponseType.operation, id: id, params: params); case 'FREQ': - return ATResponse( - type: ATResponseType.done, - id: id, - params: params, - ); // Assuming DONE for FREQ + return ATResponse(type: ATResponseType.freq, id: id, params: params); case 'SETRGB': return ATResponse( type: ATResponseType.done, @@ -132,7 +159,7 @@ ATResponse? parseATResponse(String message) { } /// AT Response types -enum ATResponseType { version, error, done, status } +enum ATResponseType { version, error, done, status, operation, freq } /// Device status from periodic STATUS messages enum DeviceStatus { available, generating } @@ -184,4 +211,45 @@ class ATResponse { DeviceStatus get status => params.isNotEmpty && params[0] == 'AVAILABLE' ? DeviceStatus.available : DeviceStatus.generating; + + /// Operation status getter + /// Returns the actual operation status based on response format: + /// - AT+OPERATION=id#PREPARE#COMPLETED -> returns "COMPLETED" + /// - AT+OPERATION=id#GENERATE#COMPLETED -> returns "COMPLETED" + /// - AT+OPERATION=id#GENERATING#stepId -> returns "GENERATING" + String get operationStatus { + if (params.isEmpty) return ''; + + // If second param is COMPLETED, that's the status + if (params.length > 1 && params[1] == 'COMPLETED') { + return 'COMPLETED'; + } + + // Otherwise return first param (PREPARE, GENERATE, GENERATING, etc.) + return params[0]; + } + + /// Step ID for GENERATING status (AT+OPERATION=id#GENERATING#stepId) + int? get operationStepId { + if (params.length > 1 && params[0] == 'GENERATING') { + return int.tryParse(params[1]); + } + return null; + } + + /// Check if FREQ command completed (AT+FREQ=stepId#freq#timeMs#COMPLETED) + bool get freqCompleted { + if (type == ATResponseType.freq && params.length >= 3) { + return params[2] == 'COMPLETED'; + } + return false; + } + + /// Check if OPERATION command completed + bool get operationCompleted { + if (type == ATResponseType.operation && params.isNotEmpty) { + return params.contains('COMPLETED'); + } + return false; + } } diff --git a/hexagenapp/lib/src/core/device/device.dart b/hexagenapp/lib/src/core/device/device.dart index 2c41448..1b0d658 100644 --- a/hexagenapp/lib/src/core/device/device.dart +++ b/hexagenapp/lib/src/core/device/device.dart @@ -17,6 +17,8 @@ typedef DeviceResponseCallback = AppError? error, DeviceStatus? status, int? responseId, + String? operationStatus, + int? operationStepId, required bool waiting, }); @@ -32,7 +34,7 @@ class HexaTuneDeviceManager { String? _connectedId; bool _waitingForResponse = false; Timer? _responseTimeout; - final List _sysexBuffer = []; // SysEx mesaj buffer + final List _sysexBuffer = []; // SysEx message buffer DeviceResponseCallback? _responseCallback; @@ -206,10 +208,10 @@ class HexaTuneDeviceManager { ); logger.midi('Received MIDI data: ${bytes.length} bytes'); - // Buffer'a ekle + // Add to buffer _sysexBuffer.addAll(bytes); - // F7 (SysEx end) geldi mi kontrol et + // Check if F7 (SysEx end) marker received final hasEndMarker = _sysexBuffer.contains(0xF7); if (!hasEndMarker) { @@ -217,7 +219,7 @@ class HexaTuneDeviceManager { 'Waiting for more data (buffer: ${_sysexBuffer.length} bytes)', category: LogCategory.midi, ); - return; // Daha fazla veri bekle + return; // Wait for more data } // Cancel timeout @@ -284,6 +286,38 @@ class HexaTuneDeviceManager { ); break; + case ATResponseType.operation: + logger.info( + 'AT Operation: status=${response.operationStatus}, stepId=${response.operationStepId} (id: ${response.id})', + category: LogCategory.midi, + ); + final id = int.tryParse(response.id) ?? 0; + _notifyResponse( + version: null, + error: null, + status: null, + responseId: id, + operationStatus: response.operationStatus, + operationStepId: response.operationStepId, + waiting: false, + ); + break; + + case ATResponseType.freq: + logger.info( + 'AT Freq: completed=${response.freqCompleted} (id: ${response.id})', + category: LogCategory.midi, + ); + final id = int.tryParse(response.id) ?? 0; + _notifyResponse( + version: null, + error: null, + status: null, + responseId: id, + waiting: false, + ); + break; + case ATResponseType.done: logger.info( 'AT Done (id: ${response.id})', @@ -332,6 +366,8 @@ class HexaTuneDeviceManager { AppError? error, DeviceStatus? status, int? responseId, + String? operationStatus, + int? operationStepId, required bool waiting, }) { _responseCallback?.call( @@ -339,6 +375,8 @@ class HexaTuneDeviceManager { error: error, status: status, responseId: responseId, + operationStatus: operationStatus, + operationStepId: operationStepId, waiting: waiting, ); } diff --git a/hexagenapp/lib/src/core/error/error.dart b/hexagenapp/lib/src/core/error/error.dart index 1d3ac8a..c53ee09 100644 --- a/hexagenapp/lib/src/core/error/error.dart +++ b/hexagenapp/lib/src/core/error/error.dart @@ -12,6 +12,7 @@ enum AppError { paramValue, notAQuery, unknownCommand, + operationStepsFull, } /// Extension to get error code for each error type @@ -37,6 +38,8 @@ extension AppErrorExtension on AppError { return 'E001008'; case AppError.unknownCommand: return 'E001009'; + case AppError.operationStepsFull: + return 'E001010'; } } @@ -69,6 +72,8 @@ extension AppErrorExtension on AppError { return localizations.notAQuery; case AppError.unknownCommand: return localizations.unknownCommand; + case AppError.operationStepsFull: + return localizations.operationStepsFull; } } } diff --git a/hexagenapp/lib/src/core/service/device_service.dart b/hexagenapp/lib/src/core/service/device_service.dart index cd90e13..97fd619 100644 --- a/hexagenapp/lib/src/core/service/device_service.dart +++ b/hexagenapp/lib/src/core/service/device_service.dart @@ -28,6 +28,12 @@ class DeviceService extends ChangeNotifier { bool _waitingForResponse = false; bool _isInitialized = false; + // Operation state tracking + int? _currentOperationId; + String? _currentOperationStatus; // PREPARE, GENERATE, GENERATING + int? _currentGeneratingStepId; + AppError? _currentOperationError; // Track operation-specific errors + // ID generation and command tracking static const int _maxId = 9999; int _nextId = 1; @@ -49,6 +55,10 @@ class DeviceService extends ChangeNotifier { bool get isConnected => _currentDevice != null && _deviceManager.connectedId != null; bool get isInitialized => _isInitialized; + int? get currentOperationId => _currentOperationId; + String? get currentOperationStatus => _currentOperationStatus; + int? get currentGeneratingStepId => _currentGeneratingStepId; + AppError? get currentOperationError => _currentOperationError; /// Initialize the device service Future initialize() async { @@ -164,10 +174,12 @@ class DeviceService extends ChangeNotifier { AppError? error, DeviceStatus? status, int? responseId, + String? operationStatus, + int? operationStepId, required bool waiting, }) { logger.print( - 'DeviceService: _onResponse called - version: $version, error: ${error?.code}, status: $status, responseId: $responseId, waiting: $waiting', + 'DeviceService: _onResponse called - version: $version, error: ${error?.code}, status: $status, responseId: $responseId, operationStatus: $operationStatus, operationStepId: $operationStepId, waiting: $waiting', ); if (version != null) { logger.info( @@ -197,6 +209,25 @@ class DeviceService extends ChangeNotifier { _deviceStatus = status; } + // Update operation status if provided + if (operationStatus != null) { + logger.info( + 'Operation status update: $operationStatus (stepId: $operationStepId)', + category: LogCategory.device, + ); + _currentOperationStatus = operationStatus; + _currentGeneratingStepId = operationStepId; + + // Mark operation as complete if status is COMPLETED + if (operationStatus == 'COMPLETED' || + operationStatus.contains('COMPLETED')) { + logger.info( + 'Operation $_currentOperationId completed', + category: LogCategory.device, + ); + } + } + // Update command status if responseId provided if (responseId != null) { logger.print('DeviceService: Updating status for responseId $responseId'); @@ -207,6 +238,16 @@ class DeviceService extends ChangeNotifier { errorCode: error.code, ); addNotification('Command failed: ${error.code}'); + + // Track operation-specific errors + if (_currentOperationId != null && responseId == _currentOperationId) { + logger.warning( + 'Operation error detected: ${error.code} for operation $_currentOperationId', + category: LogCategory.device, + ); + _currentOperationError = error; + _currentOperationStatus = 'ERROR'; + } } else { _updateCommandStatus(responseId, CommandStatus.success); } @@ -323,6 +364,90 @@ class DeviceService extends ChangeNotifier { await sendATCommand(command); } + /// Send AT+OPERATION=id#PREPARE command and wait for response + Future sendOperationPrepare(int operationId) async { + final completer = Completer(); + final command = ATCommand.operationPrepare(operationId); + final compiled = command.compile(); + + _commandCompleters[operationId] = completer; + _currentOperationId = operationId; + _currentOperationStatus = 'PREPARE'; + + _trackCommand(operationId, compiled, timeout: const Duration(seconds: 5)); + logger.info( + 'Sending OPERATION PREPARE and waiting: $compiled', + category: LogCategory.midi, + ); + _deviceManager.sendData(command.buildSysEx(), _deviceManager.connectedId!); + + return completer.future; + } + + /// Send AT+OPERATION=id#GENERATE command (don't wait for response, use polling instead) + Future sendOperationGenerate(int operationId) async { + final command = ATCommand.operationGenerate(operationId); + final compiled = command.compile(); + + _currentOperationStatus = 'GENERATE'; + + // Don't track with timeout since we're polling instead + logger.info( + 'Sending OPERATION GENERATE (polling for status): $compiled', + category: LogCategory.midi, + ); + _deviceManager.sendData(command.buildSysEx(), _deviceManager.connectedId!); + } + + /// Query operation status (AT+OPERATION?) + Future queryOperationStatus() async { + if (_deviceManager.connectedId == null) { + logger.warning( + 'Cannot query operation status: No device connected', + category: LogCategory.device, + ); + return; + } + final command = ATCommand.operationQuery(); + final compiled = command.compile(); + logger.info( + 'Querying operation status: $compiled', + category: LogCategory.midi, + ); + _deviceManager.sendData(command.buildSysEx(), _deviceManager.connectedId!); + } + + /// Send AT+FREQ command for batch operation (with step ID) + Future sendFreqCommandForOperation( + int stepId, + int freq, + int timeMs, + ) async { + final completer = Completer(); + final command = ATCommand.freq(stepId, freq, timeMs); + final compiled = command.compile(); + + _commandCompleters[stepId] = completer; + + _trackCommand(stepId, compiled, timeout: const Duration(seconds: 5)); + logger.info( + 'Sending FREQ command (step $stepId) and waiting: $compiled', + category: LogCategory.midi, + ); + _deviceManager.sendData(command.buildSysEx(), _deviceManager.connectedId!); + + return completer.future; + } + + /// Reset operation state + void resetOperationState() { + _currentOperationId = null; + _currentOperationStatus = null; + _currentGeneratingStepId = null; + _currentOperationError = null; + notifyListeners(); + } + /// Send raw data to device void sendData(Uint8List bytes, String deviceId) { _deviceManager.sendData(bytes, deviceId); diff --git a/hexagenapp/lib/src/core/service/notification_service.dart b/hexagenapp/lib/src/core/service/notification_service.dart deleted file mode 100644 index cccb9fa..0000000 --- a/hexagenapp/lib/src/core/service/notification_service.dart +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-FileCopyrightText: 2025 hexaTune LLC -// SPDX-License-Identifier: MIT - -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:hexagenapp/src/core/service/log_service.dart'; - -class NotificationService { - static final NotificationService _instance = NotificationService._internal(); - factory NotificationService() => _instance; - - NotificationService._internal(); - - final FlutterLocalNotificationsPlugin _notifications = - FlutterLocalNotificationsPlugin(); - - bool _initialized = false; - - Future initialize() async { - if (_initialized) return; - - logger.info('Initializing notification service', category: LogCategory.app); - - const androidSettings = AndroidInitializationSettings( - '@mipmap/launcher_icon', - ); - const iosSettings = DarwinInitializationSettings( - requestAlertPermission: true, - requestBadgePermission: true, - requestSoundPermission: true, - ); - - const initSettings = InitializationSettings( - android: androidSettings, - iOS: iosSettings, - ); - - try { - await _notifications.initialize( - initSettings, - onDidReceiveNotificationResponse: _onNotificationTap, - ); - - await _createNotificationChannel(); - await _requestPermissions(); - - _initialized = true; - logger.info( - 'Notification service initialized', - category: LogCategory.app, - ); - } catch (e, stack) { - logger.error( - 'Failed to initialize notification service', - category: LogCategory.app, - error: e, - stackTrace: stack, - ); - } - } - - Future _createNotificationChannel() async { - const channel = AndroidNotificationChannel( - 'hexaGen_channel', - 'hexaGen', - description: 'Frequency generation notifications', - importance: Importance.high, - playSound: true, - enableVibration: true, - showBadge: true, - ); - - final androidPlugin = _notifications - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - - if (androidPlugin != null) { - try { - await androidPlugin.createNotificationChannel(channel); - logger.info( - 'Android notification channel created: ${channel.id}', - category: LogCategory.app, - ); - - final channels = await androidPlugin.getNotificationChannels(); - logger.info( - 'Available channels: ${channels?.map((c) => c.id).join(", ") ?? "none"}', - category: LogCategory.app, - ); - } catch (e, stack) { - logger.error( - 'Failed to create notification channel', - category: LogCategory.app, - error: e, - stackTrace: stack, - ); - } - } else { - logger.warning( - 'Android plugin is null, cannot create channel', - category: LogCategory.app, - ); - } - } - - Future _requestPermissions() async { - final androidPlugin = _notifications - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - - if (androidPlugin != null) { - final granted = await androidPlugin.requestNotificationsPermission(); - logger.info( - 'Android notification permission: ${granted == true ? "granted" : "denied"}', - category: LogCategory.app, - ); - } - - final iosPlugin = _notifications - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(); - - if (iosPlugin != null) { - final granted = await iosPlugin.requestPermissions( - alert: true, - badge: true, - sound: true, - ); - logger.info( - 'iOS notification permission: ${granted == true ? "granted" : "denied"}', - category: LogCategory.app, - ); - } - } - - void _onNotificationTap(NotificationResponse response) { - logger.debug( - 'Notification tapped: ${response.payload}', - category: LogCategory.app, - ); - } - - Future showNotification({ - required String title, - required String body, - bool isError = false, - }) async { - if (!_initialized) { - logger.warning( - 'Cannot show notification: Service not initialized', - category: LogCategory.app, - ); - return; - } - - final androidPlugin = _notifications - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - - if (androidPlugin != null) { - final permissionGranted = - await androidPlugin.areNotificationsEnabled() ?? false; - logger.info( - 'Notification permission status: $permissionGranted', - category: LogCategory.app, - ); - - if (!permissionGranted) { - logger.warning( - 'Cannot show notification: Permission not granted', - category: LogCategory.app, - ); - return; - } - - final channels = await androidPlugin.getNotificationChannels(); - logger.info( - 'Available channels before show: ${channels?.map((c) => c.id).join(", ") ?? "none"}', - category: LogCategory.app, - ); - } - - logger.info( - 'Showing notification: $title - $body', - category: LogCategory.app, - ); - - final notificationId = DateTime.now().millisecondsSinceEpoch % 100000; - logger.info('Notification ID: $notificationId', category: LogCategory.app); - - const androidDetails = AndroidNotificationDetails( - 'hexaGen_channel', - 'hexaGen', - channelDescription: 'Frequency generation notifications', - importance: Importance.high, - priority: Priority.high, - showWhen: true, - ); - - const iosDetails = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - ); - - const details = NotificationDetails( - android: androidDetails, - iOS: iosDetails, - ); - - try { - await _notifications.show(notificationId, title, body, details); - logger.info('Notification shown successfully', category: LogCategory.app); - } catch (e, stack) { - logger.error( - 'Failed to show notification', - category: LogCategory.app, - error: e, - stackTrace: stack, - ); - } - } - - Future cancelAll() async { - await _notifications.cancelAll(); - } -} diff --git a/hexagenapp/lib/src/pages/main.dart b/hexagenapp/lib/src/pages/layout.dart similarity index 57% rename from hexagenapp/lib/src/pages/main.dart rename to hexagenapp/lib/src/pages/layout.dart index a5d2304..9c426d2 100644 --- a/hexagenapp/lib/src/pages/main.dart +++ b/hexagenapp/lib/src/pages/layout.dart @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2025 hexaTune LLC // SPDX-License-Identifier: MIT +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hexagenapp/src/pages/generation.dart'; import 'package:hexagenapp/src/pages/history.dart'; @@ -11,9 +12,9 @@ import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:hexagenapp/l10n/app_localizations.dart'; import 'package:hexagenapp/src/core/service/device_service.dart'; import 'package:hexagenapp/src/core/service/storage_service.dart'; -import 'package:hexagenapp/src/core/service/notification_service.dart'; import 'package:hexagenapp/src/core/at/at.dart'; import 'package:hexagenapp/src/core/service/log_service.dart'; +import 'package:hexagenapp/src/core/error/error.dart'; enum MainPageTab { howTo, history, generation, products, settings } @@ -29,7 +30,12 @@ class _MainPageState extends State with WidgetsBindingObserver { int _generationItemCount = 0; OverlayEntry? _notificationOverlay; bool _isSending = false; - AppLifecycleState _lifecycleState = AppLifecycleState.resumed; + int _operationId = 1; // Auto-incrementing operation ID + Timer? _pollingTimer; + + // Repeat functionality + int _currentRepeatIteration = 0; + int _totalRepeats = 1; final GlobalKey _generationKey = GlobalKey(); @@ -57,25 +63,22 @@ class _MainPageState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _listenToBackgroundService(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _pollingTimer?.cancel(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { - _lifecycleState = state; if (state == AppLifecycleState.detached && _isSending) { _stopOperation(); } } - void _listenToBackgroundService() {} - void _onFabPressed() { if (_selectedTab != MainPageTab.generation) { setState(() { @@ -92,123 +95,342 @@ class _MainPageState extends State with WidgetsBindingObserver { final state = _generationKey.currentState as dynamic; final sequence = state?.getSequence() ?? []; final repeatCount = state?.getRepeatCount() ?? 1; - final operationId = DateTime.now().millisecondsSinceEpoch.toString(); + + // Initialize repeat tracking + _currentRepeatIteration = 1; + _totalRepeats = repeatCount; + + logger.print( + 'MainPage: Starting operation ID $_operationId with ${sequence.length} items, total repeats: $_totalRepeats', + ); + + setState(() => _isSending = true); + + // Execute the first iteration + await _executeIteration(state, sequence); + } + + Future _executeIteration( + dynamic state, + List> sequence, + ) async { final lang = AppLocalizations.of(context)!; final messenger = ScaffoldMessenger.of(context); final errorColor = Theme.of(context).colorScheme.error; logger.print( - 'MainPage: Starting operation with ${sequence.length} items, repeat $repeatCount', + 'MainPage: === Iteration $_currentRepeatIteration/$_totalRepeats ===', ); state?.resetAllItemStatuses(); - setState(() => _isSending = true); final deviceService = DeviceServiceProvider.of(context); bool success = true; bool cancelled = false; try { - for (int r = 0; r < repeatCount && !cancelled; r++) { - if (!_isSending) { - cancelled = true; - break; - } - logger.print('MainPage: Starting repeat $r'); - for (int i = 0; i < sequence.length; i++) { + // PHASE 1: PREPARE + logger.print( + 'MainPage: PHASE 1 - Sending PREPARE for operation $_operationId (iteration $_currentRepeatIteration)', + ); + final prepareStatus = await deviceService.sendOperationPrepare( + _operationId, + ); + + if (prepareStatus != CommandStatus.success) { + logger.print('MainPage: PREPARE failed with status: $prepareStatus'); + success = false; + } else { + logger.print('MainPage: PREPARE completed successfully'); + + // PHASE 2: FREQ batch + logger.print('MainPage: PHASE 2 - Sending FREQ commands'); + for (int i = 0; i < sequence.length && success; i++) { if (!_isSending) { cancelled = true; break; } + final item = sequence[i]; final freqHz = item['freqHz'] as int; final timeMs = ((item['seconds'] as double) * 1000).round(); + final stepId = i; // Use list index as stepId state?.updateItemStatus(i, ItemStatus.processing); - logger.print('MainPage: Sending FREQ $freqHz Hz for ${timeMs}ms'); + logger.print( + 'MainPage: Sending FREQ stepId=$stepId freq=$freqHz Hz time=${timeMs}ms', + ); try { - final status = await deviceService.sendFreqCommandAndWait( + final status = await deviceService.sendFreqCommandForOperation( + stepId, freqHz, timeMs, ); - logger.print('MainPage: Command status: $status'); + if (status != CommandStatus.success) { + logger.print( + 'MainPage: FREQ stepId=$stepId failed with status: $status', + ); state?.updateItemStatus(i, ItemStatus.error); success = false; break; } + + logger.print('MainPage: FREQ stepId=$stepId completed'); state?.updateItemStatus(i, ItemStatus.completed); } catch (e) { - logger.print('MainPage: Exception in sendFreqCommandAndWait: $e'); + logger.print('MainPage: Exception sending FREQ stepId=$stepId: $e'); state?.updateItemStatus(i, ItemStatus.error); success = false; break; } } - if (!success) break; - if (r < repeatCount - 1) { + // PHASE 3: GENERATE + if (success && !cancelled) { + logger.print( + 'MainPage: PHASE 3 - Sending GENERATE for operation $_operationId (iteration $_currentRepeatIteration)', + ); state?.resetAllItemStatuses(); + + await deviceService.sendOperationGenerate(_operationId); + logger.print('MainPage: GENERATE sent, starting polling'); + _startPolling(deviceService, state); } } } catch (e) { - logger.print('MainPage: Exception in loop: $e'); + logger.print('MainPage: Exception in operation: $e'); success = false; } - if (success && !cancelled) { - logger.print('MainPage: Sending complete'); - _saveOperation(operationId); - deviceService.addNotification(lang.operationCompletedSuccessfully); + // Handle immediate failures (before polling starts) + if (!success || cancelled) { + _pollingTimer?.cancel(); + deviceService.resetOperationState(); + + if (cancelled) { + logger.print('MainPage: Cancelled, sending reset'); + state?.resetAllItemStatuses(); + deviceService.addNotification(lang.operationStoppedByUser); + try { + await deviceService.sendResetCommand(); + logger.print('MainPage: Reset sent'); + } catch (e) { + logger.print('MainPage: Exception sending reset: $e'); + } + } else { + state?.resetAllItemStatuses(); + final errorMessage = lang.operationFailedCheckDevice; + deviceService.addNotification(lang.operationFailedWithErrors); + + if (context.mounted) { + messenger.showSnackBar( + SnackBar(content: Text(errorMessage), backgroundColor: errorColor), + ); + } + } - if (_lifecycleState != AppLifecycleState.resumed) { - await NotificationService().showNotification( - title: 'hexaGen', - body: lang.operationCompletedSuccessfully, - ); + // Reset repeat tracking and stop + _currentRepeatIteration = 0; + _totalRepeats = 1; + setState(() => _isSending = false); + } + // If successful and polling started, completion will be handled by _handleOperationCompletion + } + + void _startPolling(DeviceService deviceService, dynamic state) { + logger.print('MainPage: Starting polling timer (5 seconds interval)'); + + _pollingTimer?.cancel(); + _pollingTimer = Timer.periodic(const Duration(seconds: 5), (timer) async { + if (!_isSending) { + logger.print('MainPage: Polling stopped - operation cancelled'); + timer.cancel(); + return; } - } else if (cancelled) { - logger.print('MainPage: Cancelled, sending reset'); - state?.resetAllItemStatuses(); - deviceService.addNotification(lang.operationStoppedByUser); + + logger.print('MainPage: Polling operation status...'); try { - await deviceService.sendResetCommand(); - logger.print('MainPage: Reset sent'); + await deviceService.queryOperationStatus(); + + // Check for operation errors first + final error = deviceService.currentOperationError; + if (error != null) { + logger.print('MainPage: Operation ERROR detected: ${error.code}'); + timer.cancel(); + _handleOperationCompletion( + false, + 'Operation error: ${error.code}', + state, + ); + return; + } + + // Check if operation completed or failed + final status = deviceService.currentOperationStatus; + final stepId = deviceService.currentGeneratingStepId; + + logger.print('MainPage: Current status: $status, stepId: $stepId'); + + if (status == 'COMPLETED') { + logger.print('MainPage: Operation COMPLETED detected'); + timer.cancel(); + _handleOperationCompletion(true, null, state); + } else if (status == 'ERROR') { + logger.print('MainPage: Operation ERROR status detected'); + timer.cancel(); + _handleOperationCompletion(false, 'Operation failed', state); + } else if (status == 'GENERATING' && stepId != null) { + // Update UI with current step + // Previous steps (0 to stepId-1) should be marked as completed + // Current step (stepId) should be marked as processing + logger.print('MainPage: GENERATING at stepId=$stepId'); + _updateGeneratingProgress(state, stepId); + } } catch (e) { - logger.print('MainPage: Exception sending reset: $e'); + logger.print('MainPage: Exception during polling: $e'); + timer.cancel(); + _handleOperationCompletion(false, 'Polling error: $e', state); } - } else { - state?.resetAllItemStatuses(); - final errorMessage = lang.operationFailedCheckDevice; - deviceService.addNotification(lang.operationFailedWithErrors); - - if (_lifecycleState != AppLifecycleState.resumed) { - await NotificationService().showNotification( - title: 'hexaGen', - body: lang.operationFailedWithErrors, - isError: true, + }); + + // Errors are also handled via device callback in _onResponse + } + + void _updateGeneratingProgress(dynamic state, int currentStepId) { + if (state == null) return; + + final sequence = state.getSequence() ?? []; + + // Mark all previous steps as completed + for (int i = 0; i < currentStepId && i < sequence.length; i++) { + logger.print( + 'MainPage: Marking stepId=$i as completed (before current step)', + ); + state.updateItemStatus(i, ItemStatus.completed); + } + + // Mark current step as processing + if (currentStepId < sequence.length) { + logger.print( + 'MainPage: Marking stepId=$currentStepId as processing (current step)', + ); + state.updateItemStatus(currentStepId, ItemStatus.processing); + } + } + + void _handleOperationCompletion( + bool success, + String? errorMessage, + dynamic state, + ) async { + logger.print( + 'MainPage: Handling operation completion - success: $success (iteration $_currentRepeatIteration/$_totalRepeats)', + ); + + _pollingTimer?.cancel(); + final deviceService = DeviceServiceProvider.of(context); + deviceService.resetOperationState(); + + final lang = AppLocalizations.of(context)!; + final messenger = ScaffoldMessenger.of(context); + final errorColor = Theme.of(context).colorScheme.error; + + if (success) { + logger.print( + 'MainPage: Iteration $_currentRepeatIteration/$_totalRepeats completed successfully', + ); + + // Mark all items as completed + final sequence = state?.getSequence() ?? []; + for (int i = 0; i < sequence.length; i++) { + state?.updateItemStatus(i, ItemStatus.completed); + } + + // Check if we need to do more iterations + if (_currentRepeatIteration < _totalRepeats) { + // Show notification for completed iteration + final iterationMessage = + 'Iteration $_currentRepeatIteration/$_totalRepeats completed'; + deviceService.addNotification(iterationMessage); + logger.print('MainPage: Starting next iteration...'); + + // Increment iteration counter + _currentRepeatIteration++; + + // Wait a brief moment before starting next iteration + await Future.delayed(const Duration(milliseconds: 500)); + + // Execute next iteration with same operation ID + if (_isSending) { + await _executeIteration(state, sequence); + } + } else { + // All iterations completed - save and finish + logger.print( + 'MainPage: All $_totalRepeats iterations completed successfully', ); + + final operationIdStr = _operationId.toString(); + _saveOperation(operationIdStr); + deviceService.addNotification( + 'Operation completed: $_totalRepeats iteration${_totalRepeats > 1 ? 's' : ''}', + ); + + // Reset repeat tracking + _currentRepeatIteration = 0; + _totalRepeats = 1; + + // Increment operation ID for next operation (1-9999, wrap around) + _operationId = (_operationId % 9999) + 1; + logger.print('MainPage: Next operation ID will be $_operationId'); + + setState(() => _isSending = false); } + } else { + // Operation failed + logger.print( + 'MainPage: Iteration $_currentRepeatIteration/$_totalRepeats failed: $errorMessage', + ); + state?.resetAllItemStatuses(); + final displayMessage = errorMessage ?? lang.operationFailedCheckDevice; + deviceService.addNotification( + 'Operation failed at iteration $_currentRepeatIteration/$_totalRepeats', + ); if (context.mounted) { messenger.showSnackBar( - SnackBar(content: Text(errorMessage), backgroundColor: errorColor), + SnackBar(content: Text(displayMessage), backgroundColor: errorColor), ); } - } - setState(() => _isSending = false); + // Reset repeat tracking + _currentRepeatIteration = 0; + _totalRepeats = 1; + + setState(() => _isSending = false); + } } void _stopOperation() async { - logger.print('MainPage: Stopping operation'); + logger.print( + 'MainPage: Stopping operation (was at iteration $_currentRepeatIteration/$_totalRepeats)', + ); + + _pollingTimer?.cancel(); setState(() => _isSending = false); final state = _generationKey.currentState as dynamic; state?.resetAllItemStatuses(); final deviceService = DeviceServiceProvider.of(context); + deviceService.resetOperationState(); + + // Reset repeat tracking + _currentRepeatIteration = 0; + _totalRepeats = 1; + final lang = AppLocalizations.of(context)!; final stoppedMessage = lang.operationStopped; final messenger = ScaffoldMessenger.of(context); diff --git a/hexagenapp/lib/src/pages/settings.dart b/hexagenapp/lib/src/pages/settings.dart index b3d62cd..f635200 100644 --- a/hexagenapp/lib/src/pages/settings.dart +++ b/hexagenapp/lib/src/pages/settings.dart @@ -54,24 +54,11 @@ class _SettingsPageState extends State { // Scroll to bottom when logs update WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); - return RefreshIndicator( - onRefresh: deviceService.refresh, - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: _buildTopCard( - context, - lang, - storageService, - deviceService, - ), - ), - SliverFillRemaining( - hasScrollBody: true, - child: _buildLogMonitorCard(context, lang), - ), - ], - ), + return Column( + children: [ + _buildTopCard(context, lang, storageService, deviceService), + Expanded(child: _buildLogMonitorCard(context, lang)), + ], ); }, ); diff --git a/hexagenapp/pubspec.yaml b/hexagenapp/pubspec.yaml index 04d36cd..b56f775 100644 --- a/hexagenapp/pubspec.yaml +++ b/hexagenapp/pubspec.yaml @@ -40,8 +40,6 @@ dependencies: flutter_midi_command: ^0.5.0 shared_preferences: ^2.3.3 flutter_launcher_icons: "^0.14.4" - flutter_background_service: ^5.0.5 - flutter_local_notifications: ^18.0.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.