feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
@@ -0,0 +1,12 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final aiLeaveSuggestionRepositoryProvider =
Provider<AiLeaveSuggestionRepository>(
(ref) => AiLeaveSuggestionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
@@ -0,0 +1,15 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final formDefinitionRepositoryProvider = Provider<FormDefinitionRepository>(
(ref) => FormDefinitionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveFormDefinitionProvider = FutureProvider<LoadedFormDefinition>((ref) {
return ref.watch(formDefinitionRepositoryProvider).loadLeaveRequest();
});
@@ -0,0 +1,97 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'dart:typed_data';
import 'package:aioa_mobile/features/form/data/leave_attachment_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveAttachmentRepositoryProvider = Provider<LeaveAttachmentRepository>(
(ref) => LeaveAttachmentRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
class LeaveAttachmentState {
const LeaveAttachmentState({
this.items = const [],
this.uploading = false,
this.progress = 0,
this.error,
});
final List<LeaveAttachmentItem> items;
final bool uploading;
final double progress;
final String? error;
LeaveAttachmentState copyWith({
List<LeaveAttachmentItem>? items,
bool? uploading,
double? progress,
String? error,
bool clearError = false,
}) => LeaveAttachmentState(
items: items ?? this.items,
uploading: uploading ?? this.uploading,
progress: progress ?? this.progress,
error: clearError ? null : error ?? this.error,
);
}
final leaveAttachmentProvider =
NotifierProvider.family<
LeaveAttachmentController,
LeaveAttachmentState,
String
>((leaveRequestId) => LeaveAttachmentController(leaveRequestId));
class LeaveAttachmentController extends Notifier<LeaveAttachmentState> {
LeaveAttachmentController(this._leaveRequestId);
final String _leaveRequestId;
@override
LeaveAttachmentState build() => const LeaveAttachmentState();
Future<void> load() async {
try {
final items = await ref
.read(leaveAttachmentRepositoryProvider)
.list(_leaveRequestId);
state = state.copyWith(items: items, clearError: true);
} on LeaveAttachmentException catch (error) {
state = state.copyWith(error: error.message);
}
}
Future<void> upload({
required String fileName,
required String contentType,
required Uint8List bytes,
}) async {
if (state.uploading) return;
state = state.copyWith(uploading: true, progress: 0, clearError: true);
try {
final item = await ref
.read(leaveAttachmentRepositoryProvider)
.upload(
leaveRequestId: _leaveRequestId,
fileName: fileName,
contentType: contentType,
bytes: bytes,
onProgress: (progress) {
state = state.copyWith(progress: progress);
},
);
state = state.copyWith(
items: [...state.items, item],
uploading: false,
progress: 1,
clearError: true,
);
} on LeaveAttachmentException catch (error) {
state = state.copyWith(uploading: false, error: error.message);
}
}
}
@@ -1,21 +1,47 @@
import 'dart:async';
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
import 'package:aioa_mobile/core/forms/schema/form_validator.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:aioa_mobile/features/form/data/leave_local_draft_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveLocalDraftRepositoryProvider = Provider<LeaveLocalDraftRepository>(
(ref) => LeaveLocalDraftRepository(),
);
final leaveDraftProvider =
NotifierProvider<LeaveDraftController, DynamicFormState>(
LeaveDraftController.new,
);
class LeaveDraftController extends Notifier<DynamicFormState> {
late final LeaveLocalDraftRepository _repository;
@override
DynamicFormState build() => const DynamicFormState();
DynamicFormState build() {
_repository = ref.watch(leaveLocalDraftRepositoryProvider);
unawaited(_restore());
return const DynamicFormState();
}
Future<void> _restore() async {
final restored = await _repository.load();
if (restored == null || state.values.isNotEmpty) return;
state = DynamicFormState(
values: restored.values,
restoredAt: restored.savedAt,
);
}
void setValue(String field, Object? value) {
final values = {...state.values, field: value};
final errors = {...state.errors}..remove(field);
state = state.copyWith(values: values, errors: errors);
state = state.copyWith(
values: values,
errors: errors,
clearRestoredAt: true,
);
unawaited(_repository.save(values));
}
void applyAiSuggestion() {
@@ -36,13 +62,22 @@ class LeaveDraftController extends Notifier<DynamicFormState> {
'reason': '办理个人事务,已提前完成工作交接。',
},
);
unawaited(_repository.save(state.values));
}
bool validate() {
final errors = validateDynamicForm(
leaveFormDefinition.dataSchema,
state.values,
);
void applySuggestion(Map<String, Object?> suggestion) {
final values = {...state.values, ...suggestion};
state = DynamicFormState(values: values);
unawaited(_repository.save(values));
}
Future<void> clear() async {
await _repository.clear();
state = const DynamicFormState();
}
bool validate(JsonFormSchema schema) {
final errors = validateDynamicForm(schema, state.values);
final startsAt = DateTime.tryParse(
state.values['startsAt'] as String? ?? '',
);
@@ -0,0 +1,44 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/leave_draft_submission_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveDraftSubmissionRepositoryProvider =
Provider<LeaveDraftSubmissionRepository>(
(ref) => LeaveDraftSubmissionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveSubmissionProvider =
NotifierProvider<LeaveSubmissionController, LeaveSubmissionState>(
LeaveSubmissionController.new,
);
class LeaveSubmissionState {
const LeaveSubmissionState({this.submitting = false, this.error});
final bool submitting;
final String? error;
}
class LeaveSubmissionController extends Notifier<LeaveSubmissionState> {
@override
LeaveSubmissionState build() => const LeaveSubmissionState();
Future<CreatedLeaveDraft?> submit(Map<String, Object?> values) async {
if (state.submitting) return null;
state = const LeaveSubmissionState(submitting: true);
try {
final created = await ref
.read(leaveDraftSubmissionRepositoryProvider)
.create(values);
state = const LeaveSubmissionState();
return created;
} on LeaveDraftSubmissionException catch (error) {
state = LeaveSubmissionState(error: error.message);
return null;
}
}
}
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class AiLeaveSuggestion {
const AiLeaveSuggestion({
required this.values,
required this.assumptions,
required this.needsClarification,
required this.model,
});
final Map<String, Object?> values;
final List<String> assumptions;
final List<String> needsClarification;
final String model;
}
class AiLeaveSuggestionException implements Exception {
const AiLeaveSuggestionException(this.message);
final String message;
@override
String toString() => message;
}
class AiLeaveSuggestionRepository {
AiLeaveSuggestionRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<AiLeaveSuggestion> suggest(String text) async {
final response = await _client.post(
Uri.parse('$baseUrl/ai/leave-draft-suggestions'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({'text': text.trim(), 'timezone': 'Asia/Shanghai'}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
String? message;
try {
message =
(jsonDecode(response.body) as Map<String, Object?>)['detail']
as String?;
} catch (_) {}
throw AiLeaveSuggestionException(
message ?? 'AI 建议生成失败(${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
if (json['requiresUserConfirmation'] != true) {
throw const AiLeaveSuggestionException('AI 响应缺少用户确认保护');
}
final suggestion = Map<String, Object?>.from(json['suggestion']! as Map);
return AiLeaveSuggestion(
values: {
for (final key in const ['type', 'startsAt', 'endsAt', 'reason'])
if (suggestion[key] != null) key: suggestion[key],
},
assumptions: ((suggestion['assumptions'] as List?) ?? const [])
.cast<String>(),
needsClarification:
((suggestion['needsClarification'] as List?) ?? const [])
.cast<String>(),
model: json['model']! as String,
);
}
}
@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
enum FormDefinitionSource { remote, cache, bundled }
class LoadedFormDefinition {
const LoadedFormDefinition({required this.definition, required this.source});
final DynamicFormDefinition definition;
final FormDefinitionSource source;
}
class FormDefinitionRepository {
FormDefinitionRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
static const _cacheKey = 'form-definition.leave-request.v1';
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<LoadedFormDefinition> loadLeaveRequest() async {
final preferences = await SharedPreferences.getInstance();
try {
final response = await _client
.get(
Uri.parse('$baseUrl/form-definitions/leave-request'),
headers: {
'Accept': 'application/json',
if (accessToken.isNotEmpty)
'Authorization': 'Bearer $accessToken',
},
)
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) {
throw http.ClientException(
'Form definition request failed: ${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
final definition = DynamicFormDefinition.fromJson(json);
try {
await preferences.setString(_cacheKey, response.body);
} catch (_) {
// A valid remote definition remains usable even if local persistence
// is temporarily unavailable.
}
return LoadedFormDefinition(
definition: definition,
source: FormDefinitionSource.remote,
);
} catch (_) {
final cached = preferences.getString(_cacheKey);
if (cached != null) {
try {
return LoadedFormDefinition(
definition: DynamicFormDefinition.fromJson(
jsonDecode(cached) as Map<String, Object?>,
),
source: FormDefinitionSource.cache,
);
} catch (_) {
await preferences.remove(_cacheKey);
}
}
return const LoadedFormDefinition(
definition: leaveFormDefinition,
source: FormDefinitionSource.bundled,
);
}
}
}
@@ -0,0 +1,147 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
class LeaveAttachmentItem {
const LeaveAttachmentItem({
required this.id,
required this.fileName,
required this.contentType,
required this.sizeBytes,
required this.status,
});
final String id;
final String fileName;
final String contentType;
final int sizeBytes;
final String status;
factory LeaveAttachmentItem.fromJson(Map<String, Object?> json) {
return LeaveAttachmentItem(
id: json['id']! as String,
fileName: json['fileName']! as String,
contentType: json['contentType']! as String,
sizeBytes: json['sizeBytes']! as int,
status: json['status']! as String,
);
}
}
class LeaveAttachmentException implements Exception {
const LeaveAttachmentException(this.message);
final String message;
@override
String toString() => message;
}
class LeaveAttachmentRepository {
LeaveAttachmentRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<LeaveAttachmentItem> upload({
required String leaveRequestId,
required String fileName,
required String contentType,
required Uint8List bytes,
required void Function(double progress) onProgress,
}) async {
if (bytes.isEmpty || bytes.length > 10 * 1024 * 1024) {
throw const LeaveAttachmentException('附件大小必须在 1 字节到 10 MB 之间');
}
final taskResponse = await _client.post(
Uri.parse(
'$baseUrl/leave-requests/$leaveRequestId/attachments/upload-tasks',
),
headers: _jsonHeaders,
body: jsonEncode({
'fileName': fileName,
'contentType': contentType,
'sizeBytes': bytes.length,
}),
);
_requireSuccess(taskResponse, {201});
final task = jsonDecode(taskResponse.body) as Map<String, Object?>;
final attachment = Map<String, Object?>.from(task['attachment']! as Map);
final attachmentId = attachment['id']! as String;
final uploadRequest = http.StreamedRequest(
'PUT',
Uri.parse(task['uploadUrl']! as String),
);
uploadRequest.headers['Content-Type'] = contentType;
uploadRequest.contentLength = bytes.length;
final uploadResponseFuture = _client.send(uploadRequest);
const chunkSize = 64 * 1024;
var sent = 0;
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
final end = (offset + chunkSize).clamp(0, bytes.length);
uploadRequest.sink.add(bytes.sublist(offset, end));
sent = end;
onProgress(sent / bytes.length);
}
await uploadRequest.sink.close();
final uploadResponse = await uploadResponseFuture;
if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) {
throw LeaveAttachmentException('对象存储上传失败(${uploadResponse.statusCode}');
}
final completeResponse = await _client.post(
Uri.parse(
'$baseUrl/leave-requests/$leaveRequestId/attachments/$attachmentId/complete',
),
headers: _authHeaders,
);
_requireSuccess(completeResponse, {200});
onProgress(1);
return LeaveAttachmentItem.fromJson(
jsonDecode(completeResponse.body) as Map<String, Object?>,
);
}
Future<List<LeaveAttachmentItem>> list(String leaveRequestId) async {
final response = await _client.get(
Uri.parse('$baseUrl/leave-requests/$leaveRequestId/attachments'),
headers: _authHeaders,
);
_requireSuccess(response, {200});
return (jsonDecode(response.body) as List)
.map(
(item) => LeaveAttachmentItem.fromJson(
Map<String, Object?>.from(item as Map),
),
)
.toList();
}
Map<String, String> get _authHeaders => {
'Accept': 'application/json',
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
};
Map<String, String> get _jsonHeaders => {
..._authHeaders,
'Content-Type': 'application/json',
};
void _requireSuccess(http.Response response, Set<int> expected) {
if (expected.contains(response.statusCode)) return;
String? message;
try {
final problem = jsonDecode(response.body) as Map<String, Object?>;
message = problem['detail'] as String?;
} catch (_) {}
throw LeaveAttachmentException(message ?? '附件请求失败(${response.statusCode}');
}
}
@@ -0,0 +1,144 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class CreatedLeaveDraft {
const CreatedLeaveDraft({
required this.id,
required this.status,
required this.version,
});
final String id;
final String status;
final int version;
}
class LeaveDraftSubmissionException implements Exception {
const LeaveDraftSubmissionException(this.message, {this.retryable = true});
final String message;
final bool retryable;
@override
String toString() => message;
}
class LeaveDraftSubmissionRepository {
LeaveDraftSubmissionRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
static const pendingStorageKey = 'leave-request.pending-create.v1';
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<CreatedLeaveDraft> create(Map<String, Object?> values) async {
final requestBody = _requestBody(values);
final payload = jsonEncode(requestBody);
final preferences = await SharedPreferences.getInstance();
final pending = _readPending(preferences);
final idempotencyKey = pending?.payload == payload
? pending!.idempotencyKey
: _newIdempotencyKey();
await preferences.setString(
pendingStorageKey,
jsonEncode({'idempotencyKey': idempotencyKey, 'payload': payload}),
);
late http.Response response;
try {
response = await _client
.post(
Uri.parse('$baseUrl/leave-requests/drafts'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
'Idempotency-Key': idempotencyKey,
},
body: payload,
)
.timeout(const Duration(seconds: 10));
} catch (_) {
throw const LeaveDraftSubmissionException('网络不可用,已保存请求,可安全重试');
}
if (response.statusCode != 200 && response.statusCode != 201) {
final message =
_problemMessage(response.body) ?? '创建草稿失败(${response.statusCode}';
final retryable =
response.statusCode >= 500 || response.statusCode == 401;
if (!retryable) await preferences.remove(pendingStorageKey);
throw LeaveDraftSubmissionException(message, retryable: retryable);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
await preferences.remove(pendingStorageKey);
return CreatedLeaveDraft(
id: json['id']! as String,
status: json['status']! as String,
version: json['version']! as int,
);
}
Map<String, Object?> _requestBody(Map<String, Object?> values) => {
'type': values['type'],
'startsAt': values['startsAt'],
'endsAt': values['endsAt'],
'reason': values['reason'],
'version': 0,
};
_PendingSubmission? _readPending(SharedPreferences preferences) {
final encoded = preferences.getString(pendingStorageKey);
if (encoded == null) return null;
try {
final json = jsonDecode(encoded) as Map<String, Object?>;
return _PendingSubmission(
idempotencyKey: json['idempotencyKey']! as String,
payload: json['payload']! as String,
);
} catch (_) {
preferences.remove(pendingStorageKey);
return null;
}
}
String _newIdempotencyKey() {
final random = Random.secure();
final entropy = List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
return 'leave-${DateTime.now().microsecondsSinceEpoch}-$entropy';
}
String? _problemMessage(String body) {
try {
final problem = jsonDecode(body) as Map<String, Object?>;
return problem['detail'] as String? ?? problem['title'] as String?;
} catch (_) {
return null;
}
}
}
class _PendingSubmission {
const _PendingSubmission({
required this.idempotencyKey,
required this.payload,
});
final String idempotencyKey;
final String payload;
}
@@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class RestoredLeaveDraft {
const RestoredLeaveDraft({required this.values, required this.savedAt});
final Map<String, Object?> values;
final DateTime savedAt;
}
class LeaveLocalDraftRepository {
static const storageKey = 'leave-request.local-draft.v1';
Future<void> save(Map<String, Object?> values) async {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(
storageKey,
jsonEncode({
'version': 1,
'savedAt': DateTime.now().toUtc().toIso8601String(),
'values': values,
}),
);
}
Future<RestoredLeaveDraft?> load() async {
final preferences = await SharedPreferences.getInstance();
final encoded = preferences.getString(storageKey);
if (encoded == null) return null;
try {
final envelope = jsonDecode(encoded) as Map<String, Object?>;
if (envelope['version'] != 1) throw const FormatException();
final savedAt = DateTime.parse(envelope['savedAt']! as String);
final values = Map<String, Object?>.from(envelope['values']! as Map);
return RestoredLeaveDraft(values: values, savedAt: savedAt);
} catch (_) {
await preferences.remove(storageKey);
return null;
}
}
Future<void> clear() async {
final preferences = await SharedPreferences.getInstance();
await preferences.remove(storageKey);
}
}
@@ -0,0 +1,138 @@
import 'package:aioa_mobile/features/form/application/leave_attachment_controller.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LeaveAttachmentSheet extends ConsumerStatefulWidget {
const LeaveAttachmentSheet({required this.leaveRequestId, super.key});
final String leaveRequestId;
@override
ConsumerState<LeaveAttachmentSheet> createState() =>
_LeaveAttachmentSheetState();
}
class _LeaveAttachmentSheetState extends ConsumerState<LeaveAttachmentSheet> {
@override
void initState() {
super.initState();
Future.microtask(
() => ref
.read(leaveAttachmentProvider(widget.leaveRequestId).notifier)
.load(),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(leaveAttachmentProvider(widget.leaveRequestId));
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
20 + MediaQuery.viewInsetsOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'添加附件',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
const Text('支持 JPEG、PNG、PDF,单个文件不超过 10 MB。文件将直接上传至对象存储。'),
if (state.error != null) ...[
const SizedBox(height: 10),
Text(
state.error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (state.uploading) ...[
const SizedBox(height: 14),
LinearProgressIndicator(value: state.progress),
const SizedBox(height: 6),
Text('正在上传 ${(state.progress * 100).round()}%'),
],
if (state.items.isNotEmpty) ...[
const SizedBox(height: 14),
for (final item in state.items)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
item.contentType == 'application/pdf'
? Icons.picture_as_pdf_outlined
: Icons.image_outlined,
),
title: Text(
item.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${_formatBytes(item.sizeBytes)} · ${item.status == 'READY' ? '已上传' : '处理中'}',
),
trailing: item.status == 'READY'
? const Icon(Icons.check_circle, color: Colors.green)
: null,
),
],
const SizedBox(height: 14),
OutlinedButton.icon(
onPressed: state.uploading ? null : _pickAndUpload,
icon: const Icon(Icons.attach_file),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('选择附件'),
),
),
const SizedBox(height: 8),
FilledButton(
onPressed: state.uploading ? null : () => Navigator.pop(context),
child: const Text('完成'),
),
],
),
),
);
}
Future<void> _pickAndUpload() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: const ['jpg', 'jpeg', 'png', 'pdf'],
withData: true,
);
if (result == null || !mounted) return;
final file = result.files.single;
final bytes = file.bytes;
if (bytes == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法读取所选文件')));
return;
}
final contentType = switch (file.extension?.toLowerCase()) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'pdf' => 'application/pdf',
_ => null,
};
if (contentType == null) return;
await ref
.read(leaveAttachmentProvider(widget.leaveRequestId).notifier)
.upload(fileName: file.name, contentType: contentType, bytes: bytes);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
@@ -1,8 +1,13 @@
import 'dart:convert';
import 'package:aioa_mobile/core/forms/presentation/dynamic_form_card.dart';
import 'package:aioa_mobile/features/form/application/form_definition_controller.dart';
import 'package:aioa_mobile/features/form/application/ai_leave_suggestion_provider.dart';
import 'package:aioa_mobile/features/form/application/leave_draft_controller.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:aioa_mobile/features/form/application/leave_submission_controller.dart';
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
import 'package:aioa_mobile/features/form/presentation/leave_attachment_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -13,51 +18,154 @@ class LeaveFormPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final formState = ref.watch(leaveDraftProvider);
final controller = ref.read(leaveDraftProvider.notifier);
final loadedDefinition = ref.watch(leaveFormDefinitionProvider);
final submission = ref.watch(leaveSubmissionProvider);
return Scaffold(
appBar: AppBar(title: Text(leaveFormDefinition.dataSchema.title)),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_AiAssistCard(onApply: controller.applyAiSuggestion),
const SizedBox(height: 14),
DynamicFormCard(
definition: leaveFormDefinition,
state: formState,
onChanged: controller.setValue,
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () {
if (!controller.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请检查表单中的必填项和时间范围')),
);
return;
}
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => _ConfirmationSheet(
values: ref.read(leaveDraftProvider).values,
),
);
},
icon: const Icon(Icons.check_circle_outline),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('检查并确认'),
appBar: AppBar(title: const Text('请假申请')),
body: loadedDefinition.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('表单定义加载失败:$error')),
data: (loaded) => ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
_DefinitionSourceBanner(source: loaded.source),
if (formState.restoredAt != null) ...[
const SizedBox(height: 8),
_RestoredDraftBanner(onClear: controller.clear),
],
const SizedBox(height: 8),
_AiAssistCard(onApply: controller.applySuggestion),
const SizedBox(height: 14),
DynamicFormCard(
definition: loaded.definition,
state: formState,
onChanged: controller.setValue,
),
),
],
const SizedBox(height: 8),
FilledButton.icon(
onPressed: submission.submitting
? null
: () async {
if (!controller.validate(loaded.definition.dataSchema)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请检查表单中的必填项和时间范围')),
);
return;
}
final confirmed = await showModalBottomSheet<bool>(
context: context,
showDragHandle: true,
builder: (context) => _ConfirmationSheet(
values: ref.read(leaveDraftProvider).values,
),
);
if (confirmed != true || !context.mounted) return;
final created = await ref
.read(leaveSubmissionProvider.notifier)
.submit(ref.read(leaveDraftProvider).values);
if (!context.mounted) return;
if (created == null) {
final message = ref.read(leaveSubmissionProvider).error;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message ?? '创建草稿失败')),
);
return;
}
await controller.clear();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('后端草稿已创建:${created.id}')),
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
LeaveAttachmentSheet(leaveRequestId: created.id),
);
},
icon: submission.submitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check_circle_outline),
label: Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text(submission.submitting ? '正在创建草稿…' : '检查并确认'),
),
),
],
),
),
);
}
}
class _AiAssistCard extends StatelessWidget {
class _RestoredDraftBanner extends StatelessWidget {
const _RestoredDraftBanner({required this.onClear});
final Future<void> Function() onClear;
@override
Widget build(BuildContext context) {
return MaterialBanner(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
leading: const Icon(Icons.restore),
content: const Text('已恢复上次未完成的本地草稿'),
actions: [TextButton(onPressed: onClear, child: const Text('清除'))],
);
}
}
class _DefinitionSourceBanner extends StatelessWidget {
const _DefinitionSourceBanner({required this.source});
final FormDefinitionSource source;
@override
Widget build(BuildContext context) {
final (icon, text) = switch (source) {
FormDefinitionSource.remote => (Icons.cloud_done_outlined, '已加载最新表单定义'),
FormDefinitionSource.cache => (
Icons.offline_bolt_outlined,
'当前离线,使用已缓存表单定义',
),
FormDefinitionSource.bundled => (
Icons.inventory_2_outlined,
'当前离线,使用内置安全表单定义',
),
};
return Row(
children: [
Icon(icon, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(text, style: Theme.of(context).textTheme.bodySmall),
),
],
);
}
}
class _AiAssistCard extends ConsumerStatefulWidget {
const _AiAssistCard({required this.onApply});
final VoidCallback onApply;
final void Function(Map<String, Object?> values) onApply;
@override
ConsumerState<_AiAssistCard> createState() => _AiAssistCardState();
}
class _AiAssistCardState extends ConsumerState<_AiAssistCard> {
final textController = TextEditingController(text: '明天下午请事假四小时,办理个人事务');
bool loading = false;
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -67,29 +175,75 @@ class _AiAssistCard extends StatelessWidget {
).colorScheme.primaryContainer.withValues(alpha: 0.45),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CircleAvatar(child: Icon(Icons.auto_awesome)),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI 表单助手',
const Row(
children: [
CircleAvatar(child: Icon(Icons.auto_awesome)),
SizedBox(width: 12),
Expanded(
child: Text(
'千问表单助手',
style: TextStyle(fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
Text('示例:帮我填写明天下午的事假申请'),
],
),
],
),
const SizedBox(height: 12),
TextField(
controller: textController,
minLines: 2,
maxLines: 4,
maxLength: 2000,
decoration: const InputDecoration(
hintText: '例如:明天下午请事假四小时,办理个人事务',
border: OutlineInputBorder(),
),
),
TextButton(onPressed: onApply, child: const Text('自动填写')),
FilledButton.icon(
onPressed: loading ? null : _suggest,
icon: loading
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
label: Text(loading ? '正在生成建议…' : '生成草稿建议'),
),
],
),
),
);
}
Future<void> _suggest() async {
if (textController.text.trim().isEmpty) return;
setState(() => loading = true);
try {
final suggestion = await ref
.read(aiLeaveSuggestionRepositoryProvider)
.suggest(textController.text);
if (!mounted) return;
widget.onApply(suggestion.values);
final notes = [
...suggestion.assumptions,
...suggestion.needsClarification,
];
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(notes.isEmpty ? 'AI 建议已填入,请检查并确认' : notes.join('')),
),
);
} on AiLeaveSuggestionException catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.message)));
} finally {
if (mounted) setState(() => loading = false);
}
}
}
class _ConfirmationSheet extends StatelessWidget {
@@ -128,10 +282,7 @@ class _ConfirmationSheet extends StatelessWidget {
const SizedBox(height: 16),
FilledButton(
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('演示模式:草稿已通过本地校验,尚未调用后端')),
);
Navigator.pop(context, true);
},
child: const Text('确认创建草稿'),
),