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:
parent
e1eba4cb83
commit
8983a1b632
|
|
@ -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<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> with SingleTickerProviderStateMixin {
|
||||
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) {
|
||||
ResizeEdge.top || ResizeEdge.bottom => SystemMouseCursors.resizeUpDown,
|
||||
|
|
@ -70,38 +108,11 @@ class AppShell extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
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) {
|
||||
// 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(
|
||||
final hasNativeBorder = Platform.isWindows;
|
||||
|
||||
_onScreenChanged(state.screen);
|
||||
|
||||
Widget content = Stack(
|
||||
children: [
|
||||
if (state.screen == 'setup')
|
||||
const SetupScreen()
|
||||
|
|
@ -128,10 +139,54 @@ class AppShell extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (state.screen == 'settings')
|
||||
const SettingsScreen(),
|
||||
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) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class TasksScreen extends StatefulWidget {
|
|||
State<TasksScreen> createState() => _TasksScreenState();
|
||||
}
|
||||
|
||||
class _TasksScreenState extends State<TasksScreen> {
|
||||
class _TasksScreenState extends State<TasksScreen> with SingleTickerProviderStateMixin {
|
||||
bool _drawerOpen = false;
|
||||
bool _showCompleted = false;
|
||||
bool _completedVisible = false;
|
||||
|
|
@ -24,9 +24,22 @@ class _TasksScreenState extends State<TasksScreen> {
|
|||
bool _newTaskOpen = false;
|
||||
final _newListController = TextEditingController();
|
||||
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
|
||||
void dispose() {
|
||||
_newTaskAnim.dispose();
|
||||
_newListController.dispose();
|
||||
_newListFocus.dispose();
|
||||
super.dispose();
|
||||
|
|
@ -47,10 +60,13 @@ class _TasksScreenState extends State<TasksScreen> {
|
|||
final state = context.read<AppState>();
|
||||
if (state.activeListId == null) return;
|
||||
setState(() => _newTaskOpen = true);
|
||||
_newTaskAnim.forward();
|
||||
}
|
||||
|
||||
void _closeNewTask() {
|
||||
setState(() => _newTaskOpen = false);
|
||||
_newTaskAnim.reverse().then((_) {
|
||||
if (mounted) setState(() => _newTaskOpen = false);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleCreateTask(String title, String desc, {String? dueDate}) async {
|
||||
|
|
@ -94,12 +110,16 @@ class _TasksScreenState extends State<TasksScreen> {
|
|||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
// Sliding container: drawer + main + detail
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
Positioned.fill(
|
||||
child: ClipRect(
|
||||
child: OverflowBox(
|
||||
maxWidth: drawerWidth + width,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AnimatedSlide(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
left: _drawerOpen ? 0.0 : -drawerWidth,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
offset: _drawerOpen ? Offset.zero : Offset(-drawerWidth / (drawerWidth + width), 0),
|
||||
child: SizedBox(
|
||||
width: drawerWidth + width,
|
||||
child: Row(
|
||||
children: [
|
||||
|
|
@ -111,6 +131,10 @@ class _TasksScreenState extends State<TasksScreen> {
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// FAB button (centered, 56px, hidden when drawer/detail/newTask open)
|
||||
if (!_drawerOpen && !hasDetail && !_newTaskOpen && state.activeListId != null)
|
||||
Positioned(
|
||||
|
|
@ -133,13 +157,10 @@ class _TasksScreenState extends State<TasksScreen> {
|
|||
),
|
||||
),
|
||||
// New task overlay (animated, inside app bounds)
|
||||
if (_newTaskOpen || _newTaskAnim.isAnimating)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: !_newTaskOpen,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
opacity: _newTaskOpen ? 1.0 : 0.0,
|
||||
child: FadeTransition(
|
||||
opacity: _newTaskFade,
|
||||
child: GestureDetector(
|
||||
onTap: _closeNewTask,
|
||||
child: Container(
|
||||
|
|
@ -147,9 +168,9 @@ class _TasksScreenState extends State<TasksScreen> {
|
|||
alignment: Alignment.bottomCenter,
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
child: _newTaskOpen
|
||||
? NewTaskInput(onCreate: _handleCreateTask)
|
||||
: const SizedBox.shrink(),
|
||||
child: SlideTransition(
|
||||
position: _newTaskSlide,
|
||||
child: NewTaskInput(onCreate: _handleCreateTask),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -170,10 +191,11 @@ class _TasksScreenState extends State<TasksScreen> {
|
|||
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),
|
||||
offset: hasDetail ? const Offset(-0.5, 0) : Offset.zero,
|
||||
child: SizedBox(
|
||||
width: totalWidth * 2,
|
||||
child: Row(
|
||||
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(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -225,9 +264,11 @@ class _TasksScreenState extends State<TasksScreen> {
|
|||
Widget _buildDrawer(AppState state, bool isDark) {
|
||||
return Container(
|
||||
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Header: workspace switcher (matching Tauri)
|
||||
Column(
|
||||
children: [
|
||||
// Header: workspace switcher
|
||||
GestureDetector(
|
||||
onPanStart: (_) {},
|
||||
child: Container(
|
||||
|
|
@ -261,7 +302,7 @@ class _TasksScreenState extends State<TasksScreen> {
|
|||
const SizedBox(width: 6),
|
||||
AnimatedRotation(
|
||||
turns: _workspaceSwitcherOpen ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: Icon(Icons.expand_more, size: 14,
|
||||
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
|
||||
Expanded(
|
||||
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(() {
|
||||
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<TasksScreen> {
|
|||
),
|
||||
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<TasksScreen> {
|
|||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,17 +16,24 @@ class TaskDetailView extends StatefulWidget {
|
|||
State<TaskDetailView> createState() => _TaskDetailViewState();
|
||||
}
|
||||
|
||||
class _TaskDetailViewState extends State<TaskDetailView> {
|
||||
class _TaskDetailViewState extends State<TaskDetailView> with SingleTickerProviderStateMixin {
|
||||
late TextEditingController _titleController;
|
||||
late TextEditingController _descController;
|
||||
Timer? _debounce;
|
||||
bool _showMenu = false;
|
||||
late final AnimationController _menuAnim;
|
||||
late final Animation<double> _menuFade;
|
||||
late final Animation<double> _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<double>(begin: 0.9, end: 1.0)
|
||||
.animate(CurvedAnimation(parent: _menuAnim, curve: Curves.easeOut));
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -42,6 +49,7 @@ class _TaskDetailViewState extends State<TaskDetailView> {
|
|||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_menuAnim.dispose();
|
||||
_titleController.dispose();
|
||||
_descController.dispose();
|
||||
super.dispose();
|
||||
|
|
@ -245,7 +253,11 @@ class _TaskDetailViewState extends State<TaskDetailView> {
|
|||
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,29 +271,42 @@ class _TaskDetailViewState extends State<TaskDetailView> {
|
|||
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(
|
||||
ScaleTransition(
|
||||
scale: _menuScale,
|
||||
alignment: Alignment.topRight,
|
||||
child: FadeTransition(
|
||||
opacity: _menuFade,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_showMenu,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
constraints: const BoxConstraints(minWidth: 200),
|
||||
width: 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: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_KebabMenuItem(
|
||||
icon: isCompleted ? Icons.close : Icons.check,
|
||||
|
|
@ -305,6 +330,9 @@ class _TaskDetailViewState extends State<TaskDetailView> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue