001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.wicket.extensions.markup.html.form.datetime;
018
019import java.text.DecimalFormat;
020import java.text.NumberFormat;
021import java.time.LocalTime;
022import java.time.chrono.IsoChronology;
023import java.time.format.DateTimeFormatterBuilder;
024import java.time.format.FormatStyle;
025import java.time.temporal.ChronoField;
026import java.util.Arrays;
027import java.util.Locale;
028
029import org.apache.wicket.core.util.string.CssUtils;
030import org.apache.wicket.markup.ComponentTag;
031import org.apache.wicket.markup.html.basic.Label;
032import org.apache.wicket.markup.html.form.DropDownChoice;
033import org.apache.wicket.markup.html.form.FormComponentPanel;
034import org.apache.wicket.markup.html.form.TextField;
035import org.apache.wicket.model.IModel;
036import org.apache.wicket.model.ResourceModel;
037import org.apache.wicket.util.convert.ConversionException;
038import org.apache.wicket.util.convert.IConverter;
039import org.apache.wicket.util.convert.converter.IntegerConverter;
040import org.apache.wicket.validation.validator.RangeValidator;
041
042/**
043 * Works on a {@link LocalTime} object. Displays a field for hours and a field for minutes, and an
044 * AM/PM field. The format (12h/24h) of the hours field depends on the time format of this
045 * {@link TimeField}'s {@link Locale}, as does the visibility of the AM/PM field (see
046 * {@link TimeField#use12HourFormat}).
047 * 
048 * @author eelcohillenius
049 */
050public class TimeField extends FormComponentPanel<LocalTime>
051{
052        private static final long serialVersionUID = 1L;
053
054        public static final String HOURS_CSS_CLASS_KEY = CssUtils.key(TimeField.class, "hours");
055
056        public static final String MINUTES_CSS_CLASS_KEY = CssUtils.key(TimeField.class, "minutes");
057
058        /**
059         * Enumerated type for different ways of handling the render part of requests.
060         */
061        public enum AM_PM
062        {
063                AM, PM;
064        }
065
066        private static final IConverter<Integer> MINUTES_CONVERTER = new IntegerConverter()
067        {
068                private static final long serialVersionUID = 1L;
069
070                @Override
071                protected NumberFormat newNumberFormat(Locale locale)
072                {
073                        return new DecimalFormat("00");
074                }
075        };
076
077        // The TextField for "hours" and it's associated model object
078        private TextField<Integer> hoursField;
079
080        // The TextField for "minutes" and it's associated model object
081        private TextField<Integer> minutesField;
082
083        // The dropdown list for AM/PM and it's associated model object
084        private DropDownChoice<AM_PM> amOrPmChoice;
085
086        /**
087         * Construct.
088         * 
089         * @param id
090         *            the component id
091         */
092        public TimeField(String id)
093        {
094                this(id, null);
095        }
096
097        /**
098         * Construct.
099         * 
100         * @param id
101         *            the component id
102         * @param model
103         *            the component's model
104         */
105        public TimeField(String id, IModel<LocalTime> model)
106        {
107                super(id, model);
108
109                // Sets the type that will be used when updating the model for this component.
110                setType(LocalTime.class);
111
112                add(new Label("hoursSeparator", new ResourceModel("TimeField.hoursSeparator"))
113                {
114                        private static final long serialVersionUID = 1L;
115
116                        @Override
117                        protected void onConfigure()
118                        {
119                                super.onConfigure();
120
121                                minutesField.configure();
122
123                                setVisible(minutesField.isVisible());
124                        }
125                });
126        }
127
128        @Override
129        protected void onInitialize()
130        {
131                super.onInitialize();
132
133                // Create and add the "hours" TextField
134                add(hoursField = newHoursTextField("hours", new HoursModel(), Integer.class));
135
136                // Create and add the "minutes" TextField
137                add(minutesField = newMinutesTextField("minutes", new MinutesModel(), Integer.class));
138
139                // Create and add the "AM/PM" choice
140                add(amOrPmChoice = new DropDownChoice<AM_PM>("amOrPmChoice", new AmPmModel(),
141                        Arrays.asList(AM_PM.values())) {
142                                private static final long serialVersionUID = 1L;
143
144                        @Override
145                        protected boolean localizeDisplayValues()
146                        {
147                                return true;
148                        }
149                });
150        }
151
152        /**
153         * create a new {@link TextField} instance for hours to be added to this panel.
154         * 
155         * @param id
156         *            the component id
157         * @param model
158         *            model that should be used by the {@link TextField}
159         * @param type
160         *            the type of the text field
161         * @return a new text field instance
162         */
163        protected TextField<Integer> newHoursTextField(final String id, IModel<Integer> model,
164                Class<Integer> type)
165        {
166                TextField<Integer> hoursTextField = new TextField<Integer>(id, model, type)
167                {
168                        private static final long serialVersionUID = 1L;
169
170                        @Override
171                        protected String[] getInputTypes()
172                        {
173                                return new String[] { "number" };
174                        }
175
176                        @Override
177                        protected void onComponentTag(ComponentTag tag)
178                        {
179                                super.onComponentTag(tag);
180
181                                tag.append("class", getString(HOURS_CSS_CLASS_KEY), " ");
182
183                                tag.put("min", use12HourFormat() ? 1 : 0);
184                                tag.put("max", use12HourFormat() ? 12 : 23);
185                        }
186                };
187                hoursTextField
188                        .add(use12HourFormat() ? RangeValidator.range(1, 12) : RangeValidator.range(0, 23));
189                return hoursTextField;
190        }
191
192        /**
193         * create a new {@link TextField} instance for minutes to be added to this panel.
194         *
195         * @param id
196         *            the component id
197         * @param model
198         *            model that should be used by the {@link TextField}
199         * @param type
200         *            the type of the text field
201         * @return a new text field instance
202         */
203        protected TextField<Integer> newMinutesTextField(final String id, IModel<Integer> model,
204                Class<Integer> type)
205        {
206                TextField<Integer> minutesField = new TextField<Integer>(id, model, type)
207                {
208                        private static final long serialVersionUID = 1L;
209
210                        @Override
211                        protected IConverter<?> createConverter(Class<?> type)
212                        {
213                                if (Integer.class.isAssignableFrom(type))
214                                {
215                                        return MINUTES_CONVERTER;
216                                }
217                                return null;
218                        }
219
220                        @Override
221                        protected String[] getInputTypes()
222                        {
223                                return new String[] { "number" };
224                        }
225
226                        @Override
227                        protected void onComponentTag(ComponentTag tag)
228                        {
229                                super.onComponentTag(tag);
230
231                                tag.append("class", getString(MINUTES_CSS_CLASS_KEY), " ");
232
233                                tag.put("min", 0);
234                                tag.put("max", 59);
235                        }
236                };
237                minutesField.add(new RangeValidator<>(0, 59));
238                return minutesField;
239        }
240
241        @Override
242        public String getInput()
243        {
244                // since we override convertInput, we can let this method return a value
245                // that is just suitable for error reporting
246                return String.format("%s:%s", hoursField.getInput(), minutesField.getInput());
247        }
248
249        @Override
250        public void convertInput()
251        {
252                Integer hours = hoursField.getConvertedInput();
253                Integer minutes = minutesField.getConvertedInput();
254                AM_PM amOrPmInput = amOrPmChoice.getConvertedInput();
255
256                LocalTime localTime;
257                if (hours == null && minutes == null)
258                {
259                        localTime = null;
260                }
261                else if (hours != null && minutes != null)
262                {
263                        // Use the input to create a LocalTime object
264                        localTime = LocalTime.of(hours, minutes);
265
266                        // Adjust for halfday if needed
267                        if (use12HourFormat())
268                        {
269                                int halfday = (amOrPmInput == AM_PM.PM ? 1 : 0);
270                                localTime = localTime.with(ChronoField.AMPM_OF_DAY, halfday);
271                        }
272                }
273                else
274                {
275                        error(newValidationError(new ConversionException("Cannot parse time").setTargetType(getType())));
276                        return;
277                }
278
279                setConvertedInput(localTime);
280        }
281
282        @Override
283        protected void onConfigure()
284        {
285                super.onConfigure();
286
287                hoursField.setRequired(isRequired());
288                minutesField.setRequired(isRequired());
289
290                amOrPmChoice.setVisible(use12HourFormat());
291        }
292
293        /**
294         * Checks whether the current {@link Locale} uses the 12h or 24h time format. This method can be
295         * overridden to e.g. always use 24h format.
296         * 
297         * @return {@code true}, if the current {@link Locale} uses the 12h format.<br/>
298         *         {@code false}, otherwise
299         */
300        protected boolean use12HourFormat()
301        {
302                String pattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern(null,
303                        FormatStyle.SHORT, IsoChronology.INSTANCE, getLocale());
304                return pattern.indexOf('a') != -1 || pattern.indexOf('h') != -1
305                        || pattern.indexOf('K') != -1;
306        }
307
308        protected class HoursModel implements IModel<Integer>
309        {
310                private static final long serialVersionUID = 1L;
311
312                @Override
313                public Integer getObject()
314                {
315                        LocalTime t = TimeField.this.getModelObject();
316                        if (t == null)
317                        {
318                                return null;
319                        }
320                        return use12HourFormat() ? t.get(ChronoField.CLOCK_HOUR_OF_AMPM) : t.getHour();
321                }
322
323                @Override
324                public void setObject(Integer hour)
325                {
326                        // ignored
327                }
328        }
329
330        protected class MinutesModel implements IModel<Integer>
331        {
332                private static final long serialVersionUID = 1L;
333
334                @Override
335                public Integer getObject()
336                {
337                        LocalTime t = TimeField.this.getModelObject();
338                        return t == null ? null : t.getMinute();
339                }
340
341                @Override
342                public void setObject(Integer minute)
343                {
344                        // ignored
345                }
346        }
347
348        protected class AmPmModel implements IModel<AM_PM>
349        {
350                private static final long serialVersionUID = 1L;
351
352                @Override
353                public AM_PM getObject()
354                {
355                        LocalTime t = TimeField.this.getModelObject();
356                        int i = t == null ? 0 : t.get(ChronoField.AMPM_OF_DAY);
357                        return i == 0 ? AM_PM.AM : AM_PM.PM;
358                }
359
360                @Override
361                public void setObject(AM_PM amPm)
362                {
363                        // ignored
364                }
365        }
366}