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.
188 lines
7.1 KiB
188 lines
7.1 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 车载香薰,2026-10-09 起 EMAI 耳机共用)。
|
|
///
|
|
/// 车载助手和 EMAI 的提示词里有「系统条件」:`${location}`(用户当前位置)、`${date}`(今天的日期)。
|
|
/// 以前一个都没传,AI 问天气只能反问「你要查哪里」。现在两条通道一起给
|
|
/// (见 [BailianMultimodalService.start]):
|
|
/// - `client_info.location` = {latitude, longitude, city_name}(lumi 也是这么传经纬度的);
|
|
/// - `biz_params.user_prompt_params` = {location: 完整地址, date: 年月日 星期 时:分}。
|
|
/// `${location}` 优先高德返回的详细地址(路名、门牌),没有再退回省市区加街道。
|
|
/// 经纬度只进 `client_info.location`,不进这段要念出来的文案。
|
|
///
|
|
/// 节奏照 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 || s.vendor == DeviceVendors.bes)) {
|
|
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() => formatPlaceText(_place);
|
|
|
|
/// 纯函数,方便单测。`place` 的键与 [LocationService.currentPlace] 一致。
|
|
static String formatPlaceText(Map<String, dynamic>? place) {
|
|
String field(String k) => (place?[k] ?? '').toString().trim();
|
|
final address = field('address');
|
|
if (address.isNotEmpty) return address;
|
|
final parts = <String>[];
|
|
for (final k in ['province', 'city', 'district', 'street']) {
|
|
final v = field(k);
|
|
if (v.isNotEmpty && !parts.contains(v)) parts.add(v);
|
|
}
|
|
if (parts.isEmpty) 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)}';
|
|
}
|
|
}
|
|
|