diff --git a/frontend/mobile-app/lib/core/di/injection_container.dart b/frontend/mobile-app/lib/core/di/injection_container.dart index 10ad4c55..4f93ba11 100644 --- a/frontend/mobile-app/lib/core/di/injection_container.dart +++ b/frontend/mobile-app/lib/core/di/injection_container.dart @@ -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((ref) { @@ -93,6 +95,18 @@ final systemConfigServiceProvider = Provider((ref) { return SystemConfigService(apiClient: apiClient); }); +// Contract Signing Service Provider (调用 planting-service) +final contractSigningServiceProvider = Provider((ref) { + final apiClient = ref.watch(apiClientProvider); + return ContractSigningService(apiClient: apiClient); +}); + +// Contract Check Service Provider (用于启动时检查未签署合同) +final contractCheckServiceProvider = Provider((ref) { + final contractSigningService = ref.watch(contractSigningServiceProvider); + return ContractCheckService(contractSigningService: contractSigningService); +}); + // Override provider with initialized instance ProviderContainer createProviderContainer(LocalStorage localStorage) { return ProviderContainer( diff --git a/frontend/mobile-app/lib/core/services/contract_check_service.dart b/frontend/mobile-app/lib/core/services/contract_check_service.dart new file mode 100644 index 00000000..74ad8fed --- /dev/null +++ b/frontend/mobile-app/lib/core/services/contract_check_service.dart @@ -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 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 getPendingContractCount() async { + try { + final unsignedTasks = await _contractSigningService.getUnsignedTasks(); + return unsignedTasks.length; + } catch (e) { + debugPrint('[ContractCheckService] 获取待签署合同数量失败: $e'); + return 0; + } + } +} diff --git a/frontend/mobile-app/lib/core/services/contract_signing_service.dart b/frontend/mobile-app/lib/core/services/contract_signing_service.dart new file mode 100644 index 00000000..23089052 --- /dev/null +++ b/frontend/mobile-app/lib/core/services/contract_signing_service.dart @@ -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 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 json) { + return ContractTemplate( + version: json['version'] ?? '', + title: json['title'] ?? '', + content: json['content'] ?? '', + ); + } +} + +/// 合同签署服务 +class ContractSigningService { + final ApiClient _apiClient; + + ContractSigningService({required ApiClient apiClient}) : _apiClient = apiClient; + + /// 获取待签署任务列表 + Future> getPendingTasks() async { + try { + debugPrint('[ContractSigningService] 获取待签署任务列表'); + + final response = await _apiClient.get('/planting/contract-signing/pending'); + + if (response.statusCode == 200) { + final data = response.data as List; + debugPrint('[ContractSigningService] 待签署任务数量: ${data.length}'); + return data.map((e) => ContractSigningTask.fromJson(e)).toList(); + } + + throw Exception('获取待签署任务失败: ${response.statusCode}'); + } catch (e) { + debugPrint('[ContractSigningService] 获取待签署任务失败: $e'); + rethrow; + } + } + + /// 获取未签署任务列表(包括超时的) + Future> getUnsignedTasks() async { + try { + debugPrint('[ContractSigningService] 获取未签署任务列表'); + + final response = await _apiClient.get('/planting/contract-signing/unsigned'); + + if (response.statusCode == 200) { + final data = response.data as List; + debugPrint('[ContractSigningService] 未签署任务数量: ${data.length}'); + return data.map((e) => ContractSigningTask.fromJson(e)).toList(); + } + + throw Exception('获取未签署任务失败: ${response.statusCode}'); + } catch (e) { + debugPrint('[ContractSigningService] 获取未签署任务失败: $e'); + rethrow; + } + } + + /// 获取签署任务详情 + Future 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; + return ContractSigningTask.fromJson(data); + } + + throw Exception('获取签署任务详情失败: ${response.statusCode}'); + } catch (e) { + debugPrint('[ContractSigningService] 获取签署任务详情失败: $e'); + rethrow; + } + } + + /// 标记已滚动到底部 + Future 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; + debugPrint('[ContractSigningService] 滚动标记成功'); + return ContractSigningTask.fromJson(data); + } + + throw Exception('标记滚动失败: ${response.statusCode}'); + } catch (e) { + debugPrint('[ContractSigningService] 标记滚动失败: $e'); + rethrow; + } + } + + /// 确认法律效力 + Future 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; + debugPrint('[ContractSigningService] 确认法律效力成功'); + return ContractSigningTask.fromJson(data); + } + + throw Exception('确认法律效力失败: ${response.statusCode}'); + } catch (e) { + debugPrint('[ContractSigningService] 确认法律效力失败: $e'); + rethrow; + } + } + + /// 签署合同 + Future 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; + debugPrint('[ContractSigningService] 签署成功'); + return ContractSigningTask.fromJson(data); + } + + throw Exception('签署合同失败: ${response.statusCode}'); + } catch (e) { + debugPrint('[ContractSigningService] 签署合同失败: $e'); + rethrow; + } + } + + /// 补签合同(超时后) + Future 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; + debugPrint('[ContractSigningService] 补签成功'); + return ContractSigningTask.fromJson(data); + } + + throw Exception('补签合同失败: ${response.statusCode}'); + } catch (e) { + debugPrint('[ContractSigningService] 补签合同失败: $e'); + rethrow; + } + } + + /// 获取当前合同模板 + Future getCurrentTemplate() async { + try { + debugPrint('[ContractSigningService] 获取当前合同模板'); + + final response = await _apiClient.get('/planting/contract-signing/template'); + + if (response.statusCode == 200) { + final data = response.data as Map; + 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(); +} diff --git a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart new file mode 100644 index 00000000..d1a01fed --- /dev/null +++ b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart @@ -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 createState() => _ContractSigningPageState(); +} + +class _ContractSigningPageState extends ConsumerState { + /// 签署任务 + 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 _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 _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 _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 _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), + ), + ), + ], + ); + } +} diff --git a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/pending_contracts_page.dart b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/pending_contracts_page.dart new file mode 100644 index 00000000..ae346367 --- /dev/null +++ b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/pending_contracts_page.dart @@ -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 createState() => _PendingContractsPageState(); +} + +class _PendingContractsPageState extends ConsumerState { + List _pendingTasks = []; + List _unsignedTasks = []; + bool _isLoading = true; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadTasks(); + } + + Future _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 _signContract(ContractSigningTask task) async { + final result = await context.push( + '${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), + ), + ), + ), + ); + } +} diff --git a/frontend/mobile-app/lib/features/contract_signing/presentation/widgets/signature_pad.dart b/frontend/mobile-app/lib/features/contract_signing/presentation/widgets/signature_pad.dart new file mode 100644 index 00000000..178f4869 --- /dev/null +++ b/frontend/mobile-app/lib/features/contract_signing/presentation/widgets/signature_pad.dart @@ -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 createState() => _SignaturePadState(); +} + +class _SignaturePadState extends State { + /// 签名路径列表 + final List> _strokes = []; + + /// 当前笔画 + List _currentStroke = []; + + /// 是否有签名 + bool get _hasSignature => _strokes.isNotEmpty || _currentStroke.isNotEmpty; + + /// 清除签名 + void _clear() { + setState(() { + _strokes.clear(); + _currentStroke.clear(); + }); + } + + /// 提交签名 + Future _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 _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> strokes; + final List 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; + } +} diff --git a/frontend/mobile-app/lib/features/home/presentation/pages/home_shell_page.dart b/frontend/mobile-app/lib/features/home/presentation/pages/home_shell_page.dart index ee6fb280..f513bdd1 100644 --- a/frontend/mobile-app/lib/features/home/presentation/pages/home_shell_page.dart +++ b/frontend/mobile-app/lib/features/home/presentation/pages/home_shell_page.dart @@ -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 /// 下次允许检查更新的时间 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 } } + /// 检查待签署合同 + Future _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-我 diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index d49044a9..2c900bec 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -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((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, diff --git a/frontend/mobile-app/lib/routes/route_names.dart b/frontend/mobile-app/lib/routes/route_names.dart index 78e36aa0..3a508e08 100644 --- a/frontend/mobile-app/lib/routes/route_names.dart +++ b/frontend/mobile-app/lib/routes/route_names.dart @@ -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'; } diff --git a/frontend/mobile-app/lib/routes/route_paths.dart b/frontend/mobile-app/lib/routes/route_paths.dart index cbab2e8e..0b893d74 100644 --- a/frontend/mobile-app/lib/routes/route_paths.dart +++ b/frontend/mobile-app/lib/routes/route_paths.dart @@ -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'; }