feat: add day template edit/creation page

This commit is contained in:
Freek van de Ven 2024-07-07 13:48:54 +02:00 committed by Bart Ribbers
parent f2e279a592
commit 39da1a4f21
10 changed files with 594 additions and 4 deletions

View file

@ -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<Color>? templateColors;
final List<Color> templateColors;
}
/// Builder definition for providing a base screen surrounding each page

View file

@ -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,

View file

@ -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(),
),
);

View file

@ -84,6 +84,31 @@ class AvailabilityService {
);
});
}
/// Creates a new template
Future<void> createTemplate(AvailabilityTemplateModel template) async {
await dataInterface.createTemplateForUser(
userId,
template,
);
}
/// Updates a template
Future<void> updateTemplate(AvailabilityTemplateModel template) async {
await dataInterface.updateTemplateForUser(
userId,
template.id!,
template,
);
}
/// Deletes a template
Future<void> deleteTemplate(AvailabilityTemplateModel template) async {
await dataInterface.deleteTemplateForUser(
userId,
template.id!,
);
}
}
/// A combination of availability and template for a single day

View file

@ -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<AvailabilityDayTemplateEdit> createState() =>
_AvailabilityDayTemplateEditState();
}
class _AvailabilityDayTemplateEditState
extends State<AvailabilityDayTemplateEdit> {
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<void> onDeletePressed() async {
await service.deleteTemplate(_template);
widget.onExit();
}
Future<void> 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);
}
}

View file

@ -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,
);
}

View file

@ -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<void> 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,
);
}
}

View file

@ -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,
),
],
);
}
}

View file

@ -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,
),
),
],
),
],
);
}
}

View file

@ -317,6 +317,18 @@ class DayTemplateData implements TemplateData {
];
}
/// copy the current instance with new values
DayTemplateData copyWith({
DateTime? startTime,
DateTime? endTime,
List<AvailabilityBreakModel>? breaks,
}) =>
DayTemplateData(
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
breaks: breaks ?? this.breaks,
);
@override
Map<String, dynamic> toMap() => {
"startTime": startTime.toIso8601String(),