首次提交:初始化项目
This commit is contained in:
170
lib/main.dart
Normal file
170
lib/main.dart
Normal 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
28
lib/models/message.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
116
lib/providers/chat_provider.dart
Normal file
116
lib/providers/chat_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
223
lib/screens/chat_screen.dart
Normal file
223
lib/screens/chat_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
177
lib/screens/settings_screen.dart
Normal file
177
lib/screens/settings_screen.dart
Normal 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
59
lib/theme/app_theme.dart
Normal 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;
|
||||
}
|
||||
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