A desktop app that parses Claude Code .jsonl session logs and provides a rich UI for exploring conversations, tool usage, subagents, and token consumption. Features include project browser with auto-discovery of ~/.claude/projects, conversation timeline with inline subagent expansion, agents overview, toolbelt chart, and token usage dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
871 lines
29 KiB
Dart
871 lines
29 KiB
Dart
import 'dart:io';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../providers/session_provider.dart';
|
|
import '../../theme/app_theme.dart';
|
|
|
|
// ─── Data models ─────────────────────────────────────────────
|
|
|
|
class _Project {
|
|
final String dirPath;
|
|
final String rawDirName;
|
|
final String displayName;
|
|
final String fullPath; // the actual filesystem path it maps to
|
|
final List<_SessionFile> sessions;
|
|
final DateTime lastModified;
|
|
|
|
_Project({
|
|
required this.dirPath,
|
|
required this.rawDirName,
|
|
required this.displayName,
|
|
required this.fullPath,
|
|
required this.sessions,
|
|
required this.lastModified,
|
|
});
|
|
|
|
int get sessionCount => sessions.length;
|
|
}
|
|
|
|
class _SessionFile {
|
|
final File file;
|
|
final FileStat stat;
|
|
final String sessionId;
|
|
|
|
_SessionFile({
|
|
required this.file,
|
|
required this.stat,
|
|
required this.sessionId,
|
|
});
|
|
}
|
|
|
|
// ─── Color helpers for project icons ─────────────────────────
|
|
|
|
const _projectColors = [
|
|
Color(0xFF3B82F6),
|
|
Color(0xFF10B981),
|
|
Color(0xFFA855F7),
|
|
Color(0xFFF59E0B),
|
|
Color(0xFFEF4444),
|
|
Color(0xFF06B6D4),
|
|
Color(0xFFF97316),
|
|
Color(0xFF8B5CF6),
|
|
Color(0xFFEC4899),
|
|
Color(0xFF14B8A6),
|
|
];
|
|
|
|
Color _colorForProject(String name) {
|
|
final hash = name.codeUnits.fold<int>(0, (prev, c) => prev + c);
|
|
return _projectColors[hash % _projectColors.length];
|
|
}
|
|
|
|
const _projectIcons = [
|
|
Icons.folder_outlined,
|
|
Icons.code,
|
|
Icons.terminal,
|
|
Icons.cloud_outlined,
|
|
Icons.devices_outlined,
|
|
Icons.science_outlined,
|
|
Icons.hub_outlined,
|
|
Icons.inventory_2_outlined,
|
|
Icons.web_outlined,
|
|
Icons.phone_iphone_outlined,
|
|
];
|
|
|
|
IconData _iconForProject(String name) {
|
|
final lower = name.toLowerCase();
|
|
if (lower.contains('flutter') || lower.contains('app')) return Icons.phone_iphone_outlined;
|
|
if (lower.contains('api') || lower.contains('backend')) return Icons.cloud_outlined;
|
|
if (lower.contains('docker') || lower.contains('helm') || lower.contains('kubernetes') || lower.contains('talos')) return Icons.dns_outlined;
|
|
if (lower.contains('web') || lower.contains('frontend')) return Icons.web_outlined;
|
|
if (lower.contains('agent') || lower.contains('claude') || lower.contains('auto')) return Icons.smart_toy_outlined;
|
|
if (lower.contains('dashboard') || lower.contains('cortex')) return Icons.dashboard_outlined;
|
|
if (lower.contains('doc')) return Icons.description_outlined;
|
|
final hash = name.codeUnits.fold<int>(0, (prev, c) => prev + c);
|
|
return _projectIcons[hash % _projectIcons.length];
|
|
}
|
|
|
|
// ─── Main widget ─────────────────────────────────────────────
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
final VoidCallback onSessionLoaded;
|
|
const HomeScreen({super.key, required this.onSessionLoaded});
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
List<_Project> _projects = [];
|
|
bool _scanning = true;
|
|
String _searchQuery = '';
|
|
_Project? _selectedProject;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scanProjects();
|
|
}
|
|
|
|
/// Converts the encoded dir name to a human-readable project name.
|
|
/// "-Users-mathias-Documents-workspaces-svrnty-talos-rpi5" → "svrnty / talos-rpi5"
|
|
/// "-Users-mathias" → "~ (home)"
|
|
/// "-Applications-Auto-Claude-app-Contents-Resources-backend" → "Auto-Claude / backend"
|
|
String _parsePrettyName(String dirName) {
|
|
// Reconstruct the original path: leading - is /, inner - are /
|
|
// But careful: "a-gent-maf-debug" is a single folder name with dashes.
|
|
// The trick: the encoded path uses - as separator for EVERY path component.
|
|
// We know the common prefixes so we can strip them.
|
|
String path = dirName;
|
|
|
|
// Strip known prefixes to get to the interesting part
|
|
final prefixes = [
|
|
'-Users-mathias-Documents-workspaces-',
|
|
'-Users-mathias-Documents-',
|
|
'-Users-mathias-',
|
|
'-Applications-',
|
|
];
|
|
|
|
String prefix = '';
|
|
for (final p in prefixes) {
|
|
if (path.startsWith(p)) {
|
|
prefix = p;
|
|
path = path.substring(p.length);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (path.isEmpty) {
|
|
if (dirName == '-Users-mathias') return '~ (home)';
|
|
return dirName;
|
|
}
|
|
|
|
// Now `path` is something like "svrnty-talos-rpi5--out-fondation"
|
|
// Double dashes (--) were actual dashes in folder names? No — they represent
|
|
// a subfolder that itself has a dash. We need to figure out the actual
|
|
// filesystem path. Let's just check if the reconstructed path exists.
|
|
final home = Platform.environment['HOME'] ?? '';
|
|
final reconstructed = _reconstructPath(dirName, home);
|
|
if (reconstructed != null) {
|
|
// Get last 2 meaningful segments
|
|
final segs = reconstructed.split('/').where((s) => s.isNotEmpty).toList();
|
|
// Remove common uninteresting segments
|
|
final skip = {'Users', 'mathias', 'Documents', 'workspaces', 'Applications', 'Contents', 'Resources'};
|
|
final meaningful = segs.where((s) => !skip.contains(s)).toList();
|
|
if (meaningful.length >= 2) {
|
|
return '${meaningful[meaningful.length - 2]} / ${meaningful.last}';
|
|
}
|
|
if (meaningful.isNotEmpty) return meaningful.last;
|
|
}
|
|
|
|
// Fallback: just clean up the raw name
|
|
return path.replaceAll('--', ' / ').replaceAll('-', ' ');
|
|
}
|
|
|
|
/// Try to reconstruct the actual filesystem path from the encoded dir name.
|
|
String? _reconstructPath(String dirName, String home) {
|
|
// The encoding is: replace / with - and prepend -
|
|
// So /Users/mathias/foo becomes -Users-mathias-foo
|
|
// Problem: folder names with dashes are ambiguous.
|
|
// Strategy: try splitting greedily from left, checking if dirs exist.
|
|
if (!dirName.startsWith('-')) return null;
|
|
final candidate = dirName.replaceFirst('-', '/');
|
|
|
|
// Try the obvious: replace all - with /
|
|
final simple = candidate.replaceAll('-', '/');
|
|
if (Directory(simple).existsSync()) return simple;
|
|
|
|
// Otherwise, do a greedy left-to-right directory walk
|
|
final parts = dirName.substring(1).split('-');
|
|
String current = '';
|
|
final resolved = <String>[];
|
|
|
|
for (int i = 0; i < parts.length; i++) {
|
|
if (current.isEmpty) {
|
|
current = parts[i];
|
|
} else {
|
|
current = '$current-${parts[i]}';
|
|
}
|
|
|
|
final testPath = '/${resolved.join('/')}/$current';
|
|
if (Directory(testPath).existsSync() || File(testPath).existsSync()) {
|
|
resolved.add(current);
|
|
current = '';
|
|
}
|
|
}
|
|
if (current.isNotEmpty) resolved.add(current);
|
|
|
|
final result = '/${resolved.join('/')}';
|
|
if (Directory(result).existsSync()) return result;
|
|
return result; // return anyway as best guess
|
|
}
|
|
|
|
Future<void> _scanProjects() async {
|
|
final home = Platform.environment['HOME'];
|
|
if (home == null) return;
|
|
final claudeDir = Directory('$home/.claude/projects');
|
|
if (!await claudeDir.exists()) {
|
|
setState(() => _scanning = false);
|
|
return;
|
|
}
|
|
|
|
final projects = <_Project>[];
|
|
|
|
await for (final projectDir in claudeDir.list()) {
|
|
if (projectDir is! Directory) continue;
|
|
final dirName = projectDir.path.split('/').last;
|
|
|
|
final sessions = <_SessionFile>[];
|
|
|
|
// Scan .jsonl files in the project dir
|
|
try {
|
|
await for (final entity in projectDir.list()) {
|
|
if (entity is File &&
|
|
entity.path.endsWith('.jsonl') &&
|
|
!entity.path.endsWith('sessions-index.json')) {
|
|
try {
|
|
final stat = entity.statSync();
|
|
final fileName = entity.path.split('/').last;
|
|
sessions.add(_SessionFile(
|
|
file: entity,
|
|
stat: stat,
|
|
sessionId: fileName.replaceAll('.jsonl', ''),
|
|
));
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
|
|
// Also scan sessions/ subdirectory
|
|
final sessionsDir = Directory('${projectDir.path}/sessions');
|
|
if (await sessionsDir.exists()) {
|
|
try {
|
|
await for (final entity in sessionsDir.list()) {
|
|
if (entity is File && entity.path.endsWith('.jsonl')) {
|
|
try {
|
|
final stat = entity.statSync();
|
|
final fileName = entity.path.split('/').last;
|
|
sessions.add(_SessionFile(
|
|
file: entity,
|
|
stat: stat,
|
|
sessionId: fileName.replaceAll('.jsonl', ''),
|
|
));
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (sessions.isEmpty) continue;
|
|
|
|
sessions.sort((a, b) => b.stat.modified.compareTo(a.stat.modified));
|
|
|
|
final fullPath = _reconstructPath(dirName, home) ?? dirName;
|
|
|
|
projects.add(_Project(
|
|
dirPath: projectDir.path,
|
|
rawDirName: dirName,
|
|
displayName: _parsePrettyName(dirName),
|
|
fullPath: fullPath,
|
|
sessions: sessions,
|
|
lastModified: sessions.first.stat.modified,
|
|
));
|
|
}
|
|
|
|
projects.sort((a, b) => b.lastModified.compareTo(a.lastModified));
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_projects = projects;
|
|
_scanning = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _pickFile() async {
|
|
final home = Platform.environment['HOME'] ?? '';
|
|
final result = await FilePicker.platform.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: ['jsonl'],
|
|
initialDirectory: '$home/.claude/projects',
|
|
);
|
|
if (result != null && result.files.single.path != null) {
|
|
await _loadFile(result.files.single.path!);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadFile(String path) async {
|
|
final provider = context.read<SessionProvider>();
|
|
await provider.loadSession(path);
|
|
if (provider.error == null && mounted) {
|
|
widget.onSessionLoaded();
|
|
}
|
|
}
|
|
|
|
List<_Project> get _filteredProjects {
|
|
if (_searchQuery.isEmpty) return _projects;
|
|
final q = _searchQuery.toLowerCase();
|
|
return _projects.where((p) =>
|
|
p.displayName.toLowerCase().contains(q) ||
|
|
p.fullPath.toLowerCase().contains(q)).toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final provider = context.watch<SessionProvider>();
|
|
|
|
return Scaffold(
|
|
backgroundColor: AppColors.background,
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header with breadcrumb
|
|
_buildHeader(provider),
|
|
const SizedBox(height: 16),
|
|
_buildSearchBar(),
|
|
const SizedBox(height: 16),
|
|
|
|
if (provider.isLoading)
|
|
const Padding(
|
|
padding: EdgeInsets.only(bottom: 12),
|
|
child: LinearProgressIndicator(
|
|
color: AppColors.assistant,
|
|
backgroundColor: AppColors.surfaceLight,
|
|
),
|
|
),
|
|
|
|
if (provider.error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.errorBg,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: AppColors.error.withAlpha(80)),
|
|
),
|
|
child: Text(
|
|
provider.error!,
|
|
style: const TextStyle(color: AppColors.error, fontSize: 13),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Content
|
|
if (_scanning)
|
|
const Expanded(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
CircularProgressIndicator(color: AppColors.assistant),
|
|
SizedBox(height: 12),
|
|
Text('Scanning projects...',
|
|
style: TextStyle(color: AppColors.textMuted, fontSize: 13)),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
else if (_selectedProject != null)
|
|
Expanded(child: _buildSessionList(_selectedProject!))
|
|
else
|
|
Expanded(child: _buildProjectGrid()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── Header with breadcrumb ──────────────────────────────
|
|
|
|
Widget _buildHeader(SessionProvider provider) {
|
|
return Row(
|
|
children: [
|
|
const Icon(Icons.terminal, size: 24, color: AppColors.assistant),
|
|
const SizedBox(width: 10),
|
|
// Breadcrumb
|
|
InkWell(
|
|
onTap: () => setState(() => _selectedProject = null),
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Text(
|
|
'Projects',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: _selectedProject != null
|
|
? AppColors.assistant
|
|
: AppColors.textPrimary,
|
|
decoration: _selectedProject != null
|
|
? TextDecoration.underline
|
|
: TextDecoration.none,
|
|
decorationColor: AppColors.assistant,
|
|
),
|
|
),
|
|
),
|
|
if (_selectedProject != null) ...[
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
|
child: Icon(Icons.chevron_right, size: 18, color: AppColors.textMuted),
|
|
),
|
|
Flexible(
|
|
child: Text(
|
|
_selectedProject!.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
const Spacer(),
|
|
Text(
|
|
_selectedProject != null
|
|
? '${_selectedProject!.sessionCount} session${_selectedProject!.sessionCount == 1 ? '' : 's'}'
|
|
: '${_projects.length} projects',
|
|
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
|
),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton.icon(
|
|
onPressed: _pickFile,
|
|
icon: const Icon(Icons.folder_open, size: 14),
|
|
label: const Text('Browse'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.surfaceLight,
|
|
foregroundColor: AppColors.textPrimary,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
side: const BorderSide(color: AppColors.surfaceBorder),
|
|
),
|
|
textStyle: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ─── Search bar ──────────────────────────────────────────
|
|
|
|
Widget _buildSearchBar() {
|
|
return TextField(
|
|
onChanged: (v) => setState(() => _searchQuery = v),
|
|
style: const TextStyle(fontSize: 13, color: AppColors.textPrimary),
|
|
decoration: InputDecoration(
|
|
hintText: _selectedProject != null
|
|
? 'Search sessions...'
|
|
: 'Search projects...',
|
|
hintStyle: const TextStyle(fontSize: 13, color: AppColors.textMuted),
|
|
prefixIcon: const Icon(Icons.search, size: 18, color: AppColors.textMuted),
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
|
filled: true,
|
|
fillColor: AppColors.surface,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: AppColors.surfaceBorder),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: AppColors.surfaceBorder),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: const BorderSide(color: AppColors.assistant),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── Project grid ────────────────────────────────────────
|
|
|
|
Widget _buildProjectGrid() {
|
|
final filtered = _filteredProjects;
|
|
if (filtered.isEmpty) {
|
|
return const Center(
|
|
child: Text('No projects found', style: TextStyle(color: AppColors.textMuted)),
|
|
);
|
|
}
|
|
|
|
return LayoutBuilder(builder: (context, constraints) {
|
|
final crossAxisCount = constraints.maxWidth > 900
|
|
? 4
|
|
: constraints.maxWidth > 600
|
|
? 3
|
|
: 2;
|
|
|
|
return GridView.builder(
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: crossAxisCount,
|
|
mainAxisSpacing: 12,
|
|
crossAxisSpacing: 12,
|
|
childAspectRatio: 1.6,
|
|
),
|
|
itemCount: filtered.length,
|
|
itemBuilder: (context, index) => _ProjectCard(
|
|
project: filtered[index],
|
|
onTap: () => setState(() {
|
|
_selectedProject = filtered[index];
|
|
_searchQuery = '';
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
// ─── Session list (inside a project) ─────────────────────
|
|
|
|
Widget _buildSessionList(_Project project) {
|
|
final sessions = _searchQuery.isEmpty
|
|
? project.sessions
|
|
: project.sessions.where((s) =>
|
|
s.sessionId.toLowerCase().contains(_searchQuery.toLowerCase())).toList();
|
|
|
|
if (sessions.isEmpty) {
|
|
return const Center(
|
|
child: Text('No matching sessions', style: TextStyle(color: AppColors.textMuted)),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Project info bar
|
|
Container(
|
|
padding: const EdgeInsets.all(14),
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: AppColors.surfaceBorder),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: _colorForProject(project.displayName).withAlpha(25),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
_iconForProject(project.displayName),
|
|
size: 18,
|
|
color: _colorForProject(project.displayName),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
project.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
Text(
|
|
project.fullPath,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppColors.textMuted,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Session list
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: sessions.length,
|
|
itemBuilder: (context, index) {
|
|
final session = sessions[index];
|
|
return _SessionTile(
|
|
session: session,
|
|
index: index,
|
|
isActive: context.read<SessionProvider>().filePath == session.file.path,
|
|
onTap: () => _loadFile(session.file.path),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ─── Formatting helpers ──────────────────────────────────
|
|
|
|
static String formatDate(DateTime date) {
|
|
final now = DateTime.now();
|
|
final diff = now.difference(date);
|
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
|
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
return '${months[date.month - 1]} ${date.day}';
|
|
}
|
|
|
|
static String formatDateTime(DateTime date) {
|
|
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
return '${months[date.month - 1]} ${date.day}, ${date.year} at '
|
|
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
static String formatSize(int bytes) {
|
|
final kb = bytes / 1024;
|
|
final mb = kb / 1024;
|
|
if (mb >= 1) return '${mb.toStringAsFixed(1)} MB';
|
|
return '${kb.toStringAsFixed(0)} KB';
|
|
}
|
|
}
|
|
|
|
// ─── Project card widget ───────────────────────────────────
|
|
|
|
class _ProjectCard extends StatefulWidget {
|
|
final _Project project;
|
|
final VoidCallback onTap;
|
|
|
|
const _ProjectCard({required this.project, required this.onTap});
|
|
|
|
@override
|
|
State<_ProjectCard> createState() => _ProjectCardState();
|
|
}
|
|
|
|
class _ProjectCardState extends State<_ProjectCard> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = _colorForProject(widget.project.displayName);
|
|
final icon = _iconForProject(widget.project.displayName);
|
|
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: GestureDetector(
|
|
onTap: widget.onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: _hovered ? AppColors.surfaceLight : AppColors.surface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: _hovered ? color.withAlpha(60) : AppColors.surfaceBorder,
|
|
width: _hovered ? 1.5 : 1,
|
|
),
|
|
boxShadow: _hovered
|
|
? [BoxShadow(color: color.withAlpha(15), blurRadius: 20, spreadRadius: 2)]
|
|
: null,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: color.withAlpha(25),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(icon, size: 18, color: color),
|
|
),
|
|
const Spacer(),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: color.withAlpha(15),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
'${widget.project.sessionCount}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: color,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
widget.project.displayName,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_HomeScreenState.formatDate(widget.project.lastModified),
|
|
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Session tile widget ───────────────────────────────────
|
|
|
|
class _SessionTile extends StatefulWidget {
|
|
final _SessionFile session;
|
|
final int index;
|
|
final bool isActive;
|
|
final VoidCallback onTap;
|
|
|
|
const _SessionTile({
|
|
required this.session,
|
|
required this.index,
|
|
required this.isActive,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
State<_SessionTile> createState() => _SessionTileState();
|
|
}
|
|
|
|
class _SessionTileState extends State<_SessionTile> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final dt = widget.session.stat.modified;
|
|
final isActive = widget.isActive;
|
|
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: GestureDetector(
|
|
onTap: widget.onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
margin: const EdgeInsets.only(bottom: 6),
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? AppColors.assistant.withAlpha(12)
|
|
: _hovered
|
|
? AppColors.surfaceLight
|
|
: AppColors.surface,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: isActive
|
|
? AppColors.assistant.withAlpha(50)
|
|
: _hovered
|
|
? AppColors.surfaceBorder.withAlpha(200)
|
|
: AppColors.surfaceBorder,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// Session number circle
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? AppColors.assistant.withAlpha(30)
|
|
: AppColors.surfaceLight,
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'${widget.index + 1}',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: isActive ? AppColors.assistant : AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
// Session info
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_HomeScreenState.formatDateTime(dt),
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
|
|
color: isActive ? AppColors.assistant : AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
widget.session.sessionId,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppColors.textMuted,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
// Size + relative time
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
_HomeScreenState.formatSize(widget.session.stat.size),
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.textSecondary,
|
|
fontFamily: 'JetBrains Mono',
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
_HomeScreenState.formatDate(dt),
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppColors.textMuted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
size: 18,
|
|
color: isActive
|
|
? AppColors.assistant
|
|
: _hovered
|
|
? AppColors.textSecondary
|
|
: Colors.transparent,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|