<!-- 新增models資料夾並且把定義好的TodoData搬過去 新增screens資料夾,並新增一個檔案 display_todo_screen.dart 在display_todo_screen.dart import TodoData --> # 不專業教學系列 - Flutter To-Do List 第二集 讓專案更完整 >tag: `Flutter`、`Dart` 前幾天我有發了兩篇文章[【不專業教學系列 - Flutter To-Do List】](https://hackmd.io/@allianRZ/HydmRmbvC)還有[【不專業教學系列 - Flutter 專案架構】](https://hackmd.io/@allianRZ/HJtfGSzPC),今天這篇文章就是要結合前兩天的文章,改進專案的架構,並增加新功能,以下是今日目標: - 重新設計專案結構 - 增加 `檢視已完成事項` 功能 完成今天的目標後,你應該可以完成以下畫面的功能: ![flutter_todo_app_l2_demo](https://hackmd.io/_uploads/rJbnR15fDR.gif) ## 重新安排資料夾結構 根據上一篇文章[【不專業教學系列 - Flutter 專案架構】](https://hackmd.io/@allianRZ/HJtfGSzPC)中建議的架構,讓我們稍微分析一下目前的程式,並將程式重構 (refactoring)。 首先新增資料夾與檔案: ![image](https://hackmd.io/_uploads/r18KMOMvA.png) 接著改一下程式碼 `main.dart` ```dart // main.dart import 'package:flutter/material.dart'; import 'package:todo_app/screens/display_todo_screen.dart'; void main() { runApp(MainApp()); } class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { return const MaterialApp( home: DisplayTodoScreen(), ); } } ``` `display_todo_screen.dart` ```dart // display_todo_screen.dart import 'package:flutter/material.dart'; import 'package:todo_app/models/todo_data.dart'; class DisplayTodoScreen extends StatefulWidget { const DisplayTodoScreen({super.key}); @override State<DisplayTodoScreen> createState() { return _DisplayTodoListState(); } } class _DisplayTodoListState extends State<DisplayTodoScreen> { final List<TodoData> todoList = []; final TodoData todo = TodoData(title: "title", content: "content", isChecked: false); final TextEditingController _todoTitleController = TextEditingController(); final TextEditingController _todoContentController = TextEditingController(); @override void initState() { super.initState(); init(); } void init() { todoList.add(todo); } @override Widget build(BuildContext context) { return Scaffold( body: Column( children: <Widget>[ Expanded( child: _showTodos(), ), _complectedButton(), ], ), floatingActionButton: FloatingActionButton( backgroundColor: Colors.blueGrey.shade600, onPressed: () { showDialog( context: context, builder: (context) { return AlertDialog( title: const Text("新增待辦事項", style: TextStyle(fontWeight: FontWeight.bold)), content: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ TextField( controller: _todoTitleController, decoration: const InputDecoration(hintText: "待辦事項名稱"), ), TextField( controller: _todoContentController, decoration: const InputDecoration(hintText: "待辦事項內容"), ), ], ), actions: <Widget>[ ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.blueGrey.shade600, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), ), onPressed: () { Navigator.pop(context); _todoTitleController.clear(); _todoContentController.clear(); }, child: const Text( "取消", style: TextStyle(color: Colors.white), ), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.blueGrey.shade600, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), ), onPressed: () { TodoData todo = TodoData( title: _todoTitleController.text, content: _todoContentController.text, isChecked: false, ); setState(() { todoList.add(todo); _todoTitleController.clear(); _todoContentController.clear(); }); Navigator.pop(context); }, child: const Text( "新增", style: TextStyle(color: Colors.white), ), ), ], ); }, ); }, child: const Icon(Icons.playlist_add_rounded, color: Colors.white), ), ); } Widget _showTodos() { return ListView.builder( itemCount: todoList.length, itemBuilder: (BuildContext context, int index) { return Column( children: <Widget>[ ListTile( title: Text(todoList[index].title, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 20)), subtitle: Text(todoList[index].content, style: const TextStyle(fontSize: 16)), leading: Checkbox( activeColor: Colors.blueGrey.shade600, checkColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), value: todoList[index].isChecked, onChanged: (bool? value) { setState(() { todoList[index].isChecked = value!; }); }, ), trailing: IconButton( icon: const Icon(Icons.delete, color: Colors.blueGrey), onPressed: () { setState(() { todoList.removeAt(index); }); }, ), ), const Divider(), ], ); }, ); } Widget _complectedButton() { return Padding( padding: const EdgeInsets.only(top: 10, bottom: 22), child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.blueGrey.shade600, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), minimumSize: const Size(130, 50), maximumSize: const Size(130, 50), ), onPressed: () { setState(() { todoList.removeWhere((item) => item.isChecked == true); }); }, child: const Text( "清除完成事項", style: TextStyle(color: Colors.white, fontSize: 12), )), ); } } ``` `todo_data.dart` ```dart // todo_data.dart class TodoData { String title; String content; bool isChecked; TodoData({ required this.title, required this.content, required this.isChecked, }); } ``` 可以看到我將程式碼分成三個部分,`main.dart`是整個程式的進入點,`display_todo_screen.dart`顯示了主畫面,`todo_data.dart`負責定義資料格式。 ## 新增已完成式項的畫面 目標是將已完成的`todo`顯示在另一個畫面,要達成這個功能可以使用`BottomNavigationBar`在程式下方新增導航列來切換畫面,關於`BottomNavigationBar`可以參考[官方文件](https://api.flutter.dev/flutter/material/BottomNavigationBar-class.html)。 先對`main.dart`做更動,因為導航列負責控制面的切換,所以要放在這裡。 ```dart class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { return const MaterialApp( home: NavigationScreen(), ); } } class NavigationScreen extends StatefulWidget { const NavigationScreen({super.key}); @override State<NavigationScreen> createState() { return _NavigationState(); } } class _NavigationState extends State<NavigationScreen> { int _selectedIndex = 0; static const TextStyle optionStyle = TextStyle(fontSize: 30, fontWeight: FontWeight.bold); static const List<Widget> _widgetOptions = <Widget>[ DisplayTodoScreen(), Text( '已完成事項', style: optionStyle, ), ]; void _onItemTapped(int index) { setState(() { _selectedIndex = index; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Todo App', style: TextStyle(color: Colors.white)), backgroundColor: Colors.blueGrey.shade600, ), body: Center( child: _widgetOptions.elementAt(_selectedIndex), ), bottomNavigationBar: BottomNavigationBar( items: const <BottomNavigationBarItem>[ BottomNavigationBarItem( icon: Icon(Icons.playlist_play), label: '待辦事項', ), BottomNavigationBarItem( icon: Icon(Icons.playlist_add_check), label: '已完成事項', ), ], currentIndex: _selectedIndex, selectedItemColor: Colors.blueGrey[600], onTap: _onItemTapped, ), ); } } ``` 在這裡,我們新增了`List<Widget> _widgetOptions`,我們將在這清單中增加要導航的畫面,接著定義了一個Function`_onItemTapped($index)`來控制被選中的索引`_selectedIndex`。 在`Scaffold`中使用`BottomNavigationBar`來增加底部導航列被按下時調用`_onItemTapped($index)`。 ## 讓資料在切換畫面時被保留 到了這裡你就可以在App中切換畫面了~~ 但是你會發現,當你切換過畫面後再切回來,資料通通都不見了!! 在Flutter中,如果要在多個畫面之間共享狀態,可以使用一些狀態管理工具,例如 Provider、Riverpod、Bloc 等。這些工具可以幫助你在不同的Widget中共享資料,而不會在畫面之間切換時丟失資料,本文使用官方推薦的Provider。 先新增資料夾與文件: ![image](https://hackmd.io/_uploads/S147SYGPR.png) `TodoProvider` 是一個繼承自 `ChangeNotifier` 的類,它包含了所有與 `todoList` 有關的業務邏輯。 ```dart // todo_provider.dart import 'package:flutter/foundation.dart'; import 'package:todo_app/models/todo_data.dart'; class TodoProvider extends ChangeNotifier { final List<TodoData> _todoList = []; List<TodoData> get todoList => _todoList; void addTodo(TodoData todo) { _todoList.add(todo); notifyListeners(); } void removeTodoAt(int index) { _todoList.removeAt(index); notifyListeners(); } void updateTodoCheck(int index, bool isChecked) { _todoList[index].isChecked = isChecked; notifyListeners(); } void clearCompleted() { _todoList.removeWhere((item) => item.isChecked == true); notifyListeners(); } } ``` 接著更新`main.dart`。在這裡,我們使用 `ChangeNotifierProvider` 將 `TodoProvider` 注入到 Widget 樹中。`ChangeNotifierProvider` 是 `provider` 包的一部分,它將 `TodoProvider` 提供給 Widget 樹中的所有子 Widget。 ```dart // main.dart class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (context) => TodoProvider(), child: const MaterialApp( home: NavigationScreen(), ), ); } } ``` 在 Widget 中使用 `provider`,通過 `Provider.of<TodoProvider>(context)` 獲取 `TodoProvider` 的實例,並使用它來訪問和操作 `todoList`。當 `todoList` 發生變化時,`notifyListeners` 方法會通知所有依賴 `TodoProvider` 的 Widget 更新它們的狀態。這樣就實現了在不同畫面之間共享狀態,而不會在畫面之間切換時失去資料。 ```dart // display_todo_screen.dart class _DisplayTodoListState extends State<DisplayTodoScreen> { final TextEditingController _todoTitleController = TextEditingController(); final TextEditingController _todoContentController = TextEditingController(); @override Widget build(BuildContext context) { return Scaffold( body: Column( children: <Widget>[ Expanded( child: _showTodos(), ), _complectedButton(), ], ), floatingActionButton: FloatingActionButton( backgroundColor: Colors.blueGrey.shade600, onPressed: () { showDialog( context: context, builder: (context) { return AlertDialog( title: const Text("新增待辦事項", style: TextStyle(fontWeight: FontWeight.bold)), content: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ TextField( controller: _todoTitleController, decoration: const InputDecoration(hintText: "待辦事項名稱"), ), TextField( controller: _todoContentController, decoration: const InputDecoration(hintText: "待辦事項內容"), ), ], ), actions: <Widget>[ ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.blueGrey.shade600, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), ), onPressed: () { Navigator.pop(context); _todoTitleController.clear(); _todoContentController.clear(); }, child: const Text( "取消", style: TextStyle(color: Colors.white), ), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.blueGrey.shade600, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), ), onPressed: () { // 改用 Provider 來新增待辦事項,新增資料後providr會通知所有訂閱者進行更新 final provider = Provider.of<TodoProvider>(context, listen: false); provider.addTodo(TodoData( title: _todoTitleController.text, content: _todoContentController.text, isChecked: false, )); _todoTitleController.clear(); _todoContentController.clear(); Navigator.pop(context); }, child: const Text( "新增", style: TextStyle(color: Colors.white), ), ), ], ); }, ); }, child: const Icon(Icons.playlist_add_rounded, color: Colors.white), ), ); } Widget _showTodos() { // 取得 Provider 中的資料 final provider = Provider.of<TodoProvider>(context); return ListView.builder( itemCount: provider.todoList.length, itemBuilder: (BuildContext context, int index) { return Column( children: <Widget>[ ListTile( // 改用 Provider 來顯示待辦事項 title: Text(provider.todoList[index].title, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 20)), subtitle: Text(provider.todoList[index].content, style: const TextStyle(fontSize: 16)), leading: Checkbox( activeColor: Colors.blueGrey.shade600, checkColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), value: provider.todoList[index].isChecked, onChanged: (bool? value) { // 呼叫 Provider 來更新待辦事項的狀態 provider.updateTodoCheck(index, value!); }, ), trailing: IconButton( icon: const Icon(Icons.delete, color: Colors.blueGrey), onPressed: () { provider.removeTodoAt(index); }, ), ), const Divider(), ], ); }, ); } Widget _complectedButton() { return Padding( padding: const EdgeInsets.only(top: 10, bottom: 22), child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.blueGrey.shade600, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), minimumSize: const Size(130, 50), maximumSize: const Size(130, 50), ), onPressed: () { // 呼叫 Provider 來清除已完成的待辦事項 Provider.of<TodoProvider>(context, listen: false).clearCompleted(); }, child: const Text( "清除完成事項", style: TextStyle(color: Colors.white, fontSize: 12), )), ); } } ``` ## 製作已完成事項的UI 讓我們修改一下`TodoProvider`,將已完成的事項加入新的清單`completedList`,方便`ListView`使用 : ```dart // todo_provider.dart class TodoProvider extends ChangeNotifier { final List<TodoData> _todoList = []; final List<TodoData> _completedList = []; ... void clearCompleted() { _todoList.map((item) { if (item.isChecked == true) { _completedList.add(item); } }).toList(); _todoList.removeWhere((item) => item.isChecked == true); notifyListeners(); } } ``` 新增畫面`display_completed_screen.dart`,渲染已完成事項的UI : ```dart // display_completed_screen.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:todo_app/providers/todo_provider.dart'; class DisplayCompletedScreen extends StatefulWidget { const DisplayCompletedScreen({super.key}); @override State<DisplayCompletedScreen> createState() { return _DisplayCompletedState(); } } class _DisplayCompletedState extends State<DisplayCompletedScreen> { @override Widget build(BuildContext context) { return Scaffold( body: _showCompleted(), ); } Widget _showCompleted() { // 取得 Provider 中的資料 final provider = Provider.of<TodoProvider>(context); return ListView.builder( itemCount: provider.completedList.length, itemBuilder: (BuildContext context, int index) { return Column( children: <Widget>[ ListTile( // 改用 Provider 來顯示待辦事項 title: Text(provider.completedList[index].title, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 20)), subtitle: Text(provider.completedList[index].content, style: const TextStyle(fontSize: 16)), ), const Divider(), ], ); }, ); } } ``` 最後在主畫面中調用`DisplayCompletedScreen` : ```dart // main.dart ... static const List<Widget> _widgetOptions = <Widget>[ DisplayTodoScreen(), DisplayCompletedScreen(), ]; ... ``` 完成! ## 結語 到了這一步,你應該已經完成了如開頭的[Demo](#不專業教學系列---Flutter-To-Do-List-第二集)示範的功能,看著自己的專案慢慢成長還是很有成就感的,沒意外我會繼續想可以幫這專案加甚麼新功能,然後寫成文章分享,感恩你的觀看!