Add initial project files and configurations for clawd_code

This commit is contained in:
ImBenji
2026-04-04 05:46:34 +01:00
parent c88a1badc7
commit fa4415553d
14 changed files with 763 additions and 459 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class GarageHeader extends StatelessWidget {
const GarageHeader({super.key});
class AppHeader extends StatelessWidget {
const AppHeader({super.key});
@override
Widget build(BuildContext context) {
+164 -20
View File
@@ -1,5 +1,5 @@
import "package:flutter/widgets.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/chat_provider.dart";
import "message_bubble.dart";
@@ -13,49 +13,193 @@ class ChatView extends StatefulWidget {
class _ChatViewState extends State<ChatView> {
late ScrollController _scrollController;
List<String> _previousMessageContents = [];
bool _isUserScrolling = false;
DateTime? _lastScrollTime;
bool _showJumpToBottom = false;
bool _hasNewMessagesWhileScrolledAway = false;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_handleScroll);
}
@override
void dispose() {
_scrollController.removeListener(_handleScroll);
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
void _handleScroll() {
_lastScrollTime = DateTime.now();
_isUserScrolling = true;
// Update whether to show jump-to-bottom button
if (_scrollController.hasClients) {
final position = _scrollController.position;
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
if (isFarFromBottom != _showJumpToBottom) {
setState(() {
_showJumpToBottom = isFarFromBottom;
});
}
// If user scrolls to bottom manually, clear the new messages flag
if (!isFarFromBottom) {
setState(() {
_hasNewMessagesWhileScrolledAway = false;
});
}
}
// Check if scrolling has stopped (no scroll events for 150ms)
Future.delayed(const Duration(milliseconds: 150), () {
if (_lastScrollTime != null &&
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
if (mounted) {
setState(() {
_isUserScrolling = false;
});
}
}
});
}
bool _isNearBottom() {
if (!_scrollController.hasClients) return false;
final position = _scrollController.position;
// Consider user to be "near bottom" if they're within 150 pixels of the bottom
// Add a small buffer so we don't trigger on exact bottom
return position.pixels >= position.maxScrollExtent - 150;
}
void _jumpToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
setState(() {
_showJumpToBottom = false;
_hasNewMessagesWhileScrolledAway = false;
});
}
}
@override
Widget build(BuildContext context) {
return Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
// scroll to bottom when new messages arrive
if (chatProvider.messages.isNotEmpty) {
_scrollToBottom();
// Get current messages
final currentMessages = chatProvider.messages;
// Check if messages have actually changed (not just re-renders)
bool messagesChanged = false;
if (currentMessages.length != _previousMessageContents.length) {
messagesChanged = true;
} else {
for (int i = 0; i < currentMessages.length; i++) {
if (i >= _previousMessageContents.length ||
currentMessages[i].content != _previousMessageContents[i]) {
messagesChanged = true;
break;
}
}
}
if (messagesChanged && currentMessages.isNotEmpty) {
// Check if we're near the bottom
final nearBottom = _isNearBottom();
if (nearBottom && !_isUserScrolling) {
// Auto-scroll to bottom if user is near bottom and not scrolling
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
_hasNewMessagesWhileScrolledAway = false;
} else if (!nearBottom) {
// User is scrolled away from bottom when new messages arrive
_hasNewMessagesWhileScrolledAway = true;
}
}
return ListView.builder(
controller: _scrollController,
itemCount: chatProvider.messages.length,
itemBuilder: (context, index) {
final message = chatProvider.messages[index];
return MessageBubble(message: message);
},
// Update previous message state for next build
WidgetsBinding.instance.addPostFrameCallback((_) {
_previousMessageContents = currentMessages.map((m) => m.content).toList();
});
return Stack(
children: [
ListView.builder(
controller: _scrollController,
itemCount: currentMessages.length,
itemBuilder: (context, index) {
final message = currentMessages[index];
return Padding(
padding: EdgeInsetsGeometry.only(
top: index != 0 ? 12 : 0
),
child: MessageBubble(message: message)
);
},
),
if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway)
Positioned(
bottom: 16,
right: 16,
child: GestureDetector(
onTap: _jumpToBottom,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background.withOpacity(0.9),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0xFF000000).withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.arrowDown,
size: 16,
color: Theme.of(context).colorScheme.foreground,
),
const SizedBox(width: 6),
Text(
"New messages",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.foreground,
),
),
],
),
),
),
),
],
);
},
);
}
}
}
-33
View File
@@ -1,33 +0,0 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/cost_provider.dart";
class CostBadge extends StatelessWidget {
const CostBadge();
@override
Widget build(BuildContext context) {
return Consumer<CostProvider>(
builder: (context, costProvider, _) {
final costStr = costProvider.getFormattedTotalCost();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(4),
),
child: Text(
costStr,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF475569),
),
),
);
},
);
}
}
-82
View File
@@ -1,82 +0,0 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/chat_provider.dart";
class InputBar extends StatefulWidget {
const InputBar({super.key});
@override
State<InputBar> createState() => _InputBarState();
}
class _InputBarState extends State<InputBar> {
late TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _send(ChatProvider provider) {
final text = _controller.text.trim();
if (text.isEmpty) return;
provider.sendMessage(text);
_controller.clear();
}
@override
Widget build(BuildContext context) {
return Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
return Container(
padding: const EdgeInsets.all(14),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFE2E8F0)),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _controller,
minLines: 1,
maxLines: 4,
placeholder: const Text("Type a message..."),
enabled: !chatProvider.isLoading,
onSubmitted: chatProvider.isLoading ? null : (_) => _send(chatProvider),
),
),
const SizedBox(width: 10),
chatProvider.isLoading
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(value: null),
),
)
: PrimaryButton(
onPressed: () => _send(chatProvider),
child: const Text("Send"),
),
],
),
);
},
);
}
}
+88 -47
View File
@@ -1,4 +1,4 @@
import "package:flutter/material.dart" as material hide Card;
import "package:flutter/src/material/theme_data.dart";
import "package:flutter_markdown/flutter_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
@@ -14,47 +14,95 @@ class MessageBubble extends StatelessWidget {
final isUser = message.role == "user";
final isTool = message.role == "tool";
final isAssistant = message.role == "assistant";
final accentColor = isTool
? const Color(0xFF64748B)
: const Color(0xFF94A3B8);
final theme = Theme.of(context);
if (isUser) {
return Row(
children: [
Spacer(),
OutlinedContainer(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
backgroundColor: theme.colorScheme.border,
child: MarkdownBody(
data: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: _toolMarkdownStyleSheet(context),
),
),
],
);
} else if (isAssistant) {
return MarkdownBody(
data: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: _toolMarkdownStyleSheet(context),
);
} else if (isTool) {
final lines = message.content.split("\n");
final title = lines.first.trim();
return Row(
children: [
Container(
height: 10,
width: 10,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle
),
),
Gap(8),
Text(
title,
style: theme.typography.p.copyWith(
fontSize: 13
),
),
],
);
}
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
constraints: BoxConstraints(
maxWidth: material.MediaQuery.of(context).size.width * 0.7,
),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: material.Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.role,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: accentColor,
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.role,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 4),
if (isAssistant || isTool)
MarkdownBody(
data: isTool
? _buildToolMarkdown(message.content)
: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: isTool
? _toolMarkdownStyleSheet(context)
: null,
)
else
Text(message.content),
],
),
),
const SizedBox(height: 4),
if (isAssistant || isTool)
MarkdownBody(
data: isTool
? _buildToolMarkdown(message.content)
: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: isTool
? _toolMarkdownStyleSheet(context)
: null,
)
else
Text(message.content),
],
),
),
),
@@ -78,16 +126,9 @@ class MessageBubble extends StatelessWidget {
MarkdownStyleSheet _toolMarkdownStyleSheet(BuildContext context) {
final theme = Theme.of(context);
return MarkdownStyleSheet.fromTheme(material.Theme.of(context)).copyWith(
p: theme.typography.base.copyWith(height: 1.35),
codeblockDecoration: BoxDecoration(
color: theme.colorScheme.muted.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
),
codeblockPadding: const EdgeInsets.all(12),
code: theme.typography.base.copyWith(
fontFamily: "monospace",
height: 1.35,
return MarkdownStyleSheet(
p: theme.typography.p.copyWith(
fontSize: 13
),
);
}
-97
View File
@@ -1,97 +0,0 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/chat_provider.dart";
import "../providers/session_provider.dart";
class Sidebar extends StatelessWidget {
const Sidebar();
@override
Widget build(BuildContext context) {
return Consumer<SessionProvider>(
builder: (context, sessionProvider, _) {
return Column(
children: [
// sessions list
Expanded(
child: ListView.builder(
itemCount: sessionProvider.sessions.length,
itemBuilder: (context, index) {
final session = sessionProvider.sessions[index];
final isSelected =
sessionProvider.currentSessionId == session.id;
return GestureDetector(
onTap: () async {
await sessionProvider.loadSession(session.id);
if (context.mounted) {
final chatProvider =
Provider.of<ChatProvider>(context, listen: false);
chatProvider.setConversation(
sessionProvider.getConversationHistory(),
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFFEFF6FF)
: Colors.transparent,
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
),
),
Text(
"${session.messageCount} msgs",
style: const TextStyle(
fontSize: 12,
color: Color(0xFF94A3B8),
),
),
],
),
),
);
},
),
),
// new session button
Padding(
padding: const EdgeInsets.all(12),
child: PrimaryButton(
onPressed: () async {
await sessionProvider.createNewSession();
if (context.mounted) {
final chatProvider =
Provider.of<ChatProvider>(context, listen: false);
chatProvider.setConversation(
sessionProvider.getConversationHistory(),
);
}
},
child: const Text("+ New"),
),
),
],
);
},
);
}
}