363 lines
12 KiB
Dart
363 lines
12 KiB
Dart
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<GeoApi>((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<Position> 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<String> 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<String> _baiduReverseAddress(double latitude, double longitude) async {
|
||
final Response<dynamic> res;
|
||
try {
|
||
res = await _dio.get<dynamic>(
|
||
_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<String> _nominatimReverseAddress(double latitude, double longitude) async {
|
||
final Response<dynamic> res;
|
||
try {
|
||
res = await _dio.get<dynamic>(
|
||
_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 = <String>[];
|
||
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<dynamic>(
|
||
_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<GeoSnapshot> 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<int, String> _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: '强雷阵雨伴大冰雹',
|
||
};
|