import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../constants/app_constants.dart'; import '../../../services/get/theme_controller.dart'; import '../../../utils/http/http_client.dart'; import 'user-plan.dart'; import '../components/server_info_dialog.dart'; /// 时间: 2026-03-29 /// 功能: 离线数据管理页面 /// 介绍: 从服务器加载诗词数据到本地缓存,支持离线使用 /// 最新变化: 新建页面 class OfflineDataPage extends StatefulWidget { const OfflineDataPage({super.key}); @override State createState() => _OfflineDataPageState(); } enum DownloadType { poetry, // 诗句 quiz, // 答题 } class _OfflineDataPageState extends State { final ThemeController _themeController = Get.find(); 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) { final themeController = Get.find(); Get.snackbar( '提示', '缓存已达上限500条,请先清空缓存', colorText: themeController.currentThemeColor, ); return; } // 检查100条是否需要用户体验计划 if (_selectedCount == 100) { final isUserPlanJoined = await _checkUserPlanStatus(); if (!isUserPlanJoined) { final themeController = Get.find(); Get.snackbar( '提示', '100条下载需要加入用户体验计划', colorText: themeController.currentThemeColor, mainButton: TextButton( onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (_) => const UserPlanPage()), ); }, child: Text( '去加入', style: TextStyle(color: themeController.currentThemeColor), ), ), ); 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); final themeController = Get.find(); Get.snackbar( '提示', '已保存 $_downloadedCount 条数据', colorText: themeController.currentThemeColor, ); } 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) { final themeController = Get.find(); Get.snackbar( '提示', '缓存将超过500条上限,请先清空缓存', colorText: themeController.currentThemeColor, ); setState(() { _isLoading = false; }); return; } if (mounted) { setState(() { _status = '下载完成!'; _cachedCount = currentData.length; }); final themeController = Get.find(); final themeController2 = Get.find(); Get.snackbar( '成功', '成功缓存 ${_selectedType == DownloadType.poetry ? '诗词' : '答题'} $_cachedCount 条数据', colorText: themeController2.currentThemeColor, ); } } catch (e) { if (mounted) { setState(() { _status = '下载失败: $e'; }); final themeController = Get.find(); Get.snackbar( '错误', '下载失败: $e', colorText: themeController.currentThemeColor, ); } } 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) { final themeController = Get.find(); Get.snackbar( '提示', '暂无缓存数据', colorText: themeController.currentThemeColor, ); 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); final themeController = Get.find(); Get.snackbar( '成功', '已清空$typeName离线数据', colorText: themeController.currentThemeColor, ); // 更新对应的计数 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'); final themeController = Get.find(); Get.snackbar( '成功', '已清空所有离线数据', colorText: themeController.currentThemeColor, ); 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 { final themeController = Get.find(); Get.snackbar( '错误', '服务器返回错误状态: ${data['status']}', colorText: themeController.currentThemeColor, duration: const Duration(seconds: 3), ); } } catch (e) { final themeController = Get.find(); Get.snackbar( '错误', '解析服务器数据失败: $e', colorText: themeController.currentThemeColor, duration: const Duration(seconds: 3), ); } } else { final errorMsg = '获取服务器信息失败\n' '状态码: ${response.statusCode}\n' '消息: ${response.message}'; final themeController = Get.find(); Get.snackbar( '错误', errorMsg, colorText: themeController.currentThemeColor, duration: const Duration(seconds: 5), ); } } catch (e) { if (!mounted) return; Navigator.of(context).pop(); final themeController = Get.find(); Get.snackbar( '错误', '获取服务器信息异常: $e', colorText: themeController.currentThemeColor, duration: const Duration(seconds: 5), ); } } void _displayServerInfoDialog(Map data) { ServerInfoDialog.show(context, data: data); } @override Widget build(BuildContext context) { return Obx(() { final isDark = _themeController.isDarkMode; final primaryColor = _themeController.currentThemeColor; return Scaffold( backgroundColor: isDark ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5), appBar: AppBar( title: Text( '离线使用', style: TextStyle(color: primaryColor, fontWeight: FontWeight.bold), ), backgroundColor: isDark ? const Color(0xFF2A2A2A) : Colors.white, elevation: 0, centerTitle: true, leading: IconButton( icon: Icon(Icons.arrow_back, color: primaryColor), onPressed: () => Navigator.of(context).pop(), ), actions: [ IconButton( icon: Icon(Icons.cloud_outlined, color: primaryColor), onPressed: _showServerInfo, tooltip: '服务器信息', ), ], ), body: ListView( padding: const EdgeInsets.all(16), children: [ Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: isDark ? const Color(0xFF2A2A2A) : Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(isDark ? 30 : 5), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Column( children: [ Row( children: [ Icon(Icons.download_done, color: primaryColor, size: 24), const SizedBox(width: 12), Text( '缓存状态', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: isDark ? Colors.white : Colors.black, ), ), ], ), const SizedBox(height: 16), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '精选诗句: $_poetryCount 条', style: TextStyle( fontSize: 14, color: isDark ? Colors.grey[400] : Colors.grey, ), ), const SizedBox(height: 8), Text( '答题挑战: $_quizCount 条', style: TextStyle( fontSize: 14, color: isDark ? Colors.grey[400] : Colors.grey, ), ), ], ), ], ), ), const SizedBox(height: 20), Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: isDark ? const Color(0xFF2A2A2A) : Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(isDark ? 30 : 5), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.category, color: primaryColor, size: 20), const SizedBox(width: 8), Text( '下载类型', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: isDark ? Colors.white : Colors.black, ), ), ], ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _buildTypeOption(DownloadType.poetry, '精选诗句', isDark), _buildTypeOption(DownloadType.quiz, '答题挑战', isDark), ], ), ], ), ), const SizedBox(height: 20), Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: isDark ? const Color(0xFF2A2A2A) : Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(isDark ? 30 : 5), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.settings, color: primaryColor, size: 20), const SizedBox(width: 8), Text( '下载数量', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: isDark ? Colors.white : Colors.black, ), ), ], ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: _selectedType == DownloadType.poetry ? [ _buildCountOption(20, isDark), _buildCountOption(30, isDark), _buildCountOption(60, isDark), _buildCountOption(100, isDark), ] : [ _buildCountOption(20, isDark), _buildCountOption(50, isDark), _buildCountOption(80, isDark), _buildCountOption(100, isDark), ], ), ], ), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _isLoading ? (_isCancelling ? null : _cancelDownload) : _downloadOfflineData, style: ElevatedButton.styleFrom( backgroundColor: _isCancelling ? Colors.red : 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: isDark ? const Color(0xFF2A2A2A) : Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues( alpha: isDark ? 0.3 : 0.05, ), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _status, style: TextStyle( fontSize: 14, color: isDark ? Colors.grey[400] : Colors.grey, ), ), const SizedBox(height: 12), LinearProgressIndicator( value: _progress / 100, backgroundColor: isDark ? Colors.grey[700] : Colors.grey[200], valueColor: AlwaysStoppedAnimation(primaryColor), borderRadius: BorderRadius.circular(10), ), const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: Text( '$_progress%', style: TextStyle( fontSize: 12, color: isDark ? Colors.grey[400] : Colors.grey, ), ), ), ], ), ), ], const SizedBox(height: 20), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: isDark ? Colors.blue.withValues(alpha: 0.15) : Colors.blue.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(12), border: Border.all( color: isDark ? Colors.blue.withValues(alpha: 0.3) : Colors.blue.withValues(alpha: 0.2), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.info_outline, color: Colors.blue[600], size: 20, ), const SizedBox(width: 8), Text( '温馨提示', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blue[600], ), ), ], ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.only(left: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '• 数据下载后 需手动开启离线状态', style: TextStyle( fontSize: 14, color: isDark ? Colors.grey[300] : Colors.grey[700], fontWeight: FontWeight.w500, ), ), const SizedBox(height: 4), Padding( padding: const EdgeInsets.only(left: 20), child: Text( '方法:个人 → 下拉 点击头像下面关闭按钮 头像显示离线', style: TextStyle( fontSize: 13, color: isDark ? Colors.grey[400] : Colors.grey[600], ), ), ), const SizedBox(height: 8), Text( '• 开启离线模式后,将会循环加载本地的数据源', style: TextStyle( fontSize: 14, color: isDark ? Colors.grey[300] : Colors.grey[700], ), ), const SizedBox(height: 8), Text( '• 下载的数据将保存在本地,可在无网络时使用', style: TextStyle( fontSize: 14, color: isDark ? Colors.grey[300] : Colors.grey[700], ), ), const SizedBox(height: 8), Text( '• 下载过程中请保持网络连接', style: TextStyle( fontSize: 14, color: isDark ? Colors.grey[300] : Colors.grey[700], ), ), const SizedBox(height: 8), Text( '• 缓存数据不会写入历史记录', style: TextStyle( fontSize: 14, color: isDark ? Colors.grey[300] : Colors.grey[700], ), ), const SizedBox(height: 8), Text( '• 建议在WiFi环境下下载较多数据', style: TextStyle( fontSize: 14, color: isDark ? Colors.grey[300] : Colors.grey[700], ), ), ], ), ), ], ), ), ], ), ); }); } Widget _buildTypeOption(DownloadType type, String label, bool isDark) { final isSelected = _selectedType == type; final primaryColor = _themeController.currentThemeColor; 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 ? primaryColor : (isDark ? Colors.grey[800] : Colors.grey[100]), borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? primaryColor : (isDark ? Colors.grey[700]! : Colors.grey[300]!), ), ), child: Text( label, style: TextStyle( color: isSelected ? Colors.white : (isDark ? Colors.white : Colors.black), fontSize: 14, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ), ); } Widget _buildCountOption(int count, bool isDark) { final isSelected = _selectedCount == count; final primaryColor = _themeController.currentThemeColor; return Stack( children: [ GestureDetector( onTap: () { setState(() { _selectedCount = count; }); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: isSelected ? primaryColor : (isDark ? Colors.grey[800] : Colors.grey[100]), borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? primaryColor : (isDark ? Colors.grey[700]! : Colors.grey[300]!), ), ), child: Text( '$count条', style: TextStyle( color: isSelected ? Colors.white : (isDark ? Colors.white : Colors.black), fontSize: 12, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ), ), 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, ), ), ), ), ], ); } }