diff --git a/android/app/src/main/kotlin/com/resonate/resonate/MainActivity.kt b/android/app/src/main/kotlin/com/resonate/resonate/MainActivity.kt index ebd823da..7578817f 100644 --- a/android/app/src/main/kotlin/com/resonate/resonate/MainActivity.kt +++ b/android/app/src/main/kotlin/com/resonate/resonate/MainActivity.kt @@ -1,6 +1,68 @@ package com.resonate.resonate +import android.util.Log import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterActivity() { +class MainActivity : FlutterActivity() { + + private val channelName = "voice_control_channel" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + channelName + ).setMethodCallHandler { call, result -> + when (call.method) { + "setVoiceProfile" -> { + val selectedVoice = call.argument("selectedVoice") + if (selectedVoice != null) { + applyVoiceProfile(selectedVoice) + result.success(null) + } else { + result.error( + "INVALID_ARGUMENT", + "selectedVoice must not be null", + null + ) + } + } + "setPreviewEnabled" -> { + val isPreviewEnabled = call.argument("isPreviewEnabled") + if (isPreviewEnabled != null) { + setVoicePreviewEnabled(isPreviewEnabled) + result.success(null) + } else { + result.error( + "INVALID_ARGUMENT", + "isPreviewEnabled must not be null", + null + ) + } + } + else -> result.notImplemented() + } + } + } + + /** + * Apply the selected voice profile in the native audio processing layer. + * Replace the log statement with real DSP / audio-engine calls. + */ + private fun applyVoiceProfile(profile: String) { + // TODO: forward to native audio engine + Log.d("VoiceControl", "Voice profile set: $profile") + } + + /** + * Start or stop audio preview in the native audio processing layer. + * Replace the log statement with real DSP / audio-engine calls. + */ + private fun setVoicePreviewEnabled(enabled: Boolean) { + // TODO: forward to native audio engine + Log.d("VoiceControl", "Preview enabled: $enabled") + } } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 9e39b13b..9f15d8ef 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -4,20 +4,83 @@ import flutter_local_notifications @main @objc class AppDelegate: FlutterAppDelegate { - + + private let voiceControlChannelName = "voice_control_channel" + override func application( - _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in - GeneratedPluginRegistrant.register(with: registry) - } - if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate -} + FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { registry in + GeneratedPluginRegistrant.register(with: registry) + } + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + } GeneratedPluginRegistrant.register(with: self) + + // ── Voice control channel ──────────────────────────────────────────────── + guard let controller = window?.rootViewController as? FlutterViewController else { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + let voiceChannel = FlutterMethodChannel( + name: voiceControlChannelName, + binaryMessenger: controller.binaryMessenger + ) + + voiceChannel.setMethodCallHandler { [weak self] call, result in + switch call.method { + case "setVoiceProfile": + guard + let args = call.arguments as? [String: Any], + let selectedVoice = args["selectedVoice"] as? String + else { + result(FlutterError( + code: "INVALID_ARGUMENT", + message: "selectedVoice must not be nil", + details: nil + )) + return + } + self?.applyVoiceProfile(selectedVoice) + result(nil) + + case "setPreviewEnabled": + guard + let args = call.arguments as? [String: Any], + let isPreviewEnabled = args["isPreviewEnabled"] as? Bool + else { + result(FlutterError( + code: "INVALID_ARGUMENT", + message: "isPreviewEnabled must not be nil", + details: nil + )) + return + } + self?.setVoicePreviewEnabled(isPreviewEnabled) + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } + // ──────────────────────────────────────────────────────────────────────── + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + /// Apply the selected voice profile in the native audio processing layer. + /// Replace the print with real AVAudioEngine / DSP calls. + private func applyVoiceProfile(_ profile: String) { + // TODO: forward to native audio engine + print("VoiceControl: voice profile set: \(profile)") + } + + /// Start or stop audio preview in the native audio processing layer. + /// Replace the print with real AVAudioEngine / DSP calls. + private func setVoicePreviewEnabled(_ enabled: Bool) { + // TODO: forward to native audio engine + print("VoiceControl: preview enabled: \(enabled)") + } } diff --git a/lib/controllers/voice_profile_controller.dart b/lib/controllers/voice_profile_controller.dart new file mode 100644 index 00000000..42d06fa2 --- /dev/null +++ b/lib/controllers/voice_profile_controller.dart @@ -0,0 +1,50 @@ +import 'package:get/get.dart'; +import '../services/voice_control_service.dart'; + +class VoiceProfileController extends GetxController { + /// Available predefined voice profiles. + final List voiceProfiles = const [ + 'Default', + 'Deep', + 'Soft', + 'Energetic', + 'Calm', + ]; + + /// Currently selected voice profile. + var selectedVoice = 'Default'.obs; + + /// Whether preview playback is active. + var isPreviewEnabled = false.obs; + + @override + void onInit() { + super.onInit(); + // Notify native layer of the initial defaults. + _sendVoiceProfile(); + _sendPreviewState(); + } + + /// Called when the user picks a different voice profile. + Future onVoiceProfileChanged(String? voice) async { + if (voice == null || voice == selectedVoice.value) return; + selectedVoice.value = voice; + await _sendVoiceProfile(); + } + + /// Called when the user toggles the preview switch. + Future onPreviewToggled(bool value) async { + isPreviewEnabled.value = value; + await _sendPreviewState(); + } + + // ─── private helpers ─────────────────────────────────────────────────────── + + Future _sendVoiceProfile() async { + await VoiceControlService.setVoiceProfile(selectedVoice.value); + } + + Future _sendPreviewState() async { + await VoiceControlService.setPreviewEnabled(isPreviewEnabled.value); + } +} diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 1ec72175..d1a82eb9 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -36,6 +36,7 @@ import 'package:resonate/views/screens/welcome_screen.dart'; import '../bindings/tabview_binding.dart'; import '../views/screens/about_app_screen.dart'; import '../views/screens/contribute_screen.dart'; +import '../views/screens/voice_profile_screen.dart'; class AppPages { static final List pages = [ @@ -149,5 +150,9 @@ class AppPages { name: AppRoutes.appPreferencesScreen, page: () => const AppPreferencesScreen(), ), + GetPage( + name: AppRoutes.voiceProfileScreen, + page: () => VoiceProfileScreen(), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 454c845b..c6543249 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -35,4 +35,5 @@ class AppRoutes { static const userBlockedScreen = "/userBlockedScreen"; static const liveChapterScreen = "/liveChapterScreen"; static const appPreferencesScreen = "/appPreferencesScreen"; + static const voiceProfileScreen = "/voiceProfileScreen"; } diff --git a/lib/services/voice_control_service.dart b/lib/services/voice_control_service.dart new file mode 100644 index 00000000..85b93dc4 --- /dev/null +++ b/lib/services/voice_control_service.dart @@ -0,0 +1,24 @@ +import 'package:flutter/services.dart'; + +/// Lightweight signal-only bridge to native audio processing. +/// Flutter sends voice profile selection and preview toggle states; +/// all audio work is performed on the native side. +class VoiceControlService { + static const _channel = MethodChannel('voice_control_channel'); + + /// Sends the selected voice profile name to native code. + /// [selectedVoice] – identifier of the chosen voice profile. + static Future setVoiceProfile(String selectedVoice) async { + await _channel.invokeMethod('setVoiceProfile', { + 'selectedVoice': selectedVoice, + }); + } + + /// Notifies native code to enable or disable preview playback. + /// [isPreviewEnabled] – whether preview mode is active. + static Future setPreviewEnabled(bool isPreviewEnabled) async { + await _channel.invokeMethod('setPreviewEnabled', { + 'isPreviewEnabled': isPreviewEnabled, + }); + } +} diff --git a/lib/views/screens/voice_profile_screen.dart b/lib/views/screens/voice_profile_screen.dart new file mode 100644 index 00000000..a3b3510f --- /dev/null +++ b/lib/views/screens/voice_profile_screen.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:resonate/utils/ui_sizes.dart'; +import '../../controllers/voice_profile_controller.dart'; + +class VoiceProfileScreen extends StatelessWidget { + VoiceProfileScreen({super.key}); + + final VoiceProfileController controller = + Get.put(VoiceProfileController()); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Voice Profile'), + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + elevation: 0, + ), + backgroundColor: colorScheme.surface, + body: SafeArea( + child: ListView( + padding: EdgeInsets.symmetric( + horizontal: UiSizes.width_20, + vertical: UiSizes.height_20, + ), + children: [ + // ── Section: Voice Profile Selection ──────────────────────────── + Text( + 'Select Voice Profile', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: UiSizes.height_12), + _VoiceProfileDropdown(controller: controller), + SizedBox(height: UiSizes.height_30), + + // ── Section: Preview Toggle ────────────────────────────────────── + _PreviewToggleTile(controller: controller), + ], + ), + ), + ); + } +} + +// ─── Voice Profile Dropdown ─────────────────────────────────────────────────── + +class _VoiceProfileDropdown extends StatelessWidget { + const _VoiceProfileDropdown({required this.controller}); + + final VoiceProfileController controller; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Obx( + () => DropdownButtonFormField( + value: controller.selectedVoice.value, + decoration: InputDecoration( + labelText: 'Voice Profile', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + prefixIcon: Icon( + Icons.record_voice_over_outlined, + color: colorScheme.primary, + ), + ), + items: controller.voiceProfiles + .map( + (profile) => DropdownMenuItem( + value: profile, + child: Text(profile), + ), + ) + .toList(), + onChanged: controller.onVoiceProfileChanged, + ), + ); + } +} + +// ─── Preview Toggle Tile ────────────────────────────────────────────────────── + +class _PreviewToggleTile extends StatelessWidget { + const _PreviewToggleTile({required this.controller}); + + final VoiceProfileController controller; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outline), + borderRadius: BorderRadius.circular(12), + ), + child: Obx( + () => SwitchListTile( + title: const Text('Preview Mode'), + subtitle: Text( + controller.isPreviewEnabled.value ? 'On' : 'Off', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + secondary: Icon( + Icons.headphones_outlined, + color: colorScheme.primary, + ), + value: controller.isPreviewEnabled.value, + onChanged: controller.onPreviewToggled, + activeColor: colorScheme.primary, + ), + ), + ); + } +}