diff --git a/README.md b/README.md index 098c3ee..ee4aacb 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,38 @@ -[![Published on Vaadin Directory](https://img.shields.io/badge/Vaadin%20Directory-published-00b4f0.svg)](https://vaadin.com/directory/component/template-add-on) -[![Stars on vaadin.com/directory](https://img.shields.io/vaadin-directory/star/template-add-on.svg)](https://vaadin.com/directory/component/template-add-on) -[![Build Status](https://jenkins.flowingcode.com/job/template-addon/badge/icon)](https://jenkins.flowingcode.com/job/template-addon) -[![Maven Central](https://img.shields.io/maven-central/v/com.flowingcode.vaadin.addons/template-addon)](https://mvnrepository.com/artifact/com.flowingcode.vaadin.addons/template-addon) -[![Javadoc](https://img.shields.io/badge/javadoc-00b4f0)](https://javadoc.flowingcode.com/artifact/com.flowingcode.vaadin.addons/template-addon) +[![Published on Vaadin Directory](https://img.shields.io/badge/Vaadin%20Directory-published-00b4f0.svg)](https://vaadin.com/directory/component/date-time-range-picker-add-on) +[![Stars on vaadin.com/directory](https://img.shields.io/vaadin-directory/star/template-add-on.svg)](https://vaadin.com/directory/component/date-time-range-picker-add-on) +[![Build Status](https://jenkins.flowingcode.com/job/template-addon/badge/icon)](https://jenkins.flowingcode.com/job/date-time-range-picker-addon) +[![Maven Central](https://img.shields.io/maven-central/v/com.flowingcode.vaadin.addons/template-addon)](https://mvnrepository.com/artifact/com.flowingcode.vaadin.addons/date-time-range-picker-addon) +[![Javadoc](https://img.shields.io/badge/javadoc-00b4f0)](https://javadoc.flowingcode.com/artifact/com.flowingcode.vaadin.addons/date-time-range-picker-addon) -# Template Add-on +# DateTimeRangePicker Add-on for Vaadin 24 -This is a template project for building new Vaadin 24 add-ons +A component to create [Time Intervals](https://en.wikipedia.org/wiki/ISO_8601#Time_intervals) (_a time period defined by start and end points_) constrained by a time frame. -## Features +You use the UI to define time and date ranges in which the time intervals should be created, and then call the API to operate them. + +As an example, you could set it to create an interval **every weekend** from **8:30 to 12:30 AM** between the +**1st and 15th of May 2025**.
Then, you can make the following queries: -* List the features of your add-on in here +1. How many intervals will be created? (4) +2. Starting from today, when will the next interval occur? +3. Is the 7th of May at 9:15 AM included in any interval? (It's not) + - How about the 5th of May at 9:00 AM? (Yes) +4. How many intervals will have passed after the 9th of May? (2) +5. How many intervals will remain after the 5th of May at 12:45? (3) + +... and more + +## Features +- Customizable selection of date, time and days. +- API to create and query time intervals. ## Online demo -[Online demo here](http://addonsv24.flowingcode.com/template) +[Online demo here](http://addonsv24.flowingcode.com/date-time-range-picker) ## Download release -[Available in Vaadin Directory](https://vaadin.com/directory/component/template-add-on) +[Available in Vaadin Directory](https://vaadin.com/directory/component/date-time-range-picker-add-on) ### Maven install @@ -27,7 +41,7 @@ Add the following dependencies in your pom.xml file: ```xml com.flowingcode.vaadin.addons - template-addon + date-time-range-picker-addon X.Y.Z ``` @@ -44,7 +58,7 @@ To see the demo, navigate to http://localhost:8080/ ## Release notes -See [here](https://github.com/FlowingCode/TemplateAddon/releases) +See [here](https://github.com/FlowingCode/date-time-range-picker-addon/releases) ## Issue tracking @@ -69,13 +83,65 @@ Then, follow these steps for creating a contribution: This add-on is distributed under Apache License 2.0. For license terms, see LICENSE.txt. -TEMPLATE_ADDON is written by Flowing Code S.A. +DateTimeRangePicker is written by Flowing Code S.A. # Developer Guide ## Getting started -Add your code samples in this section +``` + DateTimeRangePicker addon = new DateTimeRangePicker(); (1) + addon.setMinDate(LocalDate.now()); (2) + addon.setMaxDate(LocalDate.now().plusDays(15)); (2) + addon.setWeekDays(DayOfWeek.MONDAY, DayOfWeek.FRIDAY); (3) +``` +- (1) Instantiation. +- (2) Date range selection will be constraint between **today** and **fifteen** days later. +- (3) Component will have **Monday** and **Friday** selected by default. + +## Binding + +The API is exposed through the **DateTimeRange** class. + +``` + DateTimeRangePicker addon = new DateTimeRangePicker(); + Binder binder = new Binder<>(Pojo.class); (1) + binder.forField(addon).bind(Pojo::getDateTimeRange, Pojo::setDateTimeRange); (2) + binder.setBean(pojo); (3) +``` + - (1) The **Pojo** class is binded using its getter and setter methods (2). + - (2) The **DateTimeRangePicker** is binded. Its [value](https://vaadin.com/api/platform/current/com/vaadin/flow/component/HasValue.html) can be operated now. + - (3) The **DateTimeRange** instance is saved in the **Pojo** class or returned from it. + + +You can operate time intervals with the **DateTimeRange** class, which acts as a holder. + +``` + TimeInterval interval = pojo.getDateTimeRange().getNextInterval(); (1) + boolean includes = pojo.getDateTimeRange().includes(LocalDateTime.now()); (2) +``` + - (1) "Starting from today, when will the next interval occur?" + - (2) "Is today included in any interval?" + +You can also use the **TimeInterval** instance directly. + +``` + boolean includes = interval != null && interval.includes(LocalDateTime.now()); +``` + +## I18n support + +Customize a ``DateTimeRangePickerI18n`` instance and pass it to the component (1). + +``` + DateTimeRangePicker addon = new DateTimeRangePicker(); + addon.setI18n(new DateTimeRangePickerI18n() (1) + .setDatesTitle("Your custom title") + .setTimeChipsText("AM", "PM", "AM + PM") + .setTimesPlaceholder("Start time", "End time") + ); +``` + ## Special configuration when using Spring diff --git a/pom.xml b/pom.xml index 78099aa..627bce2 100644 --- a/pom.xml +++ b/pom.xml @@ -5,14 +5,14 @@ 4.0.0 com.flowingcode.vaadin.addons - template-addon + date-time-range-picker-addon 1.0.0-SNAPSHOT - Template Add-on - Template Add-on for Vaadin Flow + DateTimeRangePicker + DateTimeRangePicker Vaadin Add-on https://www.flowingcode.com/en/open-source/ - 24.4.6 + 24.4.7 4.10.0 17 17 @@ -156,6 +156,13 @@ 5.9.1 test + + + com.flowingcode.vaadin.addons + day-of-week-selector-addon + 1.0.1 + + diff --git a/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/api/DateTimeRange.java b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/api/DateTimeRange.java new file mode 100644 index 0000000..ddc5619 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/api/DateTimeRange.java @@ -0,0 +1,353 @@ +/*- + * #%L + * DateTimeRangePicker Add-on + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.datetimerangepicker.api; + +import java.io.Serializable; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Period; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * A class to operate {@link TimeInterval} instances based on defined date and time constraints
+ * + * @author Izaguirre, Ezequiel + * @see TimeInterval + */ +public class DateTimeRange implements Serializable { + + private static final DayOfWeek[] defaultWeekDays = DayOfWeek.values(); + private static final LocalTime defaultStartTime = LocalTime.MIN; + private static final LocalTime defaultEndTime = LocalTime.MAX; + + private final LocalDate startDate; + private final LocalDate endDate; + private final DayOfWeek[] weekDays = { + null, null, null, null, null, null, null + }; + private LocalTime startTime = defaultStartTime; + private LocalTime endTime = defaultEndTime; + + + public DateTimeRange(LocalDate startDate, LocalDate endDate, Set weekDays) { + this.startDate = startDate; + this.endDate = endDate; + setWeekDays(weekDays); + } + + public DateTimeRange(LocalDate startDate, LocalDate endDate) { + this(startDate, endDate, Set.of(defaultWeekDays)); + } + + public DateTimeRange(LocalDate startDate, LocalDate endDate, LocalTime startTime, LocalTime endTime, + Set weekDays) { + this(startDate, endDate, weekDays); + this.endTime = endTime; + this.startTime = startTime; + } + + public DateTimeRange(LocalDate startDate, LocalDate endDate, LocalTime startTime, LocalTime endTime) { + this(startDate, endDate, startTime, endTime, Set.of(defaultWeekDays)); + } + + /** + * Sets time boundaries for the intervals + * + * @param startTime the starting point + * @param endTime the ending point (exclusive). + */ + public void setDayDuration(LocalTime startTime, LocalTime endTime) { + this.startTime = startTime; + this.endTime = endTime; + } + + /** + * Defines on which days of the week an interval should exist + * + * @param weekDays a list of days + */ + public void setWeekDays(Set weekDays) { + DayOfWeek[] allWeekDays = DayOfWeek.values(); + + for (int i = 0; i < allWeekDays.length; i++) { + if (weekDays.contains(allWeekDays[i])) { + this.weekDays[i] = allWeekDays[i]; + } else { + this.weekDays[i] = null; + } + } + } + + /** + * Shorthand for setWeekDays with the entire week set + */ + public void setAllWeekDays() { + this.setWeekDays(Set.of(DayOfWeek.values())); + } + + /** + * Returns the days of the week when intervals exist + */ + public Set getWeekDays() { + Set days = new HashSet<>(); + for (DayOfWeek day : this.weekDays) { + if (day != null) { + days.add(day); + } + } + return days; + } + + /** + * Gets the intervals that conform to current constraints + */ + public List getIntervals() { + return generateIntervals(this.startDate.atTime(this.startTime), this.endDate.atTime(this.endTime)); + } + + /** + * Checks if the given {@link LocalDate} is between any interval + */ + public boolean includes(LocalDate date) { + int index = date.getDayOfWeek().getValue() - 1; + return this.weekDays[index] != null && insideRange(date); + } + + /** + * Checks if the given {@link LocalDateTime} is between any interval + */ + public boolean includes(LocalDateTime dateTime) { + boolean contains = false; + LocalDate date = dateTime.toLocalDate(); + if (this.includes(date)) { + TimeInterval interval = new TimeInterval( + date.atTime(startTime), + date.atTime(endTime) + ); + contains = interval.includes(dateTime); + } + + return contains; + } + + /** + * Gets the next interval that ends after given {@link LocalDate} + */ + public TimeInterval getNextInterval(LocalDate from) { + LocalDateTime dateTime = LocalDateTime.of(from, this.startTime); + return this.getNextInterval(dateTime); + } + + /** + * Gets the next interval that ends after current time + */ + public TimeInterval getNextInterval() { + return this.getNextInterval(LocalDateTime.now()); + } + + /** + * Gets the next interval that ends after given {@link LocalDateTime} + */ + public TimeInterval getNextInterval(LocalDateTime from) { + LocalDate date = from.toLocalDate(); + LocalTime time = from.toLocalTime(); + TimeInterval interval = null; + + int index = date.getDayOfWeek().getValue() - 1; + if (this.weekDays[index] == null) { + DayOfWeek nextWeekDay = getNextDay(date.getDayOfWeek()); + date = date.plusDays(daysBetween(nextWeekDay, date.getDayOfWeek())); + } + if (insideRange(date)) { + if (this.endTime.isAfter(time)) { + interval = new TimeInterval( + LocalDateTime.of(date, this.startTime), + LocalDateTime.of(date, this.endTime) + ); + } else { + DayOfWeek nextWeekDay = getNextDay(date.getDayOfWeek()); + LocalDate nextDay = date.plusDays(daysBetween(nextWeekDay, date.getDayOfWeek())); + if (insideRange(nextDay)) { + interval = new TimeInterval( + LocalDateTime.of(nextDay, this.startTime), + LocalDateTime.of(nextDay, this.endTime) + ); + } + } + } + + return interval; + } + + /** + * Gets the intervals that end after current time + */ + public List getIntervalsLeft() { + return this.getIntervalsLeft(LocalDateTime.now()); + } + + /** + * Gets the intervals that end after {@link LocalDateTime} + */ + public List getIntervalsLeft(LocalDateTime from) { + List result = new ArrayList<>(); + TimeInterval nextInterval = getNextInterval(from); + + if (nextInterval != null) { + result.addAll(generateIntervals(nextInterval.getStartDate(), this.endDate.atTime(LocalTime.MAX))); + } + + return result; + } + + /** + * Gets the intervals that end after given {@link LocalDate} + */ + public List getIntervalsLeft(LocalDate from) { + return this.getIntervalsLeft(from.atTime(LocalTime.MIN)); + } + + /** + * Gets the intervals that ended before current time + */ + public List getPastIntervals() { + return this.getPastIntervals(LocalDateTime.now()); + } + + /** + * Gets the intervals that ended before given {@link LocalDate} + */ + public List getPastIntervals(LocalDate from) { + return this.getPastIntervals(from.atTime(LocalTime.MIN)); + } + + /** + * Gets the intervals that ended before given {@link LocalDateTime} + */ + public List getPastIntervals(LocalDateTime from) { + TimeInterval nextInterval = getNextInterval(from); + + from = nextInterval == null ? this.getEndDate().atTime(LocalTime.MAX) : nextInterval.getStartDate(); + + return new ArrayList<>(generateIntervals(this.startDate.atTime(LocalTime.MIN), from)); + } + + /** + * Gets the time interval duration (or time period) + */ + public Duration getDayDuration() { + return Duration.between(startTime, endTime); + } + + /** + * Gets the amount of days between the start and (exclusive) end dates + */ + public Period getDaysSpan() { + return Period.between(startDate, endDate); + } + + /** + * Gets the start date + */ + public LocalDate getStartDate() { + return this.startDate; + } + + /** + * Gets the end date (exclusive) + */ + public LocalDate getEndDate() { + return this.endDate; + } + + /** + * Gets the {@link LocalTime} when intervals start + */ + public LocalTime getStartTime() { + return this.startTime; + } + + /** + * Gets the {@link LocalTime} when intervals end (exclusive) + */ + public LocalTime getEndTime() { + return this.endTime; + } + + // Utils + private DayOfWeek getNextDay(DayOfWeek previous) { + int index = previous.getValue() - 1; + DayOfWeek[] allWeekDays = DayOfWeek.values(); + DayOfWeek next; + do { + index = (index + 1) % allWeekDays.length; + } + while ((next = this.weekDays[index]) == null); + return next; + } + + private int daysBetween(DayOfWeek to, DayOfWeek from) { + int offset = to.getValue() - from.getValue(); + if (offset <= 0) { + offset = 7 + offset; + } + return offset; + } + + private List generateIntervals(LocalDateTime from, LocalDateTime to) { + LocalDate startDate = from.toLocalDate(); + LocalTime startTime = from.toLocalTime(); + LocalDate endDate = to.toLocalDate(); + int days = Period.between(startDate, endDate).getDays(); + int i = 0; + List entities = new ArrayList<>(); + DayOfWeek firstDay = startDate.getDayOfWeek(); + if (this.weekDays[firstDay.getValue() - 1] == null || !this.endTime.isAfter(startTime)) { + DayOfWeek nextDay = getNextDay(startDate.getDayOfWeek()); + i = daysBetween(nextDay, firstDay); + } + + while (i < days) { + LocalDate current = startDate.plusDays(i); + LocalDateTime start = LocalDateTime.of(current, this.startTime); + LocalDateTime end = LocalDateTime.of(current, this.endTime); + TimeInterval timeInterval = new TimeInterval(start, end); + entities.add(timeInterval); + + DayOfWeek lastDay = current.getDayOfWeek(); + DayOfWeek nextDay = getNextDay(current.getDayOfWeek()); + i += daysBetween(nextDay, lastDay); + } + + return entities; + } + + private boolean insideRange(LocalDate date) { + return (this.startDate.isEqual(date) || this.startDate.isBefore(date)) + && + (this.endDate.isAfter(date)); + } +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/api/TimeInterval.java b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/api/TimeInterval.java new file mode 100644 index 0000000..b1a7ae4 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/api/TimeInterval.java @@ -0,0 +1,103 @@ +/*- + * #%L + * DateTimeRangePicker Add-on + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.datetimerangepicker.api; + +import java.io.Serializable; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * A class that represents a time interval as defined by ISO 8601 + * + * @author Izaguirre, Ezequiel + */ +public class TimeInterval implements Serializable, Comparable { + + private final LocalDateTime startDate; + private final LocalDateTime endDate; + + public TimeInterval(LocalDateTime start, LocalDateTime end) { + this.startDate = start; + this.endDate = end; + } + + /** + * Returns the starting point of this interval + */ + public LocalDateTime getStartDate() { + return startDate; + } + + /** + * Returns the (exclusive) ending point of this interval + */ + public LocalDateTime getEndDate() { + return endDate; + } + + /** + * Checks whether the given {@link LocalDateTime} falls within this time interval + */ + public boolean includes(LocalDateTime date) { + boolean startCheck = startDate.isBefore(date) || startDate.equals(date); + boolean endCheck = endDate.isAfter(date); + return startCheck && endCheck; + } + + /** + * Checks whether this interval happens before the specified {@link LocalDateTime} + */ + public boolean isBefore(LocalDateTime date) { + return endDate.isBefore(date) || endDate.equals(date); + } + + /** + * Checks whether this interval happens after the specified {@link LocalDateTime} + */ + public boolean isAfter(LocalDateTime date) { + return startDate.isAfter(date); + } + + /** + * Returns the time duration (or time period) of this interval + */ + public Duration getDuration() { + return Duration.between(startDate.toLocalTime(), endDate.toLocalTime()); + } + + @Override + public int compareTo(TimeInterval o) { + return this.startDate.compareTo(o.getStartDate()); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof TimeInterval that)) { + return false; + } + return Objects.equals(startDate, that.startDate) && Objects.equals(endDate, that.endDate); + } + + @Override + public int hashCode() { + return Objects.hash(startDate, endDate); + } +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/ChipGroup.java b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/ChipGroup.java new file mode 100644 index 0000000..79ef98f --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/ChipGroup.java @@ -0,0 +1,209 @@ +/*- + * #%L + * DateTimeRangePicker Add-on + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.datetimerangepicker.ui; + +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.theme.lumo.LumoUtility.AlignItems; +import com.vaadin.flow.theme.lumo.LumoUtility.Border; +import com.vaadin.flow.theme.lumo.LumoUtility.BorderColor; +import com.vaadin.flow.theme.lumo.LumoUtility.BorderRadius; +import com.vaadin.flow.theme.lumo.LumoUtility.Display; +import com.vaadin.flow.theme.lumo.LumoUtility.FlexWrap; +import com.vaadin.flow.theme.lumo.LumoUtility.FontSize; +import com.vaadin.flow.theme.lumo.LumoUtility.FontWeight; +import com.vaadin.flow.theme.lumo.LumoUtility.Gap; +import com.vaadin.flow.theme.lumo.LumoUtility.JustifyContent; +import com.vaadin.flow.theme.lumo.LumoUtility.LineHeight; +import com.vaadin.flow.theme.lumo.LumoUtility.Margin; +import com.vaadin.flow.theme.lumo.LumoUtility.Padding; +import com.vaadin.flow.theme.lumo.LumoUtility.Padding.Horizontal; +import com.vaadin.flow.theme.lumo.LumoUtility.Padding.Vertical; +import com.vaadin.flow.theme.lumo.LumoUtility.TextColor; +import java.util.HashSet; +import java.util.Set; + +// A single selection ChipGroup +@CssImport("./styles/styles.css") +class ChipGroup extends HorizontalLayout { + + private final Set chips = new HashSet<>(); + private Chip checkedChip; + + public ChipGroup() { + addClassNames( + AlignItems.CENTER, + JustifyContent.END, + Gap.SMALL, + FlexWrap.WRAP, + Horizontal.SMALL, + Vertical.SMALL + ); + } + + public ChipGroup(Chip... chips) { + this(); + for (Chip chip : chips) { + addChip(chip); + } + } + + public Chip addChip(Chip chip) { + chips.add(chip); + chip.setParent(this); + add(chip); + return chip; + } + + public boolean deleteChip(Chip chip) { + chip.setParent(null); + remove(chip); + return chips.remove(chip); + } + + private void onChipChange(Chip chip) { + if (checkedChip != null) { + if (!checkedChip.equals(chip)) { + checkedChip.setChecked(false); + checkedChip = chip; + } else { + checkedChip = null; + } + } else { + checkedChip = chip; + } + } + + public void setReadOnly(boolean readOnly) { + chips.forEach(c -> c.setReadOnly(readOnly)); + } + + static class Chip extends HorizontalLayout { + + private final Icon checkIcon; + private boolean checked = false; + private final Span textField; + private boolean readOnly = false; + private ChipGroup parent; + private SerializableConsumer callback; + + public Chip(String text) { + addClassNames( + Horizontal.MEDIUM, + Vertical.XSMALL, + "fc-dtrp-hoverable", + AlignItems.CENTER, + BorderRadius.LARGE, + Border.ALL, + BorderColor.CONTRAST_10, + Display.INLINE_FLEX, + Gap.SMALL + ); + getStyle().setCursor("pointer"); + checkIcon = VaadinIcon.CHECK.create(); + checkIcon.setSize("14px"); + + textField = new Span(text); + textField.addClassNames( + FontSize.SMALL, + FontWeight.SEMIBOLD, + Margin.NONE, + Padding.NONE, + LineHeight.SMALL + ); + addClickListener(ev -> { + if (!readOnly) { + this.checked = !this.checked; + toggle(); + if (callback != null) { + callback.accept(this.checked); + } + if (parent != null) { + parent.onChipChange(this); + } + } + }); + + add(textField); + } + + private void toggle() { + if (checked) { + removeClassName("fc-dtrp-unselected"); + addClassName("fc-dtrp-selected"); + addComponentAsFirst(checkIcon); + } else { + removeClassName("fc-dtrp-selected"); + addClassName("fc-dtrp-unselected"); + remove(checkIcon); + } + } + + private void setParent(ChipGroup parent) { + this.parent = parent; + } + + public void onPress(SerializableConsumer onClick) { + this.callback = onClick; + } + + public void setChecked(boolean checked) { + this.checked = checked; + toggle(); + } + + public boolean isChecked() { + return checked; + } + + public String getText() { + return textField.getText(); + } + + public void setText(String text) { + textField.setText(text); + } + + public void setReadOnly(boolean readOnly) { + if(this.readOnly == readOnly) return; + this.readOnly = readOnly; + + if (readOnly) { + removeClassName("fc-dtrp-hoverable"); + removeClassName(TextColor.BODY); + addClassName(TextColor.SECONDARY); + getStyle().setCursor("default"); + getElement().getStyle().set("border-style", "dashed"); + } else { + addClassName("fc-dtrp-hoverable"); + removeClassName(TextColor.SECONDARY); + addClassName(TextColor.BODY); + getElement().getStyle().set("border-style", "solid"); + getStyle().setCursor("pointer"); + } + } + + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/Circle.java b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/Circle.java new file mode 100644 index 0000000..b710edf --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/Circle.java @@ -0,0 +1,55 @@ +/*- + * #%L + * DateTimeRangePicker Add-on + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.datetimerangepicker.ui; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.theme.lumo.LumoUtility.AlignItems; +import com.vaadin.flow.theme.lumo.LumoUtility.Display; +import com.vaadin.flow.theme.lumo.LumoUtility.JustifyContent; +import com.vaadin.flow.theme.lumo.LumoUtility.Padding.Vertical; +import com.vaadin.flow.theme.lumo.LumoUtility.Position; +import com.vaadin.flow.theme.lumo.LumoUtility.Width; + +// Indicator circle +class Circle extends Div { + + private final Div circle; + + public Circle() { + circle = new Div(); + + addClassNames( + Display.INLINE_FLEX, + AlignItems.CENTER, + JustifyContent.CENTER, + Vertical.XSMALL, + Position.ABSOLUTE, + Width.AUTO, + "fc-dtrp-circle" + ); + + add(circle); + } + + public void setColor(String background) { + circle.getStyle().setBackgroundColor(background); + } + +} \ No newline at end of file diff --git a/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/DateTimeRangePicker.java b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/DateTimeRangePicker.java new file mode 100644 index 0000000..5f77de4 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/DateTimeRangePicker.java @@ -0,0 +1,652 @@ +/*- + * #%L + * DateTimeRangePicker Add-on + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.datetimerangepicker.ui; + +import com.flowingcode.vaadin.addons.datetimerangepicker.api.DateTimeRange; +import com.flowingcode.vaadin.addons.datetimerangepicker.ui.ChipGroup.Chip; +import com.flowingcode.vaadin.addons.dayofweekselector.DayOfWeekSelector; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.customfield.CustomField; +import com.vaadin.flow.component.datepicker.DatePicker; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H5; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.component.timepicker.TimePicker; +import com.vaadin.flow.data.binder.HasValidator; +import com.vaadin.flow.data.binder.Validator; +import com.vaadin.flow.theme.lumo.LumoUtility.AlignItems; +import com.vaadin.flow.theme.lumo.LumoUtility.AlignSelf; +import com.vaadin.flow.theme.lumo.LumoUtility.Display; +import com.vaadin.flow.theme.lumo.LumoUtility.Flex; +import com.vaadin.flow.theme.lumo.LumoUtility.Gap; +import com.vaadin.flow.theme.lumo.LumoUtility.Height; +import com.vaadin.flow.theme.lumo.LumoUtility.JustifyContent; +import com.vaadin.flow.theme.lumo.LumoUtility.Margin; +import com.vaadin.flow.theme.lumo.LumoUtility.Padding; +import com.vaadin.flow.theme.lumo.LumoUtility.Padding.Bottom; +import com.vaadin.flow.theme.lumo.LumoUtility.Position; +import com.vaadin.flow.theme.lumo.LumoUtility.Width; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.Period; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * A component to create Time Intervals based on date and time constraints + * + * @author Izaguirre, Ezequiel + */ +public class DateTimeRangePicker + extends CustomField implements HasValidator { + + static final String defaultErrorMessage = "Invalid or incomplete fields remaining"; + + // Mandatory attributes for validation + DatePicker startDate; + DatePicker endDate; + TimePicker startTime; + TimePicker endTime; + DayOfWeekSelector weekDays; + + // UI-only validation attributes (should change after validation check) + SpanLine daysDivider; + SpanLine timeDivider; + Circle dateCircle; + Circle timeCircle; + Circle daysCircle; + + // Others + H5 datesTitle; + H5 daysTitle; + H5 timesTitle; + Component dateSelector; + Component daysSelector; + Component timeSelector; + Div verticalLine; + ChipGroup daysChipGroup; + Chip weekdaysChip; + Chip weekendChip; + Chip allDaysChip; + ChipGroup timeChipGroup; + Chip morningChip; + Chip afterNoonChip; + Chip allTimeChip; + List daysInitials; + + private void setUI() { + addClassNames( + Width.AUTO, + Display.INLINE, + Margin.NONE, + Padding.SMALL + ); + + HorizontalLayout rootLayout = new HorizontalLayout(); + rootLayout.addClassNames( + Padding.SMALL, + Width.FULL, + Height.FULL, + AlignItems.STRETCH, + Gap.MEDIUM + ); + + VerticalLayout mainLayout = new VerticalLayout(); + dateSelector = getDateSelectors(); + daysSelector = getDaysSelector(); + timeSelector = getTimeSelectors(); + + verticalLine = new Div(); + verticalLine.getStyle().setBackgroundColor("var(--lumo-contrast-10pct)"); + verticalLine.setMinWidth("1px"); + verticalLine.setMaxWidth("1px"); + verticalLine.setMinHeight("100%"); + verticalLine.setMaxHeight("100%"); + + mainLayout.addClassNames( + Gap.MEDIUM, + Display.INLINE_FLEX, + AlignItems.STRETCH, + Padding.NONE, + Flex.GROW + ); + + mainLayout.add(dateSelector, daysSelector, timeSelector); + rootLayout.add(verticalLine, mainLayout); + add(rootLayout); + } + + void refreshUI() { + if (this.startDate.getValue() != null && this.endDate.getValue() != null) { + Period distance = Period.between(this.startDate.getValue(), this.endDate.getValue()); + this.daysDivider.setText(formatPeriod(distance)); + } else { + this.daysDivider.setEmptyText(); + } + if (this.startTime.getValue() != null && this.endTime.getValue() != null) { + Duration duration = Duration.between(this.startTime.getValue(), this.endTime.getValue()); + this.timeDivider.setText(formatDuration(duration)); + } else { + this.timeDivider.setEmptyText(); + } + } + + // UI Components + private Component getDateSelectors() { + VerticalLayout layout = new VerticalLayout(); + layout.addClassNames(Gap.SMALL, Padding.NONE, Gap.XSMALL); + + Div headerWrapper = new Div(); + headerWrapper.addClassNames( + Display.FLEX, + AlignItems.CENTER, + Gap.SMALL, + Position.RELATIVE, // Required for the circle + Bottom.SMALL + ); + + datesTitle = new H5("Select dates range"); + + this.dateCircle = new Circle(); + + headerWrapper.add(this.dateCircle, datesTitle); + + this.startDate = new DatePicker(); + this.startDate.setPlaceholder("Start date"); + this.startDate.setClearButtonVisible(true); + + this.endDate = new DatePicker(); + this.endDate.setPlaceholder("End date"); + this.endDate.setClearButtonVisible(true); + + this.daysDivider = new SpanLine(); + + HorizontalLayout selectorLayout = new HorizontalLayout(); + selectorLayout.addClassNames(Gap.SMALL); + selectorLayout.add(this.startDate, this.daysDivider, this.endDate); + + layout.add(headerWrapper, selectorLayout); + + return layout; + + } + + private Component getDaysSelector() { + VerticalLayout layout = new VerticalLayout(); + layout.addClassNames(AlignItems.STRETCH, Bottom.NONE, Padding.NONE, Gap.XSMALL); + + daysTitle = new H5("Select days"); + + HorizontalLayout headerLayout = new HorizontalLayout(); + headerLayout.addClassNames( + AlignItems.CENTER, + Position.RELATIVE, + JustifyContent.BETWEEN + ); + + weekendChip = new Chip("Weekend"); + weekdaysChip = new Chip("Weekdays"); + allDaysChip = new Chip("All"); + + daysChipGroup = new ChipGroup( + weekendChip, + weekdaysChip, + allDaysChip + ); + + this.daysCircle = new Circle(); + + headerLayout.add(this.daysCircle, daysTitle, daysChipGroup); + + this.weekDays = new DayOfWeekSelector(); + this.weekDays.getChildren().forEach(e -> { + e.getStyle().setScale("1.15"); + } + ); + this.weekDays.addValueChangeListener(ev -> revalidate()); + this.weekDays.setFirstDayOfWeek(DayOfWeek.SUNDAY); + this.weekDays.addClassNames( + AlignSelf.CENTER, + Padding.NONE, + Margin.NONE + ); + this.daysInitials = List.of("S","M","T","W","T","F","S"); + this.weekDays.setWeekDaysShort(this.daysInitials); + + weekendChip.onPress(checked -> { + if (checked) { + this.weekDays.setValue(DayOfWeek.SUNDAY, DayOfWeek.SATURDAY); + } + this.weekDays.setReadOnly(checked); + revalidate(); + }); + + weekdaysChip.onPress(checked -> { + if (checked) { + this.weekDays.setValue( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY + ); + } + this.weekDays.setReadOnly(checked); + revalidate(); + }); + + allDaysChip.onPress(checked -> { + if (checked) { + this.weekDays.setValue( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + DayOfWeek.SATURDAY, + DayOfWeek.SUNDAY + ); + } + this.weekDays.setReadOnly(checked); + revalidate(); + }); + + layout.add(headerLayout, this.weekDays); + + return layout; + } + + private Component getTimeSelectors() { + VerticalLayout layout = new VerticalLayout(); + layout.addClassNames(AlignItems.STRETCH, Padding.NONE, Gap.XSMALL); + + timesTitle = new H5("Select times range"); + + morningChip = new Chip("Morning"); + afterNoonChip = new Chip("Afternoon"); + allTimeChip = new Chip("All"); + timeChipGroup = new ChipGroup( + morningChip, + afterNoonChip, + allTimeChip + ); + + this.startTime = new TimePicker(); + this.startTime.setPlaceholder("Start time"); + this.startTime.setStep(Duration.ofMinutes(30)); + // Sets the TimePicker to use 24-hours format + this.startTime.setLocale(Locale.FRENCH); + this.startTime.setClearButtonVisible(true); + + this.endTime = new TimePicker(); + this.endTime.setPlaceholder("End time"); + this.endTime.setStep(Duration.ofMinutes(30)); + this.endTime.setLocale(Locale.FRENCH); + this.endTime.setClearButtonVisible(true); + + morningChip.onPress(checked -> { + if (checked) { + LocalTime minDate = this.startTime.getMin() != null ? this.startTime.getMin() : LocalTime.MIN; + LocalTime maxDate = this.endTime.getMax() != null ? this.startTime.getMax() : LocalTime.MAX; + this.startTime.setValue(minDate); + this.endTime.setValue(maxDate.isBefore(LocalTime.NOON) ? maxDate : LocalTime.NOON); + this.startTime.setReadOnly(true); + this.endTime.setReadOnly(true); + revalidate(); + } + this.startTime.setReadOnly(checked); + this.endTime.setReadOnly(checked); + }); + + afterNoonChip.onPress(checked -> { + if (checked) { + LocalTime minDate = this.startTime.getMin() != null ? this.startTime.getMin() : LocalTime.MIN; + LocalTime maxDate = this.endTime.getMax() != null ? this.startTime.getMax() : LocalTime.MAX; + this.startTime.setValue(minDate.isAfter(LocalTime.NOON) ? minDate : LocalTime.NOON); + this.endTime.setValue(maxDate.isBefore(LocalTime.NOON) ? maxDate : LocalTime.NOON); + this.endTime.setValue(maxDate); + revalidate(); + } + this.startTime.setReadOnly(checked); + this.endTime.setReadOnly(checked); + }); + + allTimeChip.onPress(checked -> { + if (checked) { + LocalTime minDate = this.startTime.getMin() != null ? this.startTime.getMin() : LocalTime.MIN; + LocalTime maxDate = this.endTime.getMax() != null ? this.startTime.getMax() : LocalTime.MAX; + this.startTime.setValue(minDate); + this.endTime.setValue(maxDate); + revalidate(); + } + this.startTime.setReadOnly(checked); + this.endTime.setReadOnly(checked); + }); + + this.timeDivider = new SpanLine(); + + HorizontalLayout headerLayout = new HorizontalLayout(); + headerLayout.addClassNames(AlignItems.CENTER, Position.RELATIVE, JustifyContent.BETWEEN); + + this.timeCircle = new Circle(); + + headerLayout.add(this.timeCircle, timesTitle, timeChipGroup); + + HorizontalLayout selectorLayout = new HorizontalLayout(); + selectorLayout.addClassNames(Gap.SMALL); + selectorLayout.add(this.startTime, this.timeDivider, this.endTime); + + layout.add(headerLayout, selectorLayout); + + return layout; + } + + // Fires a revalidation when data changes + private void revalidate() { + getElement().executeJs("this.dispatchEvent(new Event('change'))"); + } + + // Custom field + @Override + protected DateTimeRange generateModelValue() { + return new DateTimeRange( + startDate.getValue(), + endDate.getValue(), + startTime.getValue(), + endTime.getValue(), + weekDays.getValue() + ); + } + + @Override + protected void setPresentationValue(DateTimeRange dateTimeRange) { + + startDate.setValue(dateTimeRange.getStartDate()); + endDate.setValue(dateTimeRange.getEndDate()); + startTime.setValue(dateTimeRange.getStartTime()); + endTime.setValue(dateTimeRange.getEndTime()); + weekDays.setValue(dateTimeRange.getWeekDays()); + + daysDivider.setText(formatPeriod(dateTimeRange.getDaysSpan())); + Duration timeDuration = dateTimeRange.getDayDuration(); + timeDivider.setText(formatDuration(timeDuration)); + } + + @Override + public Validator getDefaultValidator() { + return new DateTimeRangePickerValidator(this); + } + + // api + + /** + * Changes this component's visibility state + * + * @param visible whether this component should be visible + */ + @Override + public void setVisible(boolean visible) { + super.setVisible(visible); + this.setDatesVisible(visible); + this.setDaysVisible(visible); + this.setTimesVisible(visible); + } + + /** + * Changes this component's read-only state + * + * @param readOnly whether this component should be read-only + */ + @Override + public void setReadOnly(boolean readOnly) { + getElement().setProperty("readonly", readOnly); + this.setDaysReadOnly(readOnly); + this.setTimesReadOnly(readOnly); + this.setDatesReadOnly(readOnly); + this.timeChipGroup.setReadOnly(readOnly); + this.daysChipGroup.setReadOnly(readOnly); + } + + /** + * Sets the maximum days distance between start and end dates + * + * @param max the maximum distance measured in days + */ + public void setMaxDaysSpan(int max) { + + this.startDate.addValueChangeListener(it -> { + LocalDate current = it.getValue(); + LocalDate maxDate = current != null ? current.plusDays(max) : null; + if (max > 0) { + this.endDate.setMax(maxDate); + } + }); + + this.endDate.addValueChangeListener(it -> { + LocalDate current = it.getValue(); + LocalDate minDate = current != null ? current.minusDays(max) : null; + if (max > 0) { + this.startDate.setMin(minDate); + } + } + ); + } + + /** + * Sets the minimum start date + * + * @param date the minimum date that can be selected + */ + public void setMinDate(LocalDate date) { + this.startDate.setMin(date); + this.endDate.setMin(date); + } + + /** + * Sets the maximum end date + * + * @param date the maximum date that can be selected + */ + public void setMaxDate(LocalDate date) { + this.endDate.setMax(date); + this.startDate.setMax(date); + } + + /** + * Sets the minimum start time + * + * @param time the minimum time that can be selected + */ + public void setMinTime(LocalTime time) { + this.startTime.setMin(time); + this.endTime.setMin(time); + morningChip.setVisible(time.isBefore(LocalTime.NOON)); + } + + /** + * Sets the maximum end time + * + * @param time the maximum time that can be selected + */ + public void setMaxTime(LocalTime time) { + this.endTime.setMax(time); + this.startTime.setMax(time); + afterNoonChip.setVisible(time.isAfter(LocalTime.NOON)); + } + + /** + * Sets the selected week days + * + * @param weekDays the days that will be selected + *
note that days not included will be deselected + */ + public void setWeekDays(DayOfWeek... weekDays) { + this.weekDays.setValue(Set.of(weekDays)); + } + + /** + * Sets which day should be placed at the starting or left-most position + * + * @param weekDay the starting or left-most day + */ + public void setFirstWeekDay(DayOfWeek weekDay) { + this.weekDays.setFirstDayOfWeek(weekDay); + } + + /** + * Changes the date pickers' read-only state + * + * @param readOnly whether the date pickers should be read-only + */ + public void setDatesReadOnly(boolean readOnly) { + this.startDate.setReadOnly(readOnly); + this.endDate.setReadOnly(readOnly); + } + + /** + * Changes the date pickers' visibility state + * + * @param visible whether the date pickers should be visible + */ + public void setDatesVisible(boolean visible) { + this.dateSelector.setVisible(visible); + } + + /** + * Changes the days picker's read-only state + * + * @param readOnly whether the days picker should be read-only + */ + public void setDaysReadOnly(boolean readOnly) { + this.weekDays.setReadOnly(readOnly); + this.daysChipGroup.setReadOnly(readOnly); + } + + /** + * Changes the days picker's visibility state + * + * @param visible whether the days picker should be visible + */ + public void setDaysVisible(boolean visible) { + this.daysSelector.setVisible(visible); + } + + /** + * Changes the time pickers' read-only state + * + * @param readOnly whether the time pickers should be read-only + */ + public void setTimesReadOnly(boolean readOnly) { + this.startTime.setReadOnly(readOnly); + this.endTime.setReadOnly(readOnly); + this.timeChipGroup.setReadOnly(readOnly); + } + + /** + * Changes the time pickers' visibility state + * + * @param visible whether the time pickers should be visible + */ + public void setTimesVisible(boolean visible) { + this.timeSelector.setVisible(visible); + } + + /** + * Changes the left line indicator's visibility state + * + * @param visible whether the left indicator should be visible + */ + public void setIndicatorVisible(boolean visible) { + this.verticalLine.setVisible(visible); + this.dateCircle.setVisible(visible); + this.daysCircle.setVisible(visible); + this.timeCircle.setVisible(visible); + } + + /** + * Sets the minimum time gap for the time selection lists + * + * @param step the time difference between adjacent lists' items + */ + public void setTimeStep(Duration step) { + this.startTime.setStep(step); + this.endTime.setStep(step); + } + + /** + * Sets the time locale for the time selection lists + * + * @param locale the {@code Locale} to use for the time lists' items + */ + public void setTimeLocale(Locale locale) { + this.startTime.setLocale(locale); + this.endTime.setLocale(locale); + } + + /** + * Sets the custom text properties for internationalization purposes + * + * @param i18n instance to attach + * @see DateTimeRangePickerI18n + */ + public void setI18n(DateTimeRangePickerI18n i18n) { + i18n.component = this; + for(Runnable action : i18n.actions) { + if(action != null) action.run(); + } + } + + public DateTimeRangePicker() { + this(defaultErrorMessage); + } + + public DateTimeRangePicker(String errorMessage) { + super(); + setErrorMessage(errorMessage); + setUI(); + } + + public DateTimeRangePicker(DateTimeRange defaultValue, String errorMessage) { + super(defaultValue); + setErrorMessage(errorMessage); + setUI(); + } + + public DateTimeRangePicker(DateTimeRange defaultValue) { + this(defaultValue, defaultErrorMessage); + } + + public static String formatDuration(Duration duration) { + return String.format("%02d:%02d:%02d", + duration.toHoursPart(), + duration.toMinutesPart(), + duration.toSecondsPart() + ); + } + + protected static String formatPeriod(Period period) { + return String.format("%dD", period.getDays()); + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/DateTimeRangePickerI18n.java b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/DateTimeRangePickerI18n.java new file mode 100644 index 0000000..807cf12 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/DateTimeRangePickerI18n.java @@ -0,0 +1,259 @@ +/*- + * #%L + * DateTimeRangePicker Add-on + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.datetimerangepicker.ui; + +import com.vaadin.flow.function.SerializableRunnable; +import java.io.Serializable; +import java.time.DayOfWeek; +import java.util.List; + +/** + * A class to help internationalize {@link DateTimeRangePicker} instances + * + * @author Izaguirre, Ezequiel + */ +public class DateTimeRangePickerI18n implements Serializable { + + DateTimeRangePicker component; + final SerializableRunnable[] actions = {null, null, null, null, null, null, null, null}; + + /** + * Sets the date pickers' title + * + * @param text title for the pickers + */ + public DateTimeRangePickerI18n setDatesTitle(String text) { + SerializableRunnable action = () -> { + component.datesTitle.setText(text); + }; + actions[0] = action; + if(component != null) action.run(); + return this; + } + + /** + * Gets current date pickers' title + * + * @return date pickers' title or {@code null} if this object is not attached to a {@code DateTimeRangePicker} instance + */ + public String getDatesTitle() { + return component != null ? component.datesTitle.toString() : null; + } + + /** + * Sets the days picker's title + * + * @param text title for the days picker + */ + public DateTimeRangePickerI18n setDaysTitle(String text) { + SerializableRunnable action = () -> { + component.daysTitle.setText(text); + }; + actions[1] = action; + if(component != null) action.run(); + return this; + } + + /** + * Gets current days picker's title + * + * @return days picker's title or {@code null} if this object is not attached to a {@code DateTimeRangePicker} instance + */ + public String getDaysTitle() { + return component != null ? component.daysTitle.toString() : null; + } + + /** + * Sets the time pickers' title + * + * @param text title for the pickers + */ + public DateTimeRangePickerI18n setTimesTitle(String text) { + SerializableRunnable action = () -> { + component.timesTitle.setText(text); + }; + actions[2] = action; + if(component != null) action.run(); + return this; + } + + /** + * Gets current time pickers' title + * + * @return time pickers' title or {@code null} if this object is not attached to a {@code DateTimeRangePicker} instance + */ + public String getTimesTitle() { + return component != null ? component.timesTitle.toString() : null; + } + + /** + * Sets the time pickers' placeholder + * + * @param startTime placeholder for the start-time picker + * @param endTime placeholder for the end-time picker + */ + public DateTimeRangePickerI18n setTimesPlaceholder(String startTime, String endTime) { + SerializableRunnable action = () -> { + component.startTime.setPlaceholder(startTime); + component.endTime.setPlaceholder(endTime); + }; + actions[3] = action; + if(component != null) action.run(); + return this; + } + + /** + * Gets current time pickers' placeholder + * + * @return + * a list where the first element corresponds to the start-time picker's placeholder and the second to the end-time picker's placeholder + *

{@code null} if this object is not attached to a {@code DateTimeRangePicker} instance + */ + public List getTimesPlaceholder() { + return component != null ? List.of(component.startTime.getPlaceholder(), component.endTime.getPlaceholder()) : null; + } + + /** + * Sets the date pickers' placeholder + * + * @param startDate placeholder for the start-date picker + * @param endDate placeholder for the end-date picker + */ + public DateTimeRangePickerI18n setDatesPlaceholder(String startDate, String endDate) { + SerializableRunnable action = () -> { + component.startDate.setPlaceholder(startDate); + component.endDate.setPlaceholder(endDate); + }; + actions[4] = action; + if(component != null) action.run(); + return this; + } + + /** + * Gets current date pickers' placeholder + * + * @return + * a list where the first element corresponds to the start-date picker's placeholder and the second to the end-date picker's placeholder + *

{@code null} if this object is not attached to a {@code DateTimeRangePicker} instance + */ + public List getDatesPlaceholder() { + return component != null ? List.of(component.startDate.getPlaceholder(), component.endDate.getPlaceholder()) : null; + } + + /** + * Sets the week days picker's initials + * + * @param initials a list of initials for the 7 days of the week + *
The order of each depends on the order set on the picker + * @see DateTimeRangePicker#setFirstWeekDay(DayOfWeek) + */ + public DateTimeRangePickerI18n setDayInitials(List initials) { + SerializableRunnable action = () -> { + component.daysInitials = initials; + component.weekDays.setWeekDaysShort(initials); + }; + actions[5] = action; + if(component != null) action.run(); + return this; + } + + /** + * Gets current week days picker's initials + * + * @return + * a list of initials for the 7 days of the week. The order of each depends on the order set on the picker + *

{@code null} if this object is not attached to a {@code DateTimeRangePicker} instance + * @see DateTimeRangePicker#setFirstWeekDay(DayOfWeek) + */ + public List getDayInitials() { + return component != null ? component.daysInitials : null; + } + + /** + * Sets the time filter chips' text + * + * @param morning text for the morning-only chip + * @param afternoon text for the afternoon-only chip + * @param all text for the all-day chip + */ + public DateTimeRangePickerI18n setTimeChipsText(String morning, String afternoon, String all) { + SerializableRunnable action = () -> { + component.morningChip.setText(morning); + component.afterNoonChip.setText(afternoon); + component.allTimeChip.setText(all); + }; + actions[6] = action; + if(component != null) action.run(); + return this; + } + + /** + * Gets current time filter chips' text + * + * @return + * a list where the first element corresponds to the morning-only chip's text, + * the second to the afternoon-only chip's text and the third to the all-day chip's text + * + *

{@code null} if this object is not attached to a {@code DateTimeRangePicker} instance + */ + public List getTimeChipsText() { + return component != null ? List.of( + component.morningChip.getText(), + component.afterNoonChip.getText(), + component.allTimeChip.getText() + ) : null; + } + + /** + * Sets the days filter chips' text + * + * @param weekdays text for the monday-to-friday chip + * @param weekend text for the weekends-only chip + * @param all text for the all days chip + */ + public DateTimeRangePickerI18n setDaysChipsText(String weekdays, String weekend, String all) { + SerializableRunnable action = () -> { + component.weekdaysChip.setText(weekdays); + component.weekendChip.setText(weekend); + component.allDaysChip.setText(all); + }; + actions[7] = action; + if(component != null) action.run(); + return this; + } + + /** + * Gets current days filter chips' text + * + * @return + * a list where the first element corresponds to the monday-to-friday chip's text, + * the second to the weekends-only chip's text and the third to the all-days chip's text + * + *

{@code null} if this object is not attached to a {@code DateTimeRangePicker} instance + */ + public List getDaysChipsText() { + return component != null ? List.of( + component.weekdaysChip.getText(), + component.weekendChip.getText(), + component.allDaysChip.getText() + ) : null; + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/DateTimeRangePickerValidator.java b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/DateTimeRangePickerValidator.java new file mode 100644 index 0000000..efa13b7 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/DateTimeRangePickerValidator.java @@ -0,0 +1,120 @@ +/*- + * #%L + * DateTimeRangePicker Add-on + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.datetimerangepicker.ui; + +import com.flowingcode.vaadin.addons.datetimerangepicker.api.DateTimeRange; +import com.vaadin.flow.data.binder.ValidationResult; +import com.vaadin.flow.data.binder.Validator; +import com.vaadin.flow.data.binder.ValueContext; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Set; + +public class DateTimeRangePickerValidator implements Validator { + + private final DateTimeRangePicker model; + + private static final String SUCCESS_COLOR = "var(--lumo-primary-color)"; + private static final String ERROR_COLOR = "var(--lumo-error-color)"; + + public DateTimeRangePickerValidator(DateTimeRangePicker model) { + this.model = model; + setManualValidation(); + } + + private void setManualValidation() { + model.startTime.setManualValidation(true); + model.endTime.setManualValidation(true); + model.startDate.setManualValidation(true); + model.endDate.setManualValidation(true); + model.weekDays.setManualValidation(true); + } + + @Override + public ValidationResult apply(DateTimeRange data, ValueContext valueContext) { + ValidationResult result; + if ( + data != null && + dateValidation(data.getStartDate(), data.getEndDate()) + & timeValidation(data.getStartTime(), data.getEndTime()) //Single & is intentional + & daysValidation(data.getWeekDays()) + ) { + result = ValidationResult.ok(); + } else { + result = ValidationResult.error(model.getErrorMessage()); + } + + this.model.refreshUI(); + return result; + } + + private boolean dateValidation(LocalDate start, LocalDate end) { + boolean nullCheck = (start != null && end != null); + if (!nullCheck) { + model.startDate.setInvalid(false); + model.endDate.setInvalid(false); + } else { + boolean dateCheck = start.isBefore(end); + if (!dateCheck) { + model.startDate.setInvalid(true); + model.endDate.setInvalid(true); + model.dateCircle.setColor(ERROR_COLOR); + } else { + model.startDate.setInvalid(false); + model.endDate.setInvalid(false); + model.dateCircle.setColor(SUCCESS_COLOR); + return true; + } + } + return false; + } + + private boolean timeValidation(LocalTime start, LocalTime end) { + boolean nullCheck = (start != null && end != null); + if (!nullCheck) { + model.startTime.setInvalid(false); + model.endTime.setInvalid(false); + } else { + boolean dateCheck = start.isBefore(end); + if (!dateCheck) { + model.startTime.setInvalid(true); + model.endTime.setInvalid(true); + model.timeCircle.setColor(ERROR_COLOR); + } else { + model.startTime.setInvalid(false); + model.endTime.setInvalid(false); + model.timeCircle.setColor(SUCCESS_COLOR); + return true; + } + } + return false; + } + + private boolean daysValidation(Set weekDays) { + boolean check = weekDays != null && !weekDays.isEmpty(); + if (check) { + model.daysCircle.setColor(SUCCESS_COLOR); + return true; + } else { + return false; + } + } +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/SpanLine.java b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/SpanLine.java new file mode 100644 index 0000000..5871dec --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/datetimerangepicker/ui/SpanLine.java @@ -0,0 +1,89 @@ +/*- + * #%L + * DateTimeRangePicker Add-on + * %% + * Copyright (C) 2025 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.datetimerangepicker.ui; + +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.theme.lumo.LumoUtility.AlignItems; +import com.vaadin.flow.theme.lumo.LumoUtility.Display; +import com.vaadin.flow.theme.lumo.LumoUtility.FlexDirection; +import com.vaadin.flow.theme.lumo.LumoUtility.FontSize; +import com.vaadin.flow.theme.lumo.LumoUtility.FontWeight; +import com.vaadin.flow.theme.lumo.LumoUtility.JustifyContent; +import com.vaadin.flow.theme.lumo.LumoUtility.LineHeight; +import com.vaadin.flow.theme.lumo.LumoUtility.Margin; +import com.vaadin.flow.theme.lumo.LumoUtility.Padding; +import com.vaadin.flow.theme.lumo.LumoUtility.Padding.Bottom; +import com.vaadin.flow.theme.lumo.LumoUtility.Padding.Horizontal; +import com.vaadin.flow.theme.lumo.LumoUtility.TextAlignment; +import com.vaadin.flow.theme.lumo.LumoUtility.Width; + +// Horizontal line between pickers +class SpanLine extends Div { + + private final Paragraph text; + private static final String EMPTY = "\u200E"; + + public SpanLine() { + + addClassNames( + Display.INLINE_FLEX, + FlexDirection.COLUMN, + AlignItems.CENTER, + Bottom.XSMALL, + Width.AUTO, + Horizontal.SMALL, + JustifyContent.END + ); + setMinHeight("var(--lumo-size-m)"); + setMaxHeight("var(--lumo-size-m)"); + + Div line = new Div(); + line.setMinWidth("4.5rem"); + line.setMaxHeight("1px"); + line.setMinHeight("1px"); + line.getElement().getStyle().setBackgroundColor("var(--lumo-contrast-10pct)"); + + this.text = new Paragraph(EMPTY); + text.addClassNames( + TextAlignment.CENTER, + FontSize.SMALL, + Padding.NONE, + Margin.NONE, + FontWeight.SEMIBOLD, + LineHeight.SMALL + ); + text.getStyle().setColor("var(--lumo-secondary-text-color)"); + + add(line, text); + + } + + public void setText(String text) { + if (text.isEmpty()) { + text = EMPTY; + } + this.text.setText(text); + } + + public void setEmptyText() { + this.text.setText(EMPTY); + } +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/template/TemplateAddon.java b/src/main/java/com/flowingcode/vaadin/addons/template/TemplateAddon.java deleted file mode 100644 index c9ec694..0000000 --- a/src/main/java/com/flowingcode/vaadin/addons/template/TemplateAddon.java +++ /dev/null @@ -1,32 +0,0 @@ -/*- - * #%L - * Template Add-on - * %% - * Copyright (C) 2024 Flowing Code - * %% - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * #L% - */ - -package com.flowingcode.vaadin.addons.template; - -import com.vaadin.flow.component.Tag; -import com.vaadin.flow.component.dependency.JsModule; -import com.vaadin.flow.component.dependency.NpmPackage; -import com.vaadin.flow.component.html.Div; - -@SuppressWarnings("serial") -@NpmPackage(value = "@polymer/paper-input", version = "3.2.1") -@JsModule("@polymer/paper-input/paper-input.js") -@Tag("paper-input") -public class TemplateAddon extends Div {} diff --git a/src/main/resources/META-INF/VAADIN/package.properties b/src/main/resources/META-INF/VAADIN/package.properties index c66616f..b2b7e21 100644 --- a/src/main/resources/META-INF/VAADIN/package.properties +++ b/src/main/resources/META-INF/VAADIN/package.properties @@ -1 +1 @@ -vaadin.allowed-packages=com.flowingcode +vaadin.allowed-packages=com.flowingcode \ No newline at end of file diff --git a/src/main/resources/META-INF/frontend/styles/static_addon_styles b/src/main/resources/META-INF/frontend/styles/static_addon_styles deleted file mode 100644 index c2a6ed1..0000000 --- a/src/main/resources/META-INF/frontend/styles/static_addon_styles +++ /dev/null @@ -1 +0,0 @@ -Place add-on shareable styles in this folder \ No newline at end of file diff --git a/src/main/resources/META-INF/frontend/styles/styles.css b/src/main/resources/META-INF/frontend/styles/styles.css new file mode 100644 index 0000000..1fb19ea --- /dev/null +++ b/src/main/resources/META-INF/frontend/styles/styles.css @@ -0,0 +1,25 @@ + +.fc-dtrp-circle { + left : calc((var(--lumo-space-m) + 12px - 6px) * -1); + background: var(--lumo-base-color); + + div { + border-radius: 50%; + background: var(--lumo-primary-color); + min-height: 11px; + max-height: 11px; + min-width: 11px; + max-width: 11px; + } +} + +.fc-dtrp-hoverable:hover, .fc-dtrp-selected { + background: var(--lumo-primary-color-50pct); + color: var(--lumo-primary-contrast-color); +} + +.fc-dtrp-unselected { + background : initial +} + + diff --git a/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java new file mode 100644 index 0000000..4be9fe7 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/AppShellConfiguratorImpl.java @@ -0,0 +1,9 @@ +package com.flowingcode.vaadin.addons; + +import com.vaadin.flow.component.page.AppShellConfigurator; +import com.vaadin.flow.theme.Theme; + +@Theme("dtrp") +public class AppShellConfiguratorImpl implements AppShellConfigurator { + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/BinderDemo.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/BinderDemo.java new file mode 100644 index 0000000..72d4dfa --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/BinderDemo.java @@ -0,0 +1,147 @@ +package com.flowingcode.vaadin.addons.datetimerangepicker; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.datetimerangepicker.api.DateTimeRange; +import com.flowingcode.vaadin.addons.datetimerangepicker.api.TimeInterval; +import com.flowingcode.vaadin.addons.datetimerangepicker.ui.DateTimeRangePicker; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.dataview.GridListDataView; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.notification.Notification.Position; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.data.binder.Binder; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.theme.lumo.LumoUtility.AlignItems; +import com.vaadin.flow.theme.lumo.LumoUtility.Margin.Horizontal; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +@PageTitle("Binding") +@SuppressWarnings("serial") +@Route(value = "dtrp/binder", layout = DateTimeRangePickerTabbedView.class) +@DemoSource +public class BinderDemo extends VerticalLayout { + + private final Button dateButton = new Button("Show dates range"); + private final Button daysButton = new Button("Show days"); + private final Button timeButton = new Button("Show times range"); + private final Button interButton = new Button("Update intervals"); + private final Grid grid = new Grid<>(TimeInterval.class, false); + private final List intervals = new ArrayList<>(); + + /* + DateTimeRangePicker::getValue returns a DateTimeRange instance. + You operate TimeInterval instances using that class. + TimeInterval represents a time interval (ISO 8601), defined by start and end points. + */ + public BinderDemo() { + setSizeFull(); + addClassNames(AlignItems.CENTER); + + // Component creation + DateTimeRangePicker addon = new DateTimeRangePicker(); + // Distance between start and end dates is at most 30 days + addon.setMaxDaysSpan(30); + + // An object with getter/setter for DateTimeRange + Pojo pojo = new Pojo(); + Binder binder = new Binder<>(Pojo.class); + binder.forField(addon) + .bind(Pojo::getDateTimeRange, Pojo::setDateTimeRange); + binder.setBean(pojo); + + binder.addStatusChangeListener(ev -> { + boolean isValid = binder.isValid(); + dateButton.setEnabled(isValid); + daysButton.setEnabled(isValid); + timeButton.setEnabled(isValid); + interButton.setEnabled(isValid); + + }); + + Grid.Column firstCol = grid.addColumn(i -> i.getStartDate().getDayOfWeek()).setHeader("Week day") + .setSortable(true); + grid.addColumn(TimeInterval::getStartDate).setHeader("Start") + .setSortable(true); + grid.addColumn(TimeInterval::getEndDate).setHeader("End").setSortable(true); + // You can use this function to get a Duration formatted as hh:mm:ss + grid.addColumn(i -> DateTimeRangePicker.formatDuration(i.getDuration())).setHeader("Duration"); + + GridListDataView dataView = grid.setItems(intervals); + dataView.addItemCountChangeListener(c -> firstCol.setFooter("Total: " + c.getItemCount())); + grid.setWidth("75%"); + grid.addClassName(Horizontal.AUTO); + + HorizontalLayout buttonLayout = new HorizontalLayout(); + buttonLayout.setAlignItems(Alignment.CENTER); + buttonLayout.add(dateButton, daysButton, timeButton); + + dateButton.addClickListener(ev -> { + DateTimeRange result = pojo.getDateTimeRange(); + LocalDate start = result.getStartDate(); + LocalDate end = result.getEndDate(); + Notification.show( + String.format("From %s %s to: %s %s (exclusive)", + start.getDayOfWeek(), + start, + end.getDayOfWeek(), + end + ), + 5000, Position.BOTTOM_CENTER + ); + }); + dateButton.setEnabled(false); + + daysButton.addClickListener(ev -> { + DateTimeRange result = pojo.getDateTimeRange(); + Notification.show(result.getWeekDays().toString(), + 5000, Position.BOTTOM_CENTER + ); + }); + daysButton.setEnabled(false); + + timeButton.addClickListener(ev -> { + DateTimeRange result = pojo.getDateTimeRange(); + LocalTime start = result.getStartTime(); + LocalTime end = result.getEndTime(); + Notification.show( + String.format("From %s to: %s (exclusive)", + start, + end + ), + 5000, Position.BOTTOM_CENTER + ); + }); + timeButton.setEnabled(false); + + interButton.addClickListener(ev -> { + intervals.clear(); + intervals.addAll(pojo.getDateTimeRange().getIntervals()); + dataView.refreshAll(); + }); + interButton.setEnabled(false); + + add(addon, buttonLayout, interButton, grid); + + } + + private static class Pojo { + + private DateTimeRange dateTimeRange; + + public DateTimeRange getDateTimeRange() { + return dateTimeRange; + } + + public void setDateTimeRange(DateTimeRange dateTimeRange) { + this.dateTimeRange = dateTimeRange; + } + } + + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/ComponentDemo.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/ComponentDemo.java new file mode 100644 index 0000000..1b68868 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/ComponentDemo.java @@ -0,0 +1,24 @@ +package com.flowingcode.vaadin.addons.datetimerangepicker; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.datetimerangepicker.ui.DateTimeRangePicker; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.theme.lumo.LumoUtility.AlignItems; + +@PageTitle("Basic") +@SuppressWarnings("serial") +@Route(value = "dtrp/basic", layout = DateTimeRangePickerTabbedView.class) +@DemoSource +public class ComponentDemo extends VerticalLayout { + + public ComponentDemo() { + setSizeFull(); + addClassNames(AlignItems.CENTER); + + // Component creation + DateTimeRangePicker addon = new DateTimeRangePicker(); + add(addon); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/ConstrainedDemo.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/ConstrainedDemo.java new file mode 100644 index 0000000..232c37d --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/ConstrainedDemo.java @@ -0,0 +1,38 @@ +package com.flowingcode.vaadin.addons.datetimerangepicker; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.datetimerangepicker.ui.DateTimeRangePicker; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.theme.lumo.LumoUtility.AlignItems; +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalTime; + +@PageTitle("Constrained") +@SuppressWarnings("serial") +@Route(value = "dtrp/constrained", layout = DateTimeRangePickerTabbedView.class) +@DemoSource +public class ConstrainedDemo extends VerticalLayout { + + public ConstrainedDemo() { + setSizeFull(); + addClassNames(AlignItems.CENTER); + + // Component creation + DateTimeRangePicker addon = new DateTimeRangePicker(); + addon.setMinDate(LocalDate.now()); + addon.setMaxDate(LocalDate.now().plusDays(15)); + addon.setWeekDays(DayOfWeek.MONDAY, DayOfWeek.FRIDAY); + addon.setDaysReadOnly(true); + addon.setTimeStep(Duration.ofMinutes(15)); + addon.setMinTime(LocalTime.of(13, 30)); + addon.setMaxTime(LocalTime.of(20, 0)); + + add(addon); + + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/DateTimeRangePickerTabbedView.java similarity index 71% rename from src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemoView.java rename to src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/DateTimeRangePickerTabbedView.java index 1954535..094c4c3 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/DateTimeRangePickerTabbedView.java @@ -17,7 +17,7 @@ * limitations under the License. * #L% */ -package com.flowingcode.vaadin.addons.template; +package com.flowingcode.vaadin.addons.datetimerangepicker; import com.flowingcode.vaadin.addons.DemoLayout; import com.flowingcode.vaadin.addons.GithubLink; @@ -27,12 +27,15 @@ @SuppressWarnings("serial") @ParentLayout(DemoLayout.class) -@Route("template") -@GithubLink("https://github.com/FlowingCode/AddonStarter24") -public class TemplateDemoView extends TabbedDemo { +@Route("datetimerange") +@GithubLink("https://github.com/FlowingCode/DateTimeRangeSelector") +public class DateTimeRangePickerTabbedView extends TabbedDemo { - public TemplateDemoView() { - addDemo(TemplateDemo.class); + public DateTimeRangePickerTabbedView() { + addDemo(ComponentDemo.class); + addDemo(BinderDemo.class); + addDemo(StatesDemo.class); + addDemo(ConstrainedDemo.class); setSizeFull(); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/DemoView.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/DemoView.java similarity index 89% rename from src/test/java/com/flowingcode/vaadin/addons/template/DemoView.java rename to src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/DemoView.java index a600c9d..7ec50d9 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/DemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/DemoView.java @@ -18,7 +18,7 @@ * #L% */ -package com.flowingcode.vaadin.addons.template; +package com.flowingcode.vaadin.addons.datetimerangepicker; import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.router.BeforeEnterEvent; @@ -31,6 +31,6 @@ public class DemoView extends VerticalLayout implements BeforeEnterObserver { @Override public void beforeEnter(BeforeEnterEvent event) { - event.forwardTo(TemplateDemoView.class); + event.forwardTo(DateTimeRangePickerTabbedView.class); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/StatesDemo.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/StatesDemo.java new file mode 100644 index 0000000..f73f76d --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/StatesDemo.java @@ -0,0 +1,89 @@ +package com.flowingcode.vaadin.addons.datetimerangepicker; + +import com.flowingcode.vaadin.addons.datetimerangepicker.ui.DateTimeRangePickerI18n; +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.datetimerangepicker.ui.DateTimeRangePicker; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.theme.lumo.LumoUtility.AlignItems; +import java.time.DayOfWeek; + +@PageTitle("States") +@SuppressWarnings("serial") +@Route(value = "dtrp/states", layout = DateTimeRangePickerTabbedView.class) +@DemoSource +public class StatesDemo extends VerticalLayout { + + private boolean indicator = true; + private final boolean[] readOnly = {false, false, false}; + private final boolean[] visible = {true, true, true}; + + public StatesDemo() { + setSizeFull(); + addClassNames(AlignItems.CENTER); + + // Component creation + DateTimeRangePicker addon = new DateTimeRangePicker(); + // Set the first or leftmost day + addon.setFirstWeekDay(DayOfWeek.THURSDAY); + addon.setI18n(new DateTimeRangePickerI18n() + .setDatesTitle("Custom date title") + .setTimeChipsText("AM", "PM", "AM + PM") + .setTimesPlaceholder("Begin", "End") + ); + + VerticalLayout buttonLayout = new VerticalLayout(); + buttonLayout.setAlignItems(Alignment.CENTER); + HorizontalLayout readOnlyLayout = new HorizontalLayout(); + readOnlyLayout.setAlignItems(Alignment.CENTER); + HorizontalLayout visibleLayout = new HorizontalLayout(); + visibleLayout.setAlignItems(Alignment.CENTER); + + Button indicatorButton = new Button("Toggle indicator", ev -> + { + indicator = !indicator; + addon.setIndicatorVisible(indicator); + }); + + for (int i = 0; i < 3; i++) { + final int index = i; + readOnlyLayout.add( + new Button("Toggle " + (i == 0 ? "dates" : i == 1 ? "days" : "times") + " read only", + ev -> { + readOnly[index] = !readOnly[index]; + if (index == 0) { + addon.setDatesReadOnly(readOnly[index]); + } else if (index == 1) { + addon.setDaysReadOnly(readOnly[index]); + } else { + addon.setTimesReadOnly(readOnly[index]); + } + } + ) + ); + visibleLayout.add( + new Button("Toggle " + (i == 0 ? "dates" : i == 1 ? "days" : "times") + " visible", + ev -> { + visible[index] = !visible[index]; + if (index == 0) { + addon.setDatesVisible(visible[index]); + } else if (index == 1) { + addon.setDaysVisible(visible[index]); + } else { + addon.setTimesVisible(visible[index]); + } + } + ) + ); + } + + buttonLayout.add(indicatorButton, readOnlyLayout, visibleLayout); + + add(addon, buttonLayout); + + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/it/AbstractViewTest.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/it/AbstractViewTest.java similarity index 98% rename from src/test/java/com/flowingcode/vaadin/addons/template/it/AbstractViewTest.java rename to src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/it/AbstractViewTest.java index 1f7749b..d6f8d66 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/it/AbstractViewTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/it/AbstractViewTest.java @@ -18,7 +18,7 @@ * #L% */ -package com.flowingcode.vaadin.addons.template.it; +package com.flowingcode.vaadin.addons.datetimerangepicker.it; import com.vaadin.testbench.ScreenshotOnFailureRule; import com.vaadin.testbench.TestBench; diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/it/ViewIT.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/it/ViewIT.java similarity index 97% rename from src/test/java/com/flowingcode/vaadin/addons/template/it/ViewIT.java rename to src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/it/ViewIT.java index 0e5f164..af82520 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/it/ViewIT.java +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/it/ViewIT.java @@ -18,7 +18,7 @@ * #L% */ -package com.flowingcode.vaadin.addons.template.it; +package com.flowingcode.vaadin.addons.datetimerangepicker.it; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; diff --git a/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/test/DateTimeRangeTest.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/test/DateTimeRangeTest.java new file mode 100644 index 0000000..f8d3684 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/test/DateTimeRangeTest.java @@ -0,0 +1,109 @@ +package com.flowingcode.vaadin.addons.datetimerangepicker.test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import com.flowingcode.vaadin.addons.datetimerangepicker.api.DateTimeRange; +import com.flowingcode.vaadin.addons.datetimerangepicker.api.TimeInterval; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Set; +import org.junit.Test; + +public class DateTimeRangeTest { + + @Test + public void testRanges() { + // Monday 7 to Monday 21 (exclusive) - 12:00 to 20:30 (exclusive) + LocalDate startDate = LocalDate.of(2025, 4, 7); + LocalDate endDate = LocalDate.of(2025, 4, 21); + LocalTime startTime = LocalTime.NOON; + LocalTime endTime = LocalTime.of(20, 30); + + DateTimeRange dtr = new DateTimeRange( + startDate, + endDate, + startTime, + endTime, + Set.of(DayOfWeek.MONDAY, DayOfWeek.FRIDAY) + ); + + assertThat(dtr.includes(startDate), equalTo(true)); + assertThat(dtr.includes(startDate.plusDays(4)), equalTo(true)); + assertThat(dtr.includes(startDate.plusDays(7)), equalTo(true)); + assertThat(dtr.includes(startDate.plusDays(11)), equalTo(true)); + assertThat(dtr.includes(startDate.plusDays(14)), equalTo(false)); + assertThat(dtr.includes(startDate.plusDays(18)), equalTo(false)); + assertThat(dtr.includes(startDate.plusDays(3)), equalTo(false)); + assertThat(dtr.includes(endDate), equalTo(false)); + + assertThat(dtr.includes(startDate.atTime(startTime)), equalTo(true)); + assertThat(dtr.includes(startDate.atTime(LocalTime.of(13, 45))), equalTo(true)); + assertThat(dtr.includes(startDate.atTime(LocalTime.MIN)), equalTo(false)); + assertThat(dtr.includes(startDate.atTime(LocalTime.MAX)), equalTo(false)); + assertThat(dtr.includes(startDate.atTime(LocalTime.of(20, 30))), equalTo(false)); + + assertThat(dtr.getWeekDays().contains(DayOfWeek.MONDAY), equalTo(true)); + assertThat(dtr.getWeekDays().contains(DayOfWeek.FRIDAY), equalTo(true)); + assertThat(dtr.getWeekDays().contains(DayOfWeek.THURSDAY), equalTo(false)); + assertThat(dtr.getWeekDays().contains(DayOfWeek.SATURDAY), equalTo(false)); + } + + @Test + public void testIntervals() { + // Monday 7 to Monday 21 (exclusive) - 12:00 to 20:30 (exclusive) + LocalDate startDate = LocalDate.of(2025, 4, 7); + LocalDate endDate = LocalDate.of(2025, 4, 21); + LocalTime startTime = LocalTime.NOON; + LocalTime endTime = LocalTime.of(20, 30); + + DateTimeRange dtr = new DateTimeRange( + startDate, + endDate, + startTime, + endTime, + Set.of(DayOfWeek.MONDAY, DayOfWeek.FRIDAY) + ); + + assertThat(dtr.getIntervals().size(), equalTo(4)); + assertThat(dtr.getNextInterval(startDate), equalTo(new TimeInterval( + startDate.atTime(startTime), + startDate.atTime(endTime)) + ) + ); + assertThat(dtr.getNextInterval(startDate.atTime(LocalTime.MIN)), equalTo(new TimeInterval( + startDate.atTime(startTime), + startDate.atTime(endTime)) + ) + ); + assertThat(dtr.getNextInterval(startDate.atTime(LocalTime.NOON)), equalTo(new TimeInterval( + startDate.atTime(startTime), + startDate.atTime(endTime)) + ) + ); + assertThat(dtr.getNextInterval(startDate.atTime(endTime)), equalTo(new TimeInterval( + startDate.plusDays(4).atTime(startTime), + startDate.plusDays(4).atTime(endTime)) + ) + ); + assertThat(dtr.getNextInterval(startDate.atTime(LocalTime.MAX)), equalTo(new TimeInterval( + startDate.plusDays(4).atTime(startTime), + startDate.plusDays(4).atTime(endTime)) + ) + ); + assertThat(dtr.getNextInterval(endDate), equalTo(null)); + assertThat(dtr.getNextInterval(startDate.plusDays(19)), equalTo(null)); + + assertThat(dtr.getIntervalsLeft(startDate).size(), equalTo(4)); + assertThat(dtr.getIntervalsLeft(startDate.atTime(endTime)).size(), equalTo(3)); + assertThat(dtr.getIntervalsLeft(startDate.plusDays(5)).size(), equalTo(2)); + assertThat(dtr.getIntervalsLeft(startDate.plusDays(19)).size(), equalTo(0)); + + assertThat(dtr.getPastIntervals(startDate).size(), equalTo(0)); + assertThat(dtr.getPastIntervals(startDate.atTime(endTime)).size(), equalTo(1)); + assertThat(dtr.getPastIntervals(startDate.plusDays(5)).size(), equalTo(2)); + assertThat(dtr.getPastIntervals(startDate.plusDays(19)).size(), equalTo(4)); + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/test/SerializationTest.java similarity index 88% rename from src/test/java/com/flowingcode/vaadin/addons/template/test/SerializationTest.java rename to src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/test/SerializationTest.java index 1ee78c3..a851a56 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/template/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/datetimerangepicker/test/SerializationTest.java @@ -17,9 +17,9 @@ * limitations under the License. * #L% */ -package com.flowingcode.vaadin.addons.template.test; +package com.flowingcode.vaadin.addons.datetimerangepicker.test; -import com.flowingcode.vaadin.addons.template.TemplateAddon; +import com.flowingcode.vaadin.addons.datetimerangepicker.ui.DateTimeRangePicker; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -44,7 +44,7 @@ private void testSerializationOf(Object obj) throws IOException, ClassNotFoundEx @Test public void testSerialization() throws ClassNotFoundException, IOException { try { - testSerializationOf(new TemplateAddon()); + testSerializationOf(new DateTimeRangePicker()); } catch (Exception e) { Assert.fail("Problem while testing serialization: " + e.getMessage()); } diff --git a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemo.java b/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemo.java deleted file mode 100644 index 5f6e6ee..0000000 --- a/src/test/java/com/flowingcode/vaadin/addons/template/TemplateDemo.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.flowingcode.vaadin.addons.template; - -import com.flowingcode.vaadin.addons.demo.DemoSource; -import com.vaadin.flow.component.html.Div; -import com.vaadin.flow.router.PageTitle; -import com.vaadin.flow.router.Route; - -@DemoSource -@PageTitle("Template Add-on Demo") -@SuppressWarnings("serial") -@Route(value = "demo", layout = TemplateDemoView.class) -public class TemplateDemo extends Div { - - public TemplateDemo() { - add(new TemplateAddon()); - } -} diff --git a/src/test/resources/META-INF/frontend/styles/shared-styles.css b/src/test/resources/META-INF/frontend/styles/shared-styles.css deleted file mode 100644 index 6680e2d..0000000 --- a/src/test/resources/META-INF/frontend/styles/shared-styles.css +++ /dev/null @@ -1 +0,0 @@ -/*Demo styles*/ \ No newline at end of file diff --git a/src/test/resources/META-INF/frontend/themes/dtrp/theme.json b/src/test/resources/META-INF/frontend/themes/dtrp/theme.json new file mode 100644 index 0000000..b007ffd --- /dev/null +++ b/src/test/resources/META-INF/frontend/themes/dtrp/theme.json @@ -0,0 +1,3 @@ +{ + "lumoImports" : [ "typography", "color", "spacing", "badge", "utility" ] +} diff --git a/test b/test new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test @@ -0,0 +1 @@ +