Add ItemType for determining which DB methods should be called based on the screen data. Begin implementing it.
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
// Flutter
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_expense_tracker/models/asset.dart';
|
||||
import 'package:flutter_expense_tracker/models/income.dart';
|
||||
|
||||
// Local
|
||||
import '/models/tracked_item.dart';
|
||||
import '/models/item_type.dart';
|
||||
import '/models/expense.dart';
|
||||
import '/models/frequency.dart';
|
||||
import '/db.dart';
|
||||
|
||||
// TODO: Make this a generic UI based on a superclass of Expense, Income, and Assets.
|
||||
|
||||
class TrackedItemPage extends StatefulWidget {
|
||||
final Future<List<TrackedItem>> assetsToLoad;
|
||||
|
||||
const TrackedItemPage({
|
||||
super.key,
|
||||
required this.assetsToLoad,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TrackedItemPage> createState() => _TrackedItemPageState();
|
||||
}
|
||||
|
||||
class _TrackedItemPageState extends State<TrackedItemPage> {
|
||||
refresh() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return FutureBuilder<List<TrackedItem>>(
|
||||
future: widget.assetsToLoad,
|
||||
builder:
|
||||
(BuildContext context, AsyncSnapshot<List<TrackedItem>> snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return Text('Loading...');
|
||||
}
|
||||
snapshot.data!.sort(
|
||||
(a, b) => (b.calcComparableAmountYearly() -
|
||||
a.calcComparableAmountYearly())
|
||||
.toInt(),
|
||||
);
|
||||
return snapshot.data!.isEmpty
|
||||
? Text(
|
||||
"Add items to get started.",
|
||||
softWrap: true,
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (_, index) {
|
||||
final TrackedItem curr = snapshot.data![index];
|
||||
|
||||
final itemKey = Key(curr.id!.toString());
|
||||
final String itemTitle = curr.name;
|
||||
|
||||
final String itemAmount;
|
||||
if (curr.frequency != null) {
|
||||
itemAmount =
|
||||
"${curr.amount.toStringAsFixed(2)} ${curr.frequency!.title}";
|
||||
} else {
|
||||
itemAmount = curr.amount.toStringAsFixed(2);
|
||||
}
|
||||
final String itemDescription = curr.description;
|
||||
|
||||
final double itemDayAmount, itemMonthAmount, itemYearAmount;
|
||||
final String estimateSymbolDaily,
|
||||
estimateSymbolMonthly,
|
||||
estimateSymbolYearly;
|
||||
|
||||
if (curr.frequency != null) {
|
||||
itemDayAmount = curr.calcComparableAmountDaily();
|
||||
estimateSymbolDaily = curr.frequency!.numDays
|
||||
.toStringAsFixed(2)
|
||||
.endsWith(".00") &&
|
||||
itemDayAmount.toStringAsFixed(3).endsWith("0")
|
||||
? ""
|
||||
: "~";
|
||||
|
||||
itemMonthAmount =
|
||||
(curr.calcComparableAmountYearly() / 12);
|
||||
estimateSymbolMonthly = curr.frequency!.timesPerYear
|
||||
.toStringAsFixed(2)
|
||||
.endsWith(".00") &&
|
||||
itemMonthAmount.toStringAsFixed(3).endsWith("0")
|
||||
? ""
|
||||
: "~";
|
||||
|
||||
itemYearAmount = curr.calcComparableAmountYearly();
|
||||
estimateSymbolYearly = curr.frequency!.timesPerYear
|
||||
.toStringAsFixed(2)
|
||||
.endsWith(".00") &&
|
||||
itemYearAmount.toStringAsFixed(3).endsWith("0")
|
||||
? ""
|
||||
: "~";
|
||||
} else {
|
||||
itemDayAmount = -1;
|
||||
estimateSymbolDaily = "";
|
||||
itemMonthAmount = curr.amount;
|
||||
estimateSymbolMonthly = "";
|
||||
itemYearAmount = -1;
|
||||
estimateSymbolYearly = "";
|
||||
}
|
||||
final String itemTopText = itemDayAmount < 0
|
||||
? ""
|
||||
: "$estimateSymbolDaily${itemDayAmount.toStringAsFixed(2)} ${Frequency.daily.title}";
|
||||
final String itemMiddleText = itemMonthAmount < 0
|
||||
? ""
|
||||
: "$estimateSymbolMonthly${itemMonthAmount.toStringAsFixed(2)} ${Frequency.monthly.title}";
|
||||
final String itemBottomText = itemYearAmount < 0
|
||||
? ""
|
||||
: "$estimateSymbolYearly${itemYearAmount.toStringAsFixed(2)} ${Frequency.yearly.title}";
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Dismissible(
|
||||
key: itemKey,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.delete),
|
||||
Text("Delete"),
|
||||
],
|
||||
),
|
||||
),
|
||||
secondaryBackground: Container(
|
||||
color: Colors.orange,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text("Edit"),
|
||||
Icon(Icons.edit),
|
||||
],
|
||||
),
|
||||
),
|
||||
onDismissed: (direction) {
|
||||
setState(() {
|
||||
snapshot.data!.remove(curr);
|
||||
switch (direction) {
|
||||
case DismissDirection.startToEnd:
|
||||
if (curr is Expense) {
|
||||
DatabaseHelper.instance
|
||||
.removeExpense(curr.id!);
|
||||
} else if (curr is Income) {
|
||||
// TODO
|
||||
} else if (curr is Asset) {
|
||||
// TODO
|
||||
} else {
|
||||
throw UnimplementedError(
|
||||
"Cannot remove unimplemented item type.");
|
||||
}
|
||||
break;
|
||||
case DismissDirection.endToStart:
|
||||
// Open an edit dialog, then remove the item from the list.
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
content: TrackedItemInputDialog(
|
||||
notifyParent: refresh,
|
||||
entry: curr,
|
||||
amountText: curr.getAmountText(),
|
||||
type: curr.type!,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
UnimplementedError(
|
||||
"Direction ${direction.toString()} not recognized.",
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
itemTitle,
|
||||
style: TextStyle(fontSize: 20.0),
|
||||
),
|
||||
Text(
|
||||
itemAmount,
|
||||
style: TextStyle(fontSize: 12.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
itemDescription,
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
),
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
itemTopText,
|
||||
style: TextStyle(fontSize: 12.0),
|
||||
),
|
||||
Text(
|
||||
itemMiddleText,
|
||||
style: TextStyle(fontSize: 12.0),
|
||||
),
|
||||
Text(
|
||||
itemBottomText,
|
||||
style: TextStyle(fontSize: 12.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TrackedItemInputDialog extends StatefulWidget {
|
||||
final Function() notifyParent;
|
||||
final TrackedItem? entry;
|
||||
final String? amountText;
|
||||
final ItemType? type;
|
||||
|
||||
const TrackedItemInputDialog({
|
||||
super.key,
|
||||
required this.notifyParent,
|
||||
this.entry,
|
||||
this.amountText,
|
||||
this.type,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TrackedItemInputDialog> createState() => _TrackedItemInputDialogState();
|
||||
}
|
||||
|
||||
class _TrackedItemInputDialogState extends State<TrackedItemInputDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
int? _id;
|
||||
String _name = "";
|
||||
double _amount = 0;
|
||||
Frequency _freq = Frequency.monthly;
|
||||
String _desc = "";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.type == null &&
|
||||
(widget.entry != null && widget.entry!.type == null)) {
|
||||
throw FlutterError("No ItemType provided for TrackedItemInputDialog.");
|
||||
}
|
||||
ItemType? _type = widget.type;
|
||||
|
||||
if (widget.entry != null) {
|
||||
_id = widget.entry!.id;
|
||||
_name = widget.entry!.name;
|
||||
_amount = widget.entry!.amount;
|
||||
widget.entry!.frequency == null ? null : _freq = widget.entry!.frequency!;
|
||||
_desc = widget.entry!.description;
|
||||
_type = widget.entry!.type!;
|
||||
}
|
||||
|
||||
String amountText =
|
||||
widget.amountText != null ? widget.amountText! : TrackedItem.amountText;
|
||||
|
||||
return Column(
|
||||
// prevent AlertDialog from taking full vertical height.
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
alignment: FractionalOffset.topRight,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
if (widget.entry != null) {
|
||||
setState(() {
|
||||
switch (_type) {
|
||||
case ItemType.expense:
|
||||
DatabaseHelper.instance.addExpense(widget.entry!);
|
||||
break;
|
||||
case ItemType.income:
|
||||
// TODO
|
||||
break;
|
||||
case ItemType.asset:
|
||||
// TODO
|
||||
break;
|
||||
default:
|
||||
throw UnimplementedError(
|
||||
"Cannot add unimplemented type.");
|
||||
}
|
||||
widget.notifyParent();
|
||||
});
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: Icon(Icons.clear),
|
||||
),
|
||||
),
|
||||
AlertDialog(
|
||||
insetPadding: EdgeInsets.all(0),
|
||||
title: Center(
|
||||
child: widget.entry == null
|
||||
? Text("New ${_type!.title}")
|
||||
: Text("Edit Expense"),
|
||||
),
|
||||
content: FutureBuilder<List<Expense>>(
|
||||
future: DatabaseHelper.instance.getExpenses(),
|
||||
builder: (BuildContext context,
|
||||
AsyncSnapshot<List<Expense>> snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return Center(child: Text('Loading...'));
|
||||
}
|
||||
List<Expense> expenses = snapshot.data!;
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
keyboardType: TextInputType.text,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Name",
|
||||
hintText: "Example: Red Pocket",
|
||||
hintStyle: TextStyle(fontSize: 10.0),
|
||||
errorStyle: TextStyle(fontSize: 10.0),
|
||||
),
|
||||
initialValue: _name,
|
||||
validator: (value) {
|
||||
if (value!.isEmpty) {
|
||||
return "Name must be provided.";
|
||||
}
|
||||
if (!expenses.every((expense) =>
|
||||
expense.name != value || expense.id == _id)) {
|
||||
return "Name must be unique, already in use.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSaved: (value) {
|
||||
_name = value!;
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: amountText,
|
||||
hintText: "Example: 10.00",
|
||||
hintStyle: TextStyle(fontSize: 10.0),
|
||||
errorStyle: TextStyle(fontSize: 10.0),
|
||||
),
|
||||
initialValue: _amount != 0 ? _amount.toString() : "",
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "$amountText must be provided.";
|
||||
}
|
||||
if (double.tryParse(value) == null) {
|
||||
return "$amountText must be a valid number.";
|
||||
}
|
||||
if (double.parse(value) < 0) {
|
||||
return "Please use the Income page rather than having negative expenses.";
|
||||
}
|
||||
if (double.parse(value) < 0.01) {
|
||||
return "$amountText must be one hundreth (0.01) or higher.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSaved: (value) {
|
||||
_amount = double.parse(value!);
|
||||
},
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
items: Frequency.values
|
||||
.map(
|
||||
(freq) => DropdownMenuItem(
|
||||
value: freq,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
freq.title,
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(1.0),
|
||||
child: Text(
|
||||
" (${freq.hint})",
|
||||
style: TextStyle(fontSize: 10.0),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
value: _freq,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Frequency",
|
||||
errorStyle: TextStyle(fontSize: 10.0),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return "Frequency must be provided.";
|
||||
}
|
||||
if (!Frequency.values.contains(value)) {
|
||||
return "Value not valid.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (value) {
|
||||
_freq = value!;
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
keyboardType: TextInputType.text,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Description",
|
||||
hintText:
|
||||
"Example: 1GB data with unlimited talk & text.",
|
||||
hintStyle: TextStyle(fontSize: 8.0),
|
||||
errorStyle: TextStyle(fontSize: 10.0),
|
||||
),
|
||||
initialValue: _desc,
|
||||
validator: (value) {
|
||||
return null;
|
||||
},
|
||||
onSaved: (value) {
|
||||
_desc = value!;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
actions: [
|
||||
Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
_formKey.currentState!.save();
|
||||
setState(() {
|
||||
Expense expense = Expense(
|
||||
id: _id,
|
||||
name: _name,
|
||||
amount: _amount,
|
||||
frequency: _freq,
|
||||
description: _desc,
|
||||
);
|
||||
if (_id != null) {
|
||||
DatabaseHelper.instance.updateExpense(
|
||||
expense,
|
||||
);
|
||||
} else {
|
||||
DatabaseHelper.instance.addExpense(
|
||||
expense,
|
||||
);
|
||||
}
|
||||
});
|
||||
widget.notifyParent();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.save),
|
||||
label: Text('Submit'),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user