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>
This commit is contained in:
Misaka_Company
2026-05-21 17:05:44 +08:00
parent ff95d75eed
commit d11f33380c
133 changed files with 5672 additions and 2 deletions

View File

@@ -0,0 +1,173 @@
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
}
}
}
}