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

123 lines
4.2 KiB
Dart

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../core/network/api_env.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/photo.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, '照片上传失败');
}
}
/// 分页列表,最新的在前
Future<PhotoPage> list({int page = 1, int size = 30}) async {
try {
final res = await _dio.get<dynamic>(
'/photos',
queryParameters: {'page': page, 'size': size},
);
final data = res.data;
if (data is Map<String, dynamic>) return PhotoPage.fromJson(data);
if (data is Map) return PhotoPage.fromJson(Map<String, dynamic>.from(data));
return PhotoPage.empty;
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '照片列表加载失败');
}
}
/// 把原图下载到临时文件,返回本地路径。
///
/// 「重新打水印」必须先有本地文件:编辑页要读它拿尺寸、显示预览、合成成品。
/// 原图可能好几 MB,所以留了进度回调让界面能显示进度。
///
/// [url] 是后端给的相对地址(`/uploads/...`),这里用 [resolveAssetUrl]
/// 补成绝对地址 —— `/uploads` 不在 `/api` 前缀下,不能直接丢给 dio。
Future<String> downloadOriginal(
String url, {
void Function(int received, int total)? onProgress,
}) async {
try {
final dir = await getTemporaryDirectory();
final path = '${dir.path}/photo_${DateTime.now().millisecondsSinceEpoch}.jpg';
await _dio.download(
resolveAssetUrl(url),
path,
onReceiveProgress: onProgress,
// 原图几 MB,移动网络下给宽一点
options: Options(receiveTimeout: const Duration(seconds: 120)),
);
return path;
} 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)}';
}
}