水印相机前端

This commit is contained in:
2026-09-28 09:59:43 +08:00
parent 187a7e0911
commit c5b3d81d07
19 changed files with 2761 additions and 3 deletions
+247
View File
@@ -0,0 +1,247 @@
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<GeoApi>((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<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()}');
}
}
/// 经纬度 → 文字地址。
Future<String> reverseAddress({
required double latitude,
required 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) {
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<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)}';
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<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: '强雷阵雨伴大冰雹',
};
+74
View File
@@ -0,0 +1,74 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
final photoApiProvider = Provider<PhotoApi>((ref) => PhotoApi(ref.watch(dioProvider)));
/// 照片存档接口。
///
/// App 拍完照把**原图**传上来;水印是本地合成的,和这里无关。
/// 上传是「锦上添花」—— 失败了不该影响用户继续编辑和保存照片,
/// 所以调用方要自己吞掉异常并给个「重试」入口。
class PhotoApi {
PhotoApi(this._dio);
final Dio _dio;
/// 上传一张照片,返回后端记录 id
Future<int?> upload({
required String filePath,
double? latitude,
double? longitude,
String? address,
String? weather,
String? remark,
DateTime? takenTime,
}) async {
try {
final form = FormData.fromMap({
'file': await MultipartFile.fromFile(
filePath,
filename: _fileName(filePath),
),
'latitude': ?latitude,
'longitude': ?longitude,
if (address != null && address.isNotEmpty) 'address': address,
if (weather != null && weather.isNotEmpty) 'weather': weather,
if (remark != null && remark.isNotEmpty) 'remark': remark,
if (takenTime != null) 'takenTime': _formatTime(takenTime),
});
final res = await _dio.post<dynamic>(
'/photos',
data: form,
// 原图可能有好几 MB,移动网络下给宽一点
options: Options(receiveTimeout: const Duration(seconds: 60)),
);
final data = res.data;
if (data is Map) {
final id = data['id'];
if (id is int) return id;
}
return null;
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '照片上传失败');
}
}
/// 从路径里取个带扩展名的文件名,后端靠它判断文件类型
static String _fileName(String path) {
final normalized = path.replaceAll('\\', '/');
final name = normalized.substring(normalized.lastIndexOf('/') + 1);
return name.isEmpty ? 'photo.jpg' : name;
}
static String _formatTime(DateTime time) {
String two(int n) => n.toString().padLeft(2, '0');
return '${time.year}-${two(time.month)}-${two(time.day)} '
'${two(time.hour)}:${two(time.minute)}:${two(time.second)}';
}
}