Add initial project files and configurations for clawd_code
This commit is contained in:
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
// ),
|
||||
];
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user