feat(frontend): 添加电子合同签署功能前端实现
- 添加 ContractSigningService 合同签署 API 调用服务 - 添加 ContractCheckService App 启动时检查待签署合同 - 添加 ContractSigningPage 完整签署流程页面 - 24小时倒计时 - 滚动阅读 → 确认法律效力 → 手写签名 - 支持超时后补签 - 添加 PendingContractsPage 待签署合同列表 - 添加 SignaturePad 手写签名板组件 - HomeShellPage 启动时检查未签署合同,强制签署 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
714ce42e4f
commit
5b8c6bc317
|
|
@ -12,6 +12,8 @@ import '../services/planting_service.dart';
|
|||
import '../services/reward_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/system_config_service.dart';
|
||||
import '../services/contract_signing_service.dart';
|
||||
import '../services/contract_check_service.dart';
|
||||
|
||||
// Storage Providers
|
||||
final secureStorageProvider = Provider<SecureStorage>((ref) {
|
||||
|
|
@ -93,6 +95,18 @@ final systemConfigServiceProvider = Provider<SystemConfigService>((ref) {
|
|||
return SystemConfigService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// Contract Signing Service Provider (调用 planting-service)
|
||||
final contractSigningServiceProvider = Provider<ContractSigningService>((ref) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return ContractSigningService(apiClient: apiClient);
|
||||
});
|
||||
|
||||
// Contract Check Service Provider (用于启动时检查未签署合同)
|
||||
final contractCheckServiceProvider = Provider<ContractCheckService>((ref) {
|
||||
final contractSigningService = ref.watch(contractSigningServiceProvider);
|
||||
return ContractCheckService(contractSigningService: contractSigningService);
|
||||
});
|
||||
|
||||
// Override provider with initialized instance
|
||||
ProviderContainer createProviderContainer(LocalStorage localStorage) {
|
||||
return ProviderContainer(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'contract_signing_service.dart';
|
||||
|
||||
/// 合同检查服务
|
||||
/// 用于在 App 启动时检查用户是否有未签署的合同
|
||||
class ContractCheckService {
|
||||
final ContractSigningService _contractSigningService;
|
||||
|
||||
ContractCheckService({
|
||||
required ContractSigningService contractSigningService,
|
||||
}) : _contractSigningService = contractSigningService;
|
||||
|
||||
/// 检查是否有待签署的合同
|
||||
/// 返回 true 表示有待签署合同,需要强制签署
|
||||
Future<bool> hasPendingContracts() async {
|
||||
try {
|
||||
debugPrint('[ContractCheckService] 检查待签署合同...');
|
||||
|
||||
// 获取未签署任务(包括待签署和超时未签署的)
|
||||
final unsignedTasks = await _contractSigningService.getUnsignedTasks();
|
||||
|
||||
final hasPending = unsignedTasks.isNotEmpty;
|
||||
debugPrint('[ContractCheckService] 未签署合同数量: ${unsignedTasks.length}');
|
||||
|
||||
return hasPending;
|
||||
} catch (e) {
|
||||
debugPrint('[ContractCheckService] 检查待签署合同失败: $e');
|
||||
// 检查失败时不阻止用户使用 App
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取待签署合同数量
|
||||
Future<int> getPendingContractCount() async {
|
||||
try {
|
||||
final unsignedTasks = await _contractSigningService.getUnsignedTasks();
|
||||
return unsignedTasks.length;
|
||||
} catch (e) {
|
||||
debugPrint('[ContractCheckService] 获取待签署合同数量失败: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
/// 合同签署状态
|
||||
enum ContractSigningStatus {
|
||||
pending, // 待签署
|
||||
scrolled, // 已滚动到底部
|
||||
acknowledged, // 已确认法律效力
|
||||
signed, // 已签署
|
||||
unsignedTimeout, // 超时未签署
|
||||
}
|
||||
|
||||
/// 合同签署任务
|
||||
class ContractSigningTask {
|
||||
final String orderNo;
|
||||
final String accountSequence;
|
||||
final ContractSigningStatus status;
|
||||
final String contractVersion;
|
||||
final String contractContent;
|
||||
final int treeCount;
|
||||
final double totalAmount;
|
||||
final String provinceName;
|
||||
final String cityName;
|
||||
final DateTime expiresAt;
|
||||
final DateTime? scrolledToBottomAt;
|
||||
final DateTime? acknowledgedAt;
|
||||
final DateTime? signedAt;
|
||||
final String? signatureCloudUrl;
|
||||
final DateTime createdAt;
|
||||
|
||||
ContractSigningTask({
|
||||
required this.orderNo,
|
||||
required this.accountSequence,
|
||||
required this.status,
|
||||
required this.contractVersion,
|
||||
required this.contractContent,
|
||||
required this.treeCount,
|
||||
required this.totalAmount,
|
||||
required this.provinceName,
|
||||
required this.cityName,
|
||||
required this.expiresAt,
|
||||
this.scrolledToBottomAt,
|
||||
this.acknowledgedAt,
|
||||
this.signedAt,
|
||||
this.signatureCloudUrl,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ContractSigningTask.fromJson(Map<String, dynamic> json) {
|
||||
return ContractSigningTask(
|
||||
orderNo: json['orderNo'] ?? '',
|
||||
accountSequence: json['accountSequence'] ?? '',
|
||||
status: _parseStatus(json['status']),
|
||||
contractVersion: json['contractVersion'] ?? '',
|
||||
contractContent: json['contractContent'] ?? '',
|
||||
treeCount: json['treeCount'] ?? 0,
|
||||
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
|
||||
provinceName: json['provinceName'] ?? '',
|
||||
cityName: json['cityName'] ?? '',
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.parse(json['expiresAt'])
|
||||
: DateTime.now().add(const Duration(hours: 24)),
|
||||
scrolledToBottomAt: json['scrolledToBottomAt'] != null
|
||||
? DateTime.parse(json['scrolledToBottomAt'])
|
||||
: null,
|
||||
acknowledgedAt: json['acknowledgedAt'] != null
|
||||
? DateTime.parse(json['acknowledgedAt'])
|
||||
: null,
|
||||
signedAt: json['signedAt'] != null
|
||||
? DateTime.parse(json['signedAt'])
|
||||
: null,
|
||||
signatureCloudUrl: json['signatureCloudUrl'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'])
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
static ContractSigningStatus _parseStatus(String? status) {
|
||||
switch (status) {
|
||||
case 'PENDING':
|
||||
return ContractSigningStatus.pending;
|
||||
case 'SCROLLED':
|
||||
return ContractSigningStatus.scrolled;
|
||||
case 'ACKNOWLEDGED':
|
||||
return ContractSigningStatus.acknowledged;
|
||||
case 'SIGNED':
|
||||
return ContractSigningStatus.signed;
|
||||
case 'UNSIGNED_TIMEOUT':
|
||||
return ContractSigningStatus.unsignedTimeout;
|
||||
default:
|
||||
return ContractSigningStatus.pending;
|
||||
}
|
||||
}
|
||||
|
||||
/// 剩余时间(秒)
|
||||
int get remainingSeconds {
|
||||
final now = DateTime.now();
|
||||
if (now.isAfter(expiresAt)) return 0;
|
||||
return expiresAt.difference(now).inSeconds;
|
||||
}
|
||||
|
||||
/// 是否已过期
|
||||
bool get isExpired => remainingSeconds <= 0;
|
||||
|
||||
/// 是否需要签署(未完成且未过期)
|
||||
bool get needsSign =>
|
||||
status != ContractSigningStatus.signed &&
|
||||
status != ContractSigningStatus.unsignedTimeout;
|
||||
}
|
||||
|
||||
/// 合同模板
|
||||
class ContractTemplate {
|
||||
final String version;
|
||||
final String title;
|
||||
final String content;
|
||||
|
||||
ContractTemplate({
|
||||
required this.version,
|
||||
required this.title,
|
||||
required this.content,
|
||||
});
|
||||
|
||||
factory ContractTemplate.fromJson(Map<String, dynamic> json) {
|
||||
return ContractTemplate(
|
||||
version: json['version'] ?? '',
|
||||
title: json['title'] ?? '',
|
||||
content: json['content'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 合同签署服务
|
||||
class ContractSigningService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
ContractSigningService({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
/// 获取待签署任务列表
|
||||
Future<List<ContractSigningTask>> getPendingTasks() async {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 获取待签署任务列表');
|
||||
|
||||
final response = await _apiClient.get('/planting/contract-signing/pending');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as List<dynamic>;
|
||||
debugPrint('[ContractSigningService] 待签署任务数量: ${data.length}');
|
||||
return data.map((e) => ContractSigningTask.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
throw Exception('获取待签署任务失败: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 获取待签署任务失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取未签署任务列表(包括超时的)
|
||||
Future<List<ContractSigningTask>> getUnsignedTasks() async {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 获取未签署任务列表');
|
||||
|
||||
final response = await _apiClient.get('/planting/contract-signing/unsigned');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as List<dynamic>;
|
||||
debugPrint('[ContractSigningService] 未签署任务数量: ${data.length}');
|
||||
return data.map((e) => ContractSigningTask.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
throw Exception('获取未签署任务失败: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 获取未签署任务失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取签署任务详情
|
||||
Future<ContractSigningTask> getTask(String orderNo) async {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 获取签署任务详情: $orderNo');
|
||||
|
||||
final response = await _apiClient.get('/planting/contract-signing/tasks/$orderNo');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return ContractSigningTask.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('获取签署任务详情失败: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 获取签署任务详情失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记已滚动到底部
|
||||
Future<ContractSigningTask> markScrollComplete(String orderNo) async {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 标记已滚动到底部: $orderNo');
|
||||
|
||||
final response = await _apiClient.post(
|
||||
'/planting/contract-signing/tasks/$orderNo/scroll-complete',
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('[ContractSigningService] 滚动标记成功');
|
||||
return ContractSigningTask.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('标记滚动失败: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 标记滚动失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认法律效力
|
||||
Future<ContractSigningTask> acknowledgeContract(String orderNo) async {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 确认法律效力: $orderNo');
|
||||
|
||||
final response = await _apiClient.post(
|
||||
'/planting/contract-signing/tasks/$orderNo/acknowledge',
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('[ContractSigningService] 确认法律效力成功');
|
||||
return ContractSigningTask.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('确认法律效力失败: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 确认法律效力失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 签署合同
|
||||
Future<ContractSigningTask> signContract({
|
||||
required String orderNo,
|
||||
required Uint8List signatureImage,
|
||||
String? ipAddress,
|
||||
String? deviceInfo,
|
||||
String? userAgent,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 签署合同: $orderNo');
|
||||
|
||||
// 将签名图片转为 base64
|
||||
final signatureBase64 = base64Encode(signatureImage);
|
||||
|
||||
final response = await _apiClient.post(
|
||||
'/planting/contract-signing/tasks/$orderNo/sign',
|
||||
data: {
|
||||
'signatureImage': signatureBase64,
|
||||
'ipAddress': ipAddress,
|
||||
'deviceInfo': deviceInfo,
|
||||
'userAgent': userAgent,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('[ContractSigningService] 签署成功');
|
||||
return ContractSigningTask.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('签署合同失败: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 签署合同失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 补签合同(超时后)
|
||||
Future<ContractSigningTask> lateSignContract({
|
||||
required String orderNo,
|
||||
required Uint8List signatureImage,
|
||||
String? ipAddress,
|
||||
String? deviceInfo,
|
||||
String? userAgent,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 补签合同: $orderNo');
|
||||
|
||||
final signatureBase64 = base64Encode(signatureImage);
|
||||
|
||||
final response = await _apiClient.post(
|
||||
'/planting/contract-signing/tasks/$orderNo/late-sign',
|
||||
data: {
|
||||
'signatureImage': signatureBase64,
|
||||
'ipAddress': ipAddress,
|
||||
'deviceInfo': deviceInfo,
|
||||
'userAgent': userAgent,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
debugPrint('[ContractSigningService] 补签成功');
|
||||
return ContractSigningTask.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('补签合同失败: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 补签合同失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前合同模板
|
||||
Future<ContractTemplate> getCurrentTemplate() async {
|
||||
try {
|
||||
debugPrint('[ContractSigningService] 获取当前合同模板');
|
||||
|
||||
final response = await _apiClient.get('/planting/contract-signing/template');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return ContractTemplate.fromJson(data);
|
||||
}
|
||||
|
||||
throw Exception('获取合同模板失败: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('[ContractSigningService] 获取合同模板失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Base64 编码函数
|
||||
String base64Encode(Uint8List bytes) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
final result = StringBuffer();
|
||||
|
||||
for (var i = 0; i < bytes.length; i += 3) {
|
||||
final b0 = bytes[i];
|
||||
final b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||
final b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||
|
||||
result.write(chars[(b0 >> 2) & 0x3F]);
|
||||
result.write(chars[((b0 << 4) | (b1 >> 4)) & 0x3F]);
|
||||
|
||||
if (i + 1 < bytes.length) {
|
||||
result.write(chars[((b1 << 2) | (b2 >> 6)) & 0x3F]);
|
||||
} else {
|
||||
result.write('=');
|
||||
}
|
||||
|
||||
if (i + 2 < bytes.length) {
|
||||
result.write(chars[b2 & 0x3F]);
|
||||
} else {
|
||||
result.write('=');
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
|
@ -0,0 +1,917 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/contract_signing_service.dart';
|
||||
import '../widgets/signature_pad.dart';
|
||||
|
||||
/// 合同签署页面
|
||||
class ContractSigningPage extends ConsumerStatefulWidget {
|
||||
final String orderNo;
|
||||
|
||||
const ContractSigningPage({
|
||||
super.key,
|
||||
required this.orderNo,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ContractSigningPage> createState() => _ContractSigningPageState();
|
||||
}
|
||||
|
||||
class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
|
||||
/// 签署任务
|
||||
ContractSigningTask? _task;
|
||||
|
||||
/// 是否正在加载
|
||||
bool _isLoading = true;
|
||||
|
||||
/// 是否已滚动到底部
|
||||
bool _hasScrolledToBottom = false;
|
||||
|
||||
/// 是否已确认法律效力
|
||||
bool _hasAcknowledged = false;
|
||||
|
||||
/// 是否显示签名面板
|
||||
bool _showSignaturePad = false;
|
||||
|
||||
/// 是否正在提交
|
||||
bool _isSubmitting = false;
|
||||
|
||||
/// 错误信息
|
||||
String? _errorMessage;
|
||||
|
||||
/// 倒计时定时器
|
||||
Timer? _countdownTimer;
|
||||
|
||||
/// 剩余秒数
|
||||
int _remainingSeconds = 0;
|
||||
|
||||
/// 滚动控制器
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTask();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_countdownTimer?.cancel();
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载签署任务
|
||||
Future<void> _loadTask() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final service = ref.read(contractSigningServiceProvider);
|
||||
final task = await service.getTask(widget.orderNo);
|
||||
|
||||
setState(() {
|
||||
_task = task;
|
||||
_isLoading = false;
|
||||
_remainingSeconds = task.remainingSeconds;
|
||||
_hasScrolledToBottom = task.scrolledToBottomAt != null;
|
||||
_hasAcknowledged = task.acknowledgedAt != null;
|
||||
});
|
||||
|
||||
// 启动倒计时
|
||||
_startCountdown();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = '加载签署任务失败: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动倒计时
|
||||
void _startCountdown() {
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_remainingSeconds > 0) {
|
||||
setState(() {
|
||||
_remainingSeconds--;
|
||||
});
|
||||
} else {
|
||||
timer.cancel();
|
||||
// 超时处理
|
||||
_handleTimeout();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 处理超时
|
||||
void _handleTimeout() {
|
||||
if (_task?.status != ContractSigningStatus.signed) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('签署超时'),
|
||||
content: const Text('您的签署时间已超时,合同将标记为未签署状态。您可以在后续补签。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.pop(false);
|
||||
},
|
||||
child: const Text('知道了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 监听滚动
|
||||
void _onScroll() {
|
||||
if (_hasScrolledToBottom) return;
|
||||
|
||||
final maxScroll = _scrollController.position.maxScrollExtent;
|
||||
final currentScroll = _scrollController.position.pixels;
|
||||
|
||||
// 滚动到底部 90% 以上视为已读完
|
||||
if (currentScroll >= maxScroll * 0.9) {
|
||||
_markScrollComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记已滚动到底部
|
||||
Future<void> _markScrollComplete() async {
|
||||
if (_hasScrolledToBottom || _task == null) return;
|
||||
|
||||
try {
|
||||
final service = ref.read(contractSigningServiceProvider);
|
||||
final updatedTask = await service.markScrollComplete(widget.orderNo);
|
||||
|
||||
setState(() {
|
||||
_task = updatedTask;
|
||||
_hasScrolledToBottom = true;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('标记滚动失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 确认法律效力
|
||||
Future<void> _acknowledgeContract() async {
|
||||
if (_hasAcknowledged || _task == null) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
try {
|
||||
final service = ref.read(contractSigningServiceProvider);
|
||||
final updatedTask = await service.acknowledgeContract(widget.orderNo);
|
||||
|
||||
setState(() {
|
||||
_task = updatedTask;
|
||||
_hasAcknowledged = true;
|
||||
_isSubmitting = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('确认失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示签名面板
|
||||
void _showSignatureDialog() {
|
||||
if (!_hasAcknowledged) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('请先确认合同法律效力'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _showSignaturePad = true);
|
||||
}
|
||||
|
||||
/// 提交签名
|
||||
Future<void> _submitSignature(Uint8List signatureImage) async {
|
||||
if (_isSubmitting || _task == null) return;
|
||||
|
||||
setState(() {
|
||||
_showSignaturePad = false;
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 获取设备信息和位置
|
||||
String? ipAddress;
|
||||
String? deviceInfo;
|
||||
String? userAgent;
|
||||
double? latitude;
|
||||
double? longitude;
|
||||
|
||||
try {
|
||||
// 获取设备信息
|
||||
final deviceInfoPlugin = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfoPlugin.androidInfo;
|
||||
deviceInfo = '{"platform":"android","model":"${androidInfo.model}","brand":"${androidInfo.brand}","sdk":"${androidInfo.version.sdkInt}"}';
|
||||
userAgent = 'Android ${androidInfo.version.release}';
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfoPlugin.iosInfo;
|
||||
deviceInfo = '{"platform":"ios","model":"${iosInfo.model}","name":"${iosInfo.name}","systemVersion":"${iosInfo.systemVersion}"}';
|
||||
userAgent = 'iOS ${iosInfo.systemVersion}';
|
||||
}
|
||||
|
||||
// 获取位置(需要权限)
|
||||
final permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.always || permission == LocationPermission.whileInUse) {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.medium,
|
||||
);
|
||||
latitude = position.latitude;
|
||||
longitude = position.longitude;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('获取设备信息/位置失败: $e');
|
||||
}
|
||||
|
||||
final service = ref.read(contractSigningServiceProvider);
|
||||
|
||||
// 判断是正常签署还是补签
|
||||
ContractSigningTask updatedTask;
|
||||
if (_task!.status == ContractSigningStatus.unsignedTimeout) {
|
||||
updatedTask = await service.lateSignContract(
|
||||
orderNo: widget.orderNo,
|
||||
signatureImage: signatureImage,
|
||||
ipAddress: ipAddress,
|
||||
deviceInfo: deviceInfo,
|
||||
userAgent: userAgent,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
} else {
|
||||
updatedTask = await service.signContract(
|
||||
orderNo: widget.orderNo,
|
||||
signatureImage: signatureImage,
|
||||
ipAddress: ipAddress,
|
||||
deviceInfo: deviceInfo,
|
||||
userAgent: userAgent,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_task = updatedTask;
|
||||
_isSubmitting = false;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
// 显示成功对话框
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 28),
|
||||
SizedBox(width: 8),
|
||||
Text('签署成功'),
|
||||
],
|
||||
),
|
||||
content: const Text('合同已签署完成,感谢您的认种!'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.pop(true);
|
||||
},
|
||||
child: const Text('完成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('签署失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化剩余时间
|
||||
String _formatRemainingTime() {
|
||||
final hours = _remainingSeconds ~/ 3600;
|
||||
final minutes = (_remainingSeconds % 3600) ~/ 60;
|
||||
final seconds = _remainingSeconds % 60;
|
||||
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF7E6),
|
||||
Color(0xFFEAE0C8),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: _showSignaturePad
|
||||
? _buildSignaturePad()
|
||||
: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
if (!_isLoading && _task != null) _buildCountdownBar(),
|
||||
Expanded(child: _buildContent()),
|
||||
if (!_isLoading && _task != null) _buildBottomActions(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部导航栏
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF7E6).withValues(alpha: 0.8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => context.pop(false),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Color(0xFFD4AF37),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => context.pop(false),
|
||||
child: const Text(
|
||||
'返回',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'Inter',
|
||||
height: 1.5,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 42),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'签署认种协议',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25,
|
||||
letterSpacing: -0.27,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建倒计时条
|
||||
Widget _buildCountdownBar() {
|
||||
final isTimeout = _remainingSeconds <= 0;
|
||||
final isUrgent = _remainingSeconds < 3600; // 小于1小时
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isTimeout
|
||||
? const Color(0xFFFFEBEE)
|
||||
: isUrgent
|
||||
? const Color(0xFFFFF3E0)
|
||||
: const Color(0xFFE8F5E9),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isTimeout
|
||||
? const Color(0xFFE53935)
|
||||
: isUrgent
|
||||
? const Color(0xFFFF9800)
|
||||
: const Color(0xFF4CAF50),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isTimeout
|
||||
? Icons.error_outline
|
||||
: isUrgent
|
||||
? Icons.access_time
|
||||
: Icons.timer,
|
||||
color: isTimeout
|
||||
? const Color(0xFFE53935)
|
||||
: isUrgent
|
||||
? const Color(0xFFFF9800)
|
||||
: const Color(0xFF4CAF50),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isTimeout
|
||||
? '签署已超时,请尽快补签'
|
||||
: '请在 ${_formatRemainingTime()} 内完成签署',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isTimeout
|
||||
? const Color(0xFFE53935)
|
||||
: isUrgent
|
||||
? const Color(0xFFE65100)
|
||||
: const Color(0xFF2E7D32),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建内容区域
|
||||
Widget _buildContent() {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFD4AF37)),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadTask,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
),
|
||||
child: const Text('重试', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_task == null) {
|
||||
return const Center(child: Text('未找到签署任务'));
|
||||
}
|
||||
|
||||
// 如果已签署,显示签署完成信息
|
||||
if (_task!.status == ContractSigningStatus.signed) {
|
||||
return _buildSignedContent();
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildContractInfo(),
|
||||
const SizedBox(height: 16),
|
||||
_buildContractContent(),
|
||||
const SizedBox(height: 24),
|
||||
if (!_hasScrolledToBottom)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.arrow_downward, color: Color(0xFFD4AF37), size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'请滚动阅读完整合同',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFD4AF37),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建已签署内容
|
||||
Widget _buildSignedContent() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE8F5E9),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle,
|
||||
color: Color(0xFF4CAF50),
|
||||
size: 60,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'合同签署完成',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF2E7D32),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'签署时间: ${_formatDateTime(_task!.signedAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'返回',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化日期时间
|
||||
String _formatDateTime(DateTime? dateTime) {
|
||||
if (dateTime == null) return '-';
|
||||
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 构建合同信息卡片
|
||||
Widget _buildContractInfo() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'订单号: ${_task!.orderNo}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('认种数量', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
Text(
|
||||
'${_task!.treeCount} 棵',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('认种金额', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
Text(
|
||||
'${_task!.totalAmount.toStringAsFixed(2)} USDT',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'种植区域: ${_task!.provinceName} ${_task!.cityName}',
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF666666)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建合同内容
|
||||
Widget _buildContractContent() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'合同内容',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'版本: ${_task!.contractVersion}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
// 使用 HTML 渲染合同内容
|
||||
_buildHtmlContent(_task!.contractContent),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建 HTML 内容(简单实现)
|
||||
Widget _buildHtmlContent(String htmlContent) {
|
||||
// 简单解析 HTML 内容,提取纯文本显示
|
||||
// 实际项目中可以使用 flutter_html 或 flutter_widget_from_html 包
|
||||
final text = htmlContent
|
||||
.replaceAll(RegExp(r'<[^>]*>'), '')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('{{ORDER_NO}}', _task!.orderNo)
|
||||
.replaceAll('{{USER_REAL_NAME}}', '***')
|
||||
.replaceAll('{{USER_ID_CARD}}', '****')
|
||||
.replaceAll('{{USER_PHONE}}', '****')
|
||||
.replaceAll('{{ACCOUNT_SEQUENCE}}', _task!.accountSequence)
|
||||
.replaceAll('{{TREE_COUNT}}', _task!.treeCount.toString())
|
||||
.replaceAll('{{TOTAL_AMOUNT}}', _task!.totalAmount.toStringAsFixed(2))
|
||||
.replaceAll('{{PROVINCE_NAME}}', _task!.provinceName)
|
||||
.replaceAll('{{CITY_NAME}}', _task!.cityName)
|
||||
.replaceAll('{{SIGNING_DATE}}', _formatDateTime(DateTime.now()).split(' ')[0])
|
||||
.replaceAll('{{USER_SIGNATURE}}', '[待签名]')
|
||||
.replaceAll('{{SIGNING_TIMESTAMP}}', DateTime.now().toIso8601String());
|
||||
|
||||
return Text(
|
||||
text.trim(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.8,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部操作区
|
||||
Widget _buildBottomActions() {
|
||||
if (_task!.status == ContractSigningStatus.signed) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 法律效力确认复选框
|
||||
if (!_hasAcknowledged)
|
||||
GestureDetector(
|
||||
onTap: _hasScrolledToBottom ? _acknowledgeContract : null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: _hasScrolledToBottom ? const Color(0xFFD4AF37) : const Color(0xFFE0E0E0),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: _hasScrolledToBottom
|
||||
? const Icon(Icons.check, color: Colors.white, size: 16)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'我已阅读并同意上述协议,确认其具有法律效力',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: _hasScrolledToBottom ? const Color(0xFF333333) : const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_hasAcknowledged)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Color(0xFF4CAF50), size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'已确认协议法律效力',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF4CAF50),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 签署按钮
|
||||
GestureDetector(
|
||||
onTap: _hasAcknowledged && !_isSubmitting ? _showSignatureDialog : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: _hasAcknowledged
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: _hasAcknowledged
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFD4AF37).withValues(alpha: 0.3),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'签署合同',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建签名面板
|
||||
Widget _buildSignaturePad() {
|
||||
return Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(
|
||||
child: SignaturePad(
|
||||
onSubmit: _submitSignature,
|
||||
onCancel: () => setState(() => _showSignaturePad = false),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,473 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/contract_signing_service.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
|
||||
/// 待签署合同列表页面
|
||||
/// 用于 App 启动时检查并强制用户签署未完成的合同
|
||||
class PendingContractsPage extends ConsumerStatefulWidget {
|
||||
/// 是否为强制模式(不允许跳过)
|
||||
final bool forceSign;
|
||||
|
||||
const PendingContractsPage({
|
||||
super.key,
|
||||
this.forceSign = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<PendingContractsPage> createState() => _PendingContractsPageState();
|
||||
}
|
||||
|
||||
class _PendingContractsPageState extends ConsumerState<PendingContractsPage> {
|
||||
List<ContractSigningTask> _pendingTasks = [];
|
||||
List<ContractSigningTask> _unsignedTasks = [];
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTasks();
|
||||
}
|
||||
|
||||
Future<void> _loadTasks() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final service = ref.read(contractSigningServiceProvider);
|
||||
|
||||
// 并行获取待签署和未签署任务
|
||||
final results = await Future.wait([
|
||||
service.getPendingTasks(),
|
||||
service.getUnsignedTasks(),
|
||||
]);
|
||||
|
||||
setState(() {
|
||||
_pendingTasks = results[0];
|
||||
_unsignedTasks = results[1];
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// 如果没有待签署任务,自动返回
|
||||
if (_pendingTasks.isEmpty && _unsignedTasks.isEmpty) {
|
||||
if (mounted) {
|
||||
context.pop(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = '加载失败: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 签署合同
|
||||
Future<void> _signContract(ContractSigningTask task) async {
|
||||
final result = await context.push<bool>(
|
||||
'${RoutePaths.contractSigning}/${task.orderNo}',
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
// 签署成功,刷新列表
|
||||
_loadTasks();
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳过(仅非强制模式)
|
||||
void _skip() {
|
||||
if (!widget.forceSign) {
|
||||
context.pop(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFF7E6),
|
||||
Color(0xFFEAE0C8),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(child: _buildContent()),
|
||||
if (!widget.forceSign && !_isLoading) _buildSkipButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.description_outlined,
|
||||
color: Color(0xFFD4AF37),
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'待签署合同',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.forceSign
|
||||
? '您有未签署的认种合同,请完成签署后继续使用'
|
||||
: '以下合同待您签署',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFFD4AF37)),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadTasks,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFD4AF37),
|
||||
),
|
||||
child: const Text('重试', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final allTasks = [..._pendingTasks, ..._unsignedTasks];
|
||||
|
||||
if (allTasks.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline, color: Color(0xFF4CAF50), size: 64),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'所有合同已签署完成',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Color(0xFF4CAF50),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadTasks,
|
||||
color: const Color(0xFFD4AF37),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: allTasks.length,
|
||||
itemBuilder: (context, index) => _buildTaskCard(allTasks[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskCard(ContractSigningTask task) {
|
||||
final isTimeout = task.status == ContractSigningStatus.unsignedTimeout;
|
||||
final isUrgent = !isTimeout && task.remainingSeconds < 3600;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
border: isTimeout
|
||||
? Border.all(color: const Color(0xFFE53935), width: 1)
|
||||
: isUrgent
|
||||
? Border.all(color: const Color(0xFFFF9800), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _signContract(task),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'订单号: ${task.orderNo}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${task.treeCount} 棵榴莲树',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildStatusBadge(task),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'金额: ${task.totalAmount.toStringAsFixed(2)} USDT',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFFD4AF37),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${task.provinceName} ${task.cityName}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 状态提示
|
||||
if (isTimeout)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFEBEE),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Color(0xFFE53935), size: 16),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'签署已超时,请尽快补签',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFE53935),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isUrgent)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3E0),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.access_time, color: Color(0xFFFF9800), size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'剩余 ${_formatRemainingTime(task.remainingSeconds)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFE65100),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE3F2FD),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.timer, color: Color(0xFF1976D2), size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'剩余 ${_formatRemainingTime(task.remainingSeconds)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF1976D2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 签署按钮
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: isTimeout ? const Color(0xFFE53935) : const Color(0xFFD4AF37),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
isTimeout ? '立即补签' : '立即签署',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge(ContractSigningTask task) {
|
||||
Color bgColor;
|
||||
Color textColor;
|
||||
String text;
|
||||
|
||||
switch (task.status) {
|
||||
case ContractSigningStatus.pending:
|
||||
bgColor = const Color(0xFFE3F2FD);
|
||||
textColor = const Color(0xFF1976D2);
|
||||
text = '待签署';
|
||||
break;
|
||||
case ContractSigningStatus.scrolled:
|
||||
bgColor = const Color(0xFFFFF3E0);
|
||||
textColor = const Color(0xFFE65100);
|
||||
text = '阅读中';
|
||||
break;
|
||||
case ContractSigningStatus.acknowledged:
|
||||
bgColor = const Color(0xFFE8F5E9);
|
||||
textColor = const Color(0xFF2E7D32);
|
||||
text = '待签名';
|
||||
break;
|
||||
case ContractSigningStatus.unsignedTimeout:
|
||||
bgColor = const Color(0xFFFFEBEE);
|
||||
textColor = const Color(0xFFE53935);
|
||||
text = '已超时';
|
||||
break;
|
||||
default:
|
||||
bgColor = const Color(0xFFF5F5F5);
|
||||
textColor = const Color(0xFF666666);
|
||||
text = '未知';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatRemainingTime(int seconds) {
|
||||
if (seconds <= 0) return '已超时';
|
||||
|
||||
final hours = seconds ~/ 3600;
|
||||
final minutes = (seconds % 3600) ~/ 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '$hours 小时 $minutes 分钟';
|
||||
} else {
|
||||
return '$minutes 分钟';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSkipButton() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: TextButton(
|
||||
onPressed: _skip,
|
||||
child: const Text(
|
||||
'稍后再签',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 签名面板组件
|
||||
class SignaturePad extends StatefulWidget {
|
||||
final Function(Uint8List) onSubmit;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const SignaturePad({
|
||||
super.key,
|
||||
required this.onSubmit,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SignaturePad> createState() => _SignaturePadState();
|
||||
}
|
||||
|
||||
class _SignaturePadState extends State<SignaturePad> {
|
||||
/// 签名路径列表
|
||||
final List<List<Offset>> _strokes = [];
|
||||
|
||||
/// 当前笔画
|
||||
List<Offset> _currentStroke = [];
|
||||
|
||||
/// 是否有签名
|
||||
bool get _hasSignature => _strokes.isNotEmpty || _currentStroke.isNotEmpty;
|
||||
|
||||
/// 清除签名
|
||||
void _clear() {
|
||||
setState(() {
|
||||
_strokes.clear();
|
||||
_currentStroke.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/// 提交签名
|
||||
Future<void> _submit() async {
|
||||
if (!_hasSignature) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('请在签名区域签名'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将签名转换为图片
|
||||
final image = await _renderSignatureImage();
|
||||
if (image != null) {
|
||||
widget.onSubmit(image);
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染签名为图片
|
||||
Future<Uint8List?> _renderSignatureImage() async {
|
||||
try {
|
||||
// 创建画布
|
||||
const width = 600.0;
|
||||
const height = 200.0;
|
||||
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, width, height));
|
||||
|
||||
// 白色背景
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, 0, width, height),
|
||||
Paint()..color = Colors.white,
|
||||
);
|
||||
|
||||
// 绘制签名
|
||||
final paint = Paint()
|
||||
..color = Colors.black
|
||||
..strokeWidth = 3.0
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
// 计算缩放比例
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return null;
|
||||
|
||||
final size = renderBox.size;
|
||||
final scaleX = width / size.width;
|
||||
final scaleY = height / size.height;
|
||||
|
||||
for (final stroke in _strokes) {
|
||||
if (stroke.length > 1) {
|
||||
final path = Path();
|
||||
path.moveTo(stroke[0].dx * scaleX, stroke[0].dy * scaleY);
|
||||
for (var i = 1; i < stroke.length; i++) {
|
||||
path.lineTo(stroke[i].dx * scaleX, stroke[i].dy * scaleY);
|
||||
}
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
final img = await picture.toImage(width.toInt(), height.toInt());
|
||||
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
|
||||
return byteData?.buffer.asUint8List();
|
||||
} catch (e) {
|
||||
debugPrint('渲染签名图片失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: const Color(0xFFFFF7E6),
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: const Text(
|
||||
'请在下方区域签名',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 签名区域
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFD4AF37),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: GestureDetector(
|
||||
onPanStart: (details) {
|
||||
setState(() {
|
||||
_currentStroke = [details.localPosition];
|
||||
});
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
_currentStroke.add(details.localPosition);
|
||||
});
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
setState(() {
|
||||
if (_currentStroke.isNotEmpty) {
|
||||
_strokes.add(List.from(_currentStroke));
|
||||
_currentStroke.clear();
|
||||
}
|
||||
});
|
||||
},
|
||||
child: CustomPaint(
|
||||
painter: _SignaturePainter(
|
||||
strokes: _strokes,
|
||||
currentStroke: _currentStroke,
|
||||
),
|
||||
child: Container(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 提示文字
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_hasSignature ? '已签名,点击确认提交' : '在白色区域内签名',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: _hasSignature ? const Color(0xFF4CAF50) : const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 操作按钮
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: Row(
|
||||
children: [
|
||||
// 取消按钮
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onCancel,
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFD4AF37),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFD4AF37),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 清除按钮
|
||||
GestureDetector(
|
||||
onTap: _clear,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFEBEE),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.refresh,
|
||||
color: Color(0xFFE53935),
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 确认按钮
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _submit,
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: _hasSignature
|
||||
? const Color(0xFFD4AF37)
|
||||
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: _hasSignature
|
||||
? [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFD4AF37).withValues(alpha: 0.3),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'确认签名',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 签名绘制器
|
||||
class _SignaturePainter extends CustomPainter {
|
||||
final List<List<Offset>> strokes;
|
||||
final List<Offset> currentStroke;
|
||||
|
||||
_SignaturePainter({
|
||||
required this.strokes,
|
||||
required this.currentStroke,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.black
|
||||
..strokeWidth = 3.0
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
// 绘制已完成的笔画
|
||||
for (final stroke in strokes) {
|
||||
if (stroke.length > 1) {
|
||||
final path = Path();
|
||||
path.moveTo(stroke[0].dx, stroke[0].dy);
|
||||
for (var i = 1; i < stroke.length; i++) {
|
||||
path.lineTo(stroke[i].dx, stroke[i].dy);
|
||||
}
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制当前笔画
|
||||
if (currentStroke.length > 1) {
|
||||
final path = Path();
|
||||
path.moveTo(currentStroke[0].dx, currentStroke[0].dy);
|
||||
for (var i = 1; i < currentStroke.length; i++) {
|
||||
path.lineTo(currentStroke[i].dx, currentStroke[i].dy);
|
||||
}
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
// 绘制提示文字(如果没有签名)
|
||||
if (strokes.isEmpty && currentStroke.isEmpty) {
|
||||
final textPainter = TextPainter(
|
||||
text: const TextSpan(
|
||||
text: '请在此处签名',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFCCCCCC),
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset(
|
||||
(size.width - textPainter.width) / 2,
|
||||
(size.height - textPainter.height) / 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SignaturePainter oldDelegate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../../../core/theme/app_dimensions.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../bootstrap.dart';
|
||||
import '../widgets/bottom_nav_bar.dart';
|
||||
|
|
@ -22,13 +23,17 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
|
|||
/// 下次允许检查更新的时间
|
||||
static DateTime? _nextCheckAllowedTime;
|
||||
|
||||
/// 是否已检查过合同(防止重复检查)
|
||||
static bool _hasCheckedContracts = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// 首次进入时检查更新
|
||||
// 首次进入时检查更新和未签署合同
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForUpdateIfNeeded();
|
||||
_checkPendingContracts();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +63,32 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
|
|||
}
|
||||
}
|
||||
|
||||
/// 检查待签署合同
|
||||
Future<void> _checkPendingContracts() async {
|
||||
// 每次会话只检查一次
|
||||
if (_hasCheckedContracts) return;
|
||||
_hasCheckedContracts = true;
|
||||
|
||||
try {
|
||||
final contractCheckService = ref.read(contractCheckServiceProvider);
|
||||
final hasPending = await contractCheckService.hasPendingContracts();
|
||||
|
||||
if (hasPending && mounted) {
|
||||
// 有待签署合同,跳转到待签署列表页面
|
||||
// forceSign=true 表示必须签署后才能继续使用
|
||||
context.push(RoutePaths.pendingContracts, extra: true);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[HomeShellPage] 检查待签署合同失败: $e');
|
||||
// 检查失败不阻止用户使用 App
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置合同检查状态(用于用户切换账号时)
|
||||
static void resetContractCheckState() {
|
||||
_hasCheckedContracts = false;
|
||||
}
|
||||
|
||||
int _getCurrentIndex(BuildContext context) {
|
||||
final location = GoRouterState.of(context).uri.path;
|
||||
// 索引: 0-龙虎榜, 1-监控, 2-兑换, 3-我
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ import '../features/kyc/presentation/pages/kyc_id_page.dart';
|
|||
import '../features/kyc/presentation/pages/kyc_face_page.dart';
|
||||
import '../features/kyc/presentation/pages/kyc_id_card_page.dart';
|
||||
import '../features/kyc/presentation/pages/change_phone_page.dart';
|
||||
import '../features/contract_signing/presentation/pages/contract_signing_page.dart';
|
||||
import '../features/contract_signing/presentation/pages/pending_contracts_page.dart';
|
||||
import 'route_paths.dart';
|
||||
import 'route_names.dart';
|
||||
|
||||
|
|
@ -404,6 +406,26 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
builder: (context, state) => const ChangePhonePage(),
|
||||
),
|
||||
|
||||
// Contract Signing Page (合同签署)
|
||||
GoRoute(
|
||||
path: '${RoutePaths.contractSigning}/:orderNo',
|
||||
name: RouteNames.contractSigning,
|
||||
builder: (context, state) {
|
||||
final orderNo = state.pathParameters['orderNo'] ?? '';
|
||||
return ContractSigningPage(orderNo: orderNo);
|
||||
},
|
||||
),
|
||||
|
||||
// Pending Contracts Page (待签署合同列表)
|
||||
GoRoute(
|
||||
path: RoutePaths.pendingContracts,
|
||||
name: RouteNames.pendingContracts,
|
||||
builder: (context, state) {
|
||||
final forceSign = state.extra as bool? ?? false;
|
||||
return PendingContractsPage(forceSign: forceSign);
|
||||
},
|
||||
),
|
||||
|
||||
// Main Shell with Bottom Navigation
|
||||
ShellRoute(
|
||||
navigatorKey: _shellNavigatorKey,
|
||||
|
|
|
|||
|
|
@ -52,4 +52,8 @@ class RouteNames {
|
|||
static const kycFace = 'kyc-face'; // 层级2: 实人认证 (人脸活体)
|
||||
static const kycIdCard = 'kyc-id-card'; // 层级3: KYC (证件照)
|
||||
static const changePhone = 'change-phone';
|
||||
|
||||
// Contract Signing (合同签署)
|
||||
static const contractSigning = 'contract-signing';
|
||||
static const pendingContracts = 'pending-contracts';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,4 +52,8 @@ class RoutePaths {
|
|||
static const kycFace = '/kyc/face'; // 层级2: 实人认证 (人脸活体)
|
||||
static const kycIdCard = '/kyc/id-card'; // 层级3: KYC (证件照)
|
||||
static const changePhone = '/kyc/change-phone';
|
||||
|
||||
// Contract Signing (合同签署)
|
||||
static const contractSigning = '/contract-signing';
|
||||
static const pendingContracts = '/contract-signing/pending';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue