From 0d8ad2640be3c6d20595e95904eed795d88f1143 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:25:31 +0000 Subject: [PATCH 1/2] Export QSOs: add Material date picker to date-range filter The From/To date-range fields in the Export QSOs sheet were plain numeric EditTexts requiring a hand-typed YYYYMMDD value with no calendar UI and no validation. This adds a MaterialDatePicker behind a trailing calendar icon on each field while keeping the field fully editable by keyboard. - Tapping the calendar icon opens a Material date picker; a valid YYYYMMDD already in the field is preselected, otherwise today. - Selecting a date fills the field as YYYYMMDD; typed entry still works and a field can be cleared back to empty (empty = no bound, unchanged). - On Share / Save, a non-empty field that is not a strict YYYYMMDD date is rejected with a ToastMessage instead of running a broken query. - The value handed to ShareLogs remains a YYYYMMDD string (or null), so the query-layer date contract is unchanged. Date parse/validate/format logic is extracted to package-private static helpers on ExportLogSheet and covered by ExportLogSheetDateTest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/k1af/ft8af/ui/ExportLogSheet.java | 124 +++++++++++++++++- .../main/res/drawable/ic_calendar_today.xml | 10 ++ .../src/main/res/layout/dialog_export_log.xml | 16 ++- .../k1af/ft8af/ui/ExportLogSheetDateTest.java | 118 +++++++++++++++++ 4 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 ft8af/app/src/main/res/drawable/ic_calendar_today.xml create mode 100644 ft8af/app/src/test/java/com/k1af/ft8af/ui/ExportLogSheetDateTest.java diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java index 25527403d..16b8d9f1d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java @@ -1,8 +1,12 @@ package com.k1af.ft8af.ui; +import android.annotation.SuppressLint; import android.app.Dialog; import android.content.Context; +import android.content.ContextWrapper; +import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.view.MotionEvent; import android.view.View; import android.view.WindowManager; import android.widget.Button; @@ -10,6 +14,10 @@ import android.widget.ProgressBar; import android.widget.TextView; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.datepicker.MaterialDatePicker; import com.k1af.ft8af.GeneralVariables; import com.k1af.ft8af.MainViewModel; import com.k1af.ft8af.R; @@ -17,9 +25,11 @@ import com.k1af.ft8af.log.ShareLogs; import java.io.File; +import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; +import java.util.TimeZone; /** * Sheet for exporting QSO records to ADIF, then either sharing via the system @@ -45,6 +55,8 @@ protected void onCreate(Bundle savedInstanceState) { final TextView summary = findViewById(R.id.exportSummaryTextView); final EditText dateStart = findViewById(R.id.exportDateStart); final EditText dateEnd = findViewById(R.id.exportDateEnd); + setupDatePicker(dateStart, "exportDatePickerStart"); + setupDatePicker(dateEnd, "exportDatePickerEnd"); final TextView progressText = findViewById(R.id.exportProgressTextView); final ProgressBar progressBar = findViewById(R.id.exportProgressBar); Button cancel = findViewById(R.id.exportCancelButton); @@ -75,14 +87,15 @@ public void onClick(View view) { @Override public void onClick(View view) { if (working) return; + if (!validateDate(dateStart, "From") || !validateDate(dateEnd, "To")) return; + final String startDate = nullIfEmpty(dateStart.getText().toString()); + final String endDate = nullIfEmpty(dateEnd.getText().toString()); working = true; share.setEnabled(false); save.setEnabled(false); progressBar.setVisibility(View.VISIBLE); final File adi = generateTempAdi(); if (adi == null) return; - final String startDate = nullIfEmpty(dateStart.getText().toString()); - final String endDate = nullIfEmpty(dateEnd.getText().toString()); new Thread(new Runnable() { @Override public void run() { @@ -105,14 +118,15 @@ public void run() { @Override public void onClick(View view) { if (working) return; + if (!validateDate(dateStart, "From") || !validateDate(dateEnd, "To")) return; + final String startDate = nullIfEmpty(dateStart.getText().toString()); + final String endDate = nullIfEmpty(dateEnd.getText().toString()); working = true; share.setEnabled(false); save.setEnabled(false); progressBar.setVisibility(View.VISIBLE); final File adi = generateTempAdi(); if (adi == null) return; - final String startDate = nullIfEmpty(dateStart.getText().toString()); - final String endDate = nullIfEmpty(dateEnd.getText().toString()); final String displayName = "ft8af-log-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(new Date()) + ".adi"; @@ -206,6 +220,108 @@ private static String nullIfEmpty(String s) { return s.isEmpty() ? null : s; } + /** + * Wire up an editable date field so tapping its trailing calendar icon opens a + * {@link MaterialDatePicker}. The field stays fully editable by keyboard; the + * picker just fills in a {@code YYYYMMDD} string, keeping the two input paths + * in sync and producing the exact format {@code ShareLogs} expects. + */ + @SuppressLint("ClickableViewAccessibility") + private void setupDatePicker(final EditText field, final String tag) { + field.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + if (event.getAction() != MotionEvent.ACTION_UP) return false; + Drawable end = field.getCompoundDrawablesRelative()[2]; + if (end == null) return false; + int hit = field.getWidth() - field.getPaddingEnd() - end.getBounds().width(); + if (event.getX() >= hit) { + field.performClick(); + openDatePicker(field, tag); + return true; + } + return false; + } + }); + } + + private void openDatePicker(final EditText field, String tag) { + FragmentManager fm = fragmentManager(); + if (fm == null || fm.isStateSaved() || fm.findFragmentByTag(tag) != null) return; + Long preselect = parseYyyyMmddUtc(field.getText().toString()); + MaterialDatePicker.Builder builder = MaterialDatePicker.Builder.datePicker(); + builder.setSelection(preselect != null ? preselect + : MaterialDatePicker.todayInUtcMilliseconds()); + MaterialDatePicker picker = builder.build(); + picker.addOnPositiveButtonClickListener(new com.google.android.material.datepicker.MaterialPickerOnPositiveButtonClickListener() { + @Override + public void onPositiveButtonClick(Long selection) { + if (selection != null) field.setText(formatYyyyMmddUtc(selection)); + } + }); + picker.show(fm, tag); + } + + /** Unwrap {@link #getContext()} to the hosting {@link FragmentActivity}, if any. */ + private FragmentManager fragmentManager() { + Context c = getContext(); + while (c instanceof ContextWrapper) { + if (c instanceof FragmentActivity) { + return ((FragmentActivity) c).getSupportFragmentManager(); + } + c = ((ContextWrapper) c).getBaseContext(); + } + return null; + } + + /** + * Validate a date field before export. An empty field is valid ("no bound"). + * A non-empty field must be a strict {@code YYYYMMDD} date; otherwise a toast + * is shown and {@code false} returned so the caller aborts the export. + */ + private boolean validateDate(EditText field, String label) { + if (isValidOptionalDate(field.getText().toString())) return true; + ToastMessage.show("Invalid '" + label + "' date — use YYYYMMDD (e.g. 20260722)"); + return false; + } + + /** True when {@code s} is empty/null (no bound) or a strict {@code YYYYMMDD} date. */ + static boolean isValidOptionalDate(String s) { + if (s == null) return true; + s = s.trim(); + return s.isEmpty() || parseYyyyMmddUtc(s) != null; + } + + /** + * Parse a strict 8-digit {@code YYYYMMDD} string to UTC-midnight millis (the + * form {@link MaterialDatePicker} uses), or {@code null} if it is not a real + * calendar date. Used both to validate typed input and to preselect the picker. + */ + static Long parseYyyyMmddUtc(String s) { + if (s == null) return null; + s = s.trim(); + if (!s.matches("\\d{8}")) return null; + SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMdd", Locale.US); + fmt.setTimeZone(TimeZone.getTimeZone("UTC")); + fmt.setLenient(false); + try { + Date d = fmt.parse(s); + // Round-trip so out-of-band values SimpleDateFormat still accepts + // (e.g. year 0000) are rejected rather than silently normalized. + if (d == null || !fmt.format(d).equals(s)) return null; + return d.getTime(); + } catch (ParseException e) { + return null; + } + } + + /** Format {@link MaterialDatePicker}'s UTC-millis selection as {@code YYYYMMDD}. */ + static String formatYyyyMmddUtc(long utcMillis) { + SimpleDateFormat fmt = new SimpleDateFormat("yyyyMMdd", Locale.US); + fmt.setTimeZone(TimeZone.getTimeZone("UTC")); + return fmt.format(new Date(utcMillis)); + } + private static String filterLabel(int queryFilter) { switch (queryFilter) { case 1: return "confirmed only"; diff --git a/ft8af/app/src/main/res/drawable/ic_calendar_today.xml b/ft8af/app/src/main/res/drawable/ic_calendar_today.xml new file mode 100644 index 000000000..85f42289c --- /dev/null +++ b/ft8af/app/src/main/res/drawable/ic_calendar_today.xml @@ -0,0 +1,10 @@ + + + diff --git a/ft8af/app/src/main/res/layout/dialog_export_log.xml b/ft8af/app/src/main/res/layout/dialog_export_log.xml index 69df5d2f5..e25371f6c 100644 --- a/ft8af/app/src/main/res/layout/dialog_export_log.xml +++ b/ft8af/app/src/main/res/layout/dialog_export_log.xml @@ -70,15 +70,19 @@ @@ -91,15 +95,19 @@ diff --git a/ft8af/app/src/test/java/com/k1af/ft8af/ui/ExportLogSheetDateTest.java b/ft8af/app/src/test/java/com/k1af/ft8af/ui/ExportLogSheetDateTest.java new file mode 100644 index 000000000..e79a174e4 --- /dev/null +++ b/ft8af/app/src/test/java/com/k1af/ft8af/ui/ExportLogSheetDateTest.java @@ -0,0 +1,118 @@ +package com.k1af.ft8af.ui; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.util.TimeZone; + +/** + * Unit tests for the date parsing/validation/formatting helpers in + * {@link ExportLogSheet} that back the Export QSOs date-range picker. These + * guarantee the picker path and the typed path both yield the strict + * {@code YYYYMMDD} string the {@code ShareLogs} query layer expects. + */ +public class ExportLogSheetDateTest { + + // MaterialDatePicker.todayInUtcMilliseconds() is UTC-based, so all conversions + // must be UTC regardless of the test machine's default zone. + private static final long UTC_20260722 = utc(2026, 7, 22); + + private static long utc(int year, int month, int day) { + java.util.Calendar c = java.util.Calendar.getInstance(TimeZone.getTimeZone("UTC")); + c.clear(); + c.set(year, month - 1, day, 0, 0, 0); + return c.getTimeInMillis(); + } + + // ---- isValidOptionalDate: empty means "no bound" and is valid ---- + + @Test + public void nullIsValid() { + assertThat(ExportLogSheet.isValidOptionalDate(null)).isTrue(); + } + + @Test + public void emptyIsValid() { + assertThat(ExportLogSheet.isValidOptionalDate("")).isTrue(); + assertThat(ExportLogSheet.isValidOptionalDate(" ")).isTrue(); + } + + @Test + public void wellFormedDateIsValid() { + assertThat(ExportLogSheet.isValidOptionalDate("20260722")).isTrue(); + } + + @Test + public void surroundingWhitespaceIsTolerated() { + assertThat(ExportLogSheet.isValidOptionalDate(" 20260722 ")).isTrue(); + } + + // ---- isValidOptionalDate: malformed non-empty input is rejected ---- + + @Test + public void wrongLengthIsInvalid() { + assertThat(ExportLogSheet.isValidOptionalDate("2026072")).isFalse(); // 7 digits + assertThat(ExportLogSheet.isValidOptionalDate("202607221")).isFalse(); // 9 digits + } + + @Test + public void nonNumericIsInvalid() { + assertThat(ExportLogSheet.isValidOptionalDate("2026-07-22")).isFalse(); + assertThat(ExportLogSheet.isValidOptionalDate("2026Jul2")).isFalse(); + } + + @Test + public void impossibleMonthIsInvalid() { + assertThat(ExportLogSheet.isValidOptionalDate("20261301")).isFalse(); // month 13 + assertThat(ExportLogSheet.isValidOptionalDate("20260001")).isFalse(); // month 00 + } + + @Test + public void impossibleDayIsInvalid() { + assertThat(ExportLogSheet.isValidOptionalDate("20260230")).isFalse(); // Feb 30 + assertThat(ExportLogSheet.isValidOptionalDate("20260231")).isFalse(); // Feb 31 + assertThat(ExportLogSheet.isValidOptionalDate("20260732")).isFalse(); // day 32 + assertThat(ExportLogSheet.isValidOptionalDate("20260700")).isFalse(); // day 00 + } + + @Test + public void leapDayValidatesCorrectly() { + assertThat(ExportLogSheet.isValidOptionalDate("20240229")).isTrue(); // 2024 is a leap year + assertThat(ExportLogSheet.isValidOptionalDate("20260229")).isFalse(); // 2026 is not + } + + @Test + public void yearZeroIsRejected() { + // SimpleDateFormat would otherwise silently normalize this — the round-trip guards it. + assertThat(ExportLogSheet.isValidOptionalDate("00000101")).isFalse(); + } + + // ---- parseYyyyMmddUtc: preselect value for the picker ---- + + @Test + public void parseReturnsUtcMidnightMillis() { + assertThat(ExportLogSheet.parseYyyyMmddUtc("20260722")).isEqualTo(UTC_20260722); + } + + @Test + public void parseInvalidReturnsNull() { + assertThat(ExportLogSheet.parseYyyyMmddUtc("not-a-date")).isNull(); + assertThat(ExportLogSheet.parseYyyyMmddUtc("")).isNull(); + assertThat(ExportLogSheet.parseYyyyMmddUtc(null)).isNull(); + } + + // ---- formatYyyyMmddUtc: what a picker selection becomes ---- + + @Test + public void formatProducesYyyyMmdd() { + assertThat(ExportLogSheet.formatYyyyMmddUtc(UTC_20260722)).isEqualTo("20260722"); + } + + @Test + public void formatAndParseRoundTrip() { + Long millis = ExportLogSheet.parseYyyyMmddUtc("20250101"); + assertThat(millis).isNotNull(); + assertThat(ExportLogSheet.formatYyyyMmddUtc(millis)).isEqualTo("20250101"); + } +} From 50510c5bd63895cef596353893c03e9da21b475a Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Wed, 22 Jul 2026 14:36:29 +0000 Subject: [PATCH 2/2] Export date picker: expose calendar as an accessibility action Address Copilot review: the trailing calendar icon is a touch-only hit target on the EditText's compound drawable, so TalkBack users could not activate the picker. Register a ViewCompat custom accessibility action ("Open calendar date picker") on each field so the picker path is operable via accessibility services, not just touch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/k1af/ft8af/ui/ExportLogSheet.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java index 16b8d9f1d..fd87347b6 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java @@ -14,6 +14,8 @@ import android.widget.ProgressBar; import android.widget.TextView; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityViewCommand; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; @@ -243,6 +245,18 @@ public boolean onTouch(View v, MotionEvent event) { return false; } }); + // The trailing calendar icon is a touch-only hit target, so also expose the + // picker as a custom accessibility action — TalkBack users get an "Open + // calendar" entry in the field's actions menu rather than only being able + // to type. + ViewCompat.addAccessibilityAction(field, "Open calendar date picker", + new AccessibilityViewCommand() { + @Override + public boolean perform(View view, AccessibilityViewCommand.CommandArguments args) { + openDatePicker(field, tag); + return true; + } + }); } private void openDatePicker(final EditText field, String tag) {