Files
Aq-Accounting-Flutter/lib/api/geo_api.dart
T
2026-09-28 11:39:19 +08:00

363 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '强雷阵雨伴大冰雹',
};