Files
social-app/apps/lib/features/todo/presentation/screens/todo_edit_screen.dart
T

610 lines
18 KiB
Dart
Raw Normal View History

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../../app/di/injection.dart';
import '../../../../features/calendar/data/repositories/calendar_repository.dart';
import '../../../../features/calendar/data/models/schedule_item_model.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_pressable.dart';
import '../../../../shared/widgets/app_sheet_input_field.dart';
import '../../../../shared/widgets/back_title_page_header.dart';
import '../../../../shared/widgets/error_retry_surface.dart';
import '../../../../shared/widgets/full_screen_loading.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/apis/todo_api.dart';
class TodoEditScreen extends StatefulWidget {
final String? todoId;
const TodoEditScreen({super.key, required this.todoId});
const TodoEditScreen.create({super.key}) : todoId = null;
bool get isCreateMode => todoId == null;
@override
State<TodoEditScreen> createState() => _TodoEditScreenState();
}
class _TodoEditScreenState extends State<TodoEditScreen> {
final TodoApi _todoApi = sl<TodoApi>();
final CalendarRepository _calendarRepository = sl<CalendarRepository>();
final TextEditingController _titleController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
TodoResponse? _todo;
bool _loading = true;
bool _saving = false;
String? _error;
int _priority = 1;
final Set<String> _selectedScheduleItemIds = <String>{};
List<_ScheduleItemSimple> _scheduleItems = const <_ScheduleItemSimple>[];
ColorScheme get _colorScheme => Theme.of(context).colorScheme;
@override
void initState() {
super.initState();
_loadPage();
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _loadPage() async {
setState(() {
_loading = true;
_error = null;
});
try {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 30));
final end = now.add(const Duration(days: 90));
final scheduleItems = await _calendarRepository.listByRange(
startAt: start,
endAt: end,
);
TodoResponse? todo;
if (!widget.isCreateMode) {
todo = await _todoApi.getTodo(widget.todoId!);
}
if (!mounted) {
return;
}
_todo = todo;
_titleController.text = todo?.title ?? '';
_descriptionController.text = todo?.description ?? '';
_priority = todo?.priority ?? 1;
_selectedScheduleItemIds
..clear()
..addAll(todo?.scheduleItems.map((item) => item.id) ?? const []);
_scheduleItems = scheduleItems
.where((item) => item.status == ScheduleStatus.active)
.map(
(item) => _ScheduleItemSimple(
id: item.id,
title: item.title,
startAt: item.startAt,
),
)
.toList();
setState(() {
_loading = false;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_loading = false;
_error = error.toString();
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _colorScheme.surface,
resizeToAvoidBottomInset: false,
body: SafeArea(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_colorScheme.surfaceContainerLow,
_colorScheme.surface,
],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BackTitlePageHeader(
title: widget.isCreateMode
? context.l10n.todoCreateTitle
: context.l10n.todoEditTitle,
),
Expanded(child: _buildBody()),
_buildBottomAction(),
],
),
),
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const FullScreenLoading();
}
if (_error != null) {
return ErrorRetrySurface(
message: context.l10n.commonLoadFailed(_error!),
onRetry: _loadPage,
);
}
if (!widget.isCreateMode && _todo == null) {
return Center(child: Text(context.l10n.todoNotFound));
}
return ListView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.lg,
AppSpacing.lg,
),
children: [
_buildHeaderCard(),
const SizedBox(height: AppSpacing.md),
_buildFormCard(),
const SizedBox(height: AppSpacing.md),
_buildScheduleCard(),
],
);
}
Widget _buildHeaderCard() {
final headerDesc = widget.isCreateMode
? context.l10n.todoInfoDescCreate
: _todo?.status == 'done'
? context.l10n.todoInfoDescDone
: context.l10n.todoInfoDescDefault;
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: _colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: _colorScheme.outlineVariant),
boxShadow: [
BoxShadow(
color: _colorScheme.shadow.withValues(alpha: 0.18),
blurRadius: AppRadius.lg,
offset: const Offset(0, AppSpacing.xs),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.todoInfoTitle,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
headerDesc,
style: TextStyle(
fontSize: 13,
color: _colorScheme.onSurfaceVariant,
),
),
],
),
);
}
Widget _buildFormCard() {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: _colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: _colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSheetInputField(
controller: _titleController,
label: context.l10n.todoFieldTitle,
hint: context.l10n.todoFieldTitleHint,
),
const SizedBox(height: AppSpacing.lg),
AppSheetInputField(
controller: _descriptionController,
label: context.l10n.todoFieldDescriptionOptional,
hint: context.l10n.todoFieldDescriptionHint,
maxLines: 2,
),
const SizedBox(height: AppSpacing.lg),
Text(
context.l10n.todoPriority,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
_PriorityPill(
label: context.l10n.todoQuadrantImportantUrgent,
selected: _priority == 1,
borderColor: _colorScheme.error,
activeColor: _colorScheme.error,
onTap: () => setState(() => _priority = 1),
),
_PriorityPill(
label: context.l10n.todoQuadrantUrgentNotImportant,
selected: _priority == 3,
borderColor: _colorScheme.primary,
activeColor: _colorScheme.primary,
onTap: () => setState(() => _priority = 3),
),
_PriorityPill(
label: context.l10n.todoQuadrantImportantNotUrgent,
selected: _priority == 2,
borderColor: _colorScheme.tertiary,
activeColor: _colorScheme.tertiary,
onTap: () => setState(() => _priority = 2),
),
],
),
],
),
);
}
Widget _buildScheduleCard() {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: _colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: _colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
context.l10n.todoLinkedCalendarEvents,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _colorScheme.onSurface,
),
),
const Spacer(),
Text(
context.l10n.todoItemCount(_selectedScheduleItemIds.length),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
if (_scheduleItems.isEmpty)
Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.xl),
child: Center(
child: Text(
context.l10n.todoNoSelectableCalendarEvents,
style: TextStyle(color: _colorScheme.onSurfaceVariant),
),
),
)
else
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: ListView.separated(
shrinkWrap: true,
itemCount: _scheduleItems.length,
separatorBuilder: (context, index) =>
const SizedBox(height: AppSpacing.sm),
itemBuilder: (context, index) {
final item = _scheduleItems[index];
final selected = _selectedScheduleItemIds.contains(item.id);
return _ScheduleSelectableTile(
title: item.title,
subtitle: _formatDate(item.startAt),
selected: selected,
onTap: () {
setState(() {
if (selected) {
_selectedScheduleItemIds.remove(item.id);
} else {
_selectedScheduleItemIds.add(item.id);
}
});
},
);
},
),
),
],
),
);
}
Widget _buildBottomAction() {
final canSave = !_loading && !_saving;
return Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.lg,
AppSpacing.lg,
),
decoration: BoxDecoration(
color: _colorScheme.surface.withValues(alpha: 0.9),
border: Border(top: BorderSide(color: _colorScheme.outlineVariant)),
),
child: AppButton(
text: _saving
? context.l10n.todoSaveInProgress
: (widget.isCreateMode
? context.l10n.todoCreateButton
: context.l10n.todoSaveChanges),
onPressed: canSave ? _save : null,
),
);
}
Future<void> _save() async {
if (_saving) {
return;
}
final title = _titleController.text.trim();
if (title.isEmpty) {
Toast.show(context, context.l10n.todoEnterTitle, type: ToastType.warning);
return;
}
setState(() {
_saving = true;
});
try {
final description = _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim();
if (widget.isCreateMode) {
await _todoApi.createTodo(
title: title,
description: description,
priority: _priority,
scheduleItemIds: _selectedScheduleItemIds.toList(),
);
} else {
await _todoApi.updateTodo(
widget.todoId!,
title: title,
description: description,
priority: _priority,
scheduleItemIds: _selectedScheduleItemIds.toList(),
);
}
if (!mounted) {
return;
}
context.pop(true);
} catch (error) {
if (!mounted) {
return;
}
Toast.show(
context,
context.l10n.todoSaveFailed(error.toString()),
type: ToastType.error,
);
} finally {
if (mounted) {
setState(() {
_saving = false;
});
}
}
}
String _formatDate(DateTime dt) {
return DateFormat.yMd(context.l10n.localeName).add_Hm().format(dt);
}
}
class _ScheduleItemSimple {
final String id;
final String title;
final DateTime startAt;
const _ScheduleItemSimple({
required this.id,
required this.title,
required this.startAt,
});
}
class _PriorityPill extends StatelessWidget {
final String label;
final bool selected;
final Color borderColor;
final Color activeColor;
final VoidCallback onTap;
const _PriorityPill({
required this.label,
required this.selected,
required this.borderColor,
required this.activeColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.full),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOut,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: selected
? borderColor.withValues(alpha: 0.28)
: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: selected ? borderColor : colorScheme.outlineVariant,
width: selected ? 1.5 : 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? activeColor : colorScheme.onSurfaceVariant,
),
),
),
);
}
}
class _ScheduleSelectableTile extends StatelessWidget {
final String title;
final String subtitle;
final bool selected;
final VoidCallback onTap;
const _ScheduleSelectableTile({
required this.title,
required this.subtitle,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AppPressable(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: selected ? colorScheme.primaryContainer : colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: selected ? colorScheme.primary : colorScheme.outlineVariant,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: AppSpacing.md),
AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: AppSpacing.lg,
height: AppSpacing.lg,
decoration: BoxDecoration(
color: selected ? colorScheme.primary : colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(
color: selected
? colorScheme.primary
: colorScheme.outlineVariant,
),
),
child: selected
? Icon(Icons.check, size: 12, color: colorScheme.onPrimary)
: null,
),
],
),
),
);
}
}