马良AI写作初始化仓库
This commit is contained in:
321
AINoval/lib/screens/novel_list/widgets/analytics_dashboard.dart
Normal file
321
AINoval/lib/screens/novel_list/widgets/analytics_dashboard.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/analytics_data.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/impl/analytics_repository_impl.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/analytics/analytics_card.dart';
|
||||
import 'package:ainoval/widgets/analytics/token_usage_chart.dart';
|
||||
import 'package:ainoval/widgets/analytics/function_usage_chart.dart';
|
||||
import 'package:ainoval/widgets/analytics/model_usage_chart.dart';
|
||||
import 'package:ainoval/widgets/analytics/token_usage_list.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
class AnalyticsDashboard extends StatefulWidget {
|
||||
const AnalyticsDashboard({super.key});
|
||||
|
||||
@override
|
||||
State<AnalyticsDashboard> createState() => _AnalyticsDashboardState();
|
||||
}
|
||||
|
||||
class _AnalyticsDashboardState extends State<AnalyticsDashboard> {
|
||||
final _analyticsRepo = AnalyticsRepositoryImpl();
|
||||
|
||||
bool _loading = true;
|
||||
AnalyticsData? _overviewData;
|
||||
List<TokenUsageData> _tokenData = [];
|
||||
List<FunctionUsageData> _functionData = [];
|
||||
List<ModelUsageData> _modelData = [];
|
||||
List<TokenUsageRecord> _recordData = [];
|
||||
Map<String, dynamic>? _todaySummary;
|
||||
|
||||
AnalyticsViewMode _tokenViewMode = AnalyticsViewMode.daily;
|
||||
AnalyticsViewMode _functionViewMode = AnalyticsViewMode.daily;
|
||||
AnalyticsViewMode _modelViewMode = AnalyticsViewMode.daily;
|
||||
DateTimeRange? _dateRange;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_analyticsRepo.getAnalyticsOverview(),
|
||||
_analyticsRepo.getTokenUsageTrend(viewMode: _tokenViewMode),
|
||||
_analyticsRepo.getFunctionUsageStats(viewMode: _functionViewMode),
|
||||
_analyticsRepo.getModelUsageStats(viewMode: _modelViewMode),
|
||||
_analyticsRepo.getTokenUsageRecords(limit: 50), // 增加记录数量,确保包含今日数据
|
||||
]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_overviewData = results[0] as AnalyticsData;
|
||||
_tokenData = results[1] as List<TokenUsageData>;
|
||||
_functionData = results[2] as List<FunctionUsageData>;
|
||||
_modelData = results[3] as List<ModelUsageData>;
|
||||
_recordData = results[4] as List<TokenUsageRecord>;
|
||||
_todaySummary = null; // 不再使用后端汇总数据,前端自己计算
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
TopToast.error(context, '加载数据失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1280),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildOverviewCards(),
|
||||
const SizedBox(height: 32),
|
||||
_buildTokenUsageChart(),
|
||||
const SizedBox(height: 32),
|
||||
_buildChartsSection(),
|
||||
const SizedBox(height: 32),
|
||||
_buildTokenUsageList(),
|
||||
const SizedBox(height: 32),
|
||||
_buildInsightsSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverviewCards() {
|
||||
if (_overviewData == null) return const SizedBox.shrink();
|
||||
|
||||
final crossAxisCount = _getColumnCount();
|
||||
// 使用固定项高度,避免固定纵横比在不同宽度下导致轻微内容溢出
|
||||
final double itemMainAxisExtent = crossAxisCount >= 4
|
||||
? 180
|
||||
: (crossAxisCount == 2 ? 200 : 220);
|
||||
|
||||
return GridView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: 24,
|
||||
mainAxisSpacing: 24,
|
||||
mainAxisExtent: itemMainAxisExtent,
|
||||
),
|
||||
children: [
|
||||
AnalyticsOverviewCard(
|
||||
title: '总字数',
|
||||
value: _formatLargeNumber(_overviewData!.totalWords),
|
||||
changeValue: 15.2,
|
||||
isUpTrend: true,
|
||||
icon: Icons.article,
|
||||
subtitle: '本月新增 ${_formatNumber(_overviewData!.monthlyNewWords)} 字',
|
||||
),
|
||||
AnalyticsOverviewCard(
|
||||
title: 'Token 消耗',
|
||||
value: _formatLargeNumber(_overviewData!.totalTokens),
|
||||
changeValue: 23.8,
|
||||
isUpTrend: true,
|
||||
icon: Icons.flash_on,
|
||||
subtitle: '本月新增 ${_formatNumber(_overviewData!.monthlyNewTokens)} tokens',
|
||||
),
|
||||
AnalyticsOverviewCard(
|
||||
title: '功能使用次数',
|
||||
value: _formatLargeNumber(_overviewData!.functionUsageCount),
|
||||
changeValue: 12.5,
|
||||
isUpTrend: true,
|
||||
icon: Icons.trending_up,
|
||||
subtitle: '${_overviewData!.mostPopularFunction}最受欢迎',
|
||||
),
|
||||
AnalyticsOverviewCard(
|
||||
title: '写作天数',
|
||||
value: _overviewData!.writingDays.toString(),
|
||||
changeValue: 12.8,
|
||||
isUpTrend: true,
|
||||
icon: Icons.calendar_today,
|
||||
subtitle: '连续写作 ${_overviewData!.consecutiveDays} 天',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTokenUsageChart() {
|
||||
return AnalyticsCard(
|
||||
title: '',
|
||||
value: '',
|
||||
child: TokenUsageChart(
|
||||
data: _tokenData,
|
||||
viewMode: _tokenViewMode,
|
||||
onViewModeChanged: (mode) {
|
||||
setState(() => _tokenViewMode = mode);
|
||||
_loadTokenData();
|
||||
},
|
||||
dateRange: _dateRange,
|
||||
onDateRangeChanged: (range) {
|
||||
setState(() => _dateRange = range);
|
||||
if (_tokenViewMode == AnalyticsViewMode.range) {
|
||||
_loadTokenData();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartsSection() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnalyticsCard(
|
||||
title: '功能使用统计',
|
||||
value: '',
|
||||
child: FunctionUsageChart(
|
||||
data: _functionData,
|
||||
viewMode: _functionViewMode,
|
||||
onViewModeChanged: (mode) {
|
||||
setState(() => _functionViewMode = mode);
|
||||
_loadFunctionData();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
Expanded(
|
||||
child: AnalyticsCard(
|
||||
title: '大模型占比情况',
|
||||
value: '',
|
||||
child: ModelUsageChart(
|
||||
data: _modelData,
|
||||
viewMode: _modelViewMode,
|
||||
onViewModeChanged: (mode) {
|
||||
setState(() => _modelViewMode = mode);
|
||||
_loadModelData();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTokenUsageList() {
|
||||
return AnalyticsCard(
|
||||
title: '',
|
||||
value: '',
|
||||
child: TokenUsageList(
|
||||
records: _recordData,
|
||||
todaySummary: _todaySummary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInsightsSection() {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final insightsCrossAxisCount = width > 1200 ? 3 : (width > 800 ? 2 : 1);
|
||||
|
||||
return GridView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: insightsCrossAxisCount,
|
||||
crossAxisSpacing: 24,
|
||||
mainAxisSpacing: 24,
|
||||
// 固定每项高度,避免纵横比导致的轻微溢出
|
||||
mainAxisExtent: 190,
|
||||
),
|
||||
children: [
|
||||
AnalyticsInsightCard(
|
||||
icon: Icons.trending_up,
|
||||
title: '效率提升',
|
||||
description: '智能续写功能使用率上升 15%,用户写作效率显著提升',
|
||||
iconColor: Theme.of(context).primaryColor,
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
AnalyticsInsightCard(
|
||||
icon: Icons.article,
|
||||
title: '内容质量',
|
||||
description: '语法检查和风格优化功能显著提升了内容整体质量',
|
||||
iconColor: const Color(0xFF8B5CF6),
|
||||
backgroundColor: const Color(0xFF8B5CF6),
|
||||
),
|
||||
AnalyticsInsightCard(
|
||||
icon: Icons.flash_on,
|
||||
title: '用户活跃',
|
||||
description: '用户日均使用时长增加 28%,平台粘性持续增强',
|
||||
iconColor: const Color(0xFF10B981),
|
||||
backgroundColor: const Color(0xFF10B981),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
int _getColumnCount() {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
if (width > 1200) return 4;
|
||||
if (width > 800) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
String _formatNumber(int number) {
|
||||
return number.toString().replaceAllMapped(
|
||||
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
|
||||
(match) => '${match[1]},',
|
||||
);
|
||||
}
|
||||
|
||||
String _formatLargeNumber(int number) {
|
||||
if (number >= 1000000) {
|
||||
return '${(number / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (number >= 1000) {
|
||||
return '${(number / 1000).toStringAsFixed(1)}K';
|
||||
}
|
||||
return _formatNumber(number);
|
||||
}
|
||||
|
||||
Future<void> _loadTokenData() async {
|
||||
final data = await _analyticsRepo.getTokenUsageTrend(
|
||||
viewMode: _tokenViewMode,
|
||||
startDate: _dateRange?.start,
|
||||
endDate: _dateRange?.end,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _tokenData = data);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFunctionData() async {
|
||||
final data = await _analyticsRepo.getFunctionUsageStats(
|
||||
viewMode: _functionViewMode,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _functionData = data);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadModelData() async {
|
||||
final data = await _analyticsRepo.getModelUsageStats(
|
||||
viewMode: _modelViewMode,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() => _modelData = data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/animated_container_widget.dart';
|
||||
import 'package:flutter/material.dart' hide Badge;
|
||||
import 'package:ainoval/widgets/common/badge.dart';
|
||||
|
||||
class CategoryTagsNew extends StatelessWidget {
|
||||
final Function(String) onTagClick;
|
||||
|
||||
const CategoryTagsNew({
|
||||
Key? key,
|
||||
required this.onTagClick,
|
||||
}) : super(key: key);
|
||||
|
||||
static const List<Map<String, String>> categories = [
|
||||
{'name': '现代都市', 'prompt': '创作一个现代都市背景的小说,主角是一位在大城市奋斗的年轻人...'},
|
||||
{'name': '古风仙侠', 'prompt': '创作一个古风仙侠小说,描述一位修仙者的成长历程...'},
|
||||
{'name': '科幻未来', 'prompt': '创作一个科幻未来题材的小说,背景设定在2100年的地球...'},
|
||||
{'name': '悬疑推理', 'prompt': '创作一个悬疑推理小说,围绕一起神秘的案件展开...'},
|
||||
{'name': '校园青春', 'prompt': '创作一个校园青春小说,讲述高中生活中的友情与成长...'},
|
||||
{'name': '历史架空', 'prompt': '创作一个历史架空小说,设定在一个虚构的古代王朝...'},
|
||||
{'name': '玄幻魔法', 'prompt': '创作一个玄幻魔法小说,主角意外获得了强大的魔法力量...'},
|
||||
{'name': '军事战争', 'prompt': '创作一个军事战争小说,描述一场激烈的现代战争...'},
|
||||
{'name': '商战职场', 'prompt': '创作一个商战职场小说,主角在大企业中的奋斗历程...'},
|
||||
{'name': '穿越重生', 'prompt': '创作一个穿越重生小说,主角回到了十年前的自己...'},
|
||||
{'name': '末世求生', 'prompt': '创作一个末世求生小说,描述人类在灾难后的生存斗争...'},
|
||||
{'name': '异世冒险', 'prompt': '创作一个异世界冒险小说,主角被传送到了陌生的世界...'},
|
||||
{'name': '武侠江湖', 'prompt': '创作一个武侠江湖小说,讲述侠客行走江湖的故事...'},
|
||||
{'name': '娱乐圈', 'prompt': '创作一个娱乐圈题材的小说,主角是一位新人演员...'},
|
||||
{'name': '电竞游戏', 'prompt': '创作一个电竞游戏小说,描述职业选手的比赛生涯...'},
|
||||
{'name': '灵异恐怖', 'prompt': '创作一个灵异恐怖小说,主角遭遇了超自然现象...'},
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainerWidget(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择小说分类',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: categories.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final category = entry.value;
|
||||
|
||||
return AnimatedContainerWidget(
|
||||
animationType: AnimationType.scaleIn,
|
||||
delay: Duration(milliseconds: index * 50),
|
||||
child: Badge(
|
||||
text: category['name']!,
|
||||
variant: BadgeVariant.outline,
|
||||
onTap: () => onTagClick(category['prompt']!),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
fontSize: 14,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'点击标签快速填充创作提示词,或直接在上方输入框中输入您的想法',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
420
AINoval/lib/screens/novel_list/widgets/community_feed_new.dart
Normal file
420
AINoval/lib/screens/novel_list/widgets/community_feed_new.dart
Normal file
@@ -0,0 +1,420 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/animated_container_widget.dart';
|
||||
|
||||
class CommunityPost {
|
||||
final String id;
|
||||
final String title;
|
||||
final String content;
|
||||
final Author author;
|
||||
final int likes;
|
||||
final int quotes;
|
||||
final int comments;
|
||||
final bool isLiked;
|
||||
final String timeAgo;
|
||||
final String category;
|
||||
|
||||
CommunityPost({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.author,
|
||||
required this.likes,
|
||||
required this.quotes,
|
||||
required this.comments,
|
||||
required this.isLiked,
|
||||
required this.timeAgo,
|
||||
required this.category,
|
||||
});
|
||||
}
|
||||
|
||||
class Author {
|
||||
final String name;
|
||||
final String avatar;
|
||||
final String username;
|
||||
|
||||
Author({
|
||||
required this.name,
|
||||
required this.avatar,
|
||||
required this.username,
|
||||
});
|
||||
}
|
||||
|
||||
class CommunityFeedNew extends StatefulWidget {
|
||||
final Function(String) onApplyPrompt;
|
||||
|
||||
const CommunityFeedNew({
|
||||
Key? key,
|
||||
required this.onApplyPrompt,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CommunityFeedNew> createState() => _CommunityFeedNewState();
|
||||
}
|
||||
|
||||
class _CommunityFeedNewState extends State<CommunityFeedNew> {
|
||||
final List<CommunityPost> _posts = [
|
||||
CommunityPost(
|
||||
id: '1',
|
||||
title: '玄幻小说开头万能模板',
|
||||
content: '写一个少年在家族被灭后意外获得神秘力量的故事开头,要有悬念感和代入感,字数控制在500字左右...',
|
||||
author: Author(
|
||||
name: '笔墨生花',
|
||||
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&h=400&fit=crop',
|
||||
username: '@writer_master',
|
||||
),
|
||||
likes: 142,
|
||||
quotes: 28,
|
||||
comments: 15,
|
||||
isLiked: false,
|
||||
timeAgo: '2小时前',
|
||||
category: '玄幻修仙',
|
||||
),
|
||||
CommunityPost(
|
||||
id: '2',
|
||||
title: '现代都市情感描写技巧',
|
||||
content: '帮我写一段都市男女主角初次相遇的情景,要体现出心动的感觉,环境设定在咖啡厅,要求自然不做作...',
|
||||
author: Author(
|
||||
name: '城市夜语',
|
||||
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b9d25e62?w=400&h=400&fit=crop',
|
||||
username: '@city_romance',
|
||||
),
|
||||
likes: 89,
|
||||
quotes: 12,
|
||||
comments: 8,
|
||||
isLiked: true,
|
||||
timeAgo: '4小时前',
|
||||
category: '现代都市',
|
||||
),
|
||||
CommunityPost(
|
||||
id: '3',
|
||||
title: '科幻世界观构建提示',
|
||||
content: '构建一个2080年的未来世界,包含AI管理城市、虚拟现实普及、太空殖民等元素,要求逻辑自洽...',
|
||||
author: Author(
|
||||
name: '未来预言家',
|
||||
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop',
|
||||
username: '@sci_fi_master',
|
||||
),
|
||||
likes: 203,
|
||||
quotes: 45,
|
||||
comments: 32,
|
||||
isLiked: false,
|
||||
timeAgo: '6小时前',
|
||||
category: '科幻未来',
|
||||
),
|
||||
CommunityPost(
|
||||
id: '4',
|
||||
title: '古风诗词对白生成',
|
||||
content: '为古装剧本创作古风对白,男女主角在月下相遇的情景,要求用词典雅,意境优美,符合古代语言风格...',
|
||||
author: Author(
|
||||
name: '古韵悠长',
|
||||
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=400&h=400&fit=crop',
|
||||
username: '@ancient_poet',
|
||||
),
|
||||
likes: 156,
|
||||
quotes: 34,
|
||||
comments: 19,
|
||||
isLiked: true,
|
||||
timeAgo: '8小时前',
|
||||
category: '古风仙侠',
|
||||
),
|
||||
];
|
||||
|
||||
void _toggleLike(String postId) {
|
||||
setState(() {
|
||||
final index = _posts.indexWhere((post) => post.id == postId);
|
||||
if (index != -1) {
|
||||
final post = _posts[index];
|
||||
_posts[index] = CommunityPost(
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
content: post.content,
|
||||
author: post.author,
|
||||
likes: post.isLiked ? post.likes - 1 : post.likes + 1,
|
||||
quotes: post.quotes,
|
||||
comments: post.comments,
|
||||
isLiked: !post.isLiked,
|
||||
timeAgo: post.timeAgo,
|
||||
category: post.category,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'社区精选',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// Handle view more
|
||||
},
|
||||
child: Text(
|
||||
'查看更多',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Posts List
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _posts.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final post = _posts[index];
|
||||
return AnimatedContainerWidget(
|
||||
animationType: AnimationType.fadeIn,
|
||||
delay: Duration(milliseconds: index * 100),
|
||||
child: _buildPostCard(context, post),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPostCard(BuildContext context, CommunityPost post) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.5),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundImage: NetworkImage(post.author.avatar),
|
||||
backgroundColor: WebTheme.getEmptyStateColor(context),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Author Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
post.author.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
post.timeAgo,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
post.author.username,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// More Options
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.more_horiz,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
onPressed: () {
|
||||
// Handle more options
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
post.content,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.5,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Actions
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Action Buttons
|
||||
Row(
|
||||
children: [
|
||||
// Like Button
|
||||
_buildActionButton(
|
||||
context,
|
||||
icon: post.isLiked ? Icons.favorite : Icons.favorite_border,
|
||||
label: post.likes.toString(),
|
||||
color: post.isLiked ? Theme.of(context).colorScheme.error : null,
|
||||
onTap: () => _toggleLike(post.id),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Quote Button
|
||||
_buildActionButton(
|
||||
context,
|
||||
icon: Icons.format_quote,
|
||||
label: post.quotes.toString(),
|
||||
onTap: () {
|
||||
// Handle quote
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Comment Button
|
||||
_buildActionButton(
|
||||
context,
|
||||
icon: Icons.comment_outlined,
|
||||
label: post.comments.toString(),
|
||||
onTap: () {
|
||||
// Handle comment
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Apply Button
|
||||
_buildActionButton(
|
||||
context,
|
||||
icon: Icons.flash_on,
|
||||
label: '应用',
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
onTap: () => widget.onApplyPrompt(post.content),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Category Badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getEmptyStateColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
post.category,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
Color? color,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
final defaultColor = WebTheme.getSecondaryTextColor(context);
|
||||
final buttonColor = color ?? defaultColor;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: buttonColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: buttonColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
import 'package:ainoval/blocs/novel_list/novel_list_bloc.dart';
|
||||
import 'package:ainoval/models/novel_summary.dart';
|
||||
import 'package:ainoval/screens/editor/editor_screen.dart';
|
||||
import 'package:ainoval/utils/date_formatter.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// 继续写作区域组件
|
||||
class ContinueWritingSection extends StatelessWidget {
|
||||
const ContinueWritingSection({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
// 如果屏幕非常窄,则直接隐藏此区域
|
||||
if (screenWidth < 350) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocBuilder<NovelListBloc, NovelListState>(
|
||||
builder: (context, state) {
|
||||
if (state is NovelListLoaded && state.novels.isNotEmpty) {
|
||||
final recentNovels = List<NovelSummary>.from(state.novels)
|
||||
..sort((a, b) => b.lastEditTime.compareTo(a.lastEditTime));
|
||||
|
||||
if (recentNovels.length > 3) {
|
||||
recentNovels.removeRange(3, recentNovels.length);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionHeader(
|
||||
icon: Icons.edit_note,
|
||||
title: '继续写作',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 使用LayoutBuilder获取可用空间
|
||||
LayoutBuilder(builder: (context, constraints) {
|
||||
// 根据可用宽度动态计算卡片高度和数量
|
||||
double cardHeight;
|
||||
int visibleCards;
|
||||
|
||||
if (constraints.maxWidth < 450) {
|
||||
cardHeight = 120.0; // 进一步增加高度
|
||||
visibleCards = 1; // 只显示一张卡片
|
||||
} else if (constraints.maxWidth < 600) {
|
||||
cardHeight = 140.0; // 进一步增加高度
|
||||
visibleCards = 2; // 显示两张卡片
|
||||
} else {
|
||||
cardHeight = 160.0; // 进一步增加高度
|
||||
visibleCards = 3; // 显示所有卡片
|
||||
}
|
||||
|
||||
// 限制显示的卡片数量
|
||||
final displayNovels =
|
||||
recentNovels.take(visibleCards).toList();
|
||||
|
||||
return SizedBox(
|
||||
height: cardHeight,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: displayNovels.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemBuilder: (context, index) {
|
||||
final novel = displayNovels[index];
|
||||
|
||||
// 计算卡片宽度: 窄屏幕下宽度更窄,确保卡片不会过大
|
||||
double cardWidth;
|
||||
if (constraints.maxWidth < 450) {
|
||||
cardWidth =
|
||||
constraints.maxWidth * 0.85; // 非常窄的屏幕使用85%宽度
|
||||
} else if (constraints.maxWidth < 600) {
|
||||
cardWidth = constraints.maxWidth * 0.6; // 窄屏幕使用60%宽度
|
||||
} else {
|
||||
cardWidth = 280.0; // 宽屏幕使用固定宽度
|
||||
}
|
||||
|
||||
return RecentNovelCard(
|
||||
novel: novel,
|
||||
index: index,
|
||||
cardWidth: cardWidth,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近编辑过的小说卡片
|
||||
class RecentNovelCard extends StatelessWidget {
|
||||
const RecentNovelCard({
|
||||
super.key,
|
||||
required this.novel,
|
||||
required this.index,
|
||||
this.cardWidth = 280.0,
|
||||
});
|
||||
|
||||
final NovelSummary novel;
|
||||
final int index;
|
||||
final double cardWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bgColor = _getRandomPastelColor(context, novel.id, index);
|
||||
final bool isNarrow = cardWidth < 250;
|
||||
|
||||
return Container(
|
||||
width: cardWidth,
|
||||
margin: const EdgeInsets.only(left: 4, right: 12),
|
||||
child: Card(
|
||||
elevation: 3,
|
||||
shadowColor: (WebTheme.isDarkMode(context) ? WebTheme.black : WebTheme.grey400).withOpacity(0.15),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => _navigateToEditor(context),
|
||||
splashColor: WebTheme.getTextColor(context).withOpacity(0.1),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
// 封面区域 - 宽度等比例缩放
|
||||
SizedBox(
|
||||
width: isNarrow
|
||||
? cardWidth * 0.28
|
||||
: cardWidth * 0.33, // 很窄的卡片封面占比更小
|
||||
child: RecentNovelCover(
|
||||
novel: novel, bgColor: bgColor, index: index),
|
||||
),
|
||||
|
||||
// 信息区域
|
||||
Expanded(
|
||||
child: RecentNovelInfo(
|
||||
novel: novel,
|
||||
isCompact: isNarrow, // 窄卡片使用紧凑布局
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 导航到编辑器
|
||||
void _navigateToEditor(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => EditorScreen(novel: novel),
|
||||
),
|
||||
).then((_) {
|
||||
// 导航返回时刷新小说列表
|
||||
context.read<NovelListBloc>().add(LoadNovels());
|
||||
});
|
||||
}
|
||||
|
||||
// 获取动态的柔和颜色
|
||||
Color _getRandomPastelColor(BuildContext context, String id, int index) {
|
||||
final theme = Theme.of(context);
|
||||
final List<Color> lightColors = [
|
||||
const Color(0xFFBBDEFB), // Light Blue
|
||||
const Color(0xFFC8E6C9), // Light Green
|
||||
const Color(0xFFFFE0B2), // Light Orange
|
||||
const Color(0xFFF8BBD0), // Light Pink
|
||||
const Color(0xFFE1BEE7), // Light Purple
|
||||
const Color(0xFFB2DFDB), // Light Teal
|
||||
const Color(0xFFFFF9C4), // Light Yellow
|
||||
const Color(0xFFB3E5FC), // Light Cyan
|
||||
const Color(0xFFFFCCBC), // Light Deep Orange
|
||||
const Color(0xFFC5CAE9), // Light Indigo
|
||||
];
|
||||
|
||||
final List<Color> darkColors = [
|
||||
const Color(0xFF1E3A8A), // Dark Blue
|
||||
const Color(0xFF166534), // Dark Green
|
||||
const Color(0xFF9A3412), // Dark Orange
|
||||
const Color(0xFF9D174D), // Dark Pink
|
||||
const Color(0xFF7C2D92), // Dark Purple
|
||||
const Color(0xFF0F766E), // Dark Teal
|
||||
const Color(0xFF92400E), // Dark Yellow
|
||||
const Color(0xFF0E7490), // Dark Cyan
|
||||
const Color(0xFFEA580C), // Dark Deep Orange
|
||||
const Color(0xFF3730A3), // Dark Indigo
|
||||
];
|
||||
|
||||
final colors = theme.brightness == Brightness.dark ? darkColors : lightColors;
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
}
|
||||
|
||||
/// 区域标题头组件
|
||||
class SectionHeader extends StatelessWidget {
|
||||
const SectionHeader({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final bool isNarrow = screenWidth < 450;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: isNarrow ? 16 : 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(isNarrow ? 6 : 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
size: isNarrow ? 16 : 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: isNarrow ? 16 : 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近小说封面组件
|
||||
class RecentNovelCover extends StatelessWidget {
|
||||
const RecentNovelCover({
|
||||
super.key,
|
||||
required this.novel,
|
||||
required this.bgColor,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
final NovelSummary novel;
|
||||
final Color bgColor;
|
||||
final int index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
bgColor,
|
||||
bgColor.withOpacity(0.7),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 优先显示封面图片(如果有)
|
||||
if (novel.coverUrl.isNotEmpty)
|
||||
Image.network(
|
||||
novel.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// 加载失败时使用生成的设计
|
||||
return _buildCoverDesign(bgColor, novel.id, index);
|
||||
},
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
strokeWidth: 2,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
// 使用生成的设计作为默认封面
|
||||
_buildCoverDesign(bgColor, novel.id, index),
|
||||
|
||||
// 进度条
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: novel.completionPercentage,
|
||||
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey100,
|
||||
color: WebTheme.getTextColor(context),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建封面设计
|
||||
Widget _buildCoverDesign(Color baseColor, String id, int index) {
|
||||
final designType = index % 5;
|
||||
|
||||
switch (designType) {
|
||||
case 0:
|
||||
return _buildCircleDesign(baseColor);
|
||||
case 1:
|
||||
return _buildStripeDesign(baseColor);
|
||||
case 2:
|
||||
return _buildWaveDesign(baseColor);
|
||||
case 3:
|
||||
return _buildGridDesign(baseColor);
|
||||
default:
|
||||
return _buildGeometricDesign(baseColor);
|
||||
}
|
||||
}
|
||||
|
||||
// 圆形设计
|
||||
Widget _buildCircleDesign(Color baseColor) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CustomPaint(
|
||||
painter: _CirclePainter(
|
||||
baseColor: baseColor,
|
||||
color: baseColor.withOpacity(0.5),
|
||||
),
|
||||
size: const Size.square(200),
|
||||
),
|
||||
Center(
|
||||
child: Icon(
|
||||
Icons.auto_stories,
|
||||
size: 24,
|
||||
color: WebTheme.black.withOpacity(0.15),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 条纹设计
|
||||
Widget _buildStripeDesign(Color baseColor) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.7,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 15,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 3,
|
||||
color: baseColor.withGreen(180).withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 28,
|
||||
bottom: 20,
|
||||
child: Container(
|
||||
width: 4,
|
||||
color: baseColor.withBlue(180).withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 10,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: baseColor.withRed(200),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 15,
|
||||
left: 40,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: baseColor.withGreen(200),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Icon(
|
||||
Icons.menu_book,
|
||||
size: 24,
|
||||
color: WebTheme.black.withOpacity(0.15),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 波浪设计
|
||||
Widget _buildWaveDesign(Color baseColor) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: ClipPath(
|
||||
clipper: _WaveClipper(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [baseColor.withRed(200), baseColor.withBlue(200)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Icon(
|
||||
Icons.book_outlined,
|
||||
size: 24,
|
||||
color: WebTheme.black.withOpacity(0.15),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 网格设计
|
||||
Widget _buildGridDesign(Color baseColor) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CustomPaint(
|
||||
painter: _GridPainter(
|
||||
color: baseColor.withOpacity(0.5),
|
||||
lineWidth: 0.8,
|
||||
spacing: 8.0,
|
||||
),
|
||||
size: const Size.square(200),
|
||||
),
|
||||
Center(
|
||||
child: Icon(
|
||||
Icons.chrome_reader_mode,
|
||||
size: 24,
|
||||
color: WebTheme.black.withOpacity(0.15),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 几何设计
|
||||
Widget _buildGeometricDesign(Color baseColor) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.6,
|
||||
child: Transform.rotate(
|
||||
angle: -0.5,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: 10,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
color: baseColor.withBlue(200).withGreen(150),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 15,
|
||||
right: 15,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 25,
|
||||
color: baseColor.withRed(220).withGreen(180),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 35,
|
||||
right: 30,
|
||||
child: Container(
|
||||
width: 15,
|
||||
height: 50,
|
||||
color: baseColor.withGreen(200).withRed(150),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Icon(
|
||||
Icons.edit_document,
|
||||
size: 24,
|
||||
color: WebTheme.black.withOpacity(0.15),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近小说信息组件
|
||||
class RecentNovelInfo extends StatelessWidget {
|
||||
const RecentNovelInfo({
|
||||
super.key,
|
||||
required this.novel,
|
||||
this.isCompact = false,
|
||||
});
|
||||
|
||||
final NovelSummary novel;
|
||||
final bool isCompact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 使用 LayoutBuilder 来获取可用空间
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 根据可用高度决定显示哪些信息
|
||||
final availableHeight = constraints.maxHeight;
|
||||
|
||||
if (isCompact) {
|
||||
// 超紧凑模式 - 只显示最重要的信息
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 标题始终显示
|
||||
Text(
|
||||
novel.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
// 时间或系列名(二选一)
|
||||
if (novel.seriesName.isNotEmpty)
|
||||
_buildSeriesInfo(context)
|
||||
else
|
||||
_buildTimeInfo(context),
|
||||
|
||||
// 进度条始终显示
|
||||
const SizedBox(height: 3),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: novel.completionPercentage,
|
||||
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey100,
|
||||
color: WebTheme.getTextColor(context),
|
||||
minHeight: 2,
|
||||
),
|
||||
),
|
||||
|
||||
// 如果空间足够,显示字数
|
||||
if (availableHeight > 70) ...[
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.text_fields,
|
||||
size: 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${_formatNumber(novel.wordCount)}字',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 标准模式 - 使用 Flexible 控制子组件大小
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(10), // 稍微减小内边距
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
novel.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 时间信息
|
||||
_buildTimeInfo(context),
|
||||
|
||||
// 使用 Flexible 包装可能溢出的内容
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 3),
|
||||
|
||||
// 字数和系列信息
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.text_fields,
|
||||
size: 12,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${_formatNumber(novel.wordCount)}字',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (novel.seriesName.isNotEmpty && availableHeight > 100) ...[
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
novel.seriesName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
// 结构信息(如果空间足够)
|
||||
if (availableHeight > 90) ...[
|
||||
const SizedBox(height: 3),
|
||||
_buildStructureInfo(context),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 进度条
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: novel.completionPercentage,
|
||||
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey100,
|
||||
color: WebTheme.getTextColor(context),
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 构建系列信息组件
|
||||
Widget _buildSeriesInfo(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bookmark_border,
|
||||
size: 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
novel.seriesName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 构建时间信息组件
|
||||
Widget _buildTimeInfo(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: isCompact ? 10 : 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isCompact
|
||||
? DateFormatter.formatRelative(novel.lastEditTime)
|
||||
: '上次: ${DateFormatter.formatRelative(novel.lastEditTime)}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: isCompact ? 9 : 11,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 构建字数信息组件
|
||||
// removed unused _buildWordCountInfo to satisfy lints
|
||||
|
||||
// 构建卷、章节、场景数量信息组件
|
||||
Widget _buildStructureInfo(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.library_books_outlined,
|
||||
size: isCompact ? 9 : 10,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${novel.actCount}卷 / ${novel.chapterCount}章 / ${novel.sceneCount}场景',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: isCompact ? 8 : 9,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 格式化数字显示
|
||||
String _formatNumber(int number) {
|
||||
if (number < 1000) {
|
||||
return number.toString();
|
||||
} else if (number < 10000) {
|
||||
return '${(number / 1000).toStringAsFixed(1)}K';
|
||||
} else {
|
||||
return '${(number / 10000).toStringAsFixed(1)}万';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 波浪裁剪器
|
||||
class _WaveClipper extends CustomClipper<Path> {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
var path = Path();
|
||||
path.lineTo(0, size.height * 0.8);
|
||||
|
||||
var firstControlPoint = Offset(size.width / 4, size.height);
|
||||
var firstEndPoint = Offset(size.width / 2.2, size.height * 0.85);
|
||||
path.quadraticBezierTo(
|
||||
firstControlPoint.dx,
|
||||
firstControlPoint.dy,
|
||||
firstEndPoint.dx,
|
||||
firstEndPoint.dy,
|
||||
);
|
||||
|
||||
var secondControlPoint =
|
||||
Offset(size.width - (size.width / 3.5), size.height * 0.65);
|
||||
var secondEndPoint = Offset(size.width, size.height * 0.7);
|
||||
path.quadraticBezierTo(
|
||||
secondControlPoint.dx,
|
||||
secondControlPoint.dy,
|
||||
secondEndPoint.dx,
|
||||
secondEndPoint.dy,
|
||||
);
|
||||
|
||||
path.lineTo(size.width, 0);
|
||||
path.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
|
||||
}
|
||||
|
||||
// 网格绘制器
|
||||
class _GridPainter extends CustomPainter {
|
||||
_GridPainter({
|
||||
required this.color,
|
||||
required this.lineWidth,
|
||||
required this.spacing,
|
||||
});
|
||||
final Color color;
|
||||
final double lineWidth;
|
||||
final double spacing;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = lineWidth
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
// 水平线
|
||||
for (double y = 0; y <= size.height; y += spacing) {
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
||||
}
|
||||
|
||||
// 垂直线
|
||||
for (double x = 0; x <= size.width; x += spacing) {
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldPainter) => false;
|
||||
}
|
||||
|
||||
// 圆形绘制器
|
||||
class _CirclePainter extends CustomPainter {
|
||||
_CirclePainter({
|
||||
required this.color,
|
||||
required this.baseColor,
|
||||
});
|
||||
final Color color;
|
||||
final Color baseColor;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final centerX = size.width / 2;
|
||||
final centerY = size.height / 2;
|
||||
|
||||
// 绘制多个同心圆
|
||||
for (int i = 5; i > 0; i--) {
|
||||
final radius = (size.width / 2) * (i / 5);
|
||||
final paint = Paint()
|
||||
..color = i % 2 == 0 ? color : baseColor.withOpacity(0.3)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawCircle(Offset(centerX, centerY), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldPainter) => false;
|
||||
}
|
||||
75
AINoval/lib/screens/novel_list/widgets/empty_novel_view.dart
Normal file
75
AINoval/lib/screens/novel_list/widgets/empty_novel_view.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 空小说列表视图组件
|
||||
class EmptyNovelView extends StatelessWidget {
|
||||
const EmptyNovelView({
|
||||
super.key,
|
||||
required this.onCreateTap,
|
||||
});
|
||||
|
||||
final VoidCallback onCreateTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.auto_stories,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'没有找到小说',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'开始创建您的第一部小说作品吧',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onCreateTap,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建小说'),
|
||||
style: WebTheme.getSecondaryButtonStyle(context).copyWith(
|
||||
padding: WidgetStateProperty.all(
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
textStyle: WidgetStateProperty.all(
|
||||
const TextStyle(fontSize: 16),
|
||||
),
|
||||
foregroundColor: WidgetStateProperty.all(
|
||||
WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:ainoval/blocs/novel_list/novel_list_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class FilterNovelsDialog extends StatefulWidget {
|
||||
const FilterNovelsDialog({super.key});
|
||||
|
||||
@override
|
||||
State<FilterNovelsDialog> createState() => _FilterNovelsDialogState();
|
||||
}
|
||||
|
||||
class _FilterNovelsDialogState extends State<FilterNovelsDialog> {
|
||||
late FilterOption _currentFilterOption;
|
||||
final TextEditingController _seriesController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentFilterOption = (context.read<NovelListBloc>().state as NovelListLoaded).filterOption;
|
||||
_seriesController.text = _currentFilterOption.series ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('过滤选项'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('按系列过滤:'),
|
||||
TextField(
|
||||
controller: _seriesController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入系列名称',
|
||||
),
|
||||
onChanged: (value) {
|
||||
// 用户输入时可以实时更新预览,或者在点击应用时更新
|
||||
},
|
||||
),
|
||||
// 在这里可以添加更多过滤条件,例如字数范围、完成状态等
|
||||
// SwitchListTile for completion status, RangeSlider for word count, etc.
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final newFilterOption = FilterOption(
|
||||
series: _seriesController.text.trim().isNotEmpty ? _seriesController.text.trim() : null,
|
||||
// 其他过滤条件从UI元素获取
|
||||
);
|
||||
context.read<NovelListBloc>().add(FilterNovels(filterOption: newFilterOption));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('应用'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_seriesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
106
AINoval/lib/screens/novel_list/widgets/header_section.dart
Normal file
106
AINoval/lib/screens/novel_list/widgets/header_section.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/theme_toggle_button.dart';
|
||||
|
||||
/// 标题栏组件
|
||||
class HeaderSection extends StatelessWidget {
|
||||
const HeaderSection({
|
||||
super.key,
|
||||
required this.onCreateNovel,
|
||||
required this.onImportNovel,
|
||||
});
|
||||
|
||||
final VoidCallback onCreateNovel;
|
||||
final VoidCallback onImportNovel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.08),
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.menu_book,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'你的小说',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// 主题切换按钮
|
||||
const ThemeToggleButton(),
|
||||
const SizedBox(width: 12),
|
||||
// 测试按钮
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.bug_report,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
label: const Text('测试'),
|
||||
style: WebTheme.getSecondaryButtonStyle(context),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onImportNovel,
|
||||
icon: Icon(
|
||||
Icons.file_upload,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
label: const Text('导入'),
|
||||
style: WebTheme.getSecondaryButtonStyle(context),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onCreateNovel,
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
label: const Text('创建小说'),
|
||||
style: WebTheme.getSecondaryButtonStyle(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
448
AINoval/lib/screens/novel_list/widgets/import_novel_dialog.dart
Normal file
448
AINoval/lib/screens/novel_list/widgets/import_novel_dialog.dart
Normal file
@@ -0,0 +1,448 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
// Removed unused import
|
||||
import 'package:ainoval/blocs/novel_import/novel_import_bloc.dart';
|
||||
import 'package:ainoval/blocs/novel_list/novel_list_bloc.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
/// 小说导入对话框
|
||||
class ImportNovelDialog extends StatefulWidget {
|
||||
/// 创建小说导入对话框
|
||||
const ImportNovelDialog({super.key});
|
||||
|
||||
@override
|
||||
State<ImportNovelDialog> createState() => _ImportNovelDialogState();
|
||||
}
|
||||
|
||||
class _ImportNovelDialogState extends State<ImportNovelDialog> {
|
||||
// 存储BLoC引用,避免在dispose中访问context
|
||||
late final NovelImportBloc _importBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 获取并保存BLoC引用
|
||||
_importBloc = context.read<NovelImportBloc>();
|
||||
|
||||
// 初始化时延迟检查,确保 context 已经准备好
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// 检查状态并在需要时重置
|
||||
final state = _importBloc.state;
|
||||
if (state is NovelImportSuccess || state is NovelImportFailure) {
|
||||
_importBloc.add(ResetImportState());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 确保在对话框关闭时只触发一次重置
|
||||
final state = _importBloc.state;
|
||||
if (state is NovelImportInProgress && state.status != 'CANCELLING') {
|
||||
_importBloc.add(ResetImportState());
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<NovelImportBloc, NovelImportState>(
|
||||
listener: (context, state) {
|
||||
if (state is NovelImportSuccess) {
|
||||
// 延迟关闭对话框,给用户一个成功的视觉反馈
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
if (context.mounted) {
|
||||
// 重置导入状态,确保下次打开对话框时状态为初始状态
|
||||
_importBloc.add(ResetImportState());
|
||||
context.read<NovelListBloc>().add(LoadNovels());
|
||||
Navigator.of(context).pop();
|
||||
// 显示成功消息
|
||||
TopToast.success(context, '导入成功: ${state.message}');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 400,
|
||||
minHeight: 200,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.upload_file, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'导入小说',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (state is! NovelImportInProgress && state is! NovelImportSuccess)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 状态和内容
|
||||
_buildDialogContent(context, state),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 按钮
|
||||
_buildDialogActions(context, state),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建对话框内容
|
||||
Widget _buildDialogContent(BuildContext context, NovelImportState state) {
|
||||
if (state is NovelImportInitial) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.upload_file,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'导入TXT格式的小说文件,系统将自动识别章节结构。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'支持的文件格式: TXT (UTF-8编码)',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 14, color: WebTheme.getSecondaryTextColor(context)),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is NovelImportInProgress) {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 进度指示器
|
||||
LinearProgressIndicator(
|
||||
value: state.status == 'PREPARING' || state.status == 'UPLOADING'
|
||||
? null // 不确定进度时使用不确定进度条
|
||||
: (state.progress != null && state.progress! > 0) ? state.progress : null,
|
||||
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 状态图标
|
||||
_buildStatusIcon(context, state.status),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 状态文本
|
||||
Text(
|
||||
state.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 二级状态文本
|
||||
Text(
|
||||
_getStatusDescription(state.status),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
// 添加调试信息,在开发环境下显示
|
||||
if (state.jobId != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'任务ID: ${state.jobId}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'状态: ${state.status}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is NovelImportSuccess) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'导入成功',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is NovelImportFailure) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'导入失败',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.error.withOpacity(0.4)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'可能的原因:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'• 文件编码不是UTF-8\n'
|
||||
'• 文件格式不正确\n'
|
||||
'• 文件可能已损坏\n'
|
||||
'• 服务器暂时无法处理',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// 构建状态图标
|
||||
Widget _buildStatusIcon(BuildContext context, String status) {
|
||||
IconData iconData;
|
||||
Color iconColor;
|
||||
|
||||
switch (status) {
|
||||
case 'PREPARING':
|
||||
iconData = Icons.file_present;
|
||||
iconColor = Theme.of(context).colorScheme.primary;
|
||||
break;
|
||||
case 'UPLOADING':
|
||||
iconData = Icons.cloud_upload;
|
||||
iconColor = Theme.of(context).colorScheme.primary;
|
||||
break;
|
||||
case 'PROCESSING':
|
||||
iconData = Icons.auto_stories;
|
||||
iconColor = WebTheme.getTextColor(context);
|
||||
break;
|
||||
case 'SAVING':
|
||||
iconData = Icons.save;
|
||||
iconColor = WebTheme.getTextColor(context);
|
||||
break;
|
||||
case 'INDEXING':
|
||||
iconData = Icons.search;
|
||||
iconColor = WebTheme.getSecondaryTextColor(context);
|
||||
break;
|
||||
default:
|
||||
iconData = Icons.sync;
|
||||
iconColor = WebTheme.getTextColor(context);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Stack(
|
||||
children: [
|
||||
Icon(
|
||||
iconData,
|
||||
size: 48,
|
||||
color: iconColor,
|
||||
),
|
||||
if (status == 'PROCESSING' || status == 'INDEXING')
|
||||
Positioned.fill(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取状态描述
|
||||
String _getStatusDescription(String status) {
|
||||
switch (status) {
|
||||
case 'PREPARING':
|
||||
return '正在准备文件数据...';
|
||||
case 'UPLOADING':
|
||||
return '正在上传文件到服务器...';
|
||||
case 'PROCESSING':
|
||||
return '正在分析文件内容,识别章节结构...';
|
||||
case 'SAVING':
|
||||
return '正在保存小说结构和章节内容...';
|
||||
case 'INDEXING':
|
||||
return '正在为小说内容创建索引,以便AI更好地理解...';
|
||||
default:
|
||||
return '处理中...';
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建对话框按钮
|
||||
Widget _buildDialogActions(BuildContext context, NovelImportState state) {
|
||||
if (state is NovelImportInitial) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.cancel),
|
||||
label: const Text('取消'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _importBloc.add(ImportNovelFile()),
|
||||
icon: const Icon(Icons.upload_file),
|
||||
label: const Text('选择文件'),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is NovelImportInProgress) {
|
||||
return Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
_importBloc.add(ResetImportState());
|
||||
TopToast.info(context, '已取消导入');
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.cancel),
|
||||
label: const Text('取消导入'),
|
||||
),
|
||||
);
|
||||
} else if (state is NovelImportFailure) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_importBloc.add(ResetImportState());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
label: const Text('关闭'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
_importBloc.add(ResetImportState());
|
||||
_importBloc.add(ImportNovelFile());
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (state is NovelImportSuccess) {
|
||||
return FilledButton.icon(
|
||||
onPressed: () {
|
||||
_importBloc.add(ResetImportState());
|
||||
context.read<NovelListBloc>().add(LoadNovels());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('完成'),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
36
AINoval/lib/screens/novel_list/widgets/loading_view.dart
Normal file
36
AINoval/lib/screens/novel_list/widgets/loading_view.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 小说列表加载状态组件
|
||||
class LoadingView extends StatelessWidget {
|
||||
const LoadingView({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载您的小说库...',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1827
AINoval/lib/screens/novel_list/widgets/novel_card.dart
Normal file
1827
AINoval/lib/screens/novel_list/widgets/novel_card.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
652
AINoval/lib/screens/novel_list/widgets/novel_input_new.dart
Normal file
652
AINoval/lib/screens/novel_list/widgets/novel_input_new.dart
Normal file
@@ -0,0 +1,652 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/widgets/common/animated_container_widget.dart';
|
||||
import 'package:ainoval/widgets/common/model_display_selector.dart';
|
||||
import 'package:ainoval/models/unified_ai_model.dart';
|
||||
|
||||
import 'package:ainoval/models/strategy_template_info.dart';
|
||||
import 'package:ainoval/blocs/setting_generation/setting_generation_bloc.dart';
|
||||
import 'package:ainoval/blocs/setting_generation/setting_generation_event.dart';
|
||||
import 'package:ainoval/blocs/setting_generation/setting_generation_state.dart';
|
||||
import '../../setting_generation/novel_settings_generator_screen.dart';
|
||||
|
||||
class NovelInputNew extends StatefulWidget {
|
||||
final String prompt;
|
||||
final Function(String) onPromptChanged;
|
||||
final UnifiedAIModel? selectedModel;
|
||||
final Function(UnifiedAIModel?)? onModelSelected;
|
||||
|
||||
const NovelInputNew({
|
||||
Key? key,
|
||||
required this.prompt,
|
||||
required this.onPromptChanged,
|
||||
this.selectedModel,
|
||||
this.onModelSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<NovelInputNew> createState() => _NovelInputNewState();
|
||||
}
|
||||
|
||||
class _NovelInputNewState extends State<NovelInputNew> with TickerProviderStateMixin {
|
||||
late TextEditingController _controller;
|
||||
bool _isGenerating = false;
|
||||
bool _isPolishing = false;
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
String _selectedStrategy = ''; // 默认为空,将从后端获取策略列表后设置
|
||||
bool _suppressControllerListener = false; // 避免程序化同步时反向通知父组件
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.prompt);
|
||||
_controller.addListener(() {
|
||||
if (_suppressControllerListener) return;
|
||||
widget.onPromptChanged(_controller.text);
|
||||
});
|
||||
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(seconds: 2),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_pulseAnimation = Tween<double>(
|
||||
begin: 0.5,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _pulseController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
// 首帧后启动心跳动画,避免在构建期/重启切换期驱动渲染
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_pulseController.repeat(reverse: true);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化时加载可用策略(仅已登录时)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final String? userId = AppConfig.userId; // 未登录为 null
|
||||
if (userId != null && userId.isNotEmpty) {
|
||||
context.read<SettingGenerationBloc>().add(const LoadStrategiesEvent());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
super.reassemble();
|
||||
if (!mounted) return;
|
||||
// 热重载/重启后,停止并在下一帧重启动画,避免在已释放的视图上渲染
|
||||
_pulseController.stop();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_pulseController.repeat(reverse: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(NovelInputNew oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.prompt != oldWidget.prompt && widget.prompt != _controller.text) {
|
||||
_suppressControllerListener = true;
|
||||
_controller.value = TextEditingValue(
|
||||
text: widget.prompt,
|
||||
selection: TextSelection.collapsed(offset: widget.prompt.length),
|
||||
);
|
||||
_suppressControllerListener = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_pulseController.isAnimating) {
|
||||
_pulseController.stop();
|
||||
}
|
||||
_controller.dispose();
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Future<void> _handleGenerate() async {
|
||||
// if (_controller.text.trim().isEmpty) return;
|
||||
//
|
||||
// setState(() {
|
||||
// _isGenerating = true;
|
||||
// });
|
||||
|
||||
// // 模拟生成过程
|
||||
// await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
// setState(() {
|
||||
// _isGenerating = false;
|
||||
// });
|
||||
// }
|
||||
|
||||
// Future<void> _handlePolish() async {
|
||||
// if (_controller.text.trim().isEmpty) return;
|
||||
//
|
||||
// setState(() {
|
||||
// _isPolishing = true;
|
||||
// });
|
||||
|
||||
// // 模拟AI润色过程
|
||||
// await Future.delayed(const Duration(milliseconds: 1500));
|
||||
//
|
||||
// final polishedPrompt = '经过AI润色:${_controller.text}。增加更多细节描述,包含丰富的情感色彩和生动的场景描写,让故事更加引人入胜。';
|
||||
// _controller.text = polishedPrompt;
|
||||
//
|
||||
// setState(() {
|
||||
// _isPolishing = false;
|
||||
// });
|
||||
// }
|
||||
|
||||
void _handleGenerateSettings() {
|
||||
if (_controller.text.trim().isEmpty || widget.selectedModel == null) return;
|
||||
|
||||
// 打开设定生成器对话框,并传递选择的策略
|
||||
_showSettingGeneratorDialog(context);
|
||||
}
|
||||
|
||||
void _showSettingGeneratorDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _SettingGeneratorDialog(
|
||||
initialPrompt: _controller.text.trim(),
|
||||
selectedModel: widget.selectedModel,
|
||||
selectedStrategy: _selectedStrategy,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return AnimatedContainerWidget(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
// Icon with animation
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
WebTheme.getPrimaryColor(context).withOpacity(0.3 * _pulseAnimation.value),
|
||||
WebTheme.getSecondaryColor(context).withOpacity(0.2 * _pulseAnimation.value),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
WebTheme.getPrimaryColor(context),
|
||||
WebTheme.getSecondaryColor(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 32,
|
||||
color: WebTheme.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Title
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'AI小说设定助手',
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
foreground: Paint()
|
||||
..shader = LinearGradient(
|
||||
colors: [
|
||||
WebTheme.getPrimaryColor(context),
|
||||
WebTheme.getPrimaryColor(context).withOpacity(0.8),
|
||||
WebTheme.getSecondaryColor(context),
|
||||
],
|
||||
).createShader(const Rect.fromLTWH(0, 0, 400, 70)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Subtitle
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 16,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'设定生成,黄金三章',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 16,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Description
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 800),
|
||||
child: Text(
|
||||
'输入您的创意想法,或者选择下方的分类标签,让AI为您创作精彩的小说设定和开篇黄金三章',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
height: 1.6,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Input Area
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 1000),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background blur effect
|
||||
Container(
|
||||
margin: const EdgeInsets.all(8),
|
||||
height: 240,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
WebTheme.getSecondaryColor(context).withOpacity(0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Text Field
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context).withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _controller,
|
||||
maxLines: 8,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
height: 1.6,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入您的小说创意想法,例如:一个现代都市的年轻程序员意外获得了穿越时空的能力...',
|
||||
hintStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.6),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(24),
|
||||
),
|
||||
),
|
||||
// Bottom Actions
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getEmptyStateColor(context).withOpacity(0.5),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧区域:模型选择器 + 策略选择器 (占4份)
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Row(
|
||||
children: [
|
||||
// Model Selection Button
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ModelDisplaySelector(
|
||||
selectedModel: widget.selectedModel,
|
||||
onModelSelected: widget.onModelSelected,
|
||||
size: ModelDisplaySize.small,
|
||||
height: 48, // 增加一半高度保持一致
|
||||
showIcon: true,
|
||||
showTags: true,
|
||||
showSettingsButton: true,
|
||||
placeholder: '选择AI模型',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Strategy Selection Dropdown
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _buildStrategySelector(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 中间留空区域 (占3份)
|
||||
const Expanded(
|
||||
flex: 3,
|
||||
child: SizedBox(),
|
||||
),
|
||||
// 右侧区域:生成设定按钮 (占2份)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
height: 48, // 确保按钮高度与其他组件一致
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _controller.text.trim().isEmpty ||
|
||||
widget.selectedModel == null ||
|
||||
_isGenerating ||
|
||||
_isPolishing
|
||||
? null
|
||||
: _handleGenerateSettings,
|
||||
icon: const Icon(Icons.psychology, size: 18),
|
||||
label: const Text('生成设定'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.secondary.withOpacity(0.3),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// // Polish Button
|
||||
// Flexible(
|
||||
// child: OutlinedButton.icon(
|
||||
// onPressed: _controller.text.trim().isEmpty || _isPolishing || _isGenerating
|
||||
// ? null
|
||||
// : _handlePolish,
|
||||
// icon: _isPolishing
|
||||
// ? SizedBox(
|
||||
// width: 16,
|
||||
// height: 16,
|
||||
// child: CircularProgressIndicator(
|
||||
// strokeWidth: 2,
|
||||
// valueColor: AlwaysStoppedAnimation<Color>(
|
||||
// WebTheme.getPrimaryColor(context),
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// : const Icon(Icons.auto_fix_high, size: 18),
|
||||
// label: Text(_isPolishing ? 'AI润色中...' : 'AI润色'),
|
||||
// style: OutlinedButton.styleFrom(
|
||||
// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
// side: BorderSide(
|
||||
// color: WebTheme.getPrimaryColor(context).withOpacity(0.3),
|
||||
// width: 1.5,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// // Generate Button
|
||||
// Flexible(
|
||||
// child: ElevatedButton.icon(
|
||||
// onPressed: _controller.text.trim().isEmpty || _isGenerating || _isPolishing
|
||||
// ? null
|
||||
// : _handleGenerate,
|
||||
// icon: _isGenerating
|
||||
// ? SizedBox(
|
||||
// width: 18,
|
||||
// height: 18,
|
||||
// child: CircularProgressIndicator(
|
||||
// strokeWidth: 2,
|
||||
// valueColor: AlwaysStoppedAnimation<Color>(
|
||||
// WebTheme.white,
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// : const Icon(Icons.send, size: 18),
|
||||
// label: Text(_isGenerating ? 'AI正在创作中...' : '开始创作'),
|
||||
// style: ElevatedButton.styleFrom(
|
||||
// padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
// backgroundColor: WebTheme.getPrimaryColor(context),
|
||||
// foregroundColor: WebTheme.white,
|
||||
// elevation: 0,
|
||||
// shape: RoundedRectangleBorder(
|
||||
// borderRadius: BorderRadius.circular(8),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建策略选择器
|
||||
Widget _buildStrategySelector() {
|
||||
return BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
|
||||
builder: (context, state) {
|
||||
List<StrategyTemplateInfo> strategies = [];
|
||||
bool isLoading = false;
|
||||
|
||||
if (state is SettingGenerationInitial) {
|
||||
isLoading = true;
|
||||
} else if (state is SettingGenerationReady) {
|
||||
strategies = state.strategies;
|
||||
} else if (state is SettingGenerationInProgress) {
|
||||
strategies = state.strategies;
|
||||
} else if (state is SettingGenerationCompleted) {
|
||||
strategies = state.strategies;
|
||||
}
|
||||
|
||||
// 如果策略为空,显示加载状态而不是使用硬编码默认值
|
||||
if (strategies.isEmpty && !isLoading) {
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
// 智能选择当前策略:优先选择“番茄小说/网文/tomato”,否则回退到“九线法”,再否则选第一个
|
||||
if (strategies.isNotEmpty && (_selectedStrategy.isEmpty || !strategies.any((s) => s.promptTemplateId == _selectedStrategy))) {
|
||||
// 1) 优先匹配番茄网文策略
|
||||
final tomatoStrategy = strategies.where((s) =>
|
||||
s.name.contains('番茄') ||
|
||||
s.name.contains('网文') ||
|
||||
s.name.toLowerCase().contains('tomato')
|
||||
).toList();
|
||||
|
||||
if (tomatoStrategy.isNotEmpty) {
|
||||
_selectedStrategy = tomatoStrategy.first.promptTemplateId;
|
||||
} else {
|
||||
// 2) 次选:九线法
|
||||
final nineLineStrategy = strategies.where((s) =>
|
||||
s.name.contains('九线法') ||
|
||||
s.name.contains('nine-line') ||
|
||||
s.name.toLowerCase().contains('nine')
|
||||
).toList();
|
||||
|
||||
if (nineLineStrategy.isNotEmpty) {
|
||||
_selectedStrategy = nineLineStrategy.first.promptTemplateId;
|
||||
} else {
|
||||
// 3) 兜底:第一个
|
||||
_selectedStrategy = strategies.first.promptTemplateId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 48, // 增加一半高度 (32 * 1.5)
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context).withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'加载中...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedStrategy.isEmpty ? null : _selectedStrategy,
|
||||
isExpanded: true,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
dropdownColor: WebTheme.getSurfaceColor(context),
|
||||
icon: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
items: strategies.map((strategy) {
|
||||
return DropdownMenuItem(
|
||||
value: strategy.promptTemplateId,
|
||||
child: Tooltip(
|
||||
message: strategy.description,
|
||||
child: Text(
|
||||
strategy.name,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedStrategy = value;
|
||||
});
|
||||
// 记录用户的选择以便调试
|
||||
print('用户选择策略: $value');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 设定生成器对话框包装器
|
||||
class _SettingGeneratorDialog extends StatelessWidget {
|
||||
final String initialPrompt;
|
||||
final UnifiedAIModel? selectedModel;
|
||||
final String selectedStrategy;
|
||||
|
||||
const _SettingGeneratorDialog({
|
||||
required this.initialPrompt,
|
||||
this.selectedModel,
|
||||
required this.selectedStrategy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
// Setting generator content
|
||||
Expanded(
|
||||
child: NovelSettingsGeneratorScreen(
|
||||
initialPrompt: initialPrompt,
|
||||
selectedModel: selectedModel,
|
||||
selectedStrategy: selectedStrategy,
|
||||
autoStart: true, // 自动开始生成
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
class NovelListErrorView extends StatelessWidget {
|
||||
const NovelListErrorView({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: ErrorCard(
|
||||
title: '加载失败',
|
||||
message: message,
|
||||
icon: Icons.error_outline_rounded,
|
||||
primaryAction: RetryButton(onRetry: onRetry),
|
||||
secondaryAction: const HelpButton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用错误展示卡片
|
||||
class ErrorCard extends StatelessWidget {
|
||||
const ErrorCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.icon,
|
||||
required this.primaryAction,
|
||||
this.secondaryAction,
|
||||
this.maxWidth = 320,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final IconData icon;
|
||||
final Widget primaryAction;
|
||||
final Widget? secondaryAction;
|
||||
final double maxWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 图标部分
|
||||
ErrorIconContainer(
|
||||
icon: icon,
|
||||
iconColor: theme.colorScheme.error,
|
||||
backgroundColor: theme.colorScheme.errorContainer.withOpacity(0.2),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 消息内容
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 主操作按钮
|
||||
primaryAction,
|
||||
|
||||
// 次要操作按钮
|
||||
if (secondaryAction != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
secondaryAction!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误图标容器
|
||||
class ErrorIconContainer extends StatelessWidget {
|
||||
const ErrorIconContainer({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.backgroundColor,
|
||||
this.size = 48,
|
||||
this.padding = 16,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Color backgroundColor;
|
||||
final double size;
|
||||
final double padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(padding),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: size,
|
||||
color: iconColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试按钮
|
||||
class RetryButton extends StatelessWidget {
|
||||
const RetryButton({
|
||||
super.key,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('重新加载'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 帮助按钮
|
||||
class HelpButton extends StatelessWidget {
|
||||
const HelpButton({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
// 添加一个帮助选项
|
||||
TopToast.info(context, '帮助功能将在下一个版本中实现');
|
||||
},
|
||||
child: Text(
|
||||
'需要帮助?',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/widgets/common/app_search_field.dart';
|
||||
import 'package:ainoval/widgets/common/app_filter_button.dart';
|
||||
import 'package:ainoval/widgets/common/app_view_toggle.dart';
|
||||
import 'package:ainoval/widgets/common/app_toolbar.dart';
|
||||
|
||||
/// 搜索和过滤工具栏组件
|
||||
class SearchFilterBar extends StatelessWidget {
|
||||
const SearchFilterBar({
|
||||
super.key,
|
||||
required this.searchController,
|
||||
required this.isGridView,
|
||||
required this.onSearchChanged,
|
||||
required this.onViewTypeChanged,
|
||||
required this.onFilterPressed,
|
||||
required this.onSortPressed,
|
||||
required this.onGroupPressed,
|
||||
this.onRefreshPressed,
|
||||
});
|
||||
|
||||
final TextEditingController searchController;
|
||||
final bool isGridView;
|
||||
final ValueChanged<String> onSearchChanged;
|
||||
final ValueChanged<bool> onViewTypeChanged;
|
||||
final VoidCallback onFilterPressed;
|
||||
final VoidCallback onSortPressed;
|
||||
final VoidCallback onGroupPressed;
|
||||
final VoidCallback? onRefreshPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppToolbar(
|
||||
children: [
|
||||
// 搜索框
|
||||
Expanded(
|
||||
child: AppSearchField(
|
||||
controller: searchController,
|
||||
onChanged: onSearchChanged,
|
||||
hintText: '搜索名称/系列...',
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 过滤器按钮组
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
AppFilterButton(
|
||||
label: '过滤',
|
||||
icon: Icons.filter_list,
|
||||
onPressed: onFilterPressed,
|
||||
),
|
||||
AppFilterButton(
|
||||
label: '排序',
|
||||
icon: Icons.sort,
|
||||
onPressed: onSortPressed,
|
||||
),
|
||||
AppFilterButton(
|
||||
label: '分组',
|
||||
icon: Icons.group_work,
|
||||
onPressed: onGroupPressed,
|
||||
),
|
||||
if (onRefreshPressed != null)
|
||||
AppFilterButton(
|
||||
label: '刷新',
|
||||
icon: Icons.refresh,
|
||||
onPressed: onRefreshPressed!,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 视图切换按钮
|
||||
AppViewToggle(
|
||||
isGridView: isGridView,
|
||||
onViewTypeChanged: onViewTypeChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:ainoval/blocs/novel_list/novel_list_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SortNovelsDialog extends StatelessWidget {
|
||||
const SortNovelsDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentSortOption = (context.read<NovelListBloc>().state as NovelListLoaded).sortOption;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('排序方式'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: SortOption.values.map((option) {
|
||||
return RadioListTile<SortOption>(
|
||||
title: Text(_getSortOptionText(option)),
|
||||
value: option,
|
||||
groupValue: currentSortOption,
|
||||
onChanged: (SortOption? value) {
|
||||
if (value != null) {
|
||||
context.read<NovelListBloc>().add(SortNovels(sortOption: value));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getSortOptionText(SortOption option) {
|
||||
switch (option) {
|
||||
case SortOption.lastEdited:
|
||||
return '最后编辑';
|
||||
case SortOption.title:
|
||||
return '标题';
|
||||
case SortOption.wordCount:
|
||||
return '字数';
|
||||
case SortOption.creationDate:
|
||||
return '创建日期';
|
||||
case SortOption.actCount:
|
||||
return '卷数';
|
||||
case SortOption.chapterCount:
|
||||
return '章节数';
|
||||
case SortOption.sceneCount:
|
||||
return '场景数';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user