56 Commits

Author SHA1 Message Date
8ef8e0dad9 Start filling in TODO's with Income and Asset code.. 2025-03-07 12:55:34 -07:00
0b937186aa Add break statements, even if unnecessary. 2025-03-07 12:54:01 -07:00
a5e10e26cc Move testing to be a global variable and use it to control the debug output. 2025-03-07 12:47:40 -07:00
029ee8e9a8 Add DEBUG messages to get methods. Fix the income and asset tables not existing if db upgrade is not run. 2025-03-07 12:43:26 -07:00
7947d64b3b Allow the Income and Asset pages to start using the generic item creation page. 2025-03-07 12:41:52 -07:00
778b4e2f39 Add methods for accessing Incomes and Assets in the database. 2025-03-07 12:20:38 -07:00
02f08093c9 Fix formatting. 2025-03-07 08:37:26 -07:00
c2939f81c0 Fix whitespace. 2025-03-07 08:30:29 -07:00
8ae728f6bf Prevent new DEBUG folder from getting into git repo. Must have gotten created when compiling for Android emulator. 2025-02-26 10:06:09 -07:00
f6179d1fae Upgraded Flutter. 2025-02-26 09:53:09 -07:00
52fdd5c471 Fix username. 2025-02-26 09:52:59 -07:00
42548d437c Add ItemType for determining which DB methods should be called based on the screen data. Begin implementing it. 2025-02-21 09:30:46 -07:00
be66f52cbf Move down to only 1 abstract data type with a nully Frequency. Should aid in making the Expense page usable for all 3 data type. 2025-02-21 08:56:50 -07:00
992195b9a0 Style the report a little better. Still need to get the row's middle text properly aligned with the column header somehow, as if everything were in a grid. 2025-02-21 08:30:54 -07:00
991eea4d54 Add estimate tildes. 2025-02-21 08:10:38 -07:00
064884dc07 Fix code warnings by using Description variable and temporarily assuming that Assets are a RecurringTrackedType. 2025-02-19 17:40:43 -07:00
98e511056c Remove no longer necessary includes. 2025-02-19 17:26:28 -07:00
58102bab02 Add the expense totals! Yay! Still trying to center the totals part with the monthly part across all sections properly. 2025-02-19 17:25:36 -07:00
0b4937b141 Start creating the reports as a break from making the Expense page usable for all 3. Still considering whether to just have 1 main abstract class with a nully Frequency. 2025-02-14 08:49:44 -07:00
d35c250913 Add note for next steps in making the ListView more abstract. 2025-02-12 12:55:10 -07:00
21f67b8a36 Added a monthly runner. Moved field values to be in variables outside of the Widget being returned. 2025-02-12 12:53:34 -07:00
9924860181 Create the beginning of a DB backup system. 2025-02-12 08:22:16 -07:00
60363c29f2 Rename recurring class filename. Move around some libraries to be under the proper comments. 2025-02-12 07:55:22 -07:00
83dece8749 Add TODO's. 2025-02-12 07:52:05 -07:00
737264fa2f More work to make the Expense page arbitrary for re-use. 2025-02-11 15:16:46 -07:00
bea9a4bc36 Update TODO wording. 2025-02-11 14:52:57 -07:00
8e6b574023 Create the income and asset tables. 2025-02-11 14:49:50 -07:00
fcf4fca33b Add the Income and Asset classes. 2025-02-11 14:49:42 -07:00
0aa8fdf271 Properly map all fields (cost/amount). 2025-02-11 14:49:23 -07:00
3bf3dd3190 Fix order of cost validations to properly show error for character data. 2025-02-11 14:48:32 -07:00
ab9b3e0bf9 Begin making the Expense page more arbitrary for future re-use by creating superclasses which will fit all 3 object use cases. 2025-02-11 14:33:38 -07:00
7a3eaf70b5 Add TODO, format code. 2025-02-11 09:57:10 -07:00
f5f153f692 Turn off the database delete, schema is currently stable. 2025-02-11 09:53:15 -07:00
362f1214e8 Fix ability to edit an item. 2025-02-11 09:52:52 -07:00
d77e732551 Change desktop version to use the original style of navigation, keep using drawer for mobile devices. Flutter is so cool!! :) 2025-02-11 09:26:59 -07:00
cc33458457 Remove unused imports. 2025-02-11 09:25:50 -07:00
f5635d6120 Add unique name check to entry form. 2025-02-07 15:40:07 -07:00
ef58a06dfa Successfully migrate the application to SQLIte! 2025-02-07 15:29:20 -07:00
5425b22ba2 Add a Help page. 2025-02-07 10:53:29 -07:00
305012ffd4 Reset income page to just a placeholder for consistency. 2025-02-07 10:53:16 -07:00
8c31d868b9 Add a daily estimate. Move estimates to the right of the list item. 2025-02-07 10:24:20 -07:00
9d478b9cbf Fix bug which sometimes set an edited expense to Monthly. Fix typo in enum monthly. 2025-02-07 05:10:22 -07:00
452eb73773 Code formatting and dialog text enhancements. 2025-02-07 05:00:57 -07:00
631555af59 Add a dark theme. 2025-02-07 04:54:59 -07:00
0f65166123 Properly implement the dismissable backgrounds. 2025-02-07 04:41:53 -07:00
595aaefedc Added editing an expense. 2025-02-07 04:29:10 -07:00
9d8a5e6685 Add more frequencies. 2025-02-06 11:50:23 -07:00
2acabf4d3b Huge improvements to UI, very interactive now! 2025-02-06 11:50:10 -07:00
15fa4aadbd Add method which puts all expenses on equal comparison grounds. 2025-02-06 11:50:02 -07:00
78a407d0ec Always show the page names. 2025-02-06 11:48:51 -07:00
54cd86c34b Shorten the title. 2025-02-05 16:45:55 -07:00
66fd966de8 Redo how the frequencies are generated. Clean up old print statements. 2025-02-05 16:41:58 -07:00
5561f50736 Begin working on the database. 2025-02-05 14:43:06 -07:00
6b25e6e552 Add sqlite dependency with flutter pub add sqflite. 2025-02-05 14:17:29 -07:00
360a36f024 Fix the layout when there are no items. 2025-02-05 14:16:49 -07:00
ecbac615e9 Finished drawer and also now showing expenses as they are added! 2025-02-05 14:04:35 -07:00
23 changed files with 1665 additions and 413 deletions

3
.gitignore vendored
View File

@ -32,7 +32,6 @@ migrate_working_dir/
.pub-cache/ .pub-cache/
.pub/ .pub/
/build/ /build/
# Symbolication related # Symbolication related
app.*.symbols app.*.symbols
@ -43,3 +42,5 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
/android/app/.*

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 me Copyright (c) 2025 Hyperling
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

257
lib/db.dart Normal file
View File

@ -0,0 +1,257 @@
// https://docs.flutter.dev/cookbook/persistence/sqlite
// SQLite
import 'dart:io';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
// Local
import "/globals.dart";
import '/models/expense.dart';
import '/models/income.dart';
import '/models/asset.dart';
import '/models/tracked_item.dart';
// Leaned on this example:
// https://learnflutterwithme.com/sqlite
class DatabaseHelper {
DatabaseHelper._privateConstructor();
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
static Database? _db;
Future<Database> get db async => _db ??= await _initDatabase();
Future<Database> _initDatabase() async {
Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = join(documentsDirectory.path, "com_hyperling_expense.db");
return await openDatabase(
path,
version: 2,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
Future _onCreate(Database db, int version) async {
debugPrint(
"_onCreate(): version=$version",
);
_createExpense(db);
_createIncome(db);
_createAsset(db);
}
Future _onUpgrade(Database db, int previousVersion, int newVersion) async {
debugPrint(
"_onUpgrade(): previousVersion=$previousVersion, newVersion=$newVersion",
);
// Added in DB version 2.
if (previousVersion < 2 && newVersion >= 2) {
_createIncome(db);
_createAsset(db);
}
}
void _createExpense(Database db) async {
await db.execute("""
CREATE TABLE expense
( id INTEGER PRIMARY KEY
, name TEXT NOT NULL UNIQUE
, cost DOUBLE NOT NULL
, frequency TEXT NOT NULL
, description TEXT
)""");
}
void _createIncome(Database db) async {
await db.execute("""
CREATE TABLE income
( id INTEGER PRIMARY KEY
, name TEXT NOT NULL UNIQUE
, revenue DOUBLE NOT NULL
, frequency TEXT NOT NULL
, description TEXT
)""");
}
void _createAsset(Database db) async {
await db.execute("""
CREATE TABLE asset
( id INTEGER PRIMARY KEY
, name TEXT NOT NULL UNIQUE
, amount DOUBLE NOT NULL
, description TEXT
)""");
}
/// Expense Section
///
Future<List<Expense>> getExpenses() async {
if (testing) debugPrint("getExpenses(): Accessing db.");
Database db = await instance.db;
if (testing) debugPrint("getExpenses(): Querying table.");
var expenses = await db.query("expense", orderBy: "name");
if (testing) debugPrint("getExpenses(): Putting into object list.");
List<Expense> expenseList = expenses.isNotEmpty
? expenses.map((c) => Expense.fromMap(c)).toList()
: [];
if (testing) debugPrint("getExpenses(): Returning!");
return expenseList;
}
Future<int> addExpense(TrackedItem expense) async {
Database db = await instance.db;
return await db.insert(
"expense",
expense.toMap(),
);
}
Future<int> removeExpense(int id) async {
Database db = await instance.db;
return await db.delete(
"expense",
where: "id = ?",
whereArgs: [id],
);
}
Future<int> updateExpense(TrackedItem expense) async {
Database db = await instance.db;
return await db.update(
"expense",
expense.toMap(),
where: "id = ?",
whereArgs: [expense.id],
);
}
Future<bool> checkExpenseNameExists(String name) async {
Database db = await instance.db;
var expenses = await db.query(
"expense",
where: "name = ?",
whereArgs: [name],
);
return expenses.isNotEmpty;
}
///
/// Income Section
///
Future<List<Income>> getIncomes() async {
if (testing) debugPrint("getIncomes(): Accessing db.");
Database db = await instance.db;
if (testing) debugPrint("getIncomes(): Querying table.");
var incomes = await db.query("income", orderBy: "name");
if (testing) debugPrint("getIncomes(): Putting into object list.");
List<Income> incomeList = incomes.isNotEmpty
? incomes.map((c) => Income.fromMap(c)).toList()
: [];
if (testing) debugPrint("getIncomes(): Returning!");
return incomeList;
}
Future<int> addIncome(TrackedItem income) async {
Database db = await instance.db;
return await db.insert(
"income",
income.toMap(),
);
}
Future<int> removeIncome(int id) async {
Database db = await instance.db;
return await db.delete(
"income",
where: "id = ?",
whereArgs: [id],
);
}
Future<int> updateIncome(TrackedItem income) async {
Database db = await instance.db;
return await db.update(
"income",
income.toMap(),
where: "id = ?",
whereArgs: [income.id],
);
}
Future<bool> checkIncomeNameExists(String name) async {
Database db = await instance.db;
var incomes = await db.query(
"income",
where: "name = ?",
whereArgs: [name],
);
return incomes.isNotEmpty;
}
///
/// Liquid Asset Section
Future<List<Asset>> getAssets() async {
if (testing) debugPrint("getAssets(): Accessing db.");
Database db = await instance.db;
if (testing) debugPrint("getAssets(): Querying table.");
var assets = await db.query("asset", orderBy: "name");
if (testing) debugPrint("getAssets(): Putting into object list.");
List<Asset> assetList =
assets.isNotEmpty ? assets.map((c) => Asset.fromMap(c)).toList() : [];
if (testing) debugPrint("getAssets(): Returning!");
return assetList;
}
Future<int> addAsset(TrackedItem asset) async {
Database db = await instance.db;
return await db.insert(
"asset",
asset.toMap(),
);
}
Future<int> removeAsset(int id) async {
Database db = await instance.db;
return await db.delete(
"asset",
where: "id = ?",
whereArgs: [id],
);
}
Future<int> updateAsset(TrackedItem asset) async {
Database db = await instance.db;
return await db.update(
"asset",
asset.toMap(),
where: "id = ?",
whereArgs: [asset.id],
);
}
Future<bool> checkAssetNameExists(String name) async {
Database db = await instance.db;
var assets = await db.query(
"asset",
where: "name = ?",
whereArgs: [name],
);
return assets.isNotEmpty;
}
///
}

1
lib/globals.dart Normal file
View File

@ -0,0 +1 @@
const bool testing = true;

View File

@ -1,15 +1,37 @@
// Helpful guides: // Flutter
// - https://flutter.dev/docs/cookbook/forms/validation
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '/pages/expense.dart'; // Local
import '/pages/income.dart'; import '/globals.dart';
import '/pages/asset.dart'; import '/pages/home.dart';
import '/pages/report.dart';
import '/pages/settings.dart'; // SQLite
import 'dart:io';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
void main() { void main() {
// I see no good explanations of why to use this other package yet, but
// trying this to see if it fixes the DB factory errors.
// "Unhandled Exception: Bad state: databaseFactory not initialized databaseFactory is only initialized when using sqflite. When using `sqflite_common_ffi`You must call `databaseFactory = databaseFactoryFfi;` before using global openDatabase API
// https://stackoverflow.com/questions/76158800/databasefactory-not-initialized-when-using-sqflite-in-flutter
if (Platform.isWindows || Platform.isLinux) {
// Initialize FFI
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
WidgetsFlutterBinding.ensureInitialized();
// Remove the DB and recreate it to test the Database Helper multiple times.
if (testing) {
() async {
Directory documentsDirectory = await getApplicationDocumentsDirectory();
String path = join(documentsDirectory.path, 'com_hyperling_expense.db');
await deleteDatabase(path);
}();
}
runApp(const MainApp()); runApp(const MainApp());
} }
@ -24,107 +46,13 @@ class MainApp extends StatelessWidget {
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green), colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
), ),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorSchemeSeed: Colors.green,
),
themeMode: ThemeMode.system,
home: HomePage(), home: HomePage(),
); );
} }
} }
class HomePage extends StatefulWidget {
const HomePage({
super.key,
});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
var pageSelected = 0;
@override
Widget build(BuildContext context) {
Widget page;
Widget? dialog;
switch (pageSelected) {
case 0:
page = ExpensePage();
dialog = ExpenseInputDialog();
case 1:
page = IncomePage();
case 2:
page = AssetPage();
case 3:
page = ProjectionPage();
case 4:
page = SettingsPage();
default:
throw UnimplementedError('No widget for page $pageSelected yet!');
}
Future<void> addNewValue(BuildContext context) {
return showDialog(
context: context, builder: (_) => AlertDialog(content: dialog));
}
Widget? floatingButton;
if (dialog != null) {
floatingButton = IconButton(
onPressed: () {
addNewValue(context);
},
icon: Icon(Icons.add),
color: Theme.of(context).colorScheme.onSurface,
);
}
return LayoutBuilder(builder: (context, constraints) {
return Scaffold(
// TODO: Add a drawer instead of nav rail.
body: Row(
children: [
SafeArea(
child: NavigationRail(
extended: constraints.maxWidth >= 800,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.payment),
label: Text('Expenses'),
),
NavigationRailDestination(
icon: Icon(Icons.account_balance),
label: Text('Income'),
),
NavigationRailDestination(
icon: Icon(Icons.attach_money),
label: Text('Liquid Assets'),
),
NavigationRailDestination(
icon: Icon(Icons.bar_chart),
label: Text('Projections'),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Settings'),
),
],
selectedIndex: pageSelected,
onDestinationSelected: (value) {
setState(() {
pageSelected = value;
});
},
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: page,
),
),
],
),
floatingActionButton: floatingButton,
);
});
}
}

20
lib/models/asset.dart Normal file
View File

@ -0,0 +1,20 @@
// Local
import '/models/tracked_item.dart';
class Asset extends TrackedItem {
static String amountText = "Amount";
Asset({
super.id,
required super.name,
required super.amount,
required super.description,
});
factory Asset.fromMap(Map<String, dynamic> json) => Asset(
id: json['id'],
name: json['name'],
amount: json['amount'],
description: json['description'],
);
}

View File

@ -0,0 +1,57 @@
// Local
import '/models/expense.dart';
import '/models/income.dart';
import '/models/asset.dart';
import '/db.dart';
/// TODO:
/// - Test the JSON data to ensure it's formed properly.
/// - Enable the functions for income and assets once they are implemented.
/// - Create a method (factory?) to load JSON data and delete/insert the data.
class DatabaseBackup {
List<Expense> expenseTable = [];
List<Income> incomeTable = [];
List<Asset> assetTable = [];
void loadTables() async {
expenseTable = await DatabaseHelper.instance.getExpenses();
//incomeTable = await DatabaseHelper.instance.getIncomes();
//assetTable = await DatabaseHelper.instance.getAssets();
}
Map<String, dynamic> toMap() {
loadTables(); // TODO: Do we somehow need to wait for this to finish?
return {
'expense_table': [
for (Expense e in expenseTable)
{
'id': e.id,
'name': e.name,
'cost': e.amount,
'frequency': e.frequency!.title,
'description': e.description,
},
],
'income_table': [
for (Income i in incomeTable)
{
'id': i.id,
'name': i.name,
'revenue': i.amount,
'frequency': i.frequency!.title,
'description': i.description,
},
],
'asset_table': [
for (Asset a in assetTable)
{
'id': a.id,
'name': a.name,
'amount': a.amount,
'description': a.description,
},
],
};
}
}

View File

@ -1,18 +1,39 @@
// Local
import 'package:flutter_expense_tracker/models/item_type.dart';
import '/models/tracked_item.dart';
import '/models/frequency.dart'; import '/models/frequency.dart';
class Expense { class Expense extends TrackedItem {
final String name; static String amountText = "Cost";
final double cost;
final Frequency frequency;
final String description;
const Expense( Expense({
{required this.name, super.id,
required this.cost, super.type = ItemType.expense,
required this.frequency, required super.name,
required this.description}); required super.amount,
required super.frequency,
required super.description,
});
String toString() { factory Expense.fromMap(Map<String, dynamic> json) => Expense(
return "$name, $cost, ${frequency.title}, $description"; id: json['id'],
name: json['name'],
amount: json['cost'],
frequency: Frequency.values
.where((freq) => freq.title == json['frequency'])
.first,
description: json['description'],
);
@override
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'cost': amount,
'frequency': frequency!.title,
'description': description,
};
} }
} }

View File

@ -1,12 +1,63 @@
// https://www.tutorialspoint.com/dart_programming/dart_programming_enumeration.htm // https://www.tutorialspoint.com/dart_programming/dart_programming_enumeration.htm
enum Frequency { enum Frequency {
daily(title: "Daily"), daily(
weekly(title: "Weekly"), title: "Daily",
biweekly(title: "Biweekly"), hint: "Once Per Day",
montly(title: "Monthly"), timesPerYear: 364.25,
yearly(title: "Yearly"); numDays: 1,
),
weekly(
title: "Weekly",
hint: "Once Per Week",
timesPerYear: (364.25 / 7),
numDays: 7,
),
biweekly(
title: "Biweekly",
hint: "Every Other Week",
timesPerYear: (364.25 / 14),
numDays: 14,
),
bimonthly(
title: "Bimonthly",
hint: "Twice Per Month",
timesPerYear: 24,
numDays: (364.25 / 24),
),
monthly(
title: "Monthly",
hint: "Once Per Month",
timesPerYear: 12,
numDays: (364.25 / 12),
),
quarterly(
title: "Quarterly",
hint: "Every Three Months",
timesPerYear: 4,
numDays: (364.25 / 4),
),
biannual(
title: "Biannual",
hint: "Twice Per Year",
timesPerYear: 2,
numDays: (364.25 / 2),
),
yearly(
title: "Yearly",
hint: "Once Per Year",
timesPerYear: 1,
numDays: 364.25,
);
const Frequency({required this.title}); const Frequency({
required this.title,
required this.hint,
required this.timesPerYear,
required this.numDays,
});
final String title; final String title;
final String hint;
final double timesPerYear;
final double numDays;
} }

36
lib/models/income.dart Normal file
View File

@ -0,0 +1,36 @@
// Local
import '/models/tracked_item.dart';
import '/models/frequency.dart';
class Income extends TrackedItem {
static String amountText = "Revenue";
Income({
super.id,
required super.name,
required super.amount,
required super.frequency,
required super.description,
});
factory Income.fromMap(Map<String, dynamic> json) => Income(
id: json['id'],
name: json['name'],
amount: json['revenue'],
frequency: Frequency.values
.where((freq) => freq.title == json['frequency'])
.first,
description: json['description'],
);
@override
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'revenue': amount,
'frequency': frequency!.title,
'description': description,
};
}
}

22
lib/models/item_type.dart Normal file
View File

@ -0,0 +1,22 @@
enum ItemType {
expense(
title: "Expense",
plural: "Expenses",
),
income(
title: "Income",
plural: "Incomes",
),
asset(
title: "Asset",
plural: "Assets",
);
const ItemType({
required this.title,
required this.plural,
});
final String title;
final String plural;
}

View File

@ -0,0 +1,53 @@
// Local
import 'package:flutter_expense_tracker/models/item_type.dart';
import '/models/frequency.dart';
abstract class TrackedItem {
int? id;
ItemType? type;
String name;
double amount;
Frequency? frequency;
String description;
TrackedItem({
this.id,
this.type,
required this.name,
required this.amount,
this.frequency,
required this.description,
});
static String amountText = "Amount";
String getAmountText() => amountText;
@override
String toString() {
return toMap().toString();
}
double calcComparableAmountYearly() {
return frequency == null ? 0 : amount * frequency!.timesPerYear;
}
double calcComparableAmountDaily() {
return frequency == null ? 0 : amount / frequency!.numDays;
}
Map<String, dynamic> toMap() {
return frequency == null ? {
'id': id,
'name': name,
'amount': amount,
'description': description,
} : {
'id': id,
'name': name,
'amount': amount,
'frequency': frequency!.title,
'description': description,
};
}
}

View File

@ -1,12 +0,0 @@
import 'package:flutter/material.dart';
class AssetPage extends StatelessWidget {
const AssetPage({
super.key,
});
@override
Widget build(BuildContext context) {
return Placeholder();
}
}

View File

@ -1,214 +0,0 @@
import 'package:flutter/material.dart';
import '/models/expense.dart';
import '/models/frequency.dart';
List<Expense> expenses = [];
class ExpensePage extends StatelessWidget {
const ExpensePage({
super.key,
});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: expenses.length,
itemBuilder: (_, index) {
final Expense curr = expenses[index];
return Center(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Colors.greenAccent,
),
child: Column(
children: [
Text(curr.name),
Text("${curr.cost.toString()} ${curr.frequency.title}"),
],
),
),
),
);
});
/*
return ListView(
children: [
ListTile(
title: Text("Fake Item 1"),
subtitle: Text("30.00 / month"),
),
ListTile(
title: Text("Fake Item 2"),
subtitle: Text("180.00 / year"),
),
],
);
*/
}
}
class ExpenseInputDialog extends StatefulWidget {
const ExpenseInputDialog({
super.key,
});
@override
State<ExpenseInputDialog> createState() => _ExpenseInputDialogState();
}
class _ExpenseInputDialogState extends State<ExpenseInputDialog> {
final _expenseFormKey = GlobalKey<FormState>();
String _name = "";
double _cost = 0.0;
Frequency _freq = Frequency.montly;
String _desc = "";
@override
Widget build(BuildContext context) {
const inputWidth = 300.0;
const inputHeight = 50.0;
List<DropdownMenuItem> freqValues = [];
for (var freq in Frequency.values) {
freqValues.add(DropdownMenuItem(value: freq, child: Text(freq.title)));
}
;
return AlertDialog(
title: Center(child: Text("Add New Expense")),
content: Form(
key: _expenseFormKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(mainAxisSize: MainAxisSize.min, spacing: 10, children: [
SizedBox(
width: inputWidth,
height: inputHeight,
child: TextFormField(
keyboardType: TextInputType.text,
decoration: InputDecoration(
labelText: "Name",
hintText: "Example: Red Pocket Phone Bill",
),
validator: (value) {
if (value!.isEmpty) {
return "Name must be provided.";
}
return null;
},
onSaved: (newValue) {
_name = newValue!;
},
),
),
SizedBox(
width: inputWidth,
height: inputHeight,
child: TextFormField(
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: "Cost", hintText: "Example: 10.00"),
validator: (value) {
if (value!.isEmpty) {
return "Cost must be provided.";
}
if (double.tryParse(value) == null) {
return "Cost must be a valid number.";
}
return null;
},
onSaved: (newValue) {
_cost = double.parse(newValue!);
},
),
),
SizedBox(
width: inputWidth,
height: inputHeight,
child: DropdownButtonFormField(
items: freqValues,
decoration: InputDecoration(
labelText: "Recurrence", hintText: "Example: Monthly"),
validator: (value) {
if (value == null) {
return "Frequency must be provided.";
}
if (!Frequency.values.contains(value)) {
return "Value not valid.";
}
return null;
},
onChanged: (newValue) {
_freq = newValue;
},
),
),
SizedBox(
width: inputWidth,
height: inputHeight,
child: TextFormField(
keyboardType: TextInputType.text,
decoration: InputDecoration(
labelText: "Description",
hintText: "Example: 1GB data with unlimited talk & text."),
validator: (value) {
return null;
},
onSaved: (newValue) {
_desc = newValue!;
},
),
),
]),
),
actions: [
SizedBox(
width: inputWidth,
height: inputHeight,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ElevatedButton.icon(
onPressed: () {
print("TODO: Clear fields!");
Navigator.of(context).pop();
},
icon: Icon(Icons.cancel),
label: Text('Cancel'),
),
ElevatedButton.icon(
onPressed: () {
print("TODO: Save expense!");
if (_expenseFormKey.currentState!.validate()) {
_expenseFormKey.currentState!.save();
setState(() {
expenses.add(
Expense(
name: _name,
cost: _cost,
frequency: _freq,
description: _desc),
);
});
print(expenses.toString());
for (var expense in expenses) {
print(expense.toString());
}
Navigator.of(context).pop();
}
},
icon: Icon(Icons.save),
label: Text('Submit'),
),
],
),
)
],
);
}
}

72
lib/pages/help.dart Normal file
View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
class HelpPage extends StatelessWidget {
const HelpPage({
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: theme.colorScheme.onPrimary,
),
child: Column(
children: [
Text("This app is meant to be a simple budgeting tool,"
" allowing you to view your income and expenses at a high level"
" without micro managing specific budget items or adding receipts."),
//Text("Another paragraph.")
],
),
),
),
),
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: theme.colorScheme.onPrimary,
),
child: TextButton.icon(
onPressed: () {},
icon: Icon(Icons.code),
label: Text("Code Repository"),
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: theme.colorScheme.onPrimary,
),
child: TextButton.icon(
onPressed: () {},
icon: Icon(Icons.web_asset),
label: Text("Personal Website"),
),
),
),
),
],
)
],
);
}
}

161
lib/pages/home.dart Normal file
View File

@ -0,0 +1,161 @@
// Flutter
import 'package:flutter/material.dart';
import 'package:flutter_expense_tracker/models/item_type.dart';
import 'dart:io';
// Local
import '/pages/tracked_item.dart';
import '/pages/report.dart';
import '/pages/settings.dart';
import '/pages/help.dart';
import '/db.dart';
class HomePage extends StatefulWidget {
const HomePage({
super.key,
});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
var pageSelected = 0;
refresh() {
setState(() {});
}
@override
Widget build(BuildContext context) {
Widget page;
Widget? dialog;
switch (pageSelected) {
case 0:
page = TrackedItemPage(
assetsToLoad: DatabaseHelper.instance.getExpenses(),
);
dialog = TrackedItemInputDialog(
notifyParent: refresh,
type: ItemType.expense,
);
break;
case 1:
page = TrackedItemPage(
assetsToLoad: DatabaseHelper.instance.getIncomes(),
);
dialog = TrackedItemInputDialog(
notifyParent: refresh,
type: ItemType.income,
);
break;
case 2:
page = TrackedItemPage(
assetsToLoad: DatabaseHelper.instance.getAssets(),
);
dialog = TrackedItemInputDialog(
notifyParent: refresh,
type: ItemType.asset,
);
break;
case 3:
page = ProjectionPage();
break;
case 4:
page = SettingsPage();
break;
case 5:
page = HelpPage();
break;
default:
throw UnimplementedError('No widget for page $pageSelected yet!');
}
Future<void> addNewValue(BuildContext context) {
return showDialog(
context: context,
builder: (_) => AlertDialog(content: dialog),
);
}
Widget? floatingButton;
if (dialog != null) {
floatingButton = IconButton(
onPressed: () {
addNewValue(context);
},
icon: Icon(Icons.add),
color: Theme.of(context).colorScheme.onSurface,
);
}
Widget navigation = NavigationRail(
extended: true,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.payment),
label: Text('Expenses'),
),
NavigationRailDestination(
icon: Icon(Icons.account_balance),
label: Text('Income'),
),
NavigationRailDestination(
icon: Icon(Icons.attach_money),
label: Text('Liquid Assets'),
),
NavigationRailDestination(
icon: Icon(Icons.bar_chart),
label: Text('Reports'),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Settings'),
),
NavigationRailDestination(
icon: Icon(Icons.help),
label: Text('Help'),
),
],
selectedIndex: pageSelected,
onDestinationSelected: (value) {
setState(() {
pageSelected = value;
if (Platform.isAndroid || Platform.isIOS) {
Navigator.pop(context);
}
});
},
);
Widget main = Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(child: page),
);
Widget? drawer, body;
if (Platform.isAndroid || Platform.isIOS) {
drawer = navigation;
body = main;
} else {
drawer = null;
body = Row(
children: [
SafeArea(child: navigation),
Expanded(child: main),
],
);
}
return LayoutBuilder(builder: (context, constraints) {
return Scaffold(
appBar: AppBar(
title: Text("Expense Tracker"),
),
drawer: drawer,
body: body,
floatingActionButton: floatingButton,
);
});
}
}

View File

@ -1,18 +0,0 @@
import 'package:flutter/material.dart';
class IncomePage extends StatelessWidget {
const IncomePage({
super.key,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
Text("TBD"),
Placeholder(),
],
));
}
}

View File

@ -1,12 +1,154 @@
// Flutter
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ProjectionPage extends StatelessWidget { // Local
import '/db.dart';
import '/models/tracked_item.dart';
/// TODO:
/// - Expenses (total number, totals by day / month / year)
/// - Incomes (total number, totals by day / month / year)
/// - Assets (total number, total by day / month / year)
/// - Projected Assets in:
/// - 1 week, 1 month, 1 quarter, 1 year
/// - 1/2 year? 2 years? 5 years? Allow customization?
class ProjectionPage extends StatefulWidget {
const ProjectionPage({ const ProjectionPage({
super.key, super.key,
}); });
@override
State<ProjectionPage> createState() => _ProjectionPageState();
}
class _ProjectionPageState extends State<ProjectionPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Placeholder(); Widget expenseSummary = SummaryCardForTotals(
list: DatabaseHelper.instance.getExpenses(),
summaryTypeLabel: "Expense",
);
return ListView(
children: [
expenseSummary,
SummaryCard(
name: "Income Totals",
leftText: "left",
middleText: "middle",
rightText: "right",
),
SummaryCard(
name: "Asset Totals",
leftText: "left",
middleText: "middle",
rightText: "right",
),
],
);
}
}
class SummaryCardForTotals extends StatelessWidget {
const SummaryCardForTotals({
super.key,
required this.list,
required this.summaryTypeLabel,
});
final Future<List<TrackedItem>> list;
final String summaryTypeLabel;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<TrackedItem>>(
future: list,
builder: (
BuildContext context,
AsyncSnapshot<List<TrackedItem>> snapshot,
) {
if (!snapshot.hasData) {
return Text('Loading $summaryTypeLabel Section...');
}
double dailyTotal = 0, monthlyTotal = 0, yearlyTotal = 0;
for (TrackedItem e in snapshot.data!) {
dailyTotal += e.calcComparableAmountDaily();
monthlyTotal += e.calcComparableAmountYearly() / 12;
yearlyTotal += e.calcComparableAmountYearly();
}
String dailyEstimate =
dailyTotal.toStringAsFixed(3).endsWith("0") ? "" : "~",
monthlyEstimate =
monthlyTotal.toStringAsFixed(3).endsWith("0") ? "" : "~",
yearlyEstimate =
yearlyTotal.toStringAsFixed(3).endsWith("0") ? "" : "~";
return SummaryCard(
name: "$summaryTypeLabel Totals",
leftText: "$dailyEstimate${dailyTotal.toStringAsFixed(2)} Daily",
middleText:
"$monthlyEstimate${monthlyTotal.toStringAsFixed(2)} Monthly",
rightText:
"$yearlyEstimate${yearlyTotal.toStringAsFixed(2)} Yearly",
);
});
}
}
class SummaryCard extends StatelessWidget {
const SummaryCard({
super.key,
required this.name,
required this.leftText,
required this.middleText,
required this.rightText,
});
final String name;
final String leftText;
final String middleText;
final String rightText;
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).cardColor,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
color: Theme.of(context).highlightColor,
child: Column(
children: [
Text(
name,
style: TextStyle(
decoration: TextDecoration.underline,
fontSize: 16
),
),
Row(
children: [
Spacer(
flex: 3,
),
Text(leftText),
Spacer(
flex: 1,
),
Text(middleText),
Spacer(
flex: 1,
),
Text(rightText),
Spacer(
flex: 3,
),
],
),
],
),
),
),
);
} }
} }

View File

@ -1,5 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// TODO:
/// - Export DB (JSON?)
/// - Import DB (JSON?)
/// - Choose color scheme
/// - Delete / Reset DB (pull code from `main`'s `if (testing)`)
class SettingsPage extends StatelessWidget { class SettingsPage extends StatelessWidget {
const SettingsPage({ const SettingsPage({
super.key, super.key,

500
lib/pages/tracked_item.dart Normal file
View File

@ -0,0 +1,500 @@
// 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';
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:
// Remove the item from the database.
if (curr is Expense) {
DatabaseHelper.instance.removeExpense(
curr.id!,
);
} else if (curr is Income) {
DatabaseHelper.instance.removeIncome(
curr.id!,
);
} else if (curr is Asset) {
DatabaseHelper.instance.removeAsset(
curr.id!,
);
} 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:
DatabaseHelper.instance.addIncome(widget.entry!);
break;
case ItemType.asset:
DatabaseHelper.instance.addAsset(widget.entry!);
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'),
),
)
],
),
],
);
}
}

View File

@ -5,6 +5,10 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import path_provider_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
} }

View File

@ -5,50 +5,58 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.12.0"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
name: boolean_selector name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.2"
characters: characters:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.4.0"
clock: clock:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.2"
collection: collection:
dependency: transitive dependency: transitive
description: description:
name: collection name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.0" version: "1.19.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.2"
ffi:
dependency: transitive
description:
name: ffi
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -71,18 +79,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.7" version: "10.0.8"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.8" version: "3.0.9"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@ -103,10 +111,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.16+1" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@ -119,18 +127,82 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.15.0" version: "1.16.0"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2"
url: "https://pub.dev"
source: hosted
version: "2.2.15"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -140,50 +212,122 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.0" version: "1.10.1"
sqflite:
dependency: "direct main"
description:
name: sqflite
sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709"
url: "https://pub.dev"
source: hosted
version: "2.5.4+6"
sqflite_common_ffi:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36"
url: "https://pub.dev"
source: hosted
version: "2.3.4+4"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c"
url: "https://pub.dev"
source: hosted
version: "2.4.1+1"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "35d3726fe18ab1463403a5cc8d97dbc81f2a0b08082e8173851363fcc97b6627"
url: "https://pub.dev"
source: hosted
version: "2.7.2"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.0" version: "1.12.1"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
name: stream_channel name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.4"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
name: string_scanner name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225"
url: "https://pub.dev"
source: hosted
version: "3.3.0+3"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
name: term_glyph name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "1.2.2"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.3" version: "0.7.4"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -196,10 +340,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.0" version: "14.3.1"
web:
dependency: transitive
description:
name: web
sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks: sdks:
dart: ">=3.6.1 <4.0.0" dart: ">=3.7.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.24.0"

View File

@ -9,6 +9,10 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
path: ^1.9.0
path_provider: ^2.1.5
sqflite: ^2.4.1
sqflite_common_ffi: ^2.3.4+4
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: