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,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('提交'),
),
),
],
),
],
),
);
}
}