import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import '../core/network/api_exception.dart'; /// 定位 + 逆地理编码 + 天气。 /// /// **注意这里直连外部服务,不走 [dioProvider]** —— 那个 Dio 带着项目的 baseUrl /// 和 Authorization 拦截器,会把我们的 JWT 一起发到百度/Open-Meteo 去。 /// 所以本类自己持有一个干净的 Dio。 /// /// AK 说明:沿用原「水印相机」项目的百度服务端 AK(用户已确认个人项目可接受 /// 硬编码在客户端)。风险只有一个:AK 被刷爆后逆地理会失效,水印仍能正常合成。 /// 百度地图开放平台 AK(服务端类型,需勾选逆地理编码权限) /// /// ⚠️ 有每日配额,用完当天就不可用 —— 所以下面还有 [nominatimUrl] 兜底。 const String _baiduAk = 'I3IYR0glCNfmfYzMoBDr7lLTKdZ8Y0Xs'; const String _baiduReverseUrl = 'https://api.map.baidu.com/reverse_geocoding/v3/'; /// OpenStreetMap Nominatim:**完全免费、不需要 key、没有配额**。 /// /// 用它兜底是因为百度的免费配额会耗尽(用户实际撞上了)。 /// 代价两点,都写在明面上: /// 1. 它是海外服务,国内访问偶尔会慢(实测约 1 秒) /// 2. 使用政策要求「每秒不超过 1 次请求」+ 必须带 User-Agent。 /// 我们只在百度失败时才走它,量很小,符合政策。 const String _nominatimUrl = 'https://nominatim.openstreetmap.org/reverse'; const String _nominatimUserAgent = 'QiangbaoAssistant/1.0 (personal use)'; const String _openMeteoUrl = 'https://api.open-meteo.com/v1/forecast'; final geoApiProvider = Provider((ref) => GeoApi()); /// 一次定位拿到的全部信息 class GeoSnapshot { const GeoSnapshot({ this.latitude, this.longitude, this.address = '', this.weather = '', this.temperature = '', this.error, this.addressError, }); final double? latitude; final double? longitude; final String address; final String weather; final String temperature; /// 定位失败时的原因(地址/天气会留空,页面据此提示用户) final String? error; /// 定位成功了、但地址没解析出来时的原因。 /// /// 和 [error] 分开是为了不混淆:定位成功但地址失败时,坐标还在, /// 用户该看到「有位置、但地址没解析出来 + 为什么」,而不是一句笼统的失败。 final String? addressError; bool get hasPosition => latitude != null && longitude != null; } class GeoApi { GeoApi({Dio? dio}) : _dio = dio ?? Dio(BaseOptions( // 比默认宽松一点:移动网络下 TLS 握手 + DNS 偶尔要好几秒, // 国内直连百度和海外 Nominatim 的耗时也不一样 connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), )); final Dio _dio; /// 取当前位置(WGS84)。 /// /// 位置本身失败会抛 [ApiException](权限没给 / 定位服务没开), /// 由调用方决定怎么提示。 Future currentPosition() async { if (!await Geolocator.isLocationServiceEnabled()) { throw ApiException(-1, '定位服务未开启,请到系统设置里打开'); } var permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.deniedForever) { throw ApiException(-1, '定位权限已被永久拒绝,请到系统设置里开启'); } if (permission == LocationPermission.denied) { throw ApiException(-1, '没有定位权限'); } try { return await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, timeLimit: Duration(seconds: 15), ), ); } on Exception catch (e) { throw ApiException(-1, '定位失败:${e.toString()}'); } } /// 经纬度 → 文字地址。 /// /// 走「**百度优先、失败降级 Nominatim**」:百度国内数据最准(有街道门牌), /// 但它有每日配额;配额用完时自动换 OSM,至少还能拿到街道级地址。 /// 用户实际撞上过配额耗尽 —— 那时候不降级就只剩一串坐标了。 Future reverseAddress({ required double latitude, required double longitude, }) async { String? baiduError; try { return await _baiduReverseAddress(latitude, longitude); } on ApiException catch (e) { baiduError = e.message; } try { return await _nominatimReverseAddress(latitude, longitude); } catch (_) { // 两个都不行。把百度的原因带出来 —— 那多半是「配额用完」,用户看得懂 throw ApiException(-1, baiduError); } } /// 百度逆地理(主用) Future _baiduReverseAddress(double latitude, double longitude) async { final Response res; try { res = await _dio.get( _baiduReverseUrl, queryParameters: { 'ak': _baiduAk, 'output': 'json', // geolocator 给的是 WGS84 原始 GPS 坐标,必须显式告诉百度, // 否则地址会有几百米偏移 'coordtype': 'wgs84ll', 'location': '$latitude,$longitude', 'extensions_poi': 0, }, ); } on DioException catch (e) { throw ApiException(-1, '百度地址解析失败:${e.message ?? '网络异常'}'); } final data = res.data; if (data is! Map) { throw ApiException(-1, '百度地址解析失败:返回格式异常'); } final status = data['status']; if (status != 0) { final msg = data['msg']?.toString() ?? ''; throw ApiException(-1, '百度地址解析失败:${msg.isEmpty ? '返回 $status' : msg}'); } final result = data['result']; if (result is! Map) { throw ApiException(-1, '百度地址解析失败:没有结果'); } final formatted = result['formatted_address']?.toString() ?? ''; if (formatted.isNotEmpty) return formatted; // 兜底:拼 addressComponent final ac = result['addressComponent']; if (ac is Map) { final joined = '${ac['city'] ?? ''}${ac['district'] ?? ''}' '${ac['street'] ?? ''}${ac['street_number'] ?? ''}'; if (joined.isNotEmpty) return joined; } throw ApiException(-1, '百度地址解析失败:结果为空'); } /// OSM Nominatim 逆地理(兜底,免费且没有配额) Future _nominatimReverseAddress(double latitude, double longitude) async { final Response res; try { res = await _dio.get( _nominatimUrl, queryParameters: { 'format': 'json', 'lat': latitude, 'lon': longitude, 'accept-language': 'zh-CN', 'zoom': 18, // 建筑/门牌级,尽量贴近百度的粒度 }, // Nominatim 的使用政策要求带 User-Agent,不带会被拒 options: Options(headers: {'User-Agent': _nominatimUserAgent}), ); } on DioException catch (e) { throw ApiException(-1, '地址解析失败:${e.message ?? '网络异常'}'); } final data = res.data; if (data is! Map) { throw ApiException(-1, '地址解析失败:返回格式异常'); } // Nominatim 的 address 各字段是「小 → 大」,拼成中文习惯的「大 → 小」: // 云南省 + 昆明市 + 五华区 + 护国街道 + 庆云街 final address = data['address']; if (address is Map) { final parts = []; for (final key in const [ 'state', 'city', 'district', 'county', 'suburb', 'city_district', 'road', 'pedestrian', 'house_number', ]) { final value = address[key]?.toString().trim() ?? ''; // 去重:有些地方 city 和 district 会填同一个值 if (value.isEmpty || parts.contains(value)) continue; parts.add(value); } if (parts.isNotEmpty) return parts.join(); } // 再退一步:display_name 是逗号分隔的「小 → 大」,反过来拼,顺手滤掉邮编和国家 final display = data['display_name']?.toString() ?? ''; if (display.isNotEmpty) { final segments = display .split(',') .map((s) => s.trim()) .where((s) => s.isNotEmpty && s != '中国') .where((s) => int.tryParse(s) == null) .toList() .reversed; if (segments.isNotEmpty) return segments.join(); } throw ApiException(-1, '地址解析失败:结果为空'); } /// 经纬度 → 实时天气。 /// /// 拿不到就返回 null —— 天气是锦上添花,**不该因为它失败就让整张照片没法出**。 Future<({String weather, String temperature})?> weather({ required double latitude, required double longitude, }) async { try { final res = await _dio.get( _openMeteoUrl, queryParameters: { 'latitude': latitude, 'longitude': longitude, 'current_weather': true, 'timezone': 'auto', }, ); final data = res.data; if (data is! Map) return null; if (data['error'] == true) return null; final current = data['current_weather']; if (current is! Map) return null; final temp = current['temperature']; return ( weather: _weatherText(current['weathercode']), temperature: temp is num ? '${temp.round()}℃' : '', ); } catch (_) { return null; } } /// 一把梭:定位 → 地址 → 天气。 /// /// 定位失败不抛异常,而是返回带 [GeoSnapshot.error] 的结果 —— 用户可能只是 /// 不想给定位权限,那也该能继续拍照,水印少一行地点而已。 Future resolve() async { final Position position; try { position = await currentPosition(); } on ApiException catch (e) { return GeoSnapshot(error: e.message); } // 地址拿不到就退回「纬度, 经度」,至少信息量不为零(原项目也这么做)。 // 同时把**原因**记下来给页面显示 —— 只给一串坐标、不说为什么, // 上次就是这样让人以为是功能坏了。 var address = '${position.latitude.toStringAsFixed(4)}, ' '${position.longitude.toStringAsFixed(4)}'; String? addressError; try { address = await reverseAddress( latitude: position.latitude, longitude: position.longitude, ); } on ApiException catch (e) { addressError = e.message; } catch (_) { addressError = '地址解析失败'; } final weather = await this.weather( latitude: position.latitude, longitude: position.longitude, ) ?? (weather: '', temperature: ''); return GeoSnapshot( latitude: position.latitude, longitude: position.longitude, address: address, weather: weather.weather, temperature: weather.temperature, addressError: addressError, ); } /// Open-Meteo 的 WMO 天气码 → 中文 static String _weatherText(Object? code) { if (code is! num) return ''; return _weatherCodes[code.toInt()] ?? '未知天气'; } } const Map _weatherCodes = { 0: '晴空', 1: '大部晴朗', 2: '多云', 3: '阴天', 45: '雾', 48: '霜雾', 51: '细雨', 53: '中细雨', 55: '密细雨', 56: '冻细雨', 57: '强冻细雨', 61: '小雨', 63: '中雨', 65: '大雨', 66: '轻冻雨', 67: '强冻雨', 71: '小雪', 73: '中雪', 75: '大雪', 77: '雪粒', 80: '小阵雨', 81: '中阵雨', 82: '强阵雨', 85: '小阵雪', 86: '大阵雪', 95: '雷阵雨', 96: '雷阵雨伴冰雹', 99: '强雷阵雨伴大冰雹', };