Initial commit

This commit is contained in:
2025-05-04 02:54:07 +02:00
commit 5cc0bba09a
69 changed files with 8690 additions and 0 deletions

View 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"),
),
],
),
),
);
},
),
),
],
);
}
}

View 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!,
),
],
),
);
},
),
),
],
),
),
);
}
}

View 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)}",
),
),
],
);
},
);
},
),
),
);
}
}

View 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!));
},
),
],
),
),
);
},
);
}
}

View 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,
),
),
);
},
);
}
}

View 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,
);
},
),
),
],
);
},
);
}
}

View 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;
}
}

View 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,
);
},
),
),
],
);
},
);
}
}