diff --git a/.vscode/launch.json b/.vscode/launch.json index 9f28fa7b..4ff70b67 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,6 +18,13 @@ "program": "lib/showcase/airbnb_mobile_app.dart", "cwd": "./cookbook" }, + { + "name": "Todo List", + "request": "launch", + "type": "dart", + "program": "lib/showcase/todo_list/main.dart", + "cwd": "./cookbook" + }, { "name": "Scrollable Sheet", "request": "launch", diff --git a/cookbook/lib/showcase/todo_list.dart b/cookbook/lib/showcase/todo_list.dart deleted file mode 100644 index 88d5f4da..00000000 --- a/cookbook/lib/showcase/todo_list.dart +++ /dev/null @@ -1,3 +0,0 @@ -/** - * WORK IN PROGRESS - */ diff --git a/cookbook/lib/showcase/todo_list/main.dart b/cookbook/lib/showcase/todo_list/main.dart new file mode 100644 index 00000000..628a762f --- /dev/null +++ b/cookbook/lib/showcase/todo_list/main.dart @@ -0,0 +1,171 @@ +import 'package:cookbook/showcase/todo_list/models.dart'; +import 'package:cookbook/showcase/todo_list/todo_editor.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(const _TodoListExample()); +} + +class _TodoListExample extends StatelessWidget { + const _TodoListExample(); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: _Home()); + } +} + +class _Home extends StatefulWidget { + const _Home(); + + @override + State<_Home> createState() => _HomeState(); +} + +class _HomeState extends State<_Home> { + late final TodoList _todoList; + + @override + void initState() { + super.initState(); + _todoList = TodoList(); + } + + @override + void dispose() { + _todoList.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Todo List'), + ), + body: _TodoListView(todoList: _todoList), + floatingActionButton: FloatingActionButton( + onPressed: () => addTodo(), + child: const Icon(Icons.add), + ), + ); + } + + Future addTodo() async { + final todo = await showTodoEditor(context); + if (todo != null) { + _todoList.add(todo); + } + } +} + +class _TodoListView extends StatelessWidget { + const _TodoListView({ + required this.todoList, + }); + + final TodoList todoList; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: todoList, + builder: (context, _) { + return ListView.separated( + itemCount: todoList.length, + itemBuilder: (context, index) { + return _TodoListViewItem( + todo: todoList[index], + checkboxCallback: (value) => todoList.toggle(index), + ); + }, + separatorBuilder: (context, index) { + return const Divider(indent: 24); + }, + ); + }, + ); + } +} + +class _TodoListViewItem extends StatelessWidget { + const _TodoListViewItem({ + Key? key, + required this.todo, + required this.checkboxCallback, + }) : super(key: key); + + final Todo todo; + final ValueChanged checkboxCallback; + + @override + Widget build(BuildContext context) { + final statuses = [ + if (todo.priority != Priority.none) + _StatusChip( + icon: Icons.flag, + color: todo.priority.color, + label: todo.priority.displayName, + ), + ]; + + final description = switch (todo.description) { + null => null, + final text => Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Colors.black54), + ), + }; + + final secondaryContent = [ + if (description != null) ...[description, const SizedBox(height: 8)], + if (statuses.isNotEmpty && !todo.isDone) Wrap(children: statuses), + ]; + + return CheckboxListTile( + value: todo.isDone, + controlAffinity: ListTileControlAffinity.leading, + onChanged: checkboxCallback, + title: Text(todo.title), + isThreeLine: secondaryContent.isNotEmpty, + subtitle: switch (secondaryContent.isNotEmpty) { + true => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: secondaryContent, + ), + false => null, + }, + ); + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({ + required this.icon, + required this.color, + required this.label, + }); + + final IconData icon; + final Color? color; + final String label; + + @override + Widget build(BuildContext context) { + return Chip( + avatar: Icon(icon, color: color), + label: Text(label), + padding: EdgeInsets.zero, + labelPadding: const EdgeInsets.only(right: 12), + visualDensity: const VisualDensity( + vertical: -4, + horizontal: -4, + ), + ); + } +} diff --git a/cookbook/lib/showcase/todo_list/models.dart b/cookbook/lib/showcase/todo_list/models.dart new file mode 100644 index 00000000..4dae7211 --- /dev/null +++ b/cookbook/lib/showcase/todo_list/models.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +// TODO: Add 'reminder' and 'due date' fields. +class Todo { + final String title; + final String? description; + final Priority priority; + final bool isDone; + + const Todo({ + required this.title, + this.description, + this.priority = Priority.none, + this.isDone = false, + }); +} + +enum Priority { + high(displayName: 'High Priority', color: Colors.red), + medium(displayName: 'Medium Priority', color: Colors.orange), + low(displayName: 'Low Priority', color: Colors.blue), + none(displayName: 'No Priority'); + + const Priority({ + required this.displayName, + this.color, + }); + + final String displayName; + final Color? color; +} + +class TodoList extends ChangeNotifier { + final List _todos = []; + + int get length => _todos.length; + + Todo operator [](int index) => _todos[index]; + + void add(Todo todo) { + _todos.insert(0, todo); + notifyListeners(); + } + + void toggle(int index) { + final todo = _todos[index]; + _todos[index] = Todo( + title: todo.title, + description: todo.description, + priority: todo.priority, + isDone: !todo.isDone, + ); + notifyListeners(); + } +} diff --git a/cookbook/lib/showcase/todo_list/todo_editor.dart b/cookbook/lib/showcase/todo_list/todo_editor.dart new file mode 100644 index 00000000..5924c29d --- /dev/null +++ b/cookbook/lib/showcase/todo_list/todo_editor.dart @@ -0,0 +1,306 @@ +import 'package:cookbook/showcase/todo_list/models.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:smooth_sheets/smooth_sheets.dart'; + +Future showTodoEditor(BuildContext context) { + return Navigator.push( + context, + ModalSheetRoute( + builder: (context) => const TodoEditor(), + ), + ); +} + +class TodoEditor extends StatefulWidget { + const TodoEditor({super.key}); + + @override + State createState() => _TodoEditorState(); +} + +class _TodoEditorState extends State { + late final _EditingController controller; + + @override + void initState() { + super.initState(); + controller = _EditingController(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final titleInput = _MultiLineInput( + hintText: 'Title', + autofocus: true, + style: Theme.of(context).textTheme.titleLarge, + textInputAction: TextInputAction.next, + onChanged: (value) => controller.title.value = value, + ); + + final descriptionInput = _MultiLineInput( + hintText: 'Description', + style: Theme.of(context).textTheme.bodyLarge, + textInputAction: TextInputAction.newline, + onChanged: (value) => controller.description.value = value, + ); + + final attributes = Row( + mainAxisSize: MainAxisSize.min, + children: [ + _DateTimePicker(controller), + const SizedBox(width: 8), + _PrioritySelector(controller), + const SizedBox(width: 8), + _Reminder(controller), + ], + ); + + final body = SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: titleInput, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: descriptionInput, + ), + const SizedBox(height: 16), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: attributes, + ), + ], + ), + ); + + final bottomBar = BottomAppBar( + child: Row( + children: [ + _FolderSelector(controller), + const Spacer(), + _SubmitButton(controller), + ], + ), + ); + + const sheetShape = ShapeDecoration( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + ); + + return SafeArea( + bottom: false, + child: ScrollableSheet( + keyboardDismissBehavior: const SheetKeyboardDismissBehavior.onDragDown( + isContentScrollAware: true, + ), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: sheetShape, + child: SheetContentScaffold( + body: body, + bottomBar: bottomBar, + ), + ), + ), + ); + } +} + +class _MultiLineInput extends StatelessWidget { + const _MultiLineInput({ + required this.hintText, + this.onChanged, + this.style, + this.textInputAction, + this.autofocus = false, + }); + + final String hintText; + final ValueChanged? onChanged; + final TextStyle? style; + final TextInputAction? textInputAction; + final bool autofocus; + + @override + Widget build(BuildContext context) { + return TextField( + autofocus: autofocus, + maxLines: null, + textInputAction: textInputAction, + onChanged: onChanged, + style: style, + decoration: InputDecoration( + hintText: hintText, + border: InputBorder.none, + ), + ); + } +} + +class _SubmitButton extends StatelessWidget { + const _SubmitButton(this.controller); + + final _EditingController controller; + + void onPressed(BuildContext context) { + final todo = controller.compose(); + Navigator.pop(context, todo); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.canCompose, + builder: (context, canCompose, _) { + return IconButton.filled( + onPressed: canCompose ? () => onPressed(context) : null, + icon: const Icon(Icons.arrow_upward), + tooltip: 'Submit', + ); + }, + ); + } +} + +const _prioritySelectorPopupMenuHeight = 160.0; + +class _PrioritySelector extends StatelessWidget { + const _PrioritySelector(this.controller); + + final _EditingController controller; + + @override + Widget build(BuildContext context) { + return MenuAnchor( + alignmentOffset: const Offset(0, -_prioritySelectorPopupMenuHeight), + style: MenuStyle( + alignment: Alignment.bottomLeft, + maximumSize: MaterialStateProperty.all( + const Size.fromHeight(_prioritySelectorPopupMenuHeight), + ), + ), + menuChildren: [ + for (final priority in Priority.values) + MenuItemButton( + leadingIcon: buildFlagIcon(priority), + child: Text(priority.displayName), + onPressed: () => controller.priority.value = priority, + ), + ], + builder: (context, menuController, _) { + return ValueListenableBuilder( + valueListenable: controller.priority, + builder: (context, priority, _) { + return ActionChip( + avatar: buildFlagIcon(priority), + label: Text(priority.displayName), + onPressed: () { + if (menuController.isOpen) { + menuController.close(); + } else { + menuController.open(); + } + }, + ); + }, + ); + }, + ); + } + + Widget buildFlagIcon(Priority priority) { + return switch (priority.color) { + null => const Icon(Icons.flag_outlined), + final color => Icon(Icons.flag, color: color), + }; + } +} + +class _DateTimePicker extends StatelessWidget { + const _DateTimePicker(this.controller); + + final _EditingController controller; + + @override + Widget build(BuildContext context) { + return ActionChip( + avatar: const Icon(Icons.event), + label: const Text('No date'), + onPressed: () {}, + ); + } +} + +class _Reminder extends StatelessWidget { + const _Reminder(this.controller); + + final _EditingController controller; + + @override + Widget build(BuildContext context) { + return ActionChip( + avatar: const Icon(Icons.alarm), + label: const Text('Reminder'), + onPressed: () {}, + ); + } +} + +class _FolderSelector extends StatelessWidget { + const _FolderSelector(this.controller); + + final _EditingController controller; + + @override + Widget build(BuildContext context) { + return TextButton.icon( + onPressed: () {}, + icon: const Icon(Icons.folder_outlined), + label: const Row( + children: [ + Text('Inbox'), + SizedBox(width: 16), + Icon(Icons.arrow_drop_down), + ], + ), + ); + } +} + +class _EditingController extends ChangeNotifier { + _EditingController() { + title.addListener(() { + _canCompose.value = title.value?.isNotEmpty == true; + }); + } + + final title = ValueNotifier(null); + final description = ValueNotifier(null); + final priority = ValueNotifier(Priority.none); + + final _canCompose = ValueNotifier(false); + ValueListenable get canCompose => _canCompose; + + Todo compose() { + assert(title.value != null); + return Todo( + title: title.value!.trim(), + description: description.value?.trim(), + priority: priority.value, + ); + } +} diff --git a/package/README.md b/package/README.md index c0c88e85..861bdfd2 100644 --- a/package/README.md +++ b/package/README.md @@ -51,7 +51,21 @@ + + + +

Todo List

+

A simple Todo app that shows how a sheet handles the on-screen keyboard. See the cookbook for more details.

+

Used components:

+
    +
  • ScrollableSheet
  • +
  • SheetContentScaffold
  • +
  • SheetKeyboardDismissBehavior
  • +
+ + +
## Why use this? diff --git a/resources/cookbook-todo-list.gif b/resources/cookbook-todo-list.gif new file mode 100644 index 00000000..96cb6706 Binary files /dev/null and b/resources/cookbook-todo-list.gif differ