@@ -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.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user