Skip to content

Commit 445faa2

Browse files
committed
Merge branch 'develop' of https://github.com/dreautall/waterfly-iii into develop
2 parents 733ef0a + f5e0576 commit 445faa2

File tree

5 files changed

+397
-183
lines changed

5 files changed

+397
-183
lines changed

lib/l10n/app_en.arb

+9-1
Original file line numberDiff line numberDiff line change
@@ -842,10 +842,18 @@
842842
"@settingsNLAppAddInfo": {
843843
"description": "Help text when no more app is available to add."
844844
},
845+
"settingsNLAutoAdd": "Create transaction without interaction",
846+
"@settingsNLAutoAdd": {
847+
"description": "With this setting enabled, the transaction will be added automatically without further user interaction."
848+
},
845849
"settingsNLDescription": "This service allows you to fetch transaction details from incoming push notifications. Additionally, you can select a default account which the transaction should be assigned to - if no value is set, it tries to extract an account from the notification.",
846850
"@settingsNLDescription": {
847851
"description": "Description text for the notification listener service."
848852
},
853+
"settingsNLEmptyNote": "Keep note field empty",
854+
"@settingsNLEmptyNote": {
855+
"description": "Usually the note field will be pre-filled with the notification details. With this setting enabled, it will be empty instead."
856+
},
849857
"settingsNLPermissionGrant": "Tap to grant permission.",
850858
"@settingsNLPermissionGrant": {
851859
"description": "Indicates user should tap the text to grant certain permissions (notification access)."
@@ -864,7 +872,7 @@
864872
},
865873
"settingsNLPrefillTXTitle": "Prefill transaction title with notification title",
866874
"@settingsNLPrefillTXTitle": {
867-
"description": "First line for setting to use pre-fill transaction title with notification title."
875+
"description": "Setting pre-fill transaction title with notification title."
868876
},
869877
"settingsNLServiceChecking": "Checking status…",
870878
"@settingsNLServiceChecking": {

lib/notificationlistener.dart

+224-31
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import 'dart:convert';
33
import 'package:flutter/material.dart';
44
import 'package:logging/logging.dart';
55

6+
import 'package:chopper/chopper.dart';
67
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
78
import 'package:notifications_listener_service/notifications_listener_service.dart';
9+
import 'package:timezone/data/latest.dart' as tz;
810

911
import 'package:waterflyiii/app.dart';
12+
import 'package:waterflyiii/auth.dart';
13+
import 'package:waterflyiii/generated/swagger_fireflyiii_api/firefly_iii.swagger.dart';
1014
import 'package:waterflyiii/pages/transaction.dart';
1115
import 'package:waterflyiii/settings.dart';
1216

@@ -70,17 +74,19 @@ void nlCallback() async {
7074
log.finest(() => "nlCallback()");
7175
NotificationServicePlugin.instance
7276
.executeNotificationListener((NotificationEvent? evt) async {
73-
if (evt?.packageName?.startsWith("com.dreautall.waterflyiii") ?? false) {
77+
if (evt == null || evt.packageName == null) {
7478
return;
7579
}
76-
if (evt?.state == NotificationState.remove) {
80+
if (evt.packageName?.startsWith("com.dreautall.waterflyiii") ?? false) {
81+
return;
82+
}
83+
if (evt.state == NotificationState.remove) {
7784
return;
7885
}
7986

80-
final Iterable<RegExpMatch> matches =
81-
rFindMoney.allMatches(evt?.text ?? "");
87+
final Iterable<RegExpMatch> matches = rFindMoney.allMatches(evt.text ?? "");
8288
if (matches.isEmpty) {
83-
log.finer(() => "nlCallback(${evt?.packageName}): no money found");
89+
log.finer(() => "nlCallback(${evt.packageName}): no money found");
8490
return;
8591
}
8692

@@ -93,40 +99,142 @@ void nlCallback() async {
9399
}
94100
}
95101
if (!validMatch) {
96-
log.finer(() =>
97-
"nlCallback(${evt?.packageName}): no money with currency found");
102+
log.finer(
103+
() => "nlCallback(${evt.packageName}): no money with currency found");
98104
return;
99105
}
100106

101-
SettingsProvider().notificationAddKnownApp(evt?.packageName ?? "");
107+
final SettingsProvider settings = SettingsProvider();
102108

103-
if (!(await SettingsProvider().notificationUsedApps(forceReload: true))
104-
.contains(evt?.packageName ?? "")) {
105-
log.finer(() => "nlCallback(${evt?.packageName}): app not used");
109+
settings.notificationAddKnownApp(evt.packageName!);
110+
111+
if (!(await settings.notificationUsedApps()).contains(evt.packageName)) {
112+
log.finer(() => "nlCallback(${evt.packageName}): app not used");
106113
return;
107114
}
108115

109-
FlutterLocalNotificationsPlugin().show(
110-
DateTime.now().millisecondsSinceEpoch ~/ 1000,
111-
"Create Transaction?",
112-
"Click to create a transaction based on the notification ${evt?.title}",
113-
const NotificationDetails(
114-
android: AndroidNotificationDetails(
115-
'extract_transaction',
116-
'Create Transaction from Notification',
117-
channelDescription:
118-
'Notification asking to create a transaction from another Notification.',
119-
importance: Importance.low, // Android 8.0 and higher
120-
priority: Priority.low, // Android 7.1 and lower
116+
final NotificationAppSettings appSettings =
117+
await settings.notificationGetAppSettings(evt.packageName!);
118+
bool showNotification = true;
119+
120+
if (appSettings.autoAdd) {
121+
tz.initializeTimeZones();
122+
log.finer(() =>
123+
"nlCallback(${evt.packageName}): trying to auto-add transaction");
124+
try {
125+
final FireflyService ffService = FireflyService();
126+
if (!await ffService.signInFromStorage()) {
127+
throw UnauthenticatedResponse;
128+
}
129+
final FireflyIii api = ffService.api;
130+
final CurrencyRead localCurrency = ffService.defaultCurrency;
131+
late CurrencyRead? currency;
132+
late double amount;
133+
134+
(currency, amount) =
135+
await parseNotificationText(api, evt.text!, localCurrency);
136+
// Fallback solution
137+
currency ??= localCurrency;
138+
139+
// Set date
140+
DateTime date = ffService.tzHandler
141+
.notificationTXTime(
142+
DateTime.tryParse(evt.postTime ?? "") ?? DateTime.now())
143+
.toLocal();
144+
String note = "";
145+
if (appSettings.autoAdd) {
146+
note = evt.text ?? "";
147+
}
148+
149+
// Check currency
150+
if (currency != localCurrency) {
151+
throw Exception("Can't auto-add TX with foreign currency");
152+
}
153+
154+
// Check account
155+
if (appSettings.defaultAccountId == null) {
156+
throw Exception("Can't auto-add TX with no default account ID");
157+
}
158+
159+
final TransactionStore newTx = TransactionStore(
160+
groupTitle: null,
161+
transactions: <TransactionSplitStore>[
162+
TransactionSplitStore(
163+
type: TransactionTypeProperty.withdrawal,
164+
date: date,
165+
amount: amount.toString(),
166+
description: evt.title!,
167+
// destinationId
168+
// destinationName
169+
notes: note,
170+
order: 0,
171+
sourceId: appSettings.defaultAccountId,
172+
)
173+
],
174+
applyRules: true,
175+
fireWebhooks: true,
176+
errorIfDuplicateHash: true,
177+
);
178+
final Response<TransactionSingle> resp =
179+
await api.v1TransactionsPost(body: newTx);
180+
if (!resp.isSuccessful || resp.body == null) {
181+
try {
182+
ValidationErrorResponse valError = ValidationErrorResponse.fromJson(
183+
json.decode(resp.error.toString()),
184+
);
185+
throw Exception(valError.message);
186+
} catch (_) {
187+
throw Exception("unknown");
188+
}
189+
}
190+
191+
FlutterLocalNotificationsPlugin().show(
192+
DateTime.now().millisecondsSinceEpoch ~/ 1000,
193+
"Transaction created",
194+
"Transaction created based on notification ${evt.title}",
195+
const NotificationDetails(
196+
android: AndroidNotificationDetails(
197+
'extract_transaction_created',
198+
'Transaction from Notification Created',
199+
channelDescription:
200+
'Notification that a Transaction has been created from another Notification.',
201+
importance: Importance.low, // Android 8.0 and higher
202+
priority: Priority.low, // Android 7.1 and lower
203+
),
204+
),
205+
payload: "",
206+
);
207+
208+
showNotification = false;
209+
} catch (e, stackTrace) {
210+
log.severe("Error while auto-adding transaction", e, stackTrace);
211+
showNotification = true;
212+
}
213+
}
214+
215+
if (showNotification) {
216+
FlutterLocalNotificationsPlugin().show(
217+
DateTime.now().millisecondsSinceEpoch ~/ 1000,
218+
"Create Transaction?",
219+
"Click to create a transaction based on the notification ${evt.title}",
220+
const NotificationDetails(
221+
android: AndroidNotificationDetails(
222+
'extract_transaction',
223+
'Create Transaction from Notification',
224+
channelDescription:
225+
'Notification asking to create a transaction from another Notification.',
226+
importance: Importance.low, // Android 8.0 and higher
227+
priority: Priority.low, // Android 7.1 and lower
228+
),
121229
),
122-
),
123-
payload: jsonEncode(NotificationTransaction(
124-
evt?.packageName ?? "",
125-
evt?.title ?? "",
126-
evt?.text ?? "",
127-
DateTime.tryParse(evt?.postTime ?? "") ?? DateTime.now(),
128-
)),
129-
);
230+
payload: jsonEncode(NotificationTransaction(
231+
evt.packageName ?? "",
232+
evt.title ?? "",
233+
evt.text ?? "",
234+
DateTime.tryParse(evt.postTime ?? "") ?? DateTime.now(),
235+
)),
236+
);
237+
}
130238
});
131239
}
132240

@@ -150,3 +258,88 @@ Future<void> nlNotificationTap(
150258
),
151259
);
152260
}
261+
262+
Future<(CurrencyRead?, double)> parseNotificationText(
263+
FireflyIii api, String notificationBody, CurrencyRead localCurrency) async {
264+
CurrencyRead? currency;
265+
double amount = 0;
266+
// Try to extract some money
267+
final Iterable<RegExpMatch> matches = rFindMoney.allMatches(notificationBody);
268+
if (matches.isNotEmpty) {
269+
RegExpMatch? validMatch;
270+
for (RegExpMatch match in matches) {
271+
if ((match.namedGroup("postCurrency")?.isNotEmpty ?? false) ||
272+
(match.namedGroup("preCurrency")?.isNotEmpty ?? false)) {
273+
validMatch = match;
274+
break;
275+
}
276+
}
277+
if (validMatch != null) {
278+
// extract currency
279+
String currencyStr = validMatch.namedGroup("preCurrency") ?? "";
280+
String currencyStrAlt = validMatch.namedGroup("postCurrency") ?? "";
281+
if (currencyStr.isEmpty) {
282+
currencyStr = currencyStrAlt;
283+
}
284+
if (currencyStr.isEmpty) {
285+
log.warning("no currency found");
286+
}
287+
if (localCurrency.attributes.code == currencyStr ||
288+
localCurrency.attributes.symbol == currencyStr ||
289+
localCurrency.attributes.code == currencyStrAlt ||
290+
localCurrency.attributes.symbol == currencyStrAlt) {
291+
} else {
292+
final Response<CurrencyArray> response = await api.v1CurrenciesGet();
293+
if (!response.isSuccessful || response.body == null) {
294+
log.warning("api currency fetch failed");
295+
} else {
296+
for (CurrencyRead cur in response.body!.data) {
297+
if (cur.attributes.code == currencyStr ||
298+
cur.attributes.symbol == currencyStr ||
299+
cur.attributes.code == currencyStrAlt ||
300+
cur.attributes.symbol == currencyStrAlt) {
301+
currency = cur;
302+
break;
303+
}
304+
}
305+
}
306+
}
307+
// extract amount
308+
// Check if string has a decimal separator
309+
final String amountStr =
310+
(validMatch.namedGroup("amount") ?? "").replaceAll(" ", "");
311+
final int decimalSepPos = amountStr.length >= 3 &&
312+
(amountStr[amountStr.length - 3] == "." ||
313+
amountStr[amountStr.length - 3] == ",")
314+
? amountStr.length - 3
315+
: amountStr.length - 2;
316+
final String decimalSep =
317+
amountStr.length >= decimalSepPos && decimalSepPos > 0
318+
? amountStr[decimalSepPos]
319+
: "";
320+
if (decimalSep == "," || decimalSep == ".") {
321+
final double wholes = double.tryParse(amountStr
322+
.substring(0, decimalSepPos)
323+
.replaceAll(",", "")
324+
.replaceAll(".", "")) ??
325+
0;
326+
final String decStr = amountStr
327+
.substring(decimalSepPos + 1)
328+
.replaceAll(",", "")
329+
.replaceAll(".", "");
330+
final double dec = double.tryParse(decStr) ?? 0;
331+
amount = decStr.length == 1 ? wholes + dec / 10 : wholes + dec / 100;
332+
} else {
333+
amount = double.tryParse(
334+
amountStr.replaceAll(",", "").replaceAll(".", "")) ??
335+
0;
336+
}
337+
} else {
338+
log.info("no currency was found");
339+
}
340+
} else {
341+
log.warning("regex did not match");
342+
}
343+
344+
return (currency, amount);
345+
}

lib/pages/settings/notifications.dart

+39-5
Original file line numberDiff line numberDiff line change
@@ -339,11 +339,14 @@ class _AppCardState extends State<AppCard> {
339339
width: MediaQuery.of(context).size.width - 128,
340340
onSelected: (AccountRead? account) async {
341341
FocusManager.instance.primaryFocus?.unfocus();
342-
if ((account?.id ?? "0") == "0") {
343-
widget.settings.defaultAccountId = null;
344-
} else {
345-
widget.settings.defaultAccountId = account!.id;
346-
}
342+
setState(() {
343+
if ((account?.id ?? "0") == "0") {
344+
widget.settings.defaultAccountId = null;
345+
widget.settings.autoAdd = false;
346+
} else {
347+
widget.settings.defaultAccountId = account!.id;
348+
}
349+
});
347350
await context
348351
.read<SettingsProvider>()
349352
.notificationSetAppSettings(
@@ -355,6 +358,7 @@ class _AppCardState extends State<AppCard> {
355358
title: Text(S.of(context).settingsNLPrefillTXTitle),
356359
isThreeLine: false,
357360
value: widget.settings.includeTitle,
361+
enabled: !widget.settings.autoAdd,
358362
onChanged: (bool? value) async {
359363
setState(() {
360364
widget.settings.includeTitle = value ?? true;
@@ -365,6 +369,36 @@ class _AppCardState extends State<AppCard> {
365369
widget.app, widget.settings);
366370
},
367371
),
372+
CheckboxListTile(
373+
title: Text(S.of(context).settingsNLAutoAdd),
374+
isThreeLine: false,
375+
value: widget.settings.autoAdd,
376+
enabled: widget.settings.defaultAccountId != null,
377+
onChanged: (bool? value) async {
378+
setState(() {
379+
widget.settings.autoAdd = value ?? false;
380+
widget.settings.includeTitle = true;
381+
});
382+
await context
383+
.read<SettingsProvider>()
384+
.notificationSetAppSettings(
385+
widget.app, widget.settings);
386+
},
387+
),
388+
CheckboxListTile(
389+
title: Text(S.of(context).settingsNLEmptyNote),
390+
isThreeLine: false,
391+
value: widget.settings.emptyNote,
392+
onChanged: (bool? value) async {
393+
setState(() {
394+
widget.settings.emptyNote = value ?? false;
395+
});
396+
await context
397+
.read<SettingsProvider>()
398+
.notificationSetAppSettings(
399+
widget.app, widget.settings);
400+
},
401+
),
368402
],
369403
),
370404
),

0 commit comments

Comments
 (0)