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(服务端类型,需勾选逆地理编码权限) const String _baiduAk = 'I3IYR0glCNfmfYzMoBDr7lLTKdZ8Y0Xs'; const String _baiduReverseUrl = 'https://api.map.baidu.com/reverse_geocoding/v3/'; 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, }); final double? latitude; final double? longitude; final String address; final String weather; final String temperature; /// 定位失败时的原因(地址/天气会留空,页面据此提示用户) final String? error; bool get hasPosition => latitude != null && longitude != null; } class GeoApi { GeoApi({Dio? dio}) : _dio = dio ?? Dio(BaseOptions( connectTimeout: const Duration(seconds: 8), receiveTimeout: const Duration(seconds: 10), )); 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()}'); } } /// 经纬度 → 文字地址。 Future reverseAddress({ required double latitude, required 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) { throw ApiException(-1, '地址解析失败:${data['msg'] ?? '百度返回 $status'}'); } 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, '地址解析失败:结果为空'); } /// 经纬度 → 实时天气。 /// /// 拿不到就返回 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)}'; try { address = await reverseAddress( latitude: position.latitude, longitude: position.longitude, ); } catch (_) { // 保持坐标兜底 } 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, ); } /// 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: '强雷阵雨伴大冰雹', };