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:
hailin 2025-12-24 20:15:13 -08:00
parent 714ce42e4f
commit 5b8c6bc317
10 changed files with 2233 additions and 1 deletions

View File

@ -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(

View File

@ -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;
}
}
}

View File

@ -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();
}

View File

@ -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('&nbsp;', ' ')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.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),
),
),
],
);
}
}

View File

@ -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),
),
),
),
);
}
}

View File

@ -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;
}
}

View File

@ -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-

View File

@ -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,

View File

@ -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';
}

View File

@ -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';
}