feat: add pause selection with dialog for modification

This commit is contained in:
Freek van de Ven 2024-07-09 16:31:04 +02:00
parent 71d76bba04
commit aaba808a17
6 changed files with 500 additions and 41 deletions

View file

@ -29,8 +29,14 @@ class AvailabilityTranslations {
required this.templateColorLabel,
required this.time,
required this.timeSeparator,
required this.timeMinutes,
required this.templateTimeLabel,
required this.pauseSectionTitle,
required this.pauseSectionOptional,
required this.pauseDialogTitle,
required this.pauseDialogDescription,
required this.pauseDialogPeriodTitle,
required this.pauseDialogPeriodDescription,
required this.saveButton,
required this.addButton,
required this.timeFormatter,
@ -59,8 +65,16 @@ class AvailabilityTranslations {
this.templateColorLabel = "Colorlabel",
this.time = "Time",
this.timeSeparator = "to",
this.timeMinutes = "minutes",
this.templateTimeLabel = "When are you available?",
this.pauseSectionTitle = "Add a pause (optional)",
this.pauseSectionTitle = "Add a pause",
this.pauseSectionOptional = "(Optional)",
this.pauseDialogTitle = "Add a pause",
this.pauseDialogDescription = "Add a pause to your availability. "
"Choose how long you want to take a break",
this.pauseDialogPeriodTitle = "Time slot",
this.pauseDialogPeriodDescription =
"Select between which times you want to take a break",
this.saveButton = "Save",
this.addButton = "Add",
this.monthYearFormatter = _defaultMonthYearFormatter,
@ -122,12 +136,31 @@ class AvailabilityTranslations {
/// The text between start and end time
final String timeSeparator;
/// The text used for minutes
final String timeMinutes;
/// The label for the template time input
final String templateTimeLabel;
/// The title for pause configuration sections
final String pauseSectionTitle;
/// The label for optional indication on pause sections
final String pauseSectionOptional;
/// The title for the pause dialog
final String pauseDialogTitle;
/// The description for the pause dialog displayed below the title
final String pauseDialogDescription;
/// The title for the section in the pause dialog where you select the period
final String pauseDialogPeriodTitle;
/// The description for the section in the pause dialog where you select
/// the period
final String pauseDialogPeriodDescription;
/// The text on the save button
final String saveButton;

View file

@ -1,5 +1,6 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/ui/widgets/color_selection.dart";
import "package:flutter_availability/src/ui/widgets/pause_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";
@ -135,9 +136,16 @@ class _AvailabilityDayTemplateEditState
},
);
var pauseSection = const SizedBox(
height: 200,
child: Placeholder(),
var pauseSection = PauseSelection(
breaks: (_template.templateData as DayTemplateData).breaks,
onBreaksChanged: (breaks) {
setState(() {
_template = _template.copyWith(
templateData: (_template.templateData as DayTemplateData)
.copyWith(breaks: breaks),
);
});
},
);
var body = CustomScrollView(

View file

@ -0,0 +1,84 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/ui/widgets/input_fields.dart";
import "package:flutter_availability/src/util/scope.dart";
///
class TimeSelection extends StatelessWidget {
///
const TimeSelection({
required this.title,
required this.description,
required this.startTime,
required this.endTime,
required this.onStartChanged,
required this.onEndChanged,
this.crossAxisAlignment = CrossAxisAlignment.start,
super.key,
});
/// The title of the time selection
final String title;
/// The description of the time selection
final String description;
/// the axis aligment for the column
final CrossAxisAlignment crossAxisAlignment;
///
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,
children: [
Text(title, style: textTheme.titleMedium),
const SizedBox(height: 4),
Text(description, 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

@ -59,3 +59,47 @@ class TimeInputField extends StatelessWidget {
);
}
}
/// An input field for giving a duration in minutes
class DurationInputField extends StatelessWidget {
///
const DurationInputField({
required this.initialValue,
required this.onDurationChanged,
super.key,
});
///
final Duration? initialValue;
///
final void Function(Duration) onDurationChanged;
@override
Widget build(BuildContext context) {
var availabilityScope = AvailabilityScope.of(context);
var options = availabilityScope.options;
var translations = options.translations;
void onFieldChanged(String value) {
var duration = int.tryParse(value);
if (duration != null) {
onDurationChanged(Duration(minutes: duration));
}
}
return TextFormField(
decoration: InputDecoration(
labelText: translations.time,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
suffixIcon: const Icon(Icons.access_time),
),
initialValue: initialValue?.inMinutes.toString(),
keyboardType: TextInputType.number,
style: options.textStyles.inputFieldTextStyle,
onChanged: onFieldChanged,
);
}
}

View file

@ -0,0 +1,319 @@
import "package:flutter/material.dart";
import "package:flutter_availability/flutter_availability.dart";
import "package:flutter_availability/src/service/availability_service.dart";
import "package:flutter_availability/src/ui/widgets/generic_time_selection.dart";
import "package:flutter_availability/src/ui/widgets/input_fields.dart";
import "package:flutter_availability/src/util/scope.dart";
import "package:flutter_availability_data_interface/flutter_availability_data_interface.dart";
///
class PauseSelection extends StatelessWidget {
///
const PauseSelection({
required this.breaks,
required this.onBreaksChanged,
super.key,
});
/// The breaks that are currently set
final List<AvailabilityBreakModel> breaks;
/// Callback for when the breaks are changed
final void Function(List<AvailabilityBreakModel>) onBreaksChanged;
@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;
Future<AvailabilityBreakModel?> openBreakDialog(
AvailabilityBreakModel? initialBreak,
) async =>
AvailabilityBreakSelectionDialog.show(
context,
userId: availabilityScope.userId,
options: options,
service: availabilityScope.service,
);
Future<void> onClickAddBreak() async {
var newBreak = await openBreakDialog(null);
if (newBreak == null) return;
var updatedBreaks = [...breaks, newBreak];
onBreaksChanged(updatedBreaks);
}
Future<void> onEditBreak(AvailabilityBreakModel availabilityBreak) async {
var updatedBreak = await openBreakDialog(availabilityBreak);
if (updatedBreak == null) return;
var updatedBreaks = [...breaks, updatedBreak];
onBreaksChanged(updatedBreaks);
}
void onDeleteBreak(AvailabilityBreakModel availabilityBreak) {
var updatedBreaks = breaks.where((b) => b != availabilityBreak).toList();
onBreaksChanged(updatedBreaks);
}
var sortedBreaks = breaks.toList()
..sort((a, b) => a.startTime.compareTo(b.startTime));
var addButton = DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: theme.colorScheme.primary,
width: 1,
),
borderRadius: BorderRadius.circular(4),
),
child: options.textButtonBuilder(
context,
onClickAddBreak,
Text(translations.addButton),
),
);
return Column(
children: [
Row(
children: [
Text(
translations.pauseSectionTitle,
style: textTheme.titleMedium,
),
const SizedBox(width: 4),
Text(
translations.pauseSectionOptional,
style: textTheme.bodyLarge,
),
],
),
for (var breakModel in sortedBreaks) ...[
const SizedBox(height: 8),
BreakDisplay(
breakModel: breakModel,
onRemove: () => onDeleteBreak(breakModel),
onClick: () async => onEditBreak(breakModel),
),
],
const SizedBox(height: 8),
addButton,
],
);
}
}
/// Displays a single break with buttons to edit or delete it
class BreakDisplay extends StatelessWidget {
/// Creates a new break display
const BreakDisplay({
required this.breakModel,
required this.onRemove,
required this.onClick,
super.key,
});
/// The break to display
final AvailabilityBreakModel breakModel;
/// Callback for when the minus button is clicked
final VoidCallback onRemove;
/// Callback for when the break is clicked except for the minus button
final VoidCallback onClick;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var availabilityScope = AvailabilityScope.of(context);
var options = availabilityScope.options;
var colors = options.colors;
var translations = options.translations;
return GestureDetector(
onTap: onClick,
child: Container(
decoration: BoxDecoration(
color: colors.selectedDayColor,
border: Border.all(color: theme.colorScheme.primary, width: 1),
borderRadius: BorderRadius.circular(5),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Text(
"${breakModel.duration.inMinutes} "
"${translations.timeMinutes} | "
"${translations.timeFormatter(context, breakModel.startTime)} - "
"${translations.timeFormatter(context, breakModel.endTime)}",
),
const Spacer(),
GestureDetector(onTap: onRemove, child: const Icon(Icons.remove)),
],
),
),
);
}
}
///
class AvailabilityBreakSelectionDialog extends StatefulWidget {
///
const AvailabilityBreakSelectionDialog({
required this.initialBreak,
super.key,
});
/// The initial break to show in the dialog if any
final AvailabilityBreakModel? initialBreak;
/// Opens the dialog to add a break
static Future<AvailabilityBreakModel?> show(
BuildContext context, {
required AvailabilityOptions options,
required String userId,
required AvailabilityService service,
AvailabilityBreakModel? initialBreak,
}) async =>
showModalBottomSheet<AvailabilityBreakModel>(
context: context,
useSafeArea: false,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
),
),
builder: (context) => AvailabilityScope(
userId: userId,
options: options,
service: service,
child: AvailabilityBreakSelectionDialog(
initialBreak: initialBreak,
),
),
);
@override
State<AvailabilityBreakSelectionDialog> createState() =>
_AvailabilityBreakSelectionDialogState();
}
class _AvailabilityBreakSelectionDialogState
extends State<AvailabilityBreakSelectionDialog> {
late DateTime? _startTime;
late DateTime? _endTime;
late Duration? _duration;
@override
void initState() {
super.initState();
_startTime = widget.initialBreak?.startTime;
_endTime = widget.initialBreak?.endTime;
_duration = widget.initialBreak?.duration;
}
@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 spacing = options.spacing;
void onUpdateDuration(Duration duration) {
setState(() {
_duration = duration;
});
}
void onUpdateStart(DateTime time) {
setState(() {
_startTime = time;
});
}
void onUpdateEnd(DateTime time) {
setState(() {
_endTime = time;
});
}
var canSave = _startTime != null && _endTime != null;
var onSaveButtonPress = canSave
? () {
var breakModel = AvailabilityBreakModel(
startTime: _startTime!,
endTime: _endTime!,
duration: _duration,
);
Navigator.of(context).pop(breakModel);
}
: null;
var saveButton = options.primaryButtonBuilder(
context,
onSaveButtonPress,
Text(
widget.initialBreak == null
? translations.addButton
: translations.saveButton,
),
);
return Container(
margin: EdgeInsets.symmetric(
horizontal: spacing.sidePadding,
),
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 44),
Text(translations.pauseDialogTitle, style: textTheme.titleMedium),
const SizedBox(height: 4),
Text(
translations.pauseDialogDescription,
style: textTheme.bodyMedium,
),
const SizedBox(height: 16),
Row(
children: [
const Spacer(),
Expanded(
flex: 2,
child: DurationInputField(
initialValue: _duration,
onDurationChanged: onUpdateDuration,
),
),
const Spacer(),
],
),
const SizedBox(height: 24),
TimeSelection(
// rebuild the widget when the start or end time changes
key: ValueKey([_startTime, _endTime]),
title: translations.pauseDialogPeriodTitle,
description: translations.pauseDialogPeriodDescription,
crossAxisAlignment: CrossAxisAlignment.center,
startTime: _startTime,
endTime: _endTime,
onStartChanged: onUpdateStart,
onEndChanged: onUpdateEnd,
),
const SizedBox(height: 36),
saveButton,
SizedBox(height: spacing.bottomButtonPadding),
],
),
),
);
}
}

View file

@ -1,5 +1,5 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/ui/widgets/input_fields.dart";
import "package:flutter_availability/src/ui/widgets/generic_time_selection.dart";
import "package:flutter_availability/src/util/scope.dart";
///
@ -27,46 +27,17 @@ class TemplateTimeSelection extends StatelessWidget {
@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,
),
),
],
),
],
return TimeSelection(
title: translations.time,
description: translations.templateTimeLabel,
startTime: startTime,
endTime: endTime,
onStartChanged: onStartChanged,
onEndChanged: onEndChanged,
);
}
}