diff --git a/apps/flutter/lib/src/widgets/custom_title_bar.dart b/apps/flutter/lib/src/widgets/custom_title_bar.dart new file mode 100644 index 0000000..f2e834c --- /dev/null +++ b/apps/flutter/lib/src/widgets/custom_title_bar.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; +import '../theme.dart'; + +class CustomTitleBar extends StatelessWidget { + final Widget? leading; + final String? title; + final bool centerTitle; + final List? actions; + final bool showClose; + + const CustomTitleBar({super.key, this.leading, this.title, this.centerTitle = false, this.actions, this.showClose = true}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return GestureDetector( + onPanStart: (_) => windowManager.startDragging(), + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark ? AppTheme.borderDark : AppTheme.borderLight, + width: 0.5, + ), + ), + ), + child: Row( + children: [ + if (leading != null) leading!, + if (title != null) + Expanded( + child: centerTitle + ? Center( + child: Text( + title!, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ) + : Text( + title!, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ) + else + const Expanded(child: SizedBox.shrink()), + if (actions != null) ...actions!, + if (showClose) ...[ + const SizedBox(width: 4), + _TitleBarButton( + icon: Icons.close, + onPressed: () => windowManager.close(), + hoverColor: AppTheme.danger, + ), + ], + ], + ), + ), + ); + } +} + +class _TitleBarButton extends StatefulWidget { + final IconData icon; + final VoidCallback onPressed; + final Color hoverColor; + + const _TitleBarButton({required this.icon, required this.onPressed, required this.hoverColor}); + + @override + State<_TitleBarButton> createState() => _TitleBarButtonState(); +} + +class _TitleBarButtonState extends State<_TitleBarButton> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + onTap: widget.onPressed, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: _hovering ? Colors.black.withValues(alpha: 0.1) : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Icon(widget.icon, size: 14, + color: _hovering ? widget.hoverColor : Theme.of(context).iconTheme.color?.withValues(alpha: 0.5)), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/widgets/date_time_picker.dart b/apps/flutter/lib/src/widgets/date_time_picker.dart new file mode 100644 index 0000000..3443487 --- /dev/null +++ b/apps/flutter/lib/src/widgets/date_time_picker.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import '../theme.dart'; + +class DateTimePicker extends StatefulWidget { + final DateTime? initialDate; + final void Function(DateTime date) onDone; + final VoidCallback onClear; + + const DateTimePicker({super.key, this.initialDate, required this.onDone, required this.onClear}); + + @override + State createState() => _DateTimePickerState(); +} + +class _DateTimePickerState extends State { + late DateTime _viewMonth; + DateTime? _selected; + bool _showTime = false; + int _hour = 12; + int _minute = 0; + + @override + void initState() { + super.initState(); + _selected = widget.initialDate; + _viewMonth = widget.initialDate ?? DateTime.now(); + if (widget.initialDate != null) { + _hour = widget.initialDate!.hour; + _minute = widget.initialDate!.minute; + _showTime = _hour != 0 || _minute != 0; + } + } + + void _prevMonth() => setState(() => _viewMonth = DateTime(_viewMonth.year, _viewMonth.month - 1)); + void _nextMonth() => setState(() => _viewMonth = DateTime(_viewMonth.year, _viewMonth.month + 1)); + + void _done() { + if (_selected == null) return; + final result = _showTime + ? DateTime(_selected!.year, _selected!.month, _selected!.day, _hour, _minute) + : DateTime(_selected!.year, _selected!.month, _selected!.day); + widget.onDone(result); + Navigator.of(context).pop(); + } + + void _clear() { + widget.onClear(); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final firstDay = DateTime(_viewMonth.year, _viewMonth.month, 1); + final lastDay = DateTime(_viewMonth.year, _viewMonth.month + 1, 0); + final startWeekday = firstDay.weekday; // 1=Mon + const dayNames = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + children: [ + const Text('Date & Time', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const Spacer(), + GestureDetector( + onTap: _done, + child: const Text('Done', style: TextStyle(fontSize: 14, color: AppTheme.primary, fontWeight: FontWeight.w500)), + ), + ], + ), + const SizedBox(height: 16), + // Month navigation + Row( + children: [ + GestureDetector(onTap: _prevMonth, child: const Icon(Icons.chevron_left, size: 20)), + Expanded( + child: Center( + child: Text('${months[_viewMonth.month - 1]} ${_viewMonth.year}', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ), + ), + GestureDetector(onTap: _nextMonth, child: const Icon(Icons.chevron_right, size: 20)), + ], + ), + const SizedBox(height: 12), + // Day names + Row( + children: [ + for (final name in dayNames) + Expanded( + child: Center( + child: Text(name, style: TextStyle(fontSize: 11, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)), + ), + ), + ], + ), + const SizedBox(height: 4), + // Calendar grid + ...List.generate(6, (week) { + return Row( + children: List.generate(7, (dow) { + final dayIndex = week * 7 + dow - (startWeekday - 1); + if (dayIndex < 0 || dayIndex >= lastDay.day) return const Expanded(child: SizedBox(height: 32)); + final day = dayIndex + 1; + final date = DateTime(_viewMonth.year, _viewMonth.month, day); + final isToday = date == today; + final isSelected = _selected != null && date.year == _selected!.year && date.month == _selected!.month && date.day == _selected!.day; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _selected = date), + child: Container( + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? AppTheme.primary : Colors.transparent, + ), + child: Text( + '$day', + style: TextStyle( + fontSize: 13, + fontWeight: isToday ? FontWeight.w700 : FontWeight.normal, + color: isSelected ? Colors.white : (isToday ? AppTheme.primary : null), + ), + ), + ), + ), + ); + }), + ); + }), + const SizedBox(height: 8), + // Time toggle + Container( + padding: const EdgeInsets.only(top: 12), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + ), + child: Column( + children: [ + GestureDetector( + onTap: () => setState(() => _showTime = !_showTime), + child: Row( + children: [ + Icon(Icons.access_time, size: 16, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), + const SizedBox(width: 8), + Text(_showTime ? 'Time' : 'Set time', + style: TextStyle(fontSize: 13, color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight)), + const Spacer(), + Icon(_showTime ? Icons.expand_less : Icons.expand_more, size: 18, + color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), + ], + ), + ), + if (_showTime) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Hour + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButton( + value: _hour, + underline: const SizedBox.shrink(), + isDense: true, + style: TextStyle(fontSize: 14, color: isDark ? AppTheme.textDark : AppTheme.textLight), + items: List.generate(24, (i) => DropdownMenuItem(value: i, child: Text(i.toString().padLeft(2, '0')))), + onChanged: (v) => setState(() => _hour = v!), + ), + ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 8), child: Text(':', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600))), + // Minute + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + borderRadius: BorderRadius.circular(6), + ), + child: DropdownButton( + value: _minute, + underline: const SizedBox.shrink(), + isDense: true, + style: TextStyle(fontSize: 14, color: isDark ? AppTheme.textDark : AppTheme.textLight), + items: List.generate(12, (i) => i * 5).map((m) => DropdownMenuItem(value: m, child: Text(m.toString().padLeft(2, '0')))).toList(), + onChanged: (v) => setState(() => _minute = v!), + ), + ), + ], + ), + ], + ], + ), + ), + // Clear button + if (widget.initialDate != null) ...[ + const SizedBox(height: 12), + GestureDetector( + onTap: _clear, + child: const Text('Clear date', style: TextStyle(fontSize: 13, color: AppTheme.danger)), + ), + ], + ], + ), + ); + } +} diff --git a/apps/flutter/lib/src/widgets/new_task_input.dart b/apps/flutter/lib/src/widgets/new_task_input.dart new file mode 100644 index 0000000..96e1daa --- /dev/null +++ b/apps/flutter/lib/src/widgets/new_task_input.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import '../theme.dart'; +import 'date_time_picker.dart'; + +class NewTaskInput extends StatefulWidget { + final Future Function(String title, String description, {String? dueDate}) onCreate; + + const NewTaskInput({super.key, required this.onCreate}); + + @override + State createState() => _NewTaskInputState(); +} + +class _NewTaskInputState extends State { + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + final _titleFocus = FocusNode(); + DateTime? _selectedDate; + + @override + void initState() { + super.initState(); + _titleFocus.requestFocus(); + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + _titleFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + final title = _titleController.text.trim(); + if (title.isEmpty) return; + await widget.onCreate(title, _descController.text.trim(), dueDate: _selectedDate?.toUtc().toIso8601String()); + } + + void _pickDate() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => DateTimePicker( + initialDate: _selectedDate, + onDone: (date) => setState(() => _selectedDate = date), + onClear: () => setState(() => _selectedDate = null), + ), + ); + } + + String _formatDateChip(DateTime d) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final taskDate = DateTime(d.year, d.month, d.day); + final dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + final day = dayNames[d.weekday % 7]; + final pad = (int n) => n.toString().padLeft(2, '0'); + final hasTime = d.hour != 0 || d.minute != 0; + final timePart = hasTime ? ', ${pad(d.hour)}:${pad(d.minute)}' : ''; + if (taskDate == today) return 'Today$timePart'; + return '$day, ${pad(d.day)}/${pad(d.month)}$timePart'; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Container( + decoration: BoxDecoration( + color: isDark ? AppTheme.cardDark : AppTheme.surfaceLight, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Padding( + padding: EdgeInsets.only( + left: 16, right: 16, top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title input + TextField( + controller: _titleController, + focusNode: _titleFocus, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + decoration: InputDecoration( + hintText: 'Task title', + hintStyle: TextStyle( + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.3)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 16), + // Description with icon (matching Tauri) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.subject, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _descController, + style: const TextStyle(fontSize: 14), + maxLines: 3, + decoration: InputDecoration( + hintText: 'Add details', + hintStyle: TextStyle( + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + // Date/time with icon (matching Tauri) + Row( + children: [ + Icon(Icons.access_time, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + const SizedBox(width: 12), + if (_selectedDate != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.05), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _pickDate, + child: Text( + _formatDateChip(_selectedDate!), + style: const TextStyle(fontSize: 14), + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: () => setState(() => _selectedDate = null), + child: Icon(Icons.close, size: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + ), + ], + ), + ) + else + GestureDetector( + onTap: _pickDate, + child: Text('Add date/time', style: TextStyle(fontSize: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4))), + ), + ], + ), + const SizedBox(height: 16), + // Save button (centered, matching Tauri) + Container( + padding: const EdgeInsets.only(top: 12), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: isDark ? AppTheme.borderDark : AppTheme.borderLight, width: 0.5)), + ), + child: SizedBox( + width: double.infinity, + child: GestureDetector( + onTap: _submit, + child: Text('Save', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: _titleController.text.trim().isNotEmpty + ? AppTheme.primary + : AppTheme.primary.withValues(alpha: 0.3), + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/widgets/task_detail_view.dart b/apps/flutter/lib/src/widgets/task_detail_view.dart new file mode 100644 index 0000000..43940be --- /dev/null +++ b/apps/flutter/lib/src/widgets/task_detail_view.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; +import '../rust/api.dart' as api; +import '../state/app_state.dart'; +import '../theme.dart'; +import 'package:provider/provider.dart'; +import 'date_time_picker.dart'; + +class TaskDetailView extends StatefulWidget { + final api.TaskDto task; + + const TaskDetailView({super.key, required this.task}); + + @override + State createState() => _TaskDetailViewState(); +} + +class _TaskDetailViewState extends State { + late TextEditingController _titleController; + late TextEditingController _descController; + Timer? _debounce; + bool _showMenu = false; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.task.title); + _descController = TextEditingController(text: widget.task.description); + } + + @override + void didUpdateWidget(TaskDetailView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.task.id != widget.task.id) { + _titleController.text = widget.task.title; + _descController.text = widget.task.description; + _showMenu = false; + } + } + + @override + void dispose() { + _debounce?.cancel(); + _titleController.dispose(); + _descController.dispose(); + super.dispose(); + } + + void _scheduleUpdate({String? dueDate}) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 400), () { + final state = context.read(); + state.updateTask(api.TaskDto( + id: widget.task.id, + title: _titleController.text, + description: _descController.text, + status: widget.task.status, + dueDate: dueDate ?? widget.task.dueDate, + createdAt: widget.task.createdAt, + updatedAt: widget.task.updatedAt, + parentId: widget.task.parentId, + )); + }); + } + + void _updateDueDate(String? dueDate) { + final state = context.read(); + state.updateTask(api.TaskDto( + id: widget.task.id, + title: _titleController.text, + description: _descController.text, + status: widget.task.status, + dueDate: dueDate, + createdAt: widget.task.createdAt, + updatedAt: widget.task.updatedAt, + parentId: widget.task.parentId, + )); + } + + void _editDate() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => DateTimePicker( + initialDate: widget.task.dueDate != null ? DateTime.tryParse(widget.task.dueDate!) : null, + onDone: (date) => _updateDueDate(date.toUtc().toIso8601String()), + onClear: () => _updateDueDate(null), + ), + ); + } + + String _formatDateChip(String iso) { + final d = DateTime.tryParse(iso); + if (d == null) return iso; + final local = d.toLocal(); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final taskDate = DateTime(local.year, local.month, local.day); + final dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + final day = dayNames[local.weekday % 7]; + final pad = (int n) => n.toString().padLeft(2, '0'); + final hasTime = local.hour != 0 || local.minute != 0; + final timePart = hasTime ? ', ${pad(local.hour)}:${pad(local.minute)}' : ''; + if (taskDate == today) return 'Today$timePart'; + return '$day, ${pad(local.day)}/${pad(local.month)}$timePart'; + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final state = context.read(); + final isCompleted = widget.task.status == 'completed'; + return Column( + children: [ + // Header (just back button, matching Tauri) + GestureDetector( + onPanStart: (_) => windowManager.startDragging(), + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isDark ? AppTheme.borderDark : AppTheme.borderLight, + width: 0.5, + ), + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => state.selectTask(null), + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(Icons.arrow_back, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.6)), + ), + ), + ], + ), + ), + ), + // Content + Expanded( + child: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.only(left: 16, right: 16, top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + TextField( + controller: _titleController, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Task title', + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (_) => _scheduleUpdate(), + ), + const SizedBox(height: 16), + // Description with icon (matching Tauri) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.subject, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _descController, + style: const TextStyle(fontSize: 14), + maxLines: null, + minLines: 3, + decoration: InputDecoration( + border: InputBorder.none, + hintText: 'Add details', + hintStyle: TextStyle( + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + isDense: true, + contentPadding: EdgeInsets.zero, + ), + onChanged: (_) => _scheduleUpdate(), + ), + ), + ], + ), + const SizedBox(height: 16), + // Date/time with icon (matching Tauri) + Row( + children: [ + Icon(Icons.access_time, size: 20, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + const SizedBox(width: 12), + if (widget.task.dueDate != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.05), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _editDate, + child: Text( + _formatDateChip(widget.task.dueDate!), + style: const TextStyle(fontSize: 14), + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: () => _updateDueDate(null), + child: Icon(Icons.close, size: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4)), + ), + ], + ), + ) + else + GestureDetector( + onTap: _editDate, + child: Text('Add date/time', style: TextStyle(fontSize: 14, + color: (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.4))), + ), + ], + ), + ], + ), + ), + // Click-off backdrop to close kebab menu + if (_showMenu) + Positioned.fill( + child: GestureDetector( + onTap: () => setState(() => _showMenu = false), + behavior: HitTestBehavior.opaque, + child: const SizedBox.expand(), + ), + ), + // Kebab menu (absolute positioned in content, matching Tauri) + Positioned( + right: 12, + top: 8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => setState(() => _showMenu = !_showMenu), + 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); + }, + ), + _KebabMenuItem( + icon: Icons.delete_outline, + label: 'Delete', + color: AppTheme.danger, + onTap: () { + setState(() => _showMenu = false); + state.deleteTask(widget.task.id); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +class _KebabMenuItem extends StatefulWidget { + final IconData icon; + final String label; + final Color? color; + final VoidCallback onTap; + + const _KebabMenuItem({required this.icon, required this.label, this.color, required this.onTap}); + + @override + State<_KebabMenuItem> createState() => _KebabMenuItemState(); +} + +class _KebabMenuItemState extends State<_KebabMenuItem> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return 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: 8), + child: Row( + children: [ + Icon(widget.icon, size: 16, color: widget.color), + const SizedBox(width: 8), + Text(widget.label, style: TextStyle(color: widget.color, fontSize: 14)), + ], + ), + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/widgets/task_item.dart b/apps/flutter/lib/src/widgets/task_item.dart new file mode 100644 index 0000000..44432cb --- /dev/null +++ b/apps/flutter/lib/src/widgets/task_item.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import '../rust/api.dart' as api; +import '../theme.dart'; + +class TaskItem extends StatefulWidget { + final api.TaskDto task; + final VoidCallback onToggle; + final VoidCallback onTap; + + const TaskItem({super.key, required this.task, required this.onToggle, required this.onTap}); + + @override + State createState() => _TaskItemState(); +} + +class _TaskItemState extends State { + bool _hovering = false; + double _swipeOffset = 0; + + String _formatDueDate(String isoDate) { + final date = DateTime.tryParse(isoDate); + if (date == null) return ''; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final taskDate = DateTime(date.year, date.month, date.day); + final diff = taskDate.difference(today).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Tomorrow'; + return date.toLocal().toIso8601String().substring(5, 10).replaceAll('-', '/'); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final isCompleted = widget.task.status == 'completed'; + final canSwipeLeft = !isCompleted; + final canSwipeRight = isCompleted; + + return MouseRegion( + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: GestureDetector( + onTap: () { + setState(() => _hovering = false); + widget.onTap(); + }, + onHorizontalDragUpdate: (details) { + setState(() { + _swipeOffset += details.delta.dx; + if (canSwipeLeft) _swipeOffset = _swipeOffset.clamp(-150.0, 0.0); + else if (canSwipeRight) _swipeOffset = _swipeOffset.clamp(0.0, 150.0); + else _swipeOffset = 0; + }); + }, + onHorizontalDragEnd: (details) { + if (_swipeOffset.abs() > 100) widget.onToggle(); + setState(() => _swipeOffset = 0); + }, + child: Stack( + children: [ + // Swipe background + if (_swipeOffset != 0) + Positioned.fill( + child: Container( + color: AppTheme.primary, + alignment: _swipeOffset < 0 ? Alignment.centerRight : Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + _swipeOffset < 0 ? 'Complete' : 'Undo', + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + ), + // Task content + Container( + transform: Matrix4.translationValues(_swipeOffset, 0, 0), + color: _hovering + ? (isDark ? Colors.white.withValues(alpha: 0.05) : Colors.black.withValues(alpha: 0.05)) + : (isDark ? AppTheme.surfaceDark : AppTheme.surfaceLight), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Checkbox with expanded touch target + GestureDetector( + onTap: widget.onToggle, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(2), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted ? AppTheme.primary : Colors.transparent, + border: Border.all( + color: isCompleted ? AppTheme.primary : (isDark ? const Color(0xFF6B7280) : const Color(0xFF9CA3AF)), + width: 2, + ), + ), + child: isCompleted + ? const Icon(Icons.check, size: 12, color: Colors.white) + : null, + ), + ), + ), + const SizedBox(width: 12), + // Content column (title, description, due date below) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.task.title, + style: TextStyle( + fontSize: 14, + fontWeight: isCompleted ? FontWeight.normal : FontWeight.w500, + decoration: isCompleted ? TextDecoration.lineThrough : null, + color: isCompleted + ? (isDark ? AppTheme.textDark : AppTheme.textLight).withValues(alpha: 0.5) + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.task.description.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + widget.task.description, + style: TextStyle( + fontSize: 12, + color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.4), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Due date badge (below title/description, matching Tauri) + if (widget.task.dueDate != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + border: Border.all(color: isDark ? AppTheme.borderDark : AppTheme.borderLight), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + _formatDueDate(widget.task.dueDate!), + style: TextStyle( + fontSize: 12, + color: (isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight).withValues(alpha: 0.5), + ), + ), + ), + ), + ], + ), + ), + // Chevron (show on hover only) + AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: _hovering ? 0.3 : 0, + child: Padding( + padding: const EdgeInsets.only(left: 4, top: 4), + child: Icon(Icons.chevron_right, size: 16, + color: isDark ? AppTheme.textSecondaryDark : AppTheme.textSecondaryLight), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +}