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() {
super.onDestroy()
floatingView?.let { windowManager?.removeView(it) }
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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'تطبيق على جميع فقرات هذا المتحدث',
'summaryWillBeUpdatedAutomatically':
'سيتم تحديث الملخص تلقائيًا بناءً على القالب',
'nameCurrentSpeaker': 'تسمية المتحدث الحالي',
'recentlyUsedNames': 'الأسماء المستخدمة مؤخرًا',
'applyToCurrentParagraph': 'تطبيق على الفقرة الحالية',

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

@ -619,6 +619,9 @@ const Map<String, String> bgBG = {
'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Приложи към всички абзаци на този говорител',
'summaryWillBeUpdatedAutomatically':
'Резюмето ще се обновява автоматично според шаблона',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Použít na všechny odstavce tohoto mluvčího',
'summaryWillBeUpdatedAutomatically':
'Souhrn se bude automaticky aktualizovat podle šablony',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Anvend på alle afsnit for denne taler',
'summaryWillBeUpdatedAutomatically':
'Resuméet opdateres automatisk ud fra skabelonen',
'nameCurrentSpeaker': 'Navngiv nuværende taler',
'recentlyUsedNames': 'Nyligt brugte navne',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Auf alle Absätze dieses Sprechers anwenden',
'summaryWillBeUpdatedAutomatically':
'Die Zusammenfassung wird basierend auf der Vorlage automatisch aktualisiert',
'nameCurrentSpeaker': 'Aktuellen Sprecher benennen',
'recentlyUsedNames': 'Zuletzt verwendete Namen',
'applyToCurrentParagraph': 'Auf aktuellen Absatz anwenden',
'totalTime': 'Gesamtzeit',
'totalTime': 'Gesamt',
'summarySpeakerWillBeUpdatedAutomatically':
'Zusammenfassungssprecher werden automatisch aktualisiert',
'Sprecher in der Zusammenfassung werden automatisch aktualisiert',
// Meeting模块 - 云端同步相关
'syncToCloud': 'Mit Cloud synchronisieren',

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

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

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

@ -443,6 +443,11 @@ const Map<String, String> enUS = {
'meetingRecordEndWithoutSave': 'End recording without saving', // 结束录音不保存
'meetingRecordContinueInBackground':
'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', // 录音进行中
'meetingRecordRecordingNotificationBody':
'Recording in background. Tap to return to the app.', // 正在后台录音,点击返回应用
@ -626,6 +631,9 @@ const Map<String, String> enUS = {
'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Apply to All Paragraphs of This Speaker',
'summaryWillBeUpdatedAutomatically':
'Summary will be updated automatically based on the template',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// 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',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Rakenda selle kõneleja kõigile lõikudele',
'summaryWillBeUpdatedAutomatically':
'Kokkuvõtet uuendatakse automaatselt malli alusel',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph',

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

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

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

@ -612,6 +612,9 @@ const Map<String, String> fiFI = {
'exportingPDF': 'Exporting PDF...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Käytä kaikkiin tämän puhujan kappaleisiin',
'summaryWillBeUpdatedAutomatically':
'Yhteenveto päivittyy automaattisesti mallin perusteella',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// 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',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'इस वक्ता के सभी अनुच्छेदों पर लागू करें',
'summaryWillBeUpdatedAutomatically':
'टेम्पलेट के आधार पर सारांश स्वतः अपडेट होगा',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Primijeni na sve odlomke ovog govornika',
'summaryWillBeUpdatedAutomatically':
'Sažetak će se automatski ažurirati prema predlošku',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// 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',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Terapkan ke semua paragraf pembicara ini',
'summaryWillBeUpdatedAutomatically':
'Ringkasan akan diperbarui otomatis berdasarkan template',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// 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',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Applica a tutti i paragrafi di questo relatore',
'summaryWillBeUpdatedAutomatically':
'Il riepilogo verrà aggiornato automaticamente in base al modello',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'この話者のすべての段落に適用',
'summaryWillBeUpdatedAutomatically':
'要約はテンプレートに基づいて自動的に更新されます',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': '이 발언자의 모든 문단에 적용',
'summaryWillBeUpdatedAutomatically': '요약은 템플릿에 따라 자동으로 업데이트됩니다',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Taikyti visoms šio kalbėtojo pastraipoms',
'summaryWillBeUpdatedAutomatically':
'Santrauka bus automatiškai atnaujinama pagal šabloną',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// 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',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Guna pada semua perenggan penutur ini',
'summaryWillBeUpdatedAutomatically':
'Ringkasan akan dikemas kini secara automatik berdasarkan templat',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Bruk på alle avsnitt for denne taleren',
'summaryWillBeUpdatedAutomatically':
'Sammendraget oppdateres automatisk basert på malen',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// 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',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Zastosuj do wszystkich akapitów tego mówcy',
'summaryWillBeUpdatedAutomatically':
'Podsumowanie będzie automatycznie aktualizowane na podstawie szablonu',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Aplicar a todos os parágrafos deste orador',
'summaryWillBeUpdatedAutomatically':
'O resumo será atualizado automaticamente com base no modelo',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Aplică la toate paragrafele acestui vorbitor',
'summaryWillBeUpdatedAutomatically':
'Rezumatul va fi actualizat automat pe baza șablonului',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Применить ко всем абзацам этого говорящего',
'summaryWillBeUpdatedAutomatically':
'Сводка будет автоматически обновляться на основе шаблона',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// 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',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Uporabi za vse odstavke tega govorca',
'summaryWillBeUpdatedAutomatically':
'Povzetek se bo samodejno posodabljal glede na predlogo',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Primeni na sve pasuse ovog govornika',
'summaryWillBeUpdatedAutomatically':
'Sažetak će se automatski ažurirati prema šablonu',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Använd på alla stycken för denna talare',
'summaryWillBeUpdatedAutomatically':
'Sammanfattningen uppdateras automatiskt baserat på mallen',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'นำไปใช้กับทุกย่อหน้าของผู้พูดคนนี้',
'summaryWillBeUpdatedAutomatically': 'สรุปจะอัปเดตอัตโนมัติตามเทมเพลต',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Ilapat sa lahat ng talata ng tagapagsalitang ito',
'summaryWillBeUpdatedAutomatically':
'Awtomatikong maa-update ang buod batay sa template',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// 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',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// Meeting模块 - 发言者相关
'applyToAllParagraphs': 'Застосувати до всіх абзаців цього мовця',
'summaryWillBeUpdatedAutomatically':
'Підсумок буде автоматично оновлюватися на основі шаблону',
'nameCurrentSpeaker': 'Name Current Speaker',
'recentlyUsedNames': 'Recently Used Names',
'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...',
// 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',
'recentlyUsedNames': 'Recently Used Names',
'applyToCurrentParagraph': 'Apply to Current Paragraph',

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

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

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

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

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

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

52
lib/core/utils/app_lifecycle_manager.dart

@ -1,6 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:agent_service/agent_service.dart';
import 'package:get/get.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 '../translations/app_translations.dart';
//import '../../modules/settings/controllers/settings_controller.dart';
@ -23,17 +28,19 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver {
final RxBool _isInForeground = true.obs;
bool get isInForeground => _isInForeground.value;
bool? _lastCanWakeUp;
@override
void onInit() {
super.onInit();
// 注册应用生命周期观察者
WidgetsBinding.instance.addObserver(this);
Logger.info('AppLifecycleManager 已初始化');
unawaited(refreshBleWakeupPolicy());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final previousState = _appLifecycleState.value;
_appLifecycleState.value = state;
_lifecycleStateStream.value = state;
@ -44,6 +51,7 @@ class AppLifecycleManager extends GetxService with WidgetsBindingObserver {
case AppLifecycleState.resumed:
_isInForeground.value = true;
_handleAppResumed();
unawaited(refreshBleWakeupPolicy());
break;
case AppLifecycleState.paused:
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() {
// Logger.info('应用进入前台');

83
lib/core/utils/overlay_permission_util.dart

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

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

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

11
lib/main.dart

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.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_screenutil/flutter_screenutil.dart';
import 'core/utils/app_lifecycle_manager.dart';
import 'routes/app_pages.dart';
import 'routes/app_routes.dart';
import 'core/translations/app_translations.dart';
@ -127,6 +130,14 @@ class MainApp extends StatelessWidget {
initialBinding: InitialBinding(),
initialRoute: Routes.splash,
getPages: AppPages.pages,
routingCallback: (routing) {
if (!Get.isRegistered<AppLifecycleManager>()) return;
unawaited(
Get.find<AppLifecycleManager>().refreshBleWakeupPolicy(
route: routing?.current,
),
);
},
debugShowCheckedModeBanner: false,
translations: AppTranslations(),
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/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart';
import '../../meeting/controllers/meeting_record_controller.dart';
/// 连接状态枚举
enum ConnectionStatus {
@ -241,13 +240,9 @@ class HomeController extends GetxController {
Get.toNamed(Routes.pairing);
}
void openTranslation() async {
void openTranslation() {
AgentService.interruptCurrentResponse();
AgentService.setCanWakeUp(false);
Logger.d(_tag, 'liwei-----------关闭唤醒功能');
await Get.toNamed(Routes.translationMiddleware);
AgentService.setCanWakeUp(true);
Logger.d(_tag, 'liwei-----------打开唤醒功能');
Get.toNamed(Routes.translationMiddleware);
}
/// 打开会议模块
@ -255,31 +250,9 @@ class HomeController extends GetxController {
/// 返回值:`Future<void>` 无返回数据
/// 异常:不显式抛出;内部仅记录日志
/// 说明:进入会议页前关闭唤醒;返回首页后根据录音状态决定是否开启唤醒(存在录音任务则不启用)
void openMeet() async {
void openMeet() {
AgentService.interruptCurrentResponse();
AgentService.setCanWakeUp(false);
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');
}
Get.toNamed(Routes.meeting);
}
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/network/api.dart';
import '../../../routes/app_routes.dart';
import 'meeting_record_controller.dart';
import 'meeting_controller.dart';
// 会议首页控制器
@ -32,6 +33,8 @@ class MeetingHomeController extends GetxController {
final AudioPlayer _audioPlayer = AudioPlayer();
bool _isImportingLocalRecordings = false;
List<String> selected = [];
final appDocDir = getApplicationDocumentsDirectory();
@ -49,6 +52,7 @@ class MeetingHomeController extends GetxController {
final bleManager = Get.find<BleManager>();
late final Worker _updateListStateListener;
late final Worker _localImportListener;
@override
void onInit() {
@ -69,6 +73,9 @@ class MeetingHomeController extends GetxController {
ever(meetingTaskService.updateListState, (value) async {
_getDataList();
});
_localImportListener = ever(meetingTaskService.localImportState, (value) {
addMeetingAudio();
});
}
@override
@ -77,6 +84,7 @@ class MeetingHomeController extends GetxController {
filtrateController.dispose();
_audioPlayer.dispose();
_updateListStateListener.dispose();
_localImportListener.dispose();
super.onClose();
}
@ -188,12 +196,15 @@ class MeetingHomeController extends GetxController {
}
}
// 核心方法:导入本地录音文件到统一目录
// 核心方法:导入本地录音文件到统一目录
void addMeetingAudio() async {
if (_isImportingLocalRecordings) return;
_isImportingLocalRecordings = true;
EasyLoading.show(status: 'importingLocalRecordings'.tr); // 对应中文:正在导入本地录音...
try {
int addUp = 0; // 新增计数
final appDir = await appDocDir; // 获取应用文档目录
final activeRecordingWavName = _getActiveRecordingWavFileName();
final sourceDirs = [
Directory("${appDir.path}/MeetingAudio/"), // 会议录音目录
@ -221,16 +232,31 @@ class MeetingHomeController extends GetxController {
// 遍历所有文件实体
for (final fileEntity in allFiles) {
try {
final entityName = path.basename(fileEntity.path);
if (activeRecordingWavName != null &&
entityName == activeRecordingWavName) {
continue;
}
final file = File(fileEntity.path); //将文件实体转换为具体文件操作对象
final filePath = file.path;
final fileStat = await file.stat(); //获取文件状态信息,包含修改时间等
if (fileStat.size < 1024) {
continue;
}
// 使用just_audio库加载音频文件以获取时长
await _audioPlayer.setAudioSource(AudioSource.file(filePath));
int seconds =
_audioPlayer.duration?.inSeconds ?? 0; //获取音频时长(秒),若为空则设为0
final Duration? duration =
await _audioPlayer.setAudioSource(AudioSource.file(filePath));
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;
// 准备新路径
@ -303,10 +329,20 @@ class MeetingHomeController extends GetxController {
.tr
.replaceAll('{error}', e.toString())); // 对应中文:导入失败: {error}
} finally {
_isImportingLocalRecordings = false;
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)
Future<String> _archiveTranslatToLocal(String srcPath) async {
// 获取应用文档目录并确保 LocalAudio 目录存在
@ -324,7 +360,7 @@ class MeetingHomeController extends GetxController {
if (await File(destPath).exists()) {
final ts = DateTime.now().millisecondsSinceEpoch;
destPath = path.join(destDir.path, '$baseName\_$ts$ext');
destPath = path.join(destDir.path, '${baseName}_$ts$ext');
}
// 中文注释:尝试 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 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:intl/intl.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 '../../../data/services/asr_service.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/services/meeting/meeting_task_service.dart';
/// 会议记录状态枚举
enum TimerStatus {
@ -28,14 +30,25 @@ enum TimerStatus {
/// 会议记录控制器
/// 负责会议记录的状态管理、音频处理、语音识别和UI交互
class MeetingRecordController extends GetxController
with GetTickerProviderStateMixin {
with GetTickerProviderStateMixin, WidgetsBindingObserver {
static const int _backgroundRecordingNotificationId = 91001;
static const Duration _stopRecordTimeout = Duration(seconds: 12);
static const Duration _fileOpTimeout = Duration(seconds: 8);
static FlutterLocalNotificationsPlugin? _localNotificationsPlugin;
final NativePlugin _nativePlugin = NativePlugin();
Timer? _recordingFloatingBarTimer;
Timer? _routeMonitorTimer;
StreamSubscription<String>? _recordingFloatingActionSubscription;
bool _floatingBarManuallyClosed = false;
bool _overlayPermissionPromptedThisSession = false;
bool _overlayPermissionSnackShownThisSession = false;
bool _overlayPermissionRequesting = false;
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
String _lastKnownRoute = '';
OverlayEntry? _recordingInAppOverlay;
Offset? _recordingInAppOverlayOffset;
Future<FlutterLocalNotificationsPlugin>
_ensureLocalNotificationsPlugin() async {
@ -76,8 +89,15 @@ class MeetingRecordController extends GetxController
await _requestNotificationPermissionIfNeeded();
if (!Platform.isIOS) return;
if (_floatingBarManuallyClosed) return;
if (_appLifecycleState == AppLifecycleState.resumed) {
await _showInAppRecordingOverlay();
return;
}
_hideInAppRecordingOverlay();
final plugin = await _ensureLocalNotificationsPlugin();
final details = NotificationDetails(
const details = NotificationDetails(
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
@ -102,6 +122,8 @@ class MeetingRecordController extends GetxController
if (!Platform.isIOS) return;
final plugin = await _ensureLocalNotificationsPlugin();
await plugin.cancel(_backgroundRecordingNotificationId);
_floatingBarManuallyClosed = false;
_hideInAppRecordingOverlay();
}
String _formatTimeForFloatingBar(int ms) {
@ -133,28 +155,30 @@ class MeetingRecordController extends GetxController
if (!isRecording.value) return;
if (_floatingBarManuallyClosed) return;
final hasPermission = await OverlayPermissionUtil.hasOverlayPermission();
if (!hasPermission) {
final granted = await OverlayPermissionUtil.requestOverlayPermission(
scene: OverlayPermissionScene.recordingFloatingBar,
);
if (!granted) return;
}
final granted = await _ensureAndroidOverlayPermissionForFloatingBar();
if (!granted) return;
final typeText = _getRecordingTypeText();
final durationText = _formatTimeForFloatingBar(milliseconds.value);
await _nativePlugin.showRecordingFloatingBar(
duration: durationText,
recordingType: typeText,
isPaused: timerStatus.value == TimerStatus.paused,
);
try {
await _nativePlugin
.showRecordingFloatingBar(
duration: durationText,
recordingType: typeText,
isPaused: timerStatus.value == TimerStatus.paused,
)
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('显示录音悬浮条失败: ${e.toString()}');
return;
}
_recordingFloatingBarTimer?.cancel();
_recordingFloatingBarTimer =
Timer.periodic(const Duration(seconds: 1), (_) {
if (!isRecording.value) {
_stopAndroidRecordingFloatingBar();
unawaited(_stopAndroidRecordingFloatingBar());
return;
}
_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 {
if (!Platform.isAndroid) return;
_recordingFloatingBarTimer?.cancel();
_recordingFloatingBarTimer = null;
await _nativePlugin.hideRecordingFloatingBar();
try {
await _nativePlugin
.hideRecordingFloatingBar()
.timeout(const Duration(seconds: 3));
} catch (e) {
Logger.error('隐藏录音悬浮条失败: ${e.toString()}');
}
}
Future<void> togglePauseResumeFromFloatingBar() async {
@ -202,6 +280,13 @@ class MeetingRecordController extends GetxController
);
return;
case 'closeFloatingBar':
if (isRecording.value) {
final saved = await saveRecording();
if (saved && Get.isRegistered<MeetingTaskService>()) {
Get.find<MeetingTaskService>().requestLocalImport();
}
return;
}
_floatingBarManuallyClosed = true;
await _stopAndroidRecordingFloatingBar();
return;
@ -226,6 +311,7 @@ class MeetingRecordController extends GetxController
final RxBool isMarked = false.obs;
final RxBool showTextContent = false.obs;
final RxBool isRecording = false.obs;
final RxBool isSaving = false.obs;
//final RxBool isVoiceRecognitionActive = false.obs;
final RxDouble rms = 0.0.obs;
@ -262,15 +348,23 @@ class MeetingRecordController extends GetxController
_initializeComponents();
_setupFromArguments();
_setupRecordingFloatingBarActionListener();
WidgetsBinding.instance.addObserver(this);
//generateWaveData();
}
@override
void onClose() {
_cleanupResources();
WidgetsBinding.instance.removeObserver(this);
super.onClose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_appLifecycleState = state;
unawaited(_refreshRecordingIndicator());
}
void applyRouteArgumentsIfIdle() {
if (isEditingTitle.value) return;
if (isRecording.value) return;
@ -332,9 +426,12 @@ class MeetingRecordController extends GetxController
/// 控制器销毁时清理资源
void _cleanupResources() {
_timer?.cancel();
_stopAndroidRecordingFloatingBar();
unawaited(_stopAndroidRecordingFloatingBar());
_routeMonitorTimer?.cancel();
_routeMonitorTimer = null;
_recordingFloatingActionSubscription?.cancel();
_recordingFloatingActionSubscription = null;
_hideInAppRecordingOverlay();
_stopWaveformTimer();
_recognitionSubscription?.cancel();
_recognitionSubscription = null;
@ -462,6 +559,9 @@ class MeetingRecordController extends GetxController
});
isRecording.value = true;
_overlayPermissionPromptedThisSession = false;
_overlayPermissionSnackShownThisSession = false;
_overlayPermissionRequesting = false;
// 更新录音状态
fileName.value = fullFileName;
newName.value = fullFileName;
@ -472,6 +572,7 @@ class MeetingRecordController extends GetxController
_resetWaveform();
_startWaveformTimer();
startTimer();
await _refreshRecordingIndicator();
}
/// 根据音频类型启动对应的硬件服务
@ -497,6 +598,7 @@ class MeetingRecordController extends GetxController
_stopWaveformTimer();
rms.value = 0.0;
pauseTimer();
await _refreshRecordingIndicator();
}
/// 恢复已暂停的录音会话
@ -504,6 +606,7 @@ class MeetingRecordController extends GetxController
await _asrService.resumeRecord();
_startWaveformTimer();
startTimer();
await _refreshRecordingIndicator();
}
/// 处理原始Uint8List音频数据
@ -573,60 +676,152 @@ class MeetingRecordController extends GetxController
}
/// 保存录音并清理相关资源
Future<void> saveRecording() async {
resetTimer();
Future<bool> saveRecording() async {
if (isSaving.value) return false;
if (!isRecording.value) return true;
isSaving.value = true;
final prevStatus = timerStatus.value;
pauseTimer();
final prevElapsedMs = _elapsedMilliseconds;
_stopWaveformTimer();
if (audioType.value != 0) {
_bleManager.closeCodec();
}
if (!isRecording.value) return;
await _asrService.stopRecord(true);
await clearBackgroundRecordingIndicator();
// 如果标题被修改则重命名文件
if (fileName.value != newName.value) {
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,
);
try {
final bool stopped = await _asrService
.stopRecord(true)
.timeout(_stopRecordTimeout, onTimeout: () => false);
if (!stopped) {
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr);
return false;
}
}
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
if (audioType.value != 0) {
_bleManager.closeCodec();
}
try {
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 {
resetTimer();
Future<bool> discardRecording() async {
if (!isRecording.value) return true;
if (isSaving.value) return false;
isSaving.value = true;
final prevStatus = timerStatus.value;
pauseTimer();
final prevElapsedMs = _elapsedMilliseconds;
_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();
_resetToDefaultState();
isRecording.value = false;
rms.value = 0.0;
_smoothedRms = 0.0;
_latestRms = 0.0;
_resetWaveform();
_startWaveformTimer();
}
Future<void> _applyTitleChangeAfterSave() async {
if (fileName.value == newName.value) return;
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",
)
.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;
_lastStartTime = null;
seconds.value = 0;
milliseconds.value = 0;
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,
onTap: () async {
Get.back();
final result = await Get.toNamed(Routes.meetingRecord, arguments: {
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 0,
});
if (result != 'meeting_record_background') {
controller.addMeetingAudio();
}
},
),
if (controller.meetingController.type == 1)
@ -47,12 +44,9 @@ class NavigationBarBottomSheet extends GetView<MeetingHomeController> {
onTap: controller.bleManager.isConnected
? () async {
Get.back();
final result = await Get.toNamed(Routes.meetingRecord, arguments: {
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 1,
});
if (result != 'meeting_record_background') {
controller.addMeetingAudio();
}
}
: null,
),
@ -65,12 +59,9 @@ class NavigationBarBottomSheet extends GetView<MeetingHomeController> {
onTap: controller.bleManager.isConnected
? () async {
Get.back();
final result = await Get.toNamed(Routes.meetingRecord, arguments: {
await Get.toNamed(Routes.meetingRecord, arguments: {
'audioTypes': 2,
});
if (result != 'meeting_record_background') {
controller.addMeetingAudio();
}
}
: 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 '../controllers/meeting_record_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../data/services/meeting/meeting_task_service.dart';
/// 会议录音退出操作
enum _MeetingRecordExitAction {
saveAndEnd,
endWithoutSave,
@ -13,9 +15,15 @@ enum _MeetingRecordExitAction {
cancel,
}
/// 会议录音页面
class MeetingRecordView extends GetView<MeetingRecordController> {
const MeetingRecordView({super.key});
void _notifyMeetingLocalImport() {
if (!Get.isRegistered<MeetingTaskService>()) return;
Get.find<MeetingTaskService>().requestLocalImport();
}
void _deleteControllerAfterPop() {
Future.delayed(const Duration(milliseconds: 400), () {
if (Get.isRegistered<MeetingRecordController>()) {
@ -24,6 +32,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
});
}
/// 显示退出确认弹窗
Future<_MeetingRecordExitAction?> _showExitConfirmDialog(bool isDarkMode) {
return Get.dialog<_MeetingRecordExitAction>(
Dialog(
@ -58,7 +67,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
SizedBox(height: 14.h),
Text(
'meetingRecordExitTitle'.tr, // 对应中文:退出录音
'meetingRecordExitTitle'.tr, // 退出录音
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
@ -67,7 +76,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
SizedBox(height: 8.h),
Text(
'meetingRecordExitMessage'.tr, // 对应中文:请选择离开后的操作
'meetingRecordExitMessage'.tr, // 请选择离开后的操作
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
@ -96,7 +105,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
elevation: 0,
),
child: Text(
'meetingRecordEndAndSave'.tr, // 对应中文:结束录音并保存
'meetingRecordEndAndSave'.tr, // 结束录音并保存
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
@ -121,7 +130,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
),
child: Text(
'meetingRecordContinueInBackground'.tr, // 对应中文:后台继续录音
'meetingRecordContinueInBackground'.tr, // 后台继续录音
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
@ -146,7 +155,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
),
child: Text(
'meetingRecordEndWithoutSave'.tr, // 对应中文:结束录音不保存
'meetingRecordEndWithoutSave'.tr, // 结束录音不保存
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
@ -168,7 +177,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
),
child: Text(
'cancel'.tr, // 对应中文:取消
'cancel'.tr, // 取消
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
@ -211,14 +220,19 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
switch (action) {
case _MeetingRecordExitAction.saveAndEnd:
await controller.saveTitleChange();
await controller.saveRecording();
Get.back(result: 'meeting_record_saved');
_deleteControllerAfterPop();
final saved = await controller.saveRecording();
if (saved) {
_notifyMeetingLocalImport();
Get.back(result: 'meeting_record_saved');
_deleteControllerAfterPop();
}
return;
case _MeetingRecordExitAction.endWithoutSave:
await controller.discardRecording();
Get.back(result: 'meeting_record_discarded');
_deleteControllerAfterPop();
final discarded = await controller.discardRecording();
if (discarded) {
Get.back(result: 'meeting_record_discarded');
_deleteControllerAfterPop();
}
return;
case _MeetingRecordExitAction.continueInBackground:
await controller.saveTitleChange();
@ -305,6 +319,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
);
}
/// 构建音频标签页
Widget _buildAudioTab(BuildContext context, bool isDarkMode) {
return LayoutBuilder(
builder: (context, constraints) {
@ -367,17 +382,6 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
final size = Size(screenWidth, 200.h);
final waveColor =
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(
child: AudioWaveforms(
@ -399,7 +403,6 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
showTop: true,
showBottom: true,
extendWaveform: true,
// gradient: shader,
waveColor: waveColor,
scaleFactor: 90,
),
@ -517,7 +520,10 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
),
// 完成按钮
_buildActionButton(
onTap: () => _bottomSheet(context, isDarkMode),
onTap: () => _bottomSheet(
isDarkMode,
MediaQuery.of(context).viewInsets.bottom,
),
child: Icon(
Icons.check_rounded,
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) {
await controller.toggleRecording();
@ -628,7 +634,7 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
left: 20.w,
right: 20.w,
top: 20.w,
bottom: MediaQuery.of(context).viewInsets.bottom + 20.w,
bottom: bottomInset + 20.w,
),
decoration: BoxDecoration(
color: isDarkMode ? AppColors.darkBackground : Colors.white,
@ -700,8 +706,10 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
textInputAction: TextInputAction.done,
onSubmitted: (_) async {
await controller.saveTitleChange();
await controller.saveRecording();
Get.back();
final saved = await controller.saveRecording();
if (saved) {
Get.back();
}
},
),
),
@ -733,9 +741,11 @@ class MeetingRecordView extends GetView<MeetingRecordController> {
child: ElevatedButton(
onPressed: () async {
await controller.saveTitleChange();
await controller.saveRecording();
Get.back(); //关闭弹窗
Get.back(); //回退到会议记录页面
final saved = await controller.saveRecording();
if (saved) {
Get.back(); //关闭弹窗
Get.back(); //回退到会议记录页面
}
},
style: ElevatedButton.styleFrom(
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 hasSpeechDetected: Bool = false
internal var isAiStreaming: Bool = false
private var isCanWakeUp: Bool = true
// 新增:识别"启动中"与"启动中收到停止请求"状态
// 用途:解决 start 异步、stop 先于 onSessionStarted 到来时被忽略的问题
@ -167,6 +168,10 @@ class AgentServiceImpl: NSObject {
private func sendError(_ message: String, code: String = "ERROR") {
sendEvent(name: "error", data: ["code": code, "message": message])
}
func setCanWakeUp(_ canWakeUp: Bool) {
isCanWakeUp = canWakeUp
}
private func initSystemPrompt() {
systemPrompt = """
@ -2025,6 +2030,9 @@ extension AgentServiceImpl: BleService.Callback {
func onWakeupSignalReceived() {
if !isCanWakeUp {
return
}
startRecognition(useBle: true, mode: "ble_wakeup")
}
// 处理开始 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)
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:
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 {
try {
final bool result = await _channel.invokeMethod('setCanWakeUp', {
@ -528,7 +531,8 @@ class AgentService {
});
return result;
} 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.graphics.Color
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.Settings
import android.text.TextUtils
import android.util.TypedValue
import android.view.Gravity
@ -19,10 +23,8 @@ import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import io.flutter.plugin.common.MethodChannel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/// 悬浮窗口服务
class FloatingWindowService : Service() {
companion object {
const val ACTION_UPDATE_CONTENT = "UPDATE_CONTENT"
@ -51,11 +53,27 @@ class FloatingWindowService : Service() {
private var sourceTextView: TextView? = null
private var translatedTextView: TextView? = null
private var sourceLoadingView: TextView? = null
private var translatedLoadingView: TextView? = null
private var languageInfoView: TextView? = null
private var timestampView: TextView? = null
private var minimizedView: View? = null
private var expandedView: View? = null
private var minimizedTextView: 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() {
super.onCreate()
@ -76,14 +94,37 @@ class FloatingWindowService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopAllLoading()
uiHandler.removeCallbacksAndMessages(null)
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
methodChannel = null
super.onDestroy()
}
private fun createFloatingWindow() {
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()
initViews()
setupClickListeners()
@ -108,7 +149,16 @@ class FloatingWindowService : Service() {
y = dp(100)
}
windowManager?.addView(floatingView, layoutParams)
try {
windowManager?.addView(floatingView, layoutParams)
} catch (_: SecurityException) {
notifyStateChanged("hidden")
stopSelf()
return
} catch (_: Exception) {
stopSelf()
return
}
updateViewState(true)
}
@ -129,52 +179,97 @@ class FloatingWindowService : Service() {
}
private fun createMinimizedView(): View {
val container = FrameLayout(this)
val lp = FrameLayout.LayoutParams(dp(60), dp(60))
val container = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
val lp = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(48))
val margin = dp(8)
lp.setMargins(margin, margin, margin, margin)
container.layoutParams = lp
container.setPadding(dp(10), dp(8), dp(12), dp(8))
container.minimumWidth = dp(160)
val bg = GradientDrawable().apply {
setColor(0xFFE3F2FD.toInt())
cornerRadius = dp(30).toFloat()
setColor(backgroundBaseColor)
cornerRadius = dp(24).toFloat()
setStroke(dp(1), Color.argb(46, 255, 255, 255))
}
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 {
text = "译"
setTextColor(0xFF1976D2.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
text = "V"
setTextColor(0xFFFFFFFF.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
gravity = Gravity.CENTER
typeface = Typeface.DEFAULT_BOLD
background = iconBg
}
container.addView(
icon,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
val iconLp = LinearLayout.LayoutParams(dp(28), dp(28))
container.addView(icon, iconLp)
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
}
private fun createExpandedView(): View {
val container = LinearLayout(this)
val lp = FrameLayout.LayoutParams(dp(320), ViewGroup.LayoutParams.WRAP_CONTENT)
val root = FrameLayout(this)
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)
lp.setMargins(margin, margin, margin, margin)
container.layoutParams = lp
container.orientation = LinearLayout.VERTICAL
container.setPadding(dp(16), dp(16), dp(16), dp(16))
container.visibility = View.GONE
container.elevation = dp(12).toFloat()
root.layoutParams = lp
root.setPadding(0, 0, 0, 0)
root.visibility = View.GONE
root.elevation = dp(12).toFloat()
expandedContainer = root
val bg = GradientDrawable().apply {
setColor(Color.WHITE)
setColor(backgroundBaseColor)
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 {
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 {
text = "✕"
setTextColor(0xFF666666.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
setTextColor(Color.argb(210, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
gravity = Gravity.CENTER
setPadding(dp(8), dp(8), dp(8), dp(8))
}
titleRow.addView(title)
titleRow.addView(closeButton)
languageInfoView = TextView(this).apply {
text = "中文 → 英文"
setTextColor(0xFF666666.toInt())
text = "src → tgt"
setTextColor(Color.argb(235, 255, 255, 255))
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 {
text = "正在识别..."
setTextColor(0xFF333333.toInt())
text = ""
setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setPadding(0, dp(8), 0, dp(4))
maxLines = 3
ellipsize = TextUtils.TruncateAt.END
setLineSpacing(dpF(2f), 1.1f)
visibility = View.GONE
}
translatedTextView = TextView(this).apply {
text = "翻译中..."
setTextColor(0xFF1976D2.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
text = ""
setTextColor(0xFF90CAF9.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
setPadding(0, dp(4), 0, dp(8))
maxLines = 3
ellipsize = TextUtils.TruncateAt.END
setLineSpacing(dpF(2f), 1.1f)
visibility = View.GONE
}
timestampView = TextView(this).apply {
sourceLoadingView = TextView(this).apply {
text = ""
setTextColor(0xFF999999.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f)
gravity = Gravity.END
setTextColor(Color.argb(235, 255, 255, 255))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setPadding(0, dp(8), 0, dp(4))
visibility = View.GONE
}
container.addView(titleRow)
container.addView(languageInfoView)
container.addView(sourceTextView)
container.addView(translatedTextView)
container.addView(timestampView)
return container
translatedLoadingView = TextView(this).apply {
text = ""
setTextColor(0xFF90CAF9.toInt())
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
setPadding(0, dp(4), 0, dp(8))
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() {
@ -270,30 +471,79 @@ class FloatingWindowService : Service() {
private var initialTouchX: Float = 0f
private var initialTouchY: Float = 0f
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 {
val lp = layoutParams ?: return false
val wm = windowManager ?: return false
when (event.action) {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
initialX = lp.x
initialY = lp.y
initialTouchX = event.rawX
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
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
}
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 dy = (event.rawY - initialTouchY).toInt()
if (!dragging && (kotlin.math.abs(dx) > threshold || kotlin.math.abs(dy) > threshold)) {
dragging = true
}
val maxX =
resources.displayMetrics.widthPixels - (floatingView?.width ?: 0)
val maxY =
resources.displayMetrics.heightPixels - (floatingView?.height ?: 0)
val (effectiveWidth, effectiveHeight) = getEffectiveFloatingSize()
val maxX = resources.displayMetrics.widthPixels - effectiveWidth
val maxY = resources.displayMetrics.heightPixels - effectiveHeight
var nextX = initialX + dx
var nextY = initialY + dy
if (maxX > 0) nextX = nextX.coerceIn(0, maxX)
@ -304,12 +554,21 @@ class FloatingWindowService : Service() {
return true
}
MotionEvent.ACTION_POINTER_UP -> {
val remainingPointers = event.pointerCount - 1
if (scaling && remainingPointers < 2) {
scaling = false
return true
}
return false
}
MotionEvent.ACTION_UP -> {
if (!dragging) updateViewState(!isMinimized)
if (!dragging && !scaling) updateViewState(!isMinimized)
dragging = false
scaling = false
return true
}
else -> return false
}
}
@ -320,6 +579,13 @@ class FloatingWindowService : Service() {
isMinimized = minimized
minimizedView?.visibility = if (minimized) View.VISIBLE else View.GONE
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")
}
@ -328,26 +594,58 @@ class FloatingWindowService : Service() {
val translatedText = intent.getStringExtra(EXTRA_TRANSLATED_TEXT)
val sourceLang = intent.getStringExtra(EXTRA_SOURCE_LANGUAGE)
val targetLang = intent.getStringExtra(EXTRA_TARGET_LANGUAGE)
val timestamp = intent.getLongExtra(EXTRA_TIMESTAMP, 0L)
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 ->
val langText = (sourceLang ?: "") + " → " + (targetLang ?: "")
view.text = langText
}
sourceTextView?.let { view ->
val value = sourceText ?: ""
view.text = if (TextUtils.isEmpty(value)) "正在识别..." else value
if (hasSource) {
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 ->
val value = translatedText ?: ""
view.text = if (TextUtils.isEmpty(value)) "翻译中..." else value
if (hasTranslated) {
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
}
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) {
@ -386,18 +684,157 @@ class FloatingWindowService : Service() {
channel.invokeMethod("onFloatingWindowClicked", null)
}
private fun formatTimestamp(ts: Long): String {
if (ts <= 0) return ""
return try {
val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
sdf.format(Date(ts))
} catch (_: Exception) {
""
private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density).toInt()
}
private fun dpF(value: Float): Float {
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 {
return (value * resources.displayMetrics.density).toInt()
private fun startSourceLoading() {
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.TextView
/// 录音悬浮条服务
class RecordingFloatingService : Service() {
companion object {
const val ACTION_SHOW = "com.example.native_plugin.action.SHOW_RECORDING_FLOATING_BAR"

Loading…
Cancel
Save