@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rental_income_tracker/screens/home_screen.dart';
|
||||
import 'package:rental_income_tracker/state/rent_controller.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const RentalIncomeTrackerApp());
|
||||
}
|
||||
|
||||
class RentalIncomeTrackerApp extends StatelessWidget {
|
||||
const RentalIncomeTrackerApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider<RentController>(
|
||||
create: (_) => RentController()..initialize(),
|
||||
child: MaterialApp(
|
||||
title: 'Rental Income Tracker',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
class AppSettings {
|
||||
AppSettings({
|
||||
required this.rentUsd,
|
||||
required this.occupied,
|
||||
this.notOccupiedFromYearMonth,
|
||||
this.localFallbackHour = 9,
|
||||
this.localQuietHourStart = 23,
|
||||
});
|
||||
|
||||
final double rentUsd;
|
||||
final bool occupied;
|
||||
final String? notOccupiedFromYearMonth;
|
||||
final int localFallbackHour;
|
||||
final int localQuietHourStart;
|
||||
|
||||
factory AppSettings.defaults() {
|
||||
return AppSettings(rentUsd: 0, occupied: true);
|
||||
}
|
||||
|
||||
AppSettings copyWith({
|
||||
double? rentUsd,
|
||||
bool? occupied,
|
||||
String? notOccupiedFromYearMonth,
|
||||
bool clearNotOccupiedFromYearMonth = false,
|
||||
int? localFallbackHour,
|
||||
int? localQuietHourStart,
|
||||
}) {
|
||||
return AppSettings(
|
||||
rentUsd: rentUsd ?? this.rentUsd,
|
||||
occupied: occupied ?? this.occupied,
|
||||
notOccupiedFromYearMonth: clearNotOccupiedFromYearMonth
|
||||
? null
|
||||
: (notOccupiedFromYearMonth ?? this.notOccupiedFromYearMonth),
|
||||
localFallbackHour: localFallbackHour ?? this.localFallbackHour,
|
||||
localQuietHourStart: localQuietHourStart ?? this.localQuietHourStart,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'rentUsd': rentUsd,
|
||||
'occupied': occupied,
|
||||
'notOccupiedFromYearMonth': notOccupiedFromYearMonth,
|
||||
'localFallbackHour': localFallbackHour,
|
||||
'localQuietHourStart': localQuietHourStart,
|
||||
};
|
||||
}
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> json) {
|
||||
return AppSettings(
|
||||
rentUsd: (json['rentUsd'] ?? 0).toDouble(),
|
||||
occupied: json['occupied'] ?? true,
|
||||
notOccupiedFromYearMonth: json['notOccupiedFromYearMonth'] as String?,
|
||||
localFallbackHour: json['localFallbackHour'] ?? 9,
|
||||
localQuietHourStart: json['localQuietHourStart'] ?? 23,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
enum PaymentStatus { onTime, late, notPaid, notOccupied, pending }
|
||||
|
||||
class MonthlyRentRecord {
|
||||
MonthlyRentRecord({
|
||||
required this.year,
|
||||
required this.month,
|
||||
required this.usdAmount,
|
||||
required this.sekAmount,
|
||||
required this.usdToSekRate,
|
||||
required this.paidAtUtc,
|
||||
required this.onTime,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
final int year;
|
||||
final int month;
|
||||
final double usdAmount;
|
||||
final double sekAmount;
|
||||
final double usdToSekRate;
|
||||
final DateTime paidAtUtc;
|
||||
final bool onTime;
|
||||
final String source;
|
||||
|
||||
String get key => '$year-${month.toString().padLeft(2, '0')}';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'year': year,
|
||||
'month': month,
|
||||
'usdAmount': usdAmount,
|
||||
'sekAmount': sekAmount,
|
||||
'usdToSekRate': usdToSekRate,
|
||||
'paidAtUtc': paidAtUtc.toIso8601String(),
|
||||
'onTime': onTime,
|
||||
'source': source,
|
||||
};
|
||||
}
|
||||
|
||||
factory MonthlyRentRecord.fromJson(Map<String, dynamic> json) {
|
||||
return MonthlyRentRecord(
|
||||
year: json['year'] as int,
|
||||
month: json['month'] as int,
|
||||
usdAmount: (json['usdAmount'] as num).toDouble(),
|
||||
sekAmount: (json['sekAmount'] as num).toDouble(),
|
||||
usdToSekRate: (json['usdToSekRate'] as num).toDouble(),
|
||||
paidAtUtc: DateTime.parse(json['paidAtUtc'] as String).toUtc(),
|
||||
onTime: json['onTime'] as bool,
|
||||
source: (json['source'] as String?) ?? 'manual',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rental_income_tracker/models/monthly_rent_record.dart';
|
||||
import 'package:rental_income_tracker/screens/settings_screen.dart';
|
||||
import 'package:rental_income_tracker/state/rent_controller.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<RentController>(
|
||||
builder: (context, controller, _) {
|
||||
final rows = controller.buildRowsForSelectedYear();
|
||||
final usdCurrency = NumberFormat.currency(
|
||||
symbol: r'$',
|
||||
decimalDigits: 2,
|
||||
);
|
||||
final sekCurrency = NumberFormat.currency(
|
||||
symbol: 'SEK ',
|
||||
decimalDigits: 2,
|
||||
);
|
||||
|
||||
final totalUsd = rows.isEmpty ? 0.0 : rows.last.runningUsd;
|
||||
final totalSek = rows.isEmpty ? 0.0 : rows.last.runningSek;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Rental Income Tracker'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
tooltip: 'Settings',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const SettingsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: controller.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
_YearSwitcher(
|
||||
year: controller.selectedYear,
|
||||
onPrevious: () =>
|
||||
controller.selectYear(controller.selectedYear - 1),
|
||||
onNext: controller.selectedYear < DateTime.now().year
|
||||
? () => controller.selectYear(
|
||||
controller.selectedYear + 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (controller.errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
controller.errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SingleChildScrollView(
|
||||
child: DataTable(
|
||||
columns: const <DataColumn>[
|
||||
DataColumn(label: Text('Month')),
|
||||
DataColumn(label: Text('Status')),
|
||||
DataColumn(label: Text('USD')),
|
||||
DataColumn(label: Text('SEK')),
|
||||
DataColumn(label: Text('Running USD')),
|
||||
DataColumn(label: Text('Running SEK')),
|
||||
],
|
||||
rows: rows.map((row) {
|
||||
return DataRow(
|
||||
cells: <DataCell>[
|
||||
DataCell(Text(row.monthLabel)),
|
||||
DataCell(
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_statusIcon(row.status),
|
||||
const SizedBox(width: 8),
|
||||
Text(_statusLabel(row.status)),
|
||||
],
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
row.usdAmount == null
|
||||
? '-'
|
||||
: usdCurrency.format(row.usdAmount),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(
|
||||
row.sekAmount == null
|
||||
? '-'
|
||||
: sekCurrency.format(row.sekAmount),
|
||||
),
|
||||
),
|
||||
DataCell(
|
||||
Text(usdCurrency.format(row.runningUsd)),
|
||||
),
|
||||
DataCell(
|
||||
Text(sekCurrency.format(row.runningSek)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Total: ${usdCurrency.format(totalUsd)} | ${sekCurrency.format(totalSek)}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await controller.markCurrentMonthPaidManually();
|
||||
},
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('Mark current month as paid'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusIcon(PaymentStatus status) {
|
||||
switch (status) {
|
||||
case PaymentStatus.onTime:
|
||||
return const Icon(Icons.check_circle, color: Colors.green);
|
||||
case PaymentStatus.late:
|
||||
return const Icon(Icons.schedule, color: Colors.orange);
|
||||
case PaymentStatus.notPaid:
|
||||
return const Icon(Icons.cancel, color: Colors.red);
|
||||
case PaymentStatus.notOccupied:
|
||||
return const SizedBox.shrink();
|
||||
case PaymentStatus.pending:
|
||||
return const Icon(Icons.hourglass_bottom, color: Colors.grey);
|
||||
}
|
||||
}
|
||||
|
||||
String _statusLabel(PaymentStatus status) {
|
||||
switch (status) {
|
||||
case PaymentStatus.onTime:
|
||||
return 'Paid on time';
|
||||
case PaymentStatus.late:
|
||||
return 'Paid late';
|
||||
case PaymentStatus.notPaid:
|
||||
return 'Not paid';
|
||||
case PaymentStatus.notOccupied:
|
||||
return 'Not occupied';
|
||||
case PaymentStatus.pending:
|
||||
return 'Pending';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _YearSwitcher extends StatelessWidget {
|
||||
const _YearSwitcher({
|
||||
required this.year,
|
||||
required this.onPrevious,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final int year;
|
||||
final VoidCallback onPrevious;
|
||||
final VoidCallback? onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
IconButton(onPressed: onPrevious, icon: const Icon(Icons.chevron_left)),
|
||||
Text('$year', style: Theme.of(context).textTheme.titleLarge),
|
||||
IconButton(onPressed: onNext, icon: const Icon(Icons.chevron_right)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rental_income_tracker/state/rent_controller.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final TextEditingController _rentController = TextEditingController();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final controller = context.read<RentController>();
|
||||
_rentController.text = controller.settings.rentUsd.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<RentController>(
|
||||
builder: (context, controller, _) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
controller: _rentController,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Monthly rent (USD)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final value = double.tryParse(_rentController.text.trim());
|
||||
if (value == null || value <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Enter a valid rent amount.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await controller.updateRentUsd(value);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Rent updated.')),
|
||||
);
|
||||
},
|
||||
child: const Text('Save rent amount'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
title: const Text('Unit occupied'),
|
||||
subtitle: const Text(
|
||||
'When turned off, current and future months are marked Not occupied and reminders stop.',
|
||||
),
|
||||
value: controller.settings.occupied,
|
||||
onChanged: (value) async {
|
||||
await controller.setOccupied(value);
|
||||
},
|
||||
),
|
||||
const Divider(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await controller.backfillLastYear();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (controller.errorMessage == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Backfill finished for last year.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.history),
|
||||
label: const Text('Backfill last year (one click)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final path = await controller.exportJson();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Exported to $path')));
|
||||
},
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Export JSON'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'OpenExchange API key is configured in code constant openExchangeApiKey.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:rental_income_tracker/services/time_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
const String yesActionId = 'rent_yes';
|
||||
const String noActionId = 'rent_no';
|
||||
const String _pendingActionKey = 'pending_notification_actions';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void notificationTapBackground(
|
||||
NotificationResponse notificationResponse,
|
||||
) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(_pendingActionKey) ?? <String>[];
|
||||
list.add(
|
||||
jsonEncode(<String, String?>{
|
||||
'actionId': notificationResponse.actionId,
|
||||
'payload': notificationResponse.payload,
|
||||
}),
|
||||
);
|
||||
await prefs.setStringList(_pendingActionKey, list);
|
||||
}
|
||||
|
||||
class NotificationActionEvent {
|
||||
NotificationActionEvent({
|
||||
required this.year,
|
||||
required this.month,
|
||||
required this.isYes,
|
||||
required this.isRetry,
|
||||
});
|
||||
|
||||
final int year;
|
||||
final int month;
|
||||
final bool isYes;
|
||||
final bool isRetry;
|
||||
}
|
||||
|
||||
typedef NotificationActionHandler =
|
||||
Future<void> Function(NotificationActionEvent event);
|
||||
|
||||
class NotificationService {
|
||||
NotificationService(this._onAction);
|
||||
|
||||
final NotificationActionHandler _onAction;
|
||||
final FlutterLocalNotificationsPlugin _plugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> initialize() async {
|
||||
final darwinCategory = DarwinNotificationCategory(
|
||||
'rent_actions',
|
||||
actions: <DarwinNotificationAction>[
|
||||
DarwinNotificationAction.plain(yesActionId, 'Yes'),
|
||||
DarwinNotificationAction.plain(noActionId, 'No'),
|
||||
],
|
||||
);
|
||||
|
||||
final initSettings = InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
notificationCategories: <DarwinNotificationCategory>[darwinCategory],
|
||||
),
|
||||
);
|
||||
|
||||
await _plugin.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _handleNotificationResponse,
|
||||
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
|
||||
);
|
||||
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
}
|
||||
|
||||
Future<void> processPendingActions() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(_pendingActionKey) ?? <String>[];
|
||||
if (list.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prefs.remove(_pendingActionKey);
|
||||
|
||||
for (final item in list) {
|
||||
final decoded = jsonDecode(item) as Map<String, dynamic>;
|
||||
final actionId = decoded['actionId'] as String?;
|
||||
final payload = decoded['payload'] as String?;
|
||||
await _dispatchAction(actionId: actionId, payload: payload);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scheduleForMonth({
|
||||
required int year,
|
||||
required int month,
|
||||
required int quietHourStart,
|
||||
required int fallbackHour,
|
||||
}) async {
|
||||
final firstId = _firstNotificationId(year, month);
|
||||
final retryId = _retryNotificationId(year, month);
|
||||
|
||||
await _plugin.cancel(firstId);
|
||||
await _plugin.cancel(retryId);
|
||||
|
||||
final estFirst = TimeService.estFirstReminderAt(year, month);
|
||||
final localFirst = tz.TZDateTime.from(estFirst, tz.local);
|
||||
final firstDate = TimeService.nextEligibleLocal(
|
||||
candidate: TimeService.applyQuietHours(
|
||||
localTime: localFirst,
|
||||
quietHourStart: quietHourStart,
|
||||
fallbackHour: fallbackHour,
|
||||
),
|
||||
quietHourStart: quietHourStart,
|
||||
fallbackHour: fallbackHour,
|
||||
);
|
||||
|
||||
final retryDate = TimeService.nextEligibleLocal(
|
||||
candidate: DateTime(
|
||||
firstDate.year,
|
||||
firstDate.month,
|
||||
firstDate.day + 1,
|
||||
firstDate.hour,
|
||||
firstDate.minute,
|
||||
),
|
||||
quietHourStart: quietHourStart,
|
||||
fallbackHour: fallbackHour,
|
||||
);
|
||||
|
||||
await _schedule(
|
||||
id: firstId,
|
||||
title: 'Rent check ($month/$year)',
|
||||
body: 'Was rent paid this month?',
|
||||
date: firstDate,
|
||||
payload: jsonEncode(<String, dynamic>{
|
||||
'year': year,
|
||||
'month': month,
|
||||
'isRetry': false,
|
||||
}),
|
||||
);
|
||||
|
||||
await _schedule(
|
||||
id: retryId,
|
||||
title: 'Rent check retry ($month/$year)',
|
||||
body: 'Please confirm rent payment status.',
|
||||
date: retryDate,
|
||||
payload: jsonEncode(<String, dynamic>{
|
||||
'year': year,
|
||||
'month': month,
|
||||
'isRetry': true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelForMonth(int year, int month) async {
|
||||
await _plugin.cancel(_firstNotificationId(year, month));
|
||||
await _plugin.cancel(_retryNotificationId(year, month));
|
||||
}
|
||||
|
||||
Future<void> cancelAll() => _plugin.cancelAll();
|
||||
|
||||
Future<void> _schedule({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime date,
|
||||
required String payload,
|
||||
}) async {
|
||||
final tzDate = tz.TZDateTime.from(date, tz.local);
|
||||
await _plugin.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tzDate,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'rent_channel',
|
||||
'Rent reminders',
|
||||
channelDescription: 'Monthly rent confirmation reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
actions: <AndroidNotificationAction>[
|
||||
AndroidNotificationAction(yesActionId, 'Yes'),
|
||||
AndroidNotificationAction(noActionId, 'No'),
|
||||
],
|
||||
),
|
||||
iOS: DarwinNotificationDetails(categoryIdentifier: 'rent_actions'),
|
||||
),
|
||||
payload: payload,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleNotificationResponse(NotificationResponse response) {
|
||||
return _dispatchAction(
|
||||
actionId: response.actionId,
|
||||
payload: response.payload,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dispatchAction({
|
||||
required String? actionId,
|
||||
required String? payload,
|
||||
}) async {
|
||||
if (actionId != yesActionId && actionId != noActionId) {
|
||||
return;
|
||||
}
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(payload) as Map<String, dynamic>;
|
||||
final year = decoded['year'] as int;
|
||||
final month = decoded['month'] as int;
|
||||
final isRetry = decoded['isRetry'] as bool? ?? false;
|
||||
|
||||
await _onAction(
|
||||
NotificationActionEvent(
|
||||
year: year,
|
||||
month: month,
|
||||
isYes: actionId == yesActionId,
|
||||
isRetry: isRetry,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _firstNotificationId(int year, int month) {
|
||||
return year * 100 + month;
|
||||
}
|
||||
|
||||
int _retryNotificationId(int year, int month) {
|
||||
return 100000 + year * 100 + month;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class OpenExchangeService {
|
||||
OpenExchangeService({required this.httpClient, required this.apiKey});
|
||||
|
||||
final http.Client httpClient;
|
||||
final String apiKey;
|
||||
|
||||
Future<double> fetchUsdToSekRate({required DateTime estDate}) async {
|
||||
if (apiKey == 'REPLACE_WITH_YOUR_OPENEXCHANGE_API_KEY') {
|
||||
throw StateError('OpenExchange API key is not configured.');
|
||||
}
|
||||
|
||||
final date = DateFormat('yyyy-MM-dd').format(estDate);
|
||||
final uri = Uri.https(
|
||||
'openexchangerates.org',
|
||||
'/api/historical/$date.json',
|
||||
<String, String>{'app_id': apiKey, 'symbols': 'SEK'},
|
||||
);
|
||||
|
||||
final response = await httpClient.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw StateError('OpenExchange request failed (${response.statusCode}).');
|
||||
}
|
||||
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final rates = json['rates'] as Map<String, dynamic>?;
|
||||
final sek = rates?['SEK'];
|
||||
if (sek == null) {
|
||||
throw StateError('SEK rate was missing in API response.');
|
||||
}
|
||||
|
||||
return (sek as num).toDouble();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:rental_income_tracker/models/app_settings.dart';
|
||||
import 'package:rental_income_tracker/models/monthly_rent_record.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class StorageService {
|
||||
static const _settingsKey = 'app_settings';
|
||||
static const _recordsKey = 'monthly_records';
|
||||
|
||||
Future<AppSettings> loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_settingsKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return AppSettings.defaults();
|
||||
}
|
||||
|
||||
return AppSettings.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> saveSettings(AppSettings settings) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_settingsKey, jsonEncode(settings.toJson()));
|
||||
}
|
||||
|
||||
Future<Map<String, MonthlyRentRecord>> loadRecords() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_recordsKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return <String, MonthlyRentRecord>{};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return decoded.map((key, value) {
|
||||
return MapEntry(
|
||||
key,
|
||||
MonthlyRentRecord.fromJson(value as Map<String, dynamic>),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> saveRecords(Map<String, MonthlyRentRecord> records) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final encoded = records.map((key, value) => MapEntry(key, value.toJson()));
|
||||
await prefs.setString(_recordsKey, jsonEncode(encoded));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:timezone/data/latest.dart' as tzdata;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
class TimeService {
|
||||
static const String estLocationName = 'America/New_York';
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
tzdata.initializeTimeZones();
|
||||
final localName = await FlutterTimezone.getLocalTimezone();
|
||||
tz.setLocalLocation(tz.getLocation(localName));
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static tz.Location get est => tz.getLocation(estLocationName);
|
||||
|
||||
static tz.TZDateTime estNow() => tz.TZDateTime.now(est);
|
||||
|
||||
static DateTime localNow() => tz.TZDateTime.now(tz.local);
|
||||
|
||||
static tz.TZDateTime estDeadlinePassedAt(int year, int month) {
|
||||
return tz.TZDateTime(est, year, month, 2, 0, 0);
|
||||
}
|
||||
|
||||
static tz.TZDateTime estFirstReminderAt(int year, int month) {
|
||||
return tz.TZDateTime(est, year, month, 2, 0, 5);
|
||||
}
|
||||
|
||||
static bool isOnTimeByDeadline(DateTime paidAtUtc, int year, int month) {
|
||||
final estPaidAt = tz.TZDateTime.from(paidAtUtc, est);
|
||||
final cutoff = tz.TZDateTime(est, year, month, 1, 23, 59, 59, 999);
|
||||
return !estPaidAt.isAfter(cutoff);
|
||||
}
|
||||
|
||||
static DateTime applyQuietHours({
|
||||
required DateTime localTime,
|
||||
required int quietHourStart,
|
||||
required int fallbackHour,
|
||||
}) {
|
||||
if (localTime.hour >= quietHourStart) {
|
||||
return DateTime(
|
||||
localTime.year,
|
||||
localTime.month,
|
||||
localTime.day + 1,
|
||||
fallbackHour,
|
||||
);
|
||||
}
|
||||
|
||||
return localTime;
|
||||
}
|
||||
|
||||
static DateTime nextEligibleLocal({
|
||||
required DateTime candidate,
|
||||
required int quietHourStart,
|
||||
required int fallbackHour,
|
||||
}) {
|
||||
final now = localNow();
|
||||
var result = candidate;
|
||||
|
||||
if (result.isBefore(now)) {
|
||||
result = now.add(const Duration(minutes: 1));
|
||||
}
|
||||
|
||||
if (result.hour >= quietHourStart) {
|
||||
result = DateTime(
|
||||
result.year,
|
||||
result.month,
|
||||
result.day + 1,
|
||||
fallbackHour,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool monthAtOrAfter({
|
||||
required int year,
|
||||
required int month,
|
||||
required int pivotYear,
|
||||
required int pivotMonth,
|
||||
}) {
|
||||
return year > pivotYear || (year == pivotYear && month >= pivotMonth);
|
||||
}
|
||||
|
||||
static bool monthBefore({
|
||||
required int year,
|
||||
required int month,
|
||||
required int otherYear,
|
||||
required int otherMonth,
|
||||
}) {
|
||||
return year < otherYear || (year == otherYear && month < otherMonth);
|
||||
}
|
||||
|
||||
static String monthKey(int year, int month) {
|
||||
return '$year-${month.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:rental_income_tracker/models/app_settings.dart';
|
||||
import 'package:rental_income_tracker/models/monthly_rent_record.dart';
|
||||
import 'package:rental_income_tracker/services/notification_service.dart';
|
||||
import 'package:rental_income_tracker/services/open_exchange_service.dart';
|
||||
import 'package:rental_income_tracker/services/storage_service.dart';
|
||||
import 'package:rental_income_tracker/services/time_service.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
const String openExchangeApiKey = 'REPLACE_WITH_YOUR_OPENEXCHANGE_API_KEY';
|
||||
|
||||
class RentTableRow {
|
||||
RentTableRow({
|
||||
required this.year,
|
||||
required this.month,
|
||||
required this.monthLabel,
|
||||
required this.status,
|
||||
required this.usdAmount,
|
||||
required this.sekAmount,
|
||||
required this.runningUsd,
|
||||
required this.runningSek,
|
||||
});
|
||||
|
||||
final int year;
|
||||
final int month;
|
||||
final String monthLabel;
|
||||
final PaymentStatus status;
|
||||
final double? usdAmount;
|
||||
final double? sekAmount;
|
||||
final double runningUsd;
|
||||
final double runningSek;
|
||||
}
|
||||
|
||||
class RentController extends ChangeNotifier {
|
||||
RentController({
|
||||
StorageService? storageService,
|
||||
OpenExchangeService? exchangeService,
|
||||
}) : _storageService = storageService ?? StorageService(),
|
||||
_exchangeService =
|
||||
exchangeService ??
|
||||
OpenExchangeService(
|
||||
httpClient: http.Client(),
|
||||
apiKey: openExchangeApiKey,
|
||||
) {
|
||||
_notificationService = NotificationService(_onNotificationAction);
|
||||
}
|
||||
|
||||
final StorageService _storageService;
|
||||
final OpenExchangeService _exchangeService;
|
||||
late NotificationService _notificationService;
|
||||
|
||||
AppSettings _settings = AppSettings.defaults();
|
||||
Map<String, MonthlyRentRecord> _records = <String, MonthlyRentRecord>{};
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
int _selectedYear = DateTime.now().year;
|
||||
|
||||
AppSettings get settings => _settings;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
int get selectedYear => _selectedYear;
|
||||
|
||||
Future<void> initialize() async {
|
||||
_setLoading(true);
|
||||
_errorMessage = null;
|
||||
|
||||
try {
|
||||
await TimeService.initialize();
|
||||
await _notificationService.initialize();
|
||||
|
||||
_settings = await _storageService.loadSettings();
|
||||
_records = await _storageService.loadRecords();
|
||||
|
||||
await _notificationService.processPendingActions();
|
||||
await _syncNotificationScheduleForCurrentMonth();
|
||||
} catch (err) {
|
||||
_errorMessage = err.toString();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void selectYear(int year) {
|
||||
_selectedYear = year;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateRentUsd(double value) async {
|
||||
_settings = _settings.copyWith(rentUsd: value);
|
||||
await _storageService.saveSettings(_settings);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setOccupied(bool occupied) async {
|
||||
final now = DateTime.now();
|
||||
if (occupied) {
|
||||
_settings = _settings.copyWith(
|
||||
occupied: true,
|
||||
clearNotOccupiedFromYearMonth: true,
|
||||
);
|
||||
await _notificationService.cancelAll();
|
||||
await _syncNotificationScheduleForCurrentMonth();
|
||||
} else {
|
||||
_settings = _settings.copyWith(
|
||||
occupied: false,
|
||||
notOccupiedFromYearMonth: TimeService.monthKey(now.year, now.month),
|
||||
);
|
||||
await _notificationService.cancelAll();
|
||||
}
|
||||
|
||||
await _storageService.saveSettings(_settings);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> markCurrentMonthPaidManually() async {
|
||||
final now = DateTime.now();
|
||||
await markMonthPaid(
|
||||
year: now.year,
|
||||
month: now.month,
|
||||
assumeOnTime: false,
|
||||
source: 'manual',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markMonthPaid({
|
||||
required int year,
|
||||
required int month,
|
||||
required bool assumeOnTime,
|
||||
required String source,
|
||||
}) async {
|
||||
_errorMessage = null;
|
||||
_setLoading(true);
|
||||
|
||||
try {
|
||||
if (_isNotOccupiedMonth(year, month)) {
|
||||
throw StateError('Cannot mark a non-occupied month as paid.');
|
||||
}
|
||||
if (_settings.rentUsd <= 0) {
|
||||
throw StateError('Set rent amount in Settings before marking paid.');
|
||||
}
|
||||
|
||||
final paidAt = DateTime.now().toUtc();
|
||||
final estPaidAt = tz.TZDateTime.from(paidAt, TimeService.est);
|
||||
final estDate = DateTime(estPaidAt.year, estPaidAt.month, estPaidAt.day);
|
||||
final rate = await _exchangeService.fetchUsdToSekRate(estDate: estDate);
|
||||
final usd = _settings.rentUsd;
|
||||
final sek = usd * rate;
|
||||
final onTime =
|
||||
assumeOnTime || TimeService.isOnTimeByDeadline(paidAt, year, month);
|
||||
|
||||
final record = MonthlyRentRecord(
|
||||
year: year,
|
||||
month: month,
|
||||
usdAmount: usd,
|
||||
sekAmount: sek,
|
||||
usdToSekRate: rate,
|
||||
paidAtUtc: paidAt,
|
||||
onTime: onTime,
|
||||
source: source,
|
||||
);
|
||||
|
||||
_records[record.key] = record;
|
||||
await _storageService.saveRecords(_records);
|
||||
|
||||
await _notificationService.cancelForMonth(year, month);
|
||||
await _syncNotificationScheduleForCurrentMonth();
|
||||
} catch (err) {
|
||||
_errorMessage = err.toString();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> backfillLastYear() async {
|
||||
_errorMessage = null;
|
||||
_setLoading(true);
|
||||
|
||||
try {
|
||||
if (_settings.rentUsd <= 0) {
|
||||
throw StateError('Set rent amount in Settings before backfill.');
|
||||
}
|
||||
|
||||
final year = DateTime.now().year - 1;
|
||||
for (var month = 1; month <= 12; month++) {
|
||||
final key = TimeService.monthKey(year, month);
|
||||
if (_records.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final estDate = DateTime(year, month, 1);
|
||||
final rate = await _exchangeService.fetchUsdToSekRate(estDate: estDate);
|
||||
final usd = _settings.rentUsd;
|
||||
|
||||
final paidAtUtc = tz.TZDateTime(
|
||||
TimeService.est,
|
||||
year,
|
||||
month,
|
||||
1,
|
||||
12,
|
||||
).toUtc();
|
||||
|
||||
_records[key] = MonthlyRentRecord(
|
||||
year: year,
|
||||
month: month,
|
||||
usdAmount: usd,
|
||||
sekAmount: usd * rate,
|
||||
usdToSekRate: rate,
|
||||
paidAtUtc: paidAtUtc,
|
||||
onTime: true,
|
||||
source: 'backfill',
|
||||
);
|
||||
}
|
||||
|
||||
await _storageService.saveRecords(_records);
|
||||
notifyListeners();
|
||||
} catch (err) {
|
||||
_errorMessage = err.toString();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> exportJson() async {
|
||||
final records = _records.values.toList()
|
||||
..sort((a, b) {
|
||||
final byYear = a.year.compareTo(b.year);
|
||||
if (byYear != 0) {
|
||||
return byYear;
|
||||
}
|
||||
return a.month.compareTo(b.month);
|
||||
});
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'exportedAtUtc': DateTime.now().toUtc().toIso8601String(),
|
||||
'settings': _settings.toJson(),
|
||||
'records': records.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
final jsonString = const JsonEncoder.withIndent(' ').convert(payload);
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||||
final file = File('${dir.path}/rent_tracker_export_$timestamp.json');
|
||||
await file.writeAsString(jsonString);
|
||||
return file.path;
|
||||
}
|
||||
|
||||
List<RentTableRow> buildRowsForSelectedYear() {
|
||||
final now = DateTime.now();
|
||||
final maxMonth = _selectedYear < now.year
|
||||
? 12
|
||||
: (_selectedYear == now.year ? now.month : 0);
|
||||
|
||||
final rows = <RentTableRow>[];
|
||||
var runningUsd = 0.0;
|
||||
var runningSek = 0.0;
|
||||
|
||||
for (var month = 1; month <= maxMonth; month++) {
|
||||
final key = TimeService.monthKey(_selectedYear, month);
|
||||
final record = _records[key];
|
||||
final status = _statusForMonth(_selectedYear, month, record);
|
||||
|
||||
double? usd;
|
||||
double? sek;
|
||||
|
||||
if (record != null) {
|
||||
usd = record.usdAmount;
|
||||
sek = record.sekAmount;
|
||||
runningUsd += usd;
|
||||
runningSek += sek;
|
||||
}
|
||||
|
||||
rows.add(
|
||||
RentTableRow(
|
||||
year: _selectedYear,
|
||||
month: month,
|
||||
monthLabel: DateFormat.MMMM().format(DateTime(_selectedYear, month)),
|
||||
status: status,
|
||||
usdAmount: usd,
|
||||
sekAmount: sek,
|
||||
runningUsd: runningUsd,
|
||||
runningSek: runningSek,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
Future<void> _onNotificationAction(NotificationActionEvent event) async {
|
||||
if (event.isYes) {
|
||||
await markMonthPaid(
|
||||
year: event.year,
|
||||
month: event.month,
|
||||
assumeOnTime: !event.isRetry,
|
||||
source: 'notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _syncNotificationScheduleForCurrentMonth();
|
||||
}
|
||||
|
||||
PaymentStatus _statusForMonth(
|
||||
int year,
|
||||
int month,
|
||||
MonthlyRentRecord? record,
|
||||
) {
|
||||
if (_isNotOccupiedMonth(year, month)) {
|
||||
return PaymentStatus.notOccupied;
|
||||
}
|
||||
|
||||
if (record != null) {
|
||||
return record.onTime ? PaymentStatus.onTime : PaymentStatus.late;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
if (TimeService.monthBefore(
|
||||
year: year,
|
||||
month: month,
|
||||
otherYear: now.year,
|
||||
otherMonth: now.month,
|
||||
)) {
|
||||
return PaymentStatus.notPaid;
|
||||
}
|
||||
|
||||
return PaymentStatus.pending;
|
||||
}
|
||||
|
||||
bool _isNotOccupiedMonth(int year, int month) {
|
||||
if (_settings.occupied || _settings.notOccupiedFromYearMonth == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final parts = _settings.notOccupiedFromYearMonth!.split('-');
|
||||
if (parts.length != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final pivotYear = int.tryParse(parts[0]);
|
||||
final pivotMonth = int.tryParse(parts[1]);
|
||||
if (pivotYear == null || pivotMonth == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return TimeService.monthAtOrAfter(
|
||||
year: year,
|
||||
month: month,
|
||||
pivotYear: pivotYear,
|
||||
pivotMonth: pivotMonth,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _syncNotificationScheduleForCurrentMonth() async {
|
||||
final now = DateTime.now();
|
||||
final key = TimeService.monthKey(now.year, now.month);
|
||||
final isPaid = _records.containsKey(key);
|
||||
|
||||
if (!_settings.occupied ||
|
||||
_isNotOccupiedMonth(now.year, now.month) ||
|
||||
isPaid) {
|
||||
await _notificationService.cancelForMonth(now.year, now.month);
|
||||
return;
|
||||
}
|
||||
|
||||
await _notificationService.scheduleForMonth(
|
||||
year: now.year,
|
||||
month: now.month,
|
||||
quietHourStart: _settings.localQuietHourStart,
|
||||
fallbackHour: _settings.localFallbackHour,
|
||||
);
|
||||
}
|
||||
|
||||
void _setLoading(bool value) {
|
||||
_isLoading = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user