[android] Show "Opens / Closes X at Y" using formatter + add i18n strings

- Wire `PlacePageView.refreshOpenState()` to `OpenStateTextFormatter`.
- Keep <= 60 min branch with plurals (“Closes in %d minutes • at HH:mm”).
- Add day hint when next change is not today (“Opens Sat at 09:00”).
- Add localized strings with positional placeholders:
  - `opens_at` / `closes_at` (... `%s`).
  - `opens_day_at` / `closes_day_at` (`%1$s=%day`, `%2$s=%time`).

Refs: #2303

Signed-off-by: NoelClick <dev@noel.click>
(cherry picked from commit be80c7486882ab64a64efc30d0979d3674bbcc29)
Signed-off-by: NoelClick <dev@noel.click>
This commit is contained in:
NoelClick
2025-10-30 17:38:35 -07:00
committed by jeanbaptisteC
parent 94542456a2
commit 83256c4895
2 changed files with 93 additions and 48 deletions

View File

@@ -85,9 +85,11 @@ import com.google.android.material.textview.MaterialTextView;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.time.format.TextStyle;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Locale;
public class PlacePageView extends Fragment public class PlacePageView extends Fragment
implements View.OnClickListener, View.OnLongClickListener, LocationListener, SensorListener, Observer<MapObject>, implements View.OnClickListener, View.OnLongClickListener, LocationListener, SensorListener, Observer<MapObject>,
@@ -797,8 +799,13 @@ public class PlacePageView extends Fragment
final String ohStr = mMapObject.getMetadata(Metadata.MetadataType.FMD_OPEN_HOURS); final String ohStr = mMapObject.getMetadata(Metadata.MetadataType.FMD_OPEN_HOURS);
final Timetable[] timetables = OpeningHours.nativeTimetablesFromString(ohStr); final Timetable[] timetables = OpeningHours.nativeTimetablesFromString(ohStr);
if (timetables != null && timetables.length != 0) // No valid timetable
if (timetables == null || timetables.length == 0)
{ {
UiUtils.hide(mTvOpenState);
return;
}
final Context context = requireContext(); final Context context = requireContext();
final OhState poiState = OpeningHours.nativeCurrentState(timetables); final OhState poiState = OpeningHours.nativeCurrentState(timetables);
@@ -820,34 +827,64 @@ public class PlacePageView extends Fragment
final SpannableStringBuilder openStateString = new SpannableStringBuilder(); final SpannableStringBuilder openStateString = new SpannableStringBuilder();
final boolean isOpen = (poiState.state == OhState.State.Open); // False == Closed due to early exit for Unknown final boolean isOpen = (poiState.state == OhState.State.Open); // False == Closed due to early exit for Unknown
final long nextStateTime = isOpen ? poiState.nextTimeClosed : poiState.nextTimeOpen; // Unix time (seconds) final long nextStateTime = isOpen ? poiState.nextTimeClosed : poiState.nextTimeOpen; // Unix time (seconds)
final int minsToNextState = (int) ((nextStateTime - (System.currentTimeMillis() / 1000)) / 60); final long nowSec = System.currentTimeMillis() / 1000;
final int minsToNextState = (int) ((nextStateTime - nowSec) / 60);
if (minsToNextState <= 60) // POI opens/closes in 60 mins // NOTE: Timezone is currently device timezone. TODO: use feature-specific timezone.
final ZonedDateTime nextChangeLocal =
ZonedDateTime.ofInstant(Instant.ofEpochSecond(nextStateTime), ZoneId.systemDefault());
String localizedTimeString = OpenStateTextFormatter.formatHoursMinutes(
nextChangeLocal.getHour(), nextChangeLocal.getMinute(), DateUtils.is24HourFormat(context));
if (minsToNextState <= 60 && minsToNextState >= 0) // POI Opens/Closes in 60 mins • at 18:00
{ {
final String minsToChangeStr = minsToNextState + " " + getString(R.string.minute); final String minsToChangeStr = getResources().getQuantityString(
R.plurals.minutes, Math.max(minsToNextState, 1), Math.max(minsToNextState, 1));
final String nextChangeFormatted = getString(isOpen ? R.string.closes_in : R.string.opens_in, minsToChangeStr); final String nextChangeFormatted = getString(isOpen ? R.string.closes_in : R.string.opens_in, minsToChangeStr);
final ForegroundColorSpan nextChangeColor = isOpen ? colorYellow : colorRed; final ForegroundColorSpan nextChangeColor = isOpen ? colorYellow : colorRed;
// TODO: We should check closed/open time for specific feature's timezone.
ZonedDateTime time = ZonedDateTime.ofInstant(Instant.ofEpochSecond(nextStateTime), ZoneId.systemDefault());
String localizedTime =
new HoursMinutes(time.getHour(), time.getMinute(), DateUtils.is24HourFormat(context)).toString();
openStateString.append(nextChangeFormatted, nextChangeColor, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) openStateString.append(nextChangeFormatted, nextChangeColor, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
.append("") // Add spacer .append("") // Add spacer
.append(getString(R.string.at, localizedTime)); .append(getString(R.string.at, localizedTimeString));
} }
else if (isOpen) else
{
final String opensAtStr = getString(R.string.opens_at); // "Opens at %s"
final String closesAtStr = getString(R.string.closes_at); // "Closes at %s"
final String opensDayAtStr = getString(R.string.opens_day_at); // "Opens %1$s at %2$s"
final String closesDayAtStr = getString(R.string.closes_day_at); // "Closes %1$s at %2$s"
final boolean isToday =
OpenStateTextFormatter.isSameLocalDate(nextChangeLocal, ZonedDateTime.now(nextChangeLocal.getZone()));
final String dayShort =
nextChangeLocal.getDayOfWeek().getDisplayName(TextStyle.SHORT, Locale.getDefault());
if (isOpen) // > 60 minutes OR negative (safety). Show “Open now • Closes at 18:00”
{
openStateString.append(getString(R.string.open_now), colorGreen, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); openStateString.append(getString(R.string.open_now), colorGreen, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// TODO: Add "Closes at 18:00" etc
final String atLabel =
OpenStateTextFormatter.buildAtLabel(false, isToday, dayShort, localizedTimeString,
opensAtStr, closesAtStr, opensDayAtStr, closesDayAtStr);
if (!TextUtils.isEmpty(atLabel))
openStateString.append("").append(atLabel);
}
else // Closed else // Closed
{
openStateString.append(getString(R.string.closed_now), colorRed, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); openStateString.append(getString(R.string.closed_now), colorRed, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
// TODO: Add "Opens at 18:00" etc
final String atLabel =
OpenStateTextFormatter.buildAtLabel(true, isToday, dayShort, localizedTimeString,
opensAtStr, closesAtStr, opensDayAtStr, closesDayAtStr);
if (!TextUtils.isEmpty(atLabel))
openStateString.append("").append(atLabel);
}
}
UiUtils.setTextAndHideIfEmpty(mTvOpenState, openStateString); UiUtils.setTextAndHideIfEmpty(mTvOpenState, openStateString);
return;
}
// No valid timetable
UiUtils.hide(mTvOpenState);
} }
private void addPlace() private void addPlace()

View File

@@ -441,6 +441,14 @@
<string name="opens_in">Opens in %s</string> <string name="opens_in">Opens in %s</string>
<string name="closes_in">Closes in %s</string> <string name="closes_in">Closes in %s</string>
<string name="closed">Closed</string> <string name="closed">Closed</string>
<string name="opens_at">Opens at %s</string>
<string name="closes_at">Closes at %s</string>
<string name="opens_day_at">Opens %1$s at %2$s</string>
<string name="closes_day_at">Closes %1$s at %2$s</string>
<plurals name="minutes">
<item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item>
</plurals>
<!-- Used in the opening_hours fragment for the last checked date, eg. "Confirmed two weeks ago" --> <!-- Used in the opening_hours fragment for the last checked date, eg. "Confirmed two weeks ago" -->
<string name="hours_confirmed_time_ago">Confirmed %s</string> <string name="hours_confirmed_time_ago">Confirmed %s</string>
<!-- Used on the place page for the last checked date, eg. "Existence confirmed two weeks ago" --> <!-- Used on the place page for the last checked date, eg. "Existence confirmed two weeks ago" -->