diff --git a/packages/flutter_availability/lib/src/config/availability_options.dart b/packages/flutter_availability/lib/src/config/availability_options.dart index 044215f..5e6e2b7 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.textStyles = const AvailabilityTextStyles(), this.colors = const AvailabilityColors(), AvailabilityDataInterface? dataInterface, }) : dataInterface = dataInterface ?? LocalAvailabilityDataInterface(); @@ -42,6 +43,9 @@ class AvailabilityOptions { /// The spacing between elements final AvailabilitySpacing spacing; + /// The configurable text styles in the userstory + final AvailabilityTextStyles textStyles; + /// The colors used in the userstory final AvailabilityColors colors; } @@ -61,6 +65,18 @@ class AvailabilitySpacing { final double sidePadding; } +/// All customizable text styles for the availability userstory +/// If text styles are not provided the text styles will be taken from the theme +class AvailabilityTextStyles { + /// Constructor for the AvailabilityTextStyles + const AvailabilityTextStyles({ + this.inputFieldTextStyle, + }); + + /// The text style for the filled in text on all the input fields + final TextStyle? inputFieldTextStyle; +} + /// Contains all the customizable colors for the availability userstory /// /// If colors are not provided the colors will be taken from the theme @@ -74,7 +90,14 @@ class AvailabilityColors { this.outsideMonthTextColor, this.textDarkColor, this.textLightColor, - this.templateColors, + this.templateColors = const [ + Color(0xFF9bb8f2), + Color(0xFF4b77d0), + Color(0xFF283a5e), + Color(0xFF57947d), + Color(0xFFef6c75), + Color(0xFFb7198b), + ], }); /// The color of the text for the days that are not in the current month @@ -98,7 +121,7 @@ class AvailabilityColors { final Color? textDarkColor; /// The colors that are used for the templates - final List? templateColors; + final List templateColors; } /// Builder definition for providing a base screen surrounding each page diff --git a/packages/flutter_availability/lib/src/config/availability_translations.dart b/packages/flutter_availability/lib/src/config/availability_translations.dart index 69df198..3c187b7 100644 --- a/packages/flutter_availability/lib/src/config/availability_translations.dart +++ b/packages/flutter_availability/lib/src/config/availability_translations.dart @@ -22,6 +22,18 @@ class AvailabilityTranslations { required this.weekTemplates, required this.createDayTemplate, required this.createWeekTemplate, + required this.deleteTemplateButton, + required this.dayTemplateTitle, + required this.templateTitleHintText, + required this.templateTitleLabel, + required this.templateColorLabel, + required this.time, + required this.timeSeparator, + required this.templateTimeLabel, + required this.pauseSectionTitle, + required this.saveButton, + required this.addButton, + required this.timeFormatter, required this.monthYearFormatter, required this.weekDayAbbreviatedFormatter, }); @@ -40,8 +52,20 @@ class AvailabilityTranslations { this.weekTemplates = "Week templates", this.createDayTemplate = "Create day template", this.createWeekTemplate = "Create week template", + this.deleteTemplateButton = "Delete template", + this.dayTemplateTitle = "Day template", + this.templateTitleHintText = "What do you want to call this template?", + this.templateTitleLabel = "Template Title", + this.templateColorLabel = "Colorlabel", + this.time = "Time", + this.timeSeparator = "to", + this.templateTimeLabel = "When are you available?", + this.pauseSectionTitle = "Add a pause (optional)", + this.saveButton = "Save", + this.addButton = "Add", this.monthYearFormatter = _defaultMonthYearFormatter, this.weekDayAbbreviatedFormatter = _defaultWeekDayAbbreviatedFormatter, + this.timeFormatter = _defaultTimeFormatter, }); /// The title shown above the calendar @@ -77,6 +101,39 @@ class AvailabilityTranslations { /// The label for the button to create a new week template final String createWeekTemplate; + /// The label on the button to delete a template + final String deleteTemplateButton; + + /// The title for the day template edit screen + final String dayTemplateTitle; + + /// The hint text for the template title input field + final String templateTitleHintText; + + /// The label for the template title input field + final String templateTitleLabel; + + /// The title above the color selection for templates + final String templateColorLabel; + + /// The title for time sections + final String time; + + /// The text between start and end time + final String timeSeparator; + + /// The label for the template time input + final String templateTimeLabel; + + /// The title for pause configuration sections + final String pauseSectionTitle; + + /// The text on the save button + final String saveButton; + + /// The text on the add button + final String addButton; + /// Gets the month and year formatted as a string /// /// The default implementation is `MonthName Year` in english @@ -87,8 +144,16 @@ class AvailabilityTranslations { /// The default implementation is the first 2 letters of /// the weekday in english final String Function(BuildContext, DateTime) weekDayAbbreviatedFormatter; + + /// Get the time formatted as a string + /// + /// The default implementation is `HH:mm` + final String Function(BuildContext, DateTime) timeFormatter; } +String _defaultTimeFormatter(BuildContext context, DateTime date) => + "${date.hour}:${date.minute}"; + String _defaultWeekDayAbbreviatedFormatter( BuildContext context, DateTime date, diff --git a/packages/flutter_availability/lib/src/routes.dart b/packages/flutter_availability/lib/src/routes.dart index fe54ab4..f245cd3 100644 --- a/packages/flutter_availability/lib/src/routes.dart +++ b/packages/flutter_availability/lib/src/routes.dart @@ -1,7 +1,9 @@ import "package:flutter/material.dart"; import "package:flutter_availability/flutter_availability.dart"; import "package:flutter_availability/src/ui/screens/template_availability_day_overview.dart"; +import "package:flutter_availability/src/ui/screens/template_day_edit.dart"; import "package:flutter_availability/src/ui/screens/template_overview.dart"; +import "package:flutter_availability_data_interface/flutter_availability_data_interface.dart"; /// MaterialPageRoute homePageRoute(VoidCallback onExit) => MaterialPageRoute( @@ -18,8 +20,25 @@ MaterialPageRoute homePageRoute(VoidCallback onExit) => MaterialPageRoute( MaterialPageRoute templateOverviewRoute() => MaterialPageRoute( builder: (context) => AvailabilityTemplateOverview( onExit: () => Navigator.of(context).pop(), - onEditTemplate: (template) {}, - onAddTemplate: (type) {}, + onEditTemplate: (template) async { + if (template.templateType == AvailabilityTemplateType.day) { + await Navigator.of(context).push(templateEditDayRoute(template)); + } + }, + onAddTemplate: (type) async { + if (type == AvailabilityTemplateType.day) { + await Navigator.of(context).push(templateEditDayRoute(null)); + } + }, + ), + ); + +/// +MaterialPageRoute templateEditDayRoute(AvailabilityTemplateModel? template) => + MaterialPageRoute( + builder: (context) => AvailabilityDayTemplateEdit( + template: template, + onExit: () => Navigator.of(context).pop(), ), ); diff --git a/packages/flutter_availability/lib/src/service/availability_service.dart b/packages/flutter_availability/lib/src/service/availability_service.dart index 557a424..f1d153e 100644 --- a/packages/flutter_availability/lib/src/service/availability_service.dart +++ b/packages/flutter_availability/lib/src/service/availability_service.dart @@ -84,6 +84,31 @@ class AvailabilityService { ); }); } + + /// Creates a new template + Future createTemplate(AvailabilityTemplateModel template) async { + await dataInterface.createTemplateForUser( + userId, + template, + ); + } + + /// Updates a template + Future updateTemplate(AvailabilityTemplateModel template) async { + await dataInterface.updateTemplateForUser( + userId, + template.id!, + template, + ); + } + + /// Deletes a template + Future deleteTemplate(AvailabilityTemplateModel template) async { + await dataInterface.deleteTemplateForUser( + userId, + template.id!, + ); + } } /// A combination of availability and template for a single day diff --git a/packages/flutter_availability/lib/src/ui/screens/template_day_edit.dart b/packages/flutter_availability/lib/src/ui/screens/template_day_edit.dart new file mode 100644 index 0000000..85f4ef1 --- /dev/null +++ b/packages/flutter_availability/lib/src/ui/screens/template_day_edit.dart @@ -0,0 +1,191 @@ +import "package:flutter/material.dart"; +import "package:flutter_availability/src/ui/widgets/color_selection.dart"; +import "package:flutter_availability/src/ui/widgets/template_name_input.dart"; +import "package:flutter_availability/src/ui/widgets/template_time_selection.dart"; +import "package:flutter_availability/src/util/scope.dart"; +import "package:flutter_availability_data_interface/flutter_availability_data_interface.dart"; + +/// Page for creating or editing a day template +class AvailabilityDayTemplateEdit extends StatefulWidget { + /// + const AvailabilityDayTemplateEdit({ + required this.template, + required this.onExit, + super.key, + }); + + /// The day template to edit or null if creating a new one + final AvailabilityTemplateModel? template; + + /// Callback for when the user wants to navigate back + final VoidCallback onExit; + + @override + State createState() => + _AvailabilityDayTemplateEditState(); +} + +class _AvailabilityDayTemplateEditState + extends State { + late int? _selectedColor; + late AvailabilityTemplateModel _template; + + @override + void initState() { + super.initState(); + _selectedColor = widget.template?.color; + _template = widget.template ?? + AvailabilityTemplateModel( + userId: "1", + name: "", + color: 0, + templateType: AvailabilityTemplateType.day, + templateData: DayTemplateData( + startTime: DateTime.now(), + endTime: DateTime.now(), + breaks: [], + ), + ); + } + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var availabilityScope = AvailabilityScope.of(context); + var service = availabilityScope.service; + var options = availabilityScope.options; + var translations = options.translations; + var spacing = options.spacing; + + Future onDeletePressed() async { + await service.deleteTemplate(_template); + widget.onExit(); + } + + Future onSavePressed() async { + if (widget.template == null) { + await service.createTemplate(_template); + } else { + await service.updateTemplate(_template); + } + widget.onExit(); + } + + var canSave = _template.name.isNotEmpty && _selectedColor != null; + + var saveButton = options.primaryButtonBuilder( + context, + canSave ? onSavePressed : null, + Text(translations.saveButton), + ); + + var deleteButton = options.textButtonBuilder( + context, + onDeletePressed, + Text(translations.deleteTemplateButton), + ); + + var title = Center( + child: Text( + translations.dayTemplateTitle, + style: theme.textTheme.displaySmall, + ), + ); + + var templateTitleSection = TemplateNameInput( + initialValue: _template.name, + onNameChanged: (name) { + setState(() { + _template = _template.copyWith(name: name); + }); + }, + ); + + var timeSection = TemplateTimeSelection( + key: ValueKey(_template.templateData), + startTime: (_template.templateData as DayTemplateData).startTime, + endTime: (_template.templateData as DayTemplateData).endTime, + onStartChanged: (start) { + setState(() { + _template = _template.copyWith( + templateData: (_template.templateData as DayTemplateData).copyWith( + startTime: start, + ), + ); + }); + }, + onEndChanged: (end) { + setState(() { + _template = _template.copyWith( + templateData: (_template.templateData as DayTemplateData).copyWith( + endTime: end, + ), + ); + }); + }, + ); + + var colorSection = TemplateColorSelection( + selectedColor: _selectedColor, + onColorSelected: (color) { + setState(() { + _selectedColor = color; + _template = _template.copyWith(color: color); + }); + }, + ); + + var pauseSection = const SizedBox( + height: 200, + child: Placeholder(), + ); + + var body = CustomScrollView( + slivers: [ + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: spacing.sidePadding), + sliver: SliverList.list( + children: [ + const SizedBox(height: 40), + title, + const SizedBox(height: 24), + templateTitleSection, + const SizedBox(height: 24), + timeSection, + const SizedBox(height: 24), + colorSection, + const SizedBox(height: 24), + pauseSection, + const SizedBox(height: 32), + ], + ), + ), + SliverFillRemaining( + fillOverscroll: false, + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.sidePadding, + ).copyWith( + bottom: spacing.bottomButtonPadding, + ), + child: Align( + alignment: Alignment.bottomCenter, + child: Column( + children: [ + saveButton, + if (widget.template != null) ...[ + const SizedBox(height: 8), + deleteButton, + ], + ], + ), + ), + ), + ), + ], + ); + + return options.baseScreenBuilder(context, widget.onExit, body); + } +} diff --git a/packages/flutter_availability/lib/src/ui/widgets/color_selection.dart b/packages/flutter_availability/lib/src/ui/widgets/color_selection.dart new file mode 100644 index 0000000..9b14da3 --- /dev/null +++ b/packages/flutter_availability/lib/src/ui/widgets/color_selection.dart @@ -0,0 +1,72 @@ +import "package:flutter/material.dart"; +import "package:flutter_availability/src/util/scope.dart"; + +/// Widget for selecting a color for a template +/// All available colors for the templates are displayed in a wrap layout +class TemplateColorSelection extends StatelessWidget { + /// + const TemplateColorSelection({ + required this.selectedColor, + required this.onColorSelected, + super.key, + }); + + /// The selected color for the template + /// If null, no color is selected + final int? selectedColor; + + /// Callback for when a color is selected or deselected + final void Function(int?) onColorSelected; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var textTheme = theme.textTheme; + var availabilityScope = AvailabilityScope.of(context); + var options = availabilityScope.options; + var translations = options.translations; + var colors = options.colors; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + translations.templateColorLabel, + style: textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var color in colors.templateColors) + GestureDetector( + onTap: () => _onColorClick(color), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: color.value == selectedColor + ? Colors.black + : Colors.transparent, + width: 1, + ), + ), + child: selectedColor == color.value + ? const Icon(Icons.check) + : null, + ), + ), + ], + ), + ], + ); + } + + /// If the color is selected, deselect it, otherwise select it + void _onColorClick(Color color) => onColorSelected( + color.value == selectedColor ? null : color.value, + ); +} diff --git a/packages/flutter_availability/lib/src/ui/widgets/input_fields.dart b/packages/flutter_availability/lib/src/ui/widgets/input_fields.dart new file mode 100644 index 0000000..7ef6e9d --- /dev/null +++ b/packages/flutter_availability/lib/src/ui/widgets/input_fields.dart @@ -0,0 +1,61 @@ +import "package:flutter/material.dart"; +import "package:flutter_availability/src/util/scope.dart"; + +/// An input field for time selection +class TimeInputField extends StatelessWidget { + /// + const TimeInputField({ + required this.initialValue, + required this.onTimeChanged, + super.key, + }); + + /// + final DateTime? initialValue; + + /// + final void Function(DateTime) onTimeChanged; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var availabilityScope = AvailabilityScope.of(context); + var options = availabilityScope.options; + var translations = options.translations; + + Future onFieldtap() async { + var time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(initialValue ?? DateTime.now()), + ); + if (time != null) { + onTimeChanged( + DateTime( + initialValue?.year ?? DateTime.now().year, + initialValue?.month ?? DateTime.now().month, + initialValue?.day ?? DateTime.now().day, + time.hour, + time.minute, + ), + ); + } + } + + return TextFormField( + decoration: InputDecoration( + suffixIcon: const Icon(Icons.access_time), + hintText: translations.time, + hintStyle: theme.inputDecorationTheme.hintStyle, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + initialValue: initialValue != null + ? translations.timeFormatter(context, initialValue!) + : null, + readOnly: true, + style: options.textStyles.inputFieldTextStyle, + onTap: onFieldtap, + ); + } +} diff --git a/packages/flutter_availability/lib/src/ui/widgets/template_name_input.dart b/packages/flutter_availability/lib/src/ui/widgets/template_name_input.dart new file mode 100644 index 0000000..b409120 --- /dev/null +++ b/packages/flutter_availability/lib/src/ui/widgets/template_name_input.dart @@ -0,0 +1,50 @@ +import "package:flutter/material.dart"; +import "package:flutter_availability/src/util/scope.dart"; + +/// Input section for the template name +class TemplateNameInput extends StatelessWidget { + /// + const TemplateNameInput({ + required this.initialValue, + required this.onNameChanged, + super.key, + }); + + /// The initial value for the template name + final String? initialValue; + + /// callback for when the template name is changed + final void Function(String) onNameChanged; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var textTheme = theme.textTheme; + var availabilityScope = AvailabilityScope.of(context); + var options = availabilityScope.options; + var translations = options.translations; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + translations.templateTitleLabel, + style: textTheme.titleMedium, + ), + const SizedBox(height: 8), + TextFormField( + decoration: InputDecoration( + hintText: translations.templateTitleHintText, + hintStyle: theme.inputDecorationTheme.hintStyle, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + initialValue: initialValue, + style: options.textStyles.inputFieldTextStyle, + onChanged: onNameChanged, + ), + ], + ); + } +} diff --git a/packages/flutter_availability/lib/src/ui/widgets/template_time_selection.dart b/packages/flutter_availability/lib/src/ui/widgets/template_time_selection.dart new file mode 100644 index 0000000..76174ba --- /dev/null +++ b/packages/flutter_availability/lib/src/ui/widgets/template_time_selection.dart @@ -0,0 +1,72 @@ +import "package:flutter/material.dart"; +import "package:flutter_availability/src/ui/widgets/input_fields.dart"; +import "package:flutter_availability/src/util/scope.dart"; + +/// +class TemplateTimeSelection extends StatelessWidget { + /// + const TemplateTimeSelection({ + required this.startTime, + required this.endTime, + required this.onStartChanged, + required this.onEndChanged, + super.key, + }); + + /// + final DateTime? startTime; + + /// + final DateTime? endTime; + + /// + final void Function(DateTime) onStartChanged; + + /// + final void Function(DateTime) onEndChanged; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var textTheme = theme.textTheme; + var availabilityScope = AvailabilityScope.of(context); + var options = availabilityScope.options; + var translations = options.translations; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(translations.time, style: textTheme.titleMedium), + const SizedBox(height: 4), + Text(translations.templateTimeLabel, style: textTheme.bodyMedium), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + flex: 2, + child: TimeInputField( + initialValue: startTime, + onTimeChanged: onStartChanged, + ), + ), + Expanded( + flex: 1, + child: Text( + translations.timeSeparator, + style: textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ), + Expanded( + flex: 2, + child: TimeInputField( + initialValue: endTime, + onTimeChanged: onEndChanged, + ), + ), + ], + ), + ], + ); + } +} diff --git a/packages/flutter_availability_data_interface/lib/src/models/templates.dart b/packages/flutter_availability_data_interface/lib/src/models/templates.dart index 0bd7a5e..2ca66fb 100644 --- a/packages/flutter_availability_data_interface/lib/src/models/templates.dart +++ b/packages/flutter_availability_data_interface/lib/src/models/templates.dart @@ -317,6 +317,18 @@ class DayTemplateData implements TemplateData { ]; } + /// copy the current instance with new values + DayTemplateData copyWith({ + DateTime? startTime, + DateTime? endTime, + List? breaks, + }) => + DayTemplateData( + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + breaks: breaks ?? this.breaks, + ); + @override Map toMap() => { "startTime": startTime.toIso8601String(),