feat: add recurring entries (#20)

Reviewed-on: #20
This commit is contained in:
Matyáš Caras 2024-01-08 21:19:15 +01:00
parent 6163a95849
commit 17a3a1ce20
19 changed files with 921 additions and 26 deletions

View file

@ -4,6 +4,8 @@
- Create a default "no category" category, mainly to store entries with removed categories - Create a default "no category" category, mainly to store entries with removed categories
- Categories now have changeable colors assigned to them - Categories now have changeable colors assigned to them
- Added pie chart for expense/income data per category - Added pie chart for expense/income data per category
- Added recurring entries
- Fixed wrong default sorting
# 1.0.0-alpha+2 # 1.0.0-alpha+2
- Fixed localization issues - Fixed localization issues
- Added graphs for expenses and income per month/year - Added graphs for expenses and income per month/year

View file

@ -0,0 +1,52 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:prasule/api/category.dart';
import 'package:prasule/api/entry_data.dart';
import 'package:prasule/api/wallet_entry.dart';
part 'recurring_entry.g.dart';
/// This is a [WalletSingleEntry] that is automatically recurring
@JsonSerializable()
class RecurringWalletEntry extends WalletSingleEntry {
/// This is a [WalletSingleEntry] that is automatically recurring
RecurringWalletEntry({
required super.data,
required super.type,
required super.date,
required super.category,
required super.id,
required this.lastRunDate,
required this.recurType,
this.repeatAfter = 1,
});
/// Connects generated fromJson method
factory RecurringWalletEntry.fromJson(Map<String, dynamic> json) =>
_$RecurringWalletEntryFromJson(json);
/// Connects generated toJson method
@override
Map<String, dynamic> toJson() => _$RecurringWalletEntryToJson(this);
/// Last date the recurring entry was added into the single entry list
DateTime lastRunDate;
/// After how many {recurType} should the entry recur
int repeatAfter;
/// What type of recurrence should happen
RecurType recurType;
}
/// How a [RecurringWalletEntry] should recur
@JsonEnum()
enum RecurType {
/// Will recur every {repeatAfter} months
month,
/// Will recur every {repeatAfter} years
year,
/// Will recur every {repeatAfter} days
day
}

View file

@ -0,0 +1,45 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'recurring_entry.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
RecurringWalletEntry _$RecurringWalletEntryFromJson(
Map<String, dynamic> json) =>
RecurringWalletEntry(
data: EntryData.fromJson(json['data'] as Map<String, dynamic>),
type: $enumDecode(_$EntryTypeEnumMap, json['type']),
date: DateTime.parse(json['date'] as String),
category:
WalletCategory.fromJson(json['category'] as Map<String, dynamic>),
id: json['id'] as int,
lastRunDate: DateTime.parse(json['lastRunDate'] as String),
repeatAfter: json['repeatAfter'] as int,
recurType: $enumDecode(_$RecurTypeEnumMap, json['recurType']),
);
Map<String, dynamic> _$RecurringWalletEntryToJson(
RecurringWalletEntry instance) =>
<String, dynamic>{
'type': _$EntryTypeEnumMap[instance.type]!,
'data': instance.data,
'date': instance.date.toIso8601String(),
'category': instance.category,
'id': instance.id,
'lastRunDate': instance.lastRunDate.toIso8601String(),
'repeatAfter': instance.repeatAfter,
'recurType': _$RecurTypeEnumMap[instance.recurType]!,
};
const _$EntryTypeEnumMap = {
EntryType.expense: 'expense',
EntryType.income: 'income',
};
const _$RecurTypeEnumMap = {
RecurType.month: 'month',
RecurType.year: 'year',
RecurType.day: 'day',
};

View file

@ -1,8 +1,11 @@
import 'package:currency_picker/currency_picker.dart'; import 'package:currency_picker/currency_picker.dart';
import 'package:intl/intl.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:prasule/api/category.dart'; import 'package:prasule/api/category.dart';
import 'package:prasule/api/walletentry.dart'; import 'package:prasule/api/recurring_entry.dart';
import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/api/wallet_entry.dart';
import 'package:prasule/api/wallet_manager.dart';
import 'package:prasule/main.dart';
part 'wallet.g.dart'; part 'wallet.g.dart';
Currency _currencyFromJson(Map<String, dynamic> data) => Currency _currencyFromJson(Map<String, dynamic> data) =>
@ -19,12 +22,16 @@ class Wallet {
required this.currency, required this.currency,
this.categories = const [], this.categories = const [],
this.entries = const [], this.entries = const [],
this.recurringEntries = const [],
this.starterBalance = 0, this.starterBalance = 0,
}); });
/// Connects generated fromJson method /// Connects generated fromJson method
factory Wallet.fromJson(Map<String, dynamic> json) => _$WalletFromJson(json); factory Wallet.fromJson(Map<String, dynamic> json) => _$WalletFromJson(json);
/// A list of all [RecurringWalletEntry]s
final List<RecurringWalletEntry> recurringEntries;
/// Name of the wallet /// Name of the wallet
final String name; final String name;
@ -65,6 +72,56 @@ class Wallet {
return id; return id;
} }
/// Handles adding recurring entries to the entry list
void recurEntries() {
final n = DateTime.now();
for (final ent in recurringEntries) {
var m = DateTime(
(ent.recurType == RecurType.year)
? ent.lastRunDate.year + ent.repeatAfter
: ent.lastRunDate.year,
(ent.recurType == RecurType.month)
? ent.lastRunDate.month + ent.repeatAfter
: ent.lastRunDate.month,
(ent.recurType == RecurType.day)
? ent.lastRunDate.day + ent.repeatAfter
: ent.lastRunDate.day,
); // create the date after which we should recur
while (n.isAfter(
m,
)) {
logger.i("Adding recurring entry ${ent.data.name}");
recurringEntries[recurringEntries.indexOf(ent)].lastRunDate =
m; // update date on recurring entry
logger.i(recurringEntries[recurringEntries.indexOf(ent)].lastRunDate);
final addedEntry = (recurringEntries[recurringEntries.indexOf(ent)]
as WalletSingleEntry)
..date = DateTime.now()
..id = nextId; // copy entry with today's date and unique ID
entries.add(
addedEntry,
); // add it to entries
m = DateTime(
(ent.recurType == RecurType.year)
? ent.lastRunDate.year + ent.repeatAfter
: ent.lastRunDate.year,
(ent.recurType == RecurType.month)
? ent.lastRunDate.month + ent.repeatAfter
: ent.lastRunDate.month,
(ent.recurType == RecurType.day)
? ent.lastRunDate.day + ent.repeatAfter
: ent.lastRunDate.day,
); // add tne variable again to check if we aren't missing any entries
logger.i(
"Last recurred date is now on ${DateFormat.yMMMMd().format(m)} (${n.isAfter(m)})");
}
WalletManager.saveWallet(this); // save wallet
}
}
/// Removes the specified category. /// Removes the specified category.
/// ///
/// All [WalletSingleEntry]s will have their category reassigned /// All [WalletSingleEntry]s will have their category reassigned

View file

@ -18,10 +18,16 @@ Wallet _$WalletFromJson(Map<String, dynamic> json) => Wallet(
(e) => WalletSingleEntry.fromJson(e as Map<String, dynamic>)) (e) => WalletSingleEntry.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], const [],
recurringEntries: (json['recurringEntries'] as List<dynamic>?)
?.map((e) =>
RecurringWalletEntry.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
starterBalance: (json['starterBalance'] as num?)?.toDouble() ?? 0, starterBalance: (json['starterBalance'] as num?)?.toDouble() ?? 0,
); );
Map<String, dynamic> _$WalletToJson(Wallet instance) => <String, dynamic>{ Map<String, dynamic> _$WalletToJson(Wallet instance) => <String, dynamic>{
'recurringEntries': instance.recurringEntries,
'name': instance.name, 'name': instance.name,
'categories': instance.categories, 'categories': instance.categories,
'entries': instance.entries, 'entries': instance.entries,

View file

@ -2,7 +2,7 @@ import 'package:json_annotation/json_annotation.dart';
import 'package:prasule/api/category.dart'; import 'package:prasule/api/category.dart';
import 'package:prasule/api/entry_data.dart'; import 'package:prasule/api/entry_data.dart';
part 'walletentry.g.dart'; part 'wallet_entry.g.dart';
@JsonSerializable() @JsonSerializable()

View file

@ -1,6 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'walletentry.dart'; part of 'wallet_entry.dart';
// ************************************************************************** // **************************************************************************
// JsonSerializableGenerator // JsonSerializableGenerator

View file

@ -81,5 +81,11 @@
"noCategory":"Žádná kategorie", "noCategory":"Žádná kategorie",
"done":"Hotovo", "done":"Hotovo",
"pickColor":"Zvolte barvu", "pickColor":"Zvolte barvu",
"changeDate":"Změnit ze kterého měsíce/roku brát data" "changeDate":"Změnit ze kterého měsíce/roku brát data",
"recurringPayments":"Opakující se platby",
"monthCounter": "{count, plural, =1{měsíc} few{měsíce} many{měsíců} other{měsíců} }",
"dayCounter":"{count, plural, =1{den} few{dny} many{dnů} other{dnů} }",
"yearCounter":"{count, plural, =1{rok} few{rok} many{let} other{let} }",
"recurEvery":"{count, plural, =1{Opakovat každý} few{Opakovat každé} many{Opakovat každých} other{Opakovat každých}}",
"startingWithDate": "počínaje datem"
} }

View file

@ -161,5 +161,47 @@
"noCategory":"No category", "noCategory":"No category",
"done":"Done", "done":"Done",
"pickColor":"Pick a color", "pickColor":"Pick a color",
"changeDate":"Change what month/year to pick data from" "changeDate":"Change what month/year to pick data from",
"recurringPayments":"Recurring payments",
"recurEvery":"{count, plural, other{Recur every}}",
"@recurEvery":{
"description": "Shown when creating recurring entries, ex.: Recur every 2 months",
"placeholders": {
"count":{
"description": "Specifies how many X are being counted",
"type": "int"
}
}
},
"monthCounter":"{count, plural, =1{month} other{months} }",
"@monthCounter":{
"placeholders": {
"count":{
"description": "Specifies how many months are being counted",
"type": "int"
}
}
},
"dayCounter":"{count, plural, =1{day} other{days} }",
"@dayCounter":{
"placeholders": {
"count":{
"description": "Specifies how many days are being counted",
"type": "int"
}
}
},
"yearCounter":"{count, plural, =1{year} other{years} }",
"@yearCounter":{
"placeholders": {
"count":{
"description": "Specifies how many years are being counted",
"type": "int"
}
}
},
"startingWithDate": "starting",
"@startingWithDate":{
"description": "Shown after 'Recur every X Y', e.g. 'Recur every 2 month starting 20th June 2023'"
}
} }

View file

@ -3,6 +3,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/pw/platformroute.dart';
import 'package:prasule/views/graph_view.dart'; import 'package:prasule/views/graph_view.dart';
import 'package:prasule/views/home.dart'; import 'package:prasule/views/home.dart';
import 'package:prasule/views/recurring_view.dart';
/// Makes the drawer because I won't enter the same code in every view /// Makes the drawer because I won't enter the same code in every view
Drawer makeDrawer(BuildContext context, int page) => Drawer( Drawer makeDrawer(BuildContext context, int page) => Drawer(
@ -39,6 +40,22 @@ Drawer makeDrawer(BuildContext context, int page) => Drawer(
.pushReplacement(platformRoute((p0) => const GraphView())); .pushReplacement(platformRoute((p0) => const GraphView()));
}, },
), ),
ListTile(
leading: const Icon(Icons.repeat),
title: Text(
AppLocalizations.of(context).recurringPayments,
),
selected: page == 3,
onTap: () {
if (page == 3) {
Navigator.of(context).pop();
return;
}
Navigator.of(context).pushReplacement(
platformRoute((p0) => const RecurringEntriesView()),
);
},
),
], ],
), ),
); );

View file

@ -5,8 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:prasule/api/category.dart'; import 'package:prasule/api/category.dart';
import 'package:prasule/api/walletentry.dart'; import 'package:prasule/api/wallet_entry.dart';
import 'package:prasule/main.dart';
import 'package:prasule/util/get_last_date.dart'; import 'package:prasule/util/get_last_date.dart';
import 'package:prasule/util/text_color.dart'; import 'package:prasule/util/text_color.dart';

View file

@ -4,15 +4,15 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:prasule/api/category.dart'; import 'package:prasule/api/category.dart';
import 'package:prasule/api/entry_data.dart'; import 'package:prasule/api/entry_data.dart';
import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/wallet.dart';
import 'package:prasule/api/walletentry.dart'; import 'package:prasule/api/wallet_entry.dart';
import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/api/wallet_manager.dart';
import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformbutton.dart';
import 'package:prasule/pw/platformfield.dart'; import 'package:prasule/pw/platformfield.dart';
/// Used when user wants to add new entry /// Used when user wants to add new entry
class CreateEntryView extends StatefulWidget { class CreateSingleEntryView extends StatefulWidget {
/// Used when user wants to add new entry /// Used when user wants to add new entry
const CreateEntryView({required this.w, super.key, this.editEntry}); const CreateSingleEntryView({required this.w, super.key, this.editEntry});
/// The wallet, where the entry will be saved to /// The wallet, where the entry will be saved to
final Wallet w; final Wallet w;
@ -23,10 +23,10 @@ class CreateEntryView extends StatefulWidget {
final WalletSingleEntry? editEntry; final WalletSingleEntry? editEntry;
@override @override
State<CreateEntryView> createState() => _CreateEntryViewState(); State createState() => _CreateSingleEntryViewState();
} }
class _CreateEntryViewState extends State<CreateEntryView> { class _CreateSingleEntryViewState extends State<CreateSingleEntryView> {
late WalletSingleEntry newEntry; late WalletSingleEntry newEntry;
@override @override
void initState() { void initState() {

View file

@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
import 'package:prasule/api/category.dart';
import 'package:prasule/api/entry_data.dart';
import 'package:prasule/api/recurring_entry.dart';
import 'package:prasule/api/wallet.dart';
import 'package:prasule/api/wallet_manager.dart';
import 'package:prasule/main.dart';
import 'package:prasule/pw/platformbutton.dart';
import 'package:prasule/pw/platformfield.dart';
/// Used when user wants to add new entry
class CreateRecurringEntryView extends StatefulWidget {
/// Used when user wants to add new entry
const CreateRecurringEntryView({
required this.w,
required this.locale,
super.key,
this.editEntry,
});
/// The wallet, where the entry will be saved to
final Wallet w;
/// Entry we want to edit
///
/// Is null unless we are editing an existing entry
final RecurringWalletEntry? editEntry;
/// Selected locale
final String locale;
@override
State createState() => _CreateRecurringEntryViewState();
}
class _CreateRecurringEntryViewState extends State<CreateRecurringEntryView> {
late RecurringWalletEntry newEntry;
@override
void initState() {
super.initState();
if (widget.editEntry != null) {
newEntry = widget.editEntry!;
} else {
newEntry = RecurringWalletEntry(
id: widget.w.nextId,
data: EntryData(amount: 0, name: ""),
type: EntryType.expense,
date: DateTime.now(),
category: widget.w.categories.first,
lastRunDate: DateTime.now(),
recurType: RecurType.month,
);
}
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).createEntry),
),
body: SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: PlatformField(
labelText: AppLocalizations.of(context).name,
controller: TextEditingController(text: newEntry.data.name),
onChanged: (v) {
newEntry.data.name = v;
},
),
),
const SizedBox(
height: 15,
),
SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: PlatformField(
labelText: AppLocalizations.of(context).amount,
controller: TextEditingController(
text: newEntry.data.amount.toString(),
),
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'\d+[\.,]{0,1}\d{0,}'),
),
],
onChanged: (v) {
logger.i(v);
newEntry.data.amount = double.parse(v);
},
),
),
const SizedBox(
height: 20,
),
Text(AppLocalizations.of(context).type),
const SizedBox(
height: 10,
),
SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: DropdownButton<EntryType>(
value: newEntry.type,
items: [
DropdownMenuItem(
value: EntryType.expense,
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.8 - 24,
child: Text(
AppLocalizations.of(context).expense,
),
),
),
DropdownMenuItem(
value: EntryType.income,
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.8 - 24,
child: Text(AppLocalizations.of(context).income),
),
),
],
onChanged: (v) {
if (v == null) return;
newEntry.type = v;
setState(() {});
},
),
),
const SizedBox(
height: 20,
),
Text(AppLocalizations.of(context).category),
const SizedBox(
height: 10,
),
SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: DropdownButton<int>(
value: newEntry.category.id,
items: List.generate(
widget.w.categories.length,
(index) => DropdownMenuItem(
value: widget.w.categories[index].id,
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.8 - 24,
child: Text(
widget.w.categories[index].name,
),
),
),
),
onChanged: (v) {
if (v == null) return;
newEntry.category = widget.w.categories
.where((element) => element.id == v)
.first;
setState(() {});
},
),
),
const SizedBox(
height: 20,
),
Text(AppLocalizations.of(context).description),
const SizedBox(
height: 10,
),
ConstrainedBox(
constraints: BoxConstraints(
minWidth: MediaQuery.of(context).size.width * 0.8,
maxWidth: MediaQuery.of(context).size.width * 0.8,
maxHeight: 300,
),
child: PlatformField(
keyboardType: TextInputType.multiline,
maxLines: null,
controller: TextEditingController(
text: newEntry.data.description,
),
onChanged: (v) {
newEntry.data.description = v;
},
),
),
const SizedBox(
height: 20,
),
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
AppLocalizations.of(context)
.recurEvery(newEntry.repeatAfter),
),
const SizedBox(
width: 10,
),
SizedBox(
width: 50,
child: PlatformField(
controller: TextEditingController(
text: newEntry.repeatAfter.toString(),
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
FilteringTextInputFormatter.deny(
RegExp(r"^0$"),
replacementString: "1",
),
FilteringTextInputFormatter.deny(
r"\d+[\.,]{0,1}\d{0,}",
replacementString: "1",
),
],
onChanged: (s) {
final n = int.tryParse(s);
if (n == null) return;
newEntry.repeatAfter = n;
setState(() {});
},
),
),
],
),
),
SizedBox(
width: 200,
child: DropdownButton<RecurType>(
value: newEntry.recurType,
items: [
DropdownMenuItem(
value: RecurType.day,
child: SizedBox(
width: 176,
child: Text(
AppLocalizations.of(context)
.dayCounter(newEntry.repeatAfter),
),
),
),
DropdownMenuItem(
value: RecurType.month,
child: SizedBox(
width: 176,
child: Text(
AppLocalizations.of(context)
.monthCounter(newEntry.repeatAfter),
),
),
),
DropdownMenuItem(
value: RecurType.year,
child: SizedBox(
width: 176,
child: Text(
AppLocalizations.of(context)
.yearCounter(newEntry.repeatAfter),
),
),
),
],
onChanged: (v) {
if (v == null) return;
newEntry.recurType = v;
setState(() {});
},
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(AppLocalizations.of(context).startingWithDate),
const SizedBox(
width: 10,
), // TODO: maybe use sizedbox on row with spaceEvenly?
PlatformButton(
text: DateFormat.yMMMMd(widget.locale)
.format(newEntry.lastRunDate),
onPressed: () async {
final d = await showDatePicker(
context: context,
firstDate: DateTime.now(),
lastDate:
DateTime.now().add(const Duration(days: 365)),
);
if (d == null) return;
newEntry.lastRunDate = d;
setState(() {});
},
),
],
),
const SizedBox(
height: 15,
),
PlatformButton(
text: AppLocalizations.of(context).save,
onPressed: () {
if (newEntry.data.name.isEmpty) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(AppLocalizations.of(context).errorEmptyName),
),
);
return;
}
if (widget.editEntry != null) {
Navigator.of(context).pop(newEntry);
return;
}
widget.w.recurringEntries.add(newEntry);
WalletManager.saveWallet(widget.w).then(
(value) => Navigator.of(context).pop(widget.w),
); // TODO loading circle?
},
),
],
),
),
),
),
);
}
}

View file

@ -5,7 +5,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:prasule/api/category.dart'; import 'package:prasule/api/category.dart';
import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/wallet.dart';
import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/api/wallet_manager.dart';
import 'package:prasule/main.dart'; import 'package:prasule/main.dart';
import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformbutton.dart';
import 'package:prasule/pw/platformroute.dart'; import 'package:prasule/pw/platformroute.dart';

View file

@ -15,9 +15,10 @@ import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:prasule/api/category.dart'; import 'package:prasule/api/category.dart';
import 'package:prasule/api/entry_data.dart'; import 'package:prasule/api/entry_data.dart';
import 'package:prasule/api/recurring_entry.dart';
import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/wallet.dart';
import 'package:prasule/api/walletentry.dart'; import 'package:prasule/api/wallet_entry.dart';
import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/api/wallet_manager.dart';
import 'package:prasule/main.dart'; import 'package:prasule/main.dart';
import 'package:prasule/network/tessdata.dart'; import 'package:prasule/network/tessdata.dart';
import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformbutton.dart';
@ -67,6 +68,7 @@ class _HomeViewState extends State<HomeView> {
return; return;
} }
selectedWallet = wallets.first; selectedWallet = wallets.first;
selectedWallet!.recurEntries();
setState(() {}); setState(() {});
} }
@ -86,6 +88,7 @@ class _HomeViewState extends State<HomeView> {
// debug option to quickly fill a wallet with data // debug option to quickly fill a wallet with data
if (selectedWallet == null) return; if (selectedWallet == null) return;
selectedWallet!.entries.clear(); selectedWallet!.entries.clear();
selectedWallet!.recurringEntries.clear();
final random = Random(); final random = Random();
for (var i = 0; i < 30; i++) { for (var i = 0; i < 30; i++) {
selectedWallet!.entries.add( selectedWallet!.entries.add(
@ -109,7 +112,41 @@ class _HomeViewState extends State<HomeView> {
); );
} }
logger.i(selectedWallet!.entries.length); logger.d(
"Created ${selectedWallet!.entries.length} regular entries",
);
for (var i = 0; i < 3; i++) {
final type = random.nextInt(3);
selectedWallet!.recurringEntries.add(
RecurringWalletEntry(
data: EntryData(
name: "Recurring Entry #${i + 1}",
amount: random.nextInt(20000).toDouble(),
),
type: (random.nextInt(3) > 0)
? EntryType.expense
: EntryType.income,
date: DateTime(
2023,
random.nextInt(12) + 1,
random.nextInt(28) + 1,
),
category: selectedWallet!.categories[
random.nextInt(selectedWallet!.categories.length)],
id: selectedWallet!.nextId,
lastRunDate: DateTime.now().subtract(
Duration(
days: (type > 0) ? 3 : 3 * 31,
),
),
recurType: (type > 0) ? RecurType.day : RecurType.month,
),
);
}
logger.d(
"Created ${selectedWallet!.recurringEntries.length} recurring entries");
// save and reload // save and reload
WalletManager.saveWallet(selectedWallet!).then((value) { WalletManager.saveWallet(selectedWallet!).then((value) {
@ -127,7 +164,7 @@ class _HomeViewState extends State<HomeView> {
onTap: () async { onTap: () async {
final sw = await Navigator.of(context).push<Wallet>( final sw = await Navigator.of(context).push<Wallet>(
MaterialPageRoute( MaterialPageRoute(
builder: (c) => CreateEntryView(w: selectedWallet!), builder: (c) => CreateSingleEntryView(w: selectedWallet!),
), ),
); );
if (sw != null) { if (sw != null) {
@ -265,8 +302,8 @@ class _HomeViewState extends State<HomeView> {
if (yearA == null) return 0; if (yearA == null) return 0;
final yearB = RegExp(r'\d+').firstMatch(b); final yearB = RegExp(r'\d+').firstMatch(b);
if (yearB == null) return 0; if (yearB == null) return 0;
final compareYears = int.parse(yearA.group(0)!) final compareYears = int.parse(yearB.group(0)!)
.compareTo(int.parse(yearB.group(0)!)); .compareTo(int.parse(yearA.group(0)!));
if (compareYears != 0) return compareYears; if (compareYears != 0) return compareYears;
final months = List<String>.generate( final months = List<String>.generate(
12, 12,
@ -291,7 +328,7 @@ class _HomeViewState extends State<HomeView> {
Navigator.of(context) Navigator.of(context)
.push<WalletSingleEntry>( .push<WalletSingleEntry>(
MaterialPageRoute( MaterialPageRoute(
builder: (c) => CreateEntryView( builder: (c) => CreateSingleEntryView(
w: selectedWallet!, w: selectedWallet!,
editEntry: element, editEntry: element,
), ),
@ -372,7 +409,9 @@ class _HomeViewState extends State<HomeView> {
), ),
title: Text(element.data.name), title: Text(element.data.name),
subtitle: Text( subtitle: Text(
"${element.data.amount} ${selectedWallet!.currency.symbol}", NumberFormat.currency(
symbol: selectedWallet!.currency.symbol,
).format(element.data.amount),
), ),
), ),
), ),
@ -472,7 +511,7 @@ class _HomeViewState extends State<HomeView> {
final newEntry = final newEntry =
await Navigator.of(context).push<WalletSingleEntry>( await Navigator.of(context).push<WalletSingleEntry>(
platformRoute<WalletSingleEntry>( platformRoute<WalletSingleEntry>(
(c) => CreateEntryView( (c) => CreateSingleEntryView(
w: selectedWallet!, w: selectedWallet!,
editEntry: WalletSingleEntry( editEntry: WalletSingleEntry(
data: EntryData( data: EntryData(

View file

@ -0,0 +1,288 @@
// ignore_for_file: inference_failure_on_function_invocation
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
import 'package:prasule/api/recurring_entry.dart';
import 'package:prasule/api/wallet.dart';
import 'package:prasule/api/wallet_manager.dart';
import 'package:prasule/pw/platformbutton.dart';
import 'package:prasule/pw/platformdialog.dart';
import 'package:prasule/pw/platformroute.dart';
import 'package:prasule/util/drawer.dart';
import 'package:prasule/util/text_color.dart';
import 'package:prasule/views/create_recur_entry.dart';
import 'package:prasule/views/settings/settings.dart';
import 'package:prasule/views/setup.dart';
/// Used to set up recurring entries
class RecurringEntriesView extends StatefulWidget {
/// Used to set up recurring entries
const RecurringEntriesView({super.key});
@override
State<RecurringEntriesView> createState() => _RecurringEntriesViewState();
}
class _RecurringEntriesViewState extends State<RecurringEntriesView> {
Wallet? selectedWallet;
List<Wallet> wallets = [];
late String locale;
@override
void didChangeDependencies() {
super.didChangeDependencies();
locale = Localizations.localeOf(context).languageCode;
initializeDateFormatting(Localizations.localeOf(context).languageCode);
}
@override
void initState() {
super.initState();
loadWallet();
}
Future<void> loadWallet() async {
wallets = await WalletManager.listWallets();
if (wallets.isEmpty && mounted) {
unawaited(
Navigator.of(context)
.pushReplacement(platformRoute((c) => const SetupView())),
);
return;
}
selectedWallet = wallets.first;
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: makeDrawer(context, 3),
appBar: AppBar(
title: DropdownButton<int>(
value:
(selectedWallet == null) ? -1 : wallets.indexOf(selectedWallet!),
items: [
...wallets.map(
(e) => DropdownMenuItem(
value: wallets.indexOf(
e,
),
child: Text(e.name),
),
),
DropdownMenuItem(
value: -1,
child: Text(AppLocalizations.of(context).newWallet),
),
],
onChanged: (v) async {
if (v == null || v == -1) {
await Navigator.of(context).push(
platformRoute(
(c) => const SetupView(
newWallet: true,
),
),
);
wallets = await WalletManager.listWallets();
selectedWallet = wallets.last;
setState(() {});
return;
}
selectedWallet = wallets[v];
setState(() {});
},
),
actions: [
PopupMenuButton(
itemBuilder: (context) => [
AppLocalizations.of(context).settings,
AppLocalizations.of(context).about,
].map((e) => PopupMenuItem(value: e, child: Text(e))).toList(),
onSelected: (value) {
if (value == AppLocalizations.of(context).settings) {
Navigator.of(context)
.push(
platformRoute(
(context) => const SettingsView(),
),
)
.then((value) async {
selectedWallet =
await WalletManager.loadWallet(selectedWallet!.name);
});
} else if (value == AppLocalizations.of(context).about) {
showAboutDialog(
context: context,
applicationLegalese: AppLocalizations.of(context).license,
applicationName: "Prašule",
);
}
},
),
],
),
floatingActionButton: FloatingActionButton(
shape: const CircleBorder(),
child: const Icon(Icons.add),
onPressed: () {
Navigator.of(context).push(
platformRoute(
(p0) => CreateRecurringEntryView(
w: selectedWallet!,
locale: locale,
),
),
);
},
),
body: Center(
child: SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: (selectedWallet == null)
? const Column(
children: [
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(),
),
],
)
: (selectedWallet!.recurringEntries.isEmpty)
? Column(
children: [
Text(
AppLocalizations.of(context).noEntries,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
AppLocalizations.of(context).noEntriesSub,
),
],
)
: ListView.builder(
itemBuilder: (c, i) => Slidable(
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
onPressed: (c) {
Navigator.of(context)
.push<RecurringWalletEntry>(
MaterialPageRoute(
builder: (c) => CreateRecurringEntryView(
w: selectedWallet!,
locale: locale,
editEntry:
selectedWallet!.recurringEntries[i],
),
),
)
.then(
(editedEntry) {
if (editedEntry == null) return;
selectedWallet!.entries.remove(
selectedWallet!.recurringEntries[i],
);
selectedWallet!.entries.add(editedEntry);
WalletManager.saveWallet(selectedWallet!);
setState(() {});
},
);
},
backgroundColor:
Theme.of(context).colorScheme.secondary,
foregroundColor:
Theme.of(context).colorScheme.onSecondary,
icon: Icons.edit,
),
SlidableAction(
backgroundColor:
Theme.of(context).colorScheme.error,
foregroundColor:
Theme.of(context).colorScheme.onError,
icon: Icons.delete,
onPressed: (c) {
showDialog(
context: context,
builder: (cx) => PlatformDialog(
title:
AppLocalizations.of(context).sureDialog,
content: Text(
AppLocalizations.of(context).deleteSure,
),
actions: [
PlatformButton(
text: AppLocalizations.of(context).yes,
onPressed: () {
selectedWallet!.recurringEntries
.remove(
selectedWallet!.recurringEntries[i],
);
WalletManager.saveWallet(
selectedWallet!,
);
Navigator.of(cx).pop();
setState(() {});
},
),
PlatformButton(
text: AppLocalizations.of(context).no,
onPressed: () {
Navigator.of(cx).pop();
},
),
],
),
);
},
),
],
),
child: ListTile(
leading: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: selectedWallet!
.recurringEntries[i].category.color,
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(
selectedWallet!
.recurringEntries[i].category.icon,
color: selectedWallet!
.recurringEntries[i].category.color
.calculateTextColor(),
),
),
),
title: Text(
selectedWallet!.recurringEntries[i].data.name,
),
subtitle: Text(
NumberFormat.currency(
symbol: selectedWallet!.currency.symbol,
).format(
selectedWallet!.recurringEntries[i].data.amount,
),
),
),
),
itemCount: selectedWallet!.recurringEntries.length,
),
),
),
);
}
}

View file

@ -9,7 +9,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_iconpicker/flutter_iconpicker.dart'; import 'package:flutter_iconpicker/flutter_iconpicker.dart';
import 'package:prasule/api/category.dart'; import 'package:prasule/api/category.dart';
import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/wallet.dart';
import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/api/wallet_manager.dart';
import 'package:prasule/main.dart'; import 'package:prasule/main.dart';
import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformbutton.dart';
import 'package:prasule/pw/platformdialog.dart'; import 'package:prasule/pw/platformdialog.dart';

View file

@ -10,7 +10,7 @@ import 'package:flutter_iconpicker/flutter_iconpicker.dart';
import 'package:introduction_screen/introduction_screen.dart'; import 'package:introduction_screen/introduction_screen.dart';
import 'package:prasule/api/category.dart'; import 'package:prasule/api/category.dart';
import 'package:prasule/api/wallet.dart'; import 'package:prasule/api/wallet.dart';
import 'package:prasule/api/walletmanager.dart'; import 'package:prasule/api/wallet_manager.dart';
import 'package:prasule/pw/platformbutton.dart'; import 'package:prasule/pw/platformbutton.dart';
import 'package:prasule/pw/platformdialog.dart'; import 'package:prasule/pw/platformdialog.dart';
import 'package:prasule/pw/platformfield.dart'; import 'package:prasule/pw/platformfield.dart';