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:
23
lib/main.dart
Normal file
23
lib/main.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'screens/home_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const SnapLedgerApp());
|
||||
}
|
||||
|
||||
class SnapLedgerApp extends StatelessWidget {
|
||||
const SnapLedgerApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'SnapLedger',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
lib/screens/home_screen.dart
Normal file
95
lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../widgets/record_bottom_sheet.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
static const _channel = MethodChannel('com.snapledger.snapledger/share');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkForSharedData();
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
if (call.method == 'onShareReceived') {
|
||||
final args = Map<String, String>.from(call.arguments as Map);
|
||||
_handleSharedData(args);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkForSharedData() async {
|
||||
try {
|
||||
final data = await _channel.invokeMethod<Map>('getSharedData');
|
||||
if (data != null && data.containsKey('uri')) {
|
||||
_handleSharedData(Map<String, String>.from(data));
|
||||
}
|
||||
} on PlatformException {
|
||||
// No shared data
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSharedData(Map<String, String> data) async {
|
||||
final filename = data['filename'] ?? 'upload.jpg';
|
||||
|
||||
// Resolve the content URI to a local file
|
||||
// Use MethodChannel to get the actual file path from content URI
|
||||
try {
|
||||
final filePath = await _channel.invokeMethod<String>('getFilePath', {'uri': data['uri']});
|
||||
if (filePath == null || !File(filePath).existsSync()) return;
|
||||
|
||||
if (!mounted) return;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => RecordBottomSheet(
|
||||
imageFile: File(filePath),
|
||||
originalFilename: filename,
|
||||
),
|
||||
);
|
||||
} on PlatformException {
|
||||
// Could not resolve file
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('SnapLedger')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.receipt_long, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'如何使用',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'1. 在支付成功页面截图\n'
|
||||
'2. 点击分享按钮\n'
|
||||
'3. 选择 SnapLedger\n'
|
||||
'4. 添加备注(可选)并提交',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, color: Color(0xFF616161)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
173
lib/services/upload_service.dart
Normal file
173
lib/services/upload_service.dart
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
lib/widgets/record_bottom_sheet.dart
Normal file
130
lib/widgets/record_bottom_sheet.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/upload_service.dart';
|
||||
|
||||
class RecordBottomSheet extends StatefulWidget {
|
||||
final File imageFile;
|
||||
final String originalFilename;
|
||||
|
||||
const RecordBottomSheet({
|
||||
super.key,
|
||||
required this.imageFile,
|
||||
required this.originalFilename,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecordBottomSheet> createState() => _RecordBottomSheetState();
|
||||
}
|
||||
|
||||
class _RecordBottomSheetState extends State<RecordBottomSheet> {
|
||||
final _noteController = TextEditingController();
|
||||
bool _isUploading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_isUploading) return;
|
||||
setState(() => _isUploading = true);
|
||||
|
||||
final result = await UploadService.upload(
|
||||
imageFile: widget.imageFile,
|
||||
originalFilename: widget.originalFilename,
|
||||
userNote: _noteController.text.trim(),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).pop();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.success ? '记账凭证已保存' : (result.error ?? '上传失败'),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Thumbnail preview
|
||||
Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(
|
||||
widget.imageFile,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const Icon(
|
||||
Icons.broken_image,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Note input
|
||||
TextField(
|
||||
controller: _noteController,
|
||||
maxLength: 200,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '添加备注(可选)...',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _isUploading ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _isUploading ? null : _submit,
|
||||
child: _isUploading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('提交'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user