Browse Source

车载香薰:会话带上用户位置与当前时间,填百炼提示词里的 ${location} / ${date}

- 定位插件新增 getCurrentPlace:安卓高德定位(华为上高德只回坐标时退回系统 Geocoder),
  iOS CoreLocation + CLGeocoder;不在插件里弹权限框,失败回原因
- DeviceLocationService:启动/回前台刷新、10 分钟节流、落盘缓存;香薰首次连上(前台)申请一次
  「使用期间」定位权限,拒绝后不再追问;建会话只读缓存,不拖慢唤醒
- BailianMultimodalService.start:仅对登记的应用(车载香薰)带
  client_info.location{latitude,longitude,city_name} 与 biz_params.user_prompt_params{location,date},
  date 精确到分;拿不到地名时用坐标描述。EMAI 耳机的 Start 报文不变

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 2 weeks ago
parent
commit
2a913260e6
  1. 40
      apps/client/lib/data/services/bailian_multimodal_service.dart
  2. 189
      apps/client/lib/data/services/device_location_service.dart
  3. 10
      apps/client/lib/modules/eaicar/module.dart
  4. 7
      apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/LocationServicePlugin.kt
  5. 125
      apps/client/local_plugins/location_service/android/src/main/kotlin/com/yunqiinnovation/location_service/PlaceLocator.kt
  6. 10
      apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift
  7. 79
      apps/client/local_plugins/location_service/ios/location_service/Sources/location_service/PlaceLocator.swift
  8. 22
      apps/client/local_plugins/location_service/lib/location_service.dart
  9. 11
      apps/client/test/device_location_date_test.dart

40
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 '../../core/utils/logger.dart';
import '../models/appconfig.dart'; import '../models/appconfig.dart';
import '../models/assistant_directive.dart'; import '../models/assistant_directive.dart';
import 'device_location_service.dart';
/// 对话状态(对应协议的 DialogStateChanged) /// 对话状态(对应协议的 DialogStateChanged)
enum BailianDialogState { idle, listening, thinking, responding } enum BailianDialogState { idle, listening, thinking, responding }
@ -152,19 +153,26 @@ class BailianMultimodalService extends GetxService {
/// 握手参数在两份实现里必须一模一样,否则「用耳机唤醒」和「在 App 里按住说话」 /// 握手参数在两份实现里必须一模一样,否则「用耳机唤醒」和「在 App 里按住说话」
/// 会连到不同的百炼应用上,而且两边都不报错。 /// 会连到不同的百炼应用上,而且两边都不报错。
static String resolveApiKey() => 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]) { static String resolveWorkspaceId([String? override]) {
final o = override?.trim() ?? ''; final o = override?.trim() ?? '';
if (o.isNotEmpty) return o; 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; return v.isEmpty ? _defaultWorkspaceId : v;
} }
static String resolveAppId([String? override]) { static String resolveAppId([String? override]) {
final o = override?.trim() ?? ''; final o = override?.trim() ?? '';
if (o.isNotEmpty) return o; 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; return v.isEmpty ? _defaultAppId : v;
} }
@ -194,6 +202,10 @@ class BailianMultimodalService extends GetxService {
/// 服务是单例、两条链路轮流用,所以在 [start] 时定,不由调用方每次传。 /// 服务是单例、两条链路轮流用,所以在 [start] 时定,不由调用方每次传。
String directiveSource = 'emai'; String directiveSource = 'emai';
/// 需要在 Start 时带上「位置 + 日期」的百炼应用 ID(由模块在 registerServices 里登记,
/// 目前只有车载香薰)。其它应用(EMAI 耳机)的 Start 报文一个字节不变。
static final Set<String> clientContextAppIds = <String>{};
Future<bool> start({ Future<bool> start({
String mode = 'push2talk', String mode = 'push2talk',
String? voice, String? voice,
@ -214,6 +226,20 @@ class BailianMultimodalService extends GetxService {
_downstreamRate = downstreamRate; _downstreamRate = downstreamRate;
directiveSource = source; directiveSource = source;
_taskId = _uuid(); _taskId = _uuid();
// 提示词「系统条件」里的 ${location} / ${date}:只读缓存,不等定位
final withContext = clientContextAppIds.contains(_appId);
final loc = withContext ? DeviceLocationService.maybe : null;
final clientLocation = loc?.clientLocation();
final promptParams = withContext
? <String, String>{
'location': loc?.placeText() ?? '未知',
'date': DeviceLocationService.dateText(),
}
: const <String, String>{};
if (withContext) {
Logger.i(
_tag, '会话上下文: $promptParams, client_info.location=$clientLocation');
}
try { try {
_channel = IOWebSocketChannel.connect( _channel = IOWebSocketChannel.connect(
Uri.parse(_endpoint), Uri.parse(_endpoint),
@ -278,12 +304,18 @@ class BailianMultimodalService extends GetxService {
}, },
'client_info': { 'client_info': {
'user_id': _safeUserId(userId), 'user_id': _safeUserId(userId),
if (clientLocation != null) 'location': clientLocation,
}, },
// biz_params 是可选块,两件事都没有时整块不发—— // biz_params 是可选块,两件事都没有时整块不发——
// 万一应用侧没开这些能力,多带一个字段可能让 Start 直接被拒, // 万一应用侧没开这些能力,多带一个字段可能让 Start 直接被拒,
// 那样整个功能都起不来,比少一个搜索严重得多。 // 那样整个功能都起不来,比少一个搜索严重得多。
if (enableWebSearch || _hasMcpToken(mcpToken)) if (enableWebSearch ||
_hasMcpToken(mcpToken) ||
promptParams.isNotEmpty)
'biz_params': { 'biz_params': {
// 应用提示词里的自定义变量(${location}、${date})
if (promptParams.isNotEmpty) 'user_prompt_params': promptParams,
if (enableWebSearch || _hasMcpToken(mcpToken))
'user_defined_params': { 'user_defined_params': {
// MCP 会话令牌:百炼把 user_defined_params 透传给 MCP 工具, // MCP 会话令牌:百炼把 user_defined_params 透传给 MCP 工具,
// 我们的工具据此认出「这是哪个用户」。 // 我们的工具据此认出「这是哪个用户」。

189
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<DeviceLocationService>()
? Get.find<DeviceLocationService>()
: 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<String, dynamic>? _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<Map>(_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<DeviceHub>()) {
_checkSessions(DeviceHub.to.sessions);
_hubWorker = ever<List>(DeviceHub.to.sessions, _checkSessions);
} else {
Logger.w(_tag, 'DeviceHub 未注册,连上香薰时不会申请定位权限');
}
}
void _checkSessions(List list) {
if (list.any((s) => s.vendor == DeviceVendors.eaicar)) {
unawaited(askPermissionOnce());
}
}
Future<void> _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<void> askPermissionOnce() async {
if (_storage.read<bool>(_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<void> 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 = <String>[];
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<String, String>? 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)}';
}
}

10
apps/client/lib/modules/eaicar/module.dart

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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 '../../routes/app_routes.dart';
import '../emai/views/emai_view.dart'; import '../emai/views/emai_view.dart';
import '../module_descriptor.dart'; import '../module_descriptor.dart';
@ -33,6 +35,14 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
entryRoute: Routes.eaicarChat, entryRoute: Routes.eaicarChat,
// 设备按键唤醒时接的就是这个应用(不是 EMAI)——见 AgentModuleDescriptor.bailianAppId // 设备按键唤醒时接的就是这个应用(不是 EMAI)——见 AgentModuleDescriptor.bailianAppId
bailianAppId: kEaiCarBailianAppId, bailianAppId: kEaiCarBailianAppId,
// 车载助手提示词要 ${location} / ${date}:登记本应用,Start 时带上位置与日期;
// 位置服务也只为它常驻(EMAI 耳机的会话不带、不受影响)。
registerServices: () {
BailianMultimodalService.clientContextAppIds.add(kEaiCarBailianAppId);
if (!Get.isRegistered<DeviceLocationService>()) {
Get.put<DeviceLocationService>(DeviceLocationService(), permanent: true);
}
},
pages: [ pages: [
GetPage( GetPage(
name: Routes.eaicarChat, name: Routes.eaicarChat,

7
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) result.error("CHECK_SERVICE_FAILED", e.message, null)
} }
} }
"getCurrentPlace" -> {
val timeout = (call.argument<Number>("timeoutMs"))?.toLong() ?: 15000L
PlaceLocator.locate(context, timeout) { data, error ->
if (data != null) result.success(data)
else result.error("PLACE_UNAVAILABLE", error, null)
}
}
else -> { else -> {
result.notImplemented() result.notImplemented()
} }

125
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<String, Any?>? {
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, Any?>?, 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<String, Any?>?, 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<String, Any?>(
"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}")
}
}
}
}

10
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)) 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: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }

79
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)")
}
}

22
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<Map<String, dynamic>> currentPlace({int timeoutMs = 15000}) async {
try {
final r = await _methodChannel.invokeMethod<Map>(
'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<Map<String, dynamic>> location() async { Future<Map<String, dynamic>> location() async {
try { try {

11
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');
});
}
Loading…
Cancel
Save