首次提交:初始化项目

This commit is contained in:
lhr
2025-12-30 01:06:42 +08:00
parent 6dcba8d533
commit 9d45d4c726
141 changed files with 6186 additions and 133 deletions

170
lib/main.dart Normal file
View File

@@ -0,0 +1,170 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart';
import 'package:system_theme/system_theme.dart';
import 'providers/chat_provider.dart';
import 'screens/chat_screen.dart';
import 'screens/settings_screen.dart';
import 'theme/app_theme.dart';
const String appTitle = 'AI Chat';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb &&
[
TargetPlatform.windows,
TargetPlatform.android,
].contains(defaultTargetPlatform)) {
SystemTheme.accentColor.load();
}
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AppTheme()),
ChangeNotifierProvider(create: (_) => ChatProvider()),
],
child: Consumer<AppTheme>(
builder: (context, appTheme, child) {
return FluentApp(
title: appTitle,
themeMode: appTheme.mode,
debugShowCheckedModeBanner: false,
color: appTheme.color,
darkTheme: FluentThemeData(
brightness: Brightness.dark,
accentColor: appTheme.color,
visualDensity: VisualDensity.standard,
focusTheme: FocusThemeData(
glowFactor: is10footScreen(context) ? 2.0 : 0.0,
),
fontFamily: 'MiSans',
),
theme: FluentThemeData(
accentColor: appTheme.color,
visualDensity: VisualDensity.standard,
focusTheme: FocusThemeData(
glowFactor: is10footScreen(context) ? 2.0 : 0.0,
),
fontFamily: 'MiSans',
),
builder: (context, child) {
return Directionality(
textDirection: appTheme.textDirection,
child: child!,
);
},
home: const MyHomePage(),
);
},
),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _selectedIndex = 0;
final viewKey = GlobalKey(debugLabel: 'Navigation View Key');
@override
Widget build(BuildContext context) {
final appTheme = context.watch<AppTheme>();
final theme = FluentTheme.of(context);
return NavigationView(
key: viewKey,
appBar: NavigationAppBar(
automaticallyImplyLeading: false,
title: const Text(appTitle),
actions: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: ToggleSwitch(
checked: theme.brightness == Brightness.dark,
onChanged: (v) {
if (v) {
appTheme.mode = ThemeMode.dark;
} else {
appTheme.mode = ThemeMode.light;
}
},
content: const Text('Dark Mode'),
),
),
],
),
),
pane: NavigationPane(
selected: _selectedIndex,
onChanged: (index) => setState(() => _selectedIndex = index),
header: SizedBox(
height: kOneLineTileHeight,
child: ShaderMask(
shaderCallback: (rect) {
final color = appTheme.color.defaultBrushFor(theme.brightness);
return LinearGradient(colors: [color, color]).createShader(rect);
},
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(FluentIcons.robot, size: 24, color: Colors.white),
SizedBox(width: 8),
Text(
'AI Chat',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
),
displayMode: appTheme.displayMode,
indicator: () {
switch (appTheme.indicator) {
case NavigationIndicators.end:
return const EndNavigationIndicator();
case NavigationIndicators.sticky:
return const StickyNavigationIndicator();
}
}(),
items: [
PaneItem(
icon: const Icon(FluentIcons.chat),
title: const Text('Chat'),
body: const ChatScreen(),
),
],
footerItems: [
PaneItemSeparator(),
PaneItem(
icon: const Icon(FluentIcons.settings),
title: const Text('Settings'),
body: const SettingsScreen(),
),
],
),
);
}
}

28
lib/models/message.dart Normal file
View File

@@ -0,0 +1,28 @@
/// Message model representing a chat message
class Message {
final String id;
final String content;
final bool isUser;
final DateTime timestamp;
Message({
required this.id,
required this.content,
required this.isUser,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
Message copyWith({
String? id,
String? content,
bool? isUser,
DateTime? timestamp,
}) {
return Message(
id: id ?? this.id,
content: content ?? this.content,
isUser: isUser ?? this.isUser,
timestamp: timestamp ?? this.timestamp,
);
}
}

View File

@@ -0,0 +1,116 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import '../models/message.dart';
class ChatProvider extends ChangeNotifier {
final List<Message> _messages = [];
bool _isTyping = false;
String _currentAiResponse = '';
List<Message> get messages => List.unmodifiable(_messages);
bool get isTyping => _isTyping;
String get currentAiResponse => _currentAiResponse;
// Simulated AI responses for demo
final List<String> _aiResponses = [
'''# 你好!👋
我是你的 AI 助手,很高兴为你服务!
我可以帮助你:
- 回答各种问题
- 进行创意写作
- 代码编程辅助
- 翻译和语言学习
有什么我可以帮助你的吗?''',
'''## 这是一个很好的问题!
让我来详细解释一下:
1. **首先**,我们需要理解问题的核心
2. **其次**,分析可能的解决方案
3. **最后**,选择最优的实现方式
```dart
void main() {
print('Hello, Flutter!');
}
```
希望这个解释对你有帮助!''',
'''### 关于这个话题
我有以下几点想法:
> "创新是区分领导者和追随者的关键。" — 史蒂夫·乔布斯
| 方面 | 优点 | 缺点 |
|------|------|------|
| 性能 | ⚡ 快速 | 💾 内存占用 |
| 易用性 | ✅ 简单 | 📚 学习曲线 |
如果你有更多问题,随时问我!''',
'''我理解你的需求。让我为你提供一些建议:
1. 保持代码简洁清晰
2. 注重用户体验设计
3. 定期进行代码审查
4. 编写详细的文档
*记住:好的软件不仅仅是功能强大,更要易于使用和维护。*''',
];
void sendMessage(String content) {
if (content.trim().isEmpty) return;
// Add user message
final userMessage = Message(
id: DateTime.now().millisecondsSinceEpoch.toString(),
content: content,
isUser: true,
);
_messages.add(userMessage);
notifyListeners();
// Simulate AI response
_simulateAiResponse();
}
Future<void> _simulateAiResponse() async {
_isTyping = true;
_currentAiResponse = '';
notifyListeners();
// Wait a bit before starting to "type"
await Future.delayed(const Duration(milliseconds: 800));
// Get a random response
final random = Random();
final response = _aiResponses[random.nextInt(_aiResponses.length)];
// Simulate typing effect
for (int i = 0; i < response.length; i++) {
await Future.delayed(Duration(milliseconds: 10 + random.nextInt(20)));
_currentAiResponse = response.substring(0, i + 1);
notifyListeners();
}
// Add the complete message
final aiMessage = Message(
id: DateTime.now().millisecondsSinceEpoch.toString(),
content: response,
isUser: false,
);
_messages.add(aiMessage);
_isTyping = false;
_currentAiResponse = '';
notifyListeners();
}
void clearMessages() {
_messages.clear();
notifyListeners();
}
}

View File

@@ -0,0 +1,223 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import '../providers/chat_provider.dart';
import '../widgets/message_bubble.dart';
import '../widgets/typing_indicator.dart';
import '../widgets/chat_input.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final ScrollController _scrollController = ScrollController();
void _scrollToBottom() {
if (_scrollController.hasClients) {
Future.delayed(const Duration(milliseconds: 100), () {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
}
@override
Widget build(BuildContext context) {
return ScaffoldPage(
header: PageHeader(
title: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: FluentTheme.of(context).accentColor,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
FluentIcons.robot,
color: Colors.white,
size: 18,
),
),
const SizedBox(width: 12),
const Text('AI Chat'),
],
),
commandBar: CommandBar(
mainAxisAlignment: MainAxisAlignment.end,
primaryItems: [
CommandBarButton(
icon: const Icon(FluentIcons.add),
label: const Text('New Chat'),
onPressed: () {
context.read<ChatProvider>().clearMessages();
},
),
],
),
),
content: Column(
children: [
Expanded(child: _buildMessageList()),
_buildInputArea(),
],
),
);
}
Widget _buildMessageList() {
return Consumer<ChatProvider>(
builder: (context, chatProvider, child) {
final messages = chatProvider.messages;
final isTyping = chatProvider.isTyping;
final currentResponse = chatProvider.currentAiResponse;
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
if (messages.isEmpty && !isTyping) {
return _buildEmptyState();
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: messages.length + (isTyping ? 1 : 0),
itemBuilder: (context, index) {
if (index == messages.length && isTyping) {
if (currentResponse.isNotEmpty) {
return _buildTypingResponse(currentResponse);
}
return const TypingIndicator();
}
return MessageBubble(message: messages[index], showAvatar: true);
},
);
},
);
}
Widget _buildTypingResponse(String content) {
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),
Flexible(
child: Card(
padding: const EdgeInsets.all(12),
child: MarkdownBody(
data: content,
styleSheet: MarkdownStyleSheet(
p: theme.typography.body,
h1: theme.typography.title,
h2: theme.typography.subtitle,
h3: theme.typography.bodyLarge,
code: TextStyle(
fontFamily: 'Consolas',
backgroundColor: theme.cardColor,
),
),
),
),
),
const SizedBox(width: 48),
],
),
);
}
Widget _buildEmptyState() {
final theme = FluentTheme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: theme.accentColor,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(FluentIcons.robot, color: Colors.white, size: 40),
),
const SizedBox(height: 24),
Text('Hello! I\'m your AI Assistant', style: theme.typography.title),
const SizedBox(height: 12),
Text(
'How can I help you today?',
style: theme.typography.body?.copyWith(
color: theme.typography.body?.color?.withAlpha(180),
),
),
const SizedBox(height: 32),
_buildSuggestionChips(),
],
),
);
}
Widget _buildSuggestionChips() {
final suggestions = [
'Write a poem',
'Explain quantum computing',
'Help me code',
'Translate to English',
];
return Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: suggestions.map((suggestion) {
return Button(
onPressed: () {
context.read<ChatProvider>().sendMessage(suggestion);
},
child: Text(suggestion),
);
}).toList(),
);
}
Widget _buildInputArea() {
return Consumer<ChatProvider>(
builder: (context, chatProvider, child) {
return ChatInput(
onSend: (message) {
chatProvider.sendMessage(message);
},
enabled: !chatProvider.isTyping,
);
},
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,177 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:provider/provider.dart';
import '../theme/app_theme.dart';
import '../widgets/page_mixin.dart';
const List<String> accentColorNames = [
'System',
'Yellow',
'Orange',
'Red',
'Magenta',
'Purple',
'Blue',
'Teal',
'Green',
];
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> with PageMixin {
@override
Widget build(BuildContext context) {
final appTheme = context.watch<AppTheme>();
const spacer = SizedBox(height: 10);
const biggerSpacer = SizedBox(height: 40);
return ScaffoldPage.scrollable(
header: const PageHeader(title: Text('Settings')),
children: [
Text('Theme mode', style: FluentTheme.of(context).typography.subtitle),
spacer,
...List.generate(ThemeMode.values.length, (index) {
final mode = ThemeMode.values[index];
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8),
child: RadioButton(
checked: appTheme.mode == mode,
onChanged: (value) {
if (value) {
appTheme.mode = mode;
}
},
content: Text('$mode'.replaceAll('ThemeMode.', '')),
),
);
}),
biggerSpacer,
Text(
'Navigation Pane Display Mode',
style: FluentTheme.of(context).typography.subtitle,
),
spacer,
...List.generate(PaneDisplayMode.values.length, (index) {
final mode = PaneDisplayMode.values[index];
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8),
child: RadioButton(
checked: appTheme.displayMode == mode,
onChanged: (value) {
if (value) appTheme.displayMode = mode;
},
content: Text(mode.toString().replaceAll('PaneDisplayMode.', '')),
),
);
}),
biggerSpacer,
Text(
'Navigation Indicator',
style: FluentTheme.of(context).typography.subtitle,
),
spacer,
...List.generate(NavigationIndicators.values.length, (index) {
final mode = NavigationIndicators.values[index];
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8),
child: RadioButton(
checked: appTheme.indicator == mode,
onChanged: (value) {
if (value) appTheme.indicator = mode;
},
content: Text(
mode.toString().replaceAll('NavigationIndicators.', ''),
),
),
);
}),
biggerSpacer,
Text(
'Accent Color',
style: FluentTheme.of(context).typography.subtitle,
),
spacer,
Wrap(
children: [
Tooltip(
message: accentColorNames[0],
child: _buildColorBlock(appTheme, systemAccentColor),
),
...List.generate(Colors.accentColors.length, (index) {
final color = Colors.accentColors[index];
return Tooltip(
message: accentColorNames[index + 1],
child: _buildColorBlock(appTheme, color),
);
}),
],
),
biggerSpacer,
Text(
'Text Direction',
style: FluentTheme.of(context).typography.subtitle,
),
spacer,
...List.generate(TextDirection.values.length, (index) {
final direction = TextDirection.values[index];
return Padding(
padding: const EdgeInsetsDirectional.only(bottom: 8),
child: RadioButton(
checked: appTheme.textDirection == direction,
onChanged: (value) {
if (value) {
appTheme.textDirection = direction;
}
},
content: Text(
'$direction'
.replaceAll('TextDirection.', '')
.replaceAll('rtl', 'Right to left')
.replaceAll('ltr', 'Left to right'),
),
),
);
}).reversed,
],
);
}
Widget _buildColorBlock(AppTheme appTheme, AccentColor color) {
return Padding(
padding: const EdgeInsetsDirectional.all(2),
child: Button(
onPressed: () {
appTheme.color = color;
},
style: ButtonStyle(
padding: const WidgetStatePropertyAll(EdgeInsetsDirectional.zero),
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.isPressed) {
return color.light;
} else if (states.isHovered) {
return color.lighter;
}
return color;
}),
),
child: Container(
height: 40,
width: 40,
alignment: AlignmentDirectional.center,
child: appTheme.color == color
? Icon(
FluentIcons.check_mark,
color: color.basedOnLuminance(),
size: 22,
)
: null,
),
),
);
}
}

59
lib/theme/app_theme.dart Normal file
View File

@@ -0,0 +1,59 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:system_theme/system_theme.dart';
enum NavigationIndicators { sticky, end }
class AppTheme extends ChangeNotifier {
AccentColor? _color;
AccentColor get color => _color ?? systemAccentColor;
set color(AccentColor color) {
_color = color;
notifyListeners();
}
ThemeMode _mode = ThemeMode.system;
ThemeMode get mode => _mode;
set mode(ThemeMode mode) {
_mode = mode;
notifyListeners();
}
PaneDisplayMode _displayMode = PaneDisplayMode.compact;
PaneDisplayMode get displayMode => _displayMode;
set displayMode(PaneDisplayMode displayMode) {
_displayMode = displayMode;
notifyListeners();
}
NavigationIndicators _indicator = NavigationIndicators.sticky;
NavigationIndicators get indicator => _indicator;
set indicator(NavigationIndicators indicator) {
_indicator = indicator;
notifyListeners();
}
TextDirection _textDirection = TextDirection.ltr;
TextDirection get textDirection => _textDirection;
set textDirection(TextDirection direction) {
_textDirection = direction;
notifyListeners();
}
}
AccentColor get systemAccentColor {
if ((defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.android) &&
!kIsWeb) {
return AccentColor.swatch({
'darkest': SystemTheme.accentColor.darkest,
'darker': SystemTheme.accentColor.darker,
'dark': SystemTheme.accentColor.dark,
'normal': SystemTheme.accentColor.accent,
'light': SystemTheme.accentColor.light,
'lighter': SystemTheme.accentColor.lighter,
'lightest': SystemTheme.accentColor.lightest,
});
}
return Colors.blue;
}

View 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),
),
),
],
),
);
}
}

View 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),
),
),
),
),
);
}
}
}

View 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,
),
);
},
);
}
}

View 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,
),
),
);
}),
),
),
],
),
);
}
}