Browse Source

Merge branch 'new_dev' of https://github.com/deepcloud2048/deep_voice into new_dev

weicu
liwei1dao 9 months ago
parent
commit
1fa1592ab2
  1. 5
      android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt
  2. 3
      lib/core/translations/language/ar_sa.dart
  3. 3
      lib/core/translations/language/bg_bg.dart
  4. 3
      lib/core/translations/language/cs_cz.dart
  5. 3
      lib/core/translations/language/da_dk.dart
  6. 7
      lib/core/translations/language/de_de.dart
  7. 9
      lib/core/translations/language/el_gr.dart
  8. 8
      lib/core/translations/language/en_us.dart
  9. 3
      lib/core/translations/language/es_es.dart
  10. 3
      lib/core/translations/language/et_ee.dart
  11. 3
      lib/core/translations/language/fa_ir.dart
  12. 3
      lib/core/translations/language/fi_fi.dart
  13. 3
      lib/core/translations/language/fr_fr.dart
  14. 3
      lib/core/translations/language/hi_in.dart
  15. 3
      lib/core/translations/language/hr_hr.dart
  16. 3
      lib/core/translations/language/hu_hu.dart
  17. 3
      lib/core/translations/language/id_id.dart
  18. 3
      lib/core/translations/language/is_is.dart
  19. 3
      lib/core/translations/language/it_it.dart
  20. 3
      lib/core/translations/language/ja_jp.dart
  21. 2
      lib/core/translations/language/ko_kr.dart
  22. 3
      lib/core/translations/language/lt_lt.dart
  23. 3
      lib/core/translations/language/lv_lv.dart
  24. 3
      lib/core/translations/language/ms_my.dart
  25. 3
      lib/core/translations/language/nb_no.dart
  26. 3
      lib/core/translations/language/nl_nl.dart
  27. 3
      lib/core/translations/language/pl_pl.dart
  28. 3
      lib/core/translations/language/pt_pt.dart
  29. 3
      lib/core/translations/language/ro_ro.dart
  30. 3
      lib/core/translations/language/ru_ru.dart
  31. 3
      lib/core/translations/language/sk_sk.dart
  32. 3
      lib/core/translations/language/sl_si.dart
  33. 3
      lib/core/translations/language/sr_rs.dart
  34. 3
      lib/core/translations/language/sv_se.dart
  35. 2
      lib/core/translations/language/th_th.dart
  36. 3
      lib/core/translations/language/tl_ph.dart
  37. 3
      lib/core/translations/language/tr_tr.dart
  38. 3
      lib/core/translations/language/uk_ua.dart
  39. 3
      lib/core/translations/language/vi_vn.dart
  40. 5
      lib/core/translations/language/zh_cn.dart
  41. 2
      lib/core/translations/language/zh_hk.dart
  42. 2
      lib/core/translations/language/zh_tw.dart
  43. 52
      lib/core/utils/app_lifecycle_manager.dart
  44. 83
      lib/core/utils/overlay_permission_util.dart
  45. 5
      lib/data/services/meeting/meeting_task_service.dart
  46. 11
      lib/main.dart
  47. 35
      lib/modules/home/controllers/home_controller.dart
  48. 50
      lib/modules/meeting/controllers/meeting_home_controller.dart
  49. 535
      lib/modules/meeting/controllers/meeting_record_controller.dart
  50. 15
      lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart
  51. 74
      lib/modules/meeting/views/meeting_record_view.dart
  52. 8
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  53. 8
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift
  54. 8
      local_plugins/agent_service/lib/agent_service.dart
  55. 603
      local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt
  56. 1
      local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt

5
android/app/src/main/kotlin/com/saitong/voitrans/FloatingWindowService.kt

@ -370,8 +370,9 @@ class FloatingWindowService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy()
floatingView?.let { windowManager?.removeView(it) } floatingView?.let { windowManager?.removeView(it) }
serviceInstance = null serviceInstance = null
methodChannel = null
super.onDestroy()
} }
} }

3
lib/core/translations/language/ar_sa.dart

@ -608,6 +608,9 @@ const Map<String, String> arSA = {
'exportingPDF': 'جاري تصدير PDF...', 'exportingPDF': 'جاري تصدير PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'تطبيق على جميع فقرات هذا المتحدث',
'summaryWillBeUpdatedAutomatically':
'سيتم تحديث الملخص تلقائيًا بناءً على القالب',
'nameCurrentSpeaker': 'تسمية المتحدث الحالي', 'nameCurrentSpeaker': 'تسمية المتحدث الحالي',
'recentlyUsedNames': 'الأسماء المستخدمة مؤخرًا', 'recentlyUsedNames': 'الأسماء المستخدمة مؤخرًا',
'applyToCurrentParagraph': 'تطبيق على الفقرة الحالية', 'applyToCurrentParagraph': 'تطبيق على الفقرة الحالية',

3
lib/core/translations/language/bg_bg.dart

@ -619,6 +619,9 @@ const Map<String, String> bgBG = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Приложи към всички абзаци на този говорител',
'summaryWillBeUpdatedAutomatically':
'Резюмето ще се обновява автоматично според шаблона',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/cs_cz.dart

@ -611,6 +611,9 @@ const Map<String, String> csCZ = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Použít na všechny odstavce tohoto mluvčího',
'summaryWillBeUpdatedAutomatically':
'Souhrn se bude automaticky aktualizovat podle šablony',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/da_dk.dart

@ -614,6 +614,9 @@ const Map<String, String> daDK = {
'exportingPDF': 'Eksporterer PDF...', 'exportingPDF': 'Eksporterer PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Anvend på alle afsnit for denne taler',
'summaryWillBeUpdatedAutomatically':
'Resuméet opdateres automatisk ud fra skabelonen',
'nameCurrentSpeaker': 'Navngiv nuværende taler', 'nameCurrentSpeaker': 'Navngiv nuværende taler',
'recentlyUsedNames': 'Nyligt brugte navne', 'recentlyUsedNames': 'Nyligt brugte navne',
'applyToCurrentParagraph': 'Anvend på nuværende afsnit', 'applyToCurrentParagraph': 'Anvend på nuværende afsnit',

7
lib/core/translations/language/de_de.dart

@ -622,12 +622,15 @@ const Map<String, String> deDE = {
'exportingPDF': 'Exportiere PDF...', 'exportingPDF': 'Exportiere PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Auf alle Absätze dieses Sprechers anwenden',
'summaryWillBeUpdatedAutomatically':
'Die Zusammenfassung wird basierend auf der Vorlage automatisch aktualisiert',
'nameCurrentSpeaker': 'Aktuellen Sprecher benennen', 'nameCurrentSpeaker': 'Aktuellen Sprecher benennen',
'recentlyUsedNames': 'Zuletzt verwendete Namen', 'recentlyUsedNames': 'Zuletzt verwendete Namen',
'applyToCurrentParagraph': 'Auf aktuellen Absatz anwenden', 'applyToCurrentParagraph': 'Auf aktuellen Absatz anwenden',
'totalTime': 'Gesamtzeit', 'totalTime': 'Gesamt',
'summarySpeakerWillBeUpdatedAutomatically': 'summarySpeakerWillBeUpdatedAutomatically':
'Zusammenfassungssprecher werden automatisch aktualisiert', 'Sprecher in der Zusammenfassung werden automatisch aktualisiert',
// Meeting模块 - 云端同步相关 // Meeting模块 - 云端同步相关
'syncToCloud': 'Mit Cloud synchronisieren', 'syncToCloud': 'Mit Cloud synchronisieren',

9
lib/core/translations/language/el_gr.dart

@ -628,12 +628,15 @@ const Map<String, String> elGR = {
'exportingPDF': 'Εξαγωγή PDF...', 'exportingPDF': 'Εξαγωγή PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'nameCurrentSpeaker': 'Ονομασία τρέχοντος ομιλητή', 'applyToAllParagraphs': 'Εφαρμογή σε όλες τις παραγράφους αυτού του ομιλητή',
'summaryWillBeUpdatedAutomatically':
'Η σύνοψη θα ενημερώνεται αυτόματα βάσει του προτύπου',
'nameCurrentSpeaker': 'Ονοματοδοσία τρέχοντος ομιλητή',
'recentlyUsedNames': 'Πρόσφατα χρησιμοποιημένα ονόματα', 'recentlyUsedNames': 'Πρόσφατα χρησιμοποιημένα ονόματα',
'applyToCurrentParagraph': 'Εφαρμογή στην τρέχουσα παράγραφο', 'applyToCurrentParagraph': 'Εφαρμογή στην τρέχουσα παράγραφο',
'totalTime': 'Συνολικός χρόνος', 'totalTime': 'Σύνολο',
'summarySpeakerWillBeUpdatedAutomatically': 'summarySpeakerWillBeUpdatedAutomatically':
'Οι ομιλητές της περίληψης θα ενημερωθούν αυτόματα', 'Οι ομιλητές στη σύνοψη θα ενημερώνονται αυτόματα',
// Meeting模块 - 云端同步相关 // Meeting模块 - 云端同步相关
'syncToCloud': 'Συγχρονισμός στο cloud', 'syncToCloud': 'Συγχρονισμός στο cloud',

8
lib/core/translations/language/en_us.dart

@ -443,6 +443,11 @@ const Map<String, String> enUS = {
'meetingRecordEndWithoutSave': 'End recording without saving', // 结束录音不保存 'meetingRecordEndWithoutSave': 'End recording without saving', // 结束录音不保存
'meetingRecordContinueInBackground': 'meetingRecordContinueInBackground':
'Continue recording in background', // 后台继续录音 'Continue recording in background', // 后台继续录音
'meetingRecordStopTimeout':
'Stopping the recording timed out. Please try again.', // 结束录音超时,请重试
'meetingRecordStopFailed':
'Failed to stop the recording. Please try again.', // 结束录音失败,请重试
'meetingRecordSaveFailed': 'Failed to save the recording. Please try again.', // 保存录音失败,请重试
'meetingRecordRecordingNotificationTitle': 'Recording in progress', // 录音进行中 'meetingRecordRecordingNotificationTitle': 'Recording in progress', // 录音进行中
'meetingRecordRecordingNotificationBody': 'meetingRecordRecordingNotificationBody':
'Recording in background. Tap to return to the app.', // 正在后台录音,点击返回应用 'Recording in background. Tap to return to the app.', // 正在后台录音,点击返回应用
@ -626,6 +631,9 @@ const Map<String, String> enUS = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Apply to All Paragraphs of This Speaker',
'summaryWillBeUpdatedAutomatically':
'Summary will be updated automatically based on the template',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/es_es.dart

@ -612,6 +612,9 @@ const Map<String, String> esES = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Aplicar a todos los párrafos de este orador',
'summaryWillBeUpdatedAutomatically':
'El resumen se actualizará automáticamente según la plantilla',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/et_ee.dart

@ -607,6 +607,9 @@ const Map<String, String> etEE = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Rakenda selle kõneleja kõigile lõikudele',
'summaryWillBeUpdatedAutomatically':
'Kokkuvõtet uuendatakse automaatselt malli alusel',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/fa_ir.dart

@ -611,6 +611,9 @@ const Map<String, String> faIR = {
'exportingPDF': 'در حال خروجی گرفتن PDF...', 'exportingPDF': 'در حال خروجی گرفتن PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'اعمال روی همه پاراگراف‌های این گوینده',
'summaryWillBeUpdatedAutomatically':
'خلاصه بر اساس قالب به‌صورت خودکار به‌روزرسانی می‌شود',
'nameCurrentSpeaker': 'نام‌گذاری گوینده فعلی', 'nameCurrentSpeaker': 'نام‌گذاری گوینده فعلی',
'recentlyUsedNames': 'نام‌های اخیراً استفاده‌شده', 'recentlyUsedNames': 'نام‌های اخیراً استفاده‌شده',
'applyToCurrentParagraph': 'اعمال به پاراگراف فعلی', 'applyToCurrentParagraph': 'اعمال به پاراگراف فعلی',

3
lib/core/translations/language/fi_fi.dart

@ -612,6 +612,9 @@ const Map<String, String> fiFI = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Käytä kaikkiin tämän puhujan kappaleisiin',
'summaryWillBeUpdatedAutomatically':
'Yhteenveto päivittyy automaattisesti mallin perusteella',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/fr_fr.dart

@ -614,6 +614,9 @@ const Map<String, String> frFR = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Appliquer à tous les paragraphes de cet intervenant',
'summaryWillBeUpdatedAutomatically':
'Le résumé sera mis à jour automatiquement selon le modèle',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/hi_in.dart

@ -610,6 +610,9 @@ const Map<String, String> hiIN = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'इस वक्ता के सभी अनुच्छेदों पर लागू करें',
'summaryWillBeUpdatedAutomatically':
'टेम्पलेट के आधार पर सारांश स्वतः अपडेट होगा',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/hr_hr.dart

@ -614,6 +614,9 @@ const Map<String, String> hrHR = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Primijeni na sve odlomke ovog govornika',
'summaryWillBeUpdatedAutomatically':
'Sažetak će se automatski ažurirati prema predlošku',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/hu_hu.dart

@ -614,6 +614,9 @@ const Map<String, String> huHU = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Alkalmazás a beszélő összes bekezdésére',
'summaryWillBeUpdatedAutomatically':
'Az összefoglaló automatikusan frissül a sablon alapján',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/id_id.dart

@ -610,6 +610,9 @@ const Map<String, String> idID = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Terapkan ke semua paragraf pembicara ini',
'summaryWillBeUpdatedAutomatically':
'Ringkasan akan diperbarui otomatis berdasarkan template',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/is_is.dart

@ -611,6 +611,9 @@ const Map<String, String> isIS = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Beita á allar málsgreinar þessa ræðumanns',
'summaryWillBeUpdatedAutomatically':
'Samantektin verður uppfærð sjálfkrafa út frá sniðmátinu',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/it_it.dart

@ -617,6 +617,9 @@ const Map<String, String> itIT = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Applica a tutti i paragrafi di questo relatore',
'summaryWillBeUpdatedAutomatically':
'Il riepilogo verrà aggiornato automaticamente in base al modello',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/ja_jp.dart

@ -603,6 +603,9 @@ const Map<String, String> jaJP = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'この話者のすべての段落に適用',
'summaryWillBeUpdatedAutomatically':
'要約はテンプレートに基づいて自動的に更新されます',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

2
lib/core/translations/language/ko_kr.dart

@ -599,6 +599,8 @@ const Map<String, String> koKR = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': '이 발언자의 모든 문단에 적용',
'summaryWillBeUpdatedAutomatically': '요약은 템플릿에 따라 자동으로 업데이트됩니다',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/lt_lt.dart

@ -614,6 +614,9 @@ const Map<String, String> ltLT = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Taikyti visoms šio kalbėtojo pastraipoms',
'summaryWillBeUpdatedAutomatically':
'Santrauka bus automatiškai atnaujinama pagal šabloną',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/lv_lv.dart

@ -613,6 +613,9 @@ const Map<String, String> lvLV = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Piemērot visām šī runātāja rindkopām',
'summaryWillBeUpdatedAutomatically':
'Kopsavilkums tiks automātiski atjaunināts, pamatojoties uz veidni',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/ms_my.dart

@ -612,6 +612,9 @@ const Map<String, String> msMY = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Guna pada semua perenggan penutur ini',
'summaryWillBeUpdatedAutomatically':
'Ringkasan akan dikemas kini secara automatik berdasarkan templat',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/nb_no.dart

@ -612,6 +612,9 @@ const Map<String, String> nbNO = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Bruk på alle avsnitt for denne taleren',
'summaryWillBeUpdatedAutomatically':
'Sammendraget oppdateres automatisk basert på malen',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/nl_nl.dart

@ -613,6 +613,9 @@ const Map<String, String> nlNL = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': "Toepassen op alle alinea's van deze spreker",
'summaryWillBeUpdatedAutomatically':
'De samenvatting wordt automatisch bijgewerkt op basis van de sjabloon',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/pl_pl.dart

@ -614,6 +614,9 @@ const Map<String, String> plPL = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Zastosuj do wszystkich akapitów tego mówcy',
'summaryWillBeUpdatedAutomatically':
'Podsumowanie będzie automatycznie aktualizowane na podstawie szablonu',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/pt_pt.dart

@ -617,6 +617,9 @@ const Map<String, String> ptPT = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Aplicar a todos os parágrafos deste orador',
'summaryWillBeUpdatedAutomatically':
'O resumo será atualizado automaticamente com base no modelo',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/ro_ro.dart

@ -615,6 +615,9 @@ const Map<String, String> roRO = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Aplică la toate paragrafele acestui vorbitor',
'summaryWillBeUpdatedAutomatically':
'Rezumatul va fi actualizat automat pe baza șablonului',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/ru_ru.dart

@ -615,6 +615,9 @@ const Map<String, String> ruRU = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Применить ко всем абзацам этого говорящего',
'summaryWillBeUpdatedAutomatically':
'Сводка будет автоматически обновляться на основе шаблона',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/sk_sk.dart

@ -613,6 +613,9 @@ const Map<String, String> skSK = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Použiť na všetky odseky tohto rečníka',
'summaryWillBeUpdatedAutomatically':
'Zhrnutie sa bude automaticky aktualizovať podľa šablóny',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/sl_si.dart

@ -613,6 +613,9 @@ const Map<String, String> slSI = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Uporabi za vse odstavke tega govorca',
'summaryWillBeUpdatedAutomatically':
'Povzetek se bo samodejno posodabljal glede na predlogo',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/sr_rs.dart

@ -612,6 +612,9 @@ const Map<String, String> srRS = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Primeni na sve pasuse ovog govornika',
'summaryWillBeUpdatedAutomatically':
'Sažetak će se automatski ažurirati prema šablonu',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/sv_se.dart

@ -612,6 +612,9 @@ const Map<String, String> svSE = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Använd på alla stycken för denna talare',
'summaryWillBeUpdatedAutomatically':
'Sammanfattningen uppdateras automatiskt baserat på mallen',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

2
lib/core/translations/language/th_th.dart

@ -606,6 +606,8 @@ const Map<String, String> thTH = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'นำไปใช้กับทุกย่อหน้าของผู้พูดคนนี้',
'summaryWillBeUpdatedAutomatically': 'สรุปจะอัปเดตอัตโนมัติตามเทมเพลต',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/tl_ph.dart

@ -614,6 +614,9 @@ const Map<String, String> tlPH = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Ilapat sa lahat ng talata ng tagapagsalitang ito',
'summaryWillBeUpdatedAutomatically':
'Awtomatikong maa-update ang buod batay sa template',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/tr_tr.dart

@ -610,6 +610,9 @@ const Map<String, String> trTR = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Bu konuşmacının tüm paragraflarına uygula',
'summaryWillBeUpdatedAutomatically':
'Özet, şablona göre otomatik olarak güncellenecektir',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/uk_ua.dart

@ -612,6 +612,9 @@ const Map<String, String> ukUA = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Застосувати до всіх абзаців цього мовця',
'summaryWillBeUpdatedAutomatically':
'Підсумок буде автоматично оновлюватися на основі шаблону',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

3
lib/core/translations/language/vi_vn.dart

@ -609,6 +609,9 @@ const Map<String, String> viVN = {
'exportingPDF': 'Exporting PDF...', 'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Áp dụng cho tất cả đoạn của người nói này',
'summaryWillBeUpdatedAutomatically':
'Tóm tắt sẽ được cập nhật tự động dựa trên mẫu',
'nameCurrentSpeaker': 'Name Current Speaker', 'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names', 'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph', 'applyToCurrentParagraph': 'Apply to Current Paragraph',

5
lib/core/translations/language/zh_cn.dart

@ -419,6 +419,9 @@ const Map<String, String> zhCN = {
'meetingRecordEndAndSave': '结束录音并保存', // 结束录音并保存 'meetingRecordEndAndSave': '结束录音并保存', // 结束录音并保存
'meetingRecordEndWithoutSave': '结束录音不保存', // 结束录音不保存 'meetingRecordEndWithoutSave': '结束录音不保存', // 结束录音不保存
'meetingRecordContinueInBackground': '后台继续录音', // 后台继续录音 'meetingRecordContinueInBackground': '后台继续录音', // 后台继续录音
'meetingRecordStopTimeout': '结束录音超时,请重试', // 结束录音超时,请重试
'meetingRecordStopFailed': '结束录音失败,请重试', // 结束录音失败,请重试
'meetingRecordSaveFailed': '保存录音失败,请重试', // 保存录音失败,请重试
'meetingRecordRecordingNotificationTitle': '录音进行中', // 录音进行中 'meetingRecordRecordingNotificationTitle': '录音进行中', // 录音进行中
'meetingRecordRecordingNotificationBody': '正在后台录音,点击返回应用', // 正在后台录音,点击返回应用 'meetingRecordRecordingNotificationBody': '正在后台录音,点击返回应用', // 正在后台录音,点击返回应用
'overlayPermissionTitle': '悬浮窗权限', // 悬浮窗权限 'overlayPermissionTitle': '悬浮窗权限', // 悬浮窗权限
@ -585,6 +588,8 @@ const Map<String, String> zhCN = {
'exportingPDF': '正在导出PDF...', 'exportingPDF': '正在导出PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': '应用到这个发言者的所有段落',
'summaryWillBeUpdatedAutomatically': '总结将根据模版自动更新',
'nameCurrentSpeaker': '命名当前发言者', 'nameCurrentSpeaker': '命名当前发言者',
'recentlyUsedNames': '最近使用过的名称', 'recentlyUsedNames': '最近使用过的名称',
'applyToCurrentParagraph': '应用到当前段落', 'applyToCurrentParagraph': '应用到当前段落',

2
lib/core/translations/language/zh_hk.dart

@ -572,6 +572,8 @@ const Map<String, String> zhHK = {
'exportingPDF': '正在導出PDF...', 'exportingPDF': '正在導出PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': '應用到這個發言者的所有段落',
'summaryWillBeUpdatedAutomatically': '總結將根據模版自動更新',
'nameCurrentSpeaker': '命名當前發言者', 'nameCurrentSpeaker': '命名當前發言者',
'recentlyUsedNames': '最近使用過的名稱', 'recentlyUsedNames': '最近使用過的名稱',
'applyToCurrentParagraph': '應用到當前段落', 'applyToCurrentParagraph': '應用到當前段落',

2
lib/core/translations/language/zh_tw.dart

@ -572,6 +572,8 @@ const Map<String, String> zhTW = {
'exportingPDF': '正在導出PDF...', 'exportingPDF': '正在導出PDF...',
// Meeting模块 - 发言者相关 // Meeting模块 - 发言者相关
'applyToAllParagraphs': '應用到這個發言者的所有段落',
'summaryWillBeUpdatedAutomatically': '總結將根據模版自動更新',
'nameCurrentSpeaker': '命名當前發言者', 'nameCurrentSpeaker': '命名當前發言者',
'recentlyUsedNames': '最近使用過的名稱', 'recentlyUsedNames': '最近使用過的名稱',
'applyToCurrentParagraph': '應用到當前段落', 'applyToCurrentParagraph': '應用到當前段落',

52
lib/core/utils/app_lifecycle_manager.dart

@ -1,6 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:agent_service/agent_service.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import '../../modules/meeting/controllers/meeting_record_controller.dart';
import '../../routes/app_routes.dart';
import 'logger.dart'; import 'logger.dart';
import '../translations/app_translations.dart'; import '../translations/app_translations.dart';
//import '../../modules/settings/controllers/settings_controller.dart'; //import '../../modules/settings/controllers/settings_controller.dart';
@ -23,17 +28,19 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver {
final RxBool _isInForeground = true.obs; final RxBool _isInForeground = true.obs;
bool get isInForeground => _isInForeground.value; bool get isInForeground => _isInForeground.value;
bool? _lastCanWakeUp;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
// 注册应用生命周期观察者 // 注册应用生命周期观察者
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
Logger.info('AppLifecycleManager 已初始化'); Logger.info('AppLifecycleManager 已初始化');
unawaited(refreshBleWakeupPolicy());
} }
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
final previousState = _appLifecycleState.value;
_appLifecycleState.value = state; _appLifecycleState.value = state;
_lifecycleStateStream.value = state; _lifecycleStateStream.value = state;
@ -44,6 +51,7 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
_isInForeground.value = true; _isInForeground.value = true;
_handleAppResumed(); _handleAppResumed();
unawaited(refreshBleWakeupPolicy());
break; break;
case AppLifecycleState.paused: case AppLifecycleState.paused:
case AppLifecycleState.inactive: case AppLifecycleState.inactive:
@ -56,6 +64,48 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver {
} }
} }
Future<void> refreshBleWakeupPolicy({String? route}) async {
final currentRoute = route ?? Get.currentRoute;
final bool hasMeetingRecordingTask = _hasMeetingRecordingTask();
final bool shouldDisableWakeup = hasMeetingRecordingTask ||
_isMeetingRoute(currentRoute) ||
_isTranslationRoute(currentRoute);
final bool canWakeUp = !shouldDisableWakeup;
if (_lastCanWakeUp == canWakeUp) return;
_lastCanWakeUp = canWakeUp;
try {
Logger.info('lxm---设置是否可以ble唤醒agent: $canWakeUp');
await AgentService.setCanWakeUp(canWakeUp);
} catch (e) {
Logger.warning('setCanWakeUp失败: $e');
}
}
bool _isMeetingRoute(String route) {
if (route.isEmpty) return false;
if (route == Routes.profileMeeting) return true;
return route == Routes.meeting || route.startsWith('${Routes.meeting}/');
}
bool _isTranslationRoute(String route) {
if (route.isEmpty) return false;
if (route == Routes.translationMiddleware) return true;
if (route == Routes.pictureTranslation) return true;
if (route == Routes.textTranslation) return true;
return route == Routes.translation ||
route.startsWith('${Routes.translation}/');
}
bool _hasMeetingRecordingTask() {
if (!Get.isRegistered<MeetingRecordController>()) return false;
final mrc = Get.find<MeetingRecordController>();
return mrc.isRecording.value ||
mrc.timerStatus.value != TimerStatus.stopped;
}
/// 处理应用进入前台 /// 处理应用进入前台
void _handleAppResumed() { void _handleAppResumed() {
// Logger.info('应用进入前台'); // Logger.info('应用进入前台');

83
lib/core/utils/overlay_permission_util.dart

@ -13,6 +13,27 @@ enum OverlayPermissionScene {
/// 悬浮窗权限管理工具类 /// 悬浮窗权限管理工具类
class OverlayPermissionUtil { class OverlayPermissionUtil {
static const MethodChannel _channel = MethodChannel('overlay_permission'); static const MethodChannel _channel = MethodChannel('overlay_permission');
static bool _isRequestDialogShowing = false;
static DateTime? _lastRequestDialogAt;
static const Duration _requestDialogCooldown = Duration(seconds: 20);
static Future<bool> openOverlayPermissionSettings() async {
if (!Platform.isAndroid) return true;
try {
final bool opened =
await _channel.invokeMethod('requestOverlayPermission');
if (!opened) {
Get.snackbar(
'permissionRequestError'.tr,
'overlayPermissionOpenSettingsFailed'.tr,
);
}
return opened;
} catch (e) {
Logger.error('跳转悬浮窗权限设置失败: $e');
return false;
}
}
/// 检查是否有悬浮窗权限 /// 检查是否有悬浮窗权限
static Future<bool> hasOverlayPermission() async { static Future<bool> hasOverlayPermission() async {
@ -49,38 +70,42 @@ class OverlayPermissionUtil {
'overlayPermissionRecordingDesc', // 为了在录音过程中显示录音悬浮条(计时/暂停/继续),需要悬浮窗权限。 'overlayPermissionRecordingDesc', // 为了在录音过程中显示录音悬浮条(计时/暂停/继续),需要悬浮窗权限。
}; };
// 显示说明对话框 final now = DateTime.now();
bool? userAgreed = await Get.dialog<bool>( if (_isRequestDialogShowing) return false;
AlertDialog( final lastAt = _lastRequestDialogAt;
title: Text('overlayPermissionTitle'.tr), // 悬浮窗权限 if (lastAt != null && now.difference(lastAt) < _requestDialogCooldown) {
content: Text(explanationKey.tr), // 按场景显示不同说明文案 return false;
actions: [ }
TextButton(
onPressed: () => Get.back(result: false), _isRequestDialogShowing = true;
child: Text('cancel'.tr), // 取消 _lastRequestDialogAt = now;
), bool? userAgreed;
ElevatedButton( try {
onPressed: () => Get.back(result: true), userAgreed = await Get.dialog<bool>(
child: Text('goToSettings'.tr), // 去设置 AlertDialog(
), title: Text('overlayPermissionTitle'.tr),
], content: Text(explanationKey.tr),
), actions: [
barrierDismissible: false, TextButton(
); onPressed: () => Get.back(result: false),
child: Text('cancel'.tr),
),
ElevatedButton(
onPressed: () => Get.back(result: true),
child: Text('goToSettings'.tr),
),
],
),
barrierDismissible: false,
);
} finally {
_isRequestDialogShowing = false;
}
if (userAgreed == true) { if (userAgreed == true) {
try { try {
// 跳转系统设置页 final bool opened = await openOverlayPermissionSettings();
final bool opened = if (!opened) return false;
await _channel.invokeMethod('requestOverlayPermission');
if (!opened) {
Get.snackbar(
'permissionRequestError'.tr,
'overlayPermissionOpenSettingsFailed'
.tr, // 无法自动跳转到系统设置,请手动前往系统设置开启悬浮窗权限。
);
return false;
}
// 等待用户操作完成后重新检查权限 // 等待用户操作完成后重新检查权限
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
return await hasOverlayPermission(); return await hasOverlayPermission();

5
lib/data/services/meeting/meeting_task_service.dart

@ -14,6 +14,7 @@ class MeetingTaskService extends GetxService {
bool _isTask = false; //是否有正在处理的任务 bool _isTask = false; //是否有正在处理的任务
RxInt updateListState = 0.obs; //是否更新首页列表 RxInt updateListState = 0.obs; //是否更新首页列表
RxInt localImportState = 0.obs; //是否触发本地录音导入
int meetingId = 0; int meetingId = 0;
RxInt tasktype = 1.obs; //任务类型,0-初始,1-等待转写中,2-转写中,3-等待总结中,4-总结中,5-完成 RxInt tasktype = 1.obs; //任务类型,0-初始,1-等待转写中,2-转写中,3-等待总结中,4-总结中,5-完成
List textList = []; List textList = [];
@ -21,6 +22,10 @@ class MeetingTaskService extends GetxService {
String summary = ''; String summary = '';
String overview = ''; String overview = '';
void requestLocalImport() {
localImportState.value++;
}
Future<void> addTask( Future<void> addTask(
int id, int id,
String audiourl, String audiourl,

11
lib/main.dart

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart';
@ -8,6 +10,7 @@ import 'package:get_storage/get_storage.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'core/utils/app_lifecycle_manager.dart';
import 'routes/app_pages.dart'; import 'routes/app_pages.dart';
import 'routes/app_routes.dart'; import 'routes/app_routes.dart';
import 'core/translations/app_translations.dart'; import 'core/translations/app_translations.dart';
@ -127,6 +130,14 @@ class MainApp extends StatelessWidget {
initialBinding: InitialBinding(), initialBinding: InitialBinding(),
initialRoute: Routes.splash, initialRoute: Routes.splash,
getPages: AppPages.pages, getPages: AppPages.pages,
routingCallback: (routing) {
if (!Get.isRegistered<AppLifecycleManager>()) return;
unawaited(
Get.find<AppLifecycleManager>().refreshBleWakeupPolicy(
route: routing?.current,
),
);
},
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
translations: AppTranslations(), translations: AppTranslations(),
localizationsDelegates: const [ localizationsDelegates: const [

35
lib/modules/home/controllers/home_controller.dart

@ -11,7 +11,6 @@ import '../../../data/services/ble_manager.dart';
import '../../../data/services/ota_check_service.dart'; import '../../../data/services/ota_check_service.dart';
import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart'; import '../../../data/models/appconfig_model.dart';
import '../../meeting/controllers/meeting_record_controller.dart';
/// 连接状态枚举 /// 连接状态枚举
enum ConnectionStatus { enum ConnectionStatus {
@ -241,13 +240,9 @@ class HomeController extends GetxController {
Get.toNamed(Routes.pairing); Get.toNamed(Routes.pairing);
} }
void openTranslation() async { void openTranslation() {
AgentService.interruptCurrentResponse(); AgentService.interruptCurrentResponse();
AgentService.setCanWakeUp(false); Get.toNamed(Routes.translationMiddleware);
Logger.d(_tag, 'liwei-----------关闭唤醒功能');
await Get.toNamed(Routes.translationMiddleware);
AgentService.setCanWakeUp(true);
Logger.d(_tag, 'liwei-----------打开唤醒功能');
} }
/// 打开会议模块 /// 打开会议模块
@ -255,31 +250,9 @@ class HomeController extends GetxController {
/// 返回值:`Future<void>` 无返回数据 /// 返回值:`Future<void>` 无返回数据
/// 异常:不显式抛出;内部仅记录日志 /// 异常:不显式抛出;内部仅记录日志
/// 说明:进入会议页前关闭唤醒;返回首页后根据录音状态决定是否开启唤醒(存在录音任务则不启用) /// 说明:进入会议页前关闭唤醒;返回首页后根据录音状态决定是否开启唤醒(存在录音任务则不启用)
void openMeet() async { void openMeet() {
AgentService.interruptCurrentResponse(); AgentService.interruptCurrentResponse();
AgentService.setCanWakeUp(false); Get.toNamed(Routes.meeting);
Logger.d(_tag, 'liwei-----------关闭唤醒功能');
await Get.toNamed(Routes.meeting);
try {
if (Get.isRegistered<MeetingRecordController>()) {
final mrc = Get.find<MeetingRecordController>();
final hasRecording = mrc.isRecording.value ||
mrc.timerStatus.value != TimerStatus.stopped;
if (hasRecording) {
AgentService.setCanWakeUp(false);
Logger.d(_tag, 'openMeet返回:检测到录音任务,保持关闭唤醒功能');
} else {
AgentService.setCanWakeUp(true);
Logger.d(_tag, 'openMeet返回:无录音任务,打开唤醒功能');
}
} else {
AgentService.setCanWakeUp(true);
Logger.d(_tag, 'openMeet返回:未注册MeetingRecordController,默认打开唤醒功能');
}
} catch (e) {
AgentService.setCanWakeUp(true);
Logger.e(_tag, 'openMeet返回:检测录音任务异常,默认打开唤醒功能: $e');
}
} }
void exploreAIFeatures() { void exploreAIFeatures() {

50
lib/modules/meeting/controllers/meeting_home_controller.dart

@ -22,6 +22,7 @@ import '../../../data/services/meeting/meeting_task_service.dart';
import '../../../data/services/meeting/meeting_upload_service.dart'; import '../../../data/services/meeting/meeting_upload_service.dart';
import '../../../data/services/network/api.dart'; import '../../../data/services/network/api.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import 'meeting_record_controller.dart';
import 'meeting_controller.dart'; import 'meeting_controller.dart';
// 会议首页控制器 // 会议首页控制器
@ -32,6 +33,8 @@ class MeetingHomeController extends GetxController {
final AudioPlayer _audioPlayer = AudioPlayer(); final AudioPlayer _audioPlayer = AudioPlayer();
bool _isImportingLocalRecordings = false;
List<String> selected = []; List<String> selected = [];
final appDocDir = getApplicationDocumentsDirectory(); final appDocDir = getApplicationDocumentsDirectory();
@ -49,6 +52,7 @@ class MeetingHomeController extends GetxController {
final bleManager = Get.find<BleManager>(); final bleManager = Get.find<BleManager>();
late final Worker _updateListStateListener; late final Worker _updateListStateListener;
late final Worker _localImportListener;
@override @override
void onInit() { void onInit() {
@ -69,6 +73,9 @@ class MeetingHomeController extends GetxController {
ever(meetingTaskService.updateListState, (value) async { ever(meetingTaskService.updateListState, (value) async {
_getDataList(); _getDataList();
}); });
_localImportListener = ever(meetingTaskService.localImportState, (value) {
addMeetingAudio();
});
} }
@override @override
@ -77,6 +84,7 @@ class MeetingHomeController extends GetxController {
filtrateController.dispose(); filtrateController.dispose();
_audioPlayer.dispose(); _audioPlayer.dispose();
_updateListStateListener.dispose(); _updateListStateListener.dispose();
_localImportListener.dispose();
super.onClose(); super.onClose();
} }
@ -188,12 +196,15 @@ class MeetingHomeController extends GetxController {
} }
} }
// 核心方法:导入本地录音文件到统一目录 // 核心方法:导入本地录音文件到统一目录
void addMeetingAudio() async { void addMeetingAudio() async {
if (_isImportingLocalRecordings) return;
_isImportingLocalRecordings = true;
EasyLoading.show(status: 'importingLocalRecordings'.tr); // 对应中文:正在导入本地录音... EasyLoading.show(status: 'importingLocalRecordings'.tr); // 对应中文:正在导入本地录音...
try { try {
int addUp = 0; // 新增计数 int addUp = 0; // 新增计数
final appDir = await appDocDir; // 获取应用文档目录 final appDir = await appDocDir; // 获取应用文档目录
final activeRecordingWavName = _getActiveRecordingWavFileName();
final sourceDirs = [ final sourceDirs = [
Directory("${appDir.path}/MeetingAudio/"), // 会议录音目录 Directory("${appDir.path}/MeetingAudio/"), // 会议录音目录
@ -221,16 +232,31 @@ class MeetingHomeController extends GetxController {
// 遍历所有文件实体 // 遍历所有文件实体
for (final fileEntity in allFiles) { for (final fileEntity in allFiles) {
try { try {
final entityName = path.basename(fileEntity.path);
if (activeRecordingWavName != null &&
entityName == activeRecordingWavName) {
continue;
}
final file = File(fileEntity.path); //将文件实体转换为具体文件操作对象 final file = File(fileEntity.path); //将文件实体转换为具体文件操作对象
final filePath = file.path; final filePath = file.path;
final fileStat = await file.stat(); //获取文件状态信息,包含修改时间等
if (fileStat.size < 1024) {
continue;
}
// 使用just_audio库加载音频文件以获取时长 // 使用just_audio库加载音频文件以获取时长
await _audioPlayer.setAudioSource(AudioSource.file(filePath)); final Duration? duration =
int seconds = await _audioPlayer.setAudioSource(AudioSource.file(filePath));
_audioPlayer.duration?.inSeconds ?? 0; //获取音频时长(秒),若为空则设为0 final Duration? effectiveDuration = duration ?? _audioPlayer.duration;
final int durationMs = effectiveDuration?.inMilliseconds ?? 0;
final int seconds =
durationMs <= 0 ? 0 : ((durationMs + 999) ~/ 1000);
if (seconds <= 0) {
continue;
}
// 获取文件修改时间
final fileStat = await file.stat(); //获取文件状态信息,包含修改时间等
final creationTime = fileStat.changed.millisecondsSinceEpoch; final creationTime = fileStat.changed.millisecondsSinceEpoch;
// 准备新路径 // 准备新路径
@ -303,10 +329,20 @@ class MeetingHomeController extends GetxController {
.tr .tr
.replaceAll('{error}', e.toString())); // 对应中文:导入失败: {error} .replaceAll('{error}', e.toString())); // 对应中文:导入失败: {error}
} finally { } finally {
_isImportingLocalRecordings = false;
EasyLoading.dismiss(); EasyLoading.dismiss();
} }
} }
String? _getActiveRecordingWavFileName() {
if (!Get.isRegistered<MeetingRecordController>()) return null;
final controller = Get.find<MeetingRecordController>();
if (!controller.isRecording.value) return null;
final name = controller.fileName.value;
if (name.isEmpty) return null;
return '$name.wav';
}
// 新增(2025-11-04):将翻译音频归档到 LocalAudio 并返回新路径(优先 rename,失败再 copy+delete) // 新增(2025-11-04):将翻译音频归档到 LocalAudio 并返回新路径(优先 rename,失败再 copy+delete)
Future<String> _archiveTranslatToLocal(String srcPath) async { Future<String> _archiveTranslatToLocal(String srcPath) async {
// 获取应用文档目录并确保 LocalAudio 目录存在 // 获取应用文档目录并确保 LocalAudio 目录存在
@ -324,7 +360,7 @@ class MeetingHomeController extends GetxController {
if (await File(destPath).exists()) { if (await File(destPath).exists()) {
final ts = DateTime.now().millisecondsSinceEpoch; final ts = DateTime.now().millisecondsSinceEpoch;
destPath = path.join(destDir.path, '$baseName\_$ts$ext'); destPath = path.join(destDir.path, '${baseName}_$ts$ext');
} }
// 中文注释:尝试 rename(更快),失败则 copy + delete // 中文注释:尝试 rename(更快),失败则 copy + delete

535
lib/modules/meeting/controllers/meeting_record_controller.dart

@ -8,6 +8,7 @@ import '/core/utils/overlay_permission_util.dart';
import '/core/utils/permission_util.dart'; import '/core/utils/permission_util.dart';
import 'package:audio_waveforms/audio_waveforms.dart'; import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:floating_ui_plugin/native_plugin.dart'; import 'package:floating_ui_plugin/native_plugin.dart';
@ -17,6 +18,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '../../../data/services/asr_service.dart'; import '../../../data/services/asr_service.dart';
import '../../../data/services/ble_manager.dart'; import '../../../data/services/ble_manager.dart';
import '../../../data/services/meeting/meeting_task_service.dart';
/// 会议记录状态枚举 /// 会议记录状态枚举
enum TimerStatus { enum TimerStatus {
@ -28,14 +30,25 @@ enum TimerStatus {
/// 会议记录控制器 /// 会议记录控制器
/// 负责会议记录的状态管理、音频处理、语音识别和UI交互 /// 负责会议记录的状态管理、音频处理、语音识别和UI交互
class MeetingRecordController extends GetxController class MeetingRecordController extends GetxController
with GetTickerProviderStateMixin { with GetTickerProviderStateMixin, WidgetsBindingObserver {
static const int _backgroundRecordingNotificationId = 91001; static const int _backgroundRecordingNotificationId = 91001;
static const Duration _stopRecordTimeout = Duration(seconds: 12);
static const Duration _fileOpTimeout = Duration(seconds: 8);
static FlutterLocalNotificationsPlugin? _localNotificationsPlugin; static FlutterLocalNotificationsPlugin? _localNotificationsPlugin;
final NativePlugin _nativePlugin = NativePlugin(); final NativePlugin _nativePlugin = NativePlugin();
Timer? _recordingFloatingBarTimer; Timer? _recordingFloatingBarTimer;
Timer? _routeMonitorTimer;
StreamSubscription<String>? _recordingFloatingActionSubscription; StreamSubscription<String>? _recordingFloatingActionSubscription;
bool _floatingBarManuallyClosed = false; bool _floatingBarManuallyClosed = false;
bool _overlayPermissionPromptedThisSession = false;
bool _overlayPermissionSnackShownThisSession = false;
bool _overlayPermissionRequesting = false;
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
String _lastKnownRoute = '';
OverlayEntry? _recordingInAppOverlay;
Offset? _recordingInAppOverlayOffset;
Future<FlutterLocalNotificationsPlugin> Future<FlutterLocalNotificationsPlugin>
_ensureLocalNotificationsPlugin() async { _ensureLocalNotificationsPlugin() async {
@ -76,8 +89,15 @@ class MeetingRecordController extends GetxController
await _requestNotificationPermissionIfNeeded(); await _requestNotificationPermissionIfNeeded();
if (!Platform.isIOS) return; if (!Platform.isIOS) return;
if (_floatingBarManuallyClosed) return;
if (_appLifecycleState == AppLifecycleState.resumed) {
await _showInAppRecordingOverlay();
return;
}
_hideInAppRecordingOverlay();
final plugin = await _ensureLocalNotificationsPlugin(); final plugin = await _ensureLocalNotificationsPlugin();
final details = NotificationDetails( const details = NotificationDetails(
iOS: DarwinNotificationDetails( iOS: DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
presentBadge: false, presentBadge: false,
@ -102,6 +122,8 @@ class MeetingRecordController extends GetxController
if (!Platform.isIOS) return; if (!Platform.isIOS) return;
final plugin = await _ensureLocalNotificationsPlugin(); final plugin = await _ensureLocalNotificationsPlugin();
await plugin.cancel(_backgroundRecordingNotificationId); await plugin.cancel(_backgroundRecordingNotificationId);
_floatingBarManuallyClosed = false;
_hideInAppRecordingOverlay();
} }
String _formatTimeForFloatingBar(int ms) { String _formatTimeForFloatingBar(int ms) {
@ -133,28 +155,30 @@ class MeetingRecordController extends GetxController
if (!isRecording.value) return; if (!isRecording.value) return;
if (_floatingBarManuallyClosed) return; if (_floatingBarManuallyClosed) return;
final hasPermission = await OverlayPermissionUtil.hasOverlayPermission(); final granted = await _ensureAndroidOverlayPermissionForFloatingBar();
if (!hasPermission) { if (!granted) return;
final granted = await OverlayPermissionUtil.requestOverlayPermission(
scene: OverlayPermissionScene.recordingFloatingBar,
);
if (!granted) return;
}
final typeText = _getRecordingTypeText(); final typeText = _getRecordingTypeText();
final durationText = _formatTimeForFloatingBar(milliseconds.value); final durationText = _formatTimeForFloatingBar(milliseconds.value);
await _nativePlugin.showRecordingFloatingBar( try {
duration: durationText, await _nativePlugin
recordingType: typeText, .showRecordingFloatingBar(
isPaused: timerStatus.value == TimerStatus.paused, duration: durationText,
); recordingType: typeText,
isPaused: timerStatus.value == TimerStatus.paused,
)
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('显示录音悬浮条失败: ${e.toString()}');
return;
}
_recordingFloatingBarTimer?.cancel(); _recordingFloatingBarTimer?.cancel();
_recordingFloatingBarTimer = _recordingFloatingBarTimer =
Timer.periodic(const Duration(seconds: 1), (_) { Timer.periodic(const Duration(seconds: 1), (_) {
if (!isRecording.value) { if (!isRecording.value) {
_stopAndroidRecordingFloatingBar(); unawaited(_stopAndroidRecordingFloatingBar());
return; return;
} }
_nativePlugin.updateRecordingFloatingBar( _nativePlugin.updateRecordingFloatingBar(
@ -165,11 +189,65 @@ class MeetingRecordController extends GetxController
}); });
} }
void _showOverlayPermissionSnackOnce() {
if (_overlayPermissionSnackShownThisSession) return;
_overlayPermissionSnackShownThisSession = true;
Get.snackbar(
'overlayPermissionTitle'.tr,
'overlayPermissionRecordingDesc'.tr,
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 4),
mainButton: TextButton(
onPressed: () {
unawaited(OverlayPermissionUtil.openOverlayPermissionSettings());
},
child: Text('goToSettings'.tr),
),
);
}
Future<bool> _ensureAndroidOverlayPermissionForFloatingBar() async {
if (!Platform.isAndroid) return true;
if (!isRecording.value) return false;
final hasPermission = await OverlayPermissionUtil.hasOverlayPermission();
if (hasPermission) return true;
if (_appLifecycleState != AppLifecycleState.resumed) {
return false;
}
if (_overlayPermissionPromptedThisSession || _overlayPermissionRequesting) {
_showOverlayPermissionSnackOnce();
return false;
}
_overlayPermissionPromptedThisSession = true;
_overlayPermissionRequesting = true;
try {
final granted = await OverlayPermissionUtil.requestOverlayPermission(
scene: OverlayPermissionScene.recordingFloatingBar,
);
if (!granted) {
_showOverlayPermissionSnackOnce();
}
return granted;
} finally {
_overlayPermissionRequesting = false;
}
}
Future<void> _stopAndroidRecordingFloatingBar() async { Future<void> _stopAndroidRecordingFloatingBar() async {
if (!Platform.isAndroid) return; if (!Platform.isAndroid) return;
_recordingFloatingBarTimer?.cancel(); _recordingFloatingBarTimer?.cancel();
_recordingFloatingBarTimer = null; _recordingFloatingBarTimer = null;
await _nativePlugin.hideRecordingFloatingBar(); try {
await _nativePlugin
.hideRecordingFloatingBar()
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('隐藏录音悬浮条失败: ${e.toString()}');
}
} }
Future<void> togglePauseResumeFromFloatingBar() async { Future<void> togglePauseResumeFromFloatingBar() async {
@ -202,6 +280,13 @@ class MeetingRecordController extends GetxController
); );
return; return;
case 'closeFloatingBar': case 'closeFloatingBar':
if (isRecording.value) {
final saved = await saveRecording();
if (saved && Get.isRegistered<MeetingTaskService>()) {
Get.find<MeetingTaskService>().requestLocalImport();
}
return;
}
_floatingBarManuallyClosed = true; _floatingBarManuallyClosed = true;
await _stopAndroidRecordingFloatingBar(); await _stopAndroidRecordingFloatingBar();
return; return;
@ -226,6 +311,7 @@ class MeetingRecordController extends GetxController
final RxBool isMarked = false.obs; final RxBool isMarked = false.obs;
final RxBool showTextContent = false.obs; final RxBool showTextContent = false.obs;
final RxBool isRecording = false.obs; final RxBool isRecording = false.obs;
final RxBool isSaving = false.obs;
//final RxBool isVoiceRecognitionActive = false.obs; //final RxBool isVoiceRecognitionActive = false.obs;
final RxDouble rms = 0.0.obs; final RxDouble rms = 0.0.obs;
@ -262,15 +348,23 @@ class MeetingRecordController extends GetxController
_initializeComponents(); _initializeComponents();
_setupFromArguments(); _setupFromArguments();
_setupRecordingFloatingBarActionListener(); _setupRecordingFloatingBarActionListener();
WidgetsBinding.instance.addObserver(this);
//generateWaveData(); //generateWaveData();
} }
@override @override
void onClose() { void onClose() {
_cleanupResources(); _cleanupResources();
WidgetsBinding.instance.removeObserver(this);
super.onClose(); super.onClose();
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_appLifecycleState = state;
unawaited(_refreshRecordingIndicator());
}
void applyRouteArgumentsIfIdle() { void applyRouteArgumentsIfIdle() {
if (isEditingTitle.value) return; if (isEditingTitle.value) return;
if (isRecording.value) return; if (isRecording.value) return;
@ -332,9 +426,12 @@ class MeetingRecordController extends GetxController
/// 控制器销毁时清理资源 /// 控制器销毁时清理资源
void _cleanupResources() { void _cleanupResources() {
_timer?.cancel(); _timer?.cancel();
_stopAndroidRecordingFloatingBar(); unawaited(_stopAndroidRecordingFloatingBar());
_routeMonitorTimer?.cancel();
_routeMonitorTimer = null;
_recordingFloatingActionSubscription?.cancel(); _recordingFloatingActionSubscription?.cancel();
_recordingFloatingActionSubscription = null; _recordingFloatingActionSubscription = null;
_hideInAppRecordingOverlay();
_stopWaveformTimer(); _stopWaveformTimer();
_recognitionSubscription?.cancel(); _recognitionSubscription?.cancel();
_recognitionSubscription = null; _recognitionSubscription = null;
@ -462,6 +559,9 @@ class MeetingRecordController extends GetxController
}); });
isRecording.value = true; isRecording.value = true;
_overlayPermissionPromptedThisSession = false;
_overlayPermissionSnackShownThisSession = false;
_overlayPermissionRequesting = false;
// 更新录音状态 // 更新录音状态
fileName.value = fullFileName; fileName.value = fullFileName;
newName.value = fullFileName; newName.value = fullFileName;
@ -472,6 +572,7 @@ class MeetingRecordController extends GetxController
_resetWaveform(); _resetWaveform();
_startWaveformTimer(); _startWaveformTimer();
startTimer(); startTimer();
await _refreshRecordingIndicator();
} }
/// 根据音频类型启动对应的硬件服务 /// 根据音频类型启动对应的硬件服务
@ -497,6 +598,7 @@ class MeetingRecordController extends GetxController
_stopWaveformTimer(); _stopWaveformTimer();
rms.value = 0.0; rms.value = 0.0;
pauseTimer(); pauseTimer();
await _refreshRecordingIndicator();
} }
/// 恢复已暂停的录音会话 /// 恢复已暂停的录音会话
@ -504,6 +606,7 @@ class MeetingRecordController extends GetxController
await _asrService.resumeRecord(); await _asrService.resumeRecord();
_startWaveformTimer(); _startWaveformTimer();
startTimer(); startTimer();
await _refreshRecordingIndicator();
} }
/// 处理原始Uint8List音频数据 /// 处理原始Uint8List音频数据
@ -573,60 +676,152 @@ class MeetingRecordController extends GetxController
} }
/// 保存录音并清理相关资源 /// 保存录音并清理相关资源
Future<void> saveRecording() async { Future<bool> saveRecording() async {
resetTimer(); if (isSaving.value) return false;
if (!isRecording.value) return true;
isSaving.value = true;
final prevStatus = timerStatus.value;
pauseTimer();
final prevElapsedMs = _elapsedMilliseconds;
_stopWaveformTimer(); _stopWaveformTimer();
if (audioType.value != 0) {
_bleManager.closeCodec(); try {
} final bool stopped = await _asrService
if (!isRecording.value) return; .stopRecord(true)
await _asrService.stopRecord(true); .timeout(_stopRecordTimeout, onTimeout: () => false);
await clearBackgroundRecordingIndicator(); if (!stopped) {
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
// 如果标题被修改则重命名文件 Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr);
if (fileName.value != newName.value) { return false;
final appDir = await appDocDir;
final dir = Directory("${appDir.path}/MeetingAudio/");
if (!await dir.exists()) await dir.create(recursive: true);
if (isMarked.value) {
final newDir = Directory("${appDir.path}/DestAudio/");
if (!await newDir.exists()) await newDir.create(recursive: true);
await _asrService.moveFile(
"${dir.path}${fileName.value}.wav",
"${newDir.path}${fileName.value}.wav",
);
} else {
await _asrService.renameFile(
"${dir.path}${fileName.value}.wav",
newName.value,
);
} }
}
_resetToDefaultState(); if (audioType.value != 0) {
isRecording.value = false; _bleManager.closeCodec();
rms.value = 0.0; }
_smoothedRms = 0.0;
_latestRms = 0.0; try {
_resetWaveform(); await clearBackgroundRecordingIndicator()
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('清理录音指示器失败: ${e.toString()}');
}
try {
await _applyTitleChangeAfterSave();
} catch (e) {
Logger.error('保存录音后的文件处理失败: ${e.toString()}');
}
resetTimer();
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
await _refreshRecordingIndicator();
return true;
} catch (e) {
Logger.error('保存录音失败: ${e.toString()}');
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordSaveFailed'.tr);
return false;
} finally {
isSaving.value = false;
}
} }
Future<void> discardRecording() async { Future<bool> discardRecording() async {
resetTimer(); if (!isRecording.value) return true;
if (isSaving.value) return false;
isSaving.value = true;
final prevStatus = timerStatus.value;
pauseTimer();
final prevElapsedMs = _elapsedMilliseconds;
_stopWaveformTimer(); _stopWaveformTimer();
if (audioType.value != 0) {
_bleManager.closeCodec(); try {
final bool stopped = await _asrService
.stopRecord(false)
.timeout(_stopRecordTimeout, onTimeout: () => false);
if (!stopped) {
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr);
return false;
}
if (audioType.value != 0) {
_bleManager.closeCodec();
}
try {
await clearBackgroundRecordingIndicator()
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('清理录音指示器失败: ${e.toString()}');
}
resetTimer();
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
await _refreshRecordingIndicator();
return true;
} catch (e) {
Logger.error('结束录音不保存失败: ${e.toString()}');
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordStopFailed'.tr);
return false;
} finally {
isSaving.value = false;
} }
if (isRecording.value) { }
await _asrService.stopRecord(false);
void _restoreRecordingUiAfterFailedStop(
TimerStatus prevStatus,
int prevElapsedMs,
) {
_elapsedMilliseconds = prevElapsedMs;
seconds.value = (prevElapsedMs / 1000).round();
milliseconds.value = prevElapsedMs;
if (prevStatus == TimerStatus.running) {
timerStatus.value = TimerStatus.stopped;
startTimer();
} else {
timerStatus.value = prevStatus;
} }
await clearBackgroundRecordingIndicator(); _startWaveformTimer();
_resetToDefaultState(); }
isRecording.value = false;
rms.value = 0.0; Future<void> _applyTitleChangeAfterSave() async {
_smoothedRms = 0.0; if (fileName.value == newName.value) return;
_latestRms = 0.0; final appDir = await appDocDir;
_resetWaveform(); final dir = Directory("${appDir.path}/MeetingAudio/");
if (!await dir.exists()) await dir.create(recursive: true);
if (isMarked.value) {
final newDir = Directory("${appDir.path}/DestAudio/");
if (!await newDir.exists()) await newDir.create(recursive: true);
await _asrService
.moveFile(
"${dir.path}${fileName.value}.wav",
"${newDir.path}${fileName.value}.wav",
)
.timeout(_fileOpTimeout);
return;
}
await _asrService
.renameFile(
"${dir.path}${fileName.value}.wav",
newName.value,
)
.timeout(_fileOpTimeout);
} }
/// 将控制器重置到初始状态 /// 将控制器重置到初始状态
@ -690,6 +885,220 @@ class MeetingRecordController extends GetxController
_elapsedMilliseconds = 0; _elapsedMilliseconds = 0;
_lastStartTime = null; _lastStartTime = null;
seconds.value = 0; seconds.value = 0;
milliseconds.value = 0;
timerStatus.value = TimerStatus.stopped; timerStatus.value = TimerStatus.stopped;
} }
bool _shouldShowRecordingIndicator() {
if (!isRecording.value) return false;
if (_appLifecycleState != AppLifecycleState.resumed) return true;
return Get.currentRoute != Routes.meetingRecord;
}
Future<void> _refreshRecordingIndicator() async {
if (!isRecording.value) {
_routeMonitorTimer?.cancel();
_routeMonitorTimer = null;
_hideInAppRecordingOverlay();
await _stopAndroidRecordingFloatingBar();
return;
}
_startRouteMonitorIfNeeded();
final shouldShow = _shouldShowRecordingIndicator();
if (!shouldShow) {
_hideInAppRecordingOverlay();
await _stopAndroidRecordingFloatingBar();
if (Platform.isIOS) {
final plugin = await _ensureLocalNotificationsPlugin();
await plugin.cancel(_backgroundRecordingNotificationId);
}
return;
}
if (_floatingBarManuallyClosed) return;
if (Platform.isAndroid) {
await showBackgroundRecordingIndicator();
return;
}
if (Platform.isIOS) {
if (_appLifecycleState == AppLifecycleState.resumed) {
final plugin = await _ensureLocalNotificationsPlugin();
await plugin.cancel(_backgroundRecordingNotificationId);
await _showInAppRecordingOverlay();
} else {
await showBackgroundRecordingIndicator();
}
}
}
void _startRouteMonitorIfNeeded() {
if (_routeMonitorTimer != null) return;
_lastKnownRoute = Get.currentRoute;
_routeMonitorTimer = Timer.periodic(const Duration(milliseconds: 400), (_) {
final currentRoute = Get.currentRoute;
if (currentRoute == _lastKnownRoute) return;
_lastKnownRoute = currentRoute;
unawaited(_refreshRecordingIndicator());
});
}
Future<void> _showInAppRecordingOverlay() async {
if (!Platform.isIOS) return;
if (_recordingInAppOverlay != null) return;
final context = Get.overlayContext;
if (context == null) return;
final mediaQuery = MediaQuery.of(context);
final screenSize = mediaQuery.size;
final safeTop = mediaQuery.padding.top;
final safeBottom = mediaQuery.padding.bottom;
final width = 260.w;
final height = 52.h;
final initial = _recordingInAppOverlayOffset ??
Offset(16.w, max(16.h + safeTop, 100.h));
_recordingInAppOverlayOffset = Offset(
initial.dx.clamp(8.w, max(8.w, screenSize.width - width - 8.w)),
initial.dy.clamp(
8.h + safeTop,
max(8.h + safeTop, screenSize.height - height - 8.h - safeBottom),
),
);
_recordingInAppOverlay = OverlayEntry(
builder: (_) {
final offset = _recordingInAppOverlayOffset ?? Offset(16.w, 120.h);
return Positioned(
left: offset.dx,
top: offset.dy,
child: Material(
color: Colors.transparent,
child: GestureDetector(
onPanUpdate: (details) {
final next = Offset(
offset.dx + details.delta.dx,
offset.dy + details.delta.dy,
);
_recordingInAppOverlayOffset = Offset(
next.dx.clamp(8.w, max(8.w, screenSize.width - width - 8.w)),
next.dy.clamp(
8.h + safeTop,
max(
8.h + safeTop,
screenSize.height - height - 8.h - safeBottom,
),
),
);
_recordingInAppOverlay?.markNeedsBuild();
},
child: Container(
width: width,
height: height,
padding: EdgeInsets.symmetric(horizontal: 10.w),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(26.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 10.r,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Icon(
Icons.mic_rounded,
size: 18.sp,
color: Colors.white,
),
SizedBox(width: 8.w),
Expanded(
child: Obx(() {
final durationText =
_formatTimeForFloatingBar(milliseconds.value);
final typeText = _getRecordingTypeText();
return Text(
'$durationText · $typeText',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontWeight: FontWeight.w600,
),
);
}),
),
SizedBox(width: 6.w),
Obx(() {
final paused = timerStatus.value == TimerStatus.paused;
return InkWell(
onTap: togglePauseResumeFromFloatingBar,
borderRadius: BorderRadius.circular(18.r),
child: Padding(
padding: EdgeInsets.all(6.w),
child: Icon(
paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
size: 18.sp,
color: Colors.white,
),
),
);
}),
InkWell(
onTap: () {
_floatingBarManuallyClosed = false;
if (Get.currentRoute == Routes.meetingRecord) return;
Get.toNamed(
Routes.meetingRecord,
arguments: {'audioTypes': audioType.value},
);
},
borderRadius: BorderRadius.circular(18.r),
child: Padding(
padding: EdgeInsets.all(6.w),
child: Icon(
Icons.open_in_new_rounded,
size: 18.sp,
color: Colors.white,
),
),
),
InkWell(
onTap: () {
_floatingBarManuallyClosed = true;
_hideInAppRecordingOverlay();
},
borderRadius: BorderRadius.circular(18.r),
child: Padding(
padding: EdgeInsets.all(6.w),
child: Icon(
Icons.close_rounded,
size: 18.sp,
color: Colors.white70,
),
),
),
],
),
),
),
),
);
},
);
Overlay.of(context, rootOverlay: true).insert(_recordingInAppOverlay!);
}
void _hideInAppRecordingOverlay() {
_recordingInAppOverlay?.remove();
_recordingInAppOverlay = null;
}
} }

15
lib/modules/meeting/views/bottomSheet/navigation_bar_bottom_sheet.dart

@ -30,12 +30,9 @@ class NavigationBarBottomSheet extends GetView<MeetingHomeController> {
Colors.red, Colors.red,
onTap: () async { onTap: () async {
Get.back(); Get.back();
final result = await Get.toNamed(Routes.meetingRecord, arguments: { await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 0, 'audioTypes': 0,
}); });
if (result != 'meeting_record_background') {
controller.addMeetingAudio();
}
}, },
), ),
if (controller.meetingController.type == 1) if (controller.meetingController.type == 1)
@ -47,12 +44,9 @@ class NavigationBarBottomSheet extends GetView<MeetingHomeController> {
onTap: controller.bleManager.isConnected onTap: controller.bleManager.isConnected
? () async { ? () async {
Get.back(); Get.back();
final result = await Get.toNamed(Routes.meetingRecord, arguments: { await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 1, 'audioTypes': 1,
}); });
if (result != 'meeting_record_background') {
controller.addMeetingAudio();
}
} }
: null, : null,
), ),
@ -65,12 +59,9 @@ class NavigationBarBottomSheet extends GetView<MeetingHomeController> {
onTap: controller.bleManager.isConnected onTap: controller.bleManager.isConnected
? () async { ? () async {
Get.back(); Get.back();
final result = await Get.toNamed(Routes.meetingRecord, arguments: { await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 2, 'audioTypes': 2,
}); });
if (result != 'meeting_record_background') {
controller.addMeetingAudio();
}
} }
: null, : null,
), ),

74
lib/modules/meeting/views/meeting_record_view.dart

@ -5,7 +5,9 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/meeting_record_controller.dart'; import '../controllers/meeting_record_controller.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../data/services/meeting/meeting_task_service.dart';
/// 会议录音退出操作
enum _MeetingRecordExitAction { enum _MeetingRecordExitAction {
saveAndEnd, saveAndEnd,
endWithoutSave, endWithoutSave,
@ -13,9 +15,15 @@ enum _MeetingRecordExitAction {
cancel, cancel,
} }
/// 会议录音页面
class MeetingRecordView extends GetView<MeetingRecordController> { class MeetingRecordView extends GetView<MeetingRecordController> {
const MeetingRecordView({super.key}); const MeetingRecordView({super.key});
void _notifyMeetingLocalImport() {
if (!Get.isRegistered<MeetingTaskService>()) return;
Get.find<MeetingTaskService>().requestLocalImport();
}
void _deleteControllerAfterPop() { void _deleteControllerAfterPop() {
Future.delayed(const Duration(milliseconds: 400), () { Future.delayed(const Duration(milliseconds: 400), () {
if (Get.isRegistered<MeetingRecordController>()) { if (Get.isRegistered<MeetingRecordController>()) {
@ -24,6 +32,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
}); });
} }
/// 显示退出确认弹窗
Future<_MeetingRecordExitAction?> _showExitConfirmDialog(bool isDarkMode) { Future<_MeetingRecordExitAction?> _showExitConfirmDialog(bool isDarkMode) {
return Get.dialog<_MeetingRecordExitAction>( return Get.dialog<_MeetingRecordExitAction>(
Dialog( Dialog(
@ -58,7 +67,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
SizedBox(height: 14.h), SizedBox(height: 14.h),
Text( Text(
'meetingRecordExitTitle'.tr, // 对应中文:退出录音 'meetingRecordExitTitle'.tr, // 退出录音
style: TextStyle( style: TextStyle(
fontSize: 18.sp, fontSize: 18.sp,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -67,7 +76,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
SizedBox(height: 8.h), SizedBox(height: 8.h),
Text( Text(
'meetingRecordExitMessage'.tr, // 对应中文:请选择离开后的操作 'meetingRecordExitMessage'.tr, // 请选择离开后的操作
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
@ -96,7 +105,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
elevation: 0, elevation: 0,
), ),
child: Text( child: Text(
'meetingRecordEndAndSave'.tr, // 对应中文:结束录音并保存 'meetingRecordEndAndSave'.tr, // 结束录音并保存
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -121,7 +130,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
), ),
child: Text( child: Text(
'meetingRecordContinueInBackground'.tr, // 对应中文:后台继续录音 'meetingRecordContinueInBackground'.tr, // 后台继续录音
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -146,7 +155,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
), ),
child: Text( child: Text(
'meetingRecordEndWithoutSave'.tr, // 对应中文:结束录音不保存 'meetingRecordEndWithoutSave'.tr, // 结束录音不保存
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -168,7 +177,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
), ),
child: Text( child: Text(
'cancel'.tr, // 对应中文:取消 'cancel'.tr, // 取消
style: TextStyle( style: TextStyle(
fontSize: 14.sp, fontSize: 14.sp,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -211,14 +220,19 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
switch (action) { switch (action) {
case _MeetingRecordExitAction.saveAndEnd: case _MeetingRecordExitAction.saveAndEnd:
await controller.saveTitleChange(); await controller.saveTitleChange();
await controller.saveRecording(); final saved = await controller.saveRecording();
Get.back(result: 'meeting_record_saved'); if (saved) {
_deleteControllerAfterPop(); _notifyMeetingLocalImport();
Get.back(result: 'meeting_record_saved');
_deleteControllerAfterPop();
}
return; return;
case _MeetingRecordExitAction.endWithoutSave: case _MeetingRecordExitAction.endWithoutSave:
await controller.discardRecording(); final discarded = await controller.discardRecording();
Get.back(result: 'meeting_record_discarded'); if (discarded) {
_deleteControllerAfterPop(); Get.back(result: 'meeting_record_discarded');
_deleteControllerAfterPop();
}
return; return;
case _MeetingRecordExitAction.continueInBackground: case _MeetingRecordExitAction.continueInBackground:
await controller.saveTitleChange(); await controller.saveTitleChange();
@ -305,6 +319,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
); );
} }
/// 构建音频标签页
Widget _buildAudioTab(BuildContext context, bool isDarkMode) { Widget _buildAudioTab(BuildContext context, bool isDarkMode) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@ -367,17 +382,6 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
final size = Size(screenWidth, 200.h); final size = Size(screenWidth, 200.h);
final waveColor = final waveColor =
isDarkMode ? Colors.white : Colors.black87; isDarkMode ? Colors.white : Colors.black87;
final shader = ui.Gradient.linear(
const Offset(0, 0),
Offset(size.width, 0),
[
waveColor.withValues(alpha: 0.0),
waveColor.withValues(alpha: 0.25),
waveColor.withValues(alpha: 0.6),
waveColor.withValues(alpha: 0.95),
],
const [0.0, 0.55, 0.8, 1.0],
);
return Center( return Center(
child: AudioWaveforms( child: AudioWaveforms(
@ -399,7 +403,6 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
showTop: true, showTop: true,
showBottom: true, showBottom: true,
extendWaveform: true, extendWaveform: true,
// gradient: shader,
waveColor: waveColor, waveColor: waveColor,
scaleFactor: 90, scaleFactor: 90,
), ),
@ -517,7 +520,10 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
), ),
// 完成按钮 // 完成按钮
_buildActionButton( _buildActionButton(
onTap: () => _bottomSheet(context, isDarkMode), onTap: () => _bottomSheet(
isDarkMode,
MediaQuery.of(context).viewInsets.bottom,
),
child: Icon( child: Icon(
Icons.check_rounded, Icons.check_rounded,
size: 36.sp, size: 36.sp,
@ -616,7 +622,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
} }
// 底部编辑弹窗 // 底部编辑弹窗
void _bottomSheet(BuildContext context, bool isDarkMode) async { void _bottomSheet(bool isDarkMode, double bottomInset) async {
//如果当前计时器在运行,先暂停录音,避免编辑时继续录音 //如果当前计时器在运行,先暂停录音,避免编辑时继续录音
if (controller.timerStatus.value == TimerStatus.running) { if (controller.timerStatus.value == TimerStatus.running) {
await controller.toggleRecording(); await controller.toggleRecording();
@ -628,7 +634,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
left: 20.w, left: 20.w,
right: 20.w, right: 20.w,
top: 20.w, top: 20.w,
bottom: MediaQuery.of(context).viewInsets.bottom + 20.w, bottom: bottomInset + 20.w,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDarkMode ? AppColors.darkBackground : Colors.white, color: isDarkMode ? AppColors.darkBackground : Colors.white,
@ -700,8 +706,10 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) async { onSubmitted: (_) async {
await controller.saveTitleChange(); await controller.saveTitleChange();
await controller.saveRecording(); final saved = await controller.saveRecording();
Get.back(); if (saved) {
Get.back();
}
}, },
), ),
), ),
@ -733,9 +741,11 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
await controller.saveTitleChange(); await controller.saveTitleChange();
await controller.saveRecording(); final saved = await controller.saveRecording();
Get.back(); //关闭弹窗 if (saved) {
Get.back(); //回退到会议记录页面 Get.back(); //关闭弹窗
Get.back(); //回退到会议记录页面
}
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor:

8
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift

@ -80,6 +80,7 @@ class AgentServiceImpl: NSObject {
private var isRecognizing: Bool = false private var isRecognizing: Bool = false
private var hasSpeechDetected: Bool = false private var hasSpeechDetected: Bool = false
internal var isAiStreaming: Bool = false internal var isAiStreaming: Bool = false
private var isCanWakeUp: Bool = true
// 新增:识别"启动中"与"启动中收到停止请求"状态 // 新增:识别"启动中"与"启动中收到停止请求"状态
// 用途:解决 start 异步、stop 先于 onSessionStarted 到来时被忽略的问题 // 用途:解决 start 异步、stop 先于 onSessionStarted 到来时被忽略的问题
@ -167,6 +168,10 @@ class AgentServiceImpl: NSObject {
private func sendError(_ message: String, code: String = "ERROR") { private func sendError(_ message: String, code: String = "ERROR") {
sendEvent(name: "error", data: ["code": code, "message": message]) sendEvent(name: "error", data: ["code": code, "message": message])
} }
func setCanWakeUp(_ canWakeUp: Bool) {
isCanWakeUp = canWakeUp
}
private func initSystemPrompt() { private func initSystemPrompt() {
systemPrompt = """ systemPrompt = """
@ -2025,6 +2030,9 @@ extension AgentServiceImpl: BleService.Callback {
func onWakeupSignalReceived() { func onWakeupSignalReceived() {
if !isCanWakeUp {
return
}
startRecognition(useBle: true, mode: "ble_wakeup") startRecognition(useBle: true, mode: "ble_wakeup")
} }
// 处理开始 AI 单次对话 // 处理开始 AI 单次对话

8
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift

@ -194,6 +194,14 @@ public class AgentServicePlugin: NSObject, FlutterPlugin {
} }
let success = impl.setTtsRate(rate) let success = impl.setTtsRate(rate)
result(success) result(success)
case "setCanWakeUp":
guard let arguments = call.arguments as? [String: Any],
let canWakeUp = arguments["canWakeUp"] as? Bool else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "canWakeUp参数不能为空", details: nil))
return
}
impl.setCanWakeUp(canWakeUp)
result(true)
default: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }

8
local_plugins/agent_service/lib/agent_service.dart

@ -520,7 +520,10 @@ class AgentService {
} }
} }
/// 设置是否可以唤醒 /// 设置是否可以ble唤醒agent
///
/// [canWakeUp] 是否可以ble唤醒agent
/// 返回是否设置成功
static Future<bool> setCanWakeUp(bool canWakeUp) async { static Future<bool> setCanWakeUp(bool canWakeUp) async {
try { try {
final bool result = await _channel.invokeMethod('setCanWakeUp', { final bool result = await _channel.invokeMethod('setCanWakeUp', {
@ -528,7 +531,8 @@ class AgentService {
}); });
return result; return result;
} on PlatformException catch (e) { } on PlatformException catch (e) {
throw AgentServiceException(e.code, e.message ?? '设置是否可以唤醒失败', e.details); throw AgentServiceException(
e.code, e.message ?? '设置是否可以ble唤醒agent失败', e.details);
} }
} }

603
local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt

@ -4,10 +4,14 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.provider.Settings
import android.text.TextUtils import android.text.TextUtils
import android.util.TypedValue import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
@ -19,10 +23,8 @@ import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/// 悬浮窗口服务
class FloatingWindowService : Service() { class FloatingWindowService : Service() {
companion object { companion object {
const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT" const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT"
@ -51,11 +53,27 @@ class FloatingWindowService : Service() {
private var sourceTextView: TextView? = null private var sourceTextView: TextView? = null
private var translatedTextView: TextView? = null private var translatedTextView: TextView? = null
private var sourceLoadingView: TextView? = null
private var translatedLoadingView: TextView? = null
private var languageInfoView: TextView? = null private var languageInfoView: TextView? = null
private var timestampView: TextView? = null
private var minimizedView: View? = null private var minimizedView: View? = null
private var expandedView: View? = null private var expandedView: View? = null
private var minimizedTextView: TextView? = null
private var closeButton: TextView? = null private var closeButton: TextView? = null
private var expandedContainer: ViewGroup? = null
private var expandedContentLayout: LinearLayout? = null
private var resizeHandleView: View? = null
private var baseExpandedWidth: Int = 0
private var lastEffectiveWidth: Int = 0
private var lastEffectiveHeight: Int = 0
private var uiScale: Float = 1.0f
private val backgroundBaseColor: Int = Color.argb(150, 40, 40, 40)
private val uiHandler: Handler = Handler(Looper.getMainLooper())
private var sourceDotsStep: Int = 0
private var translatedDotsStep: Int = 0
private var sourceDotsRunnable: Runnable? = null
private var translatedDotsRunnable: Runnable? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -76,14 +94,37 @@ class FloatingWindowService : Service() {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() { override fun onDestroy() {
stopAllLoading()
uiHandler.removeCallbacksAndMessages(null)
removeFloatingWindow() removeFloatingWindow()
sourceTextView = null
translatedTextView = null
sourceLoadingView = null
translatedLoadingView = null
languageInfoView = null
minimizedView = null
expandedView = null
minimizedTextView = null
closeButton = null
resizeHandleView = null
expandedContentLayout = null
expandedContainer = null
layoutParams = null
windowManager = null
serviceInstance = null serviceInstance = null
methodChannel = null
super.onDestroy() super.onDestroy()
} }
private fun createFloatingWindow() { private fun createFloatingWindow() {
windowManager = getSystemService(WINDOW_SERVICE) as? WindowManager ?: return windowManager = getSystemService(WINDOW_SERVICE) as? WindowManager ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) {
notifyStateChanged("hidden")
stopSelf()
return
}
floatingView = createFloatingLayout() floatingView = createFloatingLayout()
initViews() initViews()
setupClickListeners() setupClickListeners()
@ -108,7 +149,16 @@ class FloatingWindowService : Service() {
y = dp(100) y = dp(100)
} }
windowManager?.addView(floatingView, layoutParams) try {
windowManager?.addView(floatingView, layoutParams)
} catch (_: SecurityException) {
notifyStateChanged("hidden")
stopSelf()
return
} catch (_: Exception) {
stopSelf()
return
}
updateViewState(true) updateViewState(true)
} }
@ -129,52 +179,97 @@ class FloatingWindowService : Service() {
} }
private fun createMinimizedView(): View { private fun createMinimizedView(): View {
val container = FrameLayout(this) val container = LinearLayout(this).apply {
val lp = FrameLayout.LayoutParams(dp(60), dp(60)) orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
val lp = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(48))
val margin = dp(8) val margin = dp(8)
lp.setMargins(margin, margin, margin, margin) lp.setMargins(margin, margin, margin, margin)
container.layoutParams = lp container.layoutParams = lp
container.setPadding(dp(10), dp(8), dp(12), dp(8))
container.minimumWidth = dp(160)
val bg = GradientDrawable().apply { val bg = GradientDrawable().apply {
setColor(0xFFE3F2FD.toInt()) setColor(backgroundBaseColor)
cornerRadius = dp(30).toFloat() cornerRadius = dp(24).toFloat()
setStroke(dp(1), Color.argb(46, 255, 255, 255))
} }
container.background = bg container.background = bg
container.elevation = dp(8).toFloat() container.elevation = dp(10).toFloat()
val iconBg = GradientDrawable().apply {
setColor(Color.argb(42, 255, 255, 255))
cornerRadius = dp(14).toFloat()
}
val icon = TextView(this).apply { val icon = TextView(this).apply {
text = "译" text = "V"
setTextColor(0xFF1976D2.toInt()) setTextColor(0xFFFFFFFF.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
gravity = Gravity.CENTER gravity = Gravity.CENTER
typeface = Typeface.DEFAULT_BOLD typeface = Typeface.DEFAULT_BOLD
background = iconBg
} }
container.addView(
icon, val iconLp = LinearLayout.LayoutParams(dp(28), dp(28))
FrameLayout.LayoutParams( container.addView(icon, iconLp)
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT minimizedTextView = TextView(this).apply {
) text = "Voitrans"
) setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
}
val textLp = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply {
marginStart = dp(10)
}
container.addView(minimizedTextView, textLp)
val chevron = TextView(this).apply {
text = "›"
setTextColor(Color.argb(150, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
gravity = Gravity.CENTER
setPadding(dp(6), 0, 0, 0)
}
container.addView(chevron)
return container return container
} }
private fun createExpandedView(): View { private fun createExpandedView(): View {
val container = LinearLayout(this) val root = FrameLayout(this)
val lp = FrameLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT) val maxWidth = dp(360)
val minWidth = dp(280)
val horizontalMargins = dp(8) * 2
val screenWidth = resources.displayMetrics.widthPixels
val targetWidth = (screenWidth - horizontalMargins).coerceIn(minWidth, maxWidth)
baseExpandedWidth = targetWidth
val lp = FrameLayout.LayoutParams(targetWidth, ViewGroup.LayoutParams.WRAP_CONTENT)
val margin = dp(8) val margin = dp(8)
lp.setMargins(margin, margin, margin, margin) lp.setMargins(margin, margin, margin, margin)
container.layoutParams = lp root.layoutParams = lp
container.orientation = LinearLayout.VERTICAL root.setPadding(0, 0, 0, 0)
container.setPadding(dp(16), dp(16), dp(16), dp(16)) root.visibility = View.GONE
container.visibility = View.GONE root.elevation = dp(12).toFloat()
container.elevation = dp(12).toFloat() expandedContainer = root
val bg = GradientDrawable().apply { val bg = GradientDrawable().apply {
setColor(Color.WHITE) setColor(backgroundBaseColor)
cornerRadius = dp(16).toFloat() cornerRadius = dp(16).toFloat()
setStroke(dp(1), Color.argb(46, 255, 255, 255))
} }
container.background = bg root.background = bg
val contentLayout = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(16), dp(16), dp(16), dp(16))
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
expandedContentLayout = contentLayout
val titleRow = LinearLayout(this).apply { val titleRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
@ -185,63 +280,169 @@ class FloatingWindowService : Service() {
) )
} }
val title = TextView(this).apply {
text = "实时翻译"
setTextColor(0xFF333333.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
typeface = Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
closeButton = TextView(this).apply { closeButton = TextView(this).apply {
text = "✕" text = "✕"
setTextColor(0xFF666666.toInt()) setTextColor(Color.argb(210, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
gravity = Gravity.CENTER gravity = Gravity.CENTER
setPadding(dp(8), dp(8), dp(8), dp(8)) setPadding(dp(8), dp(8), dp(8), dp(8))
} }
titleRow.addView(title)
titleRow.addView(closeButton)
languageInfoView = TextView(this).apply { languageInfoView = TextView(this).apply {
text = "中文 → 英文" text = "src → tgt"
setTextColor(0xFF666666.toInt()) setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setPadding(0, dp(8), 0, dp(8)) setPadding(dp(10), dp(6), dp(10), dp(6))
}
val langBg = GradientDrawable().apply {
setColor(Color.argb(36, 255, 255, 255))
cornerRadius = dp(10).toFloat()
} }
languageInfoView?.background = langBg
val languageInRowLp = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
sourceTextView = TextView(this).apply { sourceTextView = TextView(this).apply {
text = "正在识别..." text = ""
setTextColor(0xFF333333.toInt()) setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setPadding(0, dp(8), 0, dp(4)) setPadding(0, dp(8), 0, dp(4))
maxLines = 3 maxLines = 3
ellipsize = TextUtils.TruncateAt.END ellipsize = TextUtils.TruncateAt.END
setLineSpacing(dpF(2f), 1.1f)
visibility = View.GONE
} }
translatedTextView = TextView(this).apply { translatedTextView = TextView(this).apply {
text = "翻译中..." text = ""
setTextColor(0xFF1976D2.toInt()) setTextColor(0xFF90CAF9.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
setPadding(0, dp(4), 0, dp(8)) setPadding(0, dp(4), 0, dp(8))
maxLines = 3 maxLines = 3
ellipsize = TextUtils.TruncateAt.END ellipsize = TextUtils.TruncateAt.END
setLineSpacing(dpF(2f), 1.1f)
visibility = View.GONE
} }
timestampView = TextView(this).apply { sourceLoadingView = TextView(this).apply {
text = "" text = ""
setTextColor(0xFF999999.toInt()) setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f) setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
gravity = Gravity.END setPadding(0, dp(8), 0, dp(4))
visibility = View.GONE
} }
container.addView(titleRow) translatedLoadingView = TextView(this).apply {
container.addView(languageInfoView) text = ""
container.addView(sourceTextView) setTextColor(0xFF90CAF9.toInt())
container.addView(translatedTextView) setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
container.addView(timestampView) setPadding(0, dp(4), 0, dp(8))
return container visibility = View.GONE
}
val spacer = View(this)
val spacerLp = LinearLayout.LayoutParams(0, 0, 1f)
titleRow.addView(languageInfoView, languageInRowLp)
titleRow.addView(spacer, spacerLp)
titleRow.addView(closeButton)
contentLayout.addView(titleRow)
contentLayout.addView(sourceTextView)
contentLayout.addView(sourceLoadingView)
contentLayout.addView(translatedTextView)
contentLayout.addView(translatedLoadingView)
root.addView(contentLayout)
val handleSize = dp(30)
val resizeHandle = TextView(this).apply {
text = "⤢"
setTextColor(Color.argb(190, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
gravity = Gravity.CENTER
background = null
setPadding(0, 0, 0, 0)
scaleX = -1f
}
resizeHandleView = resizeHandle
val handleLp = FrameLayout.LayoutParams(handleSize, handleSize, Gravity.BOTTOM or Gravity.END).apply {
setMargins(0, 0, 0, 0)
}
root.addView(resizeHandle, handleLp)
resizeHandle.setOnTouchListener(object : View.OnTouchListener {
private var downRawX: Float = 0f
private var downRawY: Float = 0f
private var startWidth: Int = 0
private var startHeight: Int = 0
override fun onTouch(v: View, event: MotionEvent): Boolean {
val expanded = expandedContainer ?: return false
val rootLp = (expanded.layoutParams as? FrameLayout.LayoutParams) ?: return false
val size = v.width.takeIf { it > 0 } ?: return false
val localX = event.x
val localY = event.y
if (!isInBottomRightTriangle(localX, localY, size.toFloat())) {
return false
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downRawX = event.rawX
downRawY = event.rawY
startWidth = if (rootLp.width > 0) rootLp.width else expanded.width.takeIf { it > 0 } ?: baseExpandedWidth
val resolvedHeight = if (rootLp.height > 0 && rootLp.height != ViewGroup.LayoutParams.WRAP_CONTENT) {
rootLp.height
} else {
expanded.height.takeIf { it > 0 } ?: dp(220)
}
rootLp.height = resolvedHeight
expanded.layoutParams = rootLp
startHeight = resolvedHeight
baseExpandedWidth = startWidth
return true
}
MotionEvent.ACTION_MOVE -> {
val dx = (event.rawX - downRawX).toInt()
val dy = (event.rawY - downRawY).toInt()
val minW = dp(240)
val maxW = (resources.displayMetrics.widthPixels - dp(8) * 2).coerceAtLeast(minW)
val minH = dp(140)
val maxH = (resources.displayMetrics.heightPixels - dp(12) * 2).coerceAtLeast(minH)
val nextW = (startWidth + dx).coerceIn(minW, maxW)
val nextH = (startHeight + dy).coerceIn(minH, maxH)
if (rootLp.width != nextW || rootLp.height != nextH) {
rootLp.width = nextW
rootLp.height = nextH
expanded.layoutParams = rootLp
baseExpandedWidth = nextW
floatingView?.post {
val wm = windowManager ?: return@post
val floatingLp = layoutParams ?: return@post
val (effectiveWidth, effectiveHeight) = getEffectiveFloatingSize()
val maxX = resources.displayMetrics.widthPixels - effectiveWidth
val maxY = resources.displayMetrics.heightPixels - effectiveHeight
if (maxX > 0) floatingLp.x = floatingLp.x.coerceIn(0, maxX)
if (maxY > 0) floatingLp.y = floatingLp.y.coerceIn(0, maxY)
wm.updateViewLayout(floatingView, floatingLp)
}
}
return true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> return true
else -> return false
}
}
})
applyUiScale(uiScale)
return root
} }
private fun initViews() { private fun initViews() {
@ -270,30 +471,79 @@ class FloatingWindowService : Service() {
private var initialTouchX: Float = 0f private var initialTouchX: Float = 0f
private var initialTouchY: Float = 0f private var initialTouchY: Float = 0f
private var dragging: Boolean = false private var dragging: Boolean = false
private var scaling: Boolean = false
private var initialSpan: Float = 0f
private var initialScale: Float = 1.0f
override fun onTouch(v: View, event: MotionEvent): Boolean { override fun onTouch(v: View, event: MotionEvent): Boolean {
val lp = layoutParams ?: return false val lp = layoutParams ?: return false
val wm = windowManager ?: return false val wm = windowManager ?: return false
when (event.action) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
initialX = lp.x initialX = lp.x
initialY = lp.y initialY = lp.y
initialTouchX = event.rawX initialTouchX = event.rawX
initialTouchY = event.rawY initialTouchY = event.rawY
if (!isMinimized && isTouchingView(closeButton, event.rawX, event.rawY)
) {
return false
}
if (!isMinimized && isTouchingView(resizeHandleView, event.rawX, event.rawY)) {
return false
}
dragging = false dragging = false
scaling = false
return true
}
MotionEvent.ACTION_POINTER_DOWN -> {
if (!isMinimized && event.pointerCount >= 2) {
expandedContainer?.let { expanded ->
val expandedLp = expanded.layoutParams as? FrameLayout.LayoutParams
if (expandedLp != null && expandedLp.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
val lockedHeight = expanded.height.takeIf { it > 0 } ?: dp(220)
expandedLp.height = lockedHeight
expanded.layoutParams = expandedLp
}
val width = (expanded.layoutParams as? FrameLayout.LayoutParams)?.width ?: 0
if (width > 0) baseExpandedWidth = width
}
scaling = true
dragging = false
initialSpan = pointerSpan(event)
initialScale = uiScale
return true
}
return true return true
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
if (scaling && event.pointerCount >= 2) {
val span = pointerSpan(event)
if (initialSpan > 0f && span > 0f) {
val next = (initialScale * (span / initialSpan)).coerceIn(0.85f, 1.4f)
setUiScale(next)
val (effectiveWidth, effectiveHeight) = getEffectiveFloatingSize()
val maxX = resources.displayMetrics.widthPixels - effectiveWidth
val maxY = resources.displayMetrics.heightPixels - effectiveHeight
if (maxX > 0) lp.x = lp.x.coerceIn(0, maxX)
if (maxY > 0) lp.y = lp.y.coerceIn(0, maxY)
wm.updateViewLayout(floatingView, lp)
}
return true
}
val dx = (event.rawX - initialTouchX).toInt() val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt() val dy = (event.rawY - initialTouchY).toInt()
if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) { if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) {
dragging = true dragging = true
} }
val maxX = val (effectiveWidth, effectiveHeight) = getEffectiveFloatingSize()
resources.displayMetrics.widthPixels - (floatingView?.width ?: 0) val maxX = resources.displayMetrics.widthPixels - effectiveWidth
val maxY = val maxY = resources.displayMetrics.heightPixels - effectiveHeight
resources.displayMetrics.heightPixels - (floatingView?.height ?: 0)
var nextX = initialX + dx var nextX = initialX + dx
var nextY = initialY + dy var nextY = initialY + dy
if (maxX > 0) nextX = nextX.coerceIn(0, maxX) if (maxX > 0) nextX = nextX.coerceIn(0, maxX)
@ -304,12 +554,21 @@ class FloatingWindowService : Service() {
return true return true
} }
MotionEvent.ACTION_POINTER_UP -> {
val remainingPointers = event.pointerCount - 1
if (scaling && remainingPointers < 2) {
scaling = false
return true
}
return false
}
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
if (!dragging) updateViewState(!isMinimized) if (!dragging && !scaling) updateViewState(!isMinimized)
dragging = false dragging = false
scaling = false
return true return true
} }
else -> return false else -> return false
} }
} }
@ -320,6 +579,13 @@ class FloatingWindowService : Service() {
isMinimized = minimized isMinimized = minimized
minimizedView?.visibility = if (minimized) View.VISIBLE else View.GONE minimizedView?.visibility = if (minimized) View.VISIBLE else View.GONE
expandedView?.visibility = if (minimized) View.GONE else View.VISIBLE expandedView?.visibility = if (minimized) View.GONE else View.VISIBLE
if (minimized) {
stopAllLoading()
minimizedView?.scaleX = 1.0f
minimizedView?.scaleY = 1.0f
}
notifyStateChanged(if (minimized) "minimized" else "expanded") notifyStateChanged(if (minimized) "minimized" else "expanded")
} }
@ -328,26 +594,58 @@ class FloatingWindowService : Service() {
val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT) val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT)
val sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE) val sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE)
val targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE) val targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE)
val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0L)
val isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false) val isIntermediate = intent.getBooleanExtra(EXTRA_IS_INTERMEDIATE, false)
val sourceValue = sourceText ?: ""
val translatedValue = translatedText ?: ""
val hasSource = !TextUtils.isEmpty(sourceValue)
val hasTranslated = !TextUtils.isEmpty(translatedValue)
languageInfoView?.let { view -> languageInfoView?.let { view ->
val langText = (sourceLang ?: "") + " → " + (targetLang ?: "") val langText = (sourceLang ?: "") + " → " + (targetLang ?: "")
view.text = langText view.text = langText
} }
sourceTextView?.let { view -> sourceTextView?.let { view ->
val value = sourceText ?: "" if (hasSource) {
view.text = if (TextUtils.isEmpty(value)) "正在识别..." else value view.text = sourceValue
view.visibility = View.VISIBLE
stopSourceLoading()
} else {
view.text = ""
view.visibility = View.GONE
if (isIntermediate) {
startSourceLoading()
} else {
stopSourceLoading()
}
}
view.alpha = if (isIntermediate) 0.85f else 1.0f
} }
translatedTextView?.let { view -> translatedTextView?.let { view ->
val value = translatedText ?: "" if (hasTranslated) {
view.text = if (TextUtils.isEmpty(value)) "翻译中..." else value view.text = translatedValue
view.visibility = View.VISIBLE
stopTranslatedLoading()
} else {
view.text = ""
view.visibility = View.GONE
if (hasSource || isIntermediate) {
startTranslatedLoading()
} else {
stopTranslatedLoading()
}
}
view.alpha = if (isIntermediate) 0.7f else 1.0f view.alpha = if (isIntermediate) 0.7f else 1.0f
} }
timestampView?.text = if (timestamp > 0) formatTimestamp(timestamp) else "" minimizedTextView?.let { view ->
val primary = translatedText?.takeIf { !TextUtils.isEmpty(it) }
val secondary = sourceText?.takeIf { !TextUtils.isEmpty(it) }
view.text = primary ?: secondary ?: "Voitrans"
view.alpha = if (isIntermediate) 0.85f else 1.0f
}
} }
private fun setPosition(intent: Intent) { private fun setPosition(intent: Intent) {
@ -386,18 +684,157 @@ class FloatingWindowService : Service() {
channel.invokeMethod("onFloatingWindowClicked", null) channel.invokeMethod("onFloatingWindowClicked", null)
} }
private fun formatTimestamp(ts: Long): String { private fun dp(value: Int): Int {
if (ts <= 0) return "" return (value * resources.displayMetrics.density).toInt()
return try { }
val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
sdf.format(Date(ts)) private fun dpF(value: Float): Float {
} catch (_: Exception) { return value * resources.displayMetrics.density
"" }
private fun setUiScale(next: Float) {
uiScale = next
applyUiScale(uiScale)
}
private fun applyUiScale(scale: Float) {
val container = expandedContainer ?: return
if (baseExpandedWidth <= 0) return
languageInfoView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f * scale)
languageInfoView?.setPadding(
(dp(10) * scale).toInt().coerceIn(dp(6), dp(16)),
(dp(6) * scale).toInt().coerceIn(dp(4), dp(12)),
(dp(10) * scale).toInt().coerceIn(dp(6), dp(16)),
(dp(6) * scale).toInt().coerceIn(dp(4), dp(12))
)
sourceTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale)
translatedTextView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale)
sourceLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f * scale)
translatedLoadingView?.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f * scale)
val closeTextSizeSp = (18f * scale).coerceIn(14f, 22f)
closeButton?.setTextSize(TypedValue.COMPLEX_UNIT_SP, closeTextSizeSp)
val closeHPad = (dp(8) * scale).toInt().coerceIn(dp(6), dp(16))
val closeVPad = (dp(8) * scale).toInt().coerceIn(dp(4), dp(12))
closeButton?.setPadding(closeHPad, closeVPad, closeHPad, closeVPad)
container.setPadding(0, 0, 0, 0)
val contentPadding = (dp(16) * scale).toInt().coerceIn(dp(12), dp(24))
expandedContentLayout?.setPadding(contentPadding, contentPadding, contentPadding, contentPadding)
val maxLines = if (scale >= 1.15f) 4 else 3
sourceTextView?.maxLines = maxLines
translatedTextView?.maxLines = maxLines
}
private fun getEffectiveFloatingSize(): Pair<Int, Int> {
val view = floatingView
if (view != null) {
val w = view.width
val h = view.height
if (w > 0 && h > 0) {
lastEffectiveWidth = w
lastEffectiveHeight = h
return w to h
}
} }
layoutParams?.let { lp ->
val w = lp.width
val h = lp.height
if (w > 0 && h > 0) {
lastEffectiveWidth = w
lastEffectiveHeight = h
return w to h
}
}
(expandedContainer?.layoutParams as? FrameLayout.LayoutParams)?.let { lp ->
val w = lp.width
val h = lp.height
if (w > 0 && h > 0) {
lastEffectiveWidth = w
lastEffectiveHeight = h
return w to h
}
}
if (lastEffectiveWidth <= 0) lastEffectiveWidth = dp(280)
if (lastEffectiveHeight <= 0) lastEffectiveHeight = dp(220)
return lastEffectiveWidth to lastEffectiveHeight
} }
private fun dp(value: Int): Int { private fun startSourceLoading() {
return (value * resources.displayMetrics.density).toInt() val view = sourceLoadingView ?: return
if (view.visibility == View.VISIBLE && sourceDotsRunnable != null) return
sourceDotsStep = 0
view.visibility = View.VISIBLE
val runnable = object : Runnable {
override fun run() {
val count = (sourceDotsStep % 3) + 1
view.text = "•".repeat(count)
sourceDotsStep++
uiHandler.postDelayed(this, 350L)
}
}
sourceDotsRunnable = runnable
uiHandler.post(runnable)
}
private fun stopSourceLoading() {
sourceDotsRunnable?.let { uiHandler.removeCallbacks(it) }
sourceDotsRunnable = null
sourceLoadingView?.visibility = View.GONE
sourceLoadingView?.text = ""
}
private fun startTranslatedLoading() {
val view = translatedLoadingView ?: return
if (view.visibility == View.VISIBLE && translatedDotsRunnable != null) return
translatedDotsStep = 0
view.visibility = View.VISIBLE
val runnable = object : Runnable {
override fun run() {
val count = (translatedDotsStep % 3) + 1
view.text = "•".repeat(count)
translatedDotsStep++
uiHandler.postDelayed(this, 350L)
}
}
translatedDotsRunnable = runnable
uiHandler.post(runnable)
}
private fun stopTranslatedLoading() {
translatedDotsRunnable?.let { uiHandler.removeCallbacks(it) }
translatedDotsRunnable = null
translatedLoadingView?.visibility = View.GONE
translatedLoadingView?.text = ""
}
private fun stopAllLoading() {
stopSourceLoading()
stopTranslatedLoading()
}
private fun isTouchingView(view: View?, x: Float, y: Float): Boolean {
if (view == null || view.visibility != View.VISIBLE) return false
val rect = Rect()
if (!view.getGlobalVisibleRect(rect)) return false
return rect.contains(x.toInt(), y.toInt())
} }
}
private fun pointerSpan(event: MotionEvent): Float {
if (event.pointerCount < 2) return 0f
val dx = event.getX(0) - event.getX(1)
val dy = event.getY(0) - event.getY(1)
return kotlin.math.sqrt(dx * dx + dy * dy)
}
private fun isInBottomRightTriangle(x: Float, y: Float, size: Float): Boolean {
if (x < 0f || y < 0f || x > size || y > size) return false
return x >= size - y
}
}

1
local_plugins/native_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt

@ -16,6 +16,7 @@ import android.view.WindowManager
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
/// 录音悬浮条服务
class RecordingFloatingService : Service() { class RecordingFloatingService : Service() {
companion object { companion object {
const val ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR" const val ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR"

Loading…
Cancel
Save