Files
android/lib/services/upload_service.dart
Misaka_Company d11f33380c Implement Phase 1 Android client: Flutter share receiver + upload service
- 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>
2026-05-21 17:05:44 +08:00

174 lines
5.3 KiB
Dart
Raw Permalink 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 '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
}
}
}
}