- Register ACTION_SEND IntentFilter for image/* in AndroidManifest - Kotlin MethodChannel bridge: extract image URI, original filename, resolve to file - Flutter bottom sheet UI: thumbnail preview, note input (200 char), submit/cancel - Upload service: multipart POST, Bearer auth, 10MB limit, offline cache, exponential backoff retry - Configurable server URL and API token via SharedPreferences - Minimal home screen with usage instructions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
5.3 KiB
Dart
174 lines
5.3 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
const int _maxFileSize = 10 * 1024 * 1024; // 10MB
|
||
const int _maxRetries = 3;
|
||
|
||
class UploadResult {
|
||
final bool success;
|
||
final String? transactionId;
|
||
final String? error;
|
||
|
||
UploadResult.success(this.transactionId)
|
||
: success = true,
|
||
error = null;
|
||
UploadResult.failure(this.error)
|
||
: success = false,
|
||
transactionId = null;
|
||
}
|
||
|
||
class UploadService {
|
||
static Future<String> _getServerUrl() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
return prefs.getString('server_url') ?? '';
|
||
}
|
||
|
||
static Future<String> _getToken() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
return prefs.getString('api_token') ?? '';
|
||
}
|
||
|
||
static Future<UploadResult> upload({
|
||
required File imageFile,
|
||
required String originalFilename,
|
||
String? userNote,
|
||
}) async {
|
||
// Client-side size check
|
||
final fileSize = await imageFile.length();
|
||
if (fileSize > _maxFileSize) {
|
||
return UploadResult.failure('图片过大,请选择小于10MB的图片');
|
||
}
|
||
|
||
final serverUrl = await _getServerUrl();
|
||
final token = await _getToken();
|
||
|
||
if (serverUrl.isEmpty) {
|
||
return UploadResult.failure('请先配置服务器地址');
|
||
}
|
||
if (token.isEmpty) {
|
||
return UploadResult.failure('请先配置 API Token');
|
||
}
|
||
|
||
return _doUpload(
|
||
serverUrl: serverUrl,
|
||
token: token,
|
||
imageFile: imageFile,
|
||
originalFilename: originalFilename,
|
||
userNote: userNote,
|
||
);
|
||
}
|
||
|
||
static Future<UploadResult> _doUpload({
|
||
required String serverUrl,
|
||
required String token,
|
||
required File imageFile,
|
||
required String originalFilename,
|
||
String? userNote,
|
||
int attempt = 0,
|
||
}) async {
|
||
try {
|
||
final uri = Uri.parse('$serverUrl/api/v1/transactions/upload');
|
||
final request = http.MultipartRequest('POST', uri);
|
||
request.headers['Authorization'] = 'Bearer $token';
|
||
request.files.add(await http.MultipartFile.fromPath('file', imageFile.path));
|
||
request.fields['original_filename'] = originalFilename;
|
||
if (userNote != null && userNote.isNotEmpty) {
|
||
request.fields['user_note'] = userNote;
|
||
}
|
||
|
||
final response = await request.send().timeout(const Duration(seconds: 30));
|
||
final body = await response.stream.bytesToString();
|
||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||
|
||
if (response.statusCode == 200 && json['success'] == true) {
|
||
return UploadResult.success(json['data']?['id']?.toString());
|
||
} else {
|
||
return UploadResult.failure(json['error'] as String? ?? 'Upload failed');
|
||
}
|
||
} on SocketException catch (_) {
|
||
// Network error — cache for retry
|
||
await _cacheForRetry(
|
||
imageFile: imageFile,
|
||
originalFilename: originalFilename,
|
||
userNote: userNote,
|
||
);
|
||
|
||
if (attempt < _maxRetries) {
|
||
final delay = Duration(seconds: 5 * (1 << attempt));
|
||
await Future.delayed(delay);
|
||
return _doUpload(
|
||
serverUrl: serverUrl,
|
||
token: token,
|
||
imageFile: imageFile,
|
||
originalFilename: originalFilename,
|
||
userNote: userNote,
|
||
attempt: attempt + 1,
|
||
);
|
||
}
|
||
return UploadResult.failure('上传失败,请检查网络');
|
||
} catch (e) {
|
||
return UploadResult.failure('上传失败: $e');
|
||
}
|
||
}
|
||
|
||
static Future<void> _cacheForRetry({
|
||
required File imageFile,
|
||
required String originalFilename,
|
||
String? userNote,
|
||
}) async {
|
||
try {
|
||
final dir = await getApplicationDocumentsDirectory();
|
||
final cacheDir = Directory('${dir.path}/pending_uploads');
|
||
if (!await cacheDir.exists()) {
|
||
await cacheDir.create(recursive: true);
|
||
}
|
||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||
final cachedFile = await imageFile.copy('${cacheDir.path}/$timestamp.jpg');
|
||
final meta = {
|
||
'imagePath': cachedFile.path,
|
||
'originalFilename': originalFilename,
|
||
'userNote': userNote ?? '',
|
||
};
|
||
final metaFile = File('${cacheDir.path}/$timestamp.json');
|
||
await metaFile.writeAsString(jsonEncode(meta));
|
||
} catch (_) {
|
||
// Best-effort caching
|
||
}
|
||
}
|
||
|
||
static Future<void> retryCachedUploads() async {
|
||
final dir = await getApplicationDocumentsDirectory();
|
||
final cacheDir = Directory('${dir.path}/pending_uploads');
|
||
if (!await cacheDir.exists()) return;
|
||
|
||
final serverUrl = await _getServerUrl();
|
||
final token = await _getToken();
|
||
if (serverUrl.isEmpty || token.isEmpty) return;
|
||
|
||
final metaFiles = cacheDir.listSync().where((f) => f.path.endsWith('.json'));
|
||
for (final metaFile in metaFiles) {
|
||
try {
|
||
final meta = jsonDecode(await File(metaFile.path).readAsString()) as Map<String, dynamic>;
|
||
final imageFile = File(meta['imagePath'] as String);
|
||
if (!await imageFile.exists()) continue;
|
||
|
||
final result = await upload(
|
||
imageFile: imageFile,
|
||
originalFilename: meta['originalFilename'] as String,
|
||
userNote: meta['userNote'] as String,
|
||
);
|
||
if (result.success) {
|
||
await imageFile.delete();
|
||
await metaFile.delete();
|
||
}
|
||
} catch (_) {
|
||
// Skip problematic cache entries
|
||
}
|
||
}
|
||
}
|
||
}
|