diff --git a/ios/Runner/RunnerProfile.entitlements b/ios/Runner/RunnerProfile.entitlements new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/ios/Runner/RunnerProfile.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/data/models/appconfig_model.dart b/lib/data/models/appconfig_model.dart index 65ea7838e..e656a62c9 100644 --- a/lib/data/models/appconfig_model.dart +++ b/lib/data/models/appconfig_model.dart @@ -79,6 +79,7 @@ class DBProduct { final String? productimage; final String scanuuid; final String? version; + bool? isforceupdate; final bool? iscontinuouschat; final String? updatedescription; final String? updatepackageaddress; @@ -91,6 +92,7 @@ class DBProduct { required this.productimage, required this.scanuuid, required this.version, + required this.isforceupdate, //该产品固件版本是否强制更新 required this.iscontinuouschat, required this.updatedescription, required this.updatepackageaddress, diff --git a/lib/data/models/appconfig_model.g.dart b/lib/data/models/appconfig_model.g.dart index f99ba35c6..fc97723ed 100644 --- a/lib/data/models/appconfig_model.g.dart +++ b/lib/data/models/appconfig_model.g.dart @@ -79,6 +79,7 @@ DBProduct _$DBProductFromJson(Map json) => DBProduct( productimage: json['productimage'] as String?, scanuuid: json['scanuuid'] as String, version: json['version'] as String?, + isforceupdate: json['isforceupdate'] as bool?, iscontinuouschat: json['iscontinuouschat'] as bool?, updatedescription: json['updatedescription'] as String?, updatepackageaddress: json['updatepackageaddress'] as String?, @@ -91,6 +92,7 @@ Map _$DBProductToJson(DBProduct instance) => { 'productimage': instance.productimage, 'scanuuid': instance.scanuuid, 'version': instance.version, + 'isforceupdate': instance.isforceupdate, 'iscontinuouschat': instance.iscontinuouschat, 'devicename': instance.devicename, 'updatedescription': instance.updatedescription, diff --git a/lib/data/services/ota_check_service.dart b/lib/data/services/ota_check_service.dart index 5caa31493..13a5f09d5 100644 --- a/lib/data/services/ota_check_service.dart +++ b/lib/data/services/ota_check_service.dart @@ -57,6 +57,7 @@ class OtaCheckService extends GetxService { /// [isFullyConnected] 设备是否完全连接 /// 返回是否成功执行了检查 Future performOtaCheck(bool isFullyConnected) async { + bool isForceUpdate = false; try { // 检查是否应该进行OTA检查 if (!shouldCheckOta()) { @@ -95,7 +96,12 @@ class OtaCheckService extends GetxService { _otaCoreService.isNewVersionAvailable(currentVersion, latestVersion); if (hasUpdate) { - Logger.d(_tag, '发现新版本,准备显示升级提醒'); + isForceUpdate = latestInfo['isForceUpdate'] as bool? ?? false; + if (isForceUpdate) { + Logger.d(_tag, '发现强制更新版本,准备显示升级提醒'); + } else { + Logger.d(_tag, '发现新版本,准备显示升级提醒'); + } // 再次确认当前在Home页面(防止异步执行期间页面切换) if (_isCurrentlyOnHomePage()) { @@ -109,12 +115,18 @@ class OtaCheckService extends GetxService { } // 标记为已检查(无论是否有更新都标记为已检查) - _markAsChecked(); + if (!isForceUpdate) { + _markAsChecked(); + } + _isCheckingInProgress = false; return true; } catch (e) { Logger.e(_tag, '检查OTA升级失败: $e'); // 即使检查失败,也标记为已检查,避免重复尝试 - _markAsChecked(); + if (!isForceUpdate) { + _markAsChecked(); + } + _isCheckingInProgress = false; return false; } } diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 466b4792b..ab2478230 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -277,7 +277,7 @@ class HomeController extends GetxController { // 连接成功后检查OTA升级 if (noviceGuideService.isHomeGuide) { - Future.delayed(const Duration(seconds: 2), () { + Future.delayed(const Duration(seconds: 1), () { _checkOtaUpgrade(); }); } diff --git a/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart b/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart index cd05687f0..146645d90 100644 --- a/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart +++ b/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart @@ -525,17 +525,20 @@ class OtaCoreService { } } + product.isforceupdate = product.version?.contains("+"); + return { - 'latestVersion': product.version ?? "", + 'latestVersion': product.version?.split('+').first ?? "", 'updateDescription': product.updatedescription ?? "", 'updatePackageAddress': product.updatepackageaddress ?? "", + 'isForceUpdate': product.isforceupdate ?? false, }; } /// 比较版本号,判断是否有新版本可用 /// [currentVersion] 当前版本号 /// [latestVersion] 最新版本号 - /// 返回true表示有新版本可用(支持升级和降级) + /// 返回true表示有新版本可用(只支持升级) bool isNewVersionAvailable(String currentVersion, String latestVersion) { try { List currentParts = @@ -555,10 +558,12 @@ class OtaCoreService { latestParts.add(0); } - // 比较每一段,只要有不同就返回true(支持升级和降级) + // 从高位到低位比较版本号 for (int i = 0; i < maxLength; i++) { - if (latestParts[i] != currentParts[i]) { + if (latestParts[i] > currentParts[i]) { return true; + } else if (latestParts[i] < currentParts[i]) { + return false; } } diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 79cac6ab1..f15b1a0d0 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -2,12 +2,15 @@ import 'dart:io'; import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:intl/intl.dart'; +import 'package:native_plugin/native_plugin.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:pip/pip.dart'; import '../../../data/services/ast_service.dart'; import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/music_manager.dart'; @@ -49,7 +52,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver { final BleManager bleManager = Get.find(); final _musiceManager = Get.find(); late final TranslationUsageService _usageService; + // 安卓悬浮窗管理器 late final FloatingWindowManager _floatingWindowManager; + // 苹果悬浮窗管理器 + late final Pip _pip; + late final NativePlugin _nativePlugin; + int _pipContentView = 0; // ==================== 悬浮窗相关 ==================== final isFloatingWindowEnabled = false.obs; // 悬浮窗状态 @@ -135,7 +143,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // 底部控制栏高度 double _bottomBarHeight = 0.0; - /// 初始化悬浮窗管理器 + /// 初始化安卓悬浮窗管理器 void _initFloatingWindowManager() { try { if (!Get.isRegistered()) { @@ -148,13 +156,68 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } } + /// 初始化苹果悬浮窗管理器 + Future initPlatformState() async { + _pip = Pip(); + _nativePlugin = NativePlugin(); + try { + isFloatingWindowEnabled.value = await _pip.isActived(); + await _pip.registerStateChangedObserver( + PipStateChangedObserver( + onPipStateChanged: (state, error) { + isFloatingWindowEnabled.value = state == PipState.pipStateStarted; + + if (state == PipState.pipStateFailed) { + _pip.dispose(); + } + }, + ), + ); + } on PlatformException { + isFloatingWindowEnabled.value = false; + } + } + /// 切换悬浮窗功能 Future toggleFloatingWindow() async { if (isFloatingWindowEnabled.value) { - await _disableFloatingWindow(); + if (Platform.isAndroid) { + await _disableFloatingWindow(); + } else if (Platform.isIOS) { + await _pip.dispose(); + } } else { - await _enableFloatingWindow(); + if (Platform.isAndroid) { + await _enableFloatingWindow(); + } else if (Platform.isIOS) { + await _startPip(); + } + } + } + + Future _startPip() async { + if (_pipContentView == 0) { + _pipContentView = await _nativePlugin.createPipContentView(); } + final options = PipOptions( + autoEnterEnabled: true, + aspectRatioX: 375, + aspectRatioY: 400, + sourceRectHintLeft: 0, + sourceRectHintTop: 0, + sourceRectHintRight: 0, + sourceRectHintBottom: 0, + seamlessResizeEnabled: true, + useExternalStateMonitor: true, + externalStateMonitorInterval: 100, + contentView: _pipContentView, + preferredContentWidth: 375, + preferredContentHeight: 400, + controlStyle: 2, + ); + + await _pip.setup(options); + await _pip.start(); } /// 启用悬浮窗功能 @@ -217,16 +280,24 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _languageManager.getChineseNameByAsrCode(item.targetLanguageCode) ?? 'unknown'; - final data = FloatingWindowData( - sourceText: item.sourceText, - translatedText: item.translatedText, - sourceLanguage: sourceLanguageName, - targetLanguage: targetLanguageName, - timestamp: item.timestamp, - isIntermediate: item.isIntermediate, - ); - - await _floatingWindowManager.updateFloatingWindowContent(data); + if (Platform.isAndroid) { + final data = FloatingWindowData( + sourceText: item.sourceText, + translatedText: item.translatedText, + sourceLanguage: sourceLanguageName, + targetLanguage: targetLanguageName, + timestamp: item.timestamp, + isIntermediate: item.isIntermediate, + ); + await _floatingWindowManager.updateFloatingWindowContent(data); + } else if (Platform.isIOS) { + await _nativePlugin.updatePipContentView({ + 'viewId': _pipContentView, + 'cn': item.sourceText, + 'en': item.translatedText, + 'lang': '$sourceLanguageName -> $targetLanguageName', + }); + } } /// 更改翻译模式 @@ -374,8 +445,13 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _timerManager = RecordTimerController(); _historyManager = TranslationHistoryManager(); - // 初始化悬浮窗管理器 - _initFloatingWindowManager(); + if (Platform.isAndroid) { + // 初始化安卓悬浮窗管理器 + _initFloatingWindowManager(); + } else if (Platform.isIOS) { + // 初始化苹果悬浮窗管理器 + initPlatformState(); + } // 预先检查权限,避免在用户交互时阻塞 _preCheckPermissions(); @@ -665,6 +741,15 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // _asrService.dispose(); // _astService.dispose(); + if (Platform.isAndroid) { + _disableFloatingWindow(); + } else if (Platform.isIOS) { + _pip.dispose(); + if (_pipContentView != 0) { + _nativePlugin.disposePipContentView(_pipContentView); + } + } + // 释放管理器资源 _timerManager.dispose(); _historyManager.dispose(); diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index b51cf7fa0..c089c0ff2 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -80,6 +80,31 @@ class TranslationView extends GetView { controller: controller, ), + Positioned( + top: 2, + left: 0, + right: 0, + child: SafeArea( + top: true, + child: Container( + height: 56.h, + padding: EdgeInsets.symmetric(horizontal: 8.w), + color: Colors.transparent, + child: Align( + alignment: Alignment.centerLeft, + child: IconButton( + icon: Icon( + Icons.arrow_back_ios, + color: isDarkMode ? Colors.white : Colors.black, + size: 20.sp, + ), + onPressed: () => Get.back(), + ), + ), + ), + ), + ), + // 顶部按钮(倒过来) Positioned( top: 50.h, @@ -210,28 +235,27 @@ class TranslationView extends GetView { return Row( children: [ // 悬浮窗切换按钮(仅在支持的模式下显示) - if (Platform.isAndroid) - Obx(() { - if (controller.currentMode.value == 'call' || - controller.currentMode.value == 'audioVideo') { - return IconButton( - icon: Icon( - controller.isFloatingWindowEnabled.value - ? Icons.picture_in_picture - : Icons.picture_in_picture_alt_outlined, - color: controller.isFloatingWindowEnabled.value - ? Colors.blue - : (isDarkMode ? Colors.white : Colors.black), - size: 24.sp, - ), - onPressed: controller.toggleFloatingWindow, - tooltip: controller.isFloatingWindowEnabled.value - ? '禁用悬浮窗' - : '启用悬浮窗', - ); - } - return const SizedBox.shrink(); - }), + Obx(() { + if (controller.currentMode.value == 'call' || + controller.currentMode.value == 'audioVideo') { + return IconButton( + icon: Icon( + controller.isFloatingWindowEnabled.value + ? Icons.picture_in_picture + : Icons.picture_in_picture_alt_outlined, + color: controller.isFloatingWindowEnabled.value + ? Colors.blue + : (isDarkMode ? Colors.white : Colors.black), + size: 24.sp, + ), + onPressed: controller.toggleFloatingWindow, + tooltip: controller.isFloatingWindowEnabled.value + ? '禁用悬浮窗' + : '启用悬浮窗', + ); + } + return const SizedBox.shrink(); + }), IconButton( icon: Icon( Icons.radio_button_checked, diff --git a/local_plugins/native_plugin/.gitignore b/local_plugins/native_plugin/.gitignore new file mode 100644 index 000000000..e7d347d9d --- /dev/null +++ b/local_plugins/native_plugin/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/local_plugins/native_plugin/.metadata b/local_plugins/native_plugin/.metadata new file mode 100644 index 000000000..a5700b901 --- /dev/null +++ b/local_plugins/native_plugin/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: android + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + - platform: ios + create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/local_plugins/native_plugin/CHANGELOG.md b/local_plugins/native_plugin/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/local_plugins/native_plugin/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/local_plugins/native_plugin/LICENSE b/local_plugins/native_plugin/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/local_plugins/native_plugin/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/local_plugins/native_plugin/README.md b/local_plugins/native_plugin/README.md new file mode 100644 index 000000000..bc8261f01 --- /dev/null +++ b/local_plugins/native_plugin/README.md @@ -0,0 +1,15 @@ +# native_plugin + +A new Flutter plugin project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/local_plugins/native_plugin/analysis_options.yaml b/local_plugins/native_plugin/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/local_plugins/native_plugin/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/local_plugins/native_plugin/android/.gitignore b/local_plugins/native_plugin/android/.gitignore new file mode 100644 index 000000000..161bdcdaf --- /dev/null +++ b/local_plugins/native_plugin/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/local_plugins/native_plugin/android/build.gradle b/local_plugins/native_plugin/android/build.gradle new file mode 100644 index 000000000..948c5ef6f --- /dev/null +++ b/local_plugins/native_plugin/android/build.gradle @@ -0,0 +1,52 @@ +group = "com.example.native_plugin" +version = "1.0" + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.0") + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" + +android { + namespace = "com.example.native_plugin" + + compileSdk = 35 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/local_plugins/native_plugin/android/settings.gradle b/local_plugins/native_plugin/android/settings.gradle new file mode 100644 index 000000000..9f756a98c --- /dev/null +++ b/local_plugins/native_plugin/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'native_plugin' diff --git a/local_plugins/native_plugin/android/src/main/AndroidManifest.xml b/local_plugins/native_plugin/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5153b5dc6 --- /dev/null +++ b/local_plugins/native_plugin/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java b/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java new file mode 100644 index 000000000..265796e9d --- /dev/null +++ b/local_plugins/native_plugin/android/src/main/java/com/example/native_plugin/NativePlugin.java @@ -0,0 +1,38 @@ +package com.example.native_plugin; + +import androidx.annotation.NonNull; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +/** NativePlugin */ +public class NativePlugin implements FlutterPlugin, MethodCallHandler { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private MethodChannel channel; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "native_plugin"); + channel.setMethodCallHandler(this); + } + + @Override + public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + if (call.method.equals("getPlatformVersion")) { + result.success("Android " + android.os.Build.VERSION.RELEASE); + } else { + result.notImplemented(); + } + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + channel.setMethodCallHandler(null); + } +} diff --git a/local_plugins/native_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java b/local_plugins/native_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java new file mode 100644 index 000000000..8034030e9 --- /dev/null +++ b/local_plugins/native_plugin/android/src/test/java/com/example/native_plugin/NativePluginTest.java @@ -0,0 +1,29 @@ +package com.example.native_plugin; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import org.junit.Test; + +/** + * This demonstrates a simple unit test of the Java portion of this plugin's implementation. + * + * Once you have built the plugin's example app, you can run these tests from the command + * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or + * you can run them directly from IDEs that support JUnit such as Android Studio. + */ + +public class NativePluginTest { + @Test + public void onMethodCall_getPlatformVersion_returnsExpectedValue() { + NativePlugin plugin = new NativePlugin(); + + final MethodCall call = new MethodCall("getPlatformVersion", null); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + plugin.onMethodCall(call, mockResult); + + verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE); + } +} diff --git a/local_plugins/native_plugin/ios/.gitignore b/local_plugins/native_plugin/ios/.gitignore new file mode 100644 index 000000000..034771fc9 --- /dev/null +++ b/local_plugins/native_plugin/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh diff --git a/local_plugins/native_plugin/ios/Assets/.gitkeep b/local_plugins/native_plugin/ios/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/native_plugin/ios/Classes/FLNativeView.h b/local_plugins/native_plugin/ios/Classes/FLNativeView.h new file mode 100644 index 000000000..4c49e00f4 --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/FLNativeView.h @@ -0,0 +1,15 @@ +#import + +@interface FLNativeViewFactory : NSObject +- (instancetype)initWithMessenger:(NSObject *)messenger; +@end + +@interface FLNativeView : NSObject + +- (instancetype)initWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args + binaryMessenger:(NSObject *)messenger; + +- (UIView *)view; +@end diff --git a/local_plugins/native_plugin/ios/Classes/FLNativeView.m b/local_plugins/native_plugin/ios/Classes/FLNativeView.m new file mode 100644 index 000000000..848785a6b --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/FLNativeView.m @@ -0,0 +1,83 @@ +#import "FLNativeView.h" +#import "PlayerView.h" + +@implementation FLNativeViewFactory { + NSObject *_messenger; +} + +- (instancetype)initWithMessenger: + (NSObject *)messenger { + self = [super init]; + if (self) { + _messenger = messenger; + } + return self; +} + +- (NSObject *)createWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args { + return [[FLNativeView alloc] initWithFrame:frame + viewIdentifier:viewId + arguments:args + binaryMessenger:_messenger]; +} + +/// Implementing this method is only necessary when the `arguments` in +/// `createWithFrame` is not `nil`. +- (NSObject *)createArgsCodec { + return [FlutterStandardMessageCodec sharedInstance]; +} + +@end + +@implementation FLNativeView { + PlayerView *_view; + FlutterMethodChannel *_methodChannel; +} + +- (instancetype)initWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args + binaryMessenger:(NSObject *)messenger { + if (self = [super init]) { + _view = [[PlayerView alloc] init]; + _methodChannel = [FlutterMethodChannel + methodChannelWithName: + [NSString + stringWithFormat:@"native_plugin/native_view_%lld", viewId] + binaryMessenger:messenger]; + __weak typeof(self) weakSelf = self; + [_methodChannel setMethodCallHandler:^(FlutterMethodCall *_Nonnull call, + FlutterResult _Nonnull result) { + typeof(self) strongSelf = weakSelf; + if (strongSelf != nil) { + [strongSelf onMethodCall:call result:result]; + } + }]; + } + return self; +} + +- (UIView *)view { + return _view; +} + +- (void)onMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { + if ([@"getInternalView" isEqualToString:call.method]) { + result(@((uint64_t)(_view))); + } else if ([@"updateContent" isEqualToString:call.method]) { + NSDictionary *data = call.arguments; + NSString *lang = data[@"lang"]; + NSString *cn = data[@"cn"]; + NSString *en = data[@"en"]; + dispatch_async(dispatch_get_main_queue(), ^{ + [_view updateWithLang:lang cn:cn en:en]; + }); + result(nil); + } else { + result(@(0)); + } +} + +@end diff --git a/local_plugins/native_plugin/ios/Classes/NativePlugin.h b/local_plugins/native_plugin/ios/Classes/NativePlugin.h new file mode 100644 index 000000000..2d0b3293e --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/NativePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface NativePlugin : NSObject +@end diff --git a/local_plugins/native_plugin/ios/Classes/NativePlugin.m b/local_plugins/native_plugin/ios/Classes/NativePlugin.m new file mode 100644 index 000000000..bee63b337 --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/NativePlugin.m @@ -0,0 +1,114 @@ +#import "NativePlugin.h" +#import "FLNativeView.h" +#import "PlayerView.h" + +#import + +@interface NativePlugin () + +@property(nonatomic, strong) NSMutableArray *_Nonnull pipContentViewArray; + +@end + +@implementation NativePlugin + ++ (void)updateAudioSession { + AVAudioSession *audioSession = [AVAudioSession sharedInstance]; + NSError *categoryError = nil; + if (@available(iOS 14.5, *)) { + [audioSession + setCategory:AVAudioSessionCategoryPlayback + mode:AVAudioSessionModeMoviePlayback + options: + AVAudioSessionCategoryOptionOverrideMutedMicrophoneInterruption + error:&categoryError]; + } else { + // Fallback on earlier versions + } + if (categoryError) { + NSLog(@"Set audio session category error: %@", + categoryError.localizedDescription); + } + NSError *activeError = nil; + [audioSession setActive:YES error:&activeError]; + if (activeError) { + NSLog(@"Set audio session active error: %@", + activeError.localizedDescription); + } +} + ++ (void)registerWithRegistrar:(NSObject *)registrar { + // must call this, otherwise pip will not possible + // https://github.com/jazzychad/PiPBugDemo + [NativePlugin updateAudioSession]; + + FlutterMethodChannel *channel = + [FlutterMethodChannel methodChannelWithName:@"native_plugin" + binaryMessenger:[registrar messenger]]; + NativePlugin *instance = [[NativePlugin alloc] init]; + + FLNativeViewFactory *factory = + [[FLNativeViewFactory alloc] initWithMessenger:registrar.messenger]; + [registrar registerViewFactory:factory withId:@"native_view"]; + + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _pipContentViewArray = [NSMutableArray array]; + } + return self; +} + +- (void)handleMethodCall:(FlutterMethodCall *)call + result:(FlutterResult)result { + if ([@"getPlatformVersion" isEqualToString:call.method]) { + result([@"iOS " + stringByAppendingString:[[UIDevice currentDevice] systemVersion]]); + } else if ([@"createPipContentView" isEqualToString:call.method]) { + PlayerView *playerView = [[PlayerView alloc] init]; + + // should control the play state by event of pip, optimize it later + [playerView play]; + + [self.pipContentViewArray addObject:playerView]; + + result(@((uint64_t)playerView)); + } else if ([@"disposePipContentView" isEqualToString:call.method]) { + uint64_t viewId = [call.arguments unsignedLongLongValue]; + for (PlayerView *playerView in self.pipContentViewArray) { + if ((uint64_t)playerView == viewId) { + [playerView pause]; + [self.pipContentViewArray removeObject:playerView]; + break; + } + } + } else if ([@"updatePipContentView" isEqualToString:call.method]) { + NSDictionary *data = call.arguments; + NSString *lang = data[@"lang"]; + NSString *cn = data[@"cn"]; + NSString *en = data[@"en"]; + NSNumber *viewIdNum = data[@"viewId"]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (viewIdNum != nil) { + uint64_t targetId = viewIdNum.unsignedLongLongValue; + for (PlayerView *playerView in self.pipContentViewArray) { + if ((uint64_t)playerView == targetId) { + [playerView updateWithLang:lang cn:cn en:en]; + break; + } + } + } else { + PlayerView *playerView = [self.pipContentViewArray lastObject]; + [playerView updateWithLang:lang cn:cn en:en]; + } + }); + result(nil); + } else { + result(FlutterMethodNotImplemented); + } +} + +@end diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.h b/local_plugins/native_plugin/ios/Classes/PlayerView.h new file mode 100644 index 000000000..50044ae41 --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/PlayerView.h @@ -0,0 +1,15 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface PlayerView : UIView + +- (void)play; + +- (void)pause; + +- (void)updateWithLang:(NSString *)lang cn:(NSString *)cn en:(NSString *)en; + +@end + +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/local_plugins/native_plugin/ios/Classes/PlayerView.m b/local_plugins/native_plugin/ios/Classes/PlayerView.m new file mode 100644 index 000000000..1272aef4c --- /dev/null +++ b/local_plugins/native_plugin/ios/Classes/PlayerView.m @@ -0,0 +1,100 @@ +#import "PlayerView.h" + +@interface PlayerView () + +@property(nonatomic, strong) UILabel *titleLabel; +@property(nonatomic, strong) UILabel *langLabel; +@property(nonatomic, strong) UILabel *cnLabel; +@property(nonatomic, strong) UILabel *enLabel; +@property(nonatomic, strong) UIStackView *stackView; + +@end + +@implementation PlayerView + +- (instancetype)init { + if (self = [super init]) { + self.backgroundColor = [UIColor whiteColor]; + + UILabel *title = [[UILabel alloc] init]; + title.text = @"实时翻译"; + title.font = [UIFont boldSystemFontOfSize:22]; + title.textColor = [UIColor blackColor]; + title.textAlignment = NSTextAlignmentCenter; + title.numberOfLines = 1; + self.titleLabel = title; + + UILabel *lang = [[UILabel alloc] init]; + lang.text = @"中文(简体) -> 英语"; + lang.font = [UIFont systemFontOfSize:14]; + lang.textColor = [UIColor darkGrayColor]; + lang.textAlignment = NSTextAlignmentLeft; + lang.numberOfLines = 1; + self.langLabel = lang; + + UILabel *cn = [[UILabel alloc] init]; + cn.font = [UIFont systemFontOfSize:18]; + cn.textColor = [UIColor blackColor]; + cn.textAlignment = NSTextAlignmentLeft; + cn.numberOfLines = 0; + self.cnLabel = cn; + + UILabel *en = [[UILabel alloc] init]; + en.font = [UIFont systemFontOfSize:18]; + en.textColor = [UIColor colorWithRed:0.24 green:0.54 blue:0.90 alpha:1.0]; + en.textAlignment = NSTextAlignmentLeft; + en.numberOfLines = 0; + self.enLabel = en; + + NSMutableParagraphStyle *para = [[NSMutableParagraphStyle alloc] init]; + para.lineSpacing = 4; + + self.cnLabel.attributedText = [[NSAttributedString alloc] initWithString:@"正在识别..." attributes:@{NSParagraphStyleAttributeName: para}]; + + self.enLabel.attributedText = [[NSAttributedString alloc] initWithString:@"翻译中..." attributes:@{NSParagraphStyleAttributeName: para}]; + + UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[self.titleLabel, self.langLabel, self.cnLabel, self.enLabel]]; + stack.axis = UILayoutConstraintAxisVertical; + stack.spacing = 12; + stack.alignment = UIStackViewAlignmentFill; + stack.distribution = UIStackViewDistributionFill; + stack.translatesAutoresizingMaskIntoConstraints = NO; + self.stackView = stack; + + self.layoutMargins = UIEdgeInsetsMake(16, 16, 16, 16); + [self addSubview:self.stackView]; + + UILayoutGuide *guide = self.layoutMarginsGuide; + [NSLayoutConstraint activateConstraints:@[ + [self.stackView.topAnchor constraintEqualToAnchor:guide.topAnchor], + [self.stackView.leadingAnchor constraintEqualToAnchor:guide.leadingAnchor], + [self.stackView.trailingAnchor constraintEqualToAnchor:guide.trailingAnchor], + [self.stackView.bottomAnchor constraintLessThanOrEqualToAnchor:guide.bottomAnchor] + ]]; + } + return self; +} + +- (void)play { + +} + +- (void)pause { + +} + +- (void)updateWithLang:(NSString *)lang cn:(NSString *)cn en:(NSString *)en { + if (lang != nil) { + self.langLabel.text = lang; + } + NSMutableParagraphStyle *para = [[NSMutableParagraphStyle alloc] init]; + para.lineSpacing = 4; + if (cn != nil) { + self.cnLabel.attributedText = [[NSAttributedString alloc] initWithString:cn attributes:@{NSParagraphStyleAttributeName: para}]; + } + if (en != nil) { + self.enLabel.attributedText = [[NSAttributedString alloc] initWithString:en attributes:@{NSParagraphStyleAttributeName: para}]; + } +} + +@end diff --git a/local_plugins/native_plugin/ios/Resources/PrivacyInfo.xcprivacy b/local_plugins/native_plugin/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..a34b7e2e6 --- /dev/null +++ b/local_plugins/native_plugin/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/local_plugins/native_plugin/ios/native_plugin.podspec b/local_plugins/native_plugin/ios/native_plugin.podspec new file mode 100644 index 000000000..c84e9ffba --- /dev/null +++ b/local_plugins/native_plugin/ios/native_plugin.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint native_plugin.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'native_plugin' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'native_plugin_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/local_plugins/native_plugin/lib/native_plugin.dart b/local_plugins/native_plugin/lib/native_plugin.dart new file mode 100644 index 000000000..c54721f1f --- /dev/null +++ b/local_plugins/native_plugin/lib/native_plugin.dart @@ -0,0 +1,19 @@ +import 'native_plugin_platform_interface.dart'; + +class NativePlugin { + Future getPlatformVersion() { + return NativePluginPlatform.instance.getPlatformVersion(); + } + + Future createPipContentView() { + return NativePluginPlatform.instance.createPipContentView(); + } + + Future updatePipContentView(Map data) { + return NativePluginPlatform.instance.updatePipContentView(data); + } + + Future disposePipContentView(int viewId) { + return NativePluginPlatform.instance.disposePipContentView(viewId); + } +} diff --git a/local_plugins/native_plugin/lib/native_plugin_method_channel.dart b/local_plugins/native_plugin/lib/native_plugin_method_channel.dart new file mode 100644 index 000000000..ec8e18ac5 --- /dev/null +++ b/local_plugins/native_plugin/lib/native_plugin_method_channel.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'native_plugin_platform_interface.dart'; + +/// An implementation of [NativePluginPlatform] that uses method channels. +class MethodChannelNativePlugin extends NativePluginPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('native_plugin'); + + @override + Future getPlatformVersion() async { + final version = await methodChannel.invokeMethod( + 'getPlatformVersion', + ); + return version; + } + + @override + Future createPipContentView() async { + final viewId = await methodChannel.invokeMethod( + 'createPipContentView', + ); + return viewId ?? 0; + } + + @override + Future updatePipContentView(Map data) async { + await methodChannel.invokeMethod('updatePipContentView', data); + } + + @override + Future disposePipContentView(int viewId) async { + await methodChannel.invokeMethod('disposePipContentView', viewId); + } +} diff --git a/local_plugins/native_plugin/lib/native_plugin_platform_interface.dart b/local_plugins/native_plugin/lib/native_plugin_platform_interface.dart new file mode 100644 index 000000000..c26495c2f --- /dev/null +++ b/local_plugins/native_plugin/lib/native_plugin_platform_interface.dart @@ -0,0 +1,47 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'native_plugin_method_channel.dart'; + +abstract class NativePluginPlatform extends PlatformInterface { + /// Constructs a NativePluginPlatform. + NativePluginPlatform() : super(token: _token); + + static final Object _token = Object(); + + static NativePluginPlatform _instance = MethodChannelNativePlugin(); + + /// The default instance of [NativePluginPlatform] to use. + /// + /// Defaults to [MethodChannelNativePlugin]. + static NativePluginPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [NativePluginPlatform] when + /// they register themselves. + static set instance(NativePluginPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getPlatformVersion() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } + + Future createPipContentView() { + throw UnimplementedError( + 'createPipContentView() has not been implemented.', + ); + } + + Future updatePipContentView(Map data) { + throw UnimplementedError( + 'updatePipContentView() has not been implemented.', + ); + } + + Future disposePipContentView(int viewId) { + throw UnimplementedError( + 'disposePipContentView() has not been implemented.', + ); + } +} diff --git a/local_plugins/native_plugin/pubspec.yaml b/local_plugins/native_plugin/pubspec.yaml new file mode 100644 index 000000000..a284bfce8 --- /dev/null +++ b/local_plugins/native_plugin/pubspec.yaml @@ -0,0 +1,72 @@ +name: native_plugin +description: "A new Flutter plugin project." +version: 0.0.1 +homepage: + +environment: + sdk: ^3.7.0 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.example.native_plugin + pluginClass: NativePlugin + ios: + pluginClass: NativePlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/pubspec.yaml b/pubspec.yaml index c2cbdf8c3..d58b22f96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: voitrans description: "Voitrans - AI Voice Assistant." publish_to: "none" -version: 1.0.18+54 +version: 1.0.19+61 environment: sdk: ">=3.3.0 <4.0.0" @@ -76,6 +76,7 @@ dependencies: dio: ^5.8.0+1 sqflite: ^2.4.2 path: ^1.9.1 + pip: ^0.0.3 connectivity_plus: ^6.1.5 # 网络连接检测依赖 speech: path: local_plugins/speech @@ -111,6 +112,8 @@ dependencies: path: local_plugins/user_portrait qq_music: path: local_plugins/qq_music + native_plugin: + path: local_plugins/native_plugin test: path: local_plugins/test in_app_purchase: ^3.2.3 # 应用内购买插件