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

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)}';
}
}