123 lines
4.2 KiB
Dart
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)}';
|
|
}
|
|
}
|