首次提交:初始化项目
This commit is contained in:
88
lib/widgets/chat_input.dart
Normal file
88
lib/widgets/chat_input.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
class ChatInput extends StatefulWidget {
|
||||
final Function(String) onSend;
|
||||
final bool enabled;
|
||||
|
||||
const ChatInput({super.key, required this.onSend, this.enabled = true});
|
||||
|
||||
@override
|
||||
State<ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
class _ChatInputState extends State<ChatInput> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
bool _hasContent = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
final hasContent = _controller.text.trim().isNotEmpty;
|
||||
if (hasContent != _hasContent) {
|
||||
setState(() {
|
||||
_hasContent = hasContent;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
if (_controller.text.trim().isNotEmpty && widget.enabled) {
|
||||
widget.onSend(_controller.text.trim());
|
||||
_controller.clear();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FluentTheme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
border: Border(
|
||||
top: BorderSide(color: theme.resources.dividerStrokeColorDefault),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 150),
|
||||
child: TextBox(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
maxLines: null,
|
||||
placeholder: 'Type a message...',
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(
|
||||
onPressed: _hasContent && widget.enabled ? _sendMessage : null,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Icon(FluentIcons.send, size: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/widgets/message_bubble.dart
Normal file
128
lib/widgets/message_bubble.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
|
||||
import '../models/message.dart';
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
final bool showAvatar;
|
||||
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.showAvatar = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FluentTheme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: message.isUser
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
if (!message.isUser && showAvatar) ...[
|
||||
_buildAiAvatar(theme),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Flexible(child: _buildMessageContent(context, theme)),
|
||||
if (message.isUser && showAvatar) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildUserAvatar(theme),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAiAvatar(FluentThemeData theme) {
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.accentColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(FluentIcons.robot, color: Colors.white, size: 18),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserAvatar(FluentThemeData theme) {
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.accentColor.light,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(FluentIcons.contact, color: Colors.white, size: 18),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageContent(BuildContext context, FluentThemeData theme) {
|
||||
if (message.isUser) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Card(
|
||||
backgroundColor: theme.accentColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Text(
|
||||
message.content,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Card(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: MarkdownBody(
|
||||
data: message.content,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: theme.typography.body,
|
||||
h1: theme.typography.title,
|
||||
h2: theme.typography.subtitle,
|
||||
h3: theme.typography.bodyLarge,
|
||||
code: TextStyle(
|
||||
fontFamily: 'Consolas',
|
||||
fontSize: 13,
|
||||
backgroundColor: theme.cardColor,
|
||||
color: theme.accentColor,
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
blockquote: TextStyle(
|
||||
color: theme.typography.body?.color?.withAlpha(180),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(color: theme.accentColor, width: 3),
|
||||
),
|
||||
),
|
||||
tableHead: theme.typography.bodyStrong,
|
||||
tableBody: theme.typography.body,
|
||||
tableBorder: TableBorder.all(
|
||||
color: theme.resources.dividerStrokeColorDefault,
|
||||
),
|
||||
tableCellsPadding: const EdgeInsets.all(8),
|
||||
strong: theme.typography.bodyStrong,
|
||||
em: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: theme.typography.body?.color?.withAlpha(200),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
lib/widgets/page_mixin.dart
Normal file
31
lib/widgets/page_mixin.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
mixin PageMixin {
|
||||
Widget description({required Widget content}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(bottom: 4),
|
||||
child: DefaultTextStyle(
|
||||
style: FluentTheme.of(context).typography.body!,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget subtitle({required Widget content}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(top: 14, bottom: 2),
|
||||
child: DefaultTextStyle(
|
||||
style: FluentTheme.of(context).typography.subtitle!,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/widgets/typing_indicator.dart
Normal file
99
lib/widgets/typing_indicator.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
class TypingIndicator extends StatefulWidget {
|
||||
const TypingIndicator({super.key});
|
||||
|
||||
@override
|
||||
State<TypingIndicator> createState() => _TypingIndicatorState();
|
||||
}
|
||||
|
||||
class _TypingIndicatorState extends State<TypingIndicator>
|
||||
with TickerProviderStateMixin {
|
||||
late List<AnimationController> _controllers;
|
||||
late List<Animation<double>> _animations;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controllers = List.generate(
|
||||
3,
|
||||
(index) => AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
),
|
||||
);
|
||||
|
||||
_animations = _controllers.map((controller) {
|
||||
return Tween<double>(
|
||||
begin: 0,
|
||||
end: -8,
|
||||
).animate(CurvedAnimation(parent: controller, curve: Curves.easeInOut));
|
||||
}).toList();
|
||||
|
||||
for (int i = 0; i < _controllers.length; i++) {
|
||||
Future.delayed(Duration(milliseconds: i * 150), () {
|
||||
if (mounted) {
|
||||
_controllers[i].repeat(reverse: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (var controller in _controllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = FluentTheme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.accentColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(FluentIcons.robot, color: Colors.white, size: 18),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Card(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animations[index],
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, _animations[index].value),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(right: index < 2 ? 6 : 0),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.accentColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user