追星除了花時間,錢包也很有事,周邊、小卡、應援棒買一買,很容易就不知道自己到底花了多少。今天要做的是記帳,自行管理花了多少錢,而且跟小卡資料分開存,最後還能一眼看到總共花了多少錢。
今天要做的事:
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'); // 開啟花費資料庫
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:輸入名稱與金額,點擊新增

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

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

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