From bb01592f54d7b7af8ffa6f1f63221ba4ef8b68c9 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Fri, 5 Jul 2024 16:09:16 +0200 Subject: [PATCH] feat: add calendar view without interaction --- .../lib/src/config/availability_options.dart | 44 ++++ .../src/config/availability_translations.dart | 53 ++++ .../src/ui/screens/availability_overview.dart | 24 +- .../lib/src/ui/widgets/calendar.dart | 107 ++++++++ .../lib/src/ui/widgets/calendar_grid.dart | 229 ++++++++++++++++++ 5 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 packages/flutter_availability/lib/src/ui/widgets/calendar.dart create mode 100644 packages/flutter_availability/lib/src/ui/widgets/calendar_grid.dart diff --git a/packages/flutter_availability/lib/src/config/availability_options.dart b/packages/flutter_availability/lib/src/config/availability_options.dart index cd5224d..f2a674d 100644 --- a/packages/flutter_availability/lib/src/config/availability_options.dart +++ b/packages/flutter_availability/lib/src/config/availability_options.dart @@ -16,6 +16,7 @@ class AvailabilityOptions { this.primaryButtonBuilder = DefaultPrimaryButton.builder, this.textButtonBuilder = DefaultTextButton.builder, this.spacing = const AvailabilitySpacing(), + this.colors = const AvailabilityColors(), AvailabilityDataInterface? dataInterface, }) : dataInterface = dataInterface ?? LocalAvailabilityDataInterface(); @@ -40,6 +41,9 @@ class AvailabilityOptions { /// The spacing between elements final AvailabilitySpacing spacing; + + /// The colors used in the userstory + final AvailabilityColors colors; } /// All configurable paddings and whitespaces withing the userstory @@ -57,6 +61,46 @@ class AvailabilitySpacing { final double sidePadding; } +/// Contains all the customizable colors for the availability userstory +/// +/// If colors are not provided the colors will be taken from the theme +class AvailabilityColors { + /// Constructor for the AvailabilityColors + /// + /// If a color is not provided the color will be taken from the theme + const AvailabilityColors({ + this.selectedDayColor, + this.blankDayColor, + this.outsideMonthTextColor, + this.textDarkColor, + this.textLightColor, + this.templateColors, + }); + + /// The color of the text for the days that are not in the current month + final Color? outsideMonthTextColor; + + /// The background color of the days that are selected in the calendar + final Color? selectedDayColor; + + /// The background color of the days in the current month + /// that have no availability and are not selected + final Color? blankDayColor; + + /// The color of the text in the calendar when the background has a dark color + /// This is used to make sure the text is readable on dark backgrounds + /// If not provided the text color will be white + final Color? textLightColor; + + /// The color of the text in the calendar when the background has a light + /// color. This is used to make sure the text is readable on light backgrounds + /// If not provided the text color will be the theme's text color + final Color? textDarkColor; + + /// The colors that are used for the templates + final List? templateColors; +} + /// Builder definition for providing a base screen surrounding each page typedef BaseScreenBuilder = Widget Function( BuildContext context, diff --git a/packages/flutter_availability/lib/src/config/availability_translations.dart b/packages/flutter_availability/lib/src/config/availability_translations.dart index 4c5b123..4dac92b 100644 --- a/packages/flutter_availability/lib/src/config/availability_translations.dart +++ b/packages/flutter_availability/lib/src/config/availability_translations.dart @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: BSD-3-Clause +import "package:flutter/material.dart"; + /// Class that holds all translatable strings for the availability userstory class AvailabilityTranslations { /// AvailabilityTranslations constructor where everything is required. @@ -14,6 +16,8 @@ class AvailabilityTranslations { required this.templateLegendTitle, required this.overviewScreenTitle, required this.createTemplateButton, + required this.monthYearFormatter, + required this.weekDayAbbreviatedFormatter, }); /// AvailabilityTranslations constructor where everything is optional. @@ -24,6 +28,8 @@ class AvailabilityTranslations { this.templateLegendTitle = "Templates", this.createTemplateButton = "Create a new template", this.overviewScreenTitle = "Availability", + this.monthYearFormatter = _defaultMonthYearFormatter, + this.weekDayAbbreviatedFormatter = _defaultWeekDayAbbreviatedFormatter, }); /// The title shown above the calendar @@ -40,4 +46,51 @@ class AvailabilityTranslations { /// The label on the button to go to the tempalte creation page final String createTemplateButton; + + /// Gets the month and year formatted as a string + /// + /// The default implementation is `MonthName Year` in english + final String Function(BuildContext, DateTime) monthYearFormatter; + + /// Gets the abbreviated name of a weekday + /// + /// The default implementation is the first 2 letters of + /// the weekday in english + final String Function(BuildContext, DateTime) weekDayAbbreviatedFormatter; } + +String _defaultWeekDayAbbreviatedFormatter( + BuildContext context, + DateTime date, +) => + _getWeekDayAbbreviation(date.weekday); + +String _defaultMonthYearFormatter(BuildContext context, DateTime date) => + "${_getMonthName(date.month)} ${date.year}"; + +String _getWeekDayAbbreviation(int weekday) => switch (weekday) { + 1 => "Mo", + 2 => "Tu", + 3 => "We", + 4 => "Th", + 5 => "Fr", + 6 => "Sa", + 7 => "Su", + _ => "", + }; + +String _getMonthName(int month) => switch (month) { + 1 => "January", + 2 => "February", + 3 => "March", + 4 => "April", + 5 => "May", + 6 => "June", + 7 => "July", + 8 => "August", + 9 => "September", + 10 => "October", + 11 => "November", + 12 => "December", + _ => "", + }; diff --git a/packages/flutter_availability/lib/src/ui/screens/availability_overview.dart b/packages/flutter_availability/lib/src/ui/screens/availability_overview.dart index d712556..53f9bb5 100644 --- a/packages/flutter_availability/lib/src/ui/screens/availability_overview.dart +++ b/packages/flutter_availability/lib/src/ui/screens/availability_overview.dart @@ -1,8 +1,9 @@ import "package:flutter/material.dart"; +import "package:flutter_availability/src/ui/widgets/calendar.dart"; import "package:flutter_availability/src/util/scope.dart"; /// -class AvailabilityOverview extends StatelessWidget { +class AvailabilityOverview extends StatefulWidget { /// const AvailabilityOverview({ required this.onEditDateRange, @@ -20,6 +21,13 @@ class AvailabilityOverview extends StatelessWidget { /// Callback invoked when a user attempts to go back final VoidCallback onBack; + @override + State createState() => _AvailabilityOverviewState(); +} + +class _AvailabilityOverviewState extends State { + DateTime _selectedDate = DateTime.now(); + @override Widget build(BuildContext context) { var theme = Theme.of(context); @@ -35,9 +43,13 @@ class AvailabilityOverview extends StatelessWidget { ), ); - const calendar = SizedBox( - height: 320, - child: Placeholder(), + var calendar = CalendarView( + month: _selectedDate, + onMonthChanged: (month) { + setState(() { + _selectedDate = month; + }); + }, ); const templateLegend = SizedBox( @@ -48,7 +60,9 @@ class AvailabilityOverview extends StatelessWidget { var startEditButton = options.primaryButtonBuilder( context, () { - onEditDateRange(DateTimeRange(start: DateTime(1), end: DateTime(2))); + widget.onEditDateRange( + DateTimeRange(start: DateTime(1), end: DateTime(2)), + ); }, Text(translations.editAvailabilityButton), ); diff --git a/packages/flutter_availability/lib/src/ui/widgets/calendar.dart b/packages/flutter_availability/lib/src/ui/widgets/calendar.dart new file mode 100644 index 0000000..3a89476 --- /dev/null +++ b/packages/flutter_availability/lib/src/ui/widgets/calendar.dart @@ -0,0 +1,107 @@ +import "package:flutter/material.dart"; +import "package:flutter_availability/src/ui/widgets/calendar_grid.dart"; +import "package:flutter_availability/src/util/scope.dart"; + +/// +class CalendarView extends StatelessWidget { + /// + const CalendarView({ + required this.month, + required this.onMonthChanged, + super.key, + }); + + /// + final DateTime month; + + /// + final void Function(DateTime month) onMonthChanged; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var availabilityScope = AvailabilityScope.of(context); + var options = availabilityScope.options; + var translations = options.translations; + + var monthDateSelector = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () { + onMonthChanged(DateTime(month.year, month.month - 1)); + }, + ), + const SizedBox(width: 44), + Text( + translations.monthYearFormatter(context, month), + style: theme.textTheme.bodyLarge, + ), + const SizedBox(width: 44), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () { + onMonthChanged(DateTime(month.year, month.month + 1)); + }, + ), + ], + ); + + var calendarGrid = CalendarGrid( + month: month, + days: [ + CalendarDay( + date: DateTime(month.year, month.month, 3), + isSelected: false, + color: Colors.red, + templateDeviation: false, + ), + CalendarDay( + date: DateTime(month.year, month.month, 4), + isSelected: false, + color: Colors.red, + templateDeviation: true, + ), + CalendarDay( + date: DateTime(month.year, month.month, 10), + isSelected: false, + color: Colors.blue, + templateDeviation: false, + ), + CalendarDay( + date: DateTime(month.year, month.month, 11), + isSelected: false, + color: Colors.black, + templateDeviation: true, + ), + CalendarDay( + date: DateTime(month.year, month.month, 12), + isSelected: false, + color: Colors.white, + templateDeviation: true, + ), + CalendarDay( + date: DateTime(month.year, month.month, 13), + isSelected: true, + color: Colors.green, + templateDeviation: false, + ), + CalendarDay( + date: DateTime(month.year, month.month, 14), + isSelected: true, + color: Colors.green, + templateDeviation: true, + ), + ], + ); + + return Column( + children: [ + monthDateSelector, + const Divider(), + calendarGrid, + ], + ); + } +} diff --git a/packages/flutter_availability/lib/src/ui/widgets/calendar_grid.dart b/packages/flutter_availability/lib/src/ui/widgets/calendar_grid.dart new file mode 100644 index 0000000..2fd2d4e --- /dev/null +++ b/packages/flutter_availability/lib/src/ui/widgets/calendar_grid.dart @@ -0,0 +1,229 @@ +import "package:flutter/material.dart"; +import "package:flutter_availability/flutter_availability.dart"; +import "package:flutter_availability/src/util/scope.dart"; + +/// +class CalendarGrid extends StatelessWidget { + /// + const CalendarGrid({ + required this.month, + required this.days, + super.key, + }); + + /// The current month to display + final DateTime month; + + /// A list of days that need to be displayed differently + final List days; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var textTheme = theme.textTheme; + var colorScheme = theme.colorScheme; + var availabilityScope = AvailabilityScope.of(context); + var options = availabilityScope.options; + var colors = options.colors; + var translations = options.translations; + var calendarDays = _generateCalendarDays(month, days, colors, colorScheme); + + // get the names of the days of the week + var dayNames = List.generate(7, (index) { + var day = DateTime(2024, 7, 8 + index); // this is a monday + return translations.weekDayAbbreviatedFormatter(context, day); + }); + + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: dayNames + .map( + (day) => Expanded( + child: Center( + child: Text( + day, + style: textTheme.bodyLarge, + ), + ), + ), + ) + .toList(), + ), + const SizedBox(height: 4.0), + GridView.builder( + shrinkWrap: true, + itemCount: calendarDays.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemBuilder: (context, index) { + var day = calendarDays[index]; + var textColor = day.outsideMonth + ? colors.outsideMonthTextColor ?? colorScheme.onSurface + : _getTextColor( + day.color, + colors.textLightColor ?? Colors.white, + colors.textDarkColor, + ); + var textStyle = textTheme.bodyLarge?.copyWith(color: textColor); + + return GestureDetector( + onTap: () { + // Handle day tap here + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: day.outsideMonth ? Colors.transparent : day.color, + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: day.isSelected + ? colorScheme.primary + : Colors.transparent, + ), + ), + child: Stack( + children: [ + Center( + child: Text(day.date.day.toString(), style: textStyle), + ), + if (day.templateDeviation) ...[ + Positioned( + right: 4, + child: Text("*", style: textStyle), + ), + ], + ], + ), + ), + ); + }, + ), + ], + ); + } +} + +/// A Special day in the calendar that needs to be displayed differently +class CalendarDay { + /// + const CalendarDay({ + required this.date, + required this.isSelected, + required this.color, + required this.templateDeviation, + this.outsideMonth = false, + }); + + /// The date of the day + final DateTime date; + + /// Whether the day is selected or not + final bool isSelected; + + /// The color of the day + final Color color; + + /// Whether there is an availability on this day and it deviates from the + /// used template + final bool templateDeviation; + + /// Whether the day is outside of the current month + final bool outsideMonth; + + /// Creates a copy of the current day with the provided values + CalendarDay copyWith({ + DateTime? date, + bool? isSelected, + Color? color, + bool? templateDeviation, + bool? outsideMonth, + }) => + CalendarDay( + date: date ?? this.date, + isSelected: isSelected ?? this.isSelected, + color: color ?? this.color, + templateDeviation: templateDeviation ?? this.templateDeviation, + outsideMonth: outsideMonth ?? this.outsideMonth, + ); +} + +List _generateCalendarDays( + DateTime month, + List days, + AvailabilityColors colors, + ColorScheme colorScheme, +) { + var firstDayOfMonth = DateTime(month.year, month.month, 1); + var lastDayOfMonth = DateTime(month.year, month.month + 1, 0); + var daysInMonth = lastDayOfMonth.day; + var startWeekday = firstDayOfMonth.weekday; + var endWeekday = lastDayOfMonth.weekday; + + var calendarDays = []; + + // Add days from the previous month + for (var i = 0; i < startWeekday - 1; i++) { + var prevDay = + firstDayOfMonth.subtract(Duration(days: startWeekday - 1 - i)); + calendarDays.add( + CalendarDay( + date: prevDay, + isSelected: false, + color: Colors.transparent, + templateDeviation: false, + outsideMonth: true, + ), + ); + } + + // Add days of the current month + for (var i = 1; i <= daysInMonth; i++) { + var day = DateTime(month.year, month.month, i); + var specialDay = days.firstWhere( + (d) => + d.date.day == i && + d.date.month == month.month && + d.date.year == month.year, + orElse: () => CalendarDay( + date: day, + isSelected: false, + color: colors.blankDayColor ?? colorScheme.surfaceDim, + templateDeviation: false, + ), + ); + // if the day is selected we need to change the color + specialDay = specialDay.copyWith( + color: specialDay.isSelected + ? colors.selectedDayColor ?? colorScheme.primaryFixedDim + : null, + ); + calendarDays.add(specialDay); + } + + // Add days from the next month + for (var i = endWeekday; i < 7; i++) { + var nextDay = lastDayOfMonth.add(Duration(days: i - endWeekday + 1)); + calendarDays.add( + CalendarDay( + date: nextDay, + isSelected: false, + color: Colors.transparent, + templateDeviation: false, + outsideMonth: true, + ), + ); + } + + return calendarDays; +} + +Color? _getTextColor( + Color backgroundColor, + Color lightColor, + Color? darkColor, +) => + backgroundColor.computeLuminance() > 0.5 ? darkColor : lightColor;