import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../constants/app_constants.dart'; import '../../../utils/http/http_client.dart'; import 'user-plan.dart'; /// 时间: 2026-03-29 /// 功能: 离线数据管理页面 /// 介绍: 从服务器加载诗词数据到本地缓存,支持离线使用 /// 最新变化: 新建页面 class OfflineDataPage extends StatefulWidget { const OfflineDataPage({super.key}); @override State createState() => _OfflineDataPageState(); } enum DownloadType { poetry, // 诗句 quiz, // 答题 } class _OfflineDataPageState extends State { DownloadType _selectedType = DownloadType.poetry; int _selectedCount = 30; bool _isLoading = false; bool _isCancelling = false; int _progress = 0; String _status = ''; int _cachedCount = 0; bool _shouldCancel = false; int _downloadedCount = 0; int _poetryCount = 0; int _quizCount = 0; @override void initState() { super.initState(); _loadCachedCount(); } @override void dispose() { // 页面销毁时不取消下载,让下载在后台继续 super.dispose(); } Future _loadCachedCount() async { final prefs = await SharedPreferences.getInstance(); final poetryData = prefs.getStringList('offline_poetry_data') ?? []; final quizData = prefs.getStringList('offline_quiz_data') ?? []; setState(() { _poetryCount = poetryData.length; _quizCount = quizData.length; _cachedCount = _selectedType == DownloadType.poetry ? _poetryCount : _quizCount; }); } Future _checkUserPlanStatus() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool('user_plan_joined') ?? false; } Future _downloadOfflineData() async { if (_isLoading) return; // 检查缓存数量限制 final prefs = await SharedPreferences.getInstance(); final existingData = prefs.getStringList( _selectedType == DownloadType.poetry ? 'offline_poetry_data' : 'offline_quiz_data', ) ?? []; if (existingData.length >= 500) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('缓存已达上限500条,请先清空缓存'), backgroundColor: Colors.red, ), ); } return; } // 检查100条是否需要用户体验计划 if (_selectedCount == 100) { final isUserPlanJoined = await _checkUserPlanStatus(); if (!isUserPlanJoined) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('100条下载需要加入用户体验计划'), backgroundColor: Colors.orange, action: SnackBarAction( label: '去加入', onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (_) => const UserPlanPage()), ); }, ), ), ); } return; } } setState(() { _isLoading = true; _isCancelling = false; _shouldCancel = false; _progress = 0; _status = '开始下载数据...'; _downloadedCount = 0; }); try { final dataKey = _selectedType == DownloadType.poetry ? 'offline_poetry_data' : 'offline_quiz_data'; var currentData = prefs.getStringList(dataKey) ?? []; for (int i = 0; i < _selectedCount; i++) { // 检查是否需要取消 if (_shouldCancel) { if (mounted) { setState(() { _status = '下载已取消'; _isLoading = false; _isCancelling = false; }); } // 取消时保存已下载的数据 if (_downloadedCount > 0) { await prefs.setStringList(dataKey, currentData); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('已保存 $_downloadedCount 条数据'), backgroundColor: AppConstants.primaryColor, ), ); } } return; } dynamic response; if (_selectedType == DownloadType.poetry) { // 下载诗句 response = await HttpClient.get('pms.php'); } else { // 下载答题:使用question接口获取完整数据(包含options) // 使用循环id来获取不同的题目 response = await HttpClient.get( 'poe/api.php', queryParameters: {'action': 'question', 'id': '$i'}, ); } if (response.isSuccess && response.jsonData != null) { final responseData = response.jsonData; // 检查API返回格式 if (responseData['code'] == 0 && responseData['data'] != null) { final itemData = responseData['data'] as Map; // 对于答题数据,确保options字段被正确序列化 if (_selectedType == DownloadType.quiz) { // 深拷贝数据,避免修改原始数据 final dataToStore = Map.from(itemData); // 确保options字段是JSON字符串格式 if (dataToStore.containsKey('options') && dataToStore['options'] != null) { if (dataToStore['options'] is List) { // 将List转换为JSON字符串 dataToStore['options'] = jsonEncode(dataToStore['options']); } else if (dataToStore['options'] is String) { // 已经是字符串,直接使用 } } else { // 如果没有options,添加一个空数组 dataToStore['options'] = jsonEncode([]); } // 将整个Map转换为JSON字符串存储 final storedString = jsonEncode(dataToStore); currentData.add(storedString); } else { currentData.add(itemData.toString()); } _downloadedCount++; // 下载一条,写入一条 await prefs.setStringList(dataKey, currentData); // 实时更新缓存状态 if (mounted) { setState(() { if (_selectedType == DownloadType.poetry) { _poetryCount = currentData.length; } else { _quizCount = currentData.length; } _cachedCount = currentData.length; }); } } } final currentProgress = ((i + 1) / _selectedCount * 100).toInt(); if (mounted) { setState(() { _progress = currentProgress; _status = '下载中... ${i + 1}/$_selectedCount'; }); } // 模拟网络延迟,避免请求过于频繁 await Future.delayed(const Duration(milliseconds: 500)); } // 检查总缓存数量 if (currentData.length > 500) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('缓存将超过500条上限,请先清空缓存'), backgroundColor: Colors.red, ), ); setState(() { _isLoading = false; }); } return; } if (mounted) { setState(() { _status = '下载完成!'; _cachedCount = currentData.length; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( '成功缓存 ${_selectedType == DownloadType.poetry ? '诗词' : '答题'} $_cachedCount 条数据', ), backgroundColor: AppConstants.primaryColor, ), ); } } catch (e) { if (mounted) { setState(() { _status = '下载失败: $e'; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('下载失败: $e'), backgroundColor: Colors.red), ); } } finally { if (!_shouldCancel && mounted) { setState(() { _isLoading = false; }); } } } Future _clearOfflineData() async { final prefs = await SharedPreferences.getInstance(); // 获取当前两种类型的缓存数量 final poetryCount = (prefs.getStringList('offline_poetry_data') ?? []).length; final quizCount = (prefs.getStringList('offline_quiz_data') ?? []).length; if (poetryCount == 0 && quizCount == 0) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('暂无缓存数据'), backgroundColor: Colors.orange, ), ); } return; } // 显示选择弹窗 if (mounted) { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: const Text('选择清空内容'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (poetryCount > 0) Text('• 精选诗句: $poetryCount 条'), if (quizCount > 0) Text('• 答题挑战: $quizCount 条'), const SizedBox(height: 16), const Text('请选择要清空的缓存类型'), ], ), actions: [ if (poetryCount > 0) TextButton( onPressed: () { Navigator.of(context).pop(); _clearSpecificData('offline_poetry_data', '精选诗句'); }, child: const Text('清空诗句'), ), if (quizCount > 0) TextButton( onPressed: () { Navigator.of(context).pop(); _clearSpecificData('offline_quiz_data', '答题挑战'); }, child: const Text('清空答题'), ), TextButton( onPressed: () { Navigator.of(context).pop(); _clearAllData(); }, style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('清空全部'), ), TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('取消'), ), ], ); }, ); } } Future _clearSpecificData(String key, String typeName) async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(key); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('已清空$typeName离线数据'), backgroundColor: AppConstants.primaryColor, ), ); } // 更新对应的计数 if (key == 'offline_poetry_data') { setState(() { _poetryCount = 0; if (_selectedType == DownloadType.poetry) { _cachedCount = 0; } }); } else if (key == 'offline_quiz_data') { setState(() { _quizCount = 0; if (_selectedType == DownloadType.quiz) { _cachedCount = 0; } }); } } Future _clearAllData() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove('offline_poetry_data'); await prefs.remove('offline_quiz_data'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('已清空所有离线数据'), backgroundColor: AppConstants.primaryColor, ), ); } setState(() { _poetryCount = 0; _quizCount = 0; _cachedCount = 0; }); } void _cancelDownload() { if (mounted) { setState(() { _isCancelling = true; _shouldCancel = true; _status = '正在取消下载...'; }); } } Future _showServerInfo() async { showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { return const AlertDialog( content: Row( children: [ CircularProgressIndicator(), SizedBox(width: 16), Text('正在检测网络状态...'), ], ), ); }, ); try { final response = await HttpClient.get('poe/load.php'); if (!mounted) return; Navigator.of(context).pop(); if (response.isSuccess) { try { final data = response.jsonData; if (data['status'] == 'success') { _displayServerInfoDialog(data); } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('服务器返回错误状态: ${data['status']}'), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), ); } } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('解析服务器数据失败: $e'), backgroundColor: Colors.red, duration: const Duration(seconds: 3), ), ); } } } else { if (mounted) { final errorMsg = '获取服务器信息失败\n' '状态码: ${response.statusCode}\n' '消息: ${response.message}'; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(errorMsg), backgroundColor: Colors.red, duration: const Duration(seconds: 5), ), ); } } } catch (e) { if (!mounted) return; Navigator.of(context).pop(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('获取服务器信息异常: $e'), backgroundColor: Colors.red, duration: const Duration(seconds: 5), ), ); } } } void _displayServerInfoDialog(Map data) { final server = data['server'] as Map?; final network = data['network'] as Map?; final timestamp = data['timestamp'] as Map?; final load = server?['load'] as Map?; final latency = network?['latency'] as List?; final serverResponseTime = network?['server_response_time']; showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Row( children: [ Icon(Icons.cloud, color: AppConstants.primaryColor, size: 20), const SizedBox(width: 8), const Text('服务器信息', style: TextStyle(fontSize: 18)), ], ), content: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const Text( '广州 server-ls', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), const SizedBox(height: 12), _buildInfoSection('⏰ 服务器时间', timestamp?['datetime'] ?? '--'), const SizedBox(height: 12), _buildInfoSection( '📊 服务器负载', '1分钟: ${load?['1min'] ?? '--'} | 5分钟: ${load?['5min'] ?? '--'} | 15分钟: ${load?['15min'] ?? '--'}', ), const SizedBox(height: 12), _buildInfoSection( '⚡ 服务器响应', '${serverResponseTime ?? '--'} ms', ), const SizedBox(height: 16), const Text( '🌐 网络延迟', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), const SizedBox(height: 8), if (latency != null) ...latency.map((item) { final host = item['host'] as String?; final ip = item['ip'] as String?; final lat = item['latency']; final status = item['status'] as String?; return Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( '• $host ($ip): ${status == 'online' ? '$lat ms' : '离线'}', style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ); }).toList(), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('关闭'), ), ], ); }, ); } Widget _buildInfoSection(String title, String content) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), const SizedBox(height: 4), Text(content, style: const TextStyle(fontSize: 12, color: Colors.grey)), ], ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), appBar: AppBar( title: Text( '离线使用', style: TextStyle( color: AppConstants.primaryColor, fontWeight: FontWeight.bold, ), ), backgroundColor: Colors.white, elevation: 0, centerTitle: true, leading: IconButton( icon: Icon(Icons.arrow_back, color: AppConstants.primaryColor), onPressed: () => Navigator.of(context).pop(), ), actions: [ IconButton( icon: Icon(Icons.cloud_outlined, color: AppConstants.primaryColor), onPressed: _showServerInfo, tooltip: '服务器信息', ), ], ), body: ListView( padding: const EdgeInsets.all(16), children: [ // 缓存状态卡片 Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Column( children: [ Row( children: [ Icon( Icons.download_done, color: AppConstants.primaryColor, size: 24, ), const SizedBox(width: 12), const Text( '缓存状态', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 16), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '精选诗句: $_poetryCount 条', style: const TextStyle(fontSize: 14, color: Colors.grey), ), const SizedBox(height: 8), Text( '答题挑战: $_quizCount 条', style: const TextStyle(fontSize: 14, color: Colors.grey), ), ], ), ], ), ), const SizedBox(height: 20), // 下载类型选择 Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.category, color: AppConstants.primaryColor, size: 20, ), const SizedBox(width: 8), const Text( '下载类型', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildTypeOption(DownloadType.poetry, '精选诗句'), _buildTypeOption(DownloadType.quiz, '答题挑战'), ], ), ], ), ), const SizedBox(height: 20), // 下载数量选择 Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.settings, color: AppConstants.primaryColor, size: 20, ), const SizedBox(width: 8), const Text( '下载数量', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: _selectedType == DownloadType.poetry ? [ _buildCountOption(20), _buildCountOption(30), _buildCountOption(60), _buildCountOption(100), ] : [ _buildCountOption(20), _buildCountOption(50), _buildCountOption(80), _buildCountOption(100), ], ), ], ), ), const SizedBox(height: 20), // 下载/取消按钮 SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _isLoading ? (_isCancelling ? null : _cancelDownload) : _downloadOfflineData, style: ElevatedButton.styleFrom( backgroundColor: _isCancelling ? Colors.red : AppConstants.primaryColor, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: _isCancelling ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Colors.white), ), ) : Text( _isLoading ? '取消下载' : '开始下载', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ), const SizedBox(height: 16), // 清空按钮 SizedBox( width: double.infinity, child: OutlinedButton( onPressed: _clearOfflineData, style: OutlinedButton.styleFrom( foregroundColor: Colors.red, side: const BorderSide(color: Colors.red), padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: const Text( '清空缓存', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ), ), const SizedBox(height: 20), // 进度条 if (_isLoading) ...[ Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _status, style: const TextStyle(fontSize: 14, color: Colors.grey), ), const SizedBox(height: 12), LinearProgressIndicator( value: _progress / 100, backgroundColor: Colors.grey[200], valueColor: AlwaysStoppedAnimation( AppConstants.primaryColor, ), borderRadius: BorderRadius.circular(10), ), const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: Text( '$_progress%', style: const TextStyle(fontSize: 12, color: Colors.grey), ), ), ], ), ), ], const SizedBox(height: 20), // 说明信息 Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.blue.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.blue.withValues(alpha: 0.2)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.info_outline, color: Colors.blue[600], size: 16), const SizedBox(width: 8), Text( '温馨提示', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Colors.blue[600], ), ), ], ), const SizedBox(height: 8), const Text( '• 开启离线模式后,将会循环加载本地的数据源', style: TextStyle(fontSize: 12, color: Colors.grey), ), const Text( '• 下载的数据将保存在本地,可在无网络时使用', style: TextStyle(fontSize: 12, color: Colors.grey), ), const Text( '• 下载过程中请保持网络连接', style: TextStyle(fontSize: 12, color: Colors.grey), ), const Text( '• 缓存数据不会写入历史记录', style: TextStyle(fontSize: 12, color: Colors.grey), ), const Text( '• 建议在WiFi环境下下载较多数据', style: TextStyle(fontSize: 12, color: Colors.grey), ), ], ), ), ], ), ); } Widget _buildTypeOption(DownloadType type, String label) { final isSelected = _selectedType == type; return GestureDetector( onTap: () { setState(() { _selectedType = type; // 重置为默认数量 _selectedCount = type == DownloadType.poetry ? 30 : 50; }); // 重新加载缓存数量 _loadCachedCount(); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), decoration: BoxDecoration( color: isSelected ? AppConstants.primaryColor : Colors.grey[100], borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? AppConstants.primaryColor : Colors.grey[300]!, ), ), child: Text( label, style: TextStyle( color: isSelected ? Colors.white : Colors.black, fontSize: 14, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ), ); } Widget _buildCountOption(int count) { final isSelected = _selectedCount == count; return Stack( children: [ GestureDetector( onTap: () { setState(() { _selectedCount = count; }); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: isSelected ? AppConstants.primaryColor : Colors.grey[100], borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? AppConstants.primaryColor : Colors.grey[300]!, ), ), child: Text( '$count条', style: TextStyle( color: isSelected ? Colors.white : Colors.black, fontSize: 12, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ), ), // 为100条添加标记(在卡片外面右上角) if (count == 100) Positioned( top: -6, right: -6, child: Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( color: Colors.orange, borderRadius: BorderRadius.circular(6), ), child: const Text( '体验', style: TextStyle( color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold, ), ), ), ), ], ); } }