<!-- 新增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 專案架構】](https://hackmd.io/@allianRZ/HJtfGSzPC)中建議的架構,讓我們稍微分析一下目前的程式,並將程式重構 (refactoring)。
首先新增資料夾與檔案:

接著改一下程式碼
`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。
先新增資料夾與文件:

`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-第二集)示範的功能,看著自己的專案慢慢成長還是很有成就感的,沒意外我會繼續想可以幫這專案加甚麼新功能,然後寫成文章分享,感恩你的觀看!