iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
自我挑戰組

攜手 AI 從零開始打造一款 Flutter 應用程式系列 第 14

Day 14: 補完 CRUD 最後一哩路 - 實作更新與滑動刪除

  • 分享至 

  • xImage
  •  

前言

大家好!在 Day 13,我們進行了一次重要的「期中重構」,成功地將 UI 邏輯與 Firestore 的資料操作分離,讓 App 的架構變得清晰且健壯。

目前,我們的 App 已經可以新增 (Create) 和讀取 (Read) 消費紀錄。今天,我們將補完 CRUD (Create, Read, Update, Delete) 的最後一哩路,實作更新 (Update) 與 刪除 (Delete) 功能。

我們將學習如何:

  1. 修改一筆已經存在的消費紀錄。
  2. 整合 Flutter 內建的 Dismissible Widget,實現手機 App 中最常見的「滑動刪除」互動效果。

完成後,「省錢拍拍」的核心資料管理功能將正式宣告完備!

Step 1: D for Delete - 在 FirestoreService 新增刪除方法

與新增和讀取一樣,我們首先在我們的「數據管家」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 或其他錯誤處理機制
    }
  }
}

刪除操作很單純:我們只需要根據 userIdtransactionId 定位到那份唯一的文件,然後呼叫 .delete() 即可。

Step 2: 整合滑動刪除 - Dismissible Widget

接下來是今天最有趣的部分!我們要讓列表中的每個項目都可以被滑動刪除。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)}'),
    ),
  ),
);

程式碼解析:

  1. key (最重要): Dismissible 需要一個 獨特的 Key 來在 Widget 樹中識別每一個項目。我們模型的 transaction.id 是來自 Firestore 的唯一文件 ID,是 key 的完美選擇。
  2. onDismissed: 這是核心邏輯所在。當動畫結束時,我們呼叫 deleteTransaction 方法。因為 HomePage 的列表是由 StreamBuilder 驅動的,當 Firestore 的資料被刪除後,StreamBuilder 會自動收到更新並重繪 UI,列表項就會消失!
  3. background: 這是為了提升使用者體驗,讓使用者明確知道滑動是刪除操作。

重新啟動 App 後,試著滑動一筆紀錄看看效果

https://ithelp.ithome.com.tw/upload/images/20250928/20163912NnsdwLRHYN.png

Step 3: U for Update - 在 FirestoreService 新增更新方法

更新功能的邏輯與新增和刪除類似。首先,在 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,其中只包含你想要變更的欄位即可。

Step 4: 整合更新功能 - 讓表單頁面可重用

我們不希望再創建一個全新的「編輯頁面」,最好的方式是讓 AddTransactionPage 能夠同時處理「新增」和「編輯」兩種模式。

  1. 改造 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( /* ... 表單內容不變 ... */ ),
    );
  }
}
  1. 從 HomePage 觸發編輯模式
    回到 _HomePageStateListTile,為它加上 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 的所有功能,讓「省錢拍拍」成為一個功能完整的資料管理應用。我們學會了:

  1. 如何在 FirestoreService 中實作 deleteupdate 方法。
  2. 使用 Dismissible Widget 實現了流暢的滑動刪除手勢。
  3. 透過傳遞參數,讓同一個表單頁面(AddTransactionPage)能夠聰明地處理「新增」和「編輯」兩種不同情境。

App 的核心功能開發已告一段落。我們的應用程式結構清晰、功能完備,並由雲端資料庫即時驅動。


上一篇
Day 13: 期中整合與重構 - 分離 UI 與業務邏輯
下一篇
Day 15: 智慧掃描第一步 - 整合 image_picker 喚醒相機
系列文
攜手 AI 從零開始打造一款 Flutter 應用程式20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言