Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 134 additions & 4 deletions ft8af/app/src/main/java/com/k1af/ft8af/ui/ExportLogSheet.java
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
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;
import android.widget.EditText;
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;

import com.google.android.material.datepicker.MaterialDatePicker;
import com.k1af.ft8af.GeneralVariables;
import com.k1af.ft8af.MainViewModel;
import com.k1af.ft8af.R;
import com.k1af.ft8af.log.OnShareLogEvents;
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
Expand All @@ -45,6 +57,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);
Expand Down Expand Up @@ -75,14 +89,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() {
Expand All @@ -105,14 +120,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";
Expand Down Expand Up @@ -206,6 +222,120 @@ 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];
Comment thread
patrickrb marked this conversation as resolved.
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;
}
});
// 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) {
FragmentManager fm = fragmentManager();
if (fm == null || fm.isStateSaved() || fm.findFragmentByTag(tag) != null) return;
Long preselect = parseYyyyMmddUtc(field.getText().toString());
MaterialDatePicker.Builder<Long> builder = MaterialDatePicker.Builder.datePicker();
builder.setSelection(preselect != null ? preselect
: MaterialDatePicker.todayInUtcMilliseconds());
MaterialDatePicker<Long> picker = builder.build();
picker.addOnPositiveButtonClickListener(new com.google.android.material.datepicker.MaterialPickerOnPositiveButtonClickListener<Long>() {
@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";
Expand Down
10 changes: 10 additions & 0 deletions ft8af/app/src/main/res/drawable/ic_calendar_today.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/help_dialog_text_color">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V9h14V19zM19,7H5V5h14V7z" />
</vector>
16 changes: 12 additions & 4 deletions ft8af/app/src/main/res/layout/dialog_export_log.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,19 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="From (YYYYMMDD, optional)"
android:text="From (optional)"
android:textColor="@color/help_dialog_text_color"
android:textSize="11sp" />
<EditText
android:id="@+id/exportDateStart"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="any"
android:contentDescription="From date, tap the calendar icon or type YYYYMMDD"
android:drawableEnd="@drawable/ic_calendar_today"
android:drawablePadding="4dp"
android:hint="YYYYMMDD"
android:inputType="number"
android:maxLength="8"
android:textColor="@color/help_dialog_text_color"
android:textColorHint="@color/help_dialog_text_color" />
</LinearLayout>
Expand All @@ -91,15 +95,19 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="To (YYYYMMDD, optional)"
android:text="To (optional)"
android:textColor="@color/help_dialog_text_color"
android:textSize="11sp" />
<EditText
android:id="@+id/exportDateEnd"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="any"
android:contentDescription="To date, tap the calendar icon or type YYYYMMDD"
android:drawableEnd="@drawable/ic_calendar_today"
android:drawablePadding="4dp"
android:hint="YYYYMMDD"
android:inputType="number"
android:maxLength="8"
android:textColor="@color/help_dialog_text_color"
android:textColorHint="@color/help_dialog_text_color" />
</LinearLayout>
Expand Down
118 changes: 118 additions & 0 deletions ft8af/app/src/test/java/com/k1af/ft8af/ui/ExportLogSheetDateTest.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading