Initial commit
This commit is contained in:
138
lib/ui/navigation.dart
Normal file
138
lib/ui/navigation.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:okane/screen.dart';
|
||||
import 'package:okane/ui/pages/account/account.dart';
|
||||
import 'package:okane/ui/pages/template_list.dart';
|
||||
import 'package:okane/ui/pages/transaction_details.dart';
|
||||
import 'package:okane/ui/pages/transaction_list.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
|
||||
enum OkanePage { accounts, transactions, beneficiaries, templates, budgets }
|
||||
|
||||
typedef OkanePageBuilder = Widget Function(bool);
|
||||
|
||||
class OkanePageItem {
|
||||
final OkanePage page;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Widget child;
|
||||
final OkanePageBuilder? details;
|
||||
|
||||
const OkanePageItem(
|
||||
this.page,
|
||||
this.icon,
|
||||
this.label,
|
||||
this.child,
|
||||
this.details,
|
||||
);
|
||||
|
||||
NavigationDestination toDestination() =>
|
||||
NavigationDestination(icon: Icon(icon), label: label);
|
||||
|
||||
NavigationRailDestination toRailDestination() =>
|
||||
NavigationRailDestination(icon: Icon(icon), label: Text(label));
|
||||
}
|
||||
|
||||
final _pages = <OkanePageItem>[
|
||||
OkanePageItem(
|
||||
OkanePage.accounts,
|
||||
Icons.account_box,
|
||||
"Accounts",
|
||||
AccountListPage(isPage: false),
|
||||
null,
|
||||
),
|
||||
OkanePageItem(
|
||||
OkanePage.transactions,
|
||||
Icons.monetization_on_rounded,
|
||||
"Transactions",
|
||||
TransactionListPage(),
|
||||
(_) => TransactionDetailsPage(),
|
||||
),
|
||||
OkanePageItem(
|
||||
OkanePage.beneficiaries,
|
||||
Icons.person,
|
||||
"Beneficiaries",
|
||||
Container(),
|
||||
null,
|
||||
),
|
||||
OkanePageItem(
|
||||
OkanePage.templates,
|
||||
Icons.list,
|
||||
"Templates",
|
||||
TemplateListPage(),
|
||||
null,
|
||||
),
|
||||
OkanePageItem(
|
||||
OkanePage.budgets,
|
||||
Icons.pie_chart,
|
||||
"Budgets",
|
||||
Placeholder(),
|
||||
null,
|
||||
),
|
||||
];
|
||||
|
||||
class OkaneNavigationRail extends StatelessWidget {
|
||||
const OkaneNavigationRail({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CoreCubit, CoreState>(
|
||||
builder:
|
||||
(context, state) => NavigationRail(
|
||||
onDestinationSelected:
|
||||
(i) => context.read<CoreCubit>().setPage(_pages[i].page),
|
||||
destinations: _pages.map((p) => p.toRailDestination()).toList(),
|
||||
selectedIndex: _pages.indexWhere((p) => p.page == state.activePage),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OkaneNavigationBar extends StatelessWidget {
|
||||
const OkaneNavigationBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CoreCubit, CoreState>(
|
||||
builder:
|
||||
(context, state) => NavigationBar(
|
||||
onDestinationSelected:
|
||||
(i) => context.read<CoreCubit>().setPage(_pages[i].page),
|
||||
destinations: _pages.map((p) => p.toDestination()).toList(),
|
||||
selectedIndex: _pages.indexWhere((p) => p.page == state.activePage),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OkaneNavigationLayout extends StatelessWidget {
|
||||
const OkaneNavigationLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = getScreenSize(context);
|
||||
return BlocBuilder<CoreCubit, CoreState>(
|
||||
builder:
|
||||
(_, state) => IndexedStack(
|
||||
index: _pages.indexWhere((p) => p.page == state.activePage),
|
||||
children:
|
||||
_pages
|
||||
.map(
|
||||
(p) => switch (screenSize) {
|
||||
ScreenSize.small => p.child,
|
||||
ScreenSize.normal =>
|
||||
p.details != null
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(child: p.child),
|
||||
Expanded(child: p.details!(false)),
|
||||
],
|
||||
)
|
||||
: p.child,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
191
lib/ui/pages/account/account.dart
Normal file
191
lib/ui/pages/account/account.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:okane/database/collections/account.dart';
|
||||
import 'package:okane/database/collections/beneficiary.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/pages/account/breakdown_card.dart';
|
||||
import 'package:okane/ui/pages/account/total_balance_card.dart';
|
||||
import 'package:okane/ui/pages/account/upcoming_transactions_card.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
|
||||
class AccountListPage extends StatefulWidget {
|
||||
final bool isPage;
|
||||
|
||||
const AccountListPage({required this.isPage, super.key});
|
||||
|
||||
@override
|
||||
AccountListPageState createState() => AccountListPageState();
|
||||
}
|
||||
|
||||
class AccountListPageState extends State<AccountListPage> {
|
||||
final TextEditingController _accountNameController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
"Accounts",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: BlocBuilder<CoreCubit, CoreState>(
|
||||
builder:
|
||||
(context, state) => SizedBox(
|
||||
child: SizedBox(
|
||||
height: 100,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: state.accounts.length,
|
||||
itemBuilder:
|
||||
(context, index) => SizedBox(
|
||||
width: 150,
|
||||
height: 100,
|
||||
child: Card(
|
||||
color: colorHash(state.accounts[index].name!),
|
||||
shape:
|
||||
index == state.activeAccountIndex
|
||||
? RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
color: Colors.black,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
12,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
GetIt.I
|
||||
.get<CoreCubit>()
|
||||
.setActiveAccountIndex(index);
|
||||
},
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(state.accounts[index].name!),
|
||||
FutureBuilder(
|
||||
future: getTotalBalance(
|
||||
state.accounts[index],
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Text(
|
||||
formatCurrency(snapshot.data!),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TotalBalanceCard(),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: UpcomingTransactionsCard(),
|
||||
),
|
||||
|
||||
/*
|
||||
BlocBuilder<CoreCubit, CoreState>(
|
||||
builder:
|
||||
(context, state) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: AccountBalanceGraphCard(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),*/
|
||||
Row(
|
||||
children: [
|
||||
Padding(padding: EdgeInsets.all(16), child: BreakdownCard()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton(
|
||||
child: Icon(Icons.add),
|
||||
onPressed: () {
|
||||
showDialogOrModal(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _accountNameController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Account name",
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
if (_accountNameController.text.isEmpty) return;
|
||||
|
||||
final a =
|
||||
Account()..name = _accountNameController.text;
|
||||
final b =
|
||||
Beneficiary()
|
||||
..name = _accountNameController.text
|
||||
..account.value =
|
||||
GetIt.I.get<CoreCubit>().activeAccount
|
||||
..type = BeneficiaryType.account;
|
||||
await upsertAccount(a);
|
||||
await upsertBeneficiary(b);
|
||||
_accountNameController.text = "";
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text("Add"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
150
lib/ui/pages/account/balance_graph_card.dart
Normal file
150
lib/ui/pages/account/balance_graph_card.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'dart:collection';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
|
||||
double getBalanceGraphScaling(double maxBalance) {
|
||||
if (maxBalance < 100) {
|
||||
return 10;
|
||||
} else if (maxBalance < 1000) {
|
||||
return 200;
|
||||
} else if (maxBalance < 10000) {
|
||||
return 1000;
|
||||
}
|
||||
|
||||
return 10000;
|
||||
}
|
||||
|
||||
class AccountBalanceGraphCard extends StatelessWidget {
|
||||
const AccountBalanceGraphCard({super.key});
|
||||
|
||||
Future<List<FlSpot>> getAccountBalance() async {
|
||||
final coreCubit = GetIt.I.get<CoreCubit>();
|
||||
final today = toMidnight(DateTime.now());
|
||||
final transactions = await getLastTransactions(
|
||||
coreCubit.activeAccount!,
|
||||
today,
|
||||
30,
|
||||
);
|
||||
final totalBalance = await getTotalBalance(coreCubit.activeAccount!);
|
||||
|
||||
// Compute the differences per day
|
||||
Map<int, double> differences = Map.fromEntries(
|
||||
List.generate(30, (i) => i).map((i) => MapEntry(i, 0)),
|
||||
);
|
||||
for (final item in transactions) {
|
||||
final diff = today.difference(toMidnight(item.date)).inDays;
|
||||
final balance = differences[diff]!;
|
||||
differences[diff] = balance + item.amount;
|
||||
}
|
||||
|
||||
// Compute the balance
|
||||
final balances = HashMap<int, double>();
|
||||
balances[0] = totalBalance;
|
||||
for (final idx in List.generate(29, (i) => i + 1)) {
|
||||
balances[idx] = balances[idx - 1]! + differences[idx]!;
|
||||
}
|
||||
|
||||
List<FlSpot> result = List.empty(growable: true);
|
||||
result.add(FlSpot(0, totalBalance));
|
||||
result.addAll(
|
||||
List.generate(
|
||||
28,
|
||||
(i) => i + 1,
|
||||
).map((i) => FlSpot(i.toDouble(), balances[i]!)),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text("Account balance"),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: FutureBuilder(
|
||||
future: getAccountBalance(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return CircularProgressIndicator();
|
||||
}
|
||||
|
||||
final today = DateTime.now();
|
||||
final maxBalance = snapshot.data!
|
||||
.map((spot) => spot.y)
|
||||
.reduce((acc, y) => y > acc ? y : acc);
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
minY:
|
||||
snapshot.data!
|
||||
.map((spot) => spot.y)
|
||||
.reduce((acc, y) => y < acc ? y : acc) -
|
||||
20,
|
||||
maxY: maxBalance + 20,
|
||||
titlesData: FlTitlesData(
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
interval: 4,
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (val, meta) {
|
||||
final day = today.subtract(
|
||||
Duration(days: val.toInt()),
|
||||
);
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
child: Text(
|
||||
formatDateTime(day, formatYear: false),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 50,
|
||||
interval: getBalanceGraphScaling(maxBalance),
|
||||
getTitlesWidget:
|
||||
(value, meta) => SideTitleWidget(
|
||||
meta: meta,
|
||||
child: Text(
|
||||
formatCurrency(value, precise: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
gridData: const FlGridData(show: false),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
isCurved: false,
|
||||
barWidth: 3,
|
||||
dotData: const FlDotData(show: false),
|
||||
spots: snapshot.data!,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
172
lib/ui/pages/account/breakdown_card.dart
Normal file
172
lib/ui/pages/account/breakdown_card.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:okane/database/collections/transaction.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
|
||||
const CATEGORY_INCOME = "Income";
|
||||
const CATEGORY_OTHER = "Other";
|
||||
|
||||
typedef LegendData =
|
||||
({Map<String, double> expenses, Map<String, Color> colors, double usable});
|
||||
|
||||
class LegendItem extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
const LegendItem({required this.text, required this.color, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: SizedBox(width: 10, height: 10),
|
||||
),
|
||||
Padding(padding: EdgeInsets.only(left: 8), child: Text(text)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BreakdownCard extends StatelessWidget {
|
||||
const BreakdownCard({super.key});
|
||||
|
||||
LegendData _computeSections(List<Transaction> transactions) {
|
||||
Map<String, double> expenses = {};
|
||||
Map<String, Color> colors = {};
|
||||
double usableMoney = 0;
|
||||
transactions.forEach((t) {
|
||||
String category;
|
||||
if (t.amount > 0) {
|
||||
category = CATEGORY_INCOME;
|
||||
colors[CATEGORY_INCOME] = Colors.green;
|
||||
} else {
|
||||
if (t.expenseCategory.value?.name == null) {
|
||||
category = CATEGORY_OTHER;
|
||||
colors[category] = Colors.red;
|
||||
} else {
|
||||
category = t.expenseCategory.value!.name;
|
||||
colors[category] = colorHash(t.expenseCategory.value!.name);
|
||||
}
|
||||
}
|
||||
|
||||
expenses.update(
|
||||
category,
|
||||
(value) => value + t.amount.abs(),
|
||||
ifAbsent: () => t.amount.abs(),
|
||||
);
|
||||
usableMoney += t.amount;
|
||||
});
|
||||
return (expenses: expenses, colors: colors, usable: usableMoney);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = GetIt.I.get<CoreCubit>();
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: BlocBuilder<CoreCubit, CoreState>(
|
||||
builder: (context, state) {
|
||||
if (bloc.activeAccount == null) {
|
||||
return Text("No active account");
|
||||
}
|
||||
|
||||
return FutureBuilder(
|
||||
future: getLastTransactions(
|
||||
bloc.activeAccount!,
|
||||
DateTime.now(),
|
||||
30,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final title = Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: Text("Expense Breakdown"),
|
||||
);
|
||||
if (!snapshot.hasData) {
|
||||
return Column(children: [title, CircularProgressIndicator()]);
|
||||
}
|
||||
|
||||
if (snapshot.data!.isEmpty) {
|
||||
return Column(children: [title, Text("No transactions")]);
|
||||
}
|
||||
|
||||
final data = _computeSections(snapshot.data!);
|
||||
final sectionData =
|
||||
data.expenses.entries
|
||||
.map(
|
||||
(entry) => PieChartSectionData(
|
||||
value: entry.value,
|
||||
title: formatCurrency(entry.value, precise: false),
|
||||
titleStyle: TextStyle(fontWeight: FontWeight.bold),
|
||||
radius: 40,
|
||||
color: data.colors[entry.key]!,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
return Column(
|
||||
children: [
|
||||
title,
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 150,
|
||||
height: 150,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
borderData: FlBorderData(show: false),
|
||||
sectionsSpace: 0,
|
||||
centerSpaceRadius: 35,
|
||||
sections: sectionData,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children:
|
||||
data.expenses.keys
|
||||
.map(
|
||||
(key) => LegendItem(
|
||||
text: key,
|
||||
color: data.colors[key]!,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: Text(
|
||||
"Available money: ${formatCurrency(data.usable)}",
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
lib/ui/pages/account/total_balance_card.dart
Normal file
49
lib/ui/pages/account/total_balance_card.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:okane/database/collections/account.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
|
||||
class TotalBalanceCard extends StatelessWidget {
|
||||
const TotalBalanceCard({super.key});
|
||||
|
||||
Future<double> _getTotalBalance(List<Account> accounts) async {
|
||||
if (accounts.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final results = await Future.wait(accounts.map(getTotalBalance).toList());
|
||||
|
||||
return results.reduce((acc, val) => acc + val);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CoreCubit, CoreState>(
|
||||
builder: (context, state) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Total balance"),
|
||||
FutureBuilder(
|
||||
future: _getTotalBalance(state.accounts),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return Text("...");
|
||||
}
|
||||
|
||||
return Text(formatCurrency(snapshot.data!));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/ui/pages/account/upcoming_transactions_card.dart
Normal file
69
lib/ui/pages/account/upcoming_transactions_card.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:okane/database/collections/recurrent.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
|
||||
class UpcomingTransactionsCard extends StatelessWidget {
|
||||
const UpcomingTransactionsCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CoreCubit, CoreState>(
|
||||
builder: (context, state) {
|
||||
final today = DateTime.now();
|
||||
final upcomingRaw =
|
||||
state.recurringTransactions.where((t) {
|
||||
if (t.lastExecution == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return today.difference(t.lastExecution!).inDays <=
|
||||
(t.days * 1.5).toInt();
|
||||
}).toList();
|
||||
final List<RecurringTransaction> upcoming =
|
||||
upcomingRaw.isEmpty
|
||||
? List.empty()
|
||||
: upcomingRaw.sublist(0, min(upcomingRaw.length, 3));
|
||||
final transactions =
|
||||
upcoming.isEmpty
|
||||
? [Text("No upcoming transactions")]
|
||||
: upcoming
|
||||
.map(
|
||||
(t) => ListTile(
|
||||
title: Text(
|
||||
"${t.template.value!.name} (${t.template.value!.amount}€)",
|
||||
),
|
||||
subtitle: Text(
|
||||
"Due in ${today.difference(t.lastExecution ?? today).inDays} days",
|
||||
),
|
||||
leading: Icon(
|
||||
t.template.value!.amount < 0
|
||||
? Icons.remove
|
||||
: Icons.add,
|
||||
color:
|
||||
t.template.value!.amount < 0
|
||||
? Colors.red
|
||||
: Colors.green,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.play_arrow),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: <Widget>[Text("Upcoming Transactions")] + transactions,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/ui/pages/template_list.dart
Normal file
75
lib/ui/pages/template_list.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
import 'package:okane/ui/widgets/add_template.dart';
|
||||
|
||||
class TemplateListPage extends StatefulWidget {
|
||||
const TemplateListPage({super.key});
|
||||
|
||||
@override
|
||||
State<TemplateListPage> createState() => TemplateListState();
|
||||
}
|
||||
|
||||
class TemplateListState extends State<TemplateListPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CoreCubit, CoreState>(
|
||||
builder: (context, state) {
|
||||
final account = GetIt.I.get<CoreCubit>().activeAccount;
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: ListView.builder(
|
||||
itemCount: state.recurringTransactions.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder:
|
||||
(ctx, idx) => Card(
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
state
|
||||
.recurringTransactions[idx]
|
||||
.template
|
||||
.value!
|
||||
.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton(
|
||||
child: Icon(Icons.add),
|
||||
onPressed:
|
||||
account == null
|
||||
? () {}
|
||||
: () {
|
||||
showDialogOrModal(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AddTransactionTemplateWidget(
|
||||
activeAccountItem: account,
|
||||
onAdd: () {
|
||||
setState(() {});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
showDragHandle: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
118
lib/ui/pages/transaction_details.dart
Normal file
118
lib/ui/pages/transaction_details.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:okane/database/collections/beneficiary.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
import 'package:okane/ui/widgets/image_wrapper.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class TransactionDetailsPage extends StatelessWidget {
|
||||
final bool isPage;
|
||||
|
||||
const TransactionDetailsPage({this.isPage = false, super.key});
|
||||
|
||||
static MaterialPageRoute<void> get mobileRoute =>
|
||||
MaterialPageRoute(builder: (_) => TransactionDetailsPage(isPage: true));
|
||||
|
||||
Future<void> _updateBeneficiaryIcon(Beneficiary beneficiary) async {
|
||||
final pickedFile = await FilePicker.platform.pickFiles(
|
||||
type: FileType.image,
|
||||
);
|
||||
if (pickedFile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final file = pickedFile.files.first;
|
||||
final suppPath = await getApplicationSupportDirectory();
|
||||
final imageDir = p.join(suppPath.path, "beneficiaries");
|
||||
final imagePath = p.join(imageDir, file.name);
|
||||
print("Copying ${file.path!} to $imagePath");
|
||||
|
||||
await Directory(imageDir).create(recursive: true);
|
||||
if (beneficiary.imagePath != null) {
|
||||
await File(beneficiary.imagePath!).delete();
|
||||
}
|
||||
|
||||
await File(file.path!).copy(imagePath);
|
||||
|
||||
print("Updating DB");
|
||||
beneficiary.imagePath = imagePath;
|
||||
await upsertBeneficiary(beneficiary);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final widget = BlocBuilder<CoreCubit, CoreState>(
|
||||
builder: (context, state) {
|
||||
if (state.activeTransaction == null) {
|
||||
return Text("No transaction selected");
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ListView(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
StreamBuilder(
|
||||
stream: watchBeneficiaryObject(
|
||||
state.activeTransaction!.beneficiary.value!.id,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final obj =
|
||||
snapshot.data ??
|
||||
state.activeTransaction!.beneficiary.value!;
|
||||
return ImageWrapper(
|
||||
title: obj.name,
|
||||
path: obj.imagePath,
|
||||
onTap: () => _updateBeneficiaryIcon(obj),
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
state.activeTransaction!.beneficiary.value!.name,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
// TODO: Implement
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children:
|
||||
state.activeTransaction!.tags
|
||||
.map((tag) => Chip(label: Text(tag)))
|
||||
.toList(),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
state.activeTransaction!.amount > 0
|
||||
? Icon(Icons.add)
|
||||
: Icon(Icons.remove),
|
||||
Text(formatCurrency(state.activeTransaction!.amount)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (isPage) {
|
||||
return Scaffold(body: widget);
|
||||
}
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
163
lib/ui/pages/transaction_list.dart
Normal file
163
lib/ui/pages/transaction_list.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:grouped_list/grouped_list.dart';
|
||||
import 'package:okane/database/collections/transaction.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/screen.dart';
|
||||
import 'package:okane/ui/pages/account/balance_graph_card.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
import 'package:okane/ui/widgets/add_transaction.dart';
|
||||
import 'package:okane/ui/widgets/transaction_card.dart';
|
||||
|
||||
class TransactionListPage extends StatefulWidget {
|
||||
const TransactionListPage({super.key});
|
||||
|
||||
@override
|
||||
State<TransactionListPage> createState() => TransactionListState();
|
||||
}
|
||||
|
||||
class TransactionListState extends State<TransactionListPage> {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CoreCubit, CoreState>(
|
||||
builder: (context, state) {
|
||||
final account = GetIt.I.get<CoreCubit>().activeAccount;
|
||||
return Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: AccountBalanceGraphCard(),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: GroupedListView(
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
elements: state.transactions,
|
||||
reverse: true,
|
||||
groupBy: (Transaction item) => formatDateTime(item.date),
|
||||
groupHeaderBuilder:
|
||||
(item) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withAlpha(170),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Text(
|
||||
formatDateTime(item.date),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
shrinkWrap: true,
|
||||
indexedItemBuilder:
|
||||
(ctx, item, idx) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: TransactionCard(
|
||||
transaction: item,
|
||||
onTap: () {
|
||||
GetIt.I.get<CoreCubit>().setActiveTransaction(
|
||||
item,
|
||||
);
|
||||
if (getScreenSize(ctx) == ScreenSize.small) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed("/transactions/details");
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
/*Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: GroupedListView(
|
||||
elements: state.transactions,
|
||||
reverse: true,
|
||||
groupBy:
|
||||
(Transaction item) => formatDateTime(item.date),
|
||||
groupHeaderBuilder:
|
||||
(item) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withAlpha(170),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Text(
|
||||
formatDateTime(item.date),
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
shrinkWrap: true,
|
||||
indexedItemBuilder:
|
||||
(ctx, item, idx) => TransactionCard(
|
||||
transaction: item,
|
||||
onTap: () {
|
||||
GetIt.I.get<CoreCubit>().setActiveTransaction(
|
||||
item,
|
||||
);
|
||||
if (getScreenSize(ctx) == ScreenSize.small) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed("/transactions/details");
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),*/
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton(
|
||||
child: Icon(Icons.add),
|
||||
onPressed:
|
||||
account == null
|
||||
? () {}
|
||||
: () {
|
||||
showDialogOrModal(
|
||||
context: context,
|
||||
builder:
|
||||
(ctx) => AddTransactionWidget(
|
||||
activeAccountItem: account,
|
||||
onAdd: () {
|
||||
setState(() {});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
showDragHandle: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
152
lib/ui/state/core.dart
Normal file
152
lib/ui/state/core.dart
Normal file
@@ -0,0 +1,152 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:okane/database/collections/account.dart';
|
||||
import 'package:okane/database/collections/beneficiary.dart';
|
||||
import 'package:okane/database/collections/expense_category.dart';
|
||||
import 'package:okane/database/collections/recurrent.dart';
|
||||
import 'package:okane/database/collections/template.dart';
|
||||
import 'package:okane/database/collections/transaction.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/navigation.dart';
|
||||
|
||||
part 'core.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class CoreState with _$CoreState {
|
||||
const factory CoreState({
|
||||
@Default(OkanePage.accounts) OkanePage activePage,
|
||||
int? activeAccountIndex,
|
||||
@Default(null) Transaction? activeTransaction,
|
||||
@Default([]) List<Account> accounts,
|
||||
@Default([]) List<RecurringTransaction> recurringTransactions,
|
||||
@Default([]) List<Transaction> transactions,
|
||||
@Default([]) List<TransactionTemplate> transactionTemplates,
|
||||
@Default([]) List<Beneficiary> beneficiaries,
|
||||
@Default([]) List<ExpenseCategory> expenseCategories,
|
||||
}) = _CoreState;
|
||||
}
|
||||
|
||||
class CoreCubit extends Cubit<CoreState> {
|
||||
CoreCubit() : super(CoreState());
|
||||
|
||||
StreamSubscription<void>? _recurringTransactionStreamSubscription;
|
||||
StreamSubscription<void>? _transactionTemplatesStreamSubcription;
|
||||
StreamSubscription<void>? _accountsStreamSubscription;
|
||||
StreamSubscription<void>? _transactionsStreamSubscription;
|
||||
StreamSubscription<void>? _beneficiariesStreamSubscription;
|
||||
StreamSubscription<void>? _expenseCategoryStreamSubscription;
|
||||
|
||||
void setupAccountStream() {
|
||||
_accountsStreamSubscription?.cancel();
|
||||
_accountsStreamSubscription = watchAccounts().listen((_) async {
|
||||
final resetStreams = state.activeAccountIndex == null;
|
||||
final accounts = await getAccounts();
|
||||
emit(
|
||||
state.copyWith(
|
||||
accounts: accounts,
|
||||
activeAccountIndex: state.activeAccountIndex ?? 0,
|
||||
),
|
||||
);
|
||||
|
||||
if (resetStreams) {
|
||||
setupStreams(accounts[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setupStreams(Account account) {
|
||||
setupAccountStream();
|
||||
_recurringTransactionStreamSubscription?.cancel();
|
||||
_recurringTransactionStreamSubscription = watchRecurringTransactions(
|
||||
activeAccount!,
|
||||
).listen((_) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
recurringTransactions: await getRecurringTransactions(activeAccount!),
|
||||
),
|
||||
);
|
||||
});
|
||||
_transactionTemplatesStreamSubcription?.cancel();
|
||||
_transactionTemplatesStreamSubcription = watchTransactionTemplates(
|
||||
activeAccount!,
|
||||
).listen((_) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
transactionTemplates: await getTransactionTemplates(activeAccount!),
|
||||
),
|
||||
);
|
||||
});
|
||||
_transactionsStreamSubscription?.cancel();
|
||||
_transactionsStreamSubscription = watchTransactions(activeAccount!).listen((
|
||||
_,
|
||||
) async {
|
||||
emit(state.copyWith(transactions: await getTransactions(activeAccount!)));
|
||||
});
|
||||
_beneficiariesStreamSubscription?.cancel();
|
||||
_beneficiariesStreamSubscription = watchBeneficiaries().listen((_) async {
|
||||
emit(state.copyWith(beneficiaries: await getBeneficiaries()));
|
||||
});
|
||||
_expenseCategoryStreamSubscription?.cancel();
|
||||
_expenseCategoryStreamSubscription = watchExpenseCategory().listen((
|
||||
_,
|
||||
) async {
|
||||
emit(state.copyWith(expenseCategories: await getExpenseCategories()));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
final accounts = await getAccounts();
|
||||
final account = accounts.isEmpty ? null : accounts[0];
|
||||
emit(
|
||||
state.copyWith(
|
||||
accounts: accounts,
|
||||
activeAccountIndex: accounts.isEmpty ? null : 0,
|
||||
transactions: await getTransactions(account),
|
||||
beneficiaries: await getBeneficiaries(),
|
||||
transactionTemplates: await getTransactionTemplates(account),
|
||||
recurringTransactions: await getRecurringTransactions(account),
|
||||
expenseCategories: await getExpenseCategories(),
|
||||
),
|
||||
);
|
||||
|
||||
if (account != null) {
|
||||
setupStreams(account);
|
||||
} else {
|
||||
setupAccountStream();
|
||||
}
|
||||
print("Core init done");
|
||||
}
|
||||
|
||||
void setPage(OkanePage page) {
|
||||
emit(state.copyWith(activePage: page));
|
||||
}
|
||||
|
||||
Future<void> setActiveAccountIndex(int index) async {
|
||||
final account = state.accounts[index];
|
||||
emit(
|
||||
state.copyWith(
|
||||
activeAccountIndex: index,
|
||||
transactions: await getTransactions(account),
|
||||
beneficiaries: await getBeneficiaries(),
|
||||
transactionTemplates: await getTransactionTemplates(account),
|
||||
recurringTransactions: await getRecurringTransactions(account),
|
||||
),
|
||||
);
|
||||
setupStreams(account);
|
||||
}
|
||||
|
||||
void setActiveTransaction(Transaction? item) {
|
||||
emit(state.copyWith(activeTransaction: item));
|
||||
}
|
||||
|
||||
void setAccounts(List<Account> accounts) {
|
||||
emit(state.copyWith(accounts: accounts));
|
||||
}
|
||||
|
||||
Account? get activeAccount =>
|
||||
state.activeAccountIndex == null
|
||||
? null
|
||||
: state.accounts[state.activeAccountIndex!];
|
||||
}
|
||||
408
lib/ui/state/core.freezed.dart
Normal file
408
lib/ui/state/core.freezed.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'core.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
/// @nodoc
|
||||
mixin _$CoreState {
|
||||
OkanePage get activePage => throw _privateConstructorUsedError;
|
||||
int? get activeAccountIndex => throw _privateConstructorUsedError;
|
||||
Transaction? get activeTransaction => throw _privateConstructorUsedError;
|
||||
List<Account> get accounts => throw _privateConstructorUsedError;
|
||||
List<RecurringTransaction> get recurringTransactions =>
|
||||
throw _privateConstructorUsedError;
|
||||
List<Transaction> get transactions => throw _privateConstructorUsedError;
|
||||
List<TransactionTemplate> get transactionTemplates =>
|
||||
throw _privateConstructorUsedError;
|
||||
List<Beneficiary> get beneficiaries => throw _privateConstructorUsedError;
|
||||
List<ExpenseCategory> get expenseCategories =>
|
||||
throw _privateConstructorUsedError;
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
$CoreStateCopyWith<CoreState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $CoreStateCopyWith<$Res> {
|
||||
factory $CoreStateCopyWith(CoreState value, $Res Function(CoreState) then) =
|
||||
_$CoreStateCopyWithImpl<$Res, CoreState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
OkanePage activePage,
|
||||
int? activeAccountIndex,
|
||||
Transaction? activeTransaction,
|
||||
List<Account> accounts,
|
||||
List<RecurringTransaction> recurringTransactions,
|
||||
List<Transaction> transactions,
|
||||
List<TransactionTemplate> transactionTemplates,
|
||||
List<Beneficiary> beneficiaries,
|
||||
List<ExpenseCategory> expenseCategories,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$CoreStateCopyWithImpl<$Res, $Val extends CoreState>
|
||||
implements $CoreStateCopyWith<$Res> {
|
||||
_$CoreStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? activePage = null,
|
||||
Object? activeAccountIndex = freezed,
|
||||
Object? activeTransaction = freezed,
|
||||
Object? accounts = null,
|
||||
Object? recurringTransactions = null,
|
||||
Object? transactions = null,
|
||||
Object? transactionTemplates = null,
|
||||
Object? beneficiaries = null,
|
||||
Object? expenseCategories = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
activePage:
|
||||
null == activePage
|
||||
? _value.activePage
|
||||
: activePage // ignore: cast_nullable_to_non_nullable
|
||||
as OkanePage,
|
||||
activeAccountIndex:
|
||||
freezed == activeAccountIndex
|
||||
? _value.activeAccountIndex
|
||||
: activeAccountIndex // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
activeTransaction:
|
||||
freezed == activeTransaction
|
||||
? _value.activeTransaction
|
||||
: activeTransaction // ignore: cast_nullable_to_non_nullable
|
||||
as Transaction?,
|
||||
accounts:
|
||||
null == accounts
|
||||
? _value.accounts
|
||||
: accounts // ignore: cast_nullable_to_non_nullable
|
||||
as List<Account>,
|
||||
recurringTransactions:
|
||||
null == recurringTransactions
|
||||
? _value.recurringTransactions
|
||||
: recurringTransactions // ignore: cast_nullable_to_non_nullable
|
||||
as List<RecurringTransaction>,
|
||||
transactions:
|
||||
null == transactions
|
||||
? _value.transactions
|
||||
: transactions // ignore: cast_nullable_to_non_nullable
|
||||
as List<Transaction>,
|
||||
transactionTemplates:
|
||||
null == transactionTemplates
|
||||
? _value.transactionTemplates
|
||||
: transactionTemplates // ignore: cast_nullable_to_non_nullable
|
||||
as List<TransactionTemplate>,
|
||||
beneficiaries:
|
||||
null == beneficiaries
|
||||
? _value.beneficiaries
|
||||
: beneficiaries // ignore: cast_nullable_to_non_nullable
|
||||
as List<Beneficiary>,
|
||||
expenseCategories:
|
||||
null == expenseCategories
|
||||
? _value.expenseCategories
|
||||
: expenseCategories // ignore: cast_nullable_to_non_nullable
|
||||
as List<ExpenseCategory>,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$CoreStateImplCopyWith<$Res>
|
||||
implements $CoreStateCopyWith<$Res> {
|
||||
factory _$$CoreStateImplCopyWith(
|
||||
_$CoreStateImpl value,
|
||||
$Res Function(_$CoreStateImpl) then,
|
||||
) = __$$CoreStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
OkanePage activePage,
|
||||
int? activeAccountIndex,
|
||||
Transaction? activeTransaction,
|
||||
List<Account> accounts,
|
||||
List<RecurringTransaction> recurringTransactions,
|
||||
List<Transaction> transactions,
|
||||
List<TransactionTemplate> transactionTemplates,
|
||||
List<Beneficiary> beneficiaries,
|
||||
List<ExpenseCategory> expenseCategories,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$CoreStateImplCopyWithImpl<$Res>
|
||||
extends _$CoreStateCopyWithImpl<$Res, _$CoreStateImpl>
|
||||
implements _$$CoreStateImplCopyWith<$Res> {
|
||||
__$$CoreStateImplCopyWithImpl(
|
||||
_$CoreStateImpl _value,
|
||||
$Res Function(_$CoreStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? activePage = null,
|
||||
Object? activeAccountIndex = freezed,
|
||||
Object? activeTransaction = freezed,
|
||||
Object? accounts = null,
|
||||
Object? recurringTransactions = null,
|
||||
Object? transactions = null,
|
||||
Object? transactionTemplates = null,
|
||||
Object? beneficiaries = null,
|
||||
Object? expenseCategories = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$CoreStateImpl(
|
||||
activePage:
|
||||
null == activePage
|
||||
? _value.activePage
|
||||
: activePage // ignore: cast_nullable_to_non_nullable
|
||||
as OkanePage,
|
||||
activeAccountIndex:
|
||||
freezed == activeAccountIndex
|
||||
? _value.activeAccountIndex
|
||||
: activeAccountIndex // ignore: cast_nullable_to_non_nullable
|
||||
as int?,
|
||||
activeTransaction:
|
||||
freezed == activeTransaction
|
||||
? _value.activeTransaction
|
||||
: activeTransaction // ignore: cast_nullable_to_non_nullable
|
||||
as Transaction?,
|
||||
accounts:
|
||||
null == accounts
|
||||
? _value._accounts
|
||||
: accounts // ignore: cast_nullable_to_non_nullable
|
||||
as List<Account>,
|
||||
recurringTransactions:
|
||||
null == recurringTransactions
|
||||
? _value._recurringTransactions
|
||||
: recurringTransactions // ignore: cast_nullable_to_non_nullable
|
||||
as List<RecurringTransaction>,
|
||||
transactions:
|
||||
null == transactions
|
||||
? _value._transactions
|
||||
: transactions // ignore: cast_nullable_to_non_nullable
|
||||
as List<Transaction>,
|
||||
transactionTemplates:
|
||||
null == transactionTemplates
|
||||
? _value._transactionTemplates
|
||||
: transactionTemplates // ignore: cast_nullable_to_non_nullable
|
||||
as List<TransactionTemplate>,
|
||||
beneficiaries:
|
||||
null == beneficiaries
|
||||
? _value._beneficiaries
|
||||
: beneficiaries // ignore: cast_nullable_to_non_nullable
|
||||
as List<Beneficiary>,
|
||||
expenseCategories:
|
||||
null == expenseCategories
|
||||
? _value._expenseCategories
|
||||
: expenseCategories // ignore: cast_nullable_to_non_nullable
|
||||
as List<ExpenseCategory>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$CoreStateImpl implements _CoreState {
|
||||
const _$CoreStateImpl({
|
||||
this.activePage = OkanePage.accounts,
|
||||
this.activeAccountIndex,
|
||||
this.activeTransaction = null,
|
||||
final List<Account> accounts = const [],
|
||||
final List<RecurringTransaction> recurringTransactions = const [],
|
||||
final List<Transaction> transactions = const [],
|
||||
final List<TransactionTemplate> transactionTemplates = const [],
|
||||
final List<Beneficiary> beneficiaries = const [],
|
||||
final List<ExpenseCategory> expenseCategories = const [],
|
||||
}) : _accounts = accounts,
|
||||
_recurringTransactions = recurringTransactions,
|
||||
_transactions = transactions,
|
||||
_transactionTemplates = transactionTemplates,
|
||||
_beneficiaries = beneficiaries,
|
||||
_expenseCategories = expenseCategories;
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final OkanePage activePage;
|
||||
@override
|
||||
final int? activeAccountIndex;
|
||||
@override
|
||||
@JsonKey()
|
||||
final Transaction? activeTransaction;
|
||||
final List<Account> _accounts;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<Account> get accounts {
|
||||
if (_accounts is EqualUnmodifiableListView) return _accounts;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_accounts);
|
||||
}
|
||||
|
||||
final List<RecurringTransaction> _recurringTransactions;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<RecurringTransaction> get recurringTransactions {
|
||||
if (_recurringTransactions is EqualUnmodifiableListView)
|
||||
return _recurringTransactions;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_recurringTransactions);
|
||||
}
|
||||
|
||||
final List<Transaction> _transactions;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<Transaction> get transactions {
|
||||
if (_transactions is EqualUnmodifiableListView) return _transactions;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_transactions);
|
||||
}
|
||||
|
||||
final List<TransactionTemplate> _transactionTemplates;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<TransactionTemplate> get transactionTemplates {
|
||||
if (_transactionTemplates is EqualUnmodifiableListView)
|
||||
return _transactionTemplates;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_transactionTemplates);
|
||||
}
|
||||
|
||||
final List<Beneficiary> _beneficiaries;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<Beneficiary> get beneficiaries {
|
||||
if (_beneficiaries is EqualUnmodifiableListView) return _beneficiaries;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_beneficiaries);
|
||||
}
|
||||
|
||||
final List<ExpenseCategory> _expenseCategories;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ExpenseCategory> get expenseCategories {
|
||||
if (_expenseCategories is EqualUnmodifiableListView)
|
||||
return _expenseCategories;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_expenseCategories);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CoreState(activePage: $activePage, activeAccountIndex: $activeAccountIndex, activeTransaction: $activeTransaction, accounts: $accounts, recurringTransactions: $recurringTransactions, transactions: $transactions, transactionTemplates: $transactionTemplates, beneficiaries: $beneficiaries, expenseCategories: $expenseCategories)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$CoreStateImpl &&
|
||||
(identical(other.activePage, activePage) ||
|
||||
other.activePage == activePage) &&
|
||||
(identical(other.activeAccountIndex, activeAccountIndex) ||
|
||||
other.activeAccountIndex == activeAccountIndex) &&
|
||||
(identical(other.activeTransaction, activeTransaction) ||
|
||||
other.activeTransaction == activeTransaction) &&
|
||||
const DeepCollectionEquality().equals(other._accounts, _accounts) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._recurringTransactions,
|
||||
_recurringTransactions,
|
||||
) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._transactions,
|
||||
_transactions,
|
||||
) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._transactionTemplates,
|
||||
_transactionTemplates,
|
||||
) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._beneficiaries,
|
||||
_beneficiaries,
|
||||
) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._expenseCategories,
|
||||
_expenseCategories,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
activePage,
|
||||
activeAccountIndex,
|
||||
activeTransaction,
|
||||
const DeepCollectionEquality().hash(_accounts),
|
||||
const DeepCollectionEquality().hash(_recurringTransactions),
|
||||
const DeepCollectionEquality().hash(_transactions),
|
||||
const DeepCollectionEquality().hash(_transactionTemplates),
|
||||
const DeepCollectionEquality().hash(_beneficiaries),
|
||||
const DeepCollectionEquality().hash(_expenseCategories),
|
||||
);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$CoreStateImplCopyWith<_$CoreStateImpl> get copyWith =>
|
||||
__$$CoreStateImplCopyWithImpl<_$CoreStateImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _CoreState implements CoreState {
|
||||
const factory _CoreState({
|
||||
final OkanePage activePage,
|
||||
final int? activeAccountIndex,
|
||||
final Transaction? activeTransaction,
|
||||
final List<Account> accounts,
|
||||
final List<RecurringTransaction> recurringTransactions,
|
||||
final List<Transaction> transactions,
|
||||
final List<TransactionTemplate> transactionTemplates,
|
||||
final List<Beneficiary> beneficiaries,
|
||||
final List<ExpenseCategory> expenseCategories,
|
||||
}) = _$CoreStateImpl;
|
||||
|
||||
@override
|
||||
OkanePage get activePage;
|
||||
@override
|
||||
int? get activeAccountIndex;
|
||||
@override
|
||||
Transaction? get activeTransaction;
|
||||
@override
|
||||
List<Account> get accounts;
|
||||
@override
|
||||
List<RecurringTransaction> get recurringTransactions;
|
||||
@override
|
||||
List<Transaction> get transactions;
|
||||
@override
|
||||
List<TransactionTemplate> get transactionTemplates;
|
||||
@override
|
||||
List<Beneficiary> get beneficiaries;
|
||||
@override
|
||||
List<ExpenseCategory> get expenseCategories;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$CoreStateImplCopyWith<_$CoreStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
1
lib/ui/transaction.dart
Normal file
1
lib/ui/transaction.dart
Normal file
@@ -0,0 +1 @@
|
||||
enum TransactionDirection { send, receive }
|
||||
71
lib/ui/utils.dart
Normal file
71
lib/ui/utils.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:okane/screen.dart';
|
||||
|
||||
Future<T?> showDialogOrModal<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
bool showDragHandle = true,
|
||||
}) {
|
||||
final screenSize = getScreenSize(context);
|
||||
final width = MediaQuery.sizeOf(context).shortestSide;
|
||||
|
||||
return switch (screenSize) {
|
||||
ScreenSize.small => showModalBottomSheet<T>(
|
||||
context: context,
|
||||
showDragHandle: showDragHandle,
|
||||
builder:
|
||||
(context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: 32),
|
||||
child: builder(context),
|
||||
),
|
||||
),
|
||||
ScreenSize.normal => showDialog<T>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => Dialog(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: width * 0.7),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 32),
|
||||
child: builder(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
DateTime toMidnight(DateTime t) {
|
||||
return DateTime(t.year, t.month, t.day);
|
||||
}
|
||||
|
||||
String zeroPad(int i) {
|
||||
if (i <= 9) {
|
||||
return "0$i";
|
||||
}
|
||||
|
||||
return i.toString();
|
||||
}
|
||||
|
||||
String formatDateTime(DateTime dt, {bool formatYear = true}) {
|
||||
if (!formatYear) {
|
||||
return "${zeroPad(dt.day)}.${zeroPad(dt.month)}";
|
||||
}
|
||||
return "${zeroPad(dt.day)}.${zeroPad(dt.month)}.${zeroPad(dt.year)}";
|
||||
}
|
||||
|
||||
Color colorHash(String text) {
|
||||
final hue =
|
||||
text.characters
|
||||
.map((c) => c.codeUnitAt(0).toDouble())
|
||||
.reduce((acc, c) => c + ((acc.toInt() << 5) - acc)) %
|
||||
360;
|
||||
return HSVColor.fromAHSV(1, hue, 0.5, 0.5).toColor();
|
||||
}
|
||||
|
||||
String formatCurrency(double amount, {bool precise = true}) {
|
||||
if (!precise) {
|
||||
return "${amount.toInt()}€";
|
||||
}
|
||||
return "${amount.toStringAsFixed(2)}€";
|
||||
}
|
||||
66
lib/ui/widgets/add_expense_category.dart
Normal file
66
lib/ui/widgets/add_expense_category.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:okane/database/collections/expense_category.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
|
||||
class AddExpenseCategory extends StatefulWidget {
|
||||
const AddExpenseCategory({super.key});
|
||||
|
||||
@override
|
||||
AddExpenseCategoryState createState() => AddExpenseCategoryState();
|
||||
}
|
||||
|
||||
class AddExpenseCategoryState extends State<AddExpenseCategory> {
|
||||
final TextEditingController _categoryNameController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CoreCubit, CoreState>(
|
||||
builder:
|
||||
(context, state) => ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: 300),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListView.builder(
|
||||
itemCount: state.expenseCategories.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder:
|
||||
(context, index) => ListTile(
|
||||
title: Text(state.expenseCategories[index].name),
|
||||
onTap: () {
|
||||
_categoryNameController.text = "";
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop(state.expenseCategories[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
TextField(
|
||||
decoration: InputDecoration(hintText: "Category name"),
|
||||
controller: _categoryNameController,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
final category =
|
||||
ExpenseCategory()
|
||||
..name = _categoryNameController.text;
|
||||
await upsertExpenseCategory(category);
|
||||
_categoryNameController.text = "";
|
||||
Navigator.of(context).pop(category);
|
||||
},
|
||||
child: Text("Add"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
284
lib/ui/widgets/add_recurring_transaction.dart
Normal file
284
lib/ui/widgets/add_recurring_transaction.dart
Normal file
@@ -0,0 +1,284 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_picker_plus/picker.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:okane/database/collections/account.dart';
|
||||
import 'package:okane/database/collections/beneficiary.dart';
|
||||
import 'package:okane/database/collections/recurrent.dart';
|
||||
import 'package:okane/database/collections/template.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/transaction.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
import 'package:searchfield/searchfield.dart';
|
||||
|
||||
enum Period { days, weeks, months, years }
|
||||
|
||||
class AddRecurringTransactionTemplateWidget extends StatefulWidget {
|
||||
final VoidCallback onAdd;
|
||||
|
||||
final Account activeAccountItem;
|
||||
|
||||
const AddRecurringTransactionTemplateWidget({
|
||||
super.key,
|
||||
required this.activeAccountItem,
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddRecurringTransactionTemplateWidget> createState() =>
|
||||
_AddRecurringTransactionTemplateWidgetState();
|
||||
}
|
||||
|
||||
class _AddRecurringTransactionTemplateWidgetState
|
||||
extends State<AddRecurringTransactionTemplateWidget> {
|
||||
final TextEditingController _beneficiaryTextController =
|
||||
TextEditingController();
|
||||
final TextEditingController _amountTextController = TextEditingController();
|
||||
final TextEditingController _templateNameController = TextEditingController();
|
||||
|
||||
List<Beneficiary> beneficiaries = [];
|
||||
|
||||
SearchFieldListItem<Beneficiary>? _selectedBeneficiary;
|
||||
|
||||
TransactionDirection _selectedDirection = TransactionDirection.send;
|
||||
|
||||
Period _selectedPeriod = Period.months;
|
||||
int _periodSize = 1;
|
||||
|
||||
String getBeneficiaryName(Beneficiary item) {
|
||||
return switch (item.type) {
|
||||
BeneficiaryType.account => "${item.name} (Account)",
|
||||
BeneficiaryType.other => item.name,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _submit(BuildContext context) async {
|
||||
final beneficiaryName = _beneficiaryTextController.text;
|
||||
if (_selectedBeneficiary == null && beneficiaryName.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (_templateNameController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
Beneficiary? beneficiary = _selectedBeneficiary?.item;
|
||||
if (beneficiary == null ||
|
||||
getBeneficiaryName(beneficiary) != beneficiaryName) {
|
||||
// Add a new beneficiary, if none was selected
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text("Add Beneficiary"),
|
||||
content: Text(
|
||||
"The beneficiary '$beneficiaryName' does not exist. Do you want to add it?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
child: const Text('Add'),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (result == null || !result) {
|
||||
return;
|
||||
}
|
||||
|
||||
beneficiary =
|
||||
Beneficiary()
|
||||
..name = beneficiaryName
|
||||
..type = BeneficiaryType.other;
|
||||
await upsertBeneficiary(beneficiary);
|
||||
}
|
||||
|
||||
final days = switch (_selectedPeriod) {
|
||||
Period.days => _periodSize,
|
||||
Period.weeks => _periodSize * 7,
|
||||
Period.months => _periodSize * 31,
|
||||
Period.years => _periodSize * 365,
|
||||
};
|
||||
final factor = switch (_selectedDirection) {
|
||||
TransactionDirection.send => -1,
|
||||
TransactionDirection.receive => 1,
|
||||
};
|
||||
final amount = factor * double.parse(_amountTextController.text).abs();
|
||||
final template =
|
||||
TransactionTemplate()
|
||||
..name = _templateNameController.text
|
||||
..beneficiary.value = beneficiary
|
||||
..account.value = widget.activeAccountItem
|
||||
..recurring = true
|
||||
..amount = amount;
|
||||
await upsertTransactionTemplate(template);
|
||||
|
||||
final transaction =
|
||||
RecurringTransaction()
|
||||
..lastExecution = null
|
||||
..template.value = template
|
||||
..account.value = widget.activeAccountItem
|
||||
..days = days;
|
||||
await upsertRecurringTransaction(transaction);
|
||||
|
||||
_periodSize = 1;
|
||||
_selectedPeriod = Period.weeks;
|
||||
_amountTextController.text = "";
|
||||
_templateNameController.text = "";
|
||||
widget.onAdd();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: TextField(
|
||||
controller: _templateNameController,
|
||||
decoration: InputDecoration(label: Text("Template name")),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: SearchField<Beneficiary>(
|
||||
suggestions:
|
||||
beneficiaries
|
||||
.where((el) {
|
||||
final bloc = GetIt.I.get<CoreCubit>();
|
||||
if (el.type == BeneficiaryType.account) {
|
||||
return el.account.value?.id != bloc.activeAccount?.id;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((el) {
|
||||
return SearchFieldListItem(
|
||||
getBeneficiaryName(el),
|
||||
item: el,
|
||||
);
|
||||
})
|
||||
.toList(),
|
||||
hint: "Beneficiary",
|
||||
controller: _beneficiaryTextController,
|
||||
selectedValue: _selectedBeneficiary,
|
||||
onSuggestionTap: (beneficiary) {
|
||||
setState(() => _selectedBeneficiary = beneficiary);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: TextField(
|
||||
controller: _amountTextController,
|
||||
keyboardType: TextInputType.numberWithOptions(
|
||||
signed: false,
|
||||
decimal: false,
|
||||
),
|
||||
decoration: InputDecoration(hintText: "Amount"),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: SegmentedButton<TransactionDirection>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: TransactionDirection.send,
|
||||
label: Text("Send"),
|
||||
icon: Icon(Icons.remove),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: TransactionDirection.receive,
|
||||
label: Text("Receive"),
|
||||
icon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
selected: <TransactionDirection>{_selectedDirection},
|
||||
multiSelectionEnabled: false,
|
||||
onSelectionChanged: (selection) {
|
||||
setState(() => _selectedDirection = selection.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 16, right: 16, top: 16),
|
||||
child: SegmentedButton<Period>(
|
||||
segments: [
|
||||
ButtonSegment(value: Period.days, label: Text("Days")),
|
||||
ButtonSegment(value: Period.weeks, label: Text("Weeks")),
|
||||
ButtonSegment(value: Period.months, label: Text("Months")),
|
||||
ButtonSegment(value: Period.years, label: Text("Years")),
|
||||
],
|
||||
selected: <Period>{_selectedPeriod},
|
||||
multiSelectionEnabled: false,
|
||||
onSelectionChanged: (selection) {
|
||||
setState(() => _selectedPeriod = selection.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: "Repeat every ",
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
Picker(
|
||||
adapter: NumberPickerAdapter(
|
||||
data: [
|
||||
NumberPickerColumn(
|
||||
begin: 1,
|
||||
end: 999,
|
||||
initValue: _periodSize,
|
||||
),
|
||||
],
|
||||
),
|
||||
hideHeader: true,
|
||||
selectedTextStyle: TextStyle(color: Colors.blue),
|
||||
onConfirm: (Picker picker, List value) {
|
||||
setState(() {
|
||||
_periodSize = (value.first as int) + 1;
|
||||
});
|
||||
},
|
||||
).showDialog(context);
|
||||
},
|
||||
child: Text(_periodSize.toString()),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: switch (_selectedPeriod) {
|
||||
Period.days => " days",
|
||||
Period.weeks => " weeks",
|
||||
Period.months => " months",
|
||||
Period.years => " years",
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _submit(context),
|
||||
child: Text("Add"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
229
lib/ui/widgets/add_template.dart
Normal file
229
lib/ui/widgets/add_template.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:okane/database/collections/account.dart';
|
||||
import 'package:okane/database/collections/beneficiary.dart';
|
||||
import 'package:okane/database/collections/expense_category.dart';
|
||||
import 'package:okane/database/collections/template.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/transaction.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
import 'package:okane/ui/widgets/add_expense_category.dart';
|
||||
import 'package:searchfield/searchfield.dart';
|
||||
|
||||
class AddTransactionTemplateWidget extends StatefulWidget {
|
||||
final VoidCallback onAdd;
|
||||
|
||||
final Account activeAccountItem;
|
||||
|
||||
const AddTransactionTemplateWidget({
|
||||
super.key,
|
||||
required this.activeAccountItem,
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddTransactionTemplateWidget> createState() =>
|
||||
_AddTransactionTemplateWidgetState();
|
||||
}
|
||||
|
||||
class _AddTransactionTemplateWidgetState
|
||||
extends State<AddTransactionTemplateWidget> {
|
||||
final TextEditingController _beneficiaryTextController =
|
||||
TextEditingController();
|
||||
final TextEditingController _amountTextController = TextEditingController();
|
||||
final TextEditingController _templateNameController = TextEditingController();
|
||||
|
||||
SearchFieldListItem<Beneficiary>? _selectedBeneficiary;
|
||||
|
||||
TransactionDirection _selectedDirection = TransactionDirection.send;
|
||||
|
||||
ExpenseCategory? _expenseCategory = null;
|
||||
|
||||
String getBeneficiaryName(Beneficiary item) {
|
||||
return switch (item.type) {
|
||||
BeneficiaryType.account => "${item.name} (Account)",
|
||||
BeneficiaryType.other => item.name,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _submit(BuildContext context) async {
|
||||
final beneficiaryName = _beneficiaryTextController.text;
|
||||
if (_selectedBeneficiary == null && beneficiaryName.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (_templateNameController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
Beneficiary? beneficiary = _selectedBeneficiary?.item;
|
||||
if (beneficiary == null ||
|
||||
getBeneficiaryName(beneficiary) != beneficiaryName) {
|
||||
// Add a new beneficiary, if none was selected
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text("Add Beneficiary"),
|
||||
content: Text(
|
||||
"The beneficiary '$beneficiaryName' does not exist. Do you want to add it?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
child: const Text('Add'),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (result == null || !result) {
|
||||
return;
|
||||
}
|
||||
|
||||
beneficiary =
|
||||
Beneficiary()
|
||||
..name = beneficiaryName
|
||||
..type = BeneficiaryType.other;
|
||||
await upsertBeneficiary(beneficiary);
|
||||
}
|
||||
|
||||
final factor = switch (_selectedDirection) {
|
||||
TransactionDirection.send => -1,
|
||||
TransactionDirection.receive => 1,
|
||||
};
|
||||
final amount = factor * double.parse(_amountTextController.text).abs();
|
||||
final transaction =
|
||||
TransactionTemplate()
|
||||
..name = _templateNameController.text
|
||||
..account.value = widget.activeAccountItem
|
||||
..beneficiary.value = beneficiary
|
||||
..expenseCategory.value = _expenseCategory
|
||||
..recurring = false
|
||||
..amount = amount;
|
||||
await upsertTransactionTemplate(transaction);
|
||||
widget.onAdd();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: TextField(
|
||||
controller: _templateNameController,
|
||||
decoration: InputDecoration(label: Text("Template name")),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: BlocBuilder<CoreCubit, CoreState>(
|
||||
builder:
|
||||
(context, state) => SearchField<Beneficiary>(
|
||||
suggestions:
|
||||
state.beneficiaries
|
||||
.where((el) {
|
||||
final bloc = GetIt.I.get<CoreCubit>();
|
||||
if (el.type == BeneficiaryType.account) {
|
||||
return el.account.value?.id !=
|
||||
bloc.activeAccount?.id;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((el) {
|
||||
return SearchFieldListItem(
|
||||
getBeneficiaryName(el),
|
||||
item: el,
|
||||
);
|
||||
})
|
||||
.toList(),
|
||||
hint: "Beneficiary",
|
||||
controller: _beneficiaryTextController,
|
||||
selectedValue: _selectedBeneficiary,
|
||||
onSuggestionTap: (beneficiary) {
|
||||
setState(() => _selectedBeneficiary = beneficiary);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: TextField(
|
||||
controller: _amountTextController,
|
||||
keyboardType: TextInputType.numberWithOptions(
|
||||
signed: false,
|
||||
decimal: false,
|
||||
),
|
||||
decoration: InputDecoration(hintText: "Amount"),
|
||||
),
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Text("Expense category"),
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
final category = await showDialogOrModal(
|
||||
context: context,
|
||||
builder: (_) => AddExpenseCategory(),
|
||||
);
|
||||
if (category == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _expenseCategory = category);
|
||||
},
|
||||
child: Text(_expenseCategory?.name ?? "None"),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: SegmentedButton<TransactionDirection>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: TransactionDirection.send,
|
||||
label: Text("Send"),
|
||||
icon: Icon(Icons.remove),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: TransactionDirection.receive,
|
||||
label: Text("Receive"),
|
||||
icon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
selected: <TransactionDirection>{_selectedDirection},
|
||||
multiSelectionEnabled: false,
|
||||
onSelectionChanged: (selection) {
|
||||
setState(() => _selectedDirection = selection.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _submit(context),
|
||||
child: Text("Add"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
311
lib/ui/widgets/add_transaction.dart
Normal file
311
lib/ui/widgets/add_transaction.dart
Normal file
@@ -0,0 +1,311 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:okane/database/collections/account.dart';
|
||||
import 'package:okane/database/collections/beneficiary.dart';
|
||||
import 'package:okane/database/collections/expense_category.dart';
|
||||
import 'package:okane/database/collections/transaction.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/state/core.dart';
|
||||
import 'package:okane/ui/transaction.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
import 'package:okane/ui/widgets/add_expense_category.dart';
|
||||
import 'package:searchfield/searchfield.dart';
|
||||
|
||||
class AddTransactionWidget extends StatefulWidget {
|
||||
final VoidCallback onAdd;
|
||||
|
||||
final Account activeAccountItem;
|
||||
|
||||
const AddTransactionWidget({
|
||||
super.key,
|
||||
required this.activeAccountItem,
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AddTransactionWidget> createState() => _AddTransactionWidgetState();
|
||||
}
|
||||
|
||||
class _AddTransactionWidgetState extends State<AddTransactionWidget> {
|
||||
final TextEditingController _beneficiaryTextController =
|
||||
TextEditingController();
|
||||
final TextEditingController _amountTextController = TextEditingController();
|
||||
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
|
||||
SearchFieldListItem<Beneficiary>? _selectedBeneficiary;
|
||||
|
||||
TransactionDirection _selectedDirection = TransactionDirection.send;
|
||||
|
||||
ExpenseCategory? _expenseCategory = null;
|
||||
|
||||
String getBeneficiaryName(Beneficiary item) {
|
||||
return switch (item.type) {
|
||||
BeneficiaryType.account => "${item.name} (Account)",
|
||||
BeneficiaryType.other => item.name,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _submit(BuildContext context) async {
|
||||
final beneficiaryName = _beneficiaryTextController.text;
|
||||
if (_selectedBeneficiary == null && beneficiaryName.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
Beneficiary? beneficiary = _selectedBeneficiary?.item;
|
||||
if (beneficiary == null ||
|
||||
getBeneficiaryName(beneficiary) != beneficiaryName) {
|
||||
// Add a new beneficiary, if none was selected
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text("Add Beneficiary"),
|
||||
content: Text(
|
||||
"The beneficiary '$beneficiaryName' does not exist. Do you want to add it?",
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
child: const Text('Add'),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
textStyle: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (result == null || !result) {
|
||||
return;
|
||||
}
|
||||
|
||||
beneficiary =
|
||||
Beneficiary()
|
||||
..name = beneficiaryName
|
||||
..type = BeneficiaryType.other;
|
||||
await upsertBeneficiary(beneficiary);
|
||||
}
|
||||
|
||||
final factor = switch (_selectedDirection) {
|
||||
TransactionDirection.send => -1,
|
||||
TransactionDirection.receive => 1,
|
||||
};
|
||||
final amount = factor * double.parse(_amountTextController.text).abs();
|
||||
final transaction =
|
||||
Transaction()
|
||||
..account.value = widget.activeAccountItem
|
||||
..beneficiary.value = beneficiary
|
||||
..amount = amount
|
||||
..tags = []
|
||||
..expenseCategory.value = _expenseCategory
|
||||
..date = _selectedDate;
|
||||
await upsertTransaction(transaction);
|
||||
|
||||
if (beneficiary.type == BeneficiaryType.account) {
|
||||
final otherTransaction =
|
||||
Transaction()
|
||||
..account.value = beneficiary.account.value!
|
||||
..beneficiary.value = await getAccountBeneficiary(
|
||||
widget.activeAccountItem,
|
||||
)
|
||||
..date = _selectedDate
|
||||
..expenseCategory.value = _expenseCategory
|
||||
..amount = -1 * amount;
|
||||
await upsertTransaction(otherTransaction);
|
||||
}
|
||||
|
||||
widget.onAdd();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
final template = await showDialogOrModal<Transaction>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return BlocBuilder<CoreCubit, CoreState>(
|
||||
builder: (context, state) {
|
||||
if (state.transactionTemplates.isEmpty) {
|
||||
return Text("No templates defined");
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.transactionTemplates.length,
|
||||
itemBuilder:
|
||||
(context, index) => ListTile(
|
||||
title: Text(
|
||||
state.transactionTemplates[index].name,
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pop(state.transactionTemplates[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (template == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_amountTextController.text = template.amount.toString();
|
||||
_selectedDirection =
|
||||
template.amount > 0
|
||||
? TransactionDirection.receive
|
||||
: TransactionDirection.send;
|
||||
_selectedBeneficiary = SearchFieldListItem(
|
||||
getBeneficiaryName(template.beneficiary.value!),
|
||||
item: template.beneficiary.value!,
|
||||
);
|
||||
_beneficiaryTextController.text = getBeneficiaryName(
|
||||
template.beneficiary.value!,
|
||||
);
|
||||
},
|
||||
child: Text("Use template"),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: BlocBuilder<CoreCubit, CoreState>(
|
||||
builder:
|
||||
(context, state) => SearchField<Beneficiary>(
|
||||
suggestions:
|
||||
state.beneficiaries
|
||||
.where((el) {
|
||||
final bloc = GetIt.I.get<CoreCubit>();
|
||||
if (el.type == BeneficiaryType.account) {
|
||||
return el.account.value?.id.toInt() ==
|
||||
bloc.activeAccount?.id.toInt();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((el) {
|
||||
return SearchFieldListItem(
|
||||
getBeneficiaryName(el),
|
||||
item: el,
|
||||
);
|
||||
})
|
||||
.toList(),
|
||||
hint: "Beneficiary",
|
||||
controller: _beneficiaryTextController,
|
||||
selectedValue: _selectedBeneficiary,
|
||||
onSuggestionTap: (beneficiary) {
|
||||
setState(() => _selectedBeneficiary = beneficiary);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: TextField(
|
||||
controller: _amountTextController,
|
||||
keyboardType: TextInputType.numberWithOptions(
|
||||
signed: false,
|
||||
decimal: false,
|
||||
),
|
||||
decoration: InputDecoration(hintText: "Amount"),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text("Date"),
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
final dt = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(1),
|
||||
lastDate: DateTime(9999),
|
||||
);
|
||||
if (dt == null) return;
|
||||
|
||||
setState(() => _selectedDate = dt);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.date_range),
|
||||
Text(formatDateTime(_selectedDate)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Text("Expense category"),
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
final category = await showDialogOrModal(
|
||||
context: context,
|
||||
builder: (_) => AddExpenseCategory(),
|
||||
);
|
||||
if (category == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _expenseCategory = category);
|
||||
},
|
||||
child: Text(_expenseCategory?.name ?? "None"),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: SegmentedButton<TransactionDirection>(
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: TransactionDirection.send,
|
||||
label: Text("Send"),
|
||||
icon: Icon(Icons.remove),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: TransactionDirection.receive,
|
||||
label: Text("Receive"),
|
||||
icon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
selected: <TransactionDirection>{_selectedDirection},
|
||||
multiSelectionEnabled: false,
|
||||
onSelectionChanged: (selection) {
|
||||
setState(() => _selectedDirection = selection.first);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _submit(context),
|
||||
child: Text("Add"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
lib/ui/widgets/image_wrapper.dart
Normal file
40
lib/ui/widgets/image_wrapper.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImageWrapper extends StatelessWidget {
|
||||
final String title;
|
||||
final String? path;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ImageWrapper({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.path,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget widget;
|
||||
if (path == null) {
|
||||
widget = SizedBox(
|
||||
width: 45,
|
||||
height: 45,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(child: Text(title[0])),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
widget = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(File(path!), width: 45, height: 45),
|
||||
);
|
||||
}
|
||||
|
||||
return InkWell(onTap: onTap, child: widget);
|
||||
}
|
||||
}
|
||||
49
lib/ui/widgets/transaction_card.dart
Normal file
49
lib/ui/widgets/transaction_card.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:okane/database/collections/transaction.dart';
|
||||
import 'package:okane/database/database.dart';
|
||||
import 'package:okane/ui/utils.dart';
|
||||
import 'package:okane/ui/widgets/image_wrapper.dart';
|
||||
|
||||
class TransactionCard extends StatelessWidget {
|
||||
final Widget? subtitle;
|
||||
|
||||
const TransactionCard({
|
||||
super.key,
|
||||
required this.transaction,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
final Transaction transaction;
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
leading: ImageWrapper(
|
||||
title: transaction.beneficiary.value!.name,
|
||||
path: transaction.beneficiary.value!.imagePath,
|
||||
onTap: () {},
|
||||
),
|
||||
trailing: Text(formatDateTime(transaction.date)),
|
||||
title: Text(transaction.beneficiary.value!.name),
|
||||
subtitle: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
formatCurrency(transaction.amount),
|
||||
style: TextStyle(
|
||||
color: transaction.amount < 0 ? Colors.red : Colors.green,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) subtitle!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user