diff --git a/lib/app_router.dart b/lib/app_router.dart index 92a2d99..85dbd13 100644 --- a/lib/app_router.dart +++ b/lib/app_router.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:wealth_wave/ui/nav_path.dart'; +import 'package:wealth_wave/ui/pages/goal_page.dart'; import 'package:wealth_wave/ui/pages/investment_page.dart'; import 'package:wealth_wave/ui/pages/main_page.dart'; @@ -8,8 +9,10 @@ class AppRouter { final uri = Uri.parse(path); if (NavPath.isMainPagePath(uri.pathSegments)) { return MainPage(path: uri.pathSegments); - } else if(NavPath.isInvestmentPagePath(uri.pathSegments)) { + } else if (NavPath.isInvestmentPagePath(uri.pathSegments)) { return InvestmentPage(investmentId: int.parse(uri.pathSegments[1])); + } else if (NavPath.isGoalPagePath(uri.pathSegments)) { + return GoalPage(goalId: int.parse(uri.pathSegments[1])); } return const MainPage(path: []); } diff --git a/lib/presentation/goal_presenter.dart b/lib/presentation/goal_presenter.dart new file mode 100644 index 0000000..78908f6 --- /dev/null +++ b/lib/presentation/goal_presenter.dart @@ -0,0 +1,212 @@ +import 'package:wealth_wave/contract/goal_importance.dart'; +import 'package:wealth_wave/contract/risk_level.dart'; +import 'package:wealth_wave/core/presenter.dart'; +import 'package:wealth_wave/domain/models/goal.dart'; +import 'package:wealth_wave/domain/services/goal_service.dart'; + +class GoalPresenter extends Presenter { + final GoalService _goalService; + + GoalPresenter({final GoalService? goalService}) + : _goalService = goalService ?? GoalService(), + super(GoalViewState()); + + void fetchGoal({required final int id}) { + _goalService.getBy(id: id).then((goal) { + return GoalVO.from( + goal: goal, + goalAmountOverTime: _getGoalValueOverTime(goal), + investedAmountDataOverTime: + _getInvestmentOverTime(goal), + investedValueOverTime: _getValueOverTime(goal)); + }).then((goalVO) { + updateViewState((viewState) => viewState.goalVO = goalVO); + }); + } + + Map _getGoalValueOverTime(Goal goal) { + Map valueOverTime = {}; + + valueOverTime[goal.amountUpdatedOn] = goal.amount; + valueOverTime[goal.maturityDate] = goal.maturityAmount; + + return valueOverTime; + } + + Map _getInvestmentOverTime( + Goal goal) { + Map valueOverTime = {}; + Map dateInvestmentMap = {}; + + goal.taggedInvestments.forEach((investment, percentage) { + investment + .getPayments(till: goal.maturityDate) + .map((e) => MapEntry(e.createdOn, e.amount)) + .forEach((entry) { + dateInvestmentMap.update( + entry.key, (value) => value + entry.value * percentage/100, + ifAbsent: () => entry.value * percentage/100); + }); + dateInvestmentMap.update(goal.maturityDate, (value) => investment.getTotalInvestedAmount(till: goal.maturityDate) * percentage/100, + ifAbsent: () => investment.getTotalInvestedAmount(till: goal.maturityDate) * percentage/100); + }); + + List investmentDates = dateInvestmentMap.keys.toList(); + investmentDates.sort((a, b) => a.compareTo(b)); + + double previousValue = 0; + for (var date in investmentDates) { + double investedAmount = dateInvestmentMap[date] ?? 0; + double valueSoFar = previousValue + investedAmount; + valueOverTime[date] = valueSoFar; + previousValue = valueSoFar; + } + + return valueOverTime; + } + + Map _getValueOverTime(Goal goal) { + Map valueOverTime = {}; + Map dateInvestmentMap = {}; + double totalValue = 0; + double totalValueOnMaturity = 0; + double totalInvested = 0; + + goal.taggedInvestments.forEach((investment, percentage) { + investment + .getPayments(till: goal.maturityDate) + .map((e) => MapEntry(e.createdOn, e.amount)) + .forEach((entry) { + dateInvestmentMap.update( + entry.key, (value) => value + entry.value * percentage/100, + ifAbsent: () => entry.value * percentage/100); + }); + dateInvestmentMap.update(DateTime.now(), (value) => value,ifAbsent: () => 0); + totalValue += investment.getValueOn(date: DateTime.now()) * percentage/100; + totalInvested += + investment.getTotalInvestedAmount(till: goal.maturityDate) * percentage/100; + totalValueOnMaturity += investment.getValueOn(date: goal.maturityDate) * percentage/100; + }); + + List investmentDates = dateInvestmentMap.keys.toList(); + investmentDates.sort((a, b) => a.compareTo(b)); + + double previousValue = 0; + for (var date in investmentDates) { + double proportion = (dateInvestmentMap[date] ?? 0) / totalInvested; + double value = (proportion * totalValue); + double valueSoFar = previousValue + value; + valueOverTime[date] = valueSoFar; + previousValue = valueSoFar; + } + valueOverTime[goal.maturityDate] = totalValueOnMaturity; + + return valueOverTime; + } +} + +class GoalViewState { + GoalVO? goalVO; +} + +class GoalVO { + final int id; + final String name; + final String? description; + final double maturityAmount; + final double investedAmount; + final DateTime maturityDate; + final double currentValue; + final double valueOnMaturity; + final double inflation; + final double irr; + final GoalImportance importance; + final List healthSuggestions; + final int taggedInvestmentCount; + final Map riskComposition; + final Map basketComposition; + final Map goalAmountOverTime; + final Map investmentAmountOverTime; + final Map investedValueOverTime; + + double get yearsLeft => maturityDate.difference(DateTime.now()).inDays / 365; + + double get currentProgressPercent { + double progress = (currentValue / maturityAmount) * 100; + if (progress > 100) { + return 100; + } else { + return progress; + } + } + + double get maturityProgressPercent { + double progress = (valueOnMaturity / maturityAmount) * 100; + if (progress > 100) { + return 100; + } else { + return progress; + } + } + + double get pendingProgressPercent => 100 - maturityProgressPercent; + + double get lowRiskProgressPercent => + ((riskComposition[RiskLevel.veryLow] ?? 0.0) + + (riskComposition[RiskLevel.low] ?? 0.0)) * + 100; + + double get mediumRiskProgressPercent => + (riskComposition[RiskLevel.medium] ?? 0.0) * 100; + + double get highRiskProgressPercent => + ((riskComposition[RiskLevel.high] ?? 0.0) + + (riskComposition[RiskLevel.veryHigh] ?? 0.0)) * + 100; + + GoalVO._( + {required this.id, + required this.name, + required this.description, + required this.maturityAmount, + required this.maturityDate, + required this.currentValue, + required this.valueOnMaturity, + required this.investedAmount, + required this.importance, + required this.inflation, + required this.irr, + required this.healthSuggestions, + required this.taggedInvestmentCount, + required this.riskComposition, + required this.basketComposition, + required this.goalAmountOverTime, + required this.investmentAmountOverTime, + required this.investedValueOverTime}); + + factory GoalVO.from( + {required final Goal goal, + required Map goalAmountOverTime, + required Map investedAmountDataOverTime, + required Map investedValueOverTime}) { + return GoalVO._( + id: goal.id, + name: goal.name, + description: goal.description, + importance: goal.importance, + inflation: goal.inflation, + currentValue: goal.value, + maturityAmount: goal.maturityAmount, + investedAmount: goal.investedAmount, + maturityDate: goal.maturityDate, + irr: goal.irr, + healthSuggestions: goal.healthSuggestions, + valueOnMaturity: goal.valueOnMaturity, + taggedInvestmentCount: goal.taggedInvestments.length, + riskComposition: goal.riskComposition, + basketComposition: goal.basketComposition, + goalAmountOverTime: goalAmountOverTime, + investmentAmountOverTime: investedAmountDataOverTime, + investedValueOverTime: investedValueOverTime); + } +} diff --git a/lib/ui/nav_path.dart b/lib/ui/nav_path.dart index a4e729c..d3e5f96 100644 --- a/lib/ui/nav_path.dart +++ b/lib/ui/nav_path.dart @@ -9,6 +9,7 @@ class NavPath { static const String createBasket = '/create-basket'; static String updateBasket({required final int id}) => '/baskets/$id/update'; static String investment({required final int id}) => '/investments/$id'; + static String goal({required final int id}) => '/goals/$id'; static isMainPagePath(List paths) => paths.isEmpty; @@ -23,4 +24,7 @@ class NavPath { static isInvestmentPagePath(List paths) => paths.length == 2 && paths[0] == 'investments'; + + static isGoalPagePath(List paths) => + paths.length == 2 && paths[0] == 'goals'; } diff --git a/lib/ui/pages/goal_page.dart b/lib/ui/pages/goal_page.dart new file mode 100644 index 0000000..9ae5789 --- /dev/null +++ b/lib/ui/pages/goal_page.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:primer_progress_bar/primer_progress_bar.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:wealth_wave/contract/goal_importance.dart'; +import 'package:wealth_wave/core/page_state.dart'; +import 'package:wealth_wave/presentation/goal_presenter.dart'; +import 'package:wealth_wave/ui/app_dimen.dart'; +import 'package:wealth_wave/utils/ui_utils.dart'; + +class GoalPage extends StatefulWidget { + final int goalId; + + const GoalPage({super.key, required this.goalId}); + + @override + State createState() => _GoalPage(); +} + +class _GoalPage extends PageState { + @override + void initState() { + super.initState(); + presenter.fetchGoal(id: widget.goalId); + } + + @override + Widget buildWidget(BuildContext context, GoalViewState snapshot) { + GoalVO? goalVO = snapshot.goalVO; + + if (goalVO == null) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + +return Scaffold( + appBar: AppBar(title: const Text('Goal')), + body: SingleChildScrollView( + child: _goalWidget(context: context, goalVO: goalVO))); + } + + @override + GoalPresenter initializePresenter() { + return GoalPresenter(); + } + + Widget _goalWidget( + {required final BuildContext context, required final GoalVO goalVO}) { + return Column(children: [ + Card( + margin: const EdgeInsets.all(AppDimen.defaultPadding), + child: Padding( + padding: const EdgeInsets.all(AppDimen.defaultPadding), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + _getTitleWidget(goalVO, context), + Text('${goalVO.taggedInvestmentCount} investments'), + ], + ), + Column( + children: [ + Text(formatToCurrency(goalVO.maturityAmount), + style: Theme.of(context).textTheme.bodyLarge), + Text( + 'At ${formatToPercentage(goalVO.inflation)} inflation', + style: Theme.of(context).textTheme.labelMedium), + ], + ), + ], + ), + PrimerProgressBar( + segments: _getProgressSegments(goalVO, context), + ), + const Padding(padding: EdgeInsets.all(AppDimen.minPadding)), + _getSuggestions(context, goalVO) + ], + ), + )), + _buildTimeLine( + goalVO.goalAmountOverTime.entries.toList(), + goalVO.investedValueOverTime.entries.toList(), + goalVO.investmentAmountOverTime.entries.toList(), + ) + ]); + } + + Column _getSuggestions(BuildContext context, GoalVO goalVO) { + return Column( + children: goalVO.healthSuggestions.map((suggestion) { + return Padding( + padding: const EdgeInsets.all(AppDimen.textPadding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Text('• ', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Expanded( + child: Text(suggestion, + style: Theme.of(context).textTheme.bodyMedium), + ), + ], + ), + ); + }).toList(), + ); + } + + List _getProgressSegments(GoalVO goalVO, BuildContext context) { + List segments = []; + if (goalVO.currentProgressPercent == 100) { + segments.add( + Segment( + value: (goalVO.currentProgressPercent).toInt(), + label: const Text('Current Value'), + valueLabel: Text(formatToCurrency(goalVO.currentValue), + style: Theme.of(context).textTheme.bodyMedium), + color: + goalVO.healthSuggestions.isEmpty ? Colors.green : Colors.red), + ); + } else { + segments.add( + Segment( + value: (goalVO.currentProgressPercent).toInt(), + label: const Text('Current Value'), + valueLabel: Text(formatToCurrency(goalVO.currentValue), + style: Theme.of(context).textTheme.bodyMedium), + color: + goalVO.healthSuggestions.isEmpty ? Colors.green : Colors.red), + ); + if (goalVO.maturityProgressPercent > goalVO.currentProgressPercent) { + segments.add( + Segment( + value: (goalVO.maturityProgressPercent - + goalVO.currentProgressPercent) + .toInt(), + label: const Text('Maturity Value'), + valueLabel: Text(formatToCurrency(goalVO.valueOnMaturity), + style: Theme.of(context).textTheme.bodyMedium), + color: goalVO.healthSuggestions.isEmpty + ? Colors.lightGreen + : Colors.orange), + ); + } + segments.add(Segment( + value: (goalVO.pendingProgressPercent).toInt(), + label: const Text('Health:'), + valueLabel: Text(goalVO.healthSuggestions.isEmpty ? 'Good' : 'Risky', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: goalVO.healthSuggestions.isEmpty + ? Colors.green + : Colors.red)), + color: Colors.transparent)); + } + return segments; + } + + String _getYearsLeft(double yearsLeft) { + if (yearsLeft < 0) { + return 'Matured'; + } else if (yearsLeft < 1) { + return '${(yearsLeft * 12).round()} months'; + } else { + return '${yearsLeft.round()} yrs'; + } + } + + RichText _getTitleWidget(GoalVO goalVO, BuildContext context) { + List widgets = []; + bool isLowImportance = goalVO.importance == GoalImportance.low; + if (!isLowImportance) { + widgets.add(WidgetSpan( + child: Text(' | ${_getImportance(goalVO.importance)}', + style: Theme.of(context).textTheme.labelMedium))); + } + widgets.add(WidgetSpan( + child: Text(' | ${_getYearsLeft(goalVO.yearsLeft)}', + style: Theme.of(context).textTheme.labelMedium))); + return RichText( + text: TextSpan( + text: goalVO.name, + style: Theme.of(context).textTheme.titleMedium, + children: widgets)); + } + + String _getImportance(GoalImportance importance) { + switch (importance) { + case GoalImportance.low: + return 'Low'; + case GoalImportance.medium: + return 'Medium'; + case GoalImportance.high: + return 'High'; + } + } + + Widget _buildTimeLine( + final List> goalValueData, + final List> investmentValueData, + final List> investedAmountData) { + if (investmentValueData.isNotEmpty && + investedAmountData.isNotEmpty && + goalValueData.isNotEmpty) { + return SfCartesianChart( + primaryXAxis: const DateTimeAxis(), + primaryYAxis: NumericAxis( + numberFormat: NumberFormat.compactCurrency( + symbol: '', locale: 'en_IN', decimalDigits: 0)), + series: [ + LineSeries, DateTime>( + color: Colors.green, + dataSource: investmentValueData, + xValueMapper: (MapEntry entry, _) => entry.key, + yValueMapper: (MapEntry entry, _) => entry.value, + name: 'Invested Value', + ), + LineSeries, DateTime>( + color: Colors.blue, + dataSource: investedAmountData, + xValueMapper: (MapEntry entry, _) => entry.key, + yValueMapper: (MapEntry entry, _) => entry.value, + name: 'Invested Amount', + ), + LineSeries, DateTime>( + color: Colors.grey, + dataSource: goalValueData, + xValueMapper: (MapEntry entry, _) => entry.key, + yValueMapper: (MapEntry entry, _) => entry.value, + name: 'Goal Amount', + ), + ], + tooltipBehavior: TooltipBehavior(enable: true), + ); + } + return const Text(''); + } +} diff --git a/lib/ui/pages/goals_page.dart b/lib/ui/pages/goals_page.dart index e6a973d..7142ac8 100644 --- a/lib/ui/pages/goals_page.dart +++ b/lib/ui/pages/goals_page.dart @@ -4,6 +4,7 @@ import 'package:wealth_wave/contract/goal_importance.dart'; import 'package:wealth_wave/core/page_state.dart'; import 'package:wealth_wave/presentation/goals_presenter.dart'; import 'package:wealth_wave/ui/app_dimen.dart'; +import 'package:wealth_wave/ui/nav_path.dart'; import 'package:wealth_wave/ui/widgets/create_goal_dialog.dart'; import 'package:wealth_wave/ui/widgets/create_tag_investment_dialog.dart'; import 'package:wealth_wave/ui/widgets/view_tagged_investment_dialog.dart'; @@ -55,57 +56,62 @@ class _GoalsPage extends PageState { {required final BuildContext context, required final GoalVO goalVO}) { return Card( margin: const EdgeInsets.all(AppDimen.defaultPadding), - child: Padding( - padding: const EdgeInsets.all(AppDimen.defaultPadding), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: InkWell( + onTap: () => + {Navigator.of(context).pushNamed(NavPath.goal(id: goalVO.id))}, + child: Padding( + padding: const EdgeInsets.all(AppDimen.defaultPadding), + child: Column( children: [ Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _getTitleWidget(goalVO, context), - goalVO.taggedInvestmentCount == 0 - ? FilledButton( - onPressed: () { - showTagInvestmentDialog( - context: context, goalId: goalVO.id) - .then((value) => presenter.fetchGoals()); - }, - child: const Text('Tag Investment'), - ) - : TextButton( - onPressed: () { - showTaggedInvestmentDialog( - context: context, goalId: goalVO.id) - .then((value) => presenter.fetchGoals()); - }, - child: Text( - '${goalVO.taggedInvestmentCount} investments'), - ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + _getTitleWidget(goalVO, context), + goalVO.taggedInvestmentCount == 0 + ? FilledButton( + onPressed: () { + showTagInvestmentDialog( + context: context, goalId: goalVO.id) + .then( + (value) => presenter.fetchGoals()); + }, + child: const Text('Tag Investment'), + ) + : TextButton( + onPressed: () { + showTaggedInvestmentDialog( + context: context, goalId: goalVO.id) + .then( + (value) => presenter.fetchGoals()); + }, + child: Text( + '${goalVO.taggedInvestmentCount} investments'), + ), + ], + ), + Column( + children: [ + Text(formatToCurrency(goalVO.maturityAmount), + style: Theme.of(context).textTheme.bodyLarge), + Text( + 'At ${formatToPercentage(goalVO.inflation)} inflation', + style: Theme.of(context).textTheme.labelMedium), + ], + ), ], ), - Column( - children: [ - Text(formatToCurrency(goalVO.maturityAmount), - style: Theme.of(context).textTheme.bodyLarge), - Text( - 'At ${formatToPercentage(goalVO.inflation)} inflation', - style: Theme.of(context).textTheme.labelMedium), - ], + PrimerProgressBar( + segments: _getProgressSegments(goalVO, context), ), + const Padding(padding: EdgeInsets.all(AppDimen.minPadding)), + _getSuggestions(context, goalVO) ], ), - PrimerProgressBar( - segments: _getProgressSegments(goalVO, context), - ), - const Padding(padding: EdgeInsets.all(AppDimen.minPadding)), - _getSuggestions(context, goalVO) - ], - ), - )); + ))); } Column _getSuggestions(BuildContext context, GoalVO goalVO) {