001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.geoimage;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.BorderLayout;
007import java.awt.Component;
008import java.awt.Dimension;
009import java.awt.GridBagConstraints;
010import java.awt.GridBagLayout;
011import java.text.DateFormat;
012import java.text.ParseException;
013import java.text.SimpleDateFormat;
014import java.time.Instant;
015import java.util.ArrayList;
016import java.util.Collections;
017import java.util.Date;
018import java.util.List;
019import java.util.TimeZone;
020import java.util.concurrent.TimeUnit;
021
022import javax.swing.AbstractListModel;
023import javax.swing.JButton;
024import javax.swing.JCheckBox;
025import javax.swing.JFileChooser;
026import javax.swing.JLabel;
027import javax.swing.JList;
028import javax.swing.JPanel;
029import javax.swing.JScrollPane;
030import javax.swing.ListSelectionModel;
031
032import org.openstreetmap.josm.actions.DiskAccessAction;
033import org.openstreetmap.josm.data.gpx.GpxTimezone;
034import org.openstreetmap.josm.gui.ExtendedDialog;
035import org.openstreetmap.josm.gui.io.importexport.ImageImporter;
036import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
037import org.openstreetmap.josm.gui.widgets.JosmComboBox;
038import org.openstreetmap.josm.gui.widgets.JosmTextField;
039import org.openstreetmap.josm.spi.preferences.Config;
040import org.openstreetmap.josm.tools.date.DateUtils;
041
042/**
043 * Dialog to synchronize time from a photo of the GPS receiver
044 * @since 18045 (extracted from {@link CorrelateGpxWithImages})
045 */
046class SynchronizeTimeFromPhotoDialog extends ExtendedDialog {
047
048    private JCheckBox ckDst;
049    private ImageDisplay imgDisp;
050    private JLabel lbExifTime;
051    private JosmTextField tfGpsTime;
052    private JosmComboBox<TimeZoneItem> cbTimezones;
053
054    private final SimpleDateFormat dateFormat = getDateTimeFormat();
055
056    /**
057     * Constructs a new {@code SynchronizeTimeFromPhotoDialog}.
058     * @param parent The parent element that will be used for position and maximum size
059     * @param images list of image entries
060     */
061    SynchronizeTimeFromPhotoDialog(Component parent, List<ImageEntry> images) {
062        super(parent, tr("Synchronize time from a photo of the GPS receiver"), tr("OK"), tr("Cancel"));
063        setButtonIcons("ok", "cancel");
064        setContent(buildContent(images));
065    }
066
067    private Component buildContent(List<ImageEntry> images) {
068        JPanel panel = new JPanel(new BorderLayout());
069        panel.add(new JLabel(tr("<html>Take a photo of your GPS receiver while it displays the time.<br>"
070                + "Display that photo here.<br>"
071                + "And then, simply capture the time you read on the photo and select a timezone<hr></html>")),
072                BorderLayout.NORTH);
073
074        imgDisp = new ImageDisplay();
075        imgDisp.setPreferredSize(new Dimension(300, 225));
076        panel.add(imgDisp, BorderLayout.CENTER);
077
078        JPanel panelTf = new JPanel(new GridBagLayout());
079
080        GridBagConstraints gc = new GridBagConstraints();
081        gc.gridx = gc.gridy = 0;
082        gc.gridwidth = gc.gridheight = 1;
083        gc.weightx = gc.weighty = 0.0;
084        gc.fill = GridBagConstraints.NONE;
085        gc.anchor = GridBagConstraints.LINE_START;
086        panelTf.add(new JLabel(tr("Photo time (from exif):")), gc);
087
088        lbExifTime = new JLabel();
089        gc.gridx = 1;
090        gc.weightx = 1.0;
091        gc.fill = GridBagConstraints.HORIZONTAL;
092        gc.gridwidth = 2;
093        panelTf.add(lbExifTime, gc);
094
095        gc.gridx = 0;
096        gc.gridy = 1;
097        gc.gridwidth = gc.gridheight = 1;
098        gc.weightx = gc.weighty = 0.0;
099        gc.fill = GridBagConstraints.NONE;
100        gc.anchor = GridBagConstraints.LINE_START;
101        panelTf.add(new JLabel(tr("Gps time (read from the above photo): ")), gc);
102
103        tfGpsTime = new JosmTextField(12);
104        tfGpsTime.setEnabled(false);
105        tfGpsTime.setMinimumSize(new Dimension(155, tfGpsTime.getMinimumSize().height));
106        gc.gridx = 1;
107        gc.weightx = 1.0;
108        gc.fill = GridBagConstraints.HORIZONTAL;
109        panelTf.add(tfGpsTime, gc);
110
111        gc.gridx = 2;
112        gc.weightx = 0.2;
113        panelTf.add(new JLabel(" ["+dateFormat.toLocalizedPattern()+']'), gc);
114
115        gc.gridx = 0;
116        gc.gridy = 2;
117        gc.gridwidth = gc.gridheight = 1;
118        gc.weightx = gc.weighty = 0.0;
119        gc.fill = GridBagConstraints.NONE;
120        gc.anchor = GridBagConstraints.LINE_START;
121        panelTf.add(new JLabel(tr("Photo taken in the timezone of: ")), gc);
122
123        ckDst = new JCheckBox(tr("Use daylight saving time (where applicable)"), Config.getPref().getBoolean("geoimage.timezoneid.dst"));
124
125        String[] tmp = TimeZone.getAvailableIDs();
126        List<TimeZoneItem> vtTimezones = new ArrayList<>(tmp.length);
127
128        String defTzStr = Config.getPref().get("geoimage.timezoneid", "");
129        if (defTzStr.isEmpty()) {
130            defTzStr = TimeZone.getDefault().getID();
131        }
132        TimeZoneItem defTzItem = null;
133
134        for (String tzStr : tmp) {
135            TimeZoneItem tz = new TimeZoneItem(TimeZone.getTimeZone(tzStr));
136            vtTimezones.add(tz);
137            if (defTzStr.equals(tzStr)) {
138                defTzItem = tz;
139            }
140        }
141
142        Collections.sort(vtTimezones);
143
144        cbTimezones = new JosmComboBox<>(vtTimezones.toArray(new TimeZoneItem[0]));
145
146        if (defTzItem != null) {
147            cbTimezones.setSelectedItem(defTzItem);
148        }
149
150        gc.gridx = 1;
151        gc.weightx = 1.0;
152        gc.gridwidth = 2;
153        gc.fill = GridBagConstraints.HORIZONTAL;
154        panelTf.add(cbTimezones, gc);
155
156        gc.gridy = 3;
157        panelTf.add(ckDst, gc);
158
159        ckDst.addActionListener(x -> cbTimezones.repaint());
160
161        panel.add(panelTf, BorderLayout.SOUTH);
162
163        JPanel panelLst = new JPanel(new BorderLayout());
164
165        JList<String> imgList = new JList<>(new AbstractListModel<String>() {
166            @Override
167            public String getElementAt(int i) {
168                return images.get(i).getDisplayName();
169            }
170
171            @Override
172            public int getSize() {
173                return images.size();
174            }
175        });
176        imgList.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
177        imgList.getSelectionModel().addListSelectionListener(evt -> updateExifComponents(images.get(imgList.getSelectedIndex())));
178        panelLst.add(new JScrollPane(imgList), BorderLayout.CENTER);
179
180        JButton openButton = new JButton(tr("Open another photo"));
181        openButton.addActionListener(ae -> {
182            AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(true, false, null,
183                    ImageImporter.FILE_FILTER_WITH_FOLDERS, JFileChooser.FILES_ONLY, "geoimage.lastdirectory");
184            if (fc == null)
185                return;
186            ImageEntry entry = new ImageEntry(fc.getSelectedFile());
187            entry.extractExif();
188            updateExifComponents(entry);
189        });
190        panelLst.add(openButton, BorderLayout.PAGE_END);
191
192        panel.add(panelLst, BorderLayout.LINE_START);
193
194        return panel;
195    }
196
197    final long getDelta() throws ParseException {
198        return dateFormat.parse(lbExifTime.getText()).getTime()
199             - dateFormat.parse(tfGpsTime.getText()).getTime();
200    }
201
202    final TimeZoneItem getTimeZoneItem() {
203        return (TimeZoneItem) cbTimezones.getSelectedItem();
204    }
205
206    /**
207     * Determines if daylight saving time is selected.
208     * @return {@code true} if daylight saving time is selected
209     */
210    final boolean isDstSelected() {
211        return ckDst.isSelected();
212    }
213
214    protected void updateExifComponents(ImageEntry img) {
215        imgDisp.setImage(img);
216        Instant date = img.getExifInstant();
217        if (date != null) {
218            DateFormat df = getDateTimeFormat();
219            df.setTimeZone(DateUtils.UTC); // EXIF data does not contain timezone information and is read as UTC
220            lbExifTime.setText(df.format(Date.from(date)));
221            tfGpsTime.setText(df.format(Date.from(date)));
222            tfGpsTime.setCaretPosition(tfGpsTime.getText().length());
223            tfGpsTime.setEnabled(true);
224            tfGpsTime.requestFocus();
225        } else {
226            lbExifTime.setText(tr("No date"));
227            tfGpsTime.setText("");
228            tfGpsTime.setEnabled(false);
229        }
230    }
231
232    private static SimpleDateFormat getDateTimeFormat() {
233        return (SimpleDateFormat) DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM);
234    }
235
236    class TimeZoneItem implements Comparable<TimeZoneItem> {
237        private final TimeZone tz;
238        private String rawString;
239        private String dstString;
240
241        TimeZoneItem(TimeZone tz) {
242            this.tz = tz;
243        }
244
245        public String getFormattedString() {
246            if (ckDst.isSelected()) {
247                return getDstString();
248            } else {
249                return getRawString();
250            }
251        }
252
253        public String getDstString() {
254            if (dstString == null) {
255                dstString = formatTimezone(tz.getRawOffset() + tz.getDSTSavings());
256            }
257            return dstString;
258        }
259
260        public String getRawString() {
261            if (rawString == null) {
262                rawString = formatTimezone(tz.getRawOffset());
263            }
264            return rawString;
265        }
266
267        public String getID() {
268            return tz.getID();
269        }
270
271        @Override
272        public String toString() {
273            return getID() + " (" + getFormattedString() + ')';
274        }
275
276        @Override
277        public int compareTo(TimeZoneItem o) {
278            return getID().compareTo(o.getID());
279        }
280
281        private String formatTimezone(int offset) {
282            return new GpxTimezone((double) offset / TimeUnit.HOURS.toMillis(1)).formatTimezone();
283        }
284    }
285}