大家好!在 Day 13,我們進行了一次重要的「期中重構」,成功地將 UI 邏輯與 Firestore 的資料操作分離,讓 App 的架構變得清晰且健壯。
目前,我們的 App 已經可以新增 (Create) 和讀取 (Read) 消費紀錄。今天,我們將補完 CRUD (Create, Read, Update, Delete) 的最後一哩路,實作更新 (Update) 與 刪除 (Delete) 功能。
我們將學習如何:
Dismissible
Widget,實現手機 App 中最常見的「滑動刪除」互動效果。完成後,「省錢拍拍」的核心資料管理功能將正式宣告完備!
與新增和讀取一樣,我們首先在我們的「數據管家」FirestoreService
中定義好刪除資料的方法。
打開 lib/services/firestore_service.dart
,加入 deleteTransaction
方法:
class FirestoreService {
// ... 其他方法 ...
// D for Delete: 刪除一筆交易紀錄
Future<void> deleteTransaction({
required String userId,
required String transactionId, // 指定要刪除的文件 ID
}) async {
try {
await _usersCollection
.doc(userId)
.collection('transactions')
.doc(transactionId) // 指定要刪除的文件 ID
.delete();
} catch (e) {
print('刪除 Transaction 失敗: $e');
// 可以加上 rethrow 或其他錯誤處理機制
}
}
}
刪除操作很單純:我們只需要根據 userId
和 transactionId
定位到那份唯一的文件,然後呼叫 .delete()
即可。
接下來是今天最有趣的部分!我們要讓列表中的每個項目都可以被滑動刪除。Flutter 提供了一個完美的 Widget 來實現這個功能:Dismissible
。
回到 lib/main.dart
,找到 _HomePageState
中的 ListView.builder
,我們需要用 Dismissible
來包裹 Card
。
// lib/main.dart -> _HomePageState -> ListView.builder -> itemBuilder
// ...
final transaction = Transaction.fromFirestore(transactionDocs[index]);
// 用 Dismissible 包裹 Card
return Dismissible(
// 1. key: 每一項都必須有一個獨一無二的 Key
key: Key(transaction.id),
// 2. onDismissed: 當項目被滑動移除後觸發的回呼
onDismissed: (direction) {
// 呼叫我們的 service 來從 Firestore 刪除資料
_firestoreService.deleteTransaction(
userId: user!.uid,
transactionId: transaction.id,
);
// 可以在這裡顯示一個 SnackBar 提示使用者
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${transaction.title} 已刪除')),
);
},
// 3. background: 滑動時,底下顯示的背景
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: const Icon(Icons.delete, color: Colors.white),
),
// 4. direction: 設定滑動方向 (預設可以雙向滑動)
direction: DismissDirection.endToStart, // 只允許從右向左滑動
// 5. child: 我們原本的 Card 元件
child: Card(
child: ListTile(
title: Text(transaction.title),
subtitle: Text(transaction.category),
trailing: Text('NT\$ ${transaction.amount.toStringAsFixed(0)}'),
),
),
);
程式碼解析:
key
(最重要): Dismissible
需要一個 獨特的 Key
來在 Widget 樹中識別每一個項目。我們模型的 transaction.id
是來自 Firestore 的唯一文件 ID,是 key
的完美選擇。onDismissed
: 這是核心邏輯所在。當動畫結束時,我們呼叫 deleteTransaction
方法。因為 HomePage
的列表是由 StreamBuilder
驅動的,當 Firestore 的資料被刪除後,StreamBuilder
會自動收到更新並重繪 UI,列表項就會消失!background
: 這是為了提升使用者體驗,讓使用者明確知道滑動是刪除操作。重新啟動 App 後,試著滑動一筆紀錄看看效果
更新功能的邏輯與新增和刪除類似。首先,在 FirestoreService
中定義 updateTransaction
方法。
// lib/services/firestore_service.dart
class FirestoreService {
// ... 其他方法 ...
// U for Update: 更新一筆交易紀錄
Future<void> updateTransaction({
required String userId,
required String transactionId,
required String newTitle, // 要更新的欄位
required double newAmount, // 要更新的欄位
}) async {
try {
await _usersCollection
.doc(userId)
.collection('transactions')
.doc(transactionId)
.update({
'title': newTitle,
'amount': newAmount,
// 我們可以選擇不更新日期或其他欄位
});
} catch (e) {
print('更新 Transaction 失敗: $e');
}
}
}
更新操作使用 .update()
方法,傳入一個 Map
,其中只包含你想要變更的欄位即可。
我們不希望再創建一個全新的「編輯頁面」,最好的方式是讓 AddTransactionPage
能夠同時處理「新增」和「編輯」兩種模式。
AddTransactionPage
Transaction
物件。如果收到了物件,就代表是「編輯模式」。// lib/add_transaction_page.dart
class AddTransactionPage extends StatefulWidget {
// 1. 新增一個可選的 transaction 參數
final Transaction? transaction;
const AddTransactionPage({super.key, this.transaction});
@override
State<AddTransactionPage> createState() => _AddTransactionPageState();
}
class _AddTransactionPageState extends State<AddTransactionPage> {
// ... controllers, formKey, service ...
// 2. 透過 getter 判斷是否為編輯模式,讓程式碼更語義化
bool get isEditMode => widget.transaction != null;
@override
void initState() {
super.initState();
// 3. 如果是編輯模式,將現有資料填入輸入框
if (isEditMode) {
_titleController.text = widget.transaction!.title;
_amountController.text = widget.transaction!.amount.toString();
}
}
Future<void> _submitForm() async {
if (_formKey.currentState!.validate()) {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
// 4. 根據模式呼叫不同的 service 方法
if (isEditMode) {
await _firestoreService.updateTransaction(
userId: user.uid,
transactionId: widget.transaction!.id,
newTitle: _titleController.text,
newAmount: double.parse(_amountController.text),
);
} else {
await _firestoreService.addTransaction(
userId: user.uid,
title: _titleController.text,
amount: double.parse(_amountController.text),
);
}
if (mounted) Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
// 5. AppBar 標題也根據模式改變
appBar: AppBar(
title: Text(isEditMode ? '編輯消費' : '新增消費'),
),
body: Form( /* ... 表單內容不變 ... */ ),
);
}
}
_HomePageState
的 ListTile
,為它加上 onTap
點擊事件。// lib/main.dart -> _HomePageState -> ListTile
// ...
child: ListTile(
title: Text(transaction.title),
subtitle: Text(transaction.category),
trailing: Text('NT\$ ${transaction.amount.toStringAsFixed(0)}'),
// 加上 onTap 事件
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
// 導航到 AddTransactionPage,並傳入當前的 transaction 物件
builder: (context) => AddTransactionPage(transaction: transaction),
),
);
},
),
// ...
現在,點擊列表中的任一項目時,會跳轉到同一個表單頁面,但輸入框中已經填好了該筆紀錄的資料,讓你進行編輯!
今天我們補齊了 CRUD 的所有功能,讓「省錢拍拍」成為一個功能完整的資料管理應用。我們學會了:
delete
和 update
方法。Dismissible
Widget 實現了流暢的滑動刪除手勢。AddTransactionPage
)能夠聰明地處理「新增」和「編輯」兩種不同情境。App 的核心功能開發已告一段落。我們的應用程式結構清晰、功能完備,並由雲端資料庫即時驅動。