diff --git a/tracking_flutter/lib/calendar/view/widgets/calendar.dart b/tracking_flutter/lib/calendar/view/widgets/calendar.dart index 3c753d6..ae88b40 100644 --- a/tracking_flutter/lib/calendar/view/widgets/calendar.dart +++ b/tracking_flutter/lib/calendar/view/widgets/calendar.dart @@ -26,7 +26,7 @@ class _CalendarTheme { color: AppColors.darkBlue, ); - static const calendarPrevAndNextButtonsColor = AppColors.grey; + static const calendarPrevAndNextButtonsColor = AppColors.primarySwatch; static const todayButtonColor = AppColors.primarySwatch; @@ -251,50 +251,29 @@ class _CalendarHeader extends StatelessWidget { style: Theme.of(context).textTheme.headlineSmall, ), const Spacer(), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { - await onButtonPressed( - date: targetMonthDate.previousMonth, - context: context, - ); - }, - borderRadius: BorderRadius.circular(20), - child: Container( - alignment: Alignment.center, - padding: const EdgeInsets.only( - left: 7, - right: 8, - top: 8, - bottom: 8, - ), - child: Icon( - Iconsax.arrow_left_2_outline, - color: _CalendarTheme.calendarPrevAndNextButtonsColor, - size: Theme.of(context).appBarTheme.iconTheme!.size, - ), - ), + IconButton( + color: _CalendarTheme.calendarPrevAndNextButtonsColor, + icon: Icon( + Iconsax.arrow_left_2_outline, + size: Theme.of(context).appBarTheme.iconTheme!.size, ), - ), - const HorizontalSpacing.extraLarge(), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => onButtonPressed( - date: targetMonthDate.nextMonth, + onPressed: () async { + await onButtonPressed( + date: targetMonthDate.previousMonth, context: context, - ), - borderRadius: BorderRadius.circular(20), - child: Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(8), - child: Icon( - Iconsax.arrow_right_3_outline, - color: _CalendarTheme.calendarPrevAndNextButtonsColor, - size: Theme.of(context).appBarTheme.iconTheme!.size, - ), - ), + ); + }, + ), + const HorizontalSpacing.large(), + IconButton( + color: _CalendarTheme.calendarPrevAndNextButtonsColor, + icon: Icon( + Iconsax.arrow_right_3_outline, + size: Theme.of(context).appBarTheme.iconTheme!.size, + ), + onPressed: () => onButtonPressed( + date: targetMonthDate.nextMonth, + context: context, ), ), ], diff --git a/tracking_flutter/lib/graph/bloc/graph_bloc.dart b/tracking_flutter/lib/graph/bloc/graph_bloc.dart index 25dee41..120c9b4 100644 --- a/tracking_flutter/lib/graph/bloc/graph_bloc.dart +++ b/tracking_flutter/lib/graph/bloc/graph_bloc.dart @@ -120,7 +120,6 @@ class GraphBloc extends Bloc { settings: state.settings.copyWith( timeRangeMode: mode, ), - targetDate: const GraphTargetDateState.initial(), ), ); await _loadMoods(emit); diff --git a/tracking_flutter/lib/graph/view/graph_screen.dart b/tracking_flutter/lib/graph/view/graph_screen.dart index c2adb04..d37c8cc 100644 --- a/tracking_flutter/lib/graph/view/graph_screen.dart +++ b/tracking_flutter/lib/graph/view/graph_screen.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -133,7 +135,7 @@ class _GraphView extends StatelessWidget { const VerticalSpacing.large(), const Padding( padding: EdgeInsets.symmetric( - horizontal: horizontalPaddingSmall, + horizontal: viewPaddingHorizontal + horizontalPaddingLarge, ), child: _GraphHeader(), ), @@ -150,6 +152,8 @@ class _GraphView extends StatelessWidget { return _MonthsSelection( currentMonth: dateTimeNow.month, selectedMonth: setTargetDate.month, + disableMonths: setTargetDate.year == dateTimeNow.year, + currentYear: setTargetDate.year, onMonthSelected: (month) { context.read().add( GraphEvent.targetDateChanged( @@ -163,6 +167,8 @@ class _GraphView extends StatelessWidget { numberOfWeeks: setTargetDate.numberOfWeeksInYear, currentWeek: dateTimeNow.weekNumber, selectedWeek: setTargetDate.weekNumber, + disableWeeks: setTargetDate.year == dateTimeNow.year, + currentYear: setTargetDate.year, onWeekSelected: (week) { context.read().add( GraphEvent.targetDateChanged( @@ -175,7 +181,7 @@ class _GraphView extends StatelessWidget { } }, ), - const VerticalSpacing.extraLarge(), + const VerticalSpacing.extraExtraLarge(), Container( padding: const EdgeInsets.symmetric(horizontal: viewPaddingHorizontal), diff --git a/tracking_flutter/lib/graph/view/widgets/graph_header.dart b/tracking_flutter/lib/graph/view/widgets/graph_header.dart index d68718e..7d32372 100644 --- a/tracking_flutter/lib/graph/view/widgets/graph_header.dart +++ b/tracking_flutter/lib/graph/view/widgets/graph_header.dart @@ -31,57 +31,86 @@ class _GraphHeader extends StatelessWidget { Widget build(BuildContext context) { final translations = AppLocalizations.of(context)!; - return SizedBox( - height: 30, - child: Stack( - children: [ - Positioned.fill( - child: Center( - child: Text( - DateTime.now().year.toString(), - style: Theme.of(context).textTheme.headlineSmall, - ), + return BlocBuilder( + buildWhen: (previous, current) => + previous.targetDate.date.year != current.targetDate.date.year, + builder: (context, state) { + final nextButtonDisabled = + state.targetDate.date.year == DateTime.now().year; + + return Row( + children: [ + Text( + state.targetDate.date.year.toString(), + style: Theme.of(context).textTheme.headlineSmall, ), - ), - Positioned.fill( - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: horizontalPaddingMedium, - ), - child: IconButton( - color: AppColors.primarySwatch, - icon: Icon( - Iconsax.setting_4_outline, - size: Theme.of(context).appBarTheme.iconTheme!.size, + const Spacer(), + IconButton( + color: AppColors.primarySwatch, + icon: Icon( + Iconsax.setting_4_outline, + size: Theme.of(context).appBarTheme.iconTheme!.size, + ), + onPressed: () { + RevenueCatUIHelper.showPaywallIfNecessary( + requiresSubscriptionCallback: () async { + await showSettingsDialog(context); + }, + onPurchased: () => showToast( + context: context, + message: translations.subscriptionPurchaseSuccessful, ), - onPressed: () { - RevenueCatUIHelper.showPaywallIfNecessary( - requiresSubscriptionCallback: () async { - await showSettingsDialog(context); - }, - onPurchased: () => showToast( - context: context, - message: translations.subscriptionPurchaseSuccessful, - ), - onRestored: () => showToast( - context: context, - message: translations.subscriptionPurchaseRestored, - ), - onError: () => showToast( - context: context, - message: translations.subscriptionPurchaseFailed, - isError: true, + onRestored: () => showToast( + context: context, + message: translations.subscriptionPurchaseRestored, + ), + onError: () => showToast( + context: context, + message: translations.subscriptionPurchaseFailed, + isError: true, + ), + ); + }, + ), + const HorizontalSpacing.large(), + IconButton( + color: AppColors.primarySwatch, + icon: Icon( + Iconsax.arrow_left_2_outline, + size: Theme.of(context).appBarTheme.iconTheme!.size, + ), + onPressed: () { + context.read().add( + GraphEvent.targetDateChanged( + date: state.targetDate.date.previousYear.add( + const Duration(days: 3), + ), ), ); - }, - ), + }, + ), + const HorizontalSpacing.large(), + IconButton( + color: AppColors.primarySwatch, + icon: Icon( + Iconsax.arrow_right_3_outline, + size: Theme.of(context).appBarTheme.iconTheme!.size, ), + onPressed: nextButtonDisabled + ? null + : () { + context.read().add( + GraphEvent.targetDateChanged( + date: state.targetDate.date.nextYear.add( + const Duration(days: 3), + ), + ), + ); + }, ), - ), - ], - ), + ], + ); + }, ); } } diff --git a/tracking_flutter/lib/graph/view/widgets/months_selection.dart b/tracking_flutter/lib/graph/view/widgets/months_selection.dart index 56c2026..7a35643 100644 --- a/tracking_flutter/lib/graph/view/widgets/months_selection.dart +++ b/tracking_flutter/lib/graph/view/widgets/months_selection.dart @@ -1,31 +1,83 @@ part of '../graph_screen.dart'; -class _MonthsSelection extends StatelessWidget { +class _MonthsSelection extends StatefulWidget { const _MonthsSelection({ required this.currentMonth, required this.selectedMonth, + required this.disableMonths, required this.onMonthSelected, + required this.currentYear, }); final int currentMonth; final int selectedMonth; final void Function(int) onMonthSelected; + final bool disableMonths; + final int currentYear; - static const double monthWidth = 70; + static const double monthWidth = 75; + + static const int firstMonthIndex = 0; + static const int lastMonthIndex = 11; @override - Widget build(BuildContext context) { - final translations = AppLocalizations.of(context)!; + State<_MonthsSelection> createState() => _MonthsSelectionState(); +} - final selectedMonthIndex = selectedMonth - 1; +class _MonthsSelectionState extends State<_MonthsSelection> { + late ScrollController scrollController; + int? previousYear; - // Scroll to the month before the selected month so that - // user is able to select it without scrolling - final scrollController = ScrollController( - initialScrollOffset: - (selectedMonthIndex - 1) * (monthWidth + horizontalPaddingSmall), + @override + void initState() { + super.initState(); + + scrollController = ScrollController( + initialScrollOffset: _getSelectedMonthScrollOffset(), ); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedMonth(); + previousYear = widget.currentYear; + }); + } + + @override + void didUpdateWidget(covariant _MonthsSelection oldWidget) { + super.didUpdateWidget(oldWidget); + + // Scroll to selected month if year has changed + if (widget.currentYear != previousYear) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedMonth(); + }); + previousYear = widget.currentYear; + } + } + + void _scrollToSelectedMonth() { + final scrollOffset = _getSelectedMonthScrollOffset(); + + if (scrollController.hasClients && + scrollOffset != scrollController.offset) { + scrollController.animateTo( + scrollOffset, + duration: animationDuration, + curve: Curves.easeInOut, + ); + } + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final translations = AppLocalizations.of(context)!; + return SizedBox( height: 50, child: ListView.builder( @@ -34,8 +86,9 @@ class _MonthsSelection extends StatelessWidget { itemCount: 12, itemBuilder: (context, index) { final monthIndex = index + 1; - final isCurrentMonth = monthIndex == selectedMonth; - final isDisabled = monthIndex > currentMonth; + final isCurrentMonth = monthIndex == widget.selectedMonth; + final isDisabled = + widget.disableMonths && monthIndex > widget.currentMonth; Color textColor; if (isCurrentMonth) { @@ -60,7 +113,7 @@ class _MonthsSelection extends StatelessWidget { : () { RevenueCatUIHelper.showPaywallIfNecessary( requiresSubscriptionCallback: () => - onMonthSelected(monthIndex), + widget.onMonthSelected(monthIndex), onPurchased: () => showToast( context: context, message: translations.subscriptionPurchaseSuccessful, @@ -77,11 +130,13 @@ class _MonthsSelection extends StatelessWidget { ); }, child: Container( - width: monthWidth, + width: _MonthsSelection.monthWidth, alignment: Alignment.center, margin: EdgeInsets.only( - left: index == 0 ? viewPaddingHorizontal : 0, - right: index == 11 + left: index == _MonthsSelection.firstMonthIndex + ? viewPaddingHorizontal + : 0, + right: index == _MonthsSelection.lastMonthIndex ? viewPaddingHorizontal : horizontalPaddingSmall, top: horizontalPaddingSmall, @@ -95,7 +150,7 @@ class _MonthsSelection extends StatelessWidget { getMonthName(monthIndex), style: Theme.of(context).textTheme.bodySmall!.copyWith( color: textColor, - fontWeight: monthIndex == selectedMonth + fontWeight: monthIndex == widget.selectedMonth ? FontWeight.bold : FontWeight.normal, ), @@ -107,6 +162,17 @@ class _MonthsSelection extends StatelessWidget { ); } + double _getSelectedMonthScrollOffset() { + final selectedMonthIndex = widget.selectedMonth - 1; + return math + .max( + 0, + (selectedMonthIndex - 1) * + (_MonthsSelection.monthWidth + horizontalPaddingSmall), + ) + .toDouble(); + } + String getMonthName(int month) { return Jiffy.parseFromDateTime(DateTime(0, month)).MMM; } diff --git a/tracking_flutter/lib/graph/view/widgets/weeks_selection.dart b/tracking_flutter/lib/graph/view/widgets/weeks_selection.dart index 57364a7..f3fa418 100644 --- a/tracking_flutter/lib/graph/view/widgets/weeks_selection.dart +++ b/tracking_flutter/lib/graph/view/widgets/weeks_selection.dart @@ -1,43 +1,92 @@ part of '../graph_screen.dart'; -class _WeeksSelection extends StatelessWidget { +class _WeeksSelection extends StatefulWidget { const _WeeksSelection({ required this.numberOfWeeks, required this.currentWeek, required this.selectedWeek, required this.onWeekSelected, + required this.disableWeeks, + required this.currentYear, }); final int numberOfWeeks; final int currentWeek; final int selectedWeek; final void Function(int) onWeekSelected; + final bool disableWeeks; + final int currentYear; static const double weekWidth = 75; @override - Widget build(BuildContext context) { - final translations = AppLocalizations.of(context)!; + State<_WeeksSelection> createState() => _WeeksSelectionState(); +} - final selectedWeekIndex = selectedWeek - 1; +class _WeeksSelectionState extends State<_WeeksSelection> { + late ScrollController scrollController; + int? previousYear; - // Scroll to the month before the selected week so that - // user is able to select it without scrolling - final scrollController = ScrollController( - initialScrollOffset: - (selectedWeekIndex - 1) * (weekWidth + horizontalPaddingSmall), + @override + void initState() { + super.initState(); + + scrollController = ScrollController( + initialScrollOffset: _getSelectedWeekScrollOffset(), ); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedWeek(); + previousYear = widget.currentYear; + }); + } + + @override + void didUpdateWidget(covariant _WeeksSelection oldWidget) { + super.didUpdateWidget(oldWidget); + + // Scroll to selected week if year has changed + if (widget.currentYear != previousYear) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedWeek(); + }); + previousYear = widget.currentYear; + } + } + + void _scrollToSelectedWeek() { + final scrollOffset = _getSelectedWeekScrollOffset(); + + if (scrollController.hasClients) { + scrollController.animateTo( + scrollOffset, + duration: animationDuration, + curve: Curves.easeInOut, + ); + } + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final translations = AppLocalizations.of(context)!; + return SizedBox( height: 50, child: ListView.builder( controller: scrollController, scrollDirection: Axis.horizontal, - itemCount: numberOfWeeks, + itemCount: widget.numberOfWeeks, itemBuilder: (context, index) { final weekIndex = index + 1; - final isCurrentWeek = weekIndex == selectedWeek; - final isDisabled = weekIndex > currentWeek; + final isCurrentWeek = weekIndex == widget.selectedWeek; + final isDisabled = + widget.disableWeeks && weekIndex > widget.currentWeek; Color textColor; if (isCurrentWeek) { @@ -62,7 +111,7 @@ class _WeeksSelection extends StatelessWidget { : () { RevenueCatUIHelper.showPaywallIfNecessary( requiresSubscriptionCallback: () => - onWeekSelected(weekIndex), + widget.onWeekSelected(weekIndex), onPurchased: () => showToast( context: context, message: translations.subscriptionPurchaseSuccessful, @@ -79,10 +128,10 @@ class _WeeksSelection extends StatelessWidget { ); }, child: Container( - width: weekWidth, + width: _WeeksSelection.weekWidth, margin: EdgeInsets.only( left: weekIndex == 1 ? viewPaddingHorizontal : 0, - right: weekIndex == numberOfWeeks + right: weekIndex == widget.numberOfWeeks ? viewPaddingHorizontal : horizontalPaddingSmall, top: horizontalPaddingSmall, @@ -97,7 +146,7 @@ class _WeeksSelection extends StatelessWidget { translations.calendarWeek(weekIndex), style: Theme.of(context).textTheme.bodySmall!.copyWith( color: textColor, - fontWeight: weekIndex == selectedWeek + fontWeight: weekIndex == widget.selectedWeek ? FontWeight.bold : FontWeight.normal, ), @@ -109,4 +158,15 @@ class _WeeksSelection extends StatelessWidget { ), ); } + + double _getSelectedWeekScrollOffset() { + final selectedWeekIndex = widget.selectedWeek - 1; + return math + .max( + 0, + (selectedWeekIndex - 1) * + (_WeeksSelection.weekWidth + horizontalPaddingSmall), + ) + .toDouble(); + } } diff --git a/tracking_flutter/lib/home/view/widgets/home_moods_header.dart b/tracking_flutter/lib/home/view/widgets/home_moods_header.dart index 89109d1..f4cd9fe 100644 --- a/tracking_flutter/lib/home/view/widgets/home_moods_header.dart +++ b/tracking_flutter/lib/home/view/widgets/home_moods_header.dart @@ -17,7 +17,7 @@ class _HomeMoodsHeader extends StatelessWidget { stops: const [0.9, 1.0], colors: [ AppColors.backgroundColor, - Colors.white.withOpacity(0) + Colors.white.withOpacity(0), ], ), ), diff --git a/tracking_flutter/lib/shared/extensions/date_time.dart b/tracking_flutter/lib/shared/extensions/date_time.dart index 2e18cc4..54c01d4 100644 --- a/tracking_flutter/lib/shared/extensions/date_time.dart +++ b/tracking_flutter/lib/shared/extensions/date_time.dart @@ -4,22 +4,26 @@ import 'package:jiffy/jiffy.dart'; extension DateTimeX on DateTime { DateTime fromWeekNumber(int weekNumber) { - final firstDayOfYear = DateTime(year); - - final daysSinceFirstDayOfYear = difference(firstDayOfYear).inDays; - - final firstDayOfGivenWeek = - firstDayOfYear.add(Duration(days: (weekNumber - 1) * 7)); + // Find the first Thursday of the year. + var firstThursday = DateTime(year); + while (firstThursday.weekday != DateTime.thursday) { + firstThursday = firstThursday.add(const Duration(days: 1)); + } - return firstDayOfGivenWeek.subtract( - Duration(days: daysSinceFirstDayOfYear), - ); + // Calculate the date of the week. + // Subtraction of 3 days at the end moves the date + // to the Monday of the desired week. + return firstThursday.add(Duration(days: (weekNumber - 1) * 7 - 3)); } DateTime get previousMonth => startOfMonth.subtract(const Duration(days: 1)); DateTime get nextMonth => endOfMonth.add(const Duration(days: 1)); + DateTime get previousYear => DateTime(year - 1); + + DateTime get nextYear => DateTime(year + 1); + DateTime get startOfMonth => DateTime(year, month); DateTime get endOfMonth => DateTime(year, month + 1, 0, 23, 59, 59, 999, 999); diff --git a/tracking_flutter/lib/shared/router/router.dart b/tracking_flutter/lib/shared/router/router.dart index a7e048e..ea8136f 100644 --- a/tracking_flutter/lib/shared/router/router.dart +++ b/tracking_flutter/lib/shared/router/router.dart @@ -1,4 +1,3 @@ -import 'package:amplify_authenticator/amplify_authenticator.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; diff --git a/tracking_flutter/test/shared/extensions/date_time_test.dart b/tracking_flutter/test/shared/extensions/date_time_test.dart index 2f2f9e6..a0d7641 100644 --- a/tracking_flutter/test/shared/extensions/date_time_test.dart +++ b/tracking_flutter/test/shared/extensions/date_time_test.dart @@ -7,8 +7,8 @@ void main() { group('DateTimeX', () { test('fromWeekNumber returns correct date', () { final date = DateTime(2023); - expect(date.fromWeekNumber(1), DateTime(2023)); - expect(date.fromWeekNumber(2), DateTime(2023, 1, 8)); + expect(date.fromWeekNumber(1), DateTime(2023, 1, 2)); + expect(date.fromWeekNumber(2), DateTime(2023, 1, 9)); }); test('startOfMonth returns correct date', () {