diff --git a/lib/app/home/home.page.dart b/lib/app/home/home.page.dart index 74700a38..f862d6ec 100644 --- a/lib/app/home/home.page.dart +++ b/lib/app/home/home.page.dart @@ -2,7 +2,7 @@ import 'package:drift/drift.dart' as drift; import 'package:flutter/material.dart'; import 'package:monekin/app/accounts/account_details.dart'; import 'package:monekin/app/accounts/account_form.dart'; -import 'package:monekin/app/home/widgets/drawer.dart'; +import 'package:monekin/app/home/widgets/home_drawer.dart'; import 'package:monekin/app/home/widgets/income_or_expense_card.dart'; import 'package:monekin/app/stats/widgets/balance_bar_chart_small.dart'; import 'package:monekin/app/stats/widgets/chart_by_categories.dart'; @@ -15,6 +15,7 @@ import 'package:monekin/core/database/services/account/account_service.dart'; import 'package:monekin/core/database/services/transaction/transaction_service.dart'; import 'package:monekin/core/models/account/account.dart'; import 'package:monekin/core/presentation/responsive/breakpoints.dart'; +import 'package:monekin/core/presentation/responsive/responsive_row_column.dart'; import 'package:monekin/core/presentation/widgets/animated_progress_bar.dart'; import 'package:monekin/core/presentation/widgets/card_with_header.dart'; import 'package:monekin/core/presentation/widgets/number_ui_formatters/currency_displayer.dart'; @@ -171,7 +172,7 @@ class _HomePageState extends State { appBar: AppBar( title: const Text('Monekin'), elevation: 1, - centerTitle: true, + centerTitle: BreakPoint.of(context).isSmallerOrEqualTo(BreakpointID.md), actions: [ IconButton( onPressed: () { @@ -199,7 +200,7 @@ class _HomePageState extends State { } }); }), - drawer: const HomeDrawer(), + drawer: const Drawer(child: HomeDrawer()), body: SingleChildScrollView( child: Column( children: [ @@ -212,85 +213,112 @@ class _HomePageState extends State { bottomLeft: Radius.circular(16), bottomRight: Radius.circular(16), )), - padding: - const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - child: Column( + padding: EdgeInsets.fromLTRB( + 16, + BreakPoint.of(context).isLargerThan(BreakpointID.md) + ? 8 + : 24, + 16, + 8), + child: ResponsiveRowColumn( + direction: + BreakPoint.of(context).isLargerThan(BreakpointID.md) + ? Axis.horizontal + : Axis.vertical, + rowSpacing: 40, + columnSpacing: 18, children: [ - const SizedBox(height: 12), - StreamBuilder( - stream: _accountsStream, - builder: (context, accounts) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - '${t.home.total_balance} - ${dateRangeService.selectedDateRange.currentText(context)}', - style: const TextStyle(fontSize: 12)), - if (!accounts.hasData) ...[ - const Skeleton(width: 70, height: 40), - const Skeleton(width: 30, height: 14), - ], - if (accounts.hasData) ...[ - StreamBuilder( - stream: accountService.getAccountsMoney( - accountIds: - accounts.data!.map((e) => e.id)), - builder: (context, snapshot) { - if (snapshot.hasData) { - return CurrencyDisplayer( - amountToConvert: snapshot.data!, - textStyle: const TextStyle( - fontSize: 40, - fontWeight: FontWeight.w600), - ); - } - - return const Skeleton( - width: 90, height: 40); - }), - if (dateRangeService.startDate != null && - dateRangeService.endDate != null) + ResponsiveRowColumnItem( + child: StreamBuilder( + stream: _accountsStream, + builder: (context, accounts) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: BreakPoint.of(context) + .isLargerThan(BreakpointID.md) + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + Text( + '${t.home.total_balance} - ${dateRangeService.selectedDateRange.currentText(context)}', + style: const TextStyle(fontSize: 12)), + if (!accounts.hasData) ...[ + const Skeleton(width: 70, height: 40), + const Skeleton(width: 30, height: 14), + ], + if (accounts.hasData) ...[ StreamBuilder( - stream: accountService - .getAccountsMoneyVariation( - accounts: accounts.data!, - startDate: - dateRangeService.startDate, - endDate: dateRangeService.endDate, - convertToPreferredCurrency: true), + stream: accountService.getAccountsMoney( + accountIds: + accounts.data!.map((e) => e.id)), builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Skeleton( - width: 52, height: 22); + if (snapshot.hasData) { + return CurrencyDisplayer( + amountToConvert: snapshot.data!, + textStyle: const TextStyle( + fontSize: 40, + fontWeight: FontWeight.w600), + ); } - return TrendingValue( - percentage: snapshot.data!, - filled: true, - fontWeight: FontWeight.bold, - outlined: true, - ); + return const Skeleton( + width: 90, height: 40); }), - ] - ], - ); - }), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IncomeOrExpenseCard( - type: AccountDataFilter.income, - startDate: dateRangeService.startDate, - endDate: dateRangeService.endDate, - ), - IncomeOrExpenseCard( - type: AccountDataFilter.expense, - startDate: dateRangeService.startDate, - endDate: dateRangeService.endDate, - ), - ], + if (dateRangeService.startDate != null && + dateRangeService.endDate != null) + StreamBuilder( + stream: accountService + .getAccountsMoneyVariation( + accounts: accounts.data!, + startDate: + dateRangeService.startDate, + endDate: + dateRangeService.endDate, + convertToPreferredCurrency: + true), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Skeleton( + width: 52, height: 22); + } + + return TrendingValue( + percentage: snapshot.data!, + filled: true, + fontWeight: FontWeight.bold, + outlined: true, + ); + }), + ] + ], + ); + }), + ), + ResponsiveRowColumnItem( + child: ResponsiveRowColumn( + direction: + BreakPoint.of(context).isLargerThan(BreakpointID.md) + ? Axis.vertical + : Axis.horizontal, + rowMainAxisAlignment: MainAxisAlignment.spaceBetween, + columnCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + ResponsiveRowColumnItem( + child: IncomeOrExpenseCard( + type: AccountDataFilter.income, + startDate: dateRangeService.startDate, + endDate: dateRangeService.endDate, + ), + ), + ResponsiveRowColumnItem( + child: IncomeOrExpenseCard( + type: AccountDataFilter.expense, + startDate: dateRangeService.startDate, + endDate: dateRangeService.endDate, + ), + ), + ], + ), ), ], ), @@ -397,216 +425,206 @@ class _HomePageState extends State { ); }), const SizedBox(height: 16), - LayoutBuilder(builder: (context, constraints) { - return Wrap( - runSpacing: 16, - spacing: 16, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - SizedBox( - width: constraints.maxWidth > - BreakPoint.getById(BreakpointID.md).width - ? constraints.maxWidth / 2 - 16 - : double.infinity, - child: CardWithHeader( - title: t.financial_health.display, - body: StreamBuilder( - stream: _accountsStream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const LinearProgressIndicator(); - } + ResponsiveRowColumn.withSymetricSpacing( + spacing: 16, + direction: + BreakPoint.of(context).isLargerThan(BreakpointID.md) + ? Axis.horizontal + : Axis.vertical, + children: [ + ResponsiveRowColumnItem( + rowFit: FlexFit.tight, + child: CardWithHeader( + title: t.financial_health.display, + body: StreamBuilder( + stream: _accountsStream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const LinearProgressIndicator(); + } - final accounts = snapshot.data!; + final accounts = snapshot.data!; - return Padding( - padding: const EdgeInsets.all(16), - child: StreamBuilder( - initialData: 0.0, - stream: FinanceHealthService() - .getHealthyValue( - accounts: accounts, - startDate: - dateRangeService.startDate, - endDate: dateRangeService.endDate, - ), - builder: (context, snapshot) { - Color getHealthyValueColor( - double healthyValue) => - HSLColor.fromAHSL(1, - healthyValue, 1, 0.35) - .toColor(); + return Padding( + padding: const EdgeInsets.all(16), + child: StreamBuilder( + initialData: 0.0, + stream: FinanceHealthService() + .getHealthyValue( + accounts: accounts, + startDate: dateRangeService.startDate, + endDate: dateRangeService.endDate, + ), + builder: (context, snapshot) { + Color getHealthyValueColor( + double healthyValue) => + HSLColor.fromAHSL( + 1, healthyValue, 1, 0.35) + .toColor(); - return ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: 180), - child: Row( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - AnimatedProgressBar( - value: snapshot.data! / 100, - direction: Axis.vertical, - width: 16, - color: getHealthyValueColor( - snapshot.data!), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment - .start, - mainAxisAlignment: - MainAxisAlignment - .spaceEvenly, - children: [ - Column( - crossAxisAlignment: - CrossAxisAlignment - .start, - children: [ - Row( - crossAxisAlignment: - CrossAxisAlignment - .baseline, - textBaseline: - TextBaseline - .alphabetic, - children: [ - Text( + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + BreakPoint.of(context) + .isLargerThan( + BreakpointID.md) + ? 265 + : 180), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + AnimatedProgressBar( + value: snapshot.data! / 100, + direction: Axis.vertical, + width: 16, + color: getHealthyValueColor( + snapshot.data!), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + mainAxisAlignment: + MainAxisAlignment + .spaceEvenly, + children: [ + Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment + .baseline, + textBaseline: + TextBaseline + .alphabetic, + children: [ + Text( + snapshot.data! + .toStringAsFixed( + 0), + style: Theme.of( + context) + .textTheme + .headlineMedium! + .copyWith( + color: getHealthyValueColor( + snapshot.data!), + fontWeight: + FontWeight.w700, + ), + ), + const Text( + ' / 100') + ]), + Text( + FinanceHealthService() + .getHealthyValueReviewTitle( + context, snapshot - .data! - .toStringAsFixed( - 0), - style: Theme.of( - context) - .textTheme - .headlineMedium! - .copyWith( - color: - getHealthyValueColor(snapshot.data!), - fontWeight: - FontWeight.w700, - ), - ), - const Text( - ' / 100') - ]), - Text( - FinanceHealthService() - .getHealthyValueReviewTitle( - context, + .data!), + style: Theme.of( + context) + .textTheme + .titleMedium! + .copyWith( + color: getHealthyValueColor( snapshot .data!), - style: Theme.of( - context) - .textTheme - .titleMedium! - .copyWith( - color: getHealthyValueColor( - snapshot - .data!), - fontWeight: - FontWeight - .w700, - ), - ), - ], - ), - Text( - FinanceHealthService() - .getHealthyValueReviewDescr( - context, - snapshot - .data!), - ), - ], - ), - ) - ], - ), - ); - })); - }), - ), - ), - SizedBox( - width: constraints.maxWidth > - BreakPoint.getById(BreakpointID.md).width - ? constraints.maxWidth / 2 - 16 - : double.infinity, - child: CardWithHeader( - title: t.stats.balance_evolution, - body: FundEvolutionLineChart( - startDate: dateRangeService.startDate, - endDate: dateRangeService.endDate, - dateRange: dateRangeService.selectedDateRange, - ), - onHeaderButtonClick: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const StatsPage( - initialIndex: 1, - ))); - }), - ) - ], - ); - }), - const SizedBox(height: 16), - LayoutBuilder(builder: (context, constraints) { - return Wrap( - runSpacing: 16, - spacing: 16, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - SizedBox( - width: constraints.maxWidth > - BreakPoint.getById(BreakpointID.md).width - ? constraints.maxWidth / 2 - 16 - : double.infinity, - child: CardWithHeader( - title: t.stats.by_categories, - body: ChartByCategories( - startDate: dateRangeService.startDate, - endDate: dateRangeService.endDate, - ), - onHeaderButtonClick: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const StatsPage( - initialIndex: 0, - ))); + fontWeight: + FontWeight + .w700, + ), + ), + ], + ), + Text( + FinanceHealthService() + .getHealthyValueReviewDescr( + context, + snapshot.data!), + ), + ], + ), + ) + ], + ), + ); + })); }), ), - SizedBox( - width: constraints.maxWidth > - BreakPoint.getById(BreakpointID.md).width - ? constraints.maxWidth / 2 - 16 - : double.infinity, - child: CardWithHeader( - title: t.stats.cash_flow, - body: Padding( - padding: const EdgeInsets.only( - top: 16, left: 16, right: 16), - child: BalanceChartSmall( - dateRangeService: dateRangeService), - ), - onHeaderButtonClick: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const StatsPage( - initialIndex: 2, - ))); - }), - ) - ], - ); - }), + ), + ResponsiveRowColumnItem( + rowFit: FlexFit.tight, + child: CardWithHeader( + title: t.stats.balance_evolution, + body: FundEvolutionLineChart( + startDate: dateRangeService.startDate, + endDate: dateRangeService.endDate, + dateRange: dateRangeService.selectedDateRange, + ), + onHeaderButtonClick: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const StatsPage( + initialIndex: 1, + ))); + }), + ), + ], + ), + const SizedBox(height: 16), + ResponsiveRowColumn.withSymetricSpacing( + spacing: 16, + direction: + BreakPoint.of(context).isLargerThan(BreakpointID.md) + ? Axis.horizontal + : Axis.vertical, + children: [ + ResponsiveRowColumnItem( + rowFit: FlexFit.tight, + child: CardWithHeader( + title: t.stats.by_categories, + body: ChartByCategories( + startDate: dateRangeService.startDate, + endDate: dateRangeService.endDate, + ), + onHeaderButtonClick: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const StatsPage( + initialIndex: 0, + ))); + }), + ), + ResponsiveRowColumnItem( + rowFit: FlexFit.tight, + child: CardWithHeader( + title: t.stats.cash_flow, + body: Padding( + padding: const EdgeInsets.only( + top: 16, left: 16, right: 16), + child: BalanceChartSmall( + dateRangeService: dateRangeService), + ), + onHeaderButtonClick: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const StatsPage( + initialIndex: 2, + ))); + }), + ), + ], + ), const SizedBox(height: 64), ], ), diff --git a/lib/app/home/widgets/drawer.dart b/lib/app/home/widgets/home_drawer.dart similarity index 54% rename from lib/app/home/widgets/drawer.dart rename to lib/app/home/widgets/home_drawer.dart index a414365c..738be296 100644 --- a/lib/app/home/widgets/drawer.dart +++ b/lib/app/home/widgets/home_drawer.dart @@ -64,50 +64,57 @@ class HomeDrawer extends StatelessWidget { ), ]; - return Drawer( - child: ListView(padding: EdgeInsets.zero, children: [ - StreamBuilder( - stream: UserSettingService.instance.getSettings( - (p0) => - p0.settingKey.equalsValue(SettingKey.userName) | - p0.settingKey.equalsValue(SettingKey.avatar), - ), - builder: (context, snapshot) { - final userName = snapshot.data - ?.firstWhere( - (element) => element.settingKey == SettingKey.userName, - ) - .settingValue; - final userAvatar = snapshot.data - ?.firstWhere( - (element) => element.settingKey == SettingKey.avatar, - ) - .settingValue; + return ListView(padding: EdgeInsets.zero, children: [ + StreamBuilder( + stream: UserSettingService.instance.getSettings( + (p0) => + p0.settingKey.equalsValue(SettingKey.userName) | + p0.settingKey.equalsValue(SettingKey.avatar), + ), + builder: (context, snapshot) { + final userName = snapshot.data + ?.firstWhere( + (element) => element.settingKey == SettingKey.userName, + ) + .settingValue; + final userAvatar = snapshot.data + ?.firstWhere( + (element) => element.settingKey == SettingKey.avatar, + ) + .settingValue; - return UserAccountsDrawerHeader( - accountName: userName != null - ? Text(userName) - : const Skeleton(width: 25, height: 12), - currentAccountPicture: UserAvatar(avatar: userAvatar), - currentAccountPictureSize: const Size.fromRadius(24), - accountEmail: Text( - t.home.hello_day, - style: const TextStyle( - fontWeight: FontWeight.w300, - fontSize: 12, - ), + return UserAccountsDrawerHeader( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + ), + accountName: userName != null + ? Text( + userName, + style: TextStyle( + color: Theme.of(context).colorScheme.onBackground, + ), + ) + : const Skeleton(width: 25, height: 12), + currentAccountPicture: UserAvatar(avatar: userAvatar), + currentAccountPictureSize: const Size.fromRadius(24), + accountEmail: Text( + t.home.hello_day, + style: TextStyle( + fontWeight: FontWeight.w300, + fontSize: 12, + color: Theme.of(context).colorScheme.onBackground, ), - ); - }), - ...List.generate(drawerActions.length, (index) { - final item = drawerActions[index]; - return ListTile( - title: Text(item.label), - leading: Icon(item.icon), - onTap: item.onClick, - ); - }), - ]), - ); + ), + ); + }), + ...List.generate(drawerActions.length, (index) { + final item = drawerActions[index]; + return ListTile( + title: Text(item.label), + leading: Icon(item.icon), + onTap: item.onClick, + ); + }), + ]); } } diff --git a/lib/app/stats/widgets/balance_bar_chart_small.dart b/lib/app/stats/widgets/balance_bar_chart_small.dart index 8fa33280..d3e3c700 100644 --- a/lib/app/stats/widgets/balance_bar_chart_small.dart +++ b/lib/app/stats/widgets/balance_bar_chart_small.dart @@ -2,6 +2,7 @@ import 'package:async/async.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:monekin/core/database/services/account/account_service.dart'; +import 'package:monekin/core/presentation/responsive/breakpoints.dart'; import 'package:monekin/core/services/filters/date_range_service.dart'; import 'package:monekin/i18n/translations.g.dart'; @@ -104,7 +105,7 @@ class _BalanceChartSmallState extends State { final t = Translations.of(context); return SizedBox( - height: 250, + height: BreakPoint.of(context).isLargerThan(BreakpointID.md) ? 325 : 250, child: StreamBuilder( stream: AccountService.instance.getAccounts(), builder: (context, accountsSnapshot) { diff --git a/lib/core/presentation/responsive/app_breakpoints.dart b/lib/core/presentation/responsive/app_breakpoints.dart index b0808bb0..c621646a 100644 --- a/lib/core/presentation/responsive/app_breakpoints.dart +++ b/lib/core/presentation/responsive/app_breakpoints.dart @@ -1,6 +1,6 @@ import 'package:monekin/core/presentation/responsive/breakpoints.dart'; -Set appBreakPoints = { +final Set appBreakPoints = { const BreakPoint(BreakpointID.xs, width: double.infinity), const BreakPoint(BreakpointID.sm, width: 540), const BreakPoint(BreakpointID.md, width: 720), diff --git a/lib/core/presentation/responsive/breakpoints.dart b/lib/core/presentation/responsive/breakpoints.dart index 827b9051..dc297e62 100644 --- a/lib/core/presentation/responsive/breakpoints.dart +++ b/lib/core/presentation/responsive/breakpoints.dart @@ -30,6 +30,26 @@ class BreakPoint extends Equatable { return appBreakPoints.firstWhere((element) => element.id == id); } + bool isSmallerThan(BreakpointID id) { + return this < BreakPoint.getById(id); + } + + bool isSmallerOrEqualTo(BreakpointID id) { + return this <= BreakPoint.getById(id); + } + + bool isLargerThan(BreakpointID id) { + return this > BreakPoint.getById(id); + } + + bool isLargerOrEqualTo(BreakpointID id) { + return this >= BreakPoint.getById(id); + } + + bool isBetween(BreakpointID id1, BreakpointID id2) { + return this >= BreakPoint.getById(id1) && this <= BreakPoint.getById(id2); + } + @override List get props => [id.index]; diff --git a/lib/core/presentation/responsive/responsive_row_column.dart b/lib/core/presentation/responsive/responsive_row_column.dart new file mode 100644 index 00000000..196d45a9 --- /dev/null +++ b/lib/core/presentation/responsive/responsive_row_column.dart @@ -0,0 +1,245 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; + +/// A convenience wrapper for responsive [Row] and +/// [Column] switching with padding and spacing. +/// +/// ResponsiveRowColumn combines responsiveness +/// behaviors for managing rows and columns into one +/// convenience widget. This widget requires all [children] +/// to be [ResponsiveRowColumnItem] widgets. +/// +/// Row vs column layout is controlled by passing a [Axis] to [direction]. +/// +/// Add spacing between widgets with [rowSpacing] and +/// [columnSpacing]. Add padding around widgets with +/// [rowPadding] and [columnPadding]. +/// +/// See [ResponsiveRowColumnItem] for [Flex] and +/// [FlexFit] options. +class ResponsiveRowColumn extends StatelessWidget { + final List children; + + /// Horizontal to row layout and vertical to column layout + final Axis direction; + + final MainAxisAlignment rowMainAxisAlignment; + final MainAxisSize rowMainAxisSize; + final CrossAxisAlignment rowCrossAxisAlignment; + final TextDirection? rowTextDirection; + final VerticalDirection rowVerticalDirection; + final TextBaseline? rowTextBaseline; + final MainAxisAlignment columnMainAxisAlignment; + final MainAxisSize columnMainAxisSize; + final CrossAxisAlignment columnCrossAxisAlignment; + final TextDirection? columnTextDirection; + final VerticalDirection columnVerticalDirection; + final TextBaseline? columnTextBaseline; + + /// The space between the rows of this widget + final double? rowSpacing; + + /// The space between the columns of this widget + final double? columnSpacing; + + /// Outside padding of all the elements in the row + final EdgeInsets rowPadding; + + /// Outside padding of all the elements in the column + final EdgeInsets columnPadding; + + get isRow => direction == Axis.horizontal; + get isColumn => direction == Axis.vertical; + + /// The [ResponsiveRowColumn] constructor + const ResponsiveRowColumn( + {Key? key, + this.children = const [], + required this.direction, + this.rowMainAxisAlignment = MainAxisAlignment.start, + this.rowMainAxisSize = MainAxisSize.max, + this.rowCrossAxisAlignment = CrossAxisAlignment.center, + this.rowTextDirection, + this.rowVerticalDirection = VerticalDirection.down, + this.rowTextBaseline, + this.columnMainAxisAlignment = MainAxisAlignment.start, + this.columnMainAxisSize = MainAxisSize.max, + this.columnCrossAxisAlignment = CrossAxisAlignment.center, + this.columnTextDirection, + this.columnVerticalDirection = VerticalDirection.down, + this.columnTextBaseline, + this.rowSpacing, + this.columnSpacing, + this.rowPadding = EdgeInsets.zero, + this.columnPadding = EdgeInsets.zero}) + : super(key: key); + + /// Create a [ResponsiveRowColumn] with the same padding and space between children for row and column mode + const ResponsiveRowColumn.withSymetricSpacing( + {Key? key, + this.children = const [], + required this.direction, + this.rowMainAxisAlignment = MainAxisAlignment.start, + this.rowMainAxisSize = MainAxisSize.max, + this.rowCrossAxisAlignment = CrossAxisAlignment.center, + this.rowTextDirection, + this.rowVerticalDirection = VerticalDirection.down, + this.rowTextBaseline, + this.columnMainAxisAlignment = MainAxisAlignment.start, + this.columnMainAxisSize = MainAxisSize.max, + this.columnCrossAxisAlignment = CrossAxisAlignment.center, + this.columnTextDirection, + this.columnVerticalDirection = VerticalDirection.down, + this.columnTextBaseline, + double? spacing, + EdgeInsets padding = EdgeInsets.zero}) + : rowPadding = padding, + columnPadding = padding, + rowSpacing = spacing, + columnSpacing = spacing, + super(key: key); + + @override + Widget build(BuildContext context) { + if (isRow) { + return Padding( + padding: rowPadding, + child: Row( + mainAxisAlignment: rowMainAxisAlignment, + mainAxisSize: rowMainAxisSize, + crossAxisAlignment: rowCrossAxisAlignment, + textDirection: rowTextDirection, + verticalDirection: rowVerticalDirection, + textBaseline: rowTextBaseline, + children: [ + ...buildChildren(children, true, rowSpacing), + ], + ), + ); + } + + return Padding( + padding: columnPadding, + child: Column( + mainAxisAlignment: columnMainAxisAlignment, + mainAxisSize: columnMainAxisSize, + crossAxisAlignment: columnCrossAxisAlignment, + textDirection: columnTextDirection, + verticalDirection: columnVerticalDirection, + textBaseline: columnTextBaseline, + children: [ + ...buildChildren(children, false, columnSpacing), + ], + ), + ); + } + + /// Logic to construct widget [children]. + List buildChildren( + List children, bool rowColumn, double? spacing) { + // Sort ResponsiveRowColumnItems by their order. + List childrenHolder = []; + childrenHolder.addAll(children); + childrenHolder.sort((a, b) { + if (rowColumn) { + return a.rowOrder.compareTo(b.rowOrder); + } else { + return a.columnOrder.compareTo(b.columnOrder); + } + }); + + // Add padding between widgets.. + List widgetList = []; + for (int i = 0; i < childrenHolder.length; i++) { + widgetList.add(childrenHolder[i].copyWith(rowColumn: rowColumn)); + if (spacing != null && i != childrenHolder.length - 1) { + widgetList.add(Padding( + padding: rowColumn + ? EdgeInsets.only(right: spacing) + : EdgeInsets.only(bottom: spacing))); + } + } + return widgetList; + } +} + +/// A wrapper for [ResponsiveRowColumn] children with +/// responsiveness. +/// +/// Control the order of widgets within [ResponsiveRowColumn] +/// by assigning a [rowOrder] or [columnOrder] value. +/// Widgets without an order value are ranked behind +/// those with order values. +/// Set a widget's [Flex] value through [rowFlex] and +/// [columnFlex]. Set a widget's [FlexFit] through +/// [rowFit] and [columnFit]. +class ResponsiveRowColumnItem extends StatelessWidget { + final Widget child; + final int rowOrder; + final int columnOrder; + final bool rowColumn; + final int? rowFlex; + final int? columnFlex; + final FlexFit? rowFit; + final FlexFit? columnFit; + + const ResponsiveRowColumnItem( + {Key? key, + required this.child, + this.rowOrder = 1073741823, + this.columnOrder = 1073741823, + this.rowColumn = true, + this.rowFlex, + this.columnFlex, + this.rowFit, + this.columnFit}) + : super(key: key); + + /// Build a `SizedBox` inside the responsive row/column layout. The [space] define the height of the `SizedBox` in Column mode and the width in row mode + ResponsiveRowColumnItem.spacer(double space, + {Key? key, + this.rowOrder = 1073741823, + this.columnOrder = 1073741823, + this.rowColumn = true, + this.rowFlex, + this.columnFlex, + this.rowFit, + this.columnFit}) + : child = SizedBox( + height: rowColumn ? space : null, width: !rowColumn ? space : null), + super(key: key); + + @override + Widget build(BuildContext context) { + if (rowColumn && (rowFlex != null || rowFit != null)) { + return Flexible( + flex: rowFlex ?? 1, fit: rowFit ?? FlexFit.loose, child: child); + } else if (!rowColumn && (columnFlex != null || columnFit != null)) { + return Flexible( + flex: columnFlex ?? 1, fit: columnFit ?? FlexFit.loose, child: child); + } + + return child; + } + + ResponsiveRowColumnItem copyWith({ + int? rowOrder, + int? columnOrder, + bool? rowColumn, + int? rowFlex, + int? columnFlex, + FlexFit? rowFlexFit, + FlexFit? columnFlexFit, + Widget? child, + }) => + ResponsiveRowColumnItem( + rowOrder: rowOrder ?? this.rowOrder, + columnOrder: columnOrder ?? this.columnOrder, + rowColumn: rowColumn ?? this.rowColumn, + rowFlex: rowFlex ?? this.rowFlex, + columnFlex: columnFlex ?? this.columnFlex, + rowFit: rowFlexFit ?? rowFit, + columnFit: columnFlexFit ?? columnFit, + child: child ?? this.child, + ); +} diff --git a/lib/core/presentation/widgets/user_avatar.dart b/lib/core/presentation/widgets/user_avatar.dart index b85e50e2..00c02583 100644 --- a/lib/core/presentation/widgets/user_avatar.dart +++ b/lib/core/presentation/widgets/user_avatar.dart @@ -1,6 +1,6 @@ -import 'package:monekin/core/presentation/widgets/skeleton.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:monekin/core/presentation/widgets/skeleton.dart'; class UserAvatar extends StatelessWidget { const UserAvatar({super.key, this.avatar, this.size = 36, this.border}); diff --git a/lib/main.dart b/lib/main.dart index 3d432ac7..93153ebe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -86,7 +86,7 @@ class MonekinAppEntryPoint extends StatelessWidget { } } -class MaterialAppContainer extends ConsumerWidget { +class MaterialAppContainer extends StatelessWidget { const MaterialAppContainer( {super.key, required this.themeMode, required this.goToIntro}); @@ -94,7 +94,7 @@ class MaterialAppContainer extends ConsumerWidget { final bool goToIntro; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { // Get the language of the Intl in each rebuild of the TranslationProvider: Intl.defaultLocale = LocaleSettings.currentLocale.languageTag; diff --git a/test/widget_test.dart b/test/widget_test.dart index 844bd920..6206726f 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,12 +5,25 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:monekin/main.dart'; void main() { - testWidgets('App opens', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MonekinAppEntryPoint()); + testWidgets('MonekinAppEntryPoint builds correctly', + (WidgetTester tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(const MonekinAppEntryPoint()); + + expect(find.byType(MonekinAppEntryPoint), findsOneWidget); + }); + }); + + testWidgets('MaterialAppContainer builds correctly', + (WidgetTester tester) async { + await tester.pumpWidget(const MaterialAppContainer( + themeMode: ThemeMode.light, goToIntro: false)); + + expect(find.byType(MaterialAppContainer), findsOneWidget); }); }