feat(flutter): add widgets — title bar, task item, detail view, new task, date picker

This commit is contained in:
Tristan Michael 2026-03-31 07:03:12 -07:00
parent 0fc5066f97
commit 3b48f5fd86
5 changed files with 1056 additions and 0 deletions

View file

@ -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<Widget>? 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)),
),
),
);
}
}

View file

@ -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<DateTimePicker> createState() => _DateTimePickerState();
}
class _DateTimePickerState extends State<DateTimePicker> {
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<int>(
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<int>(
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)),
),
],
],
),
);
}
}

View file

@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import 'date_time_picker.dart';
class NewTaskInput extends StatefulWidget {
final Future<void> Function(String title, String description, {String? dueDate}) onCreate;
const NewTaskInput({super.key, required this.onCreate});
@override
State<NewTaskInput> createState() => _NewTaskInputState();
}
class _NewTaskInputState extends State<NewTaskInput> {
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<void> _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,
),
),
),
),
),
],
),
),
);
}
}

View file

@ -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<TaskDetailView> createState() => _TaskDetailViewState();
}
class _TaskDetailViewState extends State<TaskDetailView> {
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<AppState>();
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<AppState>();
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<AppState>();
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)),
],
),
),
),
);
}
}

View file

@ -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<TaskItem> createState() => _TaskItemState();
}
class _TaskItemState extends State<TaskItem> {
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),
),
),
],
),
),
],
),
),
);
}
}