diff --git a/apps/client/lib/data/services/bailian_multimodal_service.dart b/apps/client/lib/data/services/bailian_multimodal_service.dart index 77508fcc..92dc2e08 100644 --- a/apps/client/lib/data/services/bailian_multimodal_service.dart +++ b/apps/client/lib/data/services/bailian_multimodal_service.dart @@ -9,6 +9,7 @@ import 'package:web_socket_channel/io.dart'; import '../../core/utils/logger.dart'; import '../models/appconfig.dart'; import '../models/assistant_directive.dart'; +import 'device_location_service.dart'; /// 对话状态(对应协议的 DialogStateChanged) enum BailianDialogState { idle, listening, thinking, responding } @@ -152,19 +153,26 @@ class BailianMultimodalService extends GetxService { /// 握手参数在两份实现里必须一模一样,否则「用耳机唤醒」和「在 App 里按住说话」 /// 会连到不同的百炼应用上,而且两边都不报错。 static String resolveApiKey() => - AppConfig.cred('ast_alibaba', 'app_key', 'ALIBABA_OPENSPEECH_APP_KEY') ?? ''; + AppConfig.cred('ast_alibaba', 'app_key', 'ALIBABA_OPENSPEECH_APP_KEY') ?? + ''; static String resolveWorkspaceId([String? override]) { final o = override?.trim() ?? ''; if (o.isNotEmpty) return o; - final v = (AppConfig.cred('sts_bailian', 'workspace_id', 'ALIBABA_BAILIAN_WORKSPACE_ID') ?? '').trim(); + final v = (AppConfig.cred('sts_bailian', 'workspace_id', + 'ALIBABA_BAILIAN_WORKSPACE_ID') ?? + '') + .trim(); return v.isEmpty ? _defaultWorkspaceId : v; } static String resolveAppId([String? override]) { final o = override?.trim() ?? ''; if (o.isNotEmpty) return o; - final v = (AppConfig.cred('sts_bailian', 'app_id', 'ALIBABA_BAILIAN_APP_ID') ?? '').trim(); + final v = + (AppConfig.cred('sts_bailian', 'app_id', 'ALIBABA_BAILIAN_APP_ID') ?? + '') + .trim(); return v.isEmpty ? _defaultAppId : v; } @@ -194,6 +202,10 @@ class BailianMultimodalService extends GetxService { /// 服务是单例、两条链路轮流用,所以在 [start] 时定,不由调用方每次传。 String directiveSource = 'emai'; + /// 需要在 Start 时带上「位置 + 日期」的百炼应用 ID(由模块在 registerServices 里登记, + /// 目前只有车载香薰)。其它应用(EMAI 耳机)的 Start 报文一个字节不变。 + static final Set clientContextAppIds = {}; + Future start({ String mode = 'push2talk', String? voice, @@ -214,6 +226,20 @@ class BailianMultimodalService extends GetxService { _downstreamRate = downstreamRate; directiveSource = source; _taskId = _uuid(); + // 提示词「系统条件」里的 ${location} / ${date}:只读缓存,不等定位 + final withContext = clientContextAppIds.contains(_appId); + final loc = withContext ? DeviceLocationService.maybe : null; + final clientLocation = loc?.clientLocation(); + final promptParams = withContext + ? { + 'location': loc?.placeText() ?? '未知', + 'date': DeviceLocationService.dateText(), + } + : const {}; + if (withContext) { + Logger.i( + _tag, '会话上下文: $promptParams, client_info.location=$clientLocation'); + } try { _channel = IOWebSocketChannel.connect( Uri.parse(_endpoint), @@ -278,34 +304,40 @@ class BailianMultimodalService extends GetxService { }, 'client_info': { 'user_id': _safeUserId(userId), + if (clientLocation != null) 'location': clientLocation, }, // biz_params 是可选块,两件事都没有时整块不发—— // 万一应用侧没开这些能力,多带一个字段可能让 Start 直接被拒, // 那样整个功能都起不来,比少一个搜索严重得多。 - if (enableWebSearch || _hasMcpToken(mcpToken)) + if (enableWebSearch || + _hasMcpToken(mcpToken) || + promptParams.isNotEmpty) 'biz_params': { - 'user_defined_params': { - // MCP 会话令牌:百炼把 user_defined_params 透传给 MCP 工具, - // 我们的工具据此认出「这是哪个用户」。 - // - // ⚠️ **必须按工具名嵌套**,平铺写 `'auth_token': xxx` 会被静默丢弃。 - // 文档只含糊说「mcp 传递参数依照 mcp 服务本身所需的参数」, - // 实测(2026-09-07,四种形状逐个试)才确定是这个形状: - // {"<工具名>": {"auth_token": "..."}} - // 平铺 / 按 MCP 服务名嵌套 / 放 mcp 子节点,三种都收不到。 - // - // ⚠️ 这是整条链路上**唯一**能按用户变的通道:客户端连百炼只带 - // 百炼的 API Key,百炼调 MCP 的 Authorization 头是控制台里 - // 配死的、所有用户共用。不带它,这些工具一律「身份校验失败」。 - // - // ⚠️ 放的是 user_getmcptoken 签发的**短时效专用令牌**, - // 绝不能换成登录 JWT —— 这条路要经过百炼(第三方)。 - if (_hasMcpToken(mcpToken)) - for (final tool in mcpAuthTools) - tool: {'auth_token': mcpToken}, - if (enableWebSearch) - 'extra_config': {'enable_web_search': true}, - }, + // 应用提示词里的自定义变量(${location}、${date}) + if (promptParams.isNotEmpty) 'user_prompt_params': promptParams, + if (enableWebSearch || _hasMcpToken(mcpToken)) + 'user_defined_params': { + // MCP 会话令牌:百炼把 user_defined_params 透传给 MCP 工具, + // 我们的工具据此认出「这是哪个用户」。 + // + // ⚠️ **必须按工具名嵌套**,平铺写 `'auth_token': xxx` 会被静默丢弃。 + // 文档只含糊说「mcp 传递参数依照 mcp 服务本身所需的参数」, + // 实测(2026-09-07,四种形状逐个试)才确定是这个形状: + // {"<工具名>": {"auth_token": "..."}} + // 平铺 / 按 MCP 服务名嵌套 / 放 mcp 子节点,三种都收不到。 + // + // ⚠️ 这是整条链路上**唯一**能按用户变的通道:客户端连百炼只带 + // 百炼的 API Key,百炼调 MCP 的 Authorization 头是控制台里 + // 配死的、所有用户共用。不带它,这些工具一律「身份校验失败」。 + // + // ⚠️ 放的是 user_getmcptoken 签发的**短时效专用令牌**, + // 绝不能换成登录 JWT —— 这条路要经过百炼(第三方)。 + if (_hasMcpToken(mcpToken)) + for (final tool in mcpAuthTools) + tool: {'auth_token': mcpToken}, + if (enableWebSearch) + 'extra_config': {'enable_web_search': true}, + }, }, }, }, diff --git a/apps/client/lib/data/services/device_location_service.dart b/apps/client/lib/data/services/device_location_service.dart new file mode 100644 index 00000000..30c72577 --- /dev/null +++ b/apps/client/lib/data/services/device_location_service.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:location_service/location_service.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../core/utils/logger.dart'; +import '../../devices/device_hub.dart'; +import '../../devices/device_vendors.dart'; + +/// 给百炼会话提供「用户在哪、现在几点」(2026-09-25,车载香薰)。 +/// +/// 车载助手的提示词里有「系统条件」:`${location}`(用户当前位置)、`${date}`(今天的日期)。 +/// 以前一个都没传,AI 问天气只能反问「你要查哪里」。现在两条通道一起给 +/// (见 [BailianMultimodalService.start]): +/// - `client_info.location` = {latitude, longitude, city_name}(lumi 也是这么传经纬度的); +/// - `biz_params.user_prompt_params` = {location: 省市区, date: 年月日 星期 时:分}。 +/// +/// 节奏照 lumi 的 LocationService:启动 / 回到前台时刷新,10 分钟内不重复,结果落盘; +/// **建会话时只读缓存,从不等定位**,所以不拖慢唤醒。 +/// +/// 权限:车载香薰第一次连上(且 App 在前台)时申请一次「使用期间」定位权限, +/// 用户拒绝后不再追问——没有权限就只是不带位置。 +class DeviceLocationService extends GetxService with WidgetsBindingObserver { + static const String _tag = 'DeviceLocation'; + static DeviceLocationService? get maybe => + Get.isRegistered() + ? Get.find() + : null; + + static const String _cacheKey = 'device_location_cache'; + static const String _askedKey = 'device_location_perm_asked'; + static const Duration _minRefreshInterval = Duration(minutes: 10); + + final GetStorage _storage = GetStorage(); + Map? _place; + DateTime? _lastRefreshAt; + bool _refreshing = false; + Worker? _hubWorker; + AppLifecycleState? _lifecycle = AppLifecycleState.resumed; + + @override + void onInit() { + super.onInit(); + WidgetsBinding.instance.addObserver(this); + try { + final c = _storage.read(_cacheKey); + if (c != null) _place = c.map((k, v) => MapEntry('$k', v)); + } catch (_) {} + } + + @override + void onReady() { + super.onReady(); + unawaited(_logReady()); + refresh(); + // DeviceHub 在 InitialBinding 里晚于本服务注册,所以到 onReady 再盯; + // 香薰可能在这之前就已经回连上了,所以先按当前列表判一次 + if (Get.isRegistered()) { + _checkSessions(DeviceHub.to.sessions); + _hubWorker = ever(DeviceHub.to.sessions, _checkSessions); + } else { + Logger.w(_tag, 'DeviceHub 未注册,连上香薰时不会申请定位权限'); + } + } + + void _checkSessions(List list) { + if (list.any((s) => s.vendor == DeviceVendors.eaicar)) { + unawaited(askPermissionOnce()); + } + } + + Future _logReady() async { + try { + final st = await Permission.locationWhenInUse.status; + Logger.i(_tag, '位置服务就绪(定位权限=$st,缓存位置=${placeText()})'); + } catch (_) {} + } + + @override + void onClose() { + WidgetsBinding.instance.removeObserver(this); + _hubWorker?.dispose(); + super.onClose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _lifecycle = state; + if (state == AppLifecycleState.resumed) refresh(); + } + + /// 车载香薰第一次连上时申请一次定位权限。只问一次,拒绝后不再打扰。 + Future askPermissionOnce() async { + if (_storage.read(_askedKey) == true) return; + // 权限框要在前台弹;在后台(被动回连)时先不问,下次前台连上再说 + if (_lifecycle != AppLifecycleState.resumed) { + Logger.i(_tag, 'App 不在前台($_lifecycle),暂不申请定位权限'); + return; + } + await _storage.write(_askedKey, true); + try { + final st = await Permission.locationWhenInUse.request(); + Logger.i(_tag, '车载香薰首次连接,申请定位权限 → $st'); + if (st.isGranted || st.isLimited) await refresh(force: true); + } catch (e) { + Logger.w(_tag, '申请定位权限失败: $e'); + } + } + + /// 后台刷新一次位置(不弹权限框、不抛异常)。 + Future refresh({bool force = false}) async { + if (_refreshing) return; + final last = _lastRefreshAt; + if (!force && + last != null && + DateTime.now().difference(last) < _minRefreshInterval) { + return; + } + _refreshing = true; + try { + final st = await Permission.locationWhenInUse.status; + if (!(st.isGranted || st.isLimited)) { + Logger.i(_tag, '没有定位权限($st),不取位置'); + return; + } + final r = await LocationService().currentPlace(); + if (r['error'] != null) { + Logger.w(_tag, '定位未成功: ${r['error']}(沿用缓存: ${placeText()})'); + return; + } + _place = r; + _lastRefreshAt = DateTime.now(); + await _storage.write(_cacheKey, r); + Logger.i(_tag, '位置已更新: ${placeText()}(原始: $r)'); + } catch (e) { + Logger.w(_tag, '刷新位置失败: $e'); + } finally { + _refreshing = false; + } + } + + String _s(String k) => (_place?[k] ?? '').toString().trim(); + + /// `${location}` 的取值:省市区(去掉重复的直辖市名),拿不到就是「未知」。 + String placeText() { + final parts = []; + for (final k in ['province', 'city', 'district']) { + final v = _s(k); + if (v.isNotEmpty && !parts.contains(v)) parts.add(v); + } + if (parts.isEmpty) { + final addr = _s('address'); + if (addr.isNotEmpty) return addr; + // 反查不到地名时用坐标描述,模型/插件还能据此判断城市,比「未知」强 + final lat = _place?['latitude']; + final lng = _place?['longitude']; + if (lat is num && lng is num) { + return '经纬度 ${lat.toStringAsFixed(4)},${lng.toStringAsFixed(4)}'; + } + return '未知'; + } + return parts.join(); + } + + /// `client_info.location`:没有经纬度时返回 null(整块不发)。 + Map? clientLocation() { + final lat = _place?['latitude']; + final lng = _place?['longitude']; + if (lat is! num || lng is! num) return null; + final city = _s('city'); + return { + 'latitude': lat.toStringAsFixed(6), + 'longitude': lng.toStringAsFixed(6), + if (city.isNotEmpty) 'city_name': city, + }; + } + + /// `${date}` 的取值:手机本地时间,形如「2026年9月25日 星期四 21:30」。 + static String dateText([DateTime? now]) { + final t = now ?? DateTime.now(); + const week = ['一', '二', '三', '四', '五', '六', '日']; + String two(int v) => v.toString().padLeft(2, '0'); + return '${t.year}年${t.month}月${t.day}日 星期${week[t.weekday - 1]} ' + '${two(t.hour)}:${two(t.minute)}'; + } +} diff --git a/apps/client/lib/modules/eaicar/module.dart b/apps/client/lib/modules/eaicar/module.dart index a6e9cbc0..babcbf31 100644 --- a/apps/client/lib/modules/eaicar/module.dart +++ b/apps/client/lib/modules/eaicar/module.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import '../../data/services/bailian_multimodal_service.dart'; +import '../../data/services/device_location_service.dart'; import '../../routes/app_routes.dart'; import '../emai/views/emai_view.dart'; import '../module_descriptor.dart'; @@ -33,6 +35,14 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( entryRoute: Routes.eaicarChat, // 设备按键唤醒时接的就是这个应用(不是 EMAI)——见 AgentModuleDescriptor.bailianAppId bailianAppId: kEaiCarBailianAppId, + // 车载助手提示词要 ${location} / ${date}:登记本应用,Start 时带上位置与日期; + // 位置服务也只为它常驻(EMAI 耳机的会话不带、不受影响)。 + registerServices: () { + BailianMultimodalService.clientContextAppIds.add(kEaiCarBailianAppId); + if (!Get.isRegistered()) { + Get.put(DeviceLocationService(), permanent: true); + } + }, pages: [ GetPage( name: Routes.eaicarChat, diff --git a/apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt b/apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt index 87e88eda..c1d128ed 100644 --- a/apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt +++ b/apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt @@ -34,6 +34,13 @@ class LocationServicePlugin: FlutterPlugin, MethodCallHandler { result.error("CHECK_SERVICE_FAILED", e.message, null) } } + "getCurrentPlace" -> { + val timeout = (call.argument("timeoutMs"))?.toLong() ?: 15000L + PlaceLocator.locate(context, timeout) { data, error -> + if (data != null) result.success(data) + else result.error("PLACE_UNAVAILABLE", error, null) + } + } else -> { result.notImplemented() } diff --git a/apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/PlaceLocator.kt b/apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/PlaceLocator.kt new file mode 100644 index 00000000..0d5addc3 --- /dev/null +++ b/apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/PlaceLocator.kt @@ -0,0 +1,125 @@ +package com.yunqiinnovation.location_service + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.content.ContextCompat +import com.amap.api.location.AMapLocationClient +import com.amap.api.location.AMapLocationClientOption + +/** + * 取一次「当前位置 + 省市区」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。 + * + * 刻意不复用 [LocationService]:那套只收 50 米内的高精度结果、超时 60 秒,是给导航/签到用的; + * 这里只要知道「在哪个城市哪个区」,网络定位就够,要的是快。 + * + * - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请); + * - 高德定位自带逆地理(setNeedAddress),省/市/区一次拿全,华为无 GMS 的机器上也能用; + * - 坐标是高德的 GCJ-02,只用于城市级描述,偏移无关紧要。 + */ +object PlaceLocator { + /** 系统 Geocoder 反查省市区;机器不支持 / 没网 / 出错都返回 null。在后台线程调用。 */ + private fun systemGeocode(context: Context, lat: Double, lng: Double): Map? { + return try { + if (!android.location.Geocoder.isPresent()) { + Log.w(TAG, "系统 Geocoder 不可用") + return null + } + @Suppress("DEPRECATION") + val list = android.location.Geocoder(context, java.util.Locale.CHINA).getFromLocation(lat, lng, 1) + val a = list?.firstOrNull() ?: return null + Log.i(TAG, "系统 Geocoder: 省=${a.adminArea} 市=${a.locality} 区=${a.subLocality} 地址=${a.getAddressLine(0)}") + mapOf( + "province" to (a.adminArea ?: ""), + "city" to (a.locality ?: a.adminArea ?: ""), + "district" to (a.subLocality ?: ""), + "street" to (a.thoroughfare ?: ""), + "address" to (a.getAddressLine(0) ?: ""), + ) + } catch (e: Exception) { + Log.w(TAG, "系统 Geocoder 失败: ${e.message}") + null + } + } + + private const val TAG = "PlaceLocator" + private val main = Handler(Looper.getMainLooper()) + + fun locate(context: Context, timeoutMs: Long, callback: (Map?, String?) -> Unit) { + val app = context.applicationContext + val fine = ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_FINE_LOCATION) + val coarse = ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_COARSE_LOCATION) + if (fine != PackageManager.PERMISSION_GRANTED && coarse != PackageManager.PERMISSION_GRANTED) { + callback(null, "no_permission") + return + } + main.post { + var done = false + var client: AMapLocationClient? = null + fun finish(result: Map?, error: String?) { + if (done) return + done = true + try { + client?.stopLocation() + client?.onDestroy() + } catch (_: Exception) { + } + callback(result, error) + } + try { + AMapLocationClient.updatePrivacyShow(app, true, true) + AMapLocationClient.updatePrivacyAgree(app, true) + val c = AMapLocationClient(app) + client = c + // 高德的 setter 返回 option 本身(链式),Kotlin 属性赋值写法编不过,只能直接调 + val option = AMapLocationClientOption() + option.setLocationMode(AMapLocationClientOption.AMapLocationMode.Hight_Accuracy) + option.setOnceLocation(true) + option.setOnceLocationLatest(false) // 要快,不等更精的那一次 + option.setNeedAddress(true) + option.setLocationCacheEnable(true) + option.setHttpTimeOut(timeoutMs) + option.setMockEnable(false) + c.setLocationOption(option) + c.setLocationListener { loc -> + if (loc == null) { + finish(null, "null_location") + } else if (loc.errorCode != 0) { + Log.w(TAG, "定位失败 code=${loc.errorCode} ${loc.errorInfo}") + finish(null, "amap_${loc.errorCode}") + } else { + Log.i(TAG, "定位成功 type=${loc.locationType} lat=${loc.latitude} lng=${loc.longitude} " + + "省=${loc.province} 市=${loc.city} 区=${loc.district} 地址=${loc.address}") + val base = mapOf( + "latitude" to loc.latitude, + "longitude" to loc.longitude, + "province" to loc.province, + "city" to loc.city, + "district" to loc.district, + "street" to loc.street, + "address" to loc.address, + ) + if (!loc.city.isNullOrBlank() || !loc.address.isNullOrBlank()) { + finish(base, null) + } else { + // 2026-09-25 华为 ALN-AL80 实测:高德只回了坐标(type=12)、省市区全空。 + // 退一步用系统 Geocoder 反查;再不行就只回坐标,由上层用坐标描述位置。 + Thread { + val geo = systemGeocode(app, loc.latitude, loc.longitude) + main.post { finish(if (geo != null) base + geo else base, null) } + }.start() + } + } + } + main.postDelayed({ finish(null, "timeout") }, timeoutMs) + c.startLocation() + } catch (e: Exception) { + Log.w(TAG, "定位异常: ${e.message}") + finish(null, "exception: ${e.message}") + } + } + } +} diff --git a/apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift b/apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift index ba8222b6..dbc3cc8f 100644 --- a/apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift +++ b/apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift @@ -26,6 +26,16 @@ public class LocationServicePlugin: NSObject, FlutterPlugin { result(FlutterError(code: "LOCATION_ERROR", message: error.localizedDescription, details: nil)) } } + case "getCurrentPlace": + let args = call.arguments as? [String: Any] + let timeoutMs = (args?["timeoutMs"] as? NSNumber)?.doubleValue ?? 15000 + PlaceLocator.shared.locate(timeout: timeoutMs / 1000) { data, error in + if let data = data { + result(data) + } else { + result(FlutterError(code: "PLACE_UNAVAILABLE", message: error, details: nil)) + } + } default: result(FlutterMethodNotImplemented) } diff --git a/apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/PlaceLocator.swift b/apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/PlaceLocator.swift new file mode 100644 index 00000000..c73effa7 --- /dev/null +++ b/apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/PlaceLocator.swift @@ -0,0 +1,79 @@ +import Foundation +import CoreLocation + +/// 取一次「当前位置 + 省市区」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。 +/// +/// 刻意不复用 `LocationService`:那套带缓存/预热/权限请求,是给别的业务用的; +/// 这里只要城市级描述,要快、要可控。 +/// +/// - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请); +/// - 逆地理用系统 `CLGeocoder`(国内是高德数据),省/市/区取 administrativeArea/locality/subLocality; +/// 直辖市 locality 可能为空,退回 administrativeArea; +/// - 逆地理失败时仍回经纬度(地名留空),经纬度本身对 client_info 也有用。 +final class PlaceLocator: NSObject, CLLocationManagerDelegate { + static let shared = PlaceLocator() + + private var manager: CLLocationManager? + private var callbacks: [([String: Any]?, String?) -> Void] = [] + private var timer: Timer? + private let geocoder = CLGeocoder() + + func locate(timeout: TimeInterval, completion: @escaping ([String: Any]?, String?) -> Void) { + DispatchQueue.main.async { + let status = CLLocationManager().authorizationStatus + guard status == .authorizedWhenInUse || status == .authorizedAlways else { + completion(nil, "no_permission") + return + } + self.callbacks.append(completion) + if self.callbacks.count > 1 { return } // 已有一次在途,结果共享 + let m = CLLocationManager() + m.delegate = self + m.desiredAccuracy = kCLLocationAccuracyHundredMeters + self.manager = m + self.timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in + self?.finish(nil, "timeout") + } + m.requestLocation() + } + } + + private func finish(_ result: [String: Any]?, _ error: String?) { + timer?.invalidate() + timer = nil + manager?.delegate = nil + manager = nil + let cbs = callbacks + callbacks.removeAll() + cbs.forEach { $0(result, error) } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let loc = locations.last else { return } + guard !callbacks.isEmpty else { return } + timer?.invalidate() + var base: [String: Any] = [ + "latitude": loc.coordinate.latitude, + "longitude": loc.coordinate.longitude, + ] + geocoder.reverseGeocodeLocation(loc, preferredLocale: Locale(identifier: "zh_CN")) { [weak self] marks, err in + DispatchQueue.main.async { + if let p = marks?.first { + let province = p.administrativeArea ?? "" + base["province"] = province + base["city"] = (p.locality?.isEmpty == false) ? p.locality! : province + base["district"] = p.subLocality ?? "" + base["street"] = p.thoroughfare ?? "" + base["address"] = p.name ?? "" + } else if let err = err { + NSLog("[PlaceLocator] 逆地理失败: \(err.localizedDescription)") + } + self?.finish(base, nil) + } + } + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + finish(nil, "location_error: \(error.localizedDescription)") + } +} diff --git a/apps/client/local_plugins/location_service/lib/location_service.dart b/apps/client/local_plugins/location_service/lib/location_service.dart index 07466b79..36d0461f 100644 --- a/apps/client/local_plugins/location_service/lib/location_service.dart +++ b/apps/client/local_plugins/location_service/lib/location_service.dart @@ -55,6 +55,28 @@ class LocationService { } } + /// 取一次当前位置和省市区(2026-09-25 新增,给百炼 `${location}` 用)。 + /// + /// **不申请权限**:没权限、超时、定位失败都返回 `{'error': 原因}`,由调用方决定何时申请权限。 + /// 成功返回键:latitude / longitude(double)、province / city / district / street / address + /// (String,可能为空)。 + Future> currentPlace({int timeoutMs = 15000}) async { + try { + final r = await _methodChannel.invokeMethod( + 'getCurrentPlace', + {'timeoutMs': timeoutMs}, + ); + if (r == null) return {'error': 'empty'}; + return r.map((k, v) => MapEntry('$k', v)); + } on PlatformException catch (e) { + return {'error': e.message ?? e.code}; + } on MissingPluginException { + return {'error': 'not_implemented'}; + } catch (e) { + return {'error': '$e'}; + } + } + /// 获取当前位置(按需获取) Future> location() async { try { diff --git a/apps/client/test/device_location_date_test.dart b/apps/client/test/device_location_date_test.dart new file mode 100644 index 00000000..ec89c097 --- /dev/null +++ b/apps/client/test/device_location_date_test.dart @@ -0,0 +1,11 @@ +import 'package:eaimar/data/services/device_location_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('百炼 \${date}:年月日 星期 时:分', () { + expect(DeviceLocationService.dateText(DateTime(2026, 9, 25, 21, 5)), + '2026年9月25日 星期五 21:05'); + expect(DeviceLocationService.dateText(DateTime(2026, 9, 27, 9, 0)), + '2026年9月27日 星期日 09:00'); + }); +}