diff --git a/apps/flutter/lib/main.dart b/apps/flutter/lib/main.dart index af9e77b..86d663e 100644 --- a/apps/flutter/lib/main.dart +++ b/apps/flutter/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -53,10 +54,47 @@ class BevyTasksApp extends StatelessWidget { } } -class AppShell extends StatelessWidget { +class AppShell extends StatefulWidget { const AppShell({super.key}); + @override + State createState() => _AppShellState(); +} + +class _AppShellState extends State with SingleTickerProviderStateMixin { static const _edge = 8.0; + late final AnimationController _settingsAnim; + late final Animation _settingsFade; + late final Animation _settingsScale; + bool _settingsVisible = false; + String? _prevScreen; + + @override + void initState() { + super.initState(); + _settingsAnim = AnimationController(vsync: this, duration: const Duration(milliseconds: 150)); + _settingsFade = CurvedAnimation(parent: _settingsAnim, curve: Curves.easeOut); + _settingsScale = Tween(begin: 0.95, end: 1.0) + .animate(CurvedAnimation(parent: _settingsAnim, curve: Curves.easeOut)); + } + + @override + void dispose() { + _settingsAnim.dispose(); + super.dispose(); + } + + void _onScreenChanged(String screen) { + if (screen == 'settings' && _prevScreen != 'settings') { + _settingsVisible = true; + _settingsAnim.forward(); + } else if (screen != 'settings' && _prevScreen == 'settings') { + _settingsAnim.reverse().then((_) { + if (mounted) setState(() => _settingsVisible = false); + }); + } + _prevScreen = screen; + } SystemMouseCursor _cursorFor(ResizeEdge? edge) => switch (edge) { ResizeEdge.top || ResizeEdge.bottom => SystemMouseCursors.resizeUpDown, @@ -70,6 +108,57 @@ class AppShell extends StatelessWidget { Widget build(BuildContext context) { final state = context.watch(); final isDark = Theme.of(context).brightness == Brightness.dark; + final hasNativeBorder = Platform.isWindows; + + _onScreenChanged(state.screen); + + Widget content = Stack( + children: [ + if (state.screen == 'setup') + const SetupScreen() + else + const TasksScreen(), + if (state.error != null) + Positioned( + top: 0, left: 0, right: 0, + child: Material( + color: AppTheme.danger, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text(state.error!, style: const TextStyle(color: Colors.white, fontSize: 13)), + ), + GestureDetector( + onTap: state.clearError, + child: const Text('✕', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ), + if (_settingsVisible) + FadeTransition( + opacity: _settingsFade, + child: ScaleTransition( + scale: _settingsScale, + child: const SettingsScreen(), + ), + ), + ], + ); + + if (hasNativeBorder) { + // Windows provides native border + shadow, just fill with surface color + return Scaffold( + backgroundColor: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + body: ClipRect(child: content), + ); + } + + // Linux/macOS: custom border, shadow, and resize zones return Scaffold( backgroundColor: Colors.transparent, body: LayoutBuilder(builder: (context, constraints) { @@ -78,14 +167,10 @@ class AppShell extends StatelessWidget { hitTestBehavior: HitTestBehavior.translucent, child: Listener( behavior: HitTestBehavior.translucent, - onPointerHover: (event) { - // Update cursor based on edge proximity (handled by nested MouseRegion below) - }, + onPointerHover: (event) {}, child: Stack( children: [ - // Resize hit zones (in the 8px padding area) ..._buildResizeZones(constraints), - // Main content with padding Padding( padding: const EdgeInsets.all(_edge), child: Container( @@ -101,37 +186,7 @@ class AppShell extends StatelessWidget { ], ), clipBehavior: Clip.antiAlias, - child: Stack( - children: [ - if (state.screen == 'setup') - const SetupScreen() - else - const TasksScreen(), - if (state.error != null) - Positioned( - top: 0, left: 0, right: 0, - child: Material( - color: AppTheme.danger, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Expanded( - child: Text(state.error!, style: const TextStyle(color: Colors.white, fontSize: 13)), - ), - GestureDetector( - onTap: state.clearError, - child: const Text('✕', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), - ), - ], - ), - ), - ), - ), - if (state.screen == 'settings') - const SettingsScreen(), - ], - ), + child: content, ), ), ], diff --git a/apps/flutter/lib/src/screens/settings_screen.dart b/apps/flutter/lib/src/screens/settings_screen.dart index 20572c5..59ec686 100644 --- a/apps/flutter/lib/src/screens/settings_screen.dart +++ b/apps/flutter/lib/src/screens/settings_screen.dart @@ -22,7 +22,7 @@ class SettingsScreen extends StatelessWidget { onTap: () {}, child: AnimatedScale( scale: 1.0, - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 150), curve: Curves.easeOut, child: Container( decoration: BoxDecoration( @@ -112,7 +112,7 @@ class SettingsScreen extends StatelessWidget { const Spacer(), // Toggle switch (matching Tauri: h-6 w-11) AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 150), width: 44, height: 24, decoration: BoxDecoration( @@ -120,7 +120,7 @@ class SettingsScreen extends StatelessWidget { color: state.darkMode ? AppTheme.primary : (isDark ? const Color(0xFF4B5563) : const Color(0xFFD1D5DB)), ), child: AnimatedAlign( - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 150), alignment: state.darkMode ? Alignment.centerRight : Alignment.centerLeft, child: Container( width: 20, diff --git a/apps/flutter/lib/src/screens/tasks_screen.dart b/apps/flutter/lib/src/screens/tasks_screen.dart index 56dbe43..b926746 100644 --- a/apps/flutter/lib/src/screens/tasks_screen.dart +++ b/apps/flutter/lib/src/screens/tasks_screen.dart @@ -15,7 +15,7 @@ class TasksScreen extends StatefulWidget { State createState() => _TasksScreenState(); } -class _TasksScreenState extends State { +class _TasksScreenState extends State with SingleTickerProviderStateMixin { bool _drawerOpen = false; bool _showCompleted = false; bool _completedVisible = false; @@ -24,9 +24,22 @@ class _TasksScreenState extends State { bool _newTaskOpen = false; final _newListController = TextEditingController(); final _newListFocus = FocusNode(); + late final AnimationController _newTaskAnim; + late final Animation _newTaskSlide; + late final Animation _newTaskFade; + + @override + void initState() { + super.initState(); + _newTaskAnim = AnimationController(vsync: this, duration: const Duration(milliseconds: 150)); + _newTaskSlide = Tween(begin: const Offset(0, 1), end: Offset.zero) + .animate(CurvedAnimation(parent: _newTaskAnim, curve: Curves.easeOut)); + _newTaskFade = CurvedAnimation(parent: _newTaskAnim, curve: Curves.easeOut); + } @override void dispose() { + _newTaskAnim.dispose(); _newListController.dispose(); _newListFocus.dispose(); super.dispose(); @@ -47,10 +60,13 @@ class _TasksScreenState extends State { final state = context.read(); if (state.activeListId == null) return; setState(() => _newTaskOpen = true); + _newTaskAnim.forward(); } void _closeNewTask() { - setState(() => _newTaskOpen = false); + _newTaskAnim.reverse().then((_) { + if (mounted) setState(() => _newTaskOpen = false); + }); } Future _handleCreateTask(String title, String desc, {String? dueDate}) async { @@ -94,21 +110,29 @@ class _TasksScreenState extends State { clipBehavior: Clip.hardEdge, children: [ // Sliding container: drawer + main + detail - AnimatedPositioned( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - left: _drawerOpen ? 0.0 : -drawerWidth, - top: 0, - bottom: 0, - width: drawerWidth + width, - child: Row( - children: [ - SizedBox(width: drawerWidth, child: _buildDrawer(state, isDark)), - SizedBox( - width: width, - child: _buildMainWithDetail(state, isDark, width), + Positioned.fill( + child: ClipRect( + child: OverflowBox( + maxWidth: drawerWidth + width, + alignment: Alignment.centerLeft, + child: AnimatedSlide( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + offset: _drawerOpen ? Offset.zero : Offset(-drawerWidth / (drawerWidth + width), 0), + child: SizedBox( + width: drawerWidth + width, + child: Row( + children: [ + SizedBox(width: drawerWidth, child: _buildDrawer(state, isDark)), + SizedBox( + width: width, + child: _buildMainWithDetail(state, isDark, width), + ), + ], + ), + ), ), - ], + ), ), ), // FAB button (centered, 56px, hidden when drawer/detail/newTask open) @@ -133,13 +157,10 @@ class _TasksScreenState extends State { ), ), // New task overlay (animated, inside app bounds) - Positioned.fill( - child: IgnorePointer( - ignoring: !_newTaskOpen, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - opacity: _newTaskOpen ? 1.0 : 0.0, + if (_newTaskOpen || _newTaskAnim.isAnimating) + Positioned.fill( + child: FadeTransition( + opacity: _newTaskFade, child: GestureDetector( onTap: _closeNewTask, child: Container( @@ -147,15 +168,15 @@ class _TasksScreenState extends State { alignment: Alignment.bottomCenter, child: GestureDetector( onTap: () {}, - child: _newTaskOpen - ? NewTaskInput(onCreate: _handleCreateTask) - : const SizedBox.shrink(), + child: SlideTransition( + position: _newTaskSlide, + child: NewTaskInput(onCreate: _handleCreateTask), + ), ), ), ), ), ), - ), ], ); }); @@ -170,49 +191,67 @@ class _TasksScreenState extends State { child: OverflowBox( maxWidth: totalWidth * 2, alignment: Alignment.centerLeft, - child: AnimatedContainer( - duration: const Duration(milliseconds: 250), + child: AnimatedSlide( + duration: const Duration(milliseconds: 150), curve: Curves.easeOut, - transform: Matrix4.translationValues(hasDetail ? -totalWidth : 0, 0, 0), - width: totalWidth * 2, - child: Row( - children: [ - SizedBox(width: totalWidth, child: _buildMain(state, isDark)), - SizedBox( - width: totalWidth, - child: Container( - color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, - child: hasDetail - ? TaskDetailView(task: state.selectedTask!) - : const SizedBox.shrink(), + offset: hasDetail ? const Offset(-0.5, 0) : Offset.zero, + child: SizedBox( + width: totalWidth * 2, + child: Row( + children: [ + SizedBox(width: totalWidth, child: _buildMain(state, isDark)), + SizedBox( + width: totalWidth, + child: Container( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + child: hasDetail + ? TaskDetailView(task: state.selectedTask!) + : const SizedBox.shrink(), + ), ), - ), - ], + ], + ), ), ), ), ), - // Dim overlay when drawer is open (animated fade) + // Drawer shadow (narrow element at left edge casting right) + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 1, + child: IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + opacity: _drawerOpen ? 1.0 : 0.0, + child: Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(4, 0), + ), + ], + ), + ), + ), + ), + ), + // Dim overlay when drawer is open Positioned.fill( child: IgnorePointer( ignoring: !_drawerOpen, child: GestureDetector( onTap: _closeDrawer, child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), + duration: const Duration(milliseconds: 150), curve: Curves.easeOut, opacity: _drawerOpen ? 1.0 : 0.0, child: Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.4), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 24, - offset: const Offset(8, 0), - ), - ], - ), + color: Colors.black.withValues(alpha: 0.4), ), ), ), @@ -225,137 +264,59 @@ class _TasksScreenState extends State { Widget _buildDrawer(AppState state, bool isDark) { return Container( color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, - child: Column( + child: Stack( children: [ - // Header: workspace switcher (matching Tauri) - GestureDetector( - onPanStart: (_) {}, - child: Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5), - ), - ), - child: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => setState(() => _workspaceSwitcherOpen = !_workspaceSwitcherOpen), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - state.config?.currentWorkspace ?? 'Workspace', - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 6), - AnimatedRotation( - turns: _workspaceSwitcherOpen ? 0.5 : 0, - duration: const Duration(milliseconds: 200), - child: Icon(Icons.expand_more, size: 14, - color: isDark ? AppTheme.textDark : AppTheme.textLight), - ), - ], - ), - ), + Column( + children: [ + // Header: workspace switcher + GestureDetector( + onPanStart: (_) {}, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5), ), ), - ], - ), - ), - ), - // Workspace dropdown (appears below header) - if (_workspaceSwitcherOpen && state.config != null) - Container( - constraints: const BoxConstraints(maxHeight: 200), - decoration: BoxDecoration( - color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, - border: Border( - bottom: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5), - ), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), - ], - ), - child: ListView( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 4), - children: [ - for (final ws in state.config!.workspaces) - GestureDetector( - onTap: () { - state.switchWorkspace(ws.name); - setState(() => _workspaceSwitcherOpen = false); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - ), - child: Row( - children: [ - if (ws.name == state.config?.currentWorkspace) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Icon(Icons.check, size: 16, - color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => setState(() => _workspaceSwitcherOpen = !_workspaceSwitcherOpen), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + state.config?.currentWorkspace ?? 'Workspace', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(ws.name, - style: TextStyle(fontSize: 14, - fontWeight: ws.name == state.config?.currentWorkspace ? FontWeight.w700 : FontWeight.normal), - overflow: TextOverflow.ellipsis), - Text(ws.path, - style: TextStyle(fontSize: 12, - color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), - overflow: TextOverflow.ellipsis), - ], + const SizedBox(width: 6), + AnimatedRotation( + turns: _workspaceSwitcherOpen ? 0.5 : 0, + duration: const Duration(milliseconds: 150), + child: Icon(Icons.expand_more, size: 14, + color: isDark ? AppTheme.textDark : AppTheme.textLight), ), - ), - ], + ], + ), ), ), ), - ), - // Add workspace - Container( - margin: const EdgeInsets.only(top: 4), - decoration: BoxDecoration( - border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), - ), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: GestureDetector( - onTap: () { - setState(() => _workspaceSwitcherOpen = false); - state.setScreen('setup'); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Text('+ Add workspace', - style: TextStyle(fontSize: 14, color: AppTheme.primary)), - ), - ), + ], ), - ], + ), ), - ), - // List items - Expanded( + // List items + Expanded( child: ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: [ @@ -428,22 +389,95 @@ class _TasksScreenState extends State { ], ), ), - // Footer: Settings button (matching Tauri) - GestureDetector( - onTap: () => state.setScreen('settings'), - child: Container( - decoration: BoxDecoration( - border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + // Footer: Settings button (matching Tauri) + GestureDetector( + onTap: () => state.setScreen('settings'), + child: Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Icon(Icons.settings, size: 18, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), + const SizedBox(width: 8), + Text('Settings', style: TextStyle(fontSize: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5))), + ], + ), ), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - Icon(Icons.settings, size: 18, - color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), - const SizedBox(width: 8), - Text('Settings', style: TextStyle(fontSize: 14, - color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5))), - ], + ), + ], + ), + // Workspace switcher popup backdrop + if (_workspaceSwitcherOpen) + Positioned.fill( + child: GestureDetector( + onTap: () => setState(() => _workspaceSwitcherOpen = false), + behavior: HitTestBehavior.opaque, + child: const SizedBox.expand(), + ), + ), + // Workspace switcher popup menu + Positioned( + left: 8, + right: 8, + top: 48, + child: AnimatedScale( + scale: _workspaceSwitcherOpen ? 1.0 : 0.9, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + alignment: Alignment.topLeft, + child: AnimatedOpacity( + opacity: _workspaceSwitcherOpen ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: IgnorePointer( + ignoring: !_workspaceSwitcherOpen, + child: Container( + constraints: const BoxConstraints(maxHeight: 200), + decoration: BoxDecoration( + color: isDark ? AppTheme.cardDark : AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.2), blurRadius: 12, offset: const Offset(0, 4)), + ], + ), + clipBehavior: Clip.antiAlias, + child: state.config != null + ? ListView( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: 4), + children: [ + for (final ws in state.config!.workspaces) + _WorkspaceMenuItem( + name: ws.name, + path: ws.path, + isActive: ws.name == state.config?.currentWorkspace, + onTap: () { + state.switchWorkspace(ws.name); + setState(() => _workspaceSwitcherOpen = false); + }, + ), + // Add workspace + _WorkspaceMenuItem( + icon: null, + name: '+ Add workspace', + path: null, + isActive: false, + isAccent: true, + showDivider: true, + onTap: () { + setState(() => _workspaceSwitcherOpen = false); + state.setScreen('setup'); + }, + ), + ], + ) + : const SizedBox.shrink(), + ), + ), ), ), ), @@ -549,7 +583,7 @@ class _TasksScreenState extends State { setState(() { if (_showCompleted) { _showCompleted = false; - Future.delayed(const Duration(milliseconds: 300), () { + Future.delayed(const Duration(milliseconds: 150), () { if (mounted) setState(() => _completedVisible = false); }); } else { @@ -580,7 +614,7 @@ class _TasksScreenState extends State { ), AnimatedRotation( turns: _showCompleted ? 0.25 : 0, - duration: const Duration(milliseconds: 200), + duration: const Duration(milliseconds: 150), child: Icon(Icons.chevron_right, size: 16, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), ), @@ -590,10 +624,10 @@ class _TasksScreenState extends State { ), if (_completedVisible) AnimatedOpacity( - duration: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: 150), opacity: _showCompleted ? 1.0 : 0.0, child: AnimatedSlide( - duration: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: 150), offset: _showCompleted ? Offset.zero : const Offset(0, -0.05), child: Column( children: [ @@ -681,3 +715,84 @@ class _ListTileState extends State<_ListTile> { ); } } + +class _WorkspaceMenuItem extends StatefulWidget { + final String name; + final String? path; + final bool isActive; + final bool isAccent; + final bool showDivider; + final IconData? icon; + final VoidCallback onTap; + + const _WorkspaceMenuItem({ + required this.name, + this.path, + required this.isActive, + this.isAccent = false, + this.showDivider = false, + this.icon, + required this.onTap, + }); + + @override + State<_WorkspaceMenuItem> createState() => _WorkspaceMenuItemState(); +} + +class _WorkspaceMenuItemState extends State<_WorkspaceMenuItem> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showDivider) + Divider(height: 1, thickness: 0.5, color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + onTap: widget.onTap, + child: Container( + color: _hovering + ? (isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.05)) + : Colors.transparent, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + if (widget.isActive) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon(Icons.check, size: 16, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.name, + style: TextStyle( + fontSize: 14, + fontWeight: widget.isActive ? FontWeight.w700 : FontWeight.normal, + color: widget.isAccent ? AppTheme.primary : null, + ), + overflow: TextOverflow.ellipsis), + if (widget.path != null) + Text(widget.path!, + style: TextStyle(fontSize: 12, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + overflow: TextOverflow.ellipsis), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/apps/flutter/lib/src/widgets/task_detail_view.dart b/apps/flutter/lib/src/widgets/task_detail_view.dart index 43940be..a8d51a9 100644 --- a/apps/flutter/lib/src/widgets/task_detail_view.dart +++ b/apps/flutter/lib/src/widgets/task_detail_view.dart @@ -16,17 +16,24 @@ class TaskDetailView extends StatefulWidget { State createState() => _TaskDetailViewState(); } -class _TaskDetailViewState extends State { +class _TaskDetailViewState extends State with SingleTickerProviderStateMixin { late TextEditingController _titleController; late TextEditingController _descController; Timer? _debounce; bool _showMenu = false; + late final AnimationController _menuAnim; + late final Animation _menuFade; + late final Animation _menuScale; @override void initState() { super.initState(); _titleController = TextEditingController(text: widget.task.title); _descController = TextEditingController(text: widget.task.description); + _menuAnim = AnimationController(vsync: this, duration: const Duration(milliseconds: 150)); + _menuFade = CurvedAnimation(parent: _menuAnim, curve: Curves.easeOut); + _menuScale = Tween(begin: 0.9, end: 1.0) + .animate(CurvedAnimation(parent: _menuAnim, curve: Curves.easeOut)); } @override @@ -42,6 +49,7 @@ class _TaskDetailViewState extends State { @override void dispose() { _debounce?.cancel(); + _menuAnim.dispose(); _titleController.dispose(); _descController.dispose(); super.dispose(); @@ -245,7 +253,11 @@ class _TaskDetailViewState extends State { if (_showMenu) Positioned.fill( child: GestureDetector( - onTap: () => setState(() => _showMenu = false), + onTap: () { + _menuAnim.reverse().then((_) { + if (mounted) setState(() => _showMenu = false); + }); + }, behavior: HitTestBehavior.opaque, child: const SizedBox.expand(), ), @@ -259,52 +271,68 @@ class _TaskDetailViewState extends State { mainAxisSize: MainAxisSize.min, children: [ GestureDetector( - onTap: () => setState(() => _showMenu = !_showMenu), + onTap: () { + setState(() => _showMenu = !_showMenu); + if (_showMenu) _menuAnim.forward(); else _menuAnim.reverse(); + }, child: Padding( padding: const EdgeInsets.all(6), child: Icon(Icons.more_vert, size: 20, color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), ), ), - if (_showMenu) - Container( - margin: const EdgeInsets.only(top: 4), - constraints: const BoxConstraints(minWidth: 200), - decoration: BoxDecoration( - color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 8, offset: const Offset(0, 2)), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _KebabMenuItem( - icon: isCompleted ? Icons.close : Icons.check, - label: isCompleted ? 'Restore task' : 'Mark as completed', - onTap: () { - setState(() => _showMenu = false); - state.toggleTask(widget.task.id); - state.selectTask(null); - }, + ScaleTransition( + scale: _menuScale, + alignment: Alignment.topRight, + child: FadeTransition( + opacity: _menuFade, + child: IgnorePointer( + ignoring: !_showMenu, + child: Container( + margin: const EdgeInsets.only(top: 4), + width: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + child: Container( + decoration: BoxDecoration( + color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, + borderRadius: BorderRadius.circular(7), ), - _KebabMenuItem( - icon: Icons.delete_outline, - label: 'Delete', - color: AppTheme.danger, - onTap: () { - setState(() => _showMenu = false); - state.deleteTask(widget.task.id); - }, + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _KebabMenuItem( + icon: isCompleted ? Icons.close : Icons.check, + label: isCompleted ? 'Restore task' : 'Mark as completed', + onTap: () { + setState(() => _showMenu = false); + state.toggleTask(widget.task.id); + state.selectTask(null); + }, + ), + _KebabMenuItem( + icon: Icons.delete_outline, + label: 'Delete', + color: AppTheme.danger, + onTap: () { + setState(() => _showMenu = false); + state.deleteTask(widget.task.id); + }, + ), + ], ), - ], + ), ), ), ), + ), ], ), ),