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/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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue