feat: app overhaul — Briefing-first navigation, Library, capture queue
Plans 1-3 implemented: Plan 1 — Foundation - Add google_fonts ^6.2.1 - lib/core/theme.dart: slate-indigo ColorScheme (dark/light), Fraunces headings, GradientButton widget - lib/providers/capture_work_queue_provider.dart: in-memory sequential work queue; CaptureWorkQueueNotifier drains one item at a time; captureResultProvider feeds snackbars to UI - lib/app.dart: wire fabledDarkTheme/fabledLightTheme; replace blocking _QuickCaptureBar with queue-based implementation (progress bar, badge) Plan 2 — Navigation - 3-tab shell: Briefing · Library · Chat (was Notes · Tasks · Projects · Chat) - lib/screens/library/library_screen.dart: unified notes+tasks+projects list with filter pills, status sub-filter for tasks, live search, FAB - lib/widgets/library_item_card.dart: NoteLibraryCard, TaskLibraryCard (status cycle), ProjectLibraryCard - lib/screens/chat/conversations_tab_screen.dart: focused replacement for ConversationsListScreen - Delete 5 dead screens: notes_list, tasks_list, project_list, conversations_list, quick_capture Plan 3 — Briefing - lib/data/models/briefing_conversation.dart - lib/data/api/briefing_api.dart: getToday, getHistory, getMessages, triggerSlot - lib/providers/briefing_provider.dart: BriefingNotifier with sendReply (optimistic + SSE + poll, same pattern as MessagesNotifier) and refresh - lib/widgets/chat_message_bubble.dart: extracted + redesigned shared bubble (ghost user bubbles, left-accent assistant bubbles) - lib/widgets/briefing_digest_card.dart: expandable first-message card - lib/screens/briefing/briefing_screen.dart: digest card, conversation list, streaming reply bar, refresh button, history overflow menu - lib/screens/briefing/briefing_history_screen.dart: read-only past dates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,631 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/models/milestone.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/milestones_provider.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
|
||||
class ProjectListScreen extends ConsumerWidget {
|
||||
const ProjectListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final projectsAsync = ref.watch(projectsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Projects')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showCreateDialog(context, ref),
|
||||
tooltip: 'New project',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: projectsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (projects) {
|
||||
if (projects.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No projects yet.\nTap + to create one.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
final active =
|
||||
projects.where((p) => p.status == 'active').toList();
|
||||
final other =
|
||||
projects.where((p) => p.status != 'active').toList();
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
children: [
|
||||
if (active.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'Active (${active.length})'),
|
||||
...active.map((p) => _ProjectExpansionTile(project: p)),
|
||||
],
|
||||
if (other.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'Other'),
|
||||
...other.map((p) => _ProjectExpansionTile(project: p)),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateDialog(BuildContext context, WidgetRef ref) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _CreateProjectDialog(
|
||||
onCreate: (title, description, goal) async {
|
||||
try {
|
||||
await ref
|
||||
.read(projectsProvider.notifier)
|
||||
.create(title: title, description: description, goal: goal);
|
||||
} on AppException catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Section header ────────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Project expansion tile ────────────────────────────────────────────────────
|
||||
|
||||
class _ProjectExpansionTile extends ConsumerStatefulWidget {
|
||||
final Project project;
|
||||
const _ProjectExpansionTile({required this.project});
|
||||
|
||||
@override
|
||||
ConsumerState<_ProjectExpansionTile> createState() =>
|
||||
_ProjectExpansionTileState();
|
||||
}
|
||||
|
||||
class _ProjectExpansionTileState
|
||||
extends ConsumerState<_ProjectExpansionTile> {
|
||||
bool _expanded = false;
|
||||
|
||||
Color _statusColor(BuildContext context) => switch (widget.project.status) {
|
||||
'completed' => Colors.green,
|
||||
'archived' => Colors.grey,
|
||||
_ => Theme.of(context).colorScheme.primary,
|
||||
};
|
||||
|
||||
void _showOptions(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.check_circle_outline),
|
||||
title: const Text('Mark completed'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(projectsProvider.notifier).updateProject(
|
||||
widget.project.id,
|
||||
{'status': 'completed'},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.archive_outlined),
|
||||
title: const Text('Archive'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(projectsProvider.notifier).updateProject(
|
||||
widget.project.id,
|
||||
{'status': 'archived'},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete_outline,
|
||||
color: Theme.of(context).colorScheme.error),
|
||||
title: Text('Delete',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete project?'),
|
||||
content: const Text(
|
||||
'Notes and tasks will be unlinked, not deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true && context.mounted) {
|
||||
await ref
|
||||
.read(projectsProvider.notifier)
|
||||
.delete(widget.project.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColor = _statusColor(context);
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
final unfinished = tasksAsync.valueOrNull
|
||||
?.where((t) =>
|
||||
t.projectId == widget.project.id &&
|
||||
t.status != TaskStatus.done)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final subtitle = unfinished.isEmpty
|
||||
? (widget.project.description != null &&
|
||||
widget.project.description!.isNotEmpty
|
||||
? widget.project.description!
|
||||
: null)
|
||||
: '${unfinished.length} task${unfinished.length == 1 ? '' : 's'} in progress';
|
||||
|
||||
return ExpansionTile(
|
||||
key: PageStorageKey('project-${widget.project.id}'),
|
||||
initiallyExpanded: false,
|
||||
onExpansionChanged: (v) => setState(() => _expanded = v),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: statusColor.withValues(alpha: 0.15),
|
||||
child: Icon(Icons.folder_outlined, color: statusColor, size: 20),
|
||||
),
|
||||
title: GestureDetector(
|
||||
onLongPress: () => _showOptions(context),
|
||||
child: Text(widget.project.title),
|
||||
),
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
: null,
|
||||
trailing: _StatusChip(status: widget.project.status),
|
||||
children: [
|
||||
if (_expanded)
|
||||
_ProjectTaskList(
|
||||
project: widget.project,
|
||||
unfinishedTasks: unfinished,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Expanded task list grouped by milestone ───────────────────────────────────
|
||||
|
||||
class _ProjectTaskList extends ConsumerWidget {
|
||||
final Project project;
|
||||
final List<Task> unfinishedTasks;
|
||||
const _ProjectTaskList({
|
||||
required this.project,
|
||||
required this.unfinishedTasks,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final milestonesAsync =
|
||||
ref.watch(projectMilestonesProvider(project.id));
|
||||
|
||||
return milestonesAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error loading milestones: $e',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
data: (milestones) {
|
||||
// Only active milestones in order
|
||||
final activeMilestones = milestones
|
||||
.where((m) => m.status == 'active')
|
||||
.toList()
|
||||
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
|
||||
|
||||
if (unfinishedTasks.isEmpty) {
|
||||
return _EmptyProjectContent(project: project);
|
||||
}
|
||||
|
||||
// Build milestone → tasks map
|
||||
final Map<int?, List<Task>> grouped = {};
|
||||
for (final task in unfinishedTasks) {
|
||||
grouped.putIfAbsent(task.milestoneId, () => []).add(task);
|
||||
}
|
||||
|
||||
final widgets = <Widget>[];
|
||||
|
||||
// Milestone groups (in order)
|
||||
for (final ms in activeMilestones) {
|
||||
final tasks = grouped[ms.id];
|
||||
if (tasks == null || tasks.isEmpty) continue;
|
||||
widgets.add(_MilestoneHeader(milestone: ms));
|
||||
for (final task in tasks) {
|
||||
widgets.add(_TaskRow(task: task));
|
||||
}
|
||||
}
|
||||
|
||||
// No-milestone group
|
||||
final noMilestoneTasks = grouped[null] ?? [];
|
||||
if (noMilestoneTasks.isNotEmpty) {
|
||||
if (activeMilestones.isNotEmpty) {
|
||||
widgets.add(const _NoMilestoneHeader());
|
||||
}
|
||||
for (final task in noMilestoneTasks) {
|
||||
widgets.add(_TaskRow(task: task));
|
||||
}
|
||||
}
|
||||
|
||||
widgets.add(_AddTaskRow(project: project));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: widgets,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyProjectContent extends StatelessWidget {
|
||||
final Project project;
|
||||
const _EmptyProjectContent({required this.project});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
'No open tasks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
_AddTaskRow(project: project),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Milestone header ──────────────────────────────────────────────────────────
|
||||
|
||||
class _MilestoneHeader extends StatelessWidget {
|
||||
final Milestone milestone;
|
||||
const _MilestoneHeader({required this.milestone});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = milestone.total == 0 ? 0.0 : milestone.pct / 100.0;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.flag_outlined, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
milestone.title,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.secondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${milestone.completed}/${milestone.total}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: pct,
|
||||
minHeight: 3,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
backgroundColor:
|
||||
colorScheme.secondaryContainer.withValues(alpha: 0.4),
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(colorScheme.secondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoMilestoneHeader extends StatelessWidget {
|
||||
const _NoMilestoneHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
|
||||
child: Text(
|
||||
'No milestone',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _TaskRow extends StatelessWidget {
|
||||
final Task task;
|
||||
const _TaskRow({required this.task});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isInProgress = task.status == TaskStatus.inProgress;
|
||||
final dotColor = isInProgress ? colorScheme.primary : colorScheme.outline;
|
||||
final priorityColor = switch (task.priority) {
|
||||
TaskPriority.high => Colors.red,
|
||||
TaskPriority.medium => Colors.orange,
|
||||
TaskPriority.low => Colors.blue,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.fromLTRB(56, 0, 16, 0),
|
||||
leading: Icon(
|
||||
isInProgress ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: dotColor,
|
||||
),
|
||||
title: Text(task.title, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
subtitle: task.dueDate != null
|
||||
? Text(
|
||||
_formatDue(task.dueDate!),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _isDueOverdue(task.dueDate!)
|
||||
? colorScheme.error
|
||||
: colorScheme.outline,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: priorityColor != null
|
||||
? Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: priorityColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => context.push(
|
||||
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDue(DateTime due) {
|
||||
final now = DateTime.now();
|
||||
final diff = due.difference(DateTime(now.year, now.month, now.day)).inDays;
|
||||
if (diff == 0) return 'Due today';
|
||||
if (diff == 1) return 'Due tomorrow';
|
||||
if (diff < 0) return 'Overdue ${(-diff)} day${(-diff) == 1 ? '' : 's'}';
|
||||
if (diff < 7) return 'Due in $diff days';
|
||||
return 'Due ${due.month}/${due.day}';
|
||||
}
|
||||
|
||||
bool _isDueOverdue(DateTime due) {
|
||||
final now = DateTime.now();
|
||||
return due.isBefore(DateTime(now.year, now.month, now.day));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add task row ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _AddTaskRow extends StatelessWidget {
|
||||
final Project project;
|
||||
const _AddTaskRow({required this.project});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(48, 4, 16, 8),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.push('${Routes.taskNew}?projectId=${project.id}'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('New task'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.outline,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status chip ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final String status;
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (status) {
|
||||
'completed' => ('Done', Colors.green),
|
||||
'archived' => ('Archived', Colors.grey),
|
||||
_ => ('Active', Theme.of(context).colorScheme.primary),
|
||||
};
|
||||
return Chip(
|
||||
label: Text(label, style: const TextStyle(fontSize: 11)),
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
||||
backgroundColor: color.withValues(alpha: 0.1),
|
||||
labelStyle: TextStyle(color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Create project dialog ─────────────────────────────────────────────────────
|
||||
|
||||
class _CreateProjectDialog extends StatefulWidget {
|
||||
final Future<void> Function(String title, String? description, String? goal)
|
||||
onCreate;
|
||||
|
||||
const _CreateProjectDialog({required this.onCreate});
|
||||
|
||||
@override
|
||||
State<_CreateProjectDialog> createState() => _CreateProjectDialogState();
|
||||
}
|
||||
|
||||
class _CreateProjectDialogState extends State<_CreateProjectDialog> {
|
||||
final _titleController = TextEditingController();
|
||||
final _descController = TextEditingController();
|
||||
final _goalController = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_descController.dispose();
|
||||
_goalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final title = _titleController.text.trim();
|
||||
if (title.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await widget.onCreate(
|
||||
title,
|
||||
_descController.text.trim().isEmpty ? null : _descController.text.trim(),
|
||||
_goalController.text.trim().isEmpty ? null : _goalController.text.trim(),
|
||||
);
|
||||
if (mounted) Navigator.pop(context);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('New Project'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _descController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _goalController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Goal (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _submit,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user