feat(flutter): add widgets — title bar, task item, detail view, new task, date picker
This commit is contained in:
parent
0fc5066f97
commit
3b48f5fd86
101
apps/flutter/lib/src/widgets/custom_title_bar.dart
Normal file
101
apps/flutter/lib/src/widgets/custom_title_bar.dart
Normal 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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
218
apps/flutter/lib/src/widgets/date_time_picker.dart
Normal file
218
apps/flutter/lib/src/widgets/date_time_picker.dart
Normal 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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
apps/flutter/lib/src/widgets/new_task_input.dart
Normal file
200
apps/flutter/lib/src/widgets/new_task_input.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
358
apps/flutter/lib/src/widgets/task_detail_view.dart
Normal file
358
apps/flutter/lib/src/widgets/task_detail_view.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
apps/flutter/lib/src/widgets/task_item.dart
Normal file
179
apps/flutter/lib/src/widgets/task_item.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue