33 changed files with 2236 additions and 1439 deletions
@ -0,0 +1,324 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
import '../controllers/settings_controller.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'dart:ui'; |
|||
|
|||
class PermissionsView extends GetView<SettingsController> { |
|||
const PermissionsView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|||
appBar: AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
elevation: 0.5, |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
size: 20.w, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
title: Text( |
|||
'权限管理'.tr, |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
), |
|||
body: FutureBuilder<Map<Permission, PermissionStatus>>( |
|||
future: controller.checkAllPermissions(), |
|||
builder: (context, snapshot) { |
|||
if (snapshot.connectionState == ConnectionState.waiting) { |
|||
return const Center(child: CircularProgressIndicator()); |
|||
} |
|||
|
|||
if (snapshot.hasError) { |
|||
return Center(child: Text('获取权限状态失败: ${snapshot.error}'.tr)); |
|||
} |
|||
|
|||
final permissionStatuses = snapshot.data ?? {}; |
|||
|
|||
return ListView( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), |
|||
children: [ |
|||
// 通讯权限组 |
|||
_buildPermissionSection('通讯权限', Icons.phone_android, [ |
|||
Permission.phone, |
|||
Permission.sms |
|||
], permissionStatuses, isDarkMode), |
|||
|
|||
SizedBox(height: 20.h), |
|||
|
|||
// 日历权限组 |
|||
_buildPermissionSection('日历权限', Icons.calendar_today, [ |
|||
Permission.calendarFullAccess |
|||
], permissionStatuses, isDarkMode), |
|||
|
|||
SizedBox(height: 20.h), |
|||
|
|||
// 位置权限组 |
|||
_buildPermissionSection('位置权限', Icons.location_on, [ |
|||
Permission.location |
|||
], permissionStatuses, isDarkMode), |
|||
], |
|||
); |
|||
}, |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 构建权限组区域 |
|||
Widget _buildPermissionSection(String title, IconData icon, List<Permission> permissions, |
|||
Map<Permission, PermissionStatus> statuses, bool isDarkMode) { |
|||
return Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
// 组标题 |
|||
Padding( |
|||
padding: EdgeInsets.only(left: 4.w, bottom: 8.h), |
|||
child: Text( |
|||
title.tr, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.blue[400] : Colors.blue[600], |
|||
letterSpacing: 1, |
|||
), |
|||
), |
|||
), |
|||
|
|||
// 权限项容器 |
|||
Container( |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: isDarkMode |
|||
? Colors.black.withOpacity(0.2) |
|||
: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: ClipRRect( |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
child: BackdropFilter( |
|||
filter: isDarkMode |
|||
? ImageFilter.blur(sigmaX: 5, sigmaY: 5) |
|||
: ImageFilter.blur(sigmaX: 0, sigmaY: 0), |
|||
child: Column( |
|||
children: _buildPermissionItems(permissions, statuses, isDarkMode), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
// 构建权限项列表 |
|||
List<Widget> _buildPermissionItems(List<Permission> permissions, |
|||
Map<Permission, PermissionStatus> statuses, bool isDarkMode) { |
|||
final List<Widget> items = []; |
|||
|
|||
// 获取描述文本 |
|||
String getDescription(Permission permission) { |
|||
switch (permission) { |
|||
case Permission.phone: |
|||
return '用于拨打电话功能'; |
|||
case Permission.sms: |
|||
return '用于发送短信功能'; |
|||
case Permission.calendarWriteOnly: |
|||
return '用于创建日历事件和提醒'; |
|||
case Permission.calendarFullAccess: |
|||
return '用于读取和管理日历事件及提醒'; |
|||
case Permission.location: |
|||
return '用于获取当前位置信息'; |
|||
default: |
|||
return '应用功能需要'; |
|||
} |
|||
} |
|||
|
|||
// 获取名称 |
|||
String getName(Permission permission) { |
|||
switch (permission) { |
|||
case Permission.phone: |
|||
return '电话'; |
|||
case Permission.sms: |
|||
return '短信'; |
|||
case Permission.calendarWriteOnly: |
|||
return '日历写入'; |
|||
case Permission.calendarFullAccess: |
|||
return '日历完整访问'; |
|||
case Permission.location: |
|||
return '位置信息'; |
|||
default: |
|||
return permission.toString(); |
|||
} |
|||
} |
|||
|
|||
for (int i = 0; i < permissions.length; i++) { |
|||
final permission = permissions[i]; |
|||
final status = statuses[permission] ?? PermissionStatus.denied; |
|||
|
|||
items.add( |
|||
_buildPermissionItem( |
|||
name: getName(permission), |
|||
description: getDescription(permission), |
|||
status: status, |
|||
permission: permission, |
|||
isDarkMode: isDarkMode, |
|||
), |
|||
); |
|||
|
|||
// 添加分隔线,最后一个不加 |
|||
if (i < permissions.length - 1) { |
|||
items.add(Divider( |
|||
height: 1, |
|||
color: isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[200], |
|||
)); |
|||
} |
|||
} |
|||
|
|||
return items; |
|||
} |
|||
|
|||
// 构建单个权限项 |
|||
Widget _buildPermissionItem({ |
|||
required String name, |
|||
required String description, |
|||
required PermissionStatus status, |
|||
required Permission permission, |
|||
required bool isDarkMode, |
|||
}) { |
|||
return Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
name.tr, |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Text( |
|||
description.tr, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.grey[600], |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
_buildStatusText(status, isDarkMode), |
|||
], |
|||
), |
|||
), |
|||
Switch( |
|||
value: status.isGranted, |
|||
onChanged: (value) { |
|||
if (value) { |
|||
_handlePermissionAction(permission, status); |
|||
} else if (status.isGranted) { |
|||
controller.openSettings(); |
|||
} |
|||
}, |
|||
activeColor: Colors.blue[600], |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 状态文本 |
|||
Widget _buildStatusText(PermissionStatus status, bool isDarkMode) { |
|||
String text; |
|||
Color color; |
|||
|
|||
if (status.isGranted) { |
|||
text = '已授权'.tr; |
|||
color = Colors.green; |
|||
} else if (status.isDenied) { |
|||
text = '未授权'.tr; |
|||
color = Colors.red; |
|||
} else if (status.isPermanentlyDenied) { |
|||
text = '需要在系统设置中开启'.tr; |
|||
color = Colors.grey; |
|||
} else if (status.isLimited) { |
|||
text = '部分授权'.tr; |
|||
color = Colors.orange; |
|||
} else { |
|||
text = '未知状态'.tr; |
|||
color = Colors.grey; |
|||
} |
|||
|
|||
return Text( |
|||
text, |
|||
style: TextStyle( |
|||
color: color, |
|||
fontSize: 12.sp, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 处理权限请求 |
|||
Future<void> _handlePermissionAction(Permission permission, PermissionStatus status) async { |
|||
if (status.isPermanentlyDenied) { |
|||
// 打开应用设置 |
|||
controller.openSettings(); |
|||
return; |
|||
} |
|||
|
|||
if (!status.isGranted) { |
|||
// 请求权限 |
|||
final result = await controller.requestPermission(permission); |
|||
|
|||
if (result.isGranted) { |
|||
Get.snackbar('成功'.tr, '权限已授予'.tr, snackPosition: SnackPosition.BOTTOM); |
|||
} else if (result.isPermanentlyDenied) { |
|||
Get.dialog( |
|||
AlertDialog( |
|||
title: Text('需要权限'.tr), |
|||
content: Text('此权限需要在设置中手动开启'.tr), |
|||
actions: [ |
|||
TextButton( |
|||
onPressed: () => Get.back(), |
|||
child: Text('取消'.tr), |
|||
), |
|||
TextButton( |
|||
onPressed: () { |
|||
Get.back(); |
|||
controller.openSettings(); |
|||
}, |
|||
child: Text('去设置'.tr), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 刷新界面 |
|||
Get.forceAppUpdate(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
Copyright (c) 2024 Deep Voice |
|||
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy |
|||
of this software and associated documentation files (the "Software"), to deal |
|||
in the Software without restriction, including without limitation the rights |
|||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
|||
copies of the Software, and to permit persons to whom the Software is |
|||
furnished to do so, subject to the following conditions: |
|||
|
|||
The above copyright notice and this permission notice shall be included in all |
|||
copies or substantial portions of the Software. |
|||
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
|||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
|||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
|||
SOFTWARE. |
|||
@ -1,15 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>NSBluetoothAlwaysUsageDescription</key> |
|||
<string>需要蓝牙权限与设备进行通信和传输音频数据</string> |
|||
<key>NSBluetoothPeripheralUsageDescription</key> |
|||
<string>需要蓝牙权限与设备进行通信和传输音频数据</string> |
|||
<key>UIBackgroundModes</key> |
|||
<array> |
|||
<string>bluetooth-central</string> |
|||
<string>audio</string> |
|||
</array> |
|||
</dict> |
|||
</plist> |
|||
@ -0,0 +1,195 @@ |
|||
import Foundation |
|||
import CoreBluetooth |
|||
import os.log |
|||
|
|||
// 条件编译只导入在iOS 18+上可用的框架 |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
import AccessorySetupKit |
|||
import UIKit |
|||
#endif |
|||
|
|||
class BleAccessorySetup: NSObject { |
|||
// 单例实现 |
|||
static let shared = BleAccessorySetup() |
|||
|
|||
// 基本属性 |
|||
private var bleService: Any? |
|||
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") |
|||
|
|||
// iOS 18+ 特定属性和回调 |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
@available(iOS 18.0, *) |
|||
class AccessorySessionWrapper { |
|||
var session: ASAccessorySession |
|||
|
|||
init() { |
|||
self.session = ASAccessorySession() |
|||
} |
|||
} |
|||
|
|||
var accessorySessionWrapper: Any? |
|||
|
|||
typealias DeviceDiscoveredCallback = (UUID) -> Void |
|||
private var onDeviceDiscovered: DeviceDiscoveredCallback? |
|||
#endif |
|||
|
|||
private override init() { |
|||
super.init() |
|||
} |
|||
|
|||
// 设置BLE服务实例 |
|||
func setBleService(_ service: Any) { |
|||
self.bleService = service |
|||
} |
|||
|
|||
// 设置设备发现回调 |
|||
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
self.onDeviceDiscovered = callback |
|||
} |
|||
#endif |
|||
} |
|||
|
|||
// 初始化设备发现会话 |
|||
func initializeDiscoverySession() { |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
os_log("初始化iOS 18 AccessorySetupKit会话", log: logger, type: .info) |
|||
|
|||
// 创建会话 |
|||
accessorySessionWrapper = AccessorySessionWrapper() |
|||
|
|||
// 激活会话并设置事件处理 |
|||
if let wrapper = accessorySessionWrapper as? AccessorySessionWrapper { |
|||
wrapper.session.activate(on: .main) { [weak self] event in |
|||
guard let self = self else { return } |
|||
|
|||
switch event.eventType { |
|||
case .activated: |
|||
os_log("AccessorySetupKit会话已激活", log: self.logger, type: .info) |
|||
|
|||
case .accessoryAdded: |
|||
if let accessory = event.accessory, let uuid = accessory.bluetoothIdentifier { |
|||
os_log("发现新设备: %{public}@", log: self.logger, type: .info, accessory.displayName) |
|||
self.onDeviceDiscovered?(uuid) |
|||
} |
|||
|
|||
case .accessoryRemoved: |
|||
if let accessory = event.accessory { |
|||
os_log("设备已移除: %{public}@", log: self.logger, type: .info, accessory.displayName) |
|||
} |
|||
|
|||
// 根据WWDC24视频,正确的事件类型为accessoryChanged |
|||
case .accessoryChanged: |
|||
if let accessory = event.accessory { |
|||
os_log("设备已更新: %{public}@", log: self.logger, type: .info, accessory.displayName) |
|||
} |
|||
|
|||
// 根据WWDC24视频,失败事件类型已变更 |
|||
case .pickerSetupFailed: |
|||
if let error = event.error { |
|||
os_log("会话出错: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
|||
} |
|||
|
|||
default: |
|||
os_log("收到其他会话事件: %{public}d", log: self.logger, type: .debug, event.eventType.rawValue) |
|||
} |
|||
} |
|||
|
|||
// 检查现有授权设备 |
|||
if !wrapper.session.accessories.isEmpty { |
|||
os_log("发现%{public}d个已授权设备", log: logger, type: .info, wrapper.session.accessories.count) |
|||
|
|||
// 如果有现有设备,触发回调 |
|||
for accessory in wrapper.session.accessories { |
|||
if let uuid = accessory.bluetoothIdentifier { |
|||
os_log("处理已授权设备: %{public}@", log: logger, type: .info, accessory.displayName) |
|||
self.onDeviceDiscovered?(uuid) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} else { |
|||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|||
} |
|||
#else |
|||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|||
#endif |
|||
} |
|||
|
|||
// 显示设备选择器 |
|||
func showDevicePicker() { |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
if let wrapper = accessorySessionWrapper as? AccessorySessionWrapper { |
|||
os_log("显示iOS 18设备选择器", log: logger, type: .info) |
|||
|
|||
// 创建描述符和选择器项,这些API也需要iOS 18 |
|||
@available(iOS 18.0, *) |
|||
func createAndShowPicker() { |
|||
// 根据WWDC24视频,正确的类名是ASDiscoveryDescriptor |
|||
let descriptor = ASDiscoveryDescriptor() |
|||
|
|||
// 设置过滤条件 |
|||
// 使用ASBluetoothCompanyIdentifier类型 |
|||
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(UInt16(0xABEF)) // BleConst.MANUFACTURER_ID |
|||
descriptor.bluetoothServiceUUID = CBUUID(string: "0000abc0-0000-1111-2222-123456789abc") // BleConst.PRIMARY_SERVICE_UUID |
|||
|
|||
// 创建默认图像以防系统图标不可用 |
|||
let headphonesImage = UIImage(systemName: "headphones") ?? UIImage() |
|||
|
|||
// 根据WWDC24视频,正确的类名是ASPickerDisplayItem |
|||
let displayItem = ASPickerDisplayItem( |
|||
name: "语音助理耳机", |
|||
productImage: headphonesImage, |
|||
descriptor: descriptor |
|||
) |
|||
|
|||
// 显示选择器 |
|||
wrapper.session.showPicker(for: [displayItem]) { error in |
|||
if let error = error { |
|||
// 修复OSLog的错误用法 |
|||
os_log("设备选择器显示错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 调用内部函数 |
|||
createAndShowPicker() |
|||
} else { |
|||
os_log("会话未初始化", log: logger, type: .error) |
|||
} |
|||
} else { |
|||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|||
} |
|||
#else |
|||
os_log("iOS 18 AccessorySetupKit不可用", log: logger, type: .info) |
|||
#endif |
|||
} |
|||
|
|||
// 处理发现的设备 |
|||
func handleDiscoveredAccessory(uuid: UUID) { |
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
guard let service = bleService as? BleService else { |
|||
os_log("BLE服务未设置", log: logger, type: .error) |
|||
return |
|||
} |
|||
|
|||
// 获取中央管理器 |
|||
let centralManager = service.getCentralManager() |
|||
|
|||
// 通过UUID检索外设 |
|||
let peripherals = centralManager.retrievePeripherals(withIdentifiers: [uuid]) |
|||
|
|||
if let peripheral = peripherals.first { |
|||
os_log("连接到UUID对应的设备: %{public}@", log: logger, type: .info, peripheral.name ?? uuid.uuidString) |
|||
service.connect(to: peripheral) |
|||
} else { |
|||
os_log("未找到UUID对应的设备: %{public}@", log: logger, type: .error, uuid.uuidString) |
|||
} |
|||
} |
|||
#endif |
|||
} |
|||
} |
|||
@ -1,25 +0,0 @@ |
|||
import Foundation |
|||
|
|||
/// BLE服务回调代理协议 |
|||
protocol BleDelegate: AnyObject { |
|||
/// 扫描结果回调 |
|||
/// - Parameter devices: 设备列表,每个设备包含id、name、rssi和uuids等信息 |
|||
func onScanResult(devices: [[String: Any]]) |
|||
|
|||
/// 连接状态改变回调 |
|||
/// - Parameter state: 连接状态,参考BleConst中的STATE_*常量 |
|||
func onConnectionStateChanged(state: Int) |
|||
|
|||
/// 音频数据接收回调 |
|||
/// - Parameter data: 接收到的音频数据 |
|||
func onAudioDataReceived(data: Data) |
|||
|
|||
/// 唤醒信号接收回调 |
|||
func onWakeupSignalReceived() |
|||
|
|||
/// 设备信息接收回调 |
|||
/// - Parameters: |
|||
/// - infoType: 信息类型 |
|||
/// - infoData: 信息数据 |
|||
func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) |
|||
} |
|||
File diff suppressed because it is too large
@ -1,4 +1,4 @@ |
|||
#import <Flutter/Flutter.h> |
|||
|
|||
|
|||
@interface BleServicePlugin : NSObject<FlutterPlugin> |
|||
@end |
|||
@ -0,0 +1,87 @@ |
|||
# ble_service iOS 集成说明 |
|||
|
|||
## 概述 |
|||
|
|||
该插件提供了蓝牙低功耗(BLE)设备的连接和通信功能,特别支持BLE耳机设备的音频数据传输。 |
|||
|
|||
- 支持 iOS 13.0+ 的基础蓝牙连接功能 |
|||
- 支持 iOS 18.0+ 的 AccessorySetupKit 集成,实现应用后台唤醒 |
|||
|
|||
## 集成步骤 |
|||
|
|||
### 1. 权限配置 |
|||
|
|||
在应用的 `Info.plist` 中添加以下权限描述: |
|||
|
|||
```xml |
|||
<!-- 蓝牙权限 --> |
|||
<key>NSBluetoothAlwaysUsageDescription</key> |
|||
<string>需要使用蓝牙连接您的耳机设备</string> |
|||
|
|||
<!-- 蓝牙外设权限 --> |
|||
<key>NSBluetoothPeripheralUsageDescription</key> |
|||
<string>需要使用蓝牙连接您的耳机设备</string> |
|||
|
|||
<!-- 后台蓝牙模式 --> |
|||
<key>UIBackgroundModes</key> |
|||
<array> |
|||
<string>bluetooth-central</string> |
|||
</array> |
|||
``` |
|||
|
|||
### 2. iOS 18+ AccessorySetupKit 配置 |
|||
|
|||
如果需要支持 iOS 18+ 的 AccessorySetupKit 功能(应用后台唤醒),还需要在 `Info.plist` 中添加: |
|||
|
|||
```xml |
|||
<!-- 配置 AccessorySetupKit --> |
|||
<key>NSAccessorySetupBluetoothCompanyIdentifiers</key> |
|||
<array> |
|||
<string>ABEF</string> <!-- 耳机厂商ID 0xABEF --> |
|||
</array> |
|||
|
|||
<key>NSAccessorySetupBluetoothServices</key> |
|||
<array> |
|||
<string>0000ABC0-0000-1111-2222-123456789ABC</string> <!-- 主服务 UUID --> |
|||
<string>0000AE00-0000-1000-8000-00805F9B34FB</string> <!-- 音频服务 UUID --> |
|||
</array> |
|||
|
|||
<key>NSAccessorySetupKitSupports</key> |
|||
<array> |
|||
<string>Bluetooth</string> |
|||
</array> |
|||
``` |
|||
|
|||
### 3. 集成到 Runner 项目 |
|||
|
|||
1. 确保项目的最低部署版本为 iOS 13.0 |
|||
|
|||
2. 在 Xcode 中编辑 `Runner` 项目的 `info.plist`,添加上述配置 |
|||
|
|||
3. 如需自定义 UUID 或厂商 ID,请同时修改 `BleConst.swift` 文件中的对应常量 |
|||
|
|||
## 使用说明 |
|||
|
|||
### 基本流程 |
|||
|
|||
1. 首次使用时,应用需要在前台扫描并连接设备 |
|||
2. 配对成功后,后续可通过 AccessorySetupKit (iOS 18+) 自动唤醒应用 |
|||
|
|||
### 支持的命令 |
|||
|
|||
- 获取设备版本: `getDeviceVersion()` |
|||
- 获取电池信息: `getBatteryInfo()` |
|||
- 打开/关闭编解码: `openEncoder()`, `openDecoder()`, `closeCodec()` |
|||
- A2DP 控制: `playA2DP()` |
|||
|
|||
## 后台运行说明 |
|||
|
|||
1. 该插件支持在后台接收 BLE 通知和数据 |
|||
2. iOS 18+ 设备上,支持应用在后台/被杀死状态下被设备唤醒 |
|||
3. 后台保活和状态恢复由系统管理,无需额外代码 |
|||
|
|||
## 注意事项 |
|||
|
|||
- 使用前确保已在用户设备上获得蓝牙权限 |
|||
- 由于苹果限制,手动杀死应用后,仍需用户主动重新打开一次应用才能完全激活后台唤醒 |
|||
- 厂商 ID 和服务 UUID 需要与实际设备匹配 |
|||
@ -1,136 +0,0 @@ |
|||
import Foundation |
|||
import os.log |
|||
|
|||
/// iOS端的文件日志助手类,提供与Android端FileLogger类似的功能 |
|||
class FileLogger { |
|||
|
|||
private static let logQueue = DispatchQueue(label: "com.yunqiinnovation.classic_bluetooth.logger", qos: .background) |
|||
private static let dateFormatter: DateFormatter = { |
|||
let formatter = DateFormatter() |
|||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" |
|||
return formatter |
|||
}() |
|||
|
|||
/// 日志类型 |
|||
enum LogLevel: String { |
|||
case debug = "DEBUG" |
|||
case info = "INFO" |
|||
case warning = "WARNING" |
|||
case error = "ERROR" |
|||
} |
|||
|
|||
/// 输出调试日志 |
|||
/// - Parameters: |
|||
/// - tag: 标签 |
|||
/// - message: 日志消息 |
|||
static func d(_ tag: String, _ message: String) { |
|||
log(level: .debug, tag: tag, message: message) |
|||
} |
|||
|
|||
/// 输出信息日志 |
|||
/// - Parameters: |
|||
/// - tag: 标签 |
|||
/// - message: 日志消息 |
|||
static func i(_ tag: String, _ message: String) { |
|||
log(level: .info, tag: tag, message: message) |
|||
} |
|||
|
|||
/// 输出警告日志 |
|||
/// - Parameters: |
|||
/// - tag: 标签 |
|||
/// - message: 日志消息 |
|||
static func w(_ tag: String, _ message: String) { |
|||
log(level: .warning, tag: tag, message: message) |
|||
} |
|||
|
|||
/// 输出错误日志 |
|||
/// - Parameters: |
|||
/// - tag: 标签 |
|||
/// - message: 日志消息 |
|||
/// - error: 可选的错误对象 |
|||
static func e(_ tag: String, _ message: String, _ error: Error? = nil) { |
|||
var fullMessage = message |
|||
if let error = error { |
|||
fullMessage += " - Error: \(error.localizedDescription)" |
|||
} |
|||
log(level: .error, tag: tag, message: fullMessage) |
|||
} |
|||
|
|||
/// 输出日志 |
|||
/// - Parameters: |
|||
/// - level: 日志级别 |
|||
/// - tag: 标签 |
|||
/// - message: 日志消息 |
|||
private static func log(level: LogLevel, tag: String, message: String) { |
|||
let timestamp = dateFormatter.string(from: Date()) |
|||
let logMessage = "[\(timestamp)] [\(level.rawValue)] [\(tag)]: \(message)" |
|||
|
|||
// 输出到控制台 |
|||
print(logMessage) |
|||
|
|||
// 异步写入日志文件 |
|||
logQueue.async { |
|||
writeToLogFile(logMessage) |
|||
} |
|||
|
|||
// 使用系统日志 |
|||
if #available(iOS 10.0, *) { |
|||
let osLog = OSLog(subsystem: "com.yunqiinnovation.classic_bluetooth", category: tag) |
|||
let osLogType: OSLogType |
|||
|
|||
switch level { |
|||
case .debug: |
|||
osLogType = .debug |
|||
case .info: |
|||
osLogType = .info |
|||
case .warning: |
|||
osLogType = .default |
|||
case .error: |
|||
osLogType = .error |
|||
} |
|||
|
|||
os_log("%{public}@", log: osLog, type: osLogType, message) |
|||
} |
|||
} |
|||
|
|||
/// 将日志写入文件 |
|||
/// - Parameter message: 日志消息 |
|||
private static func writeToLogFile(_ message: String) { |
|||
guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { |
|||
return |
|||
} |
|||
|
|||
let logDirectoryURL = documentsDirectory.appendingPathComponent("Logs") |
|||
let dateString = DateFormatter().with { $0.dateFormat = "yyyy-MM-dd" }.string(from: Date()) |
|||
let logFileURL = logDirectoryURL.appendingPathComponent("bluetooth_\(dateString).log") |
|||
|
|||
do { |
|||
// 创建日志文件夹 |
|||
try FileManager.default.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true) |
|||
|
|||
// 如果文件不存在,创建文件 |
|||
if !FileManager.default.fileExists(atPath: logFileURL.path) { |
|||
FileManager.default.createFile(atPath: logFileURL.path, contents: nil) |
|||
} |
|||
|
|||
// 追加日志内容 |
|||
if let fileHandle = FileHandle(forWritingAtPath: logFileURL.path) { |
|||
fileHandle.seekToEndOfFile() |
|||
if let data = "\(message)\n".data(using: .utf8) { |
|||
fileHandle.write(data) |
|||
} |
|||
fileHandle.closeFile() |
|||
} |
|||
} catch { |
|||
print("写入日志文件失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// MARK: - DateFormatter 扩展 |
|||
extension DateFormatter { |
|||
func with(_ block: (DateFormatter) -> Void) -> DateFormatter { |
|||
block(self) |
|||
return self |
|||
} |
|||
} |
|||
Binary file not shown.
Binary file not shown.
@ -1,2 +1,2 @@ |
|||
#Sat May 17 17:27:45 IST 2025 |
|||
#Sun May 18 21:39:38 IST 2025 |
|||
gradle.version=8.10 |
|||
|
|||
@ -0,0 +1,45 @@ |
|||
|
|||
请帮我生成一个基于 GetX 最佳实践的 Flutter 应用项目骨架。要求如下: |
|||
|
|||
1. 目录结构 |
|||
- lib/ |
|||
- main.dart |
|||
- app/ |
|||
- data/ |
|||
- providers/ # 网络请求、API 客户端(使用 GetConnect) |
|||
- repositories/ # Repository 层,封装业务数据源 |
|||
- services/ # 全局服务(如 ThemeService、AuthService) |
|||
- modules/ |
|||
- home/ # 示例模块 |
|||
- bindings/ # HomeBinding,注册 Controller、Service |
|||
- controllers/ # HomeController |
|||
- models/ # HomeModel 数据类 |
|||
- views/ # HomePage 页面 |
|||
- routes/ |
|||
- app_routes.dart # 路由常量 |
|||
- app_pages.dart # GetPage 列表,包括中间件示例 |
|||
- translations/ # 多语言文件(en_US、zh_CN)和 Translations 实现 |
|||
- widgets/ # 公共 Widget(Loading、ErrorView) |
|||
- utils/ # 日志 Logger、网络拦截器等工具函数 |
|||
|
|||
2. main.dart |
|||
- 使用 GetMaterialApp |
|||
- 配置 initialRoute、getPages、theme、darkTheme、locale、translations |
|||
|
|||
3. 依赖注入(Bindings) |
|||
- 在 app/modules/**/bindings 中,用 Get.lazyPut、Get.putAsync、Get.create 分别示例单例、异步初始化和工厂模式 |
|||
|
|||
4. 状态管理 |
|||
- 在 Controller 中使用 Rx<T> + .obs |
|||
- 在视图中用 Obx、GetX、GetBuilder |
|||
- 在 Controller 的 onInit、onReady、onClose 中写注释说明生命周期 |
|||
|
|||
5. 路由管理 |
|||
- 在 app/routes/app_routes.dart 定义路由名称常量 |
|||
- 在 app/routes/app_pages.dart 用 GetPage 配置路由、binding |
|||
|
|||
8. 架构模式 |
|||
- 强调 “Repository → Service → Controller → View” 分层 |
|||
- 各层代码注释职责 |
|||
|
|||
- 代码风格统一、注释清晰 |
|||
Loading…
Reference in new issue