feat(availability-overview): create basic layout with options integration

This commit is contained in:
Joey Boerwinkel 2024-07-04 17:25:08 +02:00
parent 1b6a727305
commit 0945394004
16 changed files with 584 additions and 481 deletions

View file

@ -9,8 +9,11 @@ class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(
home: Home(),
Widget build(BuildContext context) => MaterialApp(
home: AvailabilityUserStory(
userId: "",
options: AvailabilityOptions(),
),
);
}
@ -18,7 +21,16 @@ class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) => availabilityNavigatorUserStory(
context,
Widget build(BuildContext context) => Scaffold(
body: const Center(
child: Text("Hello World"),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
debugPrint("starting availability user story");
await openAvailabilitiesForUser(context, "anonymous", null);
debugPrint("finishing availability user story");
},
),
);
}

View file

@ -3,6 +3,5 @@ library flutter_availability;
export "src/config/availability_options.dart";
export "src/config/availability_translations.dart";
export "src/screens/availability_overview.dart";
export "src/userstory/navigator_userstory.dart";
export "src/userstory/userstory_configuration.dart";
export "src/ui/screens/availability_overview.dart";
export "src/userstories.dart";

View file

@ -1,12 +1,72 @@
import "dart:async";
import "package:flutter/material.dart";
import "package:flutter_availability/src/config/availability_translations.dart";
import "package:flutter_availability/src/service/local_data_interface.dart";
import "package:flutter_availability/src/ui/widgets/default_base_screen.dart";
import "package:flutter_availability/src/ui/widgets/default_buttons.dart";
import "package:flutter_availability_data_interface/flutter_availability_data_interface.dart";
/// Class that holds all options for the availability userstory
class AvailabilityOptions {
/// AvailabilityOptions constructor where everything is optional.
const AvailabilityOptions({
AvailabilityOptions({
this.translations = const AvailabilityTranslations.empty(),
});
this.baseScreenBuilder = DefaultBaseScreen.builder,
this.primaryButtonBuilder = DefaultPrimaryButton.builder,
this.textButtonBuilder = DefaultTextButton.builder,
this.spacing = const AvailabilitySpacing(),
AvailabilityDataInterface? dataInterface,
}) : dataInterface = dataInterface ?? LocalAvailabilityDataInterface();
/// The translations for the availability userstory
final AvailabilityTranslations translations;
/// The implementation for communicating with the persistance layer
final AvailabilityDataInterface dataInterface;
/// A method to wrap your availability screens with a base frame.
///
/// If you provide a screen here make sure to use a [Scaffold], as some
/// elements require a [Material] or other elements that a [Scaffold]
/// provides
final BaseScreenBuilder baseScreenBuilder;
/// A way to provide your own primary button implementation
final ButtonBuilder primaryButtonBuilder;
/// A way to provide your own text button implementation
final ButtonBuilder textButtonBuilder;
/// The spacing between elements
final AvailabilitySpacing spacing;
}
/// All configurable paddings and whitespaces withing the userstory
class AvailabilitySpacing {
/// Creates an AvailabilitySpacing
const AvailabilitySpacing({
this.sidePadding = 32,
this.bottomButtonPadding = 40,
});
/// the padding below the button at the bottom
final double bottomButtonPadding;
/// the padding applied on both sides of the screen
final double sidePadding;
}
/// Builder definition for providing a base screen surrounding each page
typedef BaseScreenBuilder = Widget Function(
BuildContext context,
VoidCallback onBack,
Widget child,
);
/// Builder definition for providing a button implementation
typedef ButtonBuilder = Widget Function(
BuildContext context,
FutureOr<void>? Function() onPressed,
Widget child,
);

View file

@ -9,76 +9,35 @@ class AvailabilityTranslations {
/// You can copyWith the values to override some default translations.
/// It is recommended to use this constructor.
const AvailabilityTranslations({
required this.calendarTitle,
required this.addAvailableDayButtonText,
required this.availabilityOverviewTitle,
required this.availabilityDate,
required this.availabilityHours,
required this.availabilityEmptyMessage,
required this.availabilitySubmit,
required this.availabilitySave,
required this.appbarTitle,
required this.editAvailabilityButton,
required this.templateLegendTitle,
required this.overviewScreenTitle,
required this.createTemplateButton,
});
/// AvailabilityTranslations constructor where everything is optional.
/// This provides default english values for the availability userstory.
const AvailabilityTranslations.empty({
this.calendarTitle = "Availability",
this.addAvailableDayButtonText = "Add availability",
this.availabilityOverviewTitle = "Overview of your availabilities",
this.availabilityDate = "Date",
this.availabilityHours = "Hours",
this.availabilityEmptyMessage =
"No availabilities filled in for this month",
this.availabilitySubmit = "Submit",
this.availabilitySave = "Save",
this.appbarTitle = "Availability",
this.editAvailabilityButton = "Edit availability",
this.templateLegendTitle = "Templates",
this.createTemplateButton = "Create a new template",
this.overviewScreenTitle = "Availability",
});
/// The title shown above the calendar
final String calendarTitle;
final String appbarTitle;
/// The text shown on the button to add an available day
final String addAvailableDayButtonText;
/// The text shown on the button to edit availabilities for a range
final String editAvailabilityButton;
/// The title for the overview of the availabilities
final String availabilityOverviewTitle;
/// The title for the legend template section on the overview screen
final String templateLegendTitle;
/// The label for the date of an availability
final String availabilityDate;
/// The title on the overview screen
final String overviewScreenTitle;
/// The label for the hours of an availability
final String availabilityHours;
/// The text shown when there are no availabilities filled in for a month
final String availabilityEmptyMessage;
/// The text shown on the button to submit the availabilities
final String availabilitySubmit;
/// The text shown on the button to save a single availability
final String availabilitySave;
/// Method to update the translations with new values
AvailabilityTranslations copyWith({
String? calendarTitle,
String? addAvailableDayButtonText,
String? availabilityOverviewTitle,
String? availabilityDate,
String? availabilityHours,
String? availabilityEmptyMessage,
String? availabilitySubmit,
String? availabilitySave,
}) =>
AvailabilityTranslations(
calendarTitle: calendarTitle ?? this.calendarTitle,
addAvailableDayButtonText:
addAvailableDayButtonText ?? this.addAvailableDayButtonText,
availabilityOverviewTitle:
availabilityOverviewTitle ?? this.availabilityOverviewTitle,
availabilityDate: availabilityDate ?? this.availabilityDate,
availabilityHours: availabilityHours ?? this.availabilityHours,
availabilityEmptyMessage:
availabilityEmptyMessage ?? this.availabilityEmptyMessage,
availabilitySubmit: availabilitySubmit ?? this.availabilitySubmit,
availabilitySave: availabilitySave ?? this.availabilitySave,
);
/// The label on the button to go to the tempalte creation page
final String createTemplateButton;
}

View file

@ -0,0 +1,26 @@
import "package:flutter/material.dart";
import "package:flutter_availability/flutter_availability.dart";
import "package:flutter_availability/src/ui/screens/template_availability_day_overview.dart";
///
final homePageRoute = MaterialPageRoute(
builder: (context) => AvailabilityOverview(
onEditDateRange: (range) {
Navigator.of(context).push(availabilityViewRoute(range.start));
},
onViewTemplates: () {},
),
);
///
MaterialPageRoute availabilityViewRoute(
DateTime date,
) =>
MaterialPageRoute(
builder: (context) => AvailabilityDayOverview(
date: date,
onAvailabilitySaved: () {
Navigator.of(context).pop();
},
),
);

View file

@ -1,174 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/config/availability_options.dart";
import "package:flutter_availability_data_interface/flutter_availability_data_interface.dart";
import "package:intl/intl.dart";
///
class AvailabilityOverview extends StatefulWidget {
///
const AvailabilityOverview({
required this.userId,
required this.service,
required this.options,
required this.onDayClicked,
required this.onAvailabilityClicked,
super.key,
});
/// The user whose availability is being managed
final String userId;
/// The service to use for managing availability
final AvailabilityDataInterface service;
/// The configuration option for the availability overview
final AvailabilityOptions options;
/// Callback for when the user clicks on a day
final void Function(DateTime date) onDayClicked;
/// Callback for when the user clicks on an availability
final void Function(AvailabilityModel availability) onAvailabilityClicked;
@override
State<AvailabilityOverview> createState() => _AvailabilityOverviewState();
}
class _AvailabilityOverviewState extends State<AvailabilityOverview> {
Stream<List<AvailabilityModel>>? _availabilityStream;
void _startLoadingAvailabilities(DateTime start, DateTime end) {
setState(() {
_availabilityStream = widget.service.getAvailabilityForUser(
userId: widget.userId,
start: start,
end: end,
);
});
}
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Stack(
children: [
Column(
children: [
Text(
widget.options.translations.calendarTitle,
style: theme.textTheme.displaySmall,
),
Expanded(
child: _availabilityStream == null
? const Center(
child: Text("Press the button to load availabilities."),
)
: StreamBuilder<List<AvailabilityModel>>(
stream: _availabilityStream,
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
} else if (snapshot.hasError) {
return Center(
child: Text("Error: ${snapshot.error}"),
);
} else if (!snapshot.hasData ||
snapshot.data!.isEmpty) {
return const Center(
child: Text("No availabilities found."),
);
} else {
var sortedAvailabilities = snapshot.data!
..sort(
(a, b) => a.startDate.compareTo(b.startDate),
);
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
var availability = sortedAvailabilities[index];
return ListTile(
title: Text(
"Available from ${DateFormat(
"dd-MM-yyyy HH:mm",
).format(availability.startDate)} "
"\nto \n"
"${DateFormat("dd-MM-yyyy HH:mm").format(
availability.endDate,
)}",
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
if (availability.id == null) {
return;
}
await widget.service
.deleteAvailabilityForUser(
widget.userId,
availability.id!,
);
},
),
onTap: () =>
widget.onAvailabilityClicked(availability),
);
},
);
}
},
),
),
],
),
Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
onPressed: () async {
// ask the user to select a date
var date = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate:
DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date == null) {
return;
}
widget.onDayClicked(date);
},
child: Text(
widget.options.translations.addAvailableDayButtonText,
),
),
ElevatedButton(
onPressed: () async {
// ask the user to select a date
var dateRange = await showDateRangePicker(
context: context,
firstDate:
DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (dateRange == null) {
return;
}
_startLoadingAvailabilities(dateRange.start, dateRange.end);
},
child: const Text("Load availabilities for a date range"),
),
],
),
),
],
);
}
}

View file

@ -0,0 +1,16 @@
import "package:flutter_availability_data_interface/flutter_availability_data_interface.dart";
///
class AvailabilityService {
///
const AvailabilityService({
required this.userId,
required this.dataInterface,
});
///
final String userId;
///
final AvailabilityDataInterface dataInterface;
}

View file

@ -0,0 +1,96 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/util/scope.dart";
///
class AvailabilityOverview extends StatelessWidget {
///
const AvailabilityOverview({
required this.onEditDateRange,
required this.onViewTemplates,
super.key,
});
/// Callback for when the user clicks on a day
final void Function(DateTimeRange range) onEditDateRange;
/// Callback for when the user wants to navigate to the overview of templates
final VoidCallback onViewTemplates;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
var availabilityScope = AvailabilityScope.of(context);
var options = availabilityScope.options;
var translations = options.translations;
var spacing = options.spacing;
void onBack() {
Navigator.of(context).pop();
}
var title = Center(
child: Text(
translations.overviewScreenTitle,
style: theme.textTheme.displaySmall,
),
);
const calendar = SizedBox(
height: 320,
child: Placeholder(),
);
const templateLegend = SizedBox(
height: 40,
child: Placeholder(),
);
var startEditButton = options.primaryButtonBuilder(
context,
() {
onEditDateRange(DateTimeRange(start: DateTime(1), end: DateTime(2)));
},
Text(translations.editAvailabilityButton),
);
var body = CustomScrollView(
slivers: [
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: spacing.sidePadding),
sliver: SliverList.list(
children: [
const SizedBox(height: 40),
title,
const SizedBox(height: 24),
calendar,
const SizedBox(height: 32),
templateLegend,
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: startEditButton,
),
),
),
],
);
return options.baseScreenBuilder(
context,
onBack,
body,
);
}
}

View file

@ -1,5 +1,5 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/config/availability_options.dart";
import "package:flutter_availability/src/util/scope.dart";
import "package:flutter_availability_data_interface/flutter_availability_data_interface.dart";
import "package:intl/intl.dart";
@ -7,24 +7,12 @@ import "package:intl/intl.dart";
class AvailabilityDayOverview extends StatefulWidget {
///
const AvailabilityDayOverview({
required this.userId,
required this.service,
required this.options,
required this.date,
required this.onAvailabilitySaved,
this.initialAvailability,
super.key,
});
/// The user whose availability is being managed
final String userId;
/// The service to use for managing availability
final AvailabilityDataInterface service;
/// The configuration option for the availability overview
final AvailabilityOptions options;
/// The date for which the availability is being managed
final DateTime date;
@ -50,7 +38,7 @@ class _AvailabilityDayOverviewState extends State<AvailabilityDayOverview> {
super.initState();
_availability = widget.initialAvailability ??
AvailabilityModel(
userId: widget.userId,
userId: "",
startDate: widget.date,
endDate: widget.date,
breaks: [],
@ -86,6 +74,10 @@ class _AvailabilityDayOverviewState extends State<AvailabilityDayOverview> {
@override
Widget build(BuildContext context) {
var availabilityScope = AvailabilityScope.of(context);
var userId = availabilityScope.userId;
var service = availabilityScope.service;
Future<void> updateAvailabilityStart() async {
var selectedTime = await _selectTime(_startDateController);
if (selectedTime == null) return;
@ -125,15 +117,15 @@ class _AvailabilityDayOverviewState extends State<AvailabilityDayOverview> {
if (_clearAvailableToday) {
// remove the availability for the user
if (_availability.id != null) {
await widget.service.deleteAvailabilityForUser(
widget.userId,
await service.dataInterface.deleteAvailabilityForUser(
userId,
_availability.id!,
);
}
} else {
// add an availability for the user
await widget.service.createAvailabilityForUser(
widget.userId,
await service.dataInterface.createAvailabilityForUser(
userId,
_availability,
);
}
@ -143,7 +135,8 @@ class _AvailabilityDayOverviewState extends State<AvailabilityDayOverview> {
}
var theme = Theme.of(context);
return Padding(
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
@ -274,10 +267,11 @@ class _AvailabilityDayOverviewState extends State<AvailabilityDayOverview> {
const Spacer(),
ElevatedButton(
onPressed: () async => onClickSave(),
child: Text(widget.options.translations.availabilitySave),
child: const Text(""),
),
],
),
),
);
}
}

View file

@ -0,0 +1,35 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/util/scope.dart";
/// Default base screen for any availability screen
class DefaultBaseScreen extends StatelessWidget {
/// Create a base screen
const DefaultBaseScreen({
required this.child,
super.key,
});
/// Builder as default option
static Widget builder(
BuildContext context,
VoidCallback onBack,
Widget child,
) =>
DefaultBaseScreen(child: child);
/// Content of the page
final Widget child;
@override
Widget build(BuildContext context) {
var translations = AvailabilityScope.of(context).options.translations;
return Scaffold(
appBar: AppBar(
title: Text(translations.appbarTitle),
),
body: SafeArea(
child: child,
),
);
}
}

View file

@ -0,0 +1,65 @@
import "dart:async";
import "package:flutter/material.dart";
///
class DefaultPrimaryButton extends StatelessWidget {
///
const DefaultPrimaryButton({
required this.child,
required this.onPressed,
super.key,
});
///
static Widget builder(
BuildContext context,
FutureOr<void> Function()? onPressed,
Widget child,
) =>
DefaultPrimaryButton(
onPressed: onPressed,
child: child,
);
///
final Widget child;
///
final FutureOr<void> Function()? onPressed;
@override
Widget build(BuildContext context) =>
FilledButton(onPressed: onPressed, child: child);
}
///
class DefaultTextButton extends StatelessWidget {
///
const DefaultTextButton({
required this.child,
required this.onPressed,
super.key,
});
///
static Widget builder(
BuildContext context,
FutureOr<void> Function()? onPressed,
Widget child,
) =>
DefaultTextButton(
onPressed: onPressed,
child: child,
);
///
final Widget child;
///
final FutureOr<void> Function()? onPressed;
@override
Widget build(BuildContext context) =>
TextButton(onPressed: onPressed, child: child);
}

View file

@ -0,0 +1,83 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/config/availability_options.dart";
import "package:flutter_availability/src/routes.dart";
import "package:flutter_availability/src/service/availability_service.dart";
import "package:flutter_availability/src/util/scope.dart";
/// This pushes the availability user story to the navigator stack.
Future<void> openAvailabilitiesForUser(
BuildContext context,
String userId,
AvailabilityOptions? options,
) async {
var navigator = Navigator.of(context);
await navigator.push(
AvailabilityUserStory.route(
userId,
options ?? AvailabilityOptions(),
),
);
}
///
class AvailabilityUserStory extends StatefulWidget {
///
const AvailabilityUserStory({
required this.userId,
required this.options,
super.key,
});
///
final String userId;
///
final AvailabilityOptions options;
///
static MaterialPageRoute route(String userId, AvailabilityOptions options) =>
MaterialPageRoute(
builder: (context) => AvailabilityUserStory(
userId: userId,
options: options,
),
);
@override
State<AvailabilityUserStory> createState() => _AvailabilityUserStoryState();
}
class _AvailabilityUserStoryState extends State<AvailabilityUserStory> {
late AvailabilityService _service = AvailabilityService(
userId: widget.userId,
dataInterface: widget.options.dataInterface,
);
@override
Widget build(BuildContext context) => AvailabilityScope(
userId: widget.userId,
options: widget.options,
service: _service,
child: Navigator(
onGenerateInitialRoutes: (state, route) => [
homePageRoute,
],
),
);
@override
void didUpdateWidget(covariant AvailabilityUserStory oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId ||
oldWidget.options != widget.options) {
setState(() {
_service = AvailabilityService(
userId: widget.userId,
dataInterface: widget.options.dataInterface,
);
});
}
}
}

View file

@ -1,75 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/screens/availability_day_overview.dart";
import "package:flutter_availability/src/screens/availability_overview.dart";
import "package:flutter_availability/src/service/local_service.dart";
import "package:flutter_availability/src/userstory/userstory_configuration.dart";
import "package:flutter_availability_data_interface/flutter_availability_data_interface.dart";
///
Widget availabilityNavigatorUserStory(
BuildContext context, {
AvailabiltyUserstoryConfiguration? configuration,
}) =>
_availabiltyScreenRoute(
context,
configuration ??
AvailabiltyUserstoryConfiguration(
service: LocalAvailabilityDataInterface(),
getUserId: (_) => "no-user",
),
);
Widget _availabiltyScreenRoute(
BuildContext context,
AvailabiltyUserstoryConfiguration configuration,
) =>
SafeArea(
child: Scaffold(
body: AvailabilityOverview(
service: configuration.service,
options: configuration.options,
userId: configuration.getUserId(context),
onDayClicked: (date) async => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => _avaibiltyDayOverviewRoute(
context,
configuration,
date,
null,
),
),
),
onAvailabilityClicked: (availability) async =>
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => _avaibiltyDayOverviewRoute(
context,
configuration,
availability.startDate,
availability,
),
),
),
),
),
);
Widget _avaibiltyDayOverviewRoute(
BuildContext context,
AvailabiltyUserstoryConfiguration configuration,
DateTime date,
AvailabilityModel? availability,
) =>
SafeArea(
child: Scaffold(
appBar: AppBar(),
body: AvailabilityDayOverview(
service: configuration.service,
options: configuration.options,
userId: configuration.getUserId(context),
date: date,
initialAvailability: availability,
onAvailabilitySaved: () => Navigator.of(context).pop(),
),
),
);

View file

@ -1,22 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter_availability/src/config/availability_options.dart";
import "package:flutter_availability_data_interface/flutter_availability_data_interface.dart";
///
class AvailabiltyUserstoryConfiguration {
///
const AvailabiltyUserstoryConfiguration({
required this.service,
required this.getUserId,
this.options = const AvailabilityOptions(),
});
///
final AvailabilityOptions options;
///
final AvailabilityDataInterface service;
///
final Function(BuildContext context) getUserId;
}

View file

@ -0,0 +1,29 @@
import "package:flutter/widgets.dart";
import "package:flutter_availability/src/config/availability_options.dart";
import "package:flutter_availability/src/service/availability_service.dart";
///
class AvailabilityScope extends InheritedWidget {
///
const AvailabilityScope({
required this.userId,
required this.options,
required this.service,
required super.child,
super.key,
});
///
final String userId;
///
final AvailabilityOptions options;
///
final AvailabilityService service;
@override
bool updateShouldNotify(AvailabilityScope oldWidget) =>
oldWidget.userId != userId || options != options;
///
static AvailabilityScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AvailabilityScope>()!;
}