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}