iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
AI Engineering

從行程到應援:30 天做出追星實用工具箱系列 第 4

Day 4:模組三/追星花費紀錄與應援活動整理頁面

  • 分享至 

  • xImage
  •  

一、前言

追星除了花時間,錢包也很有事,周邊、小卡、應援棒買一買,很容易就不知道自己到底花了多少。今天要做的是記帳,自行管理花了多少錢,而且跟小卡資料分開存,最後還能一眼看到總共花了多少錢。

今天要做的事:

  1. 決定花費要記哪些欄位(項目、金額、分類、日期)
  2. 開一個新的地方存這些資料
  3. 做一個畫面可以新增、改金額、刪除,並顯示總花費

二、今日任務

  1. 建立設計花費 Data Model
import 'package:hive/hive.dart';

part 'expense_model.g.dart';

@HiveType(typeId: 1) // 注意 typeId 不能與 Day 3 的 CollectionItem (0) 重複!
class ExpenseItem extends HiveObject {
  @HiveField(0)
  String title; // 花費項目(如:演唱會 VIP 門票、五大唱片預購)

  @HiveField(1)
  double amount; // 金額

  @HiveField(2)
  String category; // 分類(門票 / 專輯 / 應援 / 周邊)

  @HiveField(3)
  DateTime date; // 日期

  ExpenseItem({
    required this.title,
    required this.amount,
    required this.category,
    required this.date,
  });
}

2. 在 main.dart 註冊與開啟 Expense Box

沿用昨天的lib/main.dart,加入 ExpenseItemAdapter 註冊與 Hive Box:

import 'features/expense/models/expense_model.dart';

  await Hive.openBox<ExpenseItem>('expense_box'); // 開啟花費資料庫

  1. 建立花費紀錄與應援整理介面
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'models/expense_model.dart';

class ExpensePage extends StatefulWidget {
  const ExpensePage({super.key});

  @override
  State<ExpensePage> createState() => _ExpensePageState();
}

class _ExpensePageState extends State<ExpensePage> {
  final TextEditingController _titleController = TextEditingController();
  final TextEditingController _amountController = TextEditingController();
  String _selectedCategory = '門票';
  late Box<ExpenseItem> _expenseBox;

  @override
  void initState() {
    super.initState();
    _expenseBox = Hive.box<ExpenseItem>('expense_box');
  }

  // C: Create (新增)
  void _addExpense() {
    final title = _titleController.text;
    final amount = double.tryParse(_amountController.text) ?? 0.0;

    if (title.isEmpty || amount <= 0) return;

    final newExpense = ExpenseItem(
      title: title,
      amount: amount,
      category: _selectedCategory,
      date: DateTime.now(),
    );

    _expenseBox.add(newExpense);
    _titleController.clear();
    _amountController.clear();
  }

  // U: Update (簡單修改金額範例)
  void _editExpenseAmount(ExpenseItem item) {
    final editController = TextEditingController(text: item.amount.toStringAsFixed(0));
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('修改「${item.title}」金額'),
        content: TextField(
          controller: editController,
          keyboardType: TextInputType.number,
          decoration: const InputDecoration(labelText: '新金額'),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('取消'),
          ),
          ElevatedButton(
            onPressed: () {
              final newAmount = double.tryParse(editController.text);
              if (newAmount != null) {
                item.amount = newAmount;
                item.save(); // 👈 HiveObject 直接 save() 即可完成 Update
                Navigator.pop(context);
              }
            },
            child: const Text('更新'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('追星花費與應援帳本')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            // 總金額顯示區塊
            ValueListenableBuilder(
              valueListenable: _expenseBox.listenable(),
              builder: (context, Box<ExpenseItem> box, _) {
                final total = box.values.fold<double>(
                  0.0, (sum, item) => sum + item.amount
                );
                return Card(
                  color: Colors.pink.shade50,
                  child: Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        const Text('追星累積總花費', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
                        Text(
                          'NT\$ ${total.toStringAsFixed(0)}',
                          style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.pink),
                        ),
                      ],
                    ),
                  ),
                );
              },
            ),
            const SizedBox(height: 15),

            // 新增輸入表單
            Row(
              children: [
                Expanded(
                  flex: 2,
                  child: TextField(
                    controller: _titleController,
                    decoration: const InputDecoration(labelText: '項目名稱 (如: 門票)', border: OutlineInputBorder()),
                  ),
                ),
                const SizedBox(width: 8),
                Expanded(
                  flex: 1,
                  child: TextField(
                    controller: _amountController,
                    keyboardType: TextInputType.number,
                    decoration: const InputDecoration(labelText: '金額', border: OutlineInputBorder()),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 10),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                DropdownButton<String>(
                  value: _selectedCategory,
                  items: ['門票', '專輯', '應援', '周邊'].map((String value) {
                    return DropdownMenuItem<String>(value: value, child: Text(value));
                  }).toList(),
                  onChanged: (val) => setState(() => _selectedCategory = val!),
                ),
                ElevatedButton.icon(
                  icon: const Icon(Icons.add_shopping_cart),
                  label: const Text('新增記帳'),
                  onPressed: _addExpense,
                ),
              ],
            ),
            const Divider(height: 30),

            // R: Read (列表展示) & D: Delete (刪除)
            Expanded(
              child: ValueListenableBuilder(
                valueListenable: _expenseBox.listenable(),
                builder: (context, Box<ExpenseItem> box, _) {
                  if (box.values.isEmpty) {
                    return const Center(child: Text('目前還沒有花費紀錄,皮包很安全!'));
                  }
                  return ListView.builder(
                    itemCount: box.length,
                    itemBuilder: (context, index) {
                      final item = box.getAt(index);
                      if (item == null) return const SizedBox();
                      return ListTile(
                        leading: CircleAvatar(
                          child: Text(item.category.substring(0, 1)),
                        ),
                        title: Text(item.title),
                        subtitle: Text(item.date.toString().split(' ')[0]),
                        trailing: Row(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Text(
                              'NT\$ ${item.amount.toStringAsFixed(0)}',
                              style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
                            ),
                            IconButton(
                              icon: const Icon(Icons.edit, color: Colors.blue),
                              onPressed: () => _editExpenseAmount(item), // U: Update
                            ),
                            IconButton(
                              icon: const Icon(Icons.delete, color: Colors.red),
                              onPressed: () => item.delete(), // D: Delete
                            ),
                          ],
                        ),
                      );
                    },
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

檢查

  • [x] 測試 Create:輸入名稱與金額,點擊新增

    螢幕擷取畫面 2026-09-09 162608

  • [x] 測試 Update:點擊藍色編輯按鈕,將金額改為7800

    螢幕擷取畫面 2026-09-09 162630

  • [x] 測試 Delete:點擊紅色垃圾桶按鈕,資料順利移除,總花費也同步扣除

    螢幕擷取畫面 2026-09-09 162649
    今日小結
    今天做完才發現,昨天做小卡用的那套方法,今天記帳整套搬過來就能用,步驟完全一樣,先想清楚資料要長什麼樣子、存進去、畫面秀出來、要改就存回去、要刪就刪掉。
    總金額那邊比較有趣,資料一變動,總數自己就重新算好顯示出來,不用另外寫一套加總邏輯去對,也不用擔心哪次忘了更新、算錯錢。


上一篇
Day 3:模組二/專輯與周邊收藏管理
下一篇
Day 5:如何用 Flutter打造Theme與配色
系列文
從行程到應援:30 天做出追星實用工具箱7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言