feat(flutter): add UI animations, fix resize overflow, polish menus

- Fix resize overflow by replacing AnimatedPositioned/AnimatedContainer
  with OverflowBox + AnimatedSlide (transform-based, no layout reflow)
- Add slide-up animation for new task toast using AnimationController
- Add fade+scale animation for settings screen overlay
- Add fade+scale animation for kebab menu in task detail view
- Convert workspace switcher from inline dropdown to floating popup
  menu with fade+scale animation and hover states
- Add hover highlights to workspace menu items and fix kebab menu
  hover highlights extending to edges
- Add drawer shadow casting onto main content when open
- Standardize all transition durations to 150ms
- Skip custom border/resize zones on Windows (native frame suffices)
This commit is contained in:
Tristan Michael 2026-03-31 09:18:02 -07:00 committed by GitButler
parent e1eba4cb83
commit 8983a1b632
4 changed files with 470 additions and 272 deletions

View file

@ -1,3 +1,4 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.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}); const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> with SingleTickerProviderStateMixin {
static const _edge = 8.0; static const _edge = 8.0;
late final AnimationController _settingsAnim;
late final Animation<double> _settingsFade;
late final Animation<double> _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<double>(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) { SystemMouseCursor _cursorFor(ResizeEdge? edge) => switch (edge) {
ResizeEdge.top || ResizeEdge.bottom => SystemMouseCursors.resizeUpDown, ResizeEdge.top || ResizeEdge.bottom => SystemMouseCursors.resizeUpDown,
@ -70,38 +108,11 @@ class AppShell extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = context.watch<AppState>(); final state = context.watch<AppState>();
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( final hasNativeBorder = Platform.isWindows;
backgroundColor: Colors.transparent,
body: LayoutBuilder(builder: (context, constraints) { _onScreenChanged(state.screen);
return MouseRegion(
cursor: SystemMouseCursors.basic, Widget content = Stack(
hitTestBehavior: HitTestBehavior.translucent,
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerHover: (event) {
// Update cursor based on edge proximity (handled by nested MouseRegion below)
},
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(
decoration: BoxDecoration(
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? Colors.white.withValues(alpha: 0.15) : Colors.black.withValues(alpha: 0.15),
),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 2)),
BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 2),
],
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [ children: [
if (state.screen == 'setup') if (state.screen == 'setup')
const SetupScreen() const SetupScreen()
@ -128,10 +139,54 @@ class AppShell extends StatelessWidget {
), ),
), ),
), ),
if (state.screen == 'settings') if (_settingsVisible)
const SettingsScreen(), 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) {
return MouseRegion(
cursor: SystemMouseCursors.basic,
hitTestBehavior: HitTestBehavior.translucent,
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerHover: (event) {},
child: Stack(
children: [
..._buildResizeZones(constraints),
Padding(
padding: const EdgeInsets.all(_edge),
child: Container(
decoration: BoxDecoration(
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark ? Colors.white.withValues(alpha: 0.15) : Colors.black.withValues(alpha: 0.15),
),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 2)),
BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 2),
], ],
), ),
clipBehavior: Clip.antiAlias,
child: content,
), ),
), ),
], ],

View file

@ -22,7 +22,7 @@ class SettingsScreen extends StatelessWidget {
onTap: () {}, onTap: () {},
child: AnimatedScale( child: AnimatedScale(
scale: 1.0, scale: 1.0,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 150),
curve: Curves.easeOut, curve: Curves.easeOut,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -112,7 +112,7 @@ class SettingsScreen extends StatelessWidget {
const Spacer(), const Spacer(),
// Toggle switch (matching Tauri: h-6 w-11) // Toggle switch (matching Tauri: h-6 w-11)
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 150),
width: 44, width: 44,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -120,7 +120,7 @@ class SettingsScreen extends StatelessWidget {
color: state.darkMode ? AppTheme.primary : (isDark ? const Color(0xFF4B5563) : const Color(0xFFD1D5DB)), color: state.darkMode ? AppTheme.primary : (isDark ? const Color(0xFF4B5563) : const Color(0xFFD1D5DB)),
), ),
child: AnimatedAlign( child: AnimatedAlign(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 150),
alignment: state.darkMode ? Alignment.centerRight : Alignment.centerLeft, alignment: state.darkMode ? Alignment.centerRight : Alignment.centerLeft,
child: Container( child: Container(
width: 20, width: 20,

View file

@ -15,7 +15,7 @@ class TasksScreen extends StatefulWidget {
State<TasksScreen> createState() => _TasksScreenState(); State<TasksScreen> createState() => _TasksScreenState();
} }
class _TasksScreenState extends State<TasksScreen> { class _TasksScreenState extends State<TasksScreen> with SingleTickerProviderStateMixin {
bool _drawerOpen = false; bool _drawerOpen = false;
bool _showCompleted = false; bool _showCompleted = false;
bool _completedVisible = false; bool _completedVisible = false;
@ -24,9 +24,22 @@ class _TasksScreenState extends State<TasksScreen> {
bool _newTaskOpen = false; bool _newTaskOpen = false;
final _newListController = TextEditingController(); final _newListController = TextEditingController();
final _newListFocus = FocusNode(); final _newListFocus = FocusNode();
late final AnimationController _newTaskAnim;
late final Animation<Offset> _newTaskSlide;
late final Animation<double> _newTaskFade;
@override
void initState() {
super.initState();
_newTaskAnim = AnimationController(vsync: this, duration: const Duration(milliseconds: 150));
_newTaskSlide = Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.animate(CurvedAnimation(parent: _newTaskAnim, curve: Curves.easeOut));
_newTaskFade = CurvedAnimation(parent: _newTaskAnim, curve: Curves.easeOut);
}
@override @override
void dispose() { void dispose() {
_newTaskAnim.dispose();
_newListController.dispose(); _newListController.dispose();
_newListFocus.dispose(); _newListFocus.dispose();
super.dispose(); super.dispose();
@ -47,10 +60,13 @@ class _TasksScreenState extends State<TasksScreen> {
final state = context.read<AppState>(); final state = context.read<AppState>();
if (state.activeListId == null) return; if (state.activeListId == null) return;
setState(() => _newTaskOpen = true); setState(() => _newTaskOpen = true);
_newTaskAnim.forward();
} }
void _closeNewTask() { void _closeNewTask() {
setState(() => _newTaskOpen = false); _newTaskAnim.reverse().then((_) {
if (mounted) setState(() => _newTaskOpen = false);
});
} }
Future<void> _handleCreateTask(String title, String desc, {String? dueDate}) async { Future<void> _handleCreateTask(String title, String desc, {String? dueDate}) async {
@ -94,12 +110,16 @@ class _TasksScreenState extends State<TasksScreen> {
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
children: [ children: [
// Sliding container: drawer + main + detail // Sliding container: drawer + main + detail
AnimatedPositioned( Positioned.fill(
duration: const Duration(milliseconds: 250), child: ClipRect(
child: OverflowBox(
maxWidth: drawerWidth + width,
alignment: Alignment.centerLeft,
child: AnimatedSlide(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut, curve: Curves.easeOut,
left: _drawerOpen ? 0.0 : -drawerWidth, offset: _drawerOpen ? Offset.zero : Offset(-drawerWidth / (drawerWidth + width), 0),
top: 0, child: SizedBox(
bottom: 0,
width: drawerWidth + width, width: drawerWidth + width,
child: Row( child: Row(
children: [ children: [
@ -111,6 +131,10 @@ class _TasksScreenState extends State<TasksScreen> {
], ],
), ),
), ),
),
),
),
),
// FAB button (centered, 56px, hidden when drawer/detail/newTask open) // FAB button (centered, 56px, hidden when drawer/detail/newTask open)
if (!_drawerOpen && !hasDetail && !_newTaskOpen && state.activeListId != null) if (!_drawerOpen && !hasDetail && !_newTaskOpen && state.activeListId != null)
Positioned( Positioned(
@ -133,13 +157,10 @@ class _TasksScreenState extends State<TasksScreen> {
), ),
), ),
// New task overlay (animated, inside app bounds) // New task overlay (animated, inside app bounds)
if (_newTaskOpen || _newTaskAnim.isAnimating)
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: FadeTransition(
ignoring: !_newTaskOpen, opacity: _newTaskFade,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
opacity: _newTaskOpen ? 1.0 : 0.0,
child: GestureDetector( child: GestureDetector(
onTap: _closeNewTask, onTap: _closeNewTask,
child: Container( child: Container(
@ -147,9 +168,9 @@ class _TasksScreenState extends State<TasksScreen> {
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: GestureDetector( child: GestureDetector(
onTap: () {}, onTap: () {},
child: _newTaskOpen child: SlideTransition(
? NewTaskInput(onCreate: _handleCreateTask) position: _newTaskSlide,
: const SizedBox.shrink(), child: NewTaskInput(onCreate: _handleCreateTask),
), ),
), ),
), ),
@ -170,10 +191,11 @@ class _TasksScreenState extends State<TasksScreen> {
child: OverflowBox( child: OverflowBox(
maxWidth: totalWidth * 2, maxWidth: totalWidth * 2,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: AnimatedContainer( child: AnimatedSlide(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 150),
curve: Curves.easeOut, curve: Curves.easeOut,
transform: Matrix4.translationValues(hasDetail ? -totalWidth : 0, 0, 0), offset: hasDetail ? const Offset(-0.5, 0) : Offset.zero,
child: SizedBox(
width: totalWidth * 2, width: totalWidth * 2,
child: Row( child: Row(
children: [ children: [
@ -192,27 +214,44 @@ class _TasksScreenState extends State<TasksScreen> {
), ),
), ),
), ),
// 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( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
ignoring: !_drawerOpen, ignoring: !_drawerOpen,
child: GestureDetector( child: GestureDetector(
onTap: _closeDrawer, onTap: _closeDrawer,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 150),
curve: Curves.easeOut, curve: Curves.easeOut,
opacity: _drawerOpen ? 1.0 : 0.0, opacity: _drawerOpen ? 1.0 : 0.0,
child: Container( child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4), color: Colors.black.withValues(alpha: 0.4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 24,
offset: const Offset(8, 0),
),
],
),
), ),
), ),
), ),
@ -225,9 +264,11 @@ class _TasksScreenState extends State<TasksScreen> {
Widget _buildDrawer(AppState state, bool isDark) { Widget _buildDrawer(AppState state, bool isDark) {
return Container( return Container(
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight, color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
child: Column( child: Stack(
children: [ children: [
// Header: workspace switcher (matching Tauri) Column(
children: [
// Header: workspace switcher
GestureDetector( GestureDetector(
onPanStart: (_) {}, onPanStart: (_) {},
child: Container( child: Container(
@ -261,7 +302,7 @@ class _TasksScreenState extends State<TasksScreen> {
const SizedBox(width: 6), const SizedBox(width: 6),
AnimatedRotation( AnimatedRotation(
turns: _workspaceSwitcherOpen ? 0.5 : 0, turns: _workspaceSwitcherOpen ? 0.5 : 0,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 150),
child: Icon(Icons.expand_more, size: 14, child: Icon(Icons.expand_more, size: 14,
color: isDark ? AppTheme.textDark : AppTheme.textLight), color: isDark ? AppTheme.textDark : AppTheme.textLight),
), ),
@ -274,86 +315,6 @@ class _TasksScreenState extends State<TasksScreen> {
), ),
), ),
), ),
// 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)),
),
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),
],
),
),
],
),
),
),
),
// 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 // List items
Expanded( Expanded(
child: ListView( child: ListView(
@ -449,6 +410,79 @@ class _TasksScreenState extends State<TasksScreen> {
), ),
], ],
), ),
// 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<TasksScreen> {
setState(() { setState(() {
if (_showCompleted) { if (_showCompleted) {
_showCompleted = false; _showCompleted = false;
Future.delayed(const Duration(milliseconds: 300), () { Future.delayed(const Duration(milliseconds: 150), () {
if (mounted) setState(() => _completedVisible = false); if (mounted) setState(() => _completedVisible = false);
}); });
} else { } else {
@ -580,7 +614,7 @@ class _TasksScreenState extends State<TasksScreen> {
), ),
AnimatedRotation( AnimatedRotation(
turns: _showCompleted ? 0.25 : 0, turns: _showCompleted ? 0.25 : 0,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 150),
child: Icon(Icons.chevron_right, size: 16, child: Icon(Icons.chevron_right, size: 16,
color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight),
), ),
@ -590,10 +624,10 @@ class _TasksScreenState extends State<TasksScreen> {
), ),
if (_completedVisible) if (_completedVisible)
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 150),
opacity: _showCompleted ? 1.0 : 0.0, opacity: _showCompleted ? 1.0 : 0.0,
child: AnimatedSlide( child: AnimatedSlide(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 150),
offset: _showCompleted ? Offset.zero : const Offset(0, -0.05), offset: _showCompleted ? Offset.zero : const Offset(0, -0.05),
child: Column( child: Column(
children: [ 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),
],
),
),
],
),
),
),
),
],
);
}
}

View file

@ -16,17 +16,24 @@ class TaskDetailView extends StatefulWidget {
State<TaskDetailView> createState() => _TaskDetailViewState(); State<TaskDetailView> createState() => _TaskDetailViewState();
} }
class _TaskDetailViewState extends State<TaskDetailView> { class _TaskDetailViewState extends State<TaskDetailView> with SingleTickerProviderStateMixin {
late TextEditingController _titleController; late TextEditingController _titleController;
late TextEditingController _descController; late TextEditingController _descController;
Timer? _debounce; Timer? _debounce;
bool _showMenu = false; bool _showMenu = false;
late final AnimationController _menuAnim;
late final Animation<double> _menuFade;
late final Animation<double> _menuScale;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_titleController = TextEditingController(text: widget.task.title); _titleController = TextEditingController(text: widget.task.title);
_descController = TextEditingController(text: widget.task.description); _descController = TextEditingController(text: widget.task.description);
_menuAnim = AnimationController(vsync: this, duration: const Duration(milliseconds: 150));
_menuFade = CurvedAnimation(parent: _menuAnim, curve: Curves.easeOut);
_menuScale = Tween<double>(begin: 0.9, end: 1.0)
.animate(CurvedAnimation(parent: _menuAnim, curve: Curves.easeOut));
} }
@override @override
@ -42,6 +49,7 @@ class _TaskDetailViewState extends State<TaskDetailView> {
@override @override
void dispose() { void dispose() {
_debounce?.cancel(); _debounce?.cancel();
_menuAnim.dispose();
_titleController.dispose(); _titleController.dispose();
_descController.dispose(); _descController.dispose();
super.dispose(); super.dispose();
@ -245,7 +253,11 @@ class _TaskDetailViewState extends State<TaskDetailView> {
if (_showMenu) if (_showMenu)
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: () => setState(() => _showMenu = false), onTap: () {
_menuAnim.reverse().then((_) {
if (mounted) setState(() => _showMenu = false);
});
},
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: const SizedBox.expand(), child: const SizedBox.expand(),
), ),
@ -259,29 +271,42 @@ class _TaskDetailViewState extends State<TaskDetailView> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
GestureDetector( GestureDetector(
onTap: () => setState(() => _showMenu = !_showMenu), onTap: () {
setState(() => _showMenu = !_showMenu);
if (_showMenu) _menuAnim.forward(); else _menuAnim.reverse();
},
child: Padding( child: Padding(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
child: Icon(Icons.more_vert, size: 20, child: Icon(Icons.more_vert, size: 20,
color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)), color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5)),
), ),
), ),
if (_showMenu) ScaleTransition(
Container( scale: _menuScale,
alignment: Alignment.topRight,
child: FadeTransition(
opacity: _menuFade,
child: IgnorePointer(
ignoring: !_showMenu,
child: Container(
margin: const EdgeInsets.only(top: 4), margin: const EdgeInsets.only(top: 4),
constraints: const BoxConstraints(minWidth: 200), width: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight),
boxShadow: [ boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 8, offset: const Offset(0, 2)), BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 8, offset: const Offset(0, 2)),
], ],
), ),
child: ClipRRect( child: Container(
borderRadius: BorderRadius.circular(8), decoration: BoxDecoration(
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(7),
),
clipBehavior: Clip.antiAlias,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_KebabMenuItem( _KebabMenuItem(
icon: isCompleted ? Icons.close : Icons.check, icon: isCompleted ? Icons.close : Icons.check,
@ -305,6 +330,9 @@ class _TaskDetailViewState extends State<TaskDetailView> {
), ),
), ),
), ),
),
),
),
], ],
), ),
), ),