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(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(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 createState() => _HomeScreenState(); } class _HomeScreenState extends State { List<_Project> _projects = []; bool _scanning = true; String _searchQuery = ''; _Project? _selectedProject; _Project? _customFolderProject; final _pathController = TextEditingController(); final _pathFocusNode = FocusNode(); @override void initState() { super.initState(); final home = _getRealHome() ?? ''; _pathController.text = '$home/.claude/projects'; _scanProjects(); } @override void dispose() { _pathController.dispose(); _pathFocusNode.dispose(); super.dispose(); } /// Returns the real user home directory, even inside App Sandbox. /// In sandbox, HOME points to ~/Library/Containers//Data. String? _getRealHome() { final home = Platform.environment['HOME']; if (home == null) return null; final match = RegExp(r'^(/Users/[^/]+)/Library/Containers/').firstMatch(home); if (match != null) return match.group(1); return home; } /// Converts the encoded dir name to a human-readable project name. /// "-Users-mathias-Documents-workspaces-svrnty-talos-rpi5" → "svrnty / talos-rpi5" /// "-Users-mathias" → "~ (home)" String _parsePrettyName(String dirName) { final home = _getRealHome() ?? ''; final username = home.split('/').last; // Try to reconstruct the actual filesystem path final reconstructed = _reconstructPath(dirName, home); if (reconstructed != null) { final segs = reconstructed.split('/').where((s) => s.isNotEmpty).toList(); // Remove common uninteresting segments final skip = {'Users', username, 'Documents', 'workspaces', 'Workspaces', 'Applications', 'Contents', 'Resources', 'Volumes'}; final meaningful = segs.where((s) => !skip.contains(s)).toList(); if (meaningful.isEmpty) return '~ (home)'; if (meaningful.length >= 2) { return '${meaningful[meaningful.length - 2]} / ${meaningful.last}'; } return meaningful.last; } // Fallback: build dynamic prefixes from the detected username final prefixes = [ '-Users-$username-Documents-workspaces-', '-Users-$username-Documents-', '-Users-$username-Workspaces-', '-Users-$username-', '-Volumes-Workspaces-', '-Volumes-', '-Applications-', ]; String path = dirName; for (final p in prefixes) { if (path.startsWith(p)) { path = path.substring(p.length); break; } } if (path.isEmpty) return '~ (home)'; 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 = []; 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 } /// Locates the Claude Code data directory. /// Claude Code always stores data at ~/.claude/ regardless of where /// the binary is installed (npm global, homebrew, etc). Future _findClaudeDataDir() async { final home = _getRealHome(); debugPrint('[Claude] HOME env: ${Platform.environment['HOME']}'); debugPrint('[Claude] Real home resolved to: $home'); if (home == null) { debugPrint('[Claude] ERROR: Could not determine home directory'); return null; } final claudeDir = Directory('$home/.claude'); final exists = await claudeDir.exists(); debugPrint('[Claude] Checking ${claudeDir.path} -> exists: $exists'); if (exists) return claudeDir; return null; } Future _scanProjects() async { final claudeData = await _findClaudeDataDir(); if (claudeData == null) { debugPrint('[Claude] No Claude Code data directory found'); setState(() => _scanning = false); return; } final projectsDir = Directory('${claudeData.path}/projects'); final projExists = await projectsDir.exists(); debugPrint('[Claude] Projects dir: ${projectsDir.path} -> exists: $projExists'); if (!projExists) { setState(() => _scanning = false); return; } final home = _getRealHome() ?? ''; final projects = <_Project>[]; await for (final projectDir in projectsDir.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')) { 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; }); } } /// Resolve a path string, expanding ~ to the home directory. String _resolvePath(String input) { final home = _getRealHome() ?? Platform.environment['HOME'] ?? ''; var path = input.trim(); if (path.startsWith('~/')) { path = '$home${path.substring(1)}'; } else if (path == '~') { path = home; } return path; } Future _scanFromPathInput() async { final resolved = _resolvePath(_pathController.text); final dir = Directory(resolved); if (await dir.exists()) { await _scanFolder(resolved); } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Folder not found: $resolved'), backgroundColor: AppColors.error, ), ); } } } Future _pickFolder() async { try { final result = await FilePicker.platform.getDirectoryPath( dialogTitle: 'Select a folder containing Claude session files', ); if (result != null) { _pathController.text = result; await _scanFolder(result); } } catch (e) { debugPrint('[Browse] Error: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Could not open folder picker: $e'), backgroundColor: AppColors.error, ), ); } } } Future _scanFolder(String folderPath) async { setState(() { _scanning = true; _selectedProject = null; }); final sessions = <_SessionFile>[]; // Recursively scan for .jsonl files final dir = Directory(folderPath); if (await dir.exists()) { await _scanFolderRecursive(dir, sessions); } sessions.sort((a, b) => b.stat.modified.compareTo(a.stat.modified)); if (mounted) { setState(() { _customFolderProject = _Project( dirPath: folderPath, rawDirName: folderPath.split('/').last, displayName: folderPath.split('/').last, fullPath: folderPath, sessions: sessions, lastModified: sessions.isNotEmpty ? sessions.first.stat.modified : DateTime.now(), ); _selectedProject = _customFolderProject; _scanning = false; }); } } Future _scanFolderRecursive(Directory dir, List<_SessionFile> sessions) async { try { await for (final entity in dir.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 (_) {} } else if (entity is Directory) { await _scanFolderRecursive(entity, sessions); } } } catch (_) {} } Future _loadSingleFile() async { try { final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['jsonl'], dialogTitle: 'Select a Claude session file (.jsonl)', ); if (result != null && result.files.single.path != null) { await _loadFile(result.files.single.path!); } } catch (e) { debugPrint('[Load] Error: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Could not open file picker: $e'), backgroundColor: AppColors.error, ), ); } } } Future _loadFile(String path) async { final provider = context.read(); 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(); 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 Column( children: [ // Row 1: breadcrumb + count Row( children: [ const Icon(Icons.terminal, size: 24, color: AppColors.assistant), const SizedBox(width: 10), 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(height: 12), // Row 2: path input + Load + Browse Row( children: [ // Path input field — flexible to fill available space Expanded( child: SizedBox( height: 36, child: TextField( controller: _pathController, focusNode: _pathFocusNode, style: const TextStyle( fontSize: 12, color: AppColors.textPrimary, fontFamily: 'JetBrains Mono', ), decoration: InputDecoration( hintText: 'Path to scan (e.g. ~/.claude/projects)', hintStyle: const TextStyle( fontSize: 12, color: AppColors.textMuted, fontFamily: 'JetBrains Mono', ), isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, ), filled: true, fillColor: AppColors.surface, border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: const BorderSide(color: AppColors.surfaceBorder), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: const BorderSide(color: AppColors.surfaceBorder), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: const BorderSide(color: AppColors.assistant), ), suffixIcon: IconButton( icon: const Icon(Icons.arrow_forward, size: 14, color: AppColors.textMuted), tooltip: 'Scan this folder', onPressed: _scanFromPathInput, ), ), onSubmitted: (_) => _scanFromPathInput(), ), ), ), const SizedBox(width: 8), SizedBox( height: 36, child: ElevatedButton.icon( onPressed: _loadSingleFile, icon: const Icon(Icons.insert_drive_file_outlined, size: 14), label: const Text('Load'), style: ElevatedButton.styleFrom( backgroundColor: AppColors.surfaceLight, foregroundColor: AppColors.textPrimary, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 0), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), side: const BorderSide(color: AppColors.surfaceBorder), ), textStyle: const TextStyle(fontSize: 12), ), ), ), const SizedBox(width: 8), SizedBox( height: 36, child: ElevatedButton.icon( onPressed: _pickFolder, 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: 0), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), side: const BorderSide(color: AppColors.surfaceBorder), ), textStyle: const TextStyle(fontSize: 12), ), ), ), const SizedBox(width: 8), Tooltip( message: 'Press Cmd+Shift+. in dialogs to show hidden folders', child: Icon(Icons.info_outline, size: 14, color: AppColors.textMuted), ), ], ), ], ); } // ─── 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().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, ), ], ), ), ), ); } }