From a371556a1c6d80d7c4bd518f2220f0ae85fcbae2 Mon Sep 17 00:00:00 2001 From: cogwheel <172976095+cogwheel0@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:48:43 +0530 Subject: [PATCH] feat(notes): Add audio recording and playback features --- .../res/drawable-v31/ic_widget_camera.xml | 1 + .../res/drawable-v31/ic_widget_clipboard.xml | 1 + .../main/res/drawable-v31/ic_widget_mic.xml | 1 + .../res/drawable-v31/ic_widget_mic_accent.xml | 1 + .../res/drawable-v31/ic_widget_photos.xml | 1 + .../res/drawable-v31/ic_widget_waveform.xml | 1 + .../res/drawable-v31/widget_background.xml | 1 + .../res/drawable-v31/widget_button_circle.xml | 1 + .../res/drawable-v31/widget_button_mic.xml | 1 + .../res/drawable-v31/widget_button_pill.xml | 1 + .../drawable-v31/widget_button_primary.xml | 1 + .../drawable-v31/widget_button_secondary.xml | 1 + android/app/src/main/res/drawable/ic_hub.xml | 1 + .../main/res/drawable/ic_widget_camera.xml | 1 + .../main/res/drawable/ic_widget_clipboard.xml | 1 + .../src/main/res/drawable/ic_widget_mic.xml | 1 + .../res/drawable/ic_widget_mic_accent.xml | 1 + .../main/res/drawable/ic_widget_photos.xml | 1 + .../main/res/drawable/ic_widget_waveform.xml | 1 + .../main/res/drawable/widget_background.xml | 1 + .../res/drawable/widget_button_circle.xml | 1 + .../main/res/drawable/widget_button_mic.xml | 1 + .../main/res/drawable/widget_button_pill.xml | 1 + .../res/drawable/widget_button_primary.xml | 1 + .../res/drawable/widget_button_secondary.xml | 1 + .../src/main/res/drawable/widget_preview.xml | 1 + .../src/main/res/layout/conduit_widget.xml | 1 + .../app/src/main/res/values-night/colors.xml | 1 + .../app/src/main/res/values-night/dimens.xml | 1 + android/app/src/main/res/values/colors.xml | 1 + android/app/src/main/res/values/dimens.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + .../src/main/res/xml/conduit_widget_info.xml | 1 + .../AccentColor.colorset/Contents.json | 1 + .../Assets.xcassets/Contents.json | 1 + .../HubIcon.imageset/Contents.json | 1 + .../Assets.xcassets/HubIcon.imageset/hub.svg | 1 + .../WidgetBackground.colorset/Contents.json | 1 + ios/ConduitWidget/ConduitWidget.entitlements | 1 + ios/ConduitWidget/Info.plist | 1 + ios/Podfile.lock | 24 +- ios/Runner/de.lproj/InfoPlist.strings | 1 + ios/Runner/en.lproj/InfoPlist.strings | 1 + ios/Runner/es.lproj/InfoPlist.strings | 1 + ios/Runner/fr.lproj/InfoPlist.strings | 1 + ios/Runner/it.lproj/InfoPlist.strings | 1 + ios/Runner/ko.lproj/InfoPlist.strings | 1 + ios/Runner/nl.lproj/InfoPlist.strings | 1 + ios/Runner/ru.lproj/InfoPlist.strings | 1 + ios/Runner/zh-Hans.lproj/InfoPlist.strings | 1 + ios/Runner/zh-Hant.lproj/InfoPlist.strings | 1 + lib/core/models/note.dart | 22 +- lib/core/services/api_service.dart | 47 +- lib/features/chat/services/tts_manager.dart | 66 ++- .../chat/services/voice_call_service.dart | 16 +- .../services/audio_recording_service.dart | 225 ++++++++++ .../notes/views/note_editor_page.dart | 420 ++++++++++++++++-- .../notes/widgets/audio_player_dialog.dart | 396 +++++++++++++++++ .../widgets/audio_recording_overlay.dart | 388 ++++++++++++++++ .../notes/widgets/note_file_attachment.dart | 312 +++++++++++++ lib/l10n/app_de.arb | 25 +- lib/l10n/app_en.arb | 92 ++++ lib/l10n/app_es.arb | 25 +- lib/l10n/app_fr.arb | 25 +- lib/l10n/app_it.arb | 25 +- lib/l10n/app_ko.arb | 25 +- lib/l10n/app_nl.arb | 25 +- lib/l10n/app_ru.arb | 25 +- lib/l10n/app_zh.arb | 25 +- lib/l10n/app_zh_Hant.arb | 25 +- lib/shared/utils/bytes_audio_source.dart | 56 +++ pubspec.lock | 80 ++-- pubspec.yaml | 2 +- 73 files changed, 2296 insertions(+), 125 deletions(-) create mode 100644 lib/features/notes/services/audio_recording_service.dart create mode 100644 lib/features/notes/widgets/audio_player_dialog.dart create mode 100644 lib/features/notes/widgets/audio_recording_overlay.dart create mode 100644 lib/features/notes/widgets/note_file_attachment.dart create mode 100644 lib/shared/utils/bytes_audio_source.dart diff --git a/android/app/src/main/res/drawable-v31/ic_widget_camera.xml b/android/app/src/main/res/drawable-v31/ic_widget_camera.xml index 613912f..35fd163 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_camera.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_camera.xml @@ -15,3 +15,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_clipboard.xml b/android/app/src/main/res/drawable-v31/ic_widget_clipboard.xml index 9681d95..6e316bc 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_clipboard.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_clipboard.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_mic.xml b/android/app/src/main/res/drawable-v31/ic_widget_mic.xml index e313d51..682ac9f 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_mic.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_mic.xml @@ -13,3 +13,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_mic_accent.xml b/android/app/src/main/res/drawable-v31/ic_widget_mic_accent.xml index 6bd3007..eb2ac70 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_mic_accent.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_mic_accent.xml @@ -13,3 +13,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_photos.xml b/android/app/src/main/res/drawable-v31/ic_widget_photos.xml index ebf4564..2f83244 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_photos.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_photos.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/ic_widget_waveform.xml b/android/app/src/main/res/drawable-v31/ic_widget_waveform.xml index a27a5c0..a50034d 100644 --- a/android/app/src/main/res/drawable-v31/ic_widget_waveform.xml +++ b/android/app/src/main/res/drawable-v31/ic_widget_waveform.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_background.xml b/android/app/src/main/res/drawable-v31/widget_background.xml index a8819d2..18c0e33 100644 --- a/android/app/src/main/res/drawable-v31/widget_background.xml +++ b/android/app/src/main/res/drawable-v31/widget_background.xml @@ -8,3 +8,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_circle.xml b/android/app/src/main/res/drawable-v31/widget_button_circle.xml index 8d8a59d..17b965d 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_circle.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_circle.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_mic.xml b/android/app/src/main/res/drawable-v31/widget_button_mic.xml index 644d7c1..e7f0d98 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_mic.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_mic.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_pill.xml b/android/app/src/main/res/drawable-v31/widget_button_pill.xml index e538a1a..0ae5617 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_pill.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_pill.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_primary.xml b/android/app/src/main/res/drawable-v31/widget_button_primary.xml index 2481c0c..6d0de28 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_primary.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_primary.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable-v31/widget_button_secondary.xml b/android/app/src/main/res/drawable-v31/widget_button_secondary.xml index bbef1b5..55bb366 100644 --- a/android/app/src/main/res/drawable-v31/widget_button_secondary.xml +++ b/android/app/src/main/res/drawable-v31/widget_button_secondary.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_hub.xml b/android/app/src/main/res/drawable/ic_hub.xml index 4d93da6..70c668d 100644 --- a/android/app/src/main/res/drawable/ic_hub.xml +++ b/android/app/src/main/res/drawable/ic_hub.xml @@ -13,3 +13,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_camera.xml b/android/app/src/main/res/drawable/ic_widget_camera.xml index 99a51ae..e9ab6d6 100644 --- a/android/app/src/main/res/drawable/ic_widget_camera.xml +++ b/android/app/src/main/res/drawable/ic_widget_camera.xml @@ -15,3 +15,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_clipboard.xml b/android/app/src/main/res/drawable/ic_widget_clipboard.xml index b1a01a8..bbbc5fa 100644 --- a/android/app/src/main/res/drawable/ic_widget_clipboard.xml +++ b/android/app/src/main/res/drawable/ic_widget_clipboard.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_mic.xml b/android/app/src/main/res/drawable/ic_widget_mic.xml index 408a920..e80e4cc 100644 --- a/android/app/src/main/res/drawable/ic_widget_mic.xml +++ b/android/app/src/main/res/drawable/ic_widget_mic.xml @@ -13,3 +13,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_mic_accent.xml b/android/app/src/main/res/drawable/ic_widget_mic_accent.xml index db16990..99bed20 100644 --- a/android/app/src/main/res/drawable/ic_widget_mic_accent.xml +++ b/android/app/src/main/res/drawable/ic_widget_mic_accent.xml @@ -13,3 +13,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_photos.xml b/android/app/src/main/res/drawable/ic_widget_photos.xml index 13bb9d1..99623d8 100644 --- a/android/app/src/main/res/drawable/ic_widget_photos.xml +++ b/android/app/src/main/res/drawable/ic_widget_photos.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/ic_widget_waveform.xml b/android/app/src/main/res/drawable/ic_widget_waveform.xml index 9873bb0..76ecb08 100644 --- a/android/app/src/main/res/drawable/ic_widget_waveform.xml +++ b/android/app/src/main/res/drawable/ic_widget_waveform.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml index 3145b11..2a58ab3 100644 --- a/android/app/src/main/res/drawable/widget_background.xml +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -8,3 +8,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_circle.xml b/android/app/src/main/res/drawable/widget_button_circle.xml index dd0e8fc..17387f2 100644 --- a/android/app/src/main/res/drawable/widget_button_circle.xml +++ b/android/app/src/main/res/drawable/widget_button_circle.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_mic.xml b/android/app/src/main/res/drawable/widget_button_mic.xml index 1a3344c..4a26642 100644 --- a/android/app/src/main/res/drawable/widget_button_mic.xml +++ b/android/app/src/main/res/drawable/widget_button_mic.xml @@ -11,3 +11,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_pill.xml b/android/app/src/main/res/drawable/widget_button_pill.xml index eaf88ee..f7cd2f1 100644 --- a/android/app/src/main/res/drawable/widget_button_pill.xml +++ b/android/app/src/main/res/drawable/widget_button_pill.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_primary.xml b/android/app/src/main/res/drawable/widget_button_primary.xml index 6625113..598558c 100644 --- a/android/app/src/main/res/drawable/widget_button_primary.xml +++ b/android/app/src/main/res/drawable/widget_button_primary.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_button_secondary.xml b/android/app/src/main/res/drawable/widget_button_secondary.xml index aaa263d..d082eb9 100644 --- a/android/app/src/main/res/drawable/widget_button_secondary.xml +++ b/android/app/src/main/res/drawable/widget_button_secondary.xml @@ -12,3 +12,4 @@ + diff --git a/android/app/src/main/res/drawable/widget_preview.xml b/android/app/src/main/res/drawable/widget_preview.xml index b89f5d2..24c86ac 100644 --- a/android/app/src/main/res/drawable/widget_preview.xml +++ b/android/app/src/main/res/drawable/widget_preview.xml @@ -54,3 +54,4 @@ + diff --git a/android/app/src/main/res/layout/conduit_widget.xml b/android/app/src/main/res/layout/conduit_widget.xml index 8f8b4f1..4a5b386 100644 --- a/android/app/src/main/res/layout/conduit_widget.xml +++ b/android/app/src/main/res/layout/conduit_widget.xml @@ -130,3 +130,4 @@ + diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml index 984cd08..d30b4c9 100644 --- a/android/app/src/main/res/values-night/colors.xml +++ b/android/app/src/main/res/values-night/colors.xml @@ -31,3 +31,4 @@ + diff --git a/android/app/src/main/res/values-night/dimens.xml b/android/app/src/main/res/values-night/dimens.xml index 8a0f40d..a4740d1 100644 --- a/android/app/src/main/res/values-night/dimens.xml +++ b/android/app/src/main/res/values-night/dimens.xml @@ -10,3 +10,4 @@ + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index f4e89eb..5aeb780 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -31,3 +31,4 @@ + diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml index f86f067..7fc5d1c 100644 --- a/android/app/src/main/res/values/dimens.xml +++ b/android/app/src/main/res/values/dimens.xml @@ -10,3 +10,4 @@ + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a156457..40c5b8c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -14,3 +14,4 @@ + diff --git a/android/app/src/main/res/xml/conduit_widget_info.xml b/android/app/src/main/res/xml/conduit_widget_info.xml index f0c92ac..c3521ef 100644 --- a/android/app/src/main/res/xml/conduit_widget_info.xml +++ b/android/app/src/main/res/xml/conduit_widget_info.xml @@ -18,3 +18,4 @@ + diff --git a/ios/ConduitWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/ConduitWidget/Assets.xcassets/AccentColor.colorset/Contents.json index 2c12335..bda4964 100644 --- a/ios/ConduitWidget/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/ios/ConduitWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -21,3 +21,4 @@ + diff --git a/ios/ConduitWidget/Assets.xcassets/Contents.json b/ios/ConduitWidget/Assets.xcassets/Contents.json index 509ef7e..ec8b9fd 100644 --- a/ios/ConduitWidget/Assets.xcassets/Contents.json +++ b/ios/ConduitWidget/Assets.xcassets/Contents.json @@ -7,3 +7,4 @@ + diff --git a/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/Contents.json b/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/Contents.json index c3e4b5c..c74f8d1 100644 --- a/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/Contents.json +++ b/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/Contents.json @@ -17,3 +17,4 @@ + diff --git a/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/hub.svg b/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/hub.svg index 6d1073e..f377b05 100644 --- a/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/hub.svg +++ b/ios/ConduitWidget/Assets.xcassets/HubIcon.imageset/hub.svg @@ -2,3 +2,4 @@ + diff --git a/ios/ConduitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/ConduitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json index 7461877..588a0d5 100644 --- a/ios/ConduitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json +++ b/ios/ConduitWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -39,3 +39,4 @@ + diff --git a/ios/ConduitWidget/ConduitWidget.entitlements b/ios/ConduitWidget/ConduitWidget.entitlements index 46f015b..922758f 100644 --- a/ios/ConduitWidget/ConduitWidget.entitlements +++ b/ios/ConduitWidget/ConduitWidget.entitlements @@ -11,3 +11,4 @@ + diff --git a/ios/ConduitWidget/Info.plist b/ios/ConduitWidget/Info.plist index b330d5a..8cabc50 100644 --- a/ios/ConduitWidget/Info.plist +++ b/ios/ConduitWidget/Info.plist @@ -12,3 +12,4 @@ + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 18414c2..9f933b5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,7 +1,6 @@ PODS: - - audioplayers_darwin (0.0.1): + - audio_session (0.0.1): - Flutter - - FlutterMacOS - connectivity_plus (0.0.1): - Flutter - CryptoSwift (1.8.4) @@ -55,6 +54,8 @@ PODS: - SDWebImageWebPCoder - flutter_local_notifications (0.0.1): - Flutter + - flutter_native_splash (2.4.3): + - Flutter - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS @@ -66,6 +67,9 @@ PODS: - Flutter - irondash_engine_context (0.0.1): - Flutter + - just_audio (0.0.1): + - Flutter + - FlutterMacOS - libwebp (1.5.0): - libwebp/demux (= 1.5.0) - libwebp/mux (= 1.5.0) @@ -138,7 +142,7 @@ PODS: - FlutterMacOS DEPENDENCIES: - - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -146,11 +150,13 @@ DEPENDENCIES: - flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`) - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) - home_widget (from `.symlinks/plugins/home_widget/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - pasteboard (from `.symlinks/plugins/pasteboard/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -184,8 +190,8 @@ SPEC REPOS: - SwiftyGif EXTERNAL SOURCES: - audioplayers_darwin: - :path: ".symlinks/plugins/audioplayers_darwin/darwin" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: @@ -200,6 +206,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_image_compress_common/ios" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_native_splash: + :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" flutter_tts: @@ -210,6 +218,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" irondash_engine_context: :path: ".symlinks/plugins/irondash_engine_context/ios" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" pasteboard: @@ -244,7 +254,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90 CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a @@ -257,11 +267,13 @@ SPEC CHECKSUMS: flutter_callkit_incoming: cb8138af67cda6dd981f7101a5d709003af21502 flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a diff --git a/ios/Runner/de.lproj/InfoPlist.strings b/ios/Runner/de.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/de.lproj/InfoPlist.strings +++ b/ios/Runner/de.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/en.lproj/InfoPlist.strings b/ios/Runner/en.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/en.lproj/InfoPlist.strings +++ b/ios/Runner/en.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/es.lproj/InfoPlist.strings b/ios/Runner/es.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/es.lproj/InfoPlist.strings +++ b/ios/Runner/es.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/fr.lproj/InfoPlist.strings b/ios/Runner/fr.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/fr.lproj/InfoPlist.strings +++ b/ios/Runner/fr.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/it.lproj/InfoPlist.strings b/ios/Runner/it.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/it.lproj/InfoPlist.strings +++ b/ios/Runner/it.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/ko.lproj/InfoPlist.strings b/ios/Runner/ko.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/ko.lproj/InfoPlist.strings +++ b/ios/Runner/ko.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/nl.lproj/InfoPlist.strings b/ios/Runner/nl.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/nl.lproj/InfoPlist.strings +++ b/ios/Runner/nl.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/ru.lproj/InfoPlist.strings b/ios/Runner/ru.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/ru.lproj/InfoPlist.strings +++ b/ios/Runner/ru.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/zh-Hans.lproj/InfoPlist.strings b/ios/Runner/zh-Hans.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/zh-Hans.lproj/InfoPlist.strings +++ b/ios/Runner/zh-Hans.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/ios/Runner/zh-Hant.lproj/InfoPlist.strings b/ios/Runner/zh-Hant.lproj/InfoPlist.strings index fe5b0cf..5c08ca7 100644 --- a/ios/Runner/zh-Hant.lproj/InfoPlist.strings +++ b/ios/Runner/zh-Hant.lproj/InfoPlist.strings @@ -2,3 +2,4 @@ CFBundleDisplayName = "Conduit"; + diff --git a/lib/core/models/note.dart b/lib/core/models/note.dart index 7604e27..955912e 100644 --- a/lib/core/models/note.dart +++ b/lib/core/models/note.dart @@ -5,6 +5,22 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'note.freezed.dart'; part 'note.g.dart'; +/// Helper to extract user_id from JSON, falling back to user.id if not present. +/// OpenWebUI's NoteItemResponse (list endpoint) doesn't include user_id directly +/// but does include the user object with an id field. +Object? _readUserId(Map json, String key) { + // First try the direct user_id field + if (json['user_id'] != null) { + return json['user_id']; + } + // Fall back to extracting from user object + final user = json['user']; + if (user is Map && user['id'] != null) { + return user['id']; + } + return null; +} + /// Content structure for a note, supporting multiple formats. @freezed sealed class NoteContent with _$NoteContent { @@ -80,7 +96,11 @@ sealed class Note with _$Note { const factory Note({ required String id, - @JsonKey(name: 'user_id') required String userId, + + /// User ID - may be null in list responses (NoteItemResponse) + /// Can be extracted from user.id if present + @JsonKey(name: 'user_id', readValue: _readUserId) String? userId, + required String title, /// Note content and associated data diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 21cded8..7b92a5b 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -33,6 +33,28 @@ void _traceApi(String message) { DebugLogger.log(message, scope: 'api/trace'); } +/// Get MIME type from file extension. +String? _getMimeType(String fileName) { + final ext = fileName.toLowerCase().split('.').last; + return switch (ext) { + 'm4a' => 'audio/mp4', + 'mp3' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'aac' => 'audio/aac', + 'ogg' => 'audio/ogg', + 'webm' => 'audio/webm', + 'mp4' => 'video/mp4', + 'jpg' || 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'pdf' => 'application/pdf', + 'txt' => 'text/plain', + 'json' => 'application/json', + _ => null, + }; +} + /// Result of a health check with proxy detection. /// /// This enum distinguishes between different failure modes: @@ -1831,6 +1853,12 @@ class ApiService { return response.data as String; } + /// Get the URL for a file's content (for direct access/playback). + /// This URL can be used directly by audio/video players. + String getFileContentUrl(String fileId) { + return '$baseUrl/api/v1/files/$fileId/content'; + } + Future deleteFile(String fileId) async { _traceApi('Deleting file: $fileId'); await _dio.delete('/api/v1/files/$fileId'); @@ -3434,7 +3462,7 @@ class ApiService { } // File upload for RAG - Future uploadFile(String filePath, String fileName) async { + Future uploadFile(String filePath, String fileName, {String? contentType}) async { _traceApi('Starting file upload: $fileName from $filePath'); try { @@ -3444,8 +3472,15 @@ class ApiService { throw Exception('File does not exist: $filePath'); } + // Determine content type from file extension if not provided + final mimeType = contentType ?? _getMimeType(fileName); + final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile(filePath, filename: fileName), + 'file': await MultipartFile.fromFile( + filePath, + filename: fileName, + contentType: mimeType != null ? DioMediaType.parse(mimeType) : null, + ), }); _traceApi('Uploading to /api/v1/files/'); @@ -4020,12 +4055,14 @@ class ApiService { return (const >[], true); } } on DioException catch (e) { - // 403 indicates notes feature is disabled server-side - if (e.response?.statusCode == 403) { + // 401/403 indicates notes feature is disabled server-side or user lacks permission + // OpenWebUI returns 401 when user doesn't have "features.notes" permission + final statusCode = e.response?.statusCode; + if (statusCode == 401 || statusCode == 403) { DebugLogger.log( 'feature-disabled', scope: 'api/notes', - data: {'status': 403}, + data: {'status': statusCode}, ); return (const >[], false); } diff --git a/lib/features/chat/services/tts_manager.dart b/lib/features/chat/services/tts_manager.dart index 950de14..dc744c1 100644 --- a/lib/features/chat/services/tts_manager.dart +++ b/lib/features/chat/services/tts_manager.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'dart:io' show Platform; -import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_tts/flutter_tts.dart'; +import 'package:just_audio/just_audio.dart'; import '../../../core/services/api_service.dart'; +import '../../../shared/utils/bytes_audio_source.dart'; // ============================================================================= // TTS Events @@ -142,9 +143,15 @@ class TtsManager { bool _handlersSet = false; Completer? _initCompleter; - // AudioPlayer for server TTS + // AudioPlayer for server TTS (using just_audio) final AudioPlayer _player = AudioPlayer(); bool _playerConfigured = false; + StreamSubscription? _playerStateSub; + + /// Flag to suppress spurious TtsPaused events during chunk transitions. + /// When true, the player is actively switching audio sources and pause + /// events should not be emitted to listeners. + bool _isTransitioningChunks = false; // API service for server TTS (must be set before using server TTS) ApiService? _apiService; @@ -222,22 +229,27 @@ class TtsManager { // Initialize FlutterTts await _ensureTtsInitialized(); - // Configure AudioPlayer for all platforms + // Configure AudioPlayer for all platforms (using just_audio) if (!_playerConfigured) { - _player.onPlayerComplete.listen((_) => _onServerAudioComplete()); - _player.onPlayerStateChanged.listen((state) { - if (state == PlayerState.playing) { + _playerStateSub = _player.playerStateStream.listen((state) { + if (state.processingState == ProcessingState.completed) { + _onServerAudioComplete(); + } + if (state.playing) { + // Clear transition flag when playback actually starts. + // This ensures pause events aren't emitted during the brief window + // between play() returning and the player entering playing state. + _isTransitioningChunks = false; _emitEvent(const TtsStarted()); - } else if (state == PlayerState.paused) { + } else if (!state.playing && + state.processingState == ProcessingState.ready && + !_isTransitioningChunks) { + // Only emit pause when actually paused, ready, and NOT transitioning + // between chunks. During chunk transitions, the player briefly enters + // a ready-but-not-playing state which should not emit pause events. _emitEvent(const TtsPaused()); } }); - // Android-specific audio context configuration - if (!kIsWeb && Platform.isAndroid) { - await _player.setAudioContext( - AudioContext(android: const AudioContextAndroid()), - ); - } _playerConfigured = true; } @@ -334,7 +346,7 @@ class TtsManager { try { if (session.useServerTts) { - await _player.resume(); + await _player.play(); _emitEvent(const TtsResumed()); } else { // Device TTS resume is handled by the native handler @@ -367,6 +379,7 @@ class TtsManager { /// Disposes the manager and releases resources. Future dispose() async { await stop(); + await _playerStateSub?.cancel(); await _player.dispose(); await _eventController.close(); } @@ -690,9 +703,17 @@ class TtsManager { _serverCurrentIndex = 0; await _player.stop(); - await _player.play( - BytesSource(firstChunk.bytes, mimeType: firstChunk.mimeType), - ); + _isTransitioningChunks = true; + // Flag will be cleared by state listener when playing=true is received. + // This prevents race condition where flag is cleared before state fires. + try { + await _player.setAudioSource(BytesAudioSource(firstChunk.bytes, firstChunk.mimeType)); + await _player.play(); + } catch (e) { + // Reset flag on error to avoid suppressing future pause events + _isTransitioningChunks = false; + rethrow; + } _emitEvent(const TtsChunkStarted(0)); // Prefetch remaining chunks in background @@ -766,7 +787,16 @@ class TtsManager { _serverCurrentIndex = nextIndex; final chunk = _serverAudioBuffer[nextIndex]; - await _player.play(BytesSource(chunk.bytes, mimeType: chunk.mimeType)); + _isTransitioningChunks = true; + // Flag will be cleared by state listener when playing=true is received. + try { + await _player.setAudioSource(BytesAudioSource(chunk.bytes, chunk.mimeType)); + await _player.play(); + } catch (e) { + // Reset flag on error to avoid suppressing future pause events + _isTransitioningChunks = false; + rethrow; + } _emitEvent(TtsChunkStarted(nextIndex)); } diff --git a/lib/features/chat/services/voice_call_service.dart b/lib/features/chat/services/voice_call_service.dart index d98fbf1..fd65d26 100644 --- a/lib/features/chat/services/voice_call_service.dart +++ b/lib/features/chat/services/voice_call_service.dart @@ -3,8 +3,8 @@ import 'dart:collection'; import 'dart:developer' as developer; import 'dart:io'; -import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; +import 'package:just_audio/just_audio.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -12,6 +12,7 @@ import '../../../core/providers/app_providers.dart'; import '../../../core/services/background_streaming_handler.dart'; import '../../../core/services/callkit_service.dart'; import '../../../core/services/socket_service.dart'; +import '../../../shared/utils/bytes_audio_source.dart'; import '../../../shared/widgets/markdown/markdown_preprocessor.dart'; import '../providers/chat_providers.dart'; import 'text_to_speech_service.dart'; @@ -64,6 +65,7 @@ class VoiceCallService { bool _listeningSuspendedForSpeech = false; final Map _serverAudioBuffer = {}; final AudioPlayer _serverAudioPlayer = AudioPlayer(); + StreamSubscription? _serverAudioStateSub; int _serverAudioSession = 0; int _pendingServerAudioFetches = 0; bool _serverPipelineActive = false; @@ -102,8 +104,10 @@ class VoiceCallService { // sentence/word callbacks are not required for call UI, but harmless ); - _serverAudioPlayer.onPlayerComplete.listen((_) { - _handleServerAudioComplete(); + _serverAudioStateSub = _serverAudioPlayer.playerStateStream.listen((state) { + if (state.processingState == ProcessingState.completed) { + _handleServerAudioComplete(); + } }); unawaited(_tts.preloadServerDefaults()); @@ -721,9 +725,10 @@ class VoiceCallService { await _prepareForSpeechPlayback(); _isSpeaking = true; _updateState(VoiceCallState.speaking); - await _serverAudioPlayer.play( - BytesSource(chunk.bytes, mimeType: chunk.mimeType), + await _serverAudioPlayer.setAudioSource( + BytesAudioSource(chunk.bytes, chunk.mimeType), ); + await _serverAudioPlayer.play(); } catch (e) { _isSpeaking = false; _handleTtsError(e.toString()); @@ -1003,6 +1008,7 @@ class VoiceCallService { _voiceInput.dispose(); await _tts.dispose(); + await _serverAudioStateSub?.cancel(); await _serverAudioPlayer.dispose(); // Cancel notification diff --git a/lib/features/notes/services/audio_recording_service.dart b/lib/features/notes/services/audio_recording_service.dart new file mode 100644 index 0000000..ab95b06 --- /dev/null +++ b/lib/features/notes/services/audio_recording_service.dart @@ -0,0 +1,225 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +/// Minimum valid audio file size (anything smaller is likely just a header) +const int _minValidAudioSize = 1000; // 1KB minimum + +/// Exception thrown when audio recording fails. +class AudioRecordingException implements Exception { + final String message; + AudioRecordingException(this.message); + + @override + String toString() => message; +} + +/// Service for recording raw audio files without real-time transcription. +/// +/// This is used in the notes feature where users want to preserve their original +/// audio recordings for later transcription using server-side Whisper, rather +/// than using Apple's real-time speech transcription which: +/// - Sends data to Apple's servers (privacy concern for self-hosted setups) +/// - Auto-stops after silence periods +/// - Loses the original audio after transcription +class AudioRecordingService { + final AudioRecorder _recorder = AudioRecorder(); + bool _isRecording = false; + String? _currentFilePath; + DateTime? _startTime; + + final _durationController = StreamController.broadcast(); + Stream get durationStream => _durationController.stream; + + Timer? _durationTimer; + + bool get isRecording => _isRecording; + + Duration get currentDuration => _startTime != null + ? DateTime.now().difference(_startTime!) + : Duration.zero; + + /// Starts recording audio to a file. + /// + /// Returns the file path where audio will be saved. + /// Throws an exception if microphone permission is denied. + Future startRecording() async { + if (_isRecording) { + throw StateError('Already recording'); + } + + // Check/request microphone permission + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + throw Exception('Microphone permission denied'); + } + + // Generate unique file path + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + + // Use AAC for iOS (native support) and better compatibility + // Use m4a extension which is widely supported + _currentFilePath = '${tempDir.path}/note_recording_$timestamp.m4a'; + + // Configure recording for high quality audio + // Using AAC encoder for good compression and cross-platform compatibility + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.aacLc, + sampleRate: 44100, + bitRate: 128000, + numChannels: 1, + // Don't alter the recording - preserve original audio + echoCancel: false, + autoGain: false, + noiseSuppress: false, + ), + path: _currentFilePath!, + ); + + _isRecording = true; + _startTime = DateTime.now(); + + // Start duration timer for UI updates + _durationTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { + // Use try-catch to handle race condition where dispose() closes + // the controller between the check and the add + try { + _durationController.add(currentDuration); + } catch (_) { + // Controller was closed, timer will be cancelled by dispose() + } + }); + + debugPrint('AudioRecordingService: Started recording to $_currentFilePath'); + return _currentFilePath!; + } + + /// Stops recording and returns the recorded file. + /// + /// Returns null if recording was not active, the file doesn't exist, + /// or the recording failed (file too small). + /// Throws an exception if the recording captured no audio data. + Future stopRecording() async { + if (!_isRecording || _currentFilePath == null) { + return null; + } + + _durationTimer?.cancel(); + _durationTimer = null; + + final path = await _recorder.stop(); + _isRecording = false; + _startTime = null; + + if (path == null) { + debugPrint('AudioRecordingService: Stop returned null path'); + return null; + } + + final file = File(path); + if (!await file.exists()) { + debugPrint('AudioRecordingService: File does not exist at $path'); + return null; + } + + final fileSize = await file.length(); + debugPrint( + 'AudioRecordingService: Recording stopped, file size: $fileSize bytes', + ); + + // Check if the file is too small (likely just header, no audio data) + if (fileSize < _minValidAudioSize) { + debugPrint( + 'AudioRecordingService: Recording failed - file too small ' + '($fileSize bytes < $_minValidAudioSize minimum). ' + 'This usually means the microphone is not working or not available.', + ); + // Clean up the invalid file + try { + await file.delete(); + } catch (_) {} + _currentFilePath = null; + throw AudioRecordingException( + 'Recording captured no audio. ' + 'Please check microphone permissions and try again.', + ); + } + + _currentFilePath = null; + return file; + } + + /// Cancels recording and deletes any recorded data. + Future cancelRecording() async { + _durationTimer?.cancel(); + _durationTimer = null; + + if (_isRecording) { + await _recorder.stop(); + _isRecording = false; + _startTime = null; + } + + if (_currentFilePath != null) { + try { + final file = File(_currentFilePath!); + if (await file.exists()) { + await file.delete(); + debugPrint('AudioRecordingService: Deleted cancelled recording'); + } + } catch (e) { + debugPrint('AudioRecordingService: Failed to delete file: $e'); + } + _currentFilePath = null; + } + } + + /// Stream of amplitude values for visualization. + /// + /// Returns amplitude data every 100ms while recording. + Stream get amplitudeStream => _recorder.onAmplitudeChanged( + const Duration(milliseconds: 100), + ); + + /// Disposes of resources used by the service. + /// + /// This should be called when the service is no longer needed to properly + /// release native audio resources. If a recording is in progress, it will + /// be cancelled and any temp files cleaned up. + Future dispose() async { + // Cancel any in-progress recording first to clean up temp files. + // Wrapped in try-catch to ensure timer/controller cleanup always happens. + if (_isRecording) { + try { + await cancelRecording(); + } catch (e) { + debugPrint('AudioRecordingService: Error cancelling recording in dispose: $e'); + } + } + + // Cancel timer BEFORE closing controller to avoid relying on exception + // handling for control flow. The try-catch in the timer callback is a + // safety net for any remaining race condition. + _durationTimer?.cancel(); + _durationTimer = null; + + if (!_durationController.isClosed) { + await _durationController.close(); + } + + // Await recorder disposal to ensure native resources are released. + // Wrapped in try-catch since recorder may be in inconsistent state if + // cancelRecording() failed above. + try { + await _recorder.dispose(); + } catch (e) { + debugPrint('AudioRecordingService: Error disposing recorder: $e'); + } + } +} + diff --git a/lib/features/notes/views/note_editor_page.dart b/lib/features/notes/views/note_editor_page.dart index f95e3e0..c50863e 100644 --- a/lib/features/notes/views/note_editor_page.dart +++ b/lib/features/notes/views/note_editor_page.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io' show Platform; +import 'dart:io' show File, Platform; import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; @@ -21,6 +21,9 @@ import '../../../shared/widgets/middle_ellipsis_text.dart'; import '../../../shared/widgets/themed_dialogs.dart'; import '../../chat/services/voice_input_service.dart'; import '../providers/notes_providers.dart'; +import '../widgets/audio_player_dialog.dart'; +import '../widgets/audio_recording_overlay.dart'; +import '../widgets/note_file_attachment.dart'; /// Page for editing a note with OpenWebUI-style layout. class NoteEditorPage extends ConsumerStatefulWidget { @@ -46,6 +49,7 @@ class _NoteEditorPageState extends ConsumerState { bool _isGeneratingTitle = false; bool _isEnhancing = false; bool _isRecording = false; + bool _isUploadingAudio = false; Note? _note; // Voice input @@ -450,6 +454,246 @@ class _NoteEditorPageState extends ConsumerState { } } + /// Shows a bottom sheet to choose between dictation and audio recording. + void _showRecordingOptions() { + final conduitTheme = context.conduitTheme; + final l10n = AppLocalizations.of(context)!; + + showModalBottomSheet( + context: context, + backgroundColor: conduitTheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.modal), + ), + ), + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.md), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Handle bar + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: Spacing.md), + decoration: BoxDecoration( + color: conduitTheme.textSecondary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.round), + ), + ), + // Dictation option + ListTile( + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: conduitTheme.buttonPrimary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.keyboard + : Icons.keyboard_voice_rounded, + color: conduitTheme.buttonPrimary, + size: IconSize.md, + ), + ), + title: Text( + l10n.dictation, + style: TextStyle( + color: conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + l10n.dictationDescription, + style: TextStyle( + color: conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + ), + ), + onTap: () { + Navigator.pop(context); + _toggleDictation(); + }, + ), + const SizedBox(height: Spacing.xs), + // Audio recording option + ListTile( + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.mic_fill + : Icons.mic_rounded, + color: Colors.red, + size: IconSize.md, + ), + ), + title: Text( + l10n.recordAudio, + style: TextStyle( + color: conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + l10n.recordAudioDescription, + style: TextStyle( + color: conduitTheme.textSecondary, + fontSize: AppTypography.bodySmall, + ), + ), + onTap: () { + Navigator.pop(context); + _showAudioRecordingOverlay(); + }, + ), + ], + ), + ), + ), + ); + } + + /// Shows the full-screen audio recording overlay. + void _showAudioRecordingOverlay() { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierDismissible: false, + pageBuilder: (context, animation, secondaryAnimation) { + return FadeTransition( + opacity: animation, + child: AudioRecordingOverlay( + onCancel: () => Navigator.pop(context), + onConfirm: (file) async { + Navigator.pop(context); + await _uploadAudioFile(file); + }, + ), + ); + }, + transitionDuration: const Duration(milliseconds: 200), + reverseTransitionDuration: const Duration(milliseconds: 150), + ), + ); + } + + /// Uploads an audio file to the server and attaches it to the note. + Future _uploadAudioFile(File audioFile) async { + final api = ref.read(apiServiceProvider); + final l10n = AppLocalizations.of(context)!; + + if (api == null || _note == null) { + _showError(l10n.failedToUploadAudio); + return; + } + + setState(() => _isUploadingAudio = true); + + try { + // Get file info + final fileSize = await audioFile.length(); + final fileName = 'recording_${DateTime.now().millisecondsSinceEpoch}.m4a'; + + // Upload file to Open WebUI with proper content type + final fileId = await api.uploadFile( + audioFile.path, + fileName, + contentType: 'audio/mp4', + ); + + // Get current note files + final currentFiles = _note!.data.files ?? []; + + // Generate a local item ID (for OpenWebUI compatibility) + final itemId = DateTime.now().millisecondsSinceEpoch.toString(); + + // Add the new file in OpenWebUI's expected format + // Must match the structure in NoteEditor.svelte uploadFileHandler + final updatedFiles = [ + ...currentFiles, + { + 'type': 'file', + 'file': '', + 'id': fileId, + 'url': fileId, + 'name': fileName, + 'collection_name': '', + 'status': 'uploaded', + 'size': fileSize, + 'error': '', + 'itemId': itemId, + }, + ]; + + debugPrint('NoteEditorPage: Saving files: $updatedFiles'); + + // Update note with the file attachment + final data = { + 'content': { + 'json': null, + 'html': _markdownToHtml(_contentController.text), + 'md': _contentController.text, + }, + 'files': updatedFiles, + }; + + debugPrint('NoteEditorPage: Updating note with data: $data'); + + final json = await api.updateNote( + widget.noteId, + title: _titleController.text.isEmpty ? l10n.untitled : _titleController.text, + data: data, + ); + + debugPrint('NoteEditorPage: Update response: $json'); + debugPrint('NoteEditorPage: Response files: ${json['data']?['files']}'); + + final updatedNote = Note.fromJson(json); + + if (mounted) { + // Update provider state inside mounted check to avoid accessing + // invalid ref after widget disposal + ref.read(notesListProvider.notifier).updateNote(updatedNote); + + setState(() { + _note = updatedNote; + _isUploadingAudio = false; + _hasChanges = false; + }); + + HapticFeedback.mediumImpact(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.audioRecordingSaved), + duration: const Duration(seconds: 2), + ), + ); + } + + // Clean up temp file + try { + await audioFile.delete(); + } catch (_) { + // Ignore cleanup errors + } + } catch (e) { + if (mounted) { + setState(() => _isUploadingAudio = false); + _showError('Failed to upload audio: $e'); + } + } + } + void _copyToClipboard() { final l10n = AppLocalizations.of(context)!; final content = _contentController.text; @@ -979,6 +1223,9 @@ class _NoteEditorPageState extends ConsumerState { // App bar height: kToolbarHeight + metadata bar (~40) final appBarHeight = kToolbarHeight + 40; + // Get attached files + final files = _note?.data.files ?? []; + return GestureDetector( onTap: () => _contentFocusNode.requestFocus(), behavior: HitTestBehavior.opaque, @@ -990,35 +1237,150 @@ class _NoteEditorPageState extends ConsumerState { Spacing.inputPadding, 120, // Extra padding for floating buttons ), - child: TextField( - controller: _contentController, - focusNode: _contentFocusNode, - style: AppTypography.bodyLargeStyle.copyWith( - color: theme.textPrimary, - height: 1.8, - ), - decoration: InputDecoration( - hintText: l10n.writeNote, - hintStyle: AppTypography.bodyLargeStyle.copyWith( - color: theme.textSecondary.withValues(alpha: 0.35), - height: 1.8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // File attachments section (if any) + if (files.isNotEmpty) ...[ + NoteFilesSection( + files: files, + onPlayFile: _playAudioFile, + onDeleteFile: _removeFile, + ), + const SizedBox(height: Spacing.lg), + ], + // Content editor + TextField( + controller: _contentController, + focusNode: _contentFocusNode, + style: AppTypography.bodyLargeStyle.copyWith( + color: theme.textPrimary, + height: 1.8, + ), + decoration: InputDecoration( + hintText: l10n.writeNote, + hintStyle: AppTypography.bodyLargeStyle.copyWith( + color: theme.textSecondary.withValues(alpha: 0.35), + height: 1.8, + ), + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + maxLines: null, + minLines: 20, + textAlignVertical: TextAlignVertical.top, + textCapitalization: TextCapitalization.sentences, + keyboardType: TextInputType.multiline, ), - filled: false, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - ), - maxLines: null, - minLines: 20, - textAlignVertical: TextAlignVertical.top, - textCapitalization: TextCapitalization.sentences, - keyboardType: TextInputType.multiline, + ], ), ), ); } + /// Play an audio file attachment. + Future _playAudioFile(Map file) async { + final fileId = file['id']?.toString(); + if (fileId == null) return; + + final api = ref.read(apiServiceProvider); + if (api == null) return; + + final fileName = file['name']?.toString() ?? 'Audio Recording'; + + await AudioPlayerDialog.show( + context, + fileId: fileId, + api: api, + fileName: fileName, + ); + } + + /// Remove a file attachment from the note. + Future _removeFile(Map file) async { + final l10n = AppLocalizations.of(context)!; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.removeFile), + content: Text(l10n.removeFileConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: context.conduitTheme.error, + ), + child: Text(l10n.delete), + ), + ], + ), + ); + + if (confirmed != true || _note == null) return; + + final api = ref.read(apiServiceProvider); + if (api == null) return; + + setState(() => _isSaving = true); + + try { + final fileId = file['id']?.toString(); + final currentFiles = _note!.data.files ?? []; + final updatedFiles = currentFiles + .where((f) => f['id']?.toString() != fileId) + .toList(); + + final data = { + 'content': { + 'json': null, + 'html': _markdownToHtml(_contentController.text), + 'md': _contentController.text, + }, + 'files': updatedFiles, + }; + + final json = await api.updateNote( + widget.noteId, + title: _titleController.text.isEmpty + ? l10n.untitled + : _titleController.text, + data: data, + ); + + final updatedNote = Note.fromJson(json); + ref.read(notesListProvider.notifier).updateNote(updatedNote); + + if (mounted) { + setState(() { + _note = updatedNote; + _isSaving = false; + _hasChanges = false; + }); + + HapticFeedback.lightImpact(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.fileRemoved), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() => _isSaving = false); + _showError(e.toString()); + } + } + } + Widget _buildFloatingActionsRow(BuildContext context) { final theme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; @@ -1026,7 +1388,7 @@ class _NoteEditorPageState extends ConsumerState { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Dictation button + // Voice/Recording button - shows menu if not recording, stops if recording _buildFloatingButton( context, icon: _isRecording @@ -1035,9 +1397,11 @@ class _NoteEditorPageState extends ConsumerState { : Icons.stop_rounded) : (Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic_rounded), color: _isRecording ? theme.error : null, - isLoading: false, - tooltip: _isRecording ? l10n.stopRecording : l10n.startDictation, - onPressed: _toggleDictation, + isLoading: _isUploadingAudio, + tooltip: _isRecording ? l10n.stopRecording : l10n.voiceOptions, + onPressed: _isUploadingAudio + ? null + : (_isRecording ? _toggleDictation : _showRecordingOptions), ), // AI button diff --git a/lib/features/notes/widgets/audio_player_dialog.dart b/lib/features/notes/widgets/audio_player_dialog.dart new file mode 100644 index 0000000..63f07f3 --- /dev/null +++ b/lib/features/notes/widgets/audio_player_dialog.dart @@ -0,0 +1,396 @@ +import 'dart:async'; +import 'dart:io' show File, Platform; + +import 'package:dio/dio.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../../core/services/api_service.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../shared/theme/theme_extensions.dart'; + +/// A dialog for playing audio files. +class AudioPlayerDialog extends StatefulWidget { + /// The file ID for downloading. + final String fileId; + + /// The API service for authenticated requests. + final ApiService api; + + /// The file name to display. + final String fileName; + + const AudioPlayerDialog({ + super.key, + required this.fileId, + required this.api, + required this.fileName, + }); + + /// Shows the audio player dialog. + static Future show( + BuildContext context, { + required String fileId, + required ApiService api, + required String fileName, + }) { + return showDialog( + context: context, + builder: (context) => AudioPlayerDialog( + fileId: fileId, + api: api, + fileName: fileName, + ), + ); + } + + @override + State createState() => _AudioPlayerDialogState(); +} + +class _AudioPlayerDialogState extends State { + final AudioPlayer _player = AudioPlayer(); + + bool _isPlaying = false; + bool _isLoading = true; + bool _hasError = false; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + File? _tempFile; + + StreamSubscription? _stateSub; + StreamSubscription? _positionSub; + StreamSubscription? _durationSub; + + @override + void initState() { + super.initState(); + _setupPlayer(); + } + + Future _setupPlayer() async { + try { + // Get file info first to determine the correct extension + final fileInfo = await widget.api.getFileInfo(widget.fileId); + final filename = fileInfo['filename'] as String? ?? 'audio.m4a'; + final contentType = (fileInfo['meta'] as Map?)?['content_type'] as String?; + + debugPrint('AudioPlayerDialog: filename=$filename, contentType=$contentType'); + debugPrint('AudioPlayerDialog: fileInfo=$fileInfo'); + + // Extract extension from filename + final extension = filename.contains('.') + ? filename.substring(filename.lastIndexOf('.')) + : '.m4a'; + + // Download the file (requires authentication) + // Use timestamp suffix to prevent conflicts if same file opened multiple times + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final tempPath = '${tempDir.path}/audio_${widget.fileId}_$timestamp$extension'; + _tempFile = File(tempPath); + + // Fetch file content through API (authenticated) + final response = await widget.api.dio.get( + '/api/v1/files/${widget.fileId}/content', + options: Options(responseType: ResponseType.bytes), + ); + + final responseData = response.data; + if (responseData is! List) { + throw Exception('Unexpected response type: ${responseData.runtimeType}'); + } + final bytes = responseData; + debugPrint('AudioPlayerDialog: Downloaded ${bytes.length} bytes'); + debugPrint('AudioPlayerDialog: First 20 bytes: ${bytes.take(20).toList()}'); + debugPrint('AudioPlayerDialog: Response content-type: ${response.headers.value('content-type')}'); + + await _tempFile!.writeAsBytes(bytes); + debugPrint('AudioPlayerDialog: Saved to $tempPath'); + + // Setup player state listeners + _stateSub = _player.playerStateStream.listen((state) { + if (!mounted) return; + setState(() { + _isPlaying = state.playing; + if (state.processingState == ProcessingState.completed) { + _isPlaying = false; + _position = _duration; + } + }); + }); + + _positionSub = _player.positionStream.listen((pos) { + if (!mounted) return; + setState(() => _position = pos); + }); + + _durationSub = _player.durationStream.listen((dur) { + if (!mounted) return; + if (dur != null) { + setState(() { + _duration = dur; + _isLoading = false; + }); + } + }); + + // Load and play the file + await _player.setFilePath(_tempFile!.path); + + if (mounted) { + setState(() => _isLoading = false); + } + + await _player.play(); + } catch (e) { + debugPrint('AudioPlayerDialog: Error loading audio: $e'); + // Clean up temp file on error to avoid orphaned files + _tempFile?.delete().then((_) { + debugPrint('AudioPlayerDialog: Cleaned up temp file after error'); + }).catchError((e) { + debugPrint('AudioPlayerDialog: Failed to clean up temp file after error: $e'); + }); + _tempFile = null; + if (!mounted) return; + setState(() { + _hasError = true; + _isLoading = false; + }); + } + } + + Future _togglePlayPause() async { + if (_isPlaying) { + await _player.pause(); + } else { + // If at end, restart from beginning + if (_position >= _duration && _duration > Duration.zero) { + await _player.seek(Duration.zero); + } + await _player.play(); + } + } + + Future _seekTo(double value) async { + final position = Duration(milliseconds: (value * _duration.inMilliseconds).round()); + await _player.seek(position); + } + + String _formatDuration(Duration duration) { + final minutes = duration.inMinutes.toString().padLeft(2, '0'); + final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + @override + void dispose() { + _stateSub?.cancel(); + _positionSub?.cancel(); + _durationSub?.cancel(); + // AudioPlayer.dispose() is async but Flutter's dispose() is sync. + // Fire-and-forget is acceptable here as just_audio handles cleanup internally. + unawaited(_player.dispose()); + // Clean up temp file (fire and forget, log errors for debugging) + _tempFile?.delete().then((_) { + debugPrint('AudioPlayerDialog: Cleaned up temp file'); + }).catchError((e) { + debugPrint('AudioPlayerDialog: Failed to clean up temp file: $e'); + }); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final l10n = AppLocalizations.of(context)!; + + final progress = _duration.inMilliseconds > 0 + ? (_position.inMilliseconds / _duration.inMilliseconds).clamp(0.0, 1.0) + : 0.0; + + return Dialog( + backgroundColor: theme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.lg), + ), + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.waveform + : Icons.audio_file_rounded, + color: Colors.orange, + size: IconSize.lg, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.fileName, + style: AppTypography.bodyMediumStyle.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Text( + l10n.audioAttachment, + style: AppTypography.captionStyle.copyWith( + color: theme.textSecondary, + ), + ), + ], + ), + ), + IconButton( + icon: Icon( + Platform.isIOS ? CupertinoIcons.xmark : Icons.close, + color: theme.textSecondary, + ), + onPressed: () => Navigator.pop(context), + ), + ], + ), + + const SizedBox(height: Spacing.xl), + + // Error state + if (_hasError) + Column( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle + : Icons.error_outline, + color: theme.error, + size: 48, + ), + const SizedBox(height: Spacing.md), + Text( + l10n.failedToLoadAudio, + style: AppTypography.bodyMediumStyle.copyWith( + color: theme.error, + ), + ), + ], + ) + // Loading state + else if (_isLoading) + Column( + children: [ + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(theme.buttonPrimary), + ), + ), + const SizedBox(height: Spacing.md), + Text( + l10n.loadingAudio, + style: AppTypography.bodyMediumStyle.copyWith( + color: theme.textSecondary, + ), + ), + ], + ) + // Player controls + else ...[ + // Progress slider + SliderTheme( + data: SliderThemeData( + trackHeight: 4, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + activeTrackColor: Colors.orange, + inactiveTrackColor: theme.surfaceContainerHighest, + thumbColor: Colors.orange, + overlayColor: Colors.orange.withValues(alpha: 0.2), + ), + child: Slider( + value: progress, + onChanged: _seekTo, + ), + ), + + // Time display + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(_position), + style: AppTypography.captionStyle.copyWith( + color: theme.textSecondary, + ), + ), + Text( + _formatDuration(_duration), + style: AppTypography.captionStyle.copyWith( + color: theme.textSecondary, + ), + ), + ], + ), + ), + + const SizedBox(height: Spacing.md), + + // Play/Pause button + GestureDetector( + onTap: _togglePlayPause, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.orange, + boxShadow: [ + BoxShadow( + color: Colors.orange.withValues(alpha: 0.3), + blurRadius: 12, + spreadRadius: 2, + ), + ], + ), + child: Icon( + _isPlaying + ? (Platform.isIOS + ? CupertinoIcons.pause_fill + : Icons.pause_rounded) + : (Platform.isIOS + ? CupertinoIcons.play_fill + : Icons.play_arrow_rounded), + color: Colors.white, + size: 32, + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/notes/widgets/audio_recording_overlay.dart b/lib/features/notes/widgets/audio_recording_overlay.dart new file mode 100644 index 0000000..3565f7d --- /dev/null +++ b/lib/features/notes/widgets/audio_recording_overlay.dart @@ -0,0 +1,388 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' show FontFeature, ImageFilter; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:conduit/l10n/app_localizations.dart'; +import 'package:record/record.dart'; + +import '../../../shared/theme/theme_extensions.dart'; +import '../services/audio_recording_service.dart'; + +/// Full-screen overlay for audio recording in notes. +/// +/// Shows recording visualization, duration, and controls to confirm or cancel. +/// The recorded audio is returned as a file for upload to the server. +class AudioRecordingOverlay extends StatefulWidget { + /// Called when the user cancels recording. + final VoidCallback onCancel; + + /// Called when the user confirms the recording with the audio file. + final void Function(File audioFile) onConfirm; + + const AudioRecordingOverlay({ + super.key, + required this.onCancel, + required this.onConfirm, + }); + + @override + State createState() => _AudioRecordingOverlayState(); +} + +class _AudioRecordingOverlayState extends State + with SingleTickerProviderStateMixin { + final AudioRecordingService _recordingService = AudioRecordingService(); + + bool _isRecording = false; + bool _isProcessing = false; + bool _hasError = false; + Duration _duration = Duration.zero; + double _amplitude = 0.0; + + StreamSubscription? _durationSub; + StreamSubscription? _amplitudeSub; + + late AnimationController _pulseController; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + + // Pulse animation for the recording indicator + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + ); + _pulseAnimation = Tween(begin: 1.0, end: 1.15).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + _pulseController.repeat(reverse: true); + + _startRecording(); + } + + Future _startRecording() async { + try { + await _recordingService.startRecording(); + + if (!mounted) return; + setState(() => _isRecording = true); + HapticFeedback.heavyImpact(); + + // Set up stream listeners only if still mounted. + // Each callback also checks mounted to handle rapid disposal. + if (!mounted) return; + + _durationSub = _recordingService.durationStream.listen((duration) { + if (mounted) setState(() => _duration = duration); + }); + + _amplitudeSub = _recordingService.amplitudeStream.listen((amp) { + if (mounted) { + // Normalize amplitude to 0-1 range + // amp.current is in dBFS, typically -160 to 0 + // We normalize from -60 to 0 for a reasonable range + final normalized = ((amp.current + 60) / 60).clamp(0.0, 1.0); + setState(() => _amplitude = normalized); + } + }); + } catch (e) { + if (!mounted) return; + setState(() => _hasError = true); + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.microphonePermissionDenied), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + // Delay briefly to show the error message + await Future.delayed(const Duration(seconds: 1)); + if (mounted) widget.onCancel(); + } + } + + Future _confirmRecording() async { + if (_isProcessing || !_isRecording || !mounted) return; + + setState(() => _isProcessing = true); + HapticFeedback.mediumImpact(); + + try { + final file = await _recordingService.stopRecording(); + + if (file != null && mounted) { + widget.onConfirm(file); + } else if (mounted) { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.recordingFailed), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + widget.onCancel(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + backgroundColor: Theme.of(context).colorScheme.error, + duration: const Duration(seconds: 4), + ), + ); + widget.onCancel(); + } + } + } + + Future _cancelRecording() async { + HapticFeedback.lightImpact(); + await _recordingService.cancelRecording(); + if (mounted) widget.onCancel(); + } + + String _formatDuration(Duration duration) { + final minutes = duration.inMinutes.toString().padLeft(2, '0'); + final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + @override + void dispose() { + _durationSub?.cancel(); + _amplitudeSub?.cancel(); + _pulseController.dispose(); + // Recording service dispose is async but Flutter's dispose() is sync. + // Fire-and-forget is acceptable here as the service handles its own cleanup. + unawaited(_recordingService.dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + + return Material( + color: Colors.black.withValues(alpha: 0.92), + child: SafeArea( + child: Stack( + children: [ + // Background blur effect + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: const SizedBox(), + ), + ), + // Content + Column( + children: [ + // Header with cancel button + Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Row( + children: [ + TextButton.icon( + onPressed: _cancelRecording, + icon: Icon( + Platform.isIOS + ? CupertinoIcons.xmark + : Icons.close_rounded, + color: Colors.white70, + size: IconSize.md, + ), + label: Text( + l10n.cancel, + style: const TextStyle( + color: Colors.white70, + fontSize: AppTypography.bodyMedium, + ), + ), + ), + ], + ), + ), + + // Recording visualization + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Animated recording indicator + AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + final scale = _isRecording + ? _pulseAnimation.value + (_amplitude * 0.3) + : 1.0; + return Transform.scale( + scale: scale, + child: child, + ); + }, + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Colors.red.withValues(alpha: 0.4), + Colors.red.withValues(alpha: 0.1), + Colors.transparent, + ], + stops: const [0.3, 0.7, 1.0], + ), + ), + child: Center( + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.red.withValues(alpha: 0.2), + border: Border.all( + color: Colors.red, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: Colors.red.withValues(alpha: 0.4), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: Icon( + Platform.isIOS + ? CupertinoIcons.mic_fill + : Icons.mic_rounded, + size: 48, + color: Colors.red, + ), + ), + ), + ), + ), + + const SizedBox(height: Spacing.xxl), + + // Duration display + Text( + _formatDuration(_duration), + style: theme.textTheme.displayMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w300, + letterSpacing: 4, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + + const SizedBox(height: Spacing.md), + + // Status text + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Text( + _hasError + ? l10n.microphonePermissionDenied + : (_isRecording + ? l10n.recordingAudio + : l10n.preparingRecording), + key: ValueKey(_isRecording), + style: theme.textTheme.bodyLarge?.copyWith( + color: Colors.white60, + letterSpacing: 1, + ), + ), + ), + + const SizedBox(height: Spacing.sm), + + // Hint text + if (_isRecording && !_hasError) + Text( + l10n.recordingHint, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white38, + ), + ), + ], + ), + ), + ), + + // Confirm button + Padding( + padding: const EdgeInsets.fromLTRB( + Spacing.xl, + Spacing.md, + Spacing.xl, + Spacing.xxl, + ), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: + _isProcessing || !_isRecording || _hasError + ? null + : _confirmRecording, + icon: _isProcessing + ? SizedBox( + width: IconSize.md, + height: IconSize.md, + child: const CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Icon( + Platform.isIOS + ? CupertinoIcons.stop_fill + : Icons.stop_rounded, + size: IconSize.lg, + ), + label: Text( + _isProcessing + ? l10n.processingRecording + : l10n.stopAndSaveRecording, + style: const TextStyle( + fontSize: AppTypography.bodyLarge, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.red.withValues( + alpha: 0.5, + ), + disabledForegroundColor: Colors.white54, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppBorderRadius.button, + ), + ), + elevation: 4, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + diff --git a/lib/features/notes/widgets/note_file_attachment.dart b/lib/features/notes/widgets/note_file_attachment.dart new file mode 100644 index 0000000..70d427f --- /dev/null +++ b/lib/features/notes/widgets/note_file_attachment.dart @@ -0,0 +1,312 @@ +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:conduit/l10n/app_localizations.dart'; + +import '../../../shared/theme/theme_extensions.dart'; + +/// A widget that displays a file attachment in a note. +/// +/// Supports different file types with appropriate icons and actions. +class NoteFileAttachment extends StatelessWidget { + /// The file data from the note. + final Map file; + + /// Called when the file is tapped. + final VoidCallback? onTap; + + /// Called when the delete button is pressed. + final VoidCallback? onDelete; + + /// Whether the file is currently loading. + final bool isLoading; + + /// Whether to show the delete button. + final bool showDelete; + + const NoteFileAttachment({ + super.key, + required this.file, + this.onTap, + this.onDelete, + this.isLoading = false, + this.showDelete = true, + }); + + String get _fileName => file['name']?.toString() ?? 'Unknown file'; + String get _fileType => file['type']?.toString() ?? 'file'; + int? get _fileSize => file['size'] as int?; + + bool get _isAudio => + _fileType == 'audio' || + _fileName.endsWith('.m4a') || + _fileName.endsWith('.mp3') || + _fileName.endsWith('.wav') || + _fileName.endsWith('.aac'); + + bool get _isImage => _fileType == 'image'; + + IconData get _icon { + if (_isAudio) { + return Platform.isIOS + ? CupertinoIcons.waveform + : Icons.audio_file_rounded; + } + if (_isImage) { + return Platform.isIOS ? CupertinoIcons.photo : Icons.image_rounded; + } + return Platform.isIOS + ? CupertinoIcons.doc_fill + : Icons.insert_drive_file_rounded; + } + + Color _iconColor(ConduitThemeExtension theme) { + if (_isAudio) return Colors.orange; + if (_isImage) return Colors.blue; + return theme.textSecondary; + } + + String _formatFileSize(int? bytes) { + if (bytes == null) return ''; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final l10n = AppLocalizations.of(context)!; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.sm, + ), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.cardBorder.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: Row( + children: [ + // File icon + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: _iconColor(theme).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: isLoading + ? Center( + child: SizedBox( + width: IconSize.sm, + height: IconSize.sm, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + _iconColor(theme), + ), + ), + ), + ) + : Icon( + _icon, + color: _iconColor(theme), + size: IconSize.md, + ), + ), + + const SizedBox(width: Spacing.sm), + + // File info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _fileName, + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textPrimary, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + _isAudio + ? l10n.audioFileType + : _isImage + ? l10n.imageFileType + : l10n.file, + style: AppTypography.captionStyle.copyWith( + color: theme.textSecondary, + ), + ), + if (_fileSize != null) ...[ + Text( + ' · ', + style: AppTypography.captionStyle.copyWith( + color: theme.textSecondary, + ), + ), + Text( + _formatFileSize(_fileSize), + style: AppTypography.captionStyle.copyWith( + color: theme.textSecondary, + ), + ), + ], + ], + ), + ], + ), + ), + + // Play button for audio + if (_isAudio && !isLoading) + IconButton( + icon: Icon( + Platform.isIOS + ? CupertinoIcons.play_circle_fill + : Icons.play_circle_filled_rounded, + color: _iconColor(theme), + size: IconSize.lg, + ), + onPressed: onTap, + tooltip: l10n.playAudio, + ), + + // Delete button + if (showDelete && !isLoading) + IconButton( + icon: Icon( + Platform.isIOS + ? CupertinoIcons.xmark_circle_fill + : Icons.cancel_rounded, + color: theme.textSecondary.withValues(alpha: 0.5), + size: IconSize.md, + ), + onPressed: () { + HapticFeedback.lightImpact(); + onDelete?.call(); + }, + tooltip: l10n.removeFile, + ), + ], + ), + ), + ), + ); + } +} + +/// A section that displays all file attachments for a note. +class NoteFilesSection extends StatelessWidget { + /// The list of files attached to the note. + final List> files; + + /// Called when a file should be played (for audio). + final void Function(Map file)? onPlayFile; + + /// Called when a file should be deleted. + final void Function(Map file)? onDeleteFile; + + /// Whether files can be deleted. + final bool canDelete; + + const NoteFilesSection({ + super.key, + required this.files, + this.onPlayFile, + this.onDeleteFile, + this.canDelete = true, + }); + + @override + Widget build(BuildContext context) { + if (files.isEmpty) return const SizedBox.shrink(); + + final theme = context.conduitTheme; + final l10n = AppLocalizations.of(context)!; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section header + Padding( + padding: const EdgeInsets.only( + left: Spacing.xs, + bottom: Spacing.xs, + ), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.paperclip + : Icons.attach_file_rounded, + size: IconSize.sm, + color: theme.textSecondary, + ), + const SizedBox(width: Spacing.xs), + Text( + l10n.attachments, + style: AppTypography.labelStyle.copyWith( + color: theme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Spacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.xs, + vertical: 2, + ), + decoration: BoxDecoration( + color: theme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + child: Text( + '${files.length}', + style: AppTypography.captionStyle.copyWith( + color: theme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + + // Files list + ...files.map( + (file) => Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: NoteFileAttachment( + file: file, + showDelete: canDelete, + onTap: () => onPlayFile?.call(file), + onDelete: () => onDeleteFile?.call(file), + ), + ), + ), + ], + ); + } +} + diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 87ff683..7642472 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -896,5 +896,28 @@ "continueButton": "Weiter", "proxyAuthRequired": "Dieser Server erfordert Proxy-Authentifizierung", "proxyAuthRequiredDescription": "Ihr Server scheint hinter einem Authentifizierungs-Proxy (wie oauth2-proxy) zu sein. Sie müssen sich zuerst über den Proxy anmelden.", - "authenticateThroughProxy": "Authentifizieren" + "authenticateThroughProxy": "Authentifizieren", + "voiceOptions": "Sprachoptionen", + "dictation": "Diktat", + "dictationDescription": "Sprechen und in Echtzeit in Text umwandeln", + "recordAudio": "Audio aufnehmen", + "recordAudioDescription": "Audio zur späteren Transkription speichern", + "recordingAudio": "Aufnahme...", + "preparingRecording": "Vorbereitung...", + "recordingHint": "Tippe auf Stopp wenn fertig", + "stopAndSaveRecording": "Stopp & Speichern", + "processingRecording": "Verarbeitung...", + "audioRecordingSaved": "Audioaufnahme gespeichert", + "microphonePermissionDenied": "Mikrofonzugriff verweigert", + "recordingFailed": "Aufnahme fehlgeschlagen", + "removeFileConfirm": "Diese Datei aus der Notiz entfernen?", + "fileRemoved": "Datei entfernt", + "playAudio": "Audio abspielen", + "removeFile": "Datei entfernen", + "audioFileType": "Audio", + "imageFileType": "Bild", + "failedToUploadAudio": "Audio hochladen fehlgeschlagen", + "audioAttachment": "Audio-Anhang", + "loadingAudio": "Audio wird geladen...", + "failedToLoadAudio": "Audio konnte nicht geladen werden" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4f64584..1cb464a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1763,6 +1763,62 @@ "@failedToStartDictation": { "description": "Error when starting dictation fails." }, + "voiceOptions": "Voice options", + "@voiceOptions": { + "description": "Tooltip for voice/recording options button." + }, + "dictation": "Dictation", + "@dictation": { + "description": "Option for real-time speech to text." + }, + "dictationDescription": "Speak and convert to text in real-time", + "@dictationDescription": { + "description": "Description for dictation option." + }, + "recordAudio": "Record Audio", + "@recordAudio": { + "description": "Option for recording audio files." + }, + "recordAudioDescription": "Save audio for later transcription", + "@recordAudioDescription": { + "description": "Description for audio recording option." + }, + "recordingAudio": "Recording...", + "@recordingAudio": { + "description": "Status text while recording audio." + }, + "preparingRecording": "Preparing...", + "@preparingRecording": { + "description": "Status text while preparing to record." + }, + "recordingHint": "Tap stop when finished", + "@recordingHint": { + "description": "Hint shown during audio recording." + }, + "stopAndSaveRecording": "Stop & Save", + "@stopAndSaveRecording": { + "description": "Button to stop and save an audio recording." + }, + "processingRecording": "Processing...", + "@processingRecording": { + "description": "Status while processing a recording." + }, + "audioRecordingSaved": "Audio recording saved", + "@audioRecordingSaved": { + "description": "Success message after saving audio recording." + }, + "failedToUploadAudio": "Failed to upload audio", + "@failedToUploadAudio": { + "description": "Error when audio upload fails." + }, + "microphonePermissionDenied": "Microphone permission denied", + "@microphonePermissionDenied": { + "description": "Error when microphone access is denied." + }, + "recordingFailed": "Recording failed", + "@recordingFailed": { + "description": "Error when audio recording fails." + }, "noteNotFound": "Note not found", "@noteNotFound": { "description": "Error message when a note cannot be found." @@ -1956,5 +2012,41 @@ "or": "or", "@or": { "description": "Separator text between authentication options." + }, + "audioAttachment": "Audio Attachment", + "@audioAttachment": { + "description": "Label for audio file attachments in notes." + }, + "loadingAudio": "Loading audio...", + "@loadingAudio": { + "description": "Loading message while audio file loads." + }, + "failedToLoadAudio": "Failed to load audio", + "@failedToLoadAudio": { + "description": "Error message when audio file fails to load." + }, + "removeFileConfirm": "Remove this file from the note?", + "@removeFileConfirm": { + "description": "Confirmation message when removing a file attachment." + }, + "fileRemoved": "File removed", + "@fileRemoved": { + "description": "Success message after removing a file attachment." + }, + "playAudio": "Play audio", + "@playAudio": { + "description": "Tooltip for audio play button." + }, + "removeFile": "Remove file", + "@removeFile": { + "description": "Tooltip for remove file button." + }, + "audioFileType": "Audio", + "@audioFileType": { + "description": "Label for audio file type." + }, + "imageFileType": "Image", + "@imageFileType": { + "description": "Label for image file type." } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9745b09..5d15ae6 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -896,5 +896,28 @@ "continueButton": "Continuar", "proxyAuthRequired": "Este servidor requiere autenticación de proxy", "proxyAuthRequiredDescription": "Su servidor parece estar detrás de un proxy de autenticación (como oauth2-proxy). Deberá iniciar sesión a través del proxy primero.", - "authenticateThroughProxy": "Autenticar" + "authenticateThroughProxy": "Autenticar", + "voiceOptions": "Opciones de voz", + "dictation": "Dictado", + "dictationDescription": "Habla y convierte a texto en tiempo real", + "recordAudio": "Grabar audio", + "recordAudioDescription": "Guardar audio para transcripción posterior", + "recordingAudio": "Grabando...", + "preparingRecording": "Preparando...", + "recordingHint": "Toca detener cuando termines", + "stopAndSaveRecording": "Detener y guardar", + "processingRecording": "Procesando...", + "audioRecordingSaved": "Grabación de audio guardada", + "microphonePermissionDenied": "Permiso de micrófono denegado", + "recordingFailed": "Grabación fallida", + "removeFileConfirm": "¿Eliminar este archivo de la nota?", + "fileRemoved": "Archivo eliminado", + "playAudio": "Reproducir audio", + "removeFile": "Eliminar archivo", + "audioFileType": "Audio", + "imageFileType": "Imagen", + "failedToUploadAudio": "Error al subir el audio", + "audioAttachment": "Archivo de audio", + "loadingAudio": "Cargando audio...", + "failedToLoadAudio": "Error al cargar el audio" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ffe87eb..1d9a2ff 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -896,5 +896,28 @@ "continueButton": "Continuer", "proxyAuthRequired": "Ce serveur nécessite une authentification proxy", "proxyAuthRequiredDescription": "Votre serveur semble être derrière un proxy d'authentification (comme oauth2-proxy). Vous devrez d'abord vous connecter via le proxy.", - "authenticateThroughProxy": "S'authentifier" + "authenticateThroughProxy": "S'authentifier", + "voiceOptions": "Options vocales", + "dictation": "Dictée", + "dictationDescription": "Parlez et convertissez en texte en temps réel", + "recordAudio": "Enregistrer l'audio", + "recordAudioDescription": "Enregistrer l'audio pour transcription ultérieure", + "recordingAudio": "Enregistrement...", + "preparingRecording": "Préparation...", + "recordingHint": "Appuyez sur arrêt quand terminé", + "stopAndSaveRecording": "Arrêter et sauvegarder", + "processingRecording": "Traitement...", + "audioRecordingSaved": "Enregistrement audio sauvegardé", + "microphonePermissionDenied": "Accès au microphone refusé", + "recordingFailed": "Enregistrement échoué", + "removeFileConfirm": "Retirer ce fichier de la note ?", + "fileRemoved": "Fichier retiré", + "playAudio": "Lire l'audio", + "removeFile": "Retirer le fichier", + "audioFileType": "Audio", + "imageFileType": "Image", + "failedToUploadAudio": "Échec du téléchargement audio", + "audioAttachment": "Pièce jointe audio", + "loadingAudio": "Chargement audio...", + "failedToLoadAudio": "Impossible de charger l'audio" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 60ddcaa..792627d 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -896,5 +896,28 @@ "continueButton": "Continua", "proxyAuthRequired": "Questo server richiede l'autenticazione proxy", "proxyAuthRequiredDescription": "Il tuo server sembra essere dietro un proxy di autenticazione (come oauth2-proxy). Dovrai prima accedere tramite il proxy.", - "authenticateThroughProxy": "Autentica" + "authenticateThroughProxy": "Autentica", + "voiceOptions": "Opzioni vocali", + "dictation": "Dettatura", + "dictationDescription": "Parla e converti in testo in tempo reale", + "recordAudio": "Registra audio", + "recordAudioDescription": "Salva l'audio per la trascrizione successiva", + "recordingAudio": "Registrazione...", + "preparingRecording": "Preparazione...", + "recordingHint": "Tocca stop quando hai finito", + "stopAndSaveRecording": "Ferma e salva", + "processingRecording": "Elaborazione...", + "audioRecordingSaved": "Registrazione audio salvata", + "microphonePermissionDenied": "Accesso al microfono negato", + "recordingFailed": "Registrazione fallita", + "removeFileConfirm": "Rimuovere questo file dalla nota?", + "fileRemoved": "File rimosso", + "playAudio": "Riproduci audio", + "removeFile": "Rimuovi file", + "audioFileType": "Audio", + "imageFileType": "Immagine", + "failedToUploadAudio": "Caricamento audio fallito", + "audioAttachment": "Allegato audio", + "loadingAudio": "Caricamento audio...", + "failedToLoadAudio": "Impossibile caricare l'audio" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index bc07612..20fe1c3 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -674,5 +674,28 @@ "continueButton": "계속", "proxyAuthRequired": "이 서버는 프록시 인증이 필요합니다", "proxyAuthRequiredDescription": "서버가 인증 프록시(예: oauth2-proxy) 뒤에 있는 것 같습니다. 먼저 프록시를 통해 로그인해야 합니다.", - "authenticateThroughProxy": "인증" + "authenticateThroughProxy": "인증", + "voiceOptions": "음성 옵션", + "dictation": "받아쓰기", + "dictationDescription": "말하면 실시간으로 텍스트로 변환", + "recordAudio": "오디오 녹음", + "recordAudioDescription": "나중에 텍스트 변환을 위해 오디오 저장", + "recordingAudio": "녹음 중...", + "preparingRecording": "준비 중...", + "recordingHint": "완료되면 정지를 누르세요", + "stopAndSaveRecording": "정지 및 저장", + "processingRecording": "처리 중...", + "audioRecordingSaved": "오디오 녹음 저장됨", + "microphonePermissionDenied": "마이크 권한이 거부되었습니다", + "recordingFailed": "녹음 실패", + "removeFileConfirm": "이 파일을 노트에서 제거하시겠습니까?", + "fileRemoved": "파일 제거됨", + "playAudio": "오디오 재생", + "removeFile": "파일 제거", + "audioFileType": "오디오", + "imageFileType": "이미지", + "failedToUploadAudio": "오디오 업로드 실패", + "audioAttachment": "오디오 첨부 파일", + "loadingAudio": "오디오 로드 중...", + "failedToLoadAudio": "오디오를 로드할 수 없습니다" } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 2318405..9db01da 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -896,5 +896,28 @@ "continueButton": "Doorgaan", "proxyAuthRequired": "Deze server vereist proxy-authenticatie", "proxyAuthRequiredDescription": "Uw server lijkt achter een authenticatieproxy (zoals oauth2-proxy) te staan. U moet eerst inloggen via de proxy.", - "authenticateThroughProxy": "Authenticeren" + "authenticateThroughProxy": "Authenticeren", + "voiceOptions": "Spraakopties", + "dictation": "Dicteren", + "dictationDescription": "Spreek en converteer naar tekst in realtime", + "recordAudio": "Audio opnemen", + "recordAudioDescription": "Audio opslaan voor latere transcriptie", + "recordingAudio": "Opnemen...", + "preparingRecording": "Voorbereiden...", + "recordingHint": "Tik op stop wanneer klaar", + "stopAndSaveRecording": "Stop en opslaan", + "processingRecording": "Verwerken...", + "audioRecordingSaved": "Audio-opname opgeslagen", + "microphonePermissionDenied": "Microfoontoegang geweigerd", + "recordingFailed": "Opname mislukt", + "removeFileConfirm": "Dit bestand uit de notitie verwijderen?", + "fileRemoved": "Bestand verwijderd", + "playAudio": "Audio afspelen", + "removeFile": "Bestand verwijderen", + "audioFileType": "Audio", + "imageFileType": "Afbeelding", + "failedToUploadAudio": "Audio uploaden mislukt", + "audioAttachment": "Audiobijlage", + "loadingAudio": "Audio laden...", + "failedToLoadAudio": "Audio laden mislukt" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6541ce9..52a35cc 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -896,5 +896,28 @@ "continueButton": "Продолжить", "proxyAuthRequired": "Этот сервер требует аутентификацию через прокси", "proxyAuthRequiredDescription": "Ваш сервер, похоже, находится за прокси аутентификации (например, oauth2-proxy). Сначала необходимо войти через прокси.", - "authenticateThroughProxy": "Аутентифицировать" + "authenticateThroughProxy": "Аутентифицировать", + "voiceOptions": "Голосовые опции", + "dictation": "Диктовка", + "dictationDescription": "Говорите и конвертируйте в текст в реальном времени", + "recordAudio": "Записать аудио", + "recordAudioDescription": "Сохранить аудио для последующей транскрипции", + "recordingAudio": "Запись...", + "preparingRecording": "Подготовка...", + "recordingHint": "Нажмите стоп когда закончите", + "stopAndSaveRecording": "Остановить и сохранить", + "processingRecording": "Обработка...", + "audioRecordingSaved": "Аудиозапись сохранена", + "microphonePermissionDenied": "Доступ к микрофону запрещён", + "recordingFailed": "Ошибка записи", + "removeFileConfirm": "Удалить этот файл из заметки?", + "fileRemoved": "Файл удалён", + "playAudio": "Воспроизвести аудио", + "removeFile": "Удалить файл", + "audioFileType": "Аудио", + "imageFileType": "Изображение", + "failedToUploadAudio": "Не удалось загрузить аудио", + "audioAttachment": "Аудио вложение", + "loadingAudio": "Загрузка аудио...", + "failedToLoadAudio": "Не удалось загрузить аудио" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e45bcb6..c557140 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -896,5 +896,28 @@ "continueButton": "继续", "proxyAuthRequired": "此服务器需要代理认证", "proxyAuthRequiredDescription": "您的服务器似乎位于认证代理(如 oauth2-proxy)后面。您需要先通过代理登录。", - "authenticateThroughProxy": "认证" + "authenticateThroughProxy": "认证", + "voiceOptions": "语音选项", + "dictation": "听写", + "dictationDescription": "说话并实时转换为文字", + "recordAudio": "录制音频", + "recordAudioDescription": "保存音频以便稍后转录", + "recordingAudio": "录音中...", + "preparingRecording": "准备中...", + "recordingHint": "完成后点击停止", + "stopAndSaveRecording": "停止并保存", + "processingRecording": "处理中...", + "audioRecordingSaved": "音频录制已保存", + "microphonePermissionDenied": "麦克风权限被拒绝", + "recordingFailed": "录音失败", + "removeFileConfirm": "从笔记中移除此文件?", + "fileRemoved": "文件已移除", + "playAudio": "播放音频", + "removeFile": "移除文件", + "audioFileType": "音频", + "imageFileType": "图片", + "failedToUploadAudio": "音频上传失败", + "audioAttachment": "音频附件", + "loadingAudio": "正在加载音频...", + "failedToLoadAudio": "无法加载音频" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 1544b49..0c72878 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -896,5 +896,28 @@ "continueButton": "繼續", "proxyAuthRequired": "此伺服器需要代理認證", "proxyAuthRequiredDescription": "您的伺服器似乎位於認證代理(如 oauth2-proxy)後面。您需要先透過代理登入。", - "authenticateThroughProxy": "認證" + "authenticateThroughProxy": "認證", + "voiceOptions": "語音選項", + "dictation": "聽寫", + "dictationDescription": "說話並即時轉換為文字", + "recordAudio": "錄製音訊", + "recordAudioDescription": "儲存音訊以便稍後轉錄", + "recordingAudio": "錄音中...", + "preparingRecording": "準備中...", + "recordingHint": "完成後點擊停止", + "stopAndSaveRecording": "停止並儲存", + "processingRecording": "處理中...", + "audioRecordingSaved": "音訊錄製已儲存", + "microphonePermissionDenied": "麥克風權限被拒絕", + "recordingFailed": "錄音失敗", + "removeFileConfirm": "從筆記中移除此檔案?", + "fileRemoved": "檔案已移除", + "playAudio": "播放音訊", + "removeFile": "移除檔案", + "audioFileType": "音訊", + "imageFileType": "圖片", + "failedToUploadAudio": "音訊上傳失敗", + "audioAttachment": "音訊附件", + "loadingAudio": "正在載入音訊...", + "failedToLoadAudio": "無法載入音訊" } diff --git a/lib/shared/utils/bytes_audio_source.dart b/lib/shared/utils/bytes_audio_source.dart new file mode 100644 index 0000000..5e19f1e --- /dev/null +++ b/lib/shared/utils/bytes_audio_source.dart @@ -0,0 +1,56 @@ +import 'dart:typed_data'; + +import 'package:just_audio/just_audio.dart'; + +/// A [StreamAudioSource] that plays audio from raw bytes in memory. +/// +/// This is useful for playing audio data that has been loaded from a network +/// response or other byte stream, such as server-side TTS audio. +/// +/// Example: +/// ```dart +/// final audioPlayer = AudioPlayer(); +/// final audioBytes = Uint8List.fromList([...]); +/// await audioPlayer.setAudioSource( +/// BytesAudioSource(audioBytes, 'audio/mpeg'), +/// ); +/// await audioPlayer.play(); +/// ``` +class BytesAudioSource extends StreamAudioSource { + /// Creates a [BytesAudioSource] from the given bytes and MIME type. + /// + /// The [mimeType] should be a valid audio MIME type such as: + /// - `audio/mpeg` for MP3 + /// - `audio/wav` for WAV + /// - `audio/mp4` or `audio/aac` for AAC/M4A + /// - `audio/ogg` for Ogg Vorbis + BytesAudioSource(this._bytes, this._mimeType); + + final Uint8List _bytes; + final String _mimeType; + + @override + Future request([int? start, int? end]) async { + start ??= 0; + end ??= _bytes.length; + + // Assert valid bounds in debug mode to catch bugs early, but don't + // crash in release. just_audio should always pass valid ranges. + assert( + start >= 0 && start <= _bytes.length, + 'BytesAudioSource: start ($start) out of bounds [0, ${_bytes.length}]', + ); + assert( + end >= start && end <= _bytes.length, + 'BytesAudioSource: end ($end) out of bounds [$start, ${_bytes.length}]', + ); + + return StreamAudioResponse( + sourceLength: _bytes.length, + contentLength: end - start, + offset: start, + stream: Stream.value(_bytes.sublist(start, end)), + contentType: _mimeType, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 7bc82e2..03e73c6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,62 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" - audioplayers: - dependency: "direct main" - description: - name: audioplayers - sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" - url: "https://pub.dev" - source: hosted - version: "6.5.1" - audioplayers_android: + audio_session: dependency: transitive description: - name: audioplayers_android - sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + name: audio_session + sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" url: "https://pub.dev" source: hosted - version: "5.2.1" - audioplayers_darwin: - dependency: transitive - description: - name: audioplayers_darwin - sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" - url: "https://pub.dev" - source: hosted - version: "6.3.0" - audioplayers_linux: - dependency: transitive - description: - name: audioplayers_linux - sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 - url: "https://pub.dev" - source: hosted - version: "4.2.1" - audioplayers_platform_interface: - dependency: transitive - description: - name: audioplayers_platform_interface - sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" - url: "https://pub.dev" - source: hosted - version: "7.1.1" - audioplayers_web: - dependency: transitive - description: - name: audioplayers_web - sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" - url: "https://pub.dev" - source: hosted - version: "5.1.1" - audioplayers_windows: - dependency: transitive - description: - name: audioplayers_windows - sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" - url: "https://pub.dev" - source: hosted - version: "4.2.1" + version: "0.2.2" boolean_selector: dependency: transitive description: @@ -997,6 +949,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.2" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" + url: "https://pub.dev" + source: hosted + version: "0.10.5" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9421942..93533d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,7 +48,6 @@ dependencies: speech_to_text: ^7.3.0 record: ^6.1.2 flutter_tts: ^4.2.3 - audioplayers: ^6.5.1 image_picker: ^1.2.0 file_picker: ^10.3.7 flutter_image_compress: ^2.1.0 @@ -83,6 +82,7 @@ dependencies: pasteboard: ^0.4.0 super_context_menu: ^0.9.1 super_drag_and_drop: ^0.9.1 + just_audio: ^0.10.5 # Clipboard functionality is available through flutter/services (part of Flutter SDK)