You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
189 lines
6.9 KiB
189 lines
6.9 KiB
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)}';
|
|
}
|
|
}
|
|
|