Add initial project files and configurations for clawd_code

This commit is contained in:
ImBenji
2026-04-03 17:48:07 +01:00
parent 7541a5279b
commit c88a1badc7
273 changed files with 28339 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import "package:clawd_code/ui/screens/new_home_screen.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "providers/settings_provider.dart";
class ClawdApp extends StatelessWidget {
const ClawdApp();
@override
Widget build(BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, _) {
return ShadcnApp(
title: "Clawd",
home: NewHomeScreen(),
theme: ThemeData(colorScheme: ColorSchemes.darkNeutral, radius: 0.5),
);
},
);
}
}
+70
View File
@@ -0,0 +1,70 @@
class SelectableAiModel {
const SelectableAiModel({
required this.group,
required this.id,
required this.label,
});
final String group;
final String id;
final String label;
}
const List<SelectableAiModel> selectableAiModels = [
SelectableAiModel(
group: "Recommended",
id: "qwen/qwen3-coder",
label: "Qwen3 Coder",
),
SelectableAiModel(
group: "Recommended",
id: "openai/gpt-oss-120b",
label: "GPT-OSS 120B",
),
SelectableAiModel(
group: "Recommended",
id: "meta-llama/llama-3.3-70b-instruct",
label: "LLaMA 3.3 70B Instruct",
),
SelectableAiModel(
group: "Recommended",
id: "deepseek/deepseek-v3.2",
label: "DeepSeek v3.2",
)
// SelectableAiModel(
// group: "Anthropic",
// id: "anthropic/claude-sonnet-4.6",
// label: "Claude Sonnet 4.6",
// ),
// SelectableAiModel(
// group: "Anthropic",
// id: "anthropic/claude-opus-4.6",
// label: "Claude Opus 4.6",
// ),
// SelectableAiModel(
// group: "Anthropic",
// id: "anthropic/claude-haiku-4.5",
// label: "Claude Haiku 4.5",
// ),
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-5.4", label: "GPT-5.4"),
// SelectableAiModel(
// group: "OpenAI",
// id: "openai/gpt-5.4-mini",
// label: "GPT-5.4 Mini",
// ),
// SelectableAiModel(group: "OpenAI", id: "openai/gpt-4.1", label: "GPT-4.1"),
// SelectableAiModel(group: "Qwen", id: "qwen/qwen3.5-9b", label: "Qwen3.5-9B"),
// SelectableAiModel(
// group: "Qwen",
// id: "qwen/qwen3.5-35b-a3b",
// label: "Qwen3.5-35B-A3B",
// ),
// SelectableAiModel(
// group: "Qwen",
// id: "qwen/qwen3.5-flash-02-23",
// label: "Qwen3.5-Flash",
// ),
];
+228
View File
@@ -0,0 +1,228 @@
import "package:flutter/foundation.dart";
import "dart:convert";
import "../../src/chat/tool_loop_service.dart";
import "../../src/api/openrouter_client.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
import "settings_provider.dart";
class ChatProvider extends ChangeNotifier {
ChatProvider(this._settingsProvider);
final SettingsProvider _settingsProvider;
final ToolLoopService _toolLoopService = ToolLoopService();
ConversationHistory? _conversationHistory;
OpenRouterClient? _client;
bool _stopRequested = false;
List<Message> _messages = <Message>[];
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
bool isLoading = false;
List<Message> get messages => _messages;
int get messageCount => _messages.length;
bool get hasConversation => _conversationHistory != null;
bool get isStopping => _stopRequested;
void setConversation(ConversationHistory history) {
_conversationHistory = history;
_messages = history.getMessages();
_apiMessages = _buildApiMessages(_messages);
notifyListeners();
}
void clearConversation() {
_conversationHistory = null;
_messages = <Message>[];
_apiMessages = <Map<String, dynamic>>[];
isLoading = false;
notifyListeners();
}
Future<void> sendMessage(String text) async {
if (text.isEmpty || _conversationHistory == null) return;
final apiKey = _settingsProvider.settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) {
throw Exception(
"OpenRouter API key not set. Please configure it in settings.",
);
}
final savedModel = _settingsProvider.settings.model;
final model = _settingsProvider.normalizeModelId(savedModel);
if (savedModel != model) {
print("Normalizing legacy model ID from $savedModel to $model");
await _settingsProvider.updateModel(model);
}
try {
_stopRequested = false;
_client = await OpenRouterClientFactory.create(apiKey: apiKey);
final session = _conversationHistory!.session;
final workingDirectory = session?.workingDirectory;
if (session != null) {
session.model = model;
if (session.name == "New Chat") {
session.name = _buildSessionName(text);
}
}
// add user message to conversation
_conversationHistory!.addMessage("user", text);
_messages = _conversationHistory!.getMessages();
_apiMessages.add(<String, dynamic>{"role": "user", "content": text});
isLoading = true;
notifyListeners();
final toolLoopResult = await _toolLoopService.runTurn(
client: _client!,
model: model,
apiMessages: _apiMessages.take(_apiMessages.length - 1).toList(),
userText: text,
workingDirectory: workingDirectory,
onToolCall: (toolName, input) {
_conversationHistory!.addMessage(
"tool",
_formatToolCall(toolName, input),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onToolResult: (toolName, result) {
_conversationHistory!.addMessage(
"tool",
_formatToolResult(toolName, result),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
);
_apiMessages = toolLoopResult.apiMessages;
// add assistant message to visible conversation
_conversationHistory!.addMessage(
"assistant",
toolLoopResult.responseText,
tokens: toolLoopResult.response.outputTokens,
);
_messages = _conversationHistory!.getMessages();
// track cost (set to 0 for now — OpenRouter pricing varies by model)
final inputTokens = toolLoopResult.response.inputTokens ?? 0;
final outputTokens = toolLoopResult.response.outputTokens ?? 0;
cost_tracker.addToTotalSessionCost(
cost: 0.0,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheReadTokens: 0,
cacheCreationTokens: 0,
model: toolLoopResult.response.model,
);
// save session
if (session != null) {
await SessionStore.instance.saveSession(session);
}
notifyListeners();
} catch (error, stackTrace) {
print("Failed to send message: $error");
print(stackTrace);
if (error is RequestCancelledException) {
_conversationHistory!.addMessage("assistant", "Generation stopped.");
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
return;
}
if (error is ToolLoopException) {
_apiMessages = List<Map<String, dynamic>>.from(error.apiMessages);
}
_conversationHistory!.addMessage(
"assistant",
_buildTurnFailureMessage(error),
);
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
rethrow;
} finally {
_client?.close();
_client = null;
_stopRequested = false;
isLoading = false;
notifyListeners();
}
}
void stopGenerating() {
if (!isLoading) {
return;
}
_stopRequested = true;
print("Stopping active turn");
_client?.cancelActiveRequest();
notifyListeners();
}
@override
void dispose() {
_client?.close();
super.dispose();
}
String _buildSessionName(String text) {
final sanitized = text.replaceAll(RegExp(r"\s+"), " ").trim();
if (sanitized.isEmpty) {
return "New Chat";
}
const maxLength = 48;
if (sanitized.length <= maxLength) {
return sanitized;
}
return "${sanitized.substring(0, maxLength - 1).trimRight()}";
}
List<Map<String, dynamic>> _buildApiMessages(List<Message> messages) {
return messages
.where(
(message) => message.role == "user" || message.role == "assistant",
)
.map(
(message) => <String, dynamic>{
"role": message.role,
"content": message.content,
},
)
.toList(growable: true);
}
String _formatToolCall(String toolName, Map<String, dynamic> input) {
const encoder = JsonEncoder.withIndent(" ");
return "$toolName call\n${encoder.convert(input)}";
}
String _formatToolResult(String toolName, String result) {
return "$toolName result\n$result";
}
String _buildTurnFailureMessage(Object error) {
return "This turn failed before the assistant could finish: $error";
}
}
+19
View File
@@ -0,0 +1,19 @@
import "package:flutter/foundation.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
class CostProvider extends ChangeNotifier {
CostProvider();
double getTotalCostUsd() => cost_tracker.getTotalCostUsd();
int getTotalInputTokens() => cost_tracker.getTotalInputTokens();
int getTotalOutputTokens() => cost_tracker.getTotalOutputTokens();
String getFormattedTotalCost() => cost_tracker.formatTotalCost();
Future<void> refreshCost() async {
// read current values from cost tracker
final _ = cost_tracker.getTotalCostUsd();
notifyListeners();
}
}
+129
View File
@@ -0,0 +1,129 @@
import "dart:io";
import "package:flutter/foundation.dart";
import "package:path/path.dart" as path;
import "package:uuid/uuid.dart";
import "../../src/project_store.dart";
class ProjectsProvider extends ChangeNotifier {
ProjectsProvider(this._projectStore) {
_projects = List<ProjectRecord>.from(_projectStore.projects);
}
final ProjectStore _projectStore;
List<ProjectRecord> _projects = <ProjectRecord>[];
String? _selectedProjectId;
List<ProjectRecord> get projects =>
List<ProjectRecord>.unmodifiable(_projects);
String? get selectedProjectId => _selectedProjectId;
ProjectRecord? get selectedProject {
final selectedId = _selectedProjectId;
if (selectedId == null) {
return null;
}
for (final project in _projects) {
if (project.id == selectedId) {
return project;
}
}
return null;
}
Future<ProjectRecord?> addProject(String workingDirectory) async {
final normalizedDirectory = workingDirectory.trim();
if (normalizedDirectory.isEmpty) {
return null;
}
final directory = Directory(normalizedDirectory);
if (!await directory.exists()) {
return null;
}
final existing = _projects.cast<ProjectRecord?>().firstWhere(
(project) => project?.workingDirectory == normalizedDirectory,
orElse: () => null,
);
if (existing != null) {
_selectedProjectId = existing.id;
notifyListeners();
return existing;
}
const uuid = Uuid();
final project = ProjectRecord(
id: uuid.v4(),
name: _projectNameForDirectory(normalizedDirectory),
workingDirectory: normalizedDirectory,
createdAt: DateTime.now().toUtc(),
);
await _projectStore.update(
(current) => <ProjectRecord>[project, ...current],
);
_projects = List<ProjectRecord>.from(_projectStore.projects);
_selectedProjectId = project.id;
notifyListeners();
return project;
}
void selectProject(String id) {
if (_selectedProjectId == id) {
return;
}
final exists = _projects.any((project) => project.id == id);
if (!exists) {
return;
}
_selectedProjectId = id;
notifyListeners();
}
void selectProjectByWorkingDirectory(String? workingDirectory) {
final normalizedDirectory = workingDirectory?.trim();
if (normalizedDirectory == null || normalizedDirectory.isEmpty) {
if (_selectedProjectId != null) {
_selectedProjectId = null;
notifyListeners();
}
return;
}
final match = _projects.cast<ProjectRecord?>().firstWhere(
(project) => project?.workingDirectory == normalizedDirectory,
orElse: () => null,
);
final nextSelectedId = match?.id;
if (_selectedProjectId == nextSelectedId) {
return;
}
_selectedProjectId = nextSelectedId;
notifyListeners();
}
Future<void> removeProject(String id) async {
await _projectStore.update(
(current) => current.where((project) => project.id != id).toList(),
);
_projects = List<ProjectRecord>.from(_projectStore.projects);
if (_selectedProjectId == id) {
_selectedProjectId = null;
}
notifyListeners();
}
String _projectNameForDirectory(String workingDirectory) {
final baseName = path.basename(workingDirectory);
return baseName.isEmpty ? workingDirectory : baseName;
}
}
+161
View File
@@ -0,0 +1,161 @@
import "package:flutter/foundation.dart";
import "package:uuid/uuid.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
class SessionProvider extends ChangeNotifier {
SessionProvider() {
_loadSessions();
}
final SessionStore _sessionStore = SessionStore.instance;
final ConversationHistory _conversationHistory = ConversationHistory();
List<SessionSummary> _sessions = <SessionSummary>[];
String? _currentSessionId;
ConversationSession? _currentSession;
String? _activeWorkingDirectory;
List<SessionSummary> get sessions => _sessions;
String? get currentSessionId => _currentSessionId;
ConversationSession? get currentSession => _currentSession;
String? get activeWorkingDirectory => _activeWorkingDirectory;
List<SessionSummary> sessionsForWorkingDirectory(String? workingDirectory) {
final normalizedDirectory = workingDirectory?.trim();
if (normalizedDirectory == null || normalizedDirectory.isEmpty) {
return List<SessionSummary>.unmodifiable(_sessions);
}
return List<SessionSummary>.unmodifiable(
_sessions.where(
(session) => session.workingDirectory == normalizedDirectory,
),
);
}
void selectWorkingDirectory(String? workingDirectory) {
_activeWorkingDirectory = workingDirectory?.trim();
notifyListeners();
}
void clearCurrentSession({String? workingDirectory}) {
_conversationHistory.setSession(
ConversationSession(
id: "",
name: "",
created: DateTime.now().toUtc(),
updated: DateTime.now().toUtc(),
workingDirectory: workingDirectory?.trim(),
),
);
_currentSession = null;
_currentSessionId = null;
_activeWorkingDirectory = workingDirectory?.trim();
notifyListeners();
}
Future<void> _loadSessions() async {
try {
_sessions = await _sessionStore.listSessions();
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to load sessions", error, stackTrace);
_sessions = <SessionSummary>[];
}
}
Future<void> createNewSession({
String? workingDirectory,
String? name,
}) async {
try {
const uuid = Uuid();
final newSessionId = uuid.v4();
final now = DateTime.now().toUtc();
final normalizedDirectory = workingDirectory?.trim();
final newSession = ConversationSession(
id: newSessionId,
name: name ?? "New Chat",
created: now,
updated: now,
workingDirectory:
normalizedDirectory == null || normalizedDirectory.isEmpty
? null
: normalizedDirectory,
);
await _sessionStore.saveSession(newSession);
_conversationHistory.setSession(newSession);
_currentSession = newSession;
_currentSessionId = newSessionId;
_activeWorkingDirectory = newSession.workingDirectory;
await _loadSessions();
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to create a new session", error, stackTrace);
}
}
Future<void> loadSession(String id) async {
try {
final session = await _sessionStore.loadSession(id);
if (session != null) {
_conversationHistory.setSession(session);
_currentSession = session;
_currentSessionId = id;
_activeWorkingDirectory = session.workingDirectory;
notifyListeners();
}
} catch (error, stackTrace) {
_logException("Failed to load session $id", error, stackTrace);
_currentSession = null;
_currentSessionId = null;
_activeWorkingDirectory = null;
}
}
Future<void> deleteSession(String id) async {
try {
await _sessionStore.deleteSession(id);
if (_currentSessionId == id) {
_conversationHistory.setSession(
ConversationSession(
id: "",
name: "",
created: DateTime.now().toUtc(),
updated: DateTime.now().toUtc(),
),
);
_currentSession = null;
_currentSessionId = null;
_activeWorkingDirectory = null;
}
await _loadSessions();
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to delete session $id", error, stackTrace);
}
}
Future<void> refreshSessions() async {
try {
await _loadSessions();
} catch (error, stackTrace) {
_logException("Failed to refresh sessions", error, stackTrace);
}
}
ConversationHistory getConversationHistory() => _conversationHistory;
void _logException(String message, Object error, StackTrace stackTrace) {
print("$message: $error");
print(stackTrace);
}
}
+59
View File
@@ -0,0 +1,59 @@
import "package:flutter/foundation.dart";
import "../../src/local_state.dart";
class SettingsProvider extends ChangeNotifier {
SettingsProvider(this._settingsStore) : settings = _settingsStore.settings;
static const Map<String, String> _legacyModelAliases = {
"google/gemini-2.0-flash": "google/gemini-2.0-flash-001",
};
final SettingsStore _settingsStore;
LocalSettings settings;
String normalizeModelId(String? modelId) {
if (modelId == null || modelId.isEmpty) {
return "anthropic/claude-sonnet-4.6";
}
return _legacyModelAliases[modelId] ?? modelId;
}
Future<void> updateModel(String newModel) async {
final normalizedModel = normalizeModelId(newModel);
await _settingsStore.update(
(current) => current.copyWith(model: normalizedModel),
);
settings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateApiKey(String newKey) async {
await _settingsStore.update(
(current) => current.copyWith(openRouterApiKey: newKey),
);
settings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateTheme(String newTheme) async {
await _settingsStore.update((current) => current.copyWith(theme: newTheme));
settings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateEffortLevel(String newLevel) async {
await _settingsStore.update(
(current) => current.copyWith(effortLevel: newLevel),
);
settings = _settingsStore.settings;
notifyListeners();
}
Future<void> resetToDefaults() async {
await _settingsStore.update((_) => const LocalSettings());
settings = _settingsStore.settings;
notifyListeners();
}
}
+34
View File
@@ -0,0 +1,34 @@
import "package:flutter/foundation.dart";
import "settings_provider.dart";
class ThemeProvider extends ChangeNotifier {
ThemeProvider(this._settingsProvider) : _currentTheme = _settingsProvider.settings.theme {
_settingsProvider.addListener(_onSettingsChanged);
}
final SettingsProvider _settingsProvider;
late String _currentTheme;
String get currentTheme => _currentTheme;
bool get isDark => _currentTheme == "dark" || _currentTheme == "dark-ansi";
void _onSettingsChanged() {
if (_currentTheme != _settingsProvider.settings.theme) {
_currentTheme = _settingsProvider.settings.theme;
notifyListeners();
}
}
Future<void> setTheme(String themeName) async {
await _settingsProvider.updateTheme(themeName);
_currentTheme = themeName;
notifyListeners();
}
@override
void dispose() {
_settingsProvider.removeListener(_onSettingsChanged);
super.dispose();
}
}
+717
View File
@@ -0,0 +1,717 @@
import "package:file_picker/file_picker.dart";
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../src/project_store.dart";
import "../../src/session/session_types.dart";
import "../constants.dart";
import "../providers/chat_provider.dart";
import "../providers/cost_provider.dart";
import "../providers/projects_provider.dart";
import "../providers/session_provider.dart";
import "../providers/settings_provider.dart";
import "../widgets/app_header.dart";
import "../widgets/chat_view.dart";
import "../widgets/settings_sheet.dart";
class NewHomeScreen extends StatefulWidget {
const NewHomeScreen({super.key});
@override
State<NewHomeScreen> createState() => _NewHomeScreenState();
}
class _NewHomeScreenState extends State<NewHomeScreen> {
late final TextEditingController _messageController;
@override
void initState() {
super.initState();
_messageController = TextEditingController();
}
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
Iterable<MapEntry<String, List<String>>> _filteredModels(String searchQuery) {
final normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return _modelGroups.entries;
}
return _modelGroups.entries
.map((entry) {
final matchingModels = entry.value
.where(
(modelId) =>
modelId.toLowerCase().contains(normalizedQuery) ||
_modelLabel(
modelId,
).toLowerCase().contains(normalizedQuery),
)
.toList();
return MapEntry(entry.key, matchingModels);
})
.where((entry) => entry.value.isNotEmpty);
}
Map<String, List<String>> get _modelGroups {
final groups = <String, List<String>>{};
for (final model in selectableAiModels) {
groups.putIfAbsent(model.group, () => <String>[]).add(model.id);
}
return groups;
}
String _modelLabel(String modelId) {
for (final model in selectableAiModels) {
if (model.id == modelId) {
return model.label;
}
}
return modelId;
}
Future<void> _pickProjectDirectory() async {
try {
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: "Select project directory",
);
if (selectedDirectory == null || !mounted) {
return;
}
final projectsProvider = context.read<ProjectsProvider>();
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
final project = await projectsProvider.addProject(selectedDirectory);
if (project == null && mounted) {
await _showProjectPickerError(
"The selected folder could not be added as a project.",
);
return;
}
projectsProvider.selectProject(project!.id);
sessionProvider.clearCurrentSession(
workingDirectory: project.workingDirectory,
);
chatProvider.clearConversation();
} catch (error, stackTrace) {
print("Project directory picker failed: $error");
print(stackTrace);
if (!mounted) {
return;
}
await _showProjectPickerError(error.toString());
}
}
Future<void> _createNewChat() async {
final projectsProvider = context.read<ProjectsProvider>();
final selectedProject = projectsProvider.selectedProject;
if (selectedProject == null) {
await _showProjectPickerError(
"Choose a project first so the new chat has a working directory.",
);
return;
}
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
await sessionProvider.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
);
chatProvider.setConversation(sessionProvider.getConversationHistory());
}
Future<void> _selectProject(ProjectRecord project) async {
final projectsProvider = context.read<ProjectsProvider>();
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
projectsProvider.selectProject(project.id);
if (sessionProvider.currentSession?.workingDirectory ==
project.workingDirectory) {
return;
}
sessionProvider.clearCurrentSession(
workingDirectory: project.workingDirectory,
);
chatProvider.clearConversation();
}
Future<void> _openSession(SessionSummary session) async {
final sessionProvider = context.read<SessionProvider>();
final chatProvider = context.read<ChatProvider>();
final projectsProvider = context.read<ProjectsProvider>();
await sessionProvider.loadSession(session.id);
chatProvider.setConversation(sessionProvider.getConversationHistory());
projectsProvider.selectProjectByWorkingDirectory(
sessionProvider.activeWorkingDirectory,
);
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty) {
return;
}
final sessionProvider = context.read<SessionProvider>();
final projectsProvider = context.read<ProjectsProvider>();
final chatProvider = context.read<ChatProvider>();
final selectedProject = projectsProvider.selectedProject;
if (sessionProvider.currentSession == null) {
if (selectedProject == null) {
await _showProjectPickerError("Pick a project before starting a chat.");
return;
}
await sessionProvider.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
);
chatProvider.setConversation(sessionProvider.getConversationHistory());
}
_messageController.clear();
try {
await chatProvider.sendMessage(text);
if (!mounted) {
return;
}
} catch (error, stackTrace) {
print("Failed to send message from home screen: $error");
print(stackTrace);
if (!mounted) {
return;
}
await _showProjectPickerError(error.toString());
} finally {
if (!mounted) {
return;
}
await context.read<SessionProvider>().refreshSessions();
}
}
void _stopMessage() {
context.read<ChatProvider>().stopGenerating();
}
void _openSettings() {
showDialog<void>(
context: context,
builder: (_) => const AlertDialog(content: SettingsSheet()),
);
}
Future<void> _showProjectPickerError(String message) {
return showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text("Heads up"),
content: Text(message),
actions: [
Button.primary(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text("OK"),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final projectsProvider = context.watch<ProjectsProvider>();
final sessionProvider = context.watch<SessionProvider>();
final chatProvider = context.watch<ChatProvider>();
final settingsProvider = context.watch<SettingsProvider>();
final costProvider = context.watch<CostProvider>();
// Group sessions by working directory
final sessionsByProject = <String, List<SessionSummary>>{};
for (final session in sessionProvider.sessions) {
final workingDirectory = session.workingDirectory ?? '';
if (!sessionsByProject.containsKey(workingDirectory)) {
sessionsByProject[workingDirectory] = <SessionSummary>[];
}
sessionsByProject[workingDirectory]!.add(session);
}
final selectedProject = projectsProvider.selectedProject;
final selectedWorkingDirectory = selectedProject?.workingDirectory;
final currentModel = settingsProvider.normalizeModelId(
settingsProvider.settings.model,
);
return Scaffold(
child: Row(
children: [
SizedBox(
width: 320,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: GarageHeader(),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
SizedBox(
width: double.infinity,
child: Button.ghost(
leading: const Icon(LucideIcons.folderPlus),
leadingGap: 12,
onPressed: _pickProjectDirectory,
child: Transform.translate(
offset: const Offset(0, 1),
child: const Align(
alignment: Alignment.centerLeft,
child: Text("New Project"),
),
),
),
),
const Gap(8),
SizedBox(
width: double.infinity,
child: Button.ghost(
leading: const Icon(LucideIcons.circlePlus),
leadingGap: 12,
onPressed:
selectedProject == null || chatProvider.isLoading
? null
: _createNewChat,
child: Transform.translate(
offset: const Offset(0, 1),
child: const Align(
alignment: Alignment.centerLeft,
child: Text("New Chat"),
),
),
),
),
],
),
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text("All Threads").textSmall.muted,
),
Expanded(
child: _ThreadsSection(
projectsProvider: projectsProvider,
sessionProvider: sessionProvider,
sessionsByProject: sessionsByProject,
onOpenSession: _openSession,
onSelectProject: _selectProject,
),
),
],
),
),
const VerticalDivider(),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedProject?.name ?? "Choose a project",
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
),
),
const Gap(6),
Text(
sessionProvider.currentSession?.name ??
(selectedProject == null
? "Use the file picker to choose a working directory."
: "Start a new chat in this working directory."),
).textSmall.muted,
],
),
),
const Gap(16),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
OutlinedContainer(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text("Working Directory").textSmall.muted,
const Gap(4),
SizedBox(
width: 280,
child: Text(
selectedWorkingDirectory ?? "Not selected",
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
).textSmall,
),
],
),
),
const Gap(8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedContainer(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Text(
"Session cost ${costProvider.getFormattedTotalCost()}",
).textSmall,
),
const Gap(8),
IconButton.ghost(
onPressed: _openSettings,
icon: const Icon(LucideIcons.settings2),
),
],
),
],
),
],
),
const Gap(20),
Expanded(
child: ClipRect(
child: OutlinedContainer(
child: chatProvider.messages.isEmpty
? _EmptyChatState(
projectName: selectedProject?.name,
hasProject: selectedProject != null,
)
: const ChatView(),
),
),
),
const Gap(16),
TextField(
controller: _messageController,
minLines: 3,
maxLines: 6,
enabled: !chatProvider.isLoading,
placeholder: Text(
selectedProject == null
? "Choose a project to start chatting"
: "Ask a question or type a message",
),
onSubmitted: chatProvider.isLoading
? null
: (_) => _sendMessage(),
features: [
InputFeature.below(
Row(
children: [
IconButton.ghost(
onPressed: _pickProjectDirectory,
icon: const Icon(LucideIcons.folderSearch),
),
const Spacer(),
Select<String>(
itemBuilder: (context, item) {
return Text(_modelLabel(item));
},
popup: SelectPopup.builder(
searchPlaceholder: const Text("Search models"),
builder: (context, searchQuery) {
final filteredModels = searchQuery == null
? _modelGroups.entries
: _filteredModels(searchQuery);
return SelectItemList(
children: [
for (final entry in filteredModels)
SelectGroup(
headers: [
SelectLabel(child: Text(entry.key)),
],
children: [
for (final modelId in entry.value)
SelectItemButton(
value: modelId,
child: Text(
_modelLabel(modelId),
),
),
],
),
],
);
},
),
onChanged: (value) {
if (value != null) {
settingsProvider.updateModel(value);
}
},
constraints: const BoxConstraints(minWidth: 220),
value: currentModel,
placeholder: const Text("Select a model"),
),
const Gap(10),
Button.primary(
onPressed: chatProvider.isLoading
? _stopMessage
: _sendMessage,
child: chatProvider.isLoading
? Text(
chatProvider.isStopping
? "Stopping..."
: "Stop",
)
: const Text("Send"),
),
],
),
),
],
),
],
),
),
),
],
),
);
}
}
class _SidebarHint extends StatelessWidget {
const _SidebarHint({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(text).textSmall.muted,
);
}
}
class _ThreadsSection extends StatelessWidget {
const _ThreadsSection({
required this.projectsProvider,
required this.sessionProvider,
required this.sessionsByProject,
required this.onOpenSession,
required this.onSelectProject,
});
final ProjectsProvider projectsProvider;
final SessionProvider sessionProvider;
final Map<String, List<SessionSummary>> sessionsByProject;
final ValueChanged<SessionSummary> onOpenSession;
final ValueChanged<ProjectRecord> onSelectProject;
@override
Widget build(BuildContext context) {
// Sort sessions by update time (newest first) within each project
final sortedSessionsByProject = <String, List<SessionSummary>>{};
sessionsByProject.forEach((workingDirectory, sessions) {
final sortedSessions = List<SessionSummary>.from(sessions)
..sort((a, b) => b.updated.compareTo(a.updated));
sortedSessionsByProject[workingDirectory] = sortedSessions;
});
return ListView(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
children: [
if (projectsProvider.projects.isEmpty)
const _SidebarHint(text: "No projects yet")
else
for (final project in projectsProvider.projects)
...[
// Project header
SizedBox(
width: double.infinity,
child: Button.ghost(
onPressed: () => onSelectProject(project),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
project.name,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
),
),
// Project sessions
if (sortedSessionsByProject[project.workingDirectory]?.isEmpty ?? true)
const Padding(
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
child: _SidebarHint(text: "No threads yet"),
)
else
for (final session in sortedSessionsByProject[project.workingDirectory]!)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
),
const Divider(height: 16),
],
// Handle sessions that don't belong to any current project
if (sortedSessionsByProject.keys.any((key) => !projectsProvider.projects.any((project) => project.workingDirectory == key)))
...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(
"Sessions Without Projects",
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
),
for (final entry in sortedSessionsByProject.entries)
if (!projectsProvider.projects.any((project) => project.workingDirectory == entry.key) && entry.key.isNotEmpty)
for (final session in entry.value)
_SidebarSessionTile(
session: session,
isSelected: sessionProvider.currentSessionId == session.id,
onTap: () => onOpenSession(session),
),
],
],
);
}
}
class _SidebarSessionTile extends StatelessWidget {
const _SidebarSessionTile({
required this.session,
required this.isSelected,
required this.onTap,
});
final SessionSummary session;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Button(
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
child: Text(
session.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).textSmall,
trailing: Text(
_formatRelativeTime(session.updated),
style: TextStyle(fontWeight: FontWeight.w400, fontSize: 13),
).muted.textSmall,
onPressed: () {
onTap();
},
),
);
}
}
class _EmptyChatState extends StatelessWidget {
const _EmptyChatState({required this.projectName, required this.hasProject});
final String? projectName;
final bool hasProject;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.messagesSquare, size: 28),
const Gap(16),
Text(
hasProject
? "Ready to chat about ${projectName ?? "this project"}"
: "Choose a project to begin",
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
hasProject
? "This chat will use the selected folder as its working directory."
: "The desktop app uses the picked folder instead of the shell launch directory.",
textAlign: TextAlign.center,
).textSmall.muted,
],
),
),
);
}
}
String _formatRelativeTime(DateTime timestamp) {
final difference = DateTime.now().toUtc().difference(timestamp.toUtc());
if (difference.inMinutes < 1) {
return "just now";
}
if (difference.inHours < 1) {
return "${difference.inMinutes}m";
}
if (difference.inDays < 1) {
return "${difference.inHours}h";
}
if (difference.inDays < 7) {
return "${difference.inDays}d";
}
final month = timestamp.month.toString().padLeft(2, "0");
final day = timestamp.day.toString().padLeft(2, "0");
return "${timestamp.year}-$month-$day";
}
+31
View File
@@ -0,0 +1,31 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class GarageHeader extends StatelessWidget {
const GarageHeader({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"THE AGENCY",
style: TextStyle(
fontSize: 32,
height: 1,
fontWeight: FontWeight.w900,
),
),
Text(
"by IMBENJI.NET LTD",
style: TextStyle(
fontSize: 12,
height: 1,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.mutedForeground,
),
),
],
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import "package:flutter/widgets.dart";
import "package:provider/provider.dart";
import "../providers/chat_provider.dart";
import "message_bubble.dart";
class ChatView extends StatefulWidget {
const ChatView();
@override
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_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,
);
}
});
}
@override
Widget build(BuildContext context) {
return Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
// scroll to bottom when new messages arrive
if (chatProvider.messages.isNotEmpty) {
_scrollToBottom();
}
return ListView.builder(
controller: _scrollController,
itemCount: chatProvider.messages.length,
itemBuilder: (context, index) {
final message = chatProvider.messages[index];
return MessageBubble(message: message);
},
);
},
);
}
}
+33
View File
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,82 @@
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"),
),
],
),
);
},
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import "package:flutter/material.dart" as material hide Card;
import "package:flutter_markdown/flutter_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../src/session/session_types.dart";
class MessageBubble extends StatelessWidget {
const MessageBubble({required this.message});
final Message message;
@override
Widget build(BuildContext context) {
final isUser = message.role == "user";
final isTool = message.role == "tool";
final isAssistant = message.role == "assistant";
final accentColor = isTool
? const Color(0xFF64748B)
: const Color(0xFF94A3B8);
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,
),
),
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),
],
),
),
),
),
);
}
String _buildToolMarkdown(String content) {
final lines = content.split("\n");
if (lines.isEmpty) {
return "```text\n\n```";
}
final title = lines.first.trim();
final body = lines.skip(1).join("\n").trimRight();
if (body.isEmpty) {
return title;
}
return "$title\n\n```text\n$body\n```";
}
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,
),
);
}
}
+145
View File
@@ -0,0 +1,145 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../../src/api/openrouter_client.dart";
import "../providers/settings_provider.dart";
class ModelPicker extends StatefulWidget {
const ModelPicker();
@override
State<ModelPicker> createState() => _ModelPickerState();
}
class _ModelPickerState extends State<ModelPicker> {
late Future<List<Map<String, dynamic>>> _modelsFuture;
@override
void initState() {
super.initState();
_modelsFuture = _loadModels();
}
Future<List<Map<String, dynamic>>> _loadModels() async {
try {
final apiKey = context.read<SettingsProvider>().settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) {
return [
{"id": "openai/gpt-4o", "name": "GPT-4o"},
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6"},
{"id": "google/gemini-2.0-flash-001", "name": "Gemini 2.0 Flash"},
];
}
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
final models = await client.listModels();
client.close();
return models;
} catch (e) {
return [
{"id": "openai/gpt-4o", "name": "GPT-4o"},
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6"},
{"id": "google/gemini-2.0-flash-001", "name": "Gemini 2.0 Flash"},
];
}
}
@override
Widget build(BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, _) {
final currentModel = settingsProvider.normalizeModelId(
settingsProvider.settings.model,
);
return FutureBuilder<List<Map<String, dynamic>>>(
future: _modelsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Text("Loading models...");
}
final models = snapshot.data ?? [];
final selectedModel = models.firstWhere(
(m) => m["id"] == currentModel,
orElse: () => {"id": currentModel, "name": currentModel},
);
return GestureDetector(
onTap: () => _showModelMenu(
context,
models,
currentModel,
settingsProvider,
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)),
borderRadius: BorderRadius.circular(6),
),
child: Text(selectedModel["name"] as String? ?? currentModel),
),
);
},
);
},
);
}
void _showModelMenu(
BuildContext context,
List<Map<String, dynamic>> models,
String currentModel,
SettingsProvider settingsProvider,
) {
showDialog(
context: context,
builder: (ctx) => Dialog(
child: Container(
constraints: const BoxConstraints(maxHeight: 400),
child: SingleChildScrollView(
child: Column(
children: models.map((model) {
final modelId = model["id"] as String;
final modelName = model["name"] as String? ?? modelId;
final isSelected = modelId == currentModel;
return Container(
color: isSelected
? const Color(0xFFF1F5F9)
: Colors.transparent,
child: GestureDetector(
onTap: () {
settingsProvider.updateModel(modelId);
Navigator.pop(ctx);
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
if (isSelected)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Text(
"",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(child: Text(modelName)),
],
),
),
),
);
}).toList(),
),
),
),
),
);
}
}
+220
View File
@@ -0,0 +1,220 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../providers/settings_provider.dart";
import "model_picker.dart";
class SettingsSheet extends StatelessWidget {
const SettingsSheet();
@override
Widget build(BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, _) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Settings",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
// model picker
const Text(
"Model",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
const ModelPicker(),
const SizedBox(height: 16),
// API key setting
const Text(
"OpenRouter API Key",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
_ApiKeyInput(settingsProvider: settingsProvider),
const SizedBox(height: 16),
// theme setting
const Text(
"Theme",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
_SimpleDropdown<String>(
value: settingsProvider.settings.theme,
items: const ["dark", "light"],
onChanged: (newTheme) {
settingsProvider.updateTheme(newTheme);
},
),
const SizedBox(height: 16),
// effort level setting
const Text(
"Effort Level",
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
_SimpleDropdown<String>(
value: settingsProvider.settings.effortLevel ?? "medium",
items: const ["low", "medium", "high", "max"],
onChanged: (newLevel) {
settingsProvider.updateEffortLevel(newLevel);
},
),
const SizedBox(height: 24),
// reset button
Button.ghost(
onPressed: () {
settingsProvider.resetToDefaults();
Navigator.pop(context);
},
child: const Text("Reset to Defaults"),
),
],
),
);
},
);
}
}
class _ApiKeyInput extends StatefulWidget {
final SettingsProvider settingsProvider;
const _ApiKeyInput({required this.settingsProvider});
@override
State<_ApiKeyInput> createState() => _ApiKeyInputState();
}
class _ApiKeyInputState extends State<_ApiKeyInput> {
late TextEditingController _controller;
bool _obscureText = true;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: widget.settingsProvider.settings.openRouterApiKey ?? "",
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: TextField(
controller: _controller,
obscureText: _obscureText,
onChanged: (value) {
widget.settingsProvider.updateApiKey(value);
},
placeholder: const Text("sk-or-v1-..."),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
setState(() {
_obscureText = !_obscureText;
});
},
child: Text(
_obscureText ? "Show" : "Hide",
style: const TextStyle(fontSize: 12, color: Color(0xFF999999)),
),
),
],
);
}
}
class _SimpleDropdown<T> extends StatelessWidget {
final T value;
final List<T> items;
final Function(T) onChanged;
const _SimpleDropdown({
required this.value,
required this.items,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => _showMenu(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(value.toString()),
const Text("", style: TextStyle(fontSize: 12)),
],
),
),
);
}
void _showMenu(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: items
.map((item) {
final isSelected = item == value;
return Container(
color: isSelected ? const Color(0xFFF1F5F9) : Colors.transparent,
child: GestureDetector(
onTap: () {
onChanged(item);
Navigator.pop(ctx);
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Text("", style: TextStyle(fontWeight: FontWeight.bold)),
),
Text(item.toString()),
],
),
),
),
);
})
.toList(),
),
),
);
}
}
+97
View File
@@ -0,0 +1,97 @@
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"),
),
),
],
);
},
);
}
}