main
dart 231 lines 7.33 KB
Raw
1 import 'package:hugeicons/hugeicons.dart';
2 import 'package:flutter/material.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:gpt_markdown/gpt_markdown.dart';
5 import 'package:localmind/core/providers/app_providers.dart';
6 import 'package:localmind/l10n/app_localizations.dart';
7 import 'package:localmind/core/theme/colors.dart';
8
9 class ReasoningWidget extends ConsumerStatefulWidget {
10 final String? reasoningContent;
11 final bool isStreaming;
12 final bool hasMainContent;
13
14 const ReasoningWidget({
15 super.key,
16 this.reasoningContent,
17 this.isStreaming = false,
18 this.hasMainContent = true,
19 });
20
21 @override
22 ConsumerState<ReasoningWidget> createState() => _ReasoningWidgetState();
23 }
24
25 class _ReasoningWidgetState extends ConsumerState<ReasoningWidget>
26 with SingleTickerProviderStateMixin {
27 late bool _isExpanded;
28 bool _hasUserManuallyToggled = false;
29 late AnimationController _animationController;
30 late Animation<double> _expandAnimation;
31
32 @override
33 void initState() {
34 super.initState();
35 final autoCollapse = ref.read(
36 settingsProvider.select((s) => s.autoCollapseThinking),
37 );
38 _isExpanded = widget.isStreaming || !widget.hasMainContent || !autoCollapse;
39 _animationController = AnimationController(
40 vsync: this,
41 duration: const Duration(milliseconds: 250),
42 );
43 _expandAnimation = CurvedAnimation(
44 parent: _animationController,
45 curve: Curves.fastOutSlowIn,
46 );
47 if (_isExpanded) {
48 _animationController.value = 1.0;
49 }
50 }
51
52 @override
53 void dispose() {
54 _animationController.dispose();
55 super.dispose();
56 }
57
58 @override
59 void didUpdateWidget(ReasoningWidget oldWidget) {
60 super.didUpdateWidget(oldWidget);
61 if (widget.isStreaming && !oldWidget.isStreaming) {
62 if (!_isExpanded) {
63 setState(() {
64 _isExpanded = true;
65 _animationController.forward();
66 });
67 }
68 } else if (!widget.isStreaming && oldWidget.isStreaming) {
69 if (!_hasUserManuallyToggled) {
70 final autoCollapse = ref.read(
71 settingsProvider.select((s) => s.autoCollapseThinking),
72 );
73 final shouldStayExpanded = !widget.hasMainContent || !autoCollapse;
74 if (!shouldStayExpanded && _isExpanded) {
75 setState(() {
76 _isExpanded = false;
77 _animationController.reverse();
78 });
79 }
80 }
81 }
82 }
83
84 void _toggleExpand() {
85 setState(() {
86 _hasUserManuallyToggled = true;
87 _isExpanded = !_isExpanded;
88 if (_isExpanded) {
89 _animationController.forward();
90 } else {
91 _animationController.reverse();
92 }
93 });
94 }
95
96 @override
97 Widget build(BuildContext context) {
98 final l10n = AppLocalizations.of(context)!;
99 final theme = Theme.of(context);
100 final isDark = theme.brightness == Brightness.dark;
101 final hasContent = widget.reasoningContent?.isNotEmpty == true;
102
103 if (!hasContent && !widget.isStreaming) {
104 return const SizedBox.shrink();
105 }
106
107 final reasoningTextColor = Color.lerp(
108 isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
109 isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
110 0.35,
111 )!;
112
113 final reasoningHighlightColor = isDark
114 ? AppColors.darkSurface.withValues(alpha: 0.9)
115 : const Color(0xFFF4F4F5);
116
117 return Container(
118 margin: const EdgeInsets.only(bottom: 12, top: 4),
119 decoration: BoxDecoration(
120 color: isDark ? AppColors.darkSurfaceInput : AppColors.lightSurface,
121 borderRadius: BorderRadius.circular(12),
122 border: Border.all(
123 color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
124 width: 1.0,
125 ),
126 boxShadow: [
127 BoxShadow(
128 color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.03),
129 blurRadius: 10,
130 offset: const Offset(0, 4),
131 ),
132 ],
133 ),
134 child: ClipRRect(
135 borderRadius: BorderRadius.circular(11),
136 child: Column(
137 mainAxisSize: MainAxisSize.min,
138 crossAxisAlignment: CrossAxisAlignment.start,
139 children: [
140 // Header Row
141 InkWell(
142 onTap: _toggleExpand,
143 borderRadius: BorderRadius.circular(11),
144 child: Padding(
145 padding: const EdgeInsets.symmetric(
146 horizontal: 14,
147 vertical: 12,
148 ),
149 child: Row(
150 children: [
151 HugeIcon(
152 icon: HugeIcons.strokeRoundedBrain,
153 size: 18,
154 color: isDark
155 ? AppColors.darkMutedText
156 : AppColors.lightMutedText,
157 ),
158 const SizedBox(width: 8),
159 Text(
160 l10n.thinking,
161 style: TextStyle(
162 fontSize: 13,
163 fontWeight: FontWeight.w600,
164 letterSpacing: 0.3,
165 color: isDark
166 ? AppColors.darkMutedText
167 : AppColors.lightMutedText,
168 ),
169 ),
170 const SizedBox(width: 8),
171 if (widget.isStreaming) ...[
172 SizedBox(
173 width: 10,
174 height: 10,
175 child: CircularProgressIndicator(
176 strokeWidth: 1.5,
177 color: theme.colorScheme.primary,
178 ),
179 ),
180 ],
181 const Spacer(),
182 RotationTransition(
183 turns: Tween(
184 begin: 0.0,
185 end: 0.5,
186 ).animate(_expandAnimation),
187 child: HugeIcon(
188 icon: HugeIcons.strokeRoundedArrowDown01,
189 size: 18,
190 color: isDark
191 ? AppColors.darkMutedText
192 : AppColors.lightMutedText,
193 ),
194 ),
195 ],
196 ),
197 ),
198 ),
199 // Expandable content
200 SizeTransition(
201 sizeFactor: _expandAnimation,
202 child: Padding(
203 padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
204 child: Column(
205 crossAxisAlignment: CrossAxisAlignment.start,
206 children: [
207 GptMarkdownTheme(
208 gptThemeData: GptMarkdownThemeData(
209 brightness: isDark ? Brightness.dark : Brightness.light,
210 highlightColor: reasoningHighlightColor,
211 linkColor: theme.colorScheme.primary,
212 ),
213 child: GptMarkdown(
214 widget.reasoningContent ?? '',
215 style: TextStyle(
216 fontSize: 13,
217 color: reasoningTextColor,
218 height: 1.5,
219 ),
220 ),
221 ),
222 ],
223 ),
224 ),
225 ),
226 ],
227 ),
228 ),
229 );
230 }
231 }