Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 11403139 authored by Ellen Poe's avatar Ellen Poe
Browse files

feat: opening hours table for next 7 days

parent 3f19708a
Loading
Loading
Loading
Loading
Loading
+306 −46
Original line number Diff line number Diff line
@@ -27,10 +27,15 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -65,7 +70,6 @@ import earth.maps.cardinal.R.drawable
import earth.maps.cardinal.R.string
import earth.maps.cardinal.data.AddressFormatter
import earth.maps.cardinal.data.AppPreferenceRepository
import earth.maps.cardinal.data.AppPreferences
import earth.maps.cardinal.data.Place
import earth.maps.cardinal.data.format
import earth.maps.cardinal.data.formatTime
@@ -77,32 +81,134 @@ import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.io.ByteArrayInputStream
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
import kotlin.time.ExperimentalTime

// Data class to hold opening hours information for each day
data class DayOpeningHours(
    val dayOfWeek: Int,
    val dayName: String,
    val timeRanges: List<String>,
    val isToday: Boolean
)

fun weekdayRangeIncludesDay(range: WeekDayRange, day: DayOfWeek): Boolean {
    if (range.startDay != null && range.startDay.ordinal == day.ordinal) {
        return true
@Composable
fun getDayName(dayOfWeek: Int): String {
    return when (dayOfWeek) {
        DayOfWeek.MONDAY.ordinal -> stringResource(string.day_monday)
        DayOfWeek.TUESDAY.ordinal -> stringResource(string.day_tuesday)
        DayOfWeek.WEDNESDAY.ordinal -> stringResource(string.day_wednesday)
        DayOfWeek.THURSDAY.ordinal -> stringResource(string.day_thursday)
        DayOfWeek.FRIDAY.ordinal -> stringResource(string.day_friday)
        DayOfWeek.SATURDAY.ordinal -> stringResource(string.day_saturday)
        DayOfWeek.SUNDAY.ordinal -> stringResource(string.day_sunday)
        else -> "Unknown day"
    }
}

// Helper function to format minutes to time string
fun formatMinutesToTime(minutes: Int, use24HourFormat: Boolean): String {
    val hours = minutes / 60
    val mins = minutes % 60
    return if (use24HourFormat) {
        String.format(Locale.getDefault(), "%02d:%02d", hours, mins)
    } else {
        val displayHour = if (hours == 0) 12 else if (hours > 12) hours - 12 else hours
        val amPm = if (hours >= 12) "PM" else "AM"
        String.format(Locale.getDefault(), "%d:%02d %s", displayHour, mins, amPm)
    }
}

// Helper function to get opening hours for a specific day
fun getOpeningHoursForDay(
    rules: List<ch.poole.openinghoursparser.Rule>,
    dayOfWeek: Int,
    use24HourFormat: Boolean
): List<String> {
    val timeRanges = mutableListOf<String>()

    for (rule in rules) {
        val days = rule.days
        val times = rule.times
        if (days == null || times == null) {
            continue
        }

        for (dayRule in days) {
            if (weekdayRangeIncludesDay(dayRule, dayOfWeek)) {
                // Collect all time ranges for this day
                for (timeRule in times) {
                    val startTime = formatMinutesToTime(timeRule.start, use24HourFormat)
                    val endTime = formatMinutesToTime(timeRule.end, use24HourFormat)
                    timeRanges.add("$startTime - $endTime")
                }
            }
        }
    }

    return timeRanges.distinct()
}

// Helper function to get opening hours for the next 7 days
@Composable
fun getOpeningHoursForNext7Days(
    openingHours: String,
    now: LocalDateTime,
    use24HourFormat: Boolean
): List<DayOpeningHours> {
    val parser =
        OpeningHoursParser(ByteArrayInputStream(openingHours.toByteArray(charset = Charsets.UTF_8)))
    val rules = try {
        parser.rules(false, false)
    } catch (e: OpeningHoursParseException) {
        Log.e("PlaceCardScreen", "Failed to parse opening hours", e)
        return emptyList()
    }

    val dayOpeningHours = mutableListOf<DayOpeningHours>()
    val today = now.dayOfWeek

    // Get opening hours for today and next 6 days
    for (i in 0..6) {
        val targetDay = (today.ordinal + i) % 7
        val dayName = if (i == 0) stringResource(string.day_today) else getDayName(targetDay)
        val timeRanges = getOpeningHoursForDay(rules, targetDay, use24HourFormat)

        dayOpeningHours.add(
            DayOpeningHours(
                dayOfWeek = targetDay,
                dayName = dayName,
                timeRanges = timeRanges,
                isToday = i == 0
            )
        )
    }
    else if (range.startDay == null || range.endDay == null) {

    return dayOpeningHours
}

fun weekdayRangeIncludesDay(range: WeekDayRange, day: Int): Boolean {
    if (range.startDay != null && range.startDay.ordinal == day) {
        return true
    } else if (range.startDay == null || range.endDay == null) {
        return false
    }
    if (range.endDay < range.startDay) {
        for (ord in 0..range.endDay.ordinal) {
            if (ord == day.ordinal) {
            if (ord == day) {
                return true
            }
        }
        for (ord in range.startDay.ordinal..6) {
            if (ord == day.ordinal) {
            if (ord == day) {
                return true
            }
        }
    } else {
        for (ord in range.startDay.ordinal..range.endDay.ordinal) {
            if (ord == day.ordinal) {
            if (ord == day) {
                return true
            }
        }
@@ -112,39 +218,183 @@ fun weekdayRangeIncludesDay(range: WeekDayRange, day: DayOfWeek): Boolean {

@OptIn(ExperimentalTime::class)
@Composable
fun OpenClosed(place: Place, now: LocalDateTime, timeZone: TimeZone, use24HourFormat: Boolean) {
fun ExpandableOpeningHours(
    place: Place,
    now: LocalDateTime,
    timeZone: TimeZone,
    use24HourFormat: Boolean
) {
    var expanded by remember { mutableStateOf(false) }
    val openingHoursData = place.openingHours?.let { openingHours ->
        getOpeningHoursForNext7Days(openingHours, now, use24HourFormat)
    } ?: return

    // Get current status for collapsed view
    val currentStatus = getCurrentOpeningStatus(place, now, timeZone, use24HourFormat)

    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(bottom = 8.dp)
            .clickable { expanded = !expanded },
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        ) {
            // Header with current status and expand/collapse icon
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable { expanded = !expanded },
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Column(
                    modifier = Modifier.weight(1f)
                ) {
                    Text(
                        text = stringResource(string.opening_hours_title),
                        style = MaterialTheme.typography.titleMedium,
                        fontWeight = FontWeight.Medium
                    )

                    currentStatus?.let { status ->
                        Row(
                            modifier = Modifier.padding(top = 4.dp),
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Text(
                                text = status.statusText,
                                color = status.statusColor,
                                style = MaterialTheme.typography.bodyMedium
                            )
                            status.nextTimeText?.let { nextTime ->
                                Spacer(modifier = Modifier.width(4.dp))
                                Text(
                                    text = nextTime,
                                    style = MaterialTheme.typography.bodyMedium,
                                    color = MaterialTheme.colorScheme.onSurfaceVariant
                                )
                            }
                        }
                    }
                }

                Icon(
                    painter = painterResource(
                        if (expanded) drawable.ic_arrow_down else drawable.ic_arrow_down
                    ),
                    contentDescription = stringResource(
                        if (expanded) string.content_description_collapse_opening_hours
                        else string.content_description_expand_opening_hours
                    ),
                    modifier = Modifier.size(24.dp)
                )
            }

            // Expanded content with table
            if (expanded && openingHoursData.isNotEmpty()) {
                Spacer(modifier = Modifier.height(8.dp))

                // Table header
                Row(
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Text(
                        text = "Day",
                        modifier = Modifier.weight(1f),
                        style = MaterialTheme.typography.bodySmall,
                        fontWeight = FontWeight.Medium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                    Text(
                        text = "Hours",
                        modifier = Modifier.weight(2f),
                        style = MaterialTheme.typography.bodySmall,
                        fontWeight = FontWeight.Medium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }

                HorizontalDivider(
                    modifier = Modifier.padding(vertical = 4.dp),
                    color = MaterialTheme.colorScheme.outlineVariant
                )

                // Table rows for each day
                openingHoursData.forEach { dayHours ->
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(vertical = 4.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Text(
                            text = dayHours.dayName,
                            modifier = Modifier.weight(1f),
                            style = MaterialTheme.typography.bodyMedium,
                            fontWeight = if (dayHours.isToday) FontWeight.Bold else FontWeight.Normal,
                            color = MaterialTheme.colorScheme.onSurface
                        )

                        Text(
                            text = if (dayHours.timeRanges.isEmpty()) {
                                stringResource(string.opening_hours_closed_all_day)
                            } else {
                                dayHours.timeRanges.joinToString(", ")
                            },
                            modifier = Modifier.weight(2f),
                            style = MaterialTheme.typography.bodyMedium,
                            color = MaterialTheme.colorScheme.onSurface
                        )
                    }

                    if (dayHours != openingHoursData.last()) {
                        HorizontalDivider(
                            color = MaterialTheme.colorScheme.outlineVariant
                        )
                    }
                }
            }
        }
    }
}

data class OpeningStatus(
    val statusText: String,
    val statusColor: Color,
    val nextTimeText: String?
)

@OptIn(ExperimentalTime::class)
@Composable
fun getCurrentOpeningStatus(
    place: Place,
    now: LocalDateTime,
    timeZone: TimeZone,
    use24HourFormat: Boolean
): OpeningStatus? {
    place.openingHours?.let { openingHours ->
        val today = now.dayOfWeek
        val parser = OpeningHoursParser(ByteArrayInputStream(openingHours.toByteArray(charset = Charsets.UTF_8)))
        val parser =
            OpeningHoursParser(ByteArrayInputStream(openingHours.toByteArray(charset = Charsets.UTF_8)))
        val rules = try {
            parser.rules(false, false)
        } catch (e: OpeningHoursParseException) {
            Log.e("PlaceCardScreen", "Failed to parse opening hours", e)
            return
            return null
        }

        val timeOfDay = now.time
        // My rationale for doing the cavewoman-brained solution instead of handling DST and other issues is that the opening hours
        // are going to be outdated far more often than any other failure mode, and we can get better ROI by spending time on other
        // areas of the app.

        // The opening hours parser gives us times in minutes from the beginning of the day, so we want the current time in that format.
        val minutesFromMidnight = timeOfDay.minute + timeOfDay.hour * 60

        // This logic gets complicated due to things like lunch breaks, but the user wants one of two things, depending on whether the place is open currently:
        // If the place is open:
        //  - When does it close? Does it reopen after that, and if so, when?
        // If the place is closed:
        //  - When does it open?

        // Whether the place is currently open.
        var isOpen = false
        // The first time today, not before the current time, that the POI will open. This may or may not be the first or last time the POI opens today.
        var openingTimeToday: Int? = null
        // The first time today, not before the current time, that the POI will close. This may or may not be the first or last time that the POI closes today.
        var closingTimeToday: Int? = null

        // Iterate through the rules in this opening hours expression and find the ones that apply to today (defined above).
        for (rule in rules) {
            val days = rule.days
            val times = rule.times
@@ -152,8 +402,7 @@ fun OpenClosed(place: Place, now: LocalDateTime, timeZone: TimeZone, use24HourFo
                continue
            }
            for (dayRule in days) {
                if (weekdayRangeIncludesDay(dayRule, today)) {
                    // Look for opening and closing times today.
                if (weekdayRangeIncludesDay(dayRule, today.ordinal)) {
                    for (timeRule in times) {
                        if (timeRule.start < minutesFromMidnight && timeRule.end > minutesFromMidnight) {
                            isOpen = true
@@ -174,26 +423,32 @@ fun OpenClosed(place: Place, now: LocalDateTime, timeZone: TimeZone, use24HourFo
        val closingInstantToday = closingTimeToday?.let { closingTime ->
            now.date.atTime(0, 0).toInstant(timeZone = timeZone).plus(closingTime.minutes)
        }
        Row {
            if (isOpen) {
                Text(modifier = Modifier.padding(bottom = 8.dp), color = Color.Green, text = stringResource(string.opening_hours_open))
                closingInstantToday?.let { closingInstant ->
                    Spacer(modifier = Modifier.width(4.dp))
                    Text(modifier = Modifier.padding(bottom = 8.dp), text = stringResource(string.opening_hours_closes_at, closingInstant.toString().formatTime(use24HourFormat)))
                }
                openingInstantToday?.let { openingInstant ->
                    Spacer(modifier = Modifier.width(4.dp))
                    Text(modifier = Modifier.padding(bottom = 8.dp), text = stringResource(string.opening_hours_opens_again_at, openingInstant.toString().formatTime(use24HourFormat)))

        return if (isOpen) {
            OpeningStatus(
                statusText = stringResource(string.opening_hours_open),
                statusColor = Color.Green,
                nextTimeText = closingInstantToday?.let { closingInstant ->
                    stringResource(
                        string.opening_hours_closes_at,
                        closingInstant.toString().formatTime(use24HourFormat)
                    )
                }
            )
        } else {
                Text(modifier = Modifier.padding(bottom = 8.dp), color = Color.Red, text = stringResource(string.opening_hours_closed))
                openingInstantToday?.let { openingInstant ->
                    Spacer(modifier = Modifier.width(4.dp))
                    Text(modifier = Modifier.padding(bottom = 8.dp), text = stringResource(string.opening_hours_opens_at, openingInstant.toString().formatTime(use24HourFormat)))
                }
            OpeningStatus(
                statusText = stringResource(string.opening_hours_closed),
                statusColor = Color.Red,
                nextTimeText = openingInstantToday?.let { openingInstant ->
                    stringResource(
                        string.opening_hours_opens_at,
                        openingInstant.toString().formatTime(use24HourFormat)
                    )
                }
            )
        }
    }
    return null
}

@OptIn(ExperimentalTime::class)
@@ -243,7 +498,12 @@ fun PlaceCardScreen(
        ) {
            PlaceHeader(displayedPlace)
            PlaceAddress(displayedPlace, addressFormatter)
            OpenClosed(displayedPlace, Clock.System.now().toLocalDateTime(timeZone = TimeZone.currentSystemDefault()), TimeZone.currentSystemDefault(), use24HourFormat)
            ExpandableOpeningHours(
                displayedPlace,
                Clock.System.now().toLocalDateTime(timeZone = TimeZone.currentSystemDefault()),
                TimeZone.currentSystemDefault(),
                use24HourFormat
            )
            PlaceActions(
                displayedPlace,
                viewModel,
@@ -296,7 +556,7 @@ private fun PlaceAddress(displayedPlace: Place, addressFormatter: AddressFormatt
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(vertical = 8.dp),
                .padding(top = 8.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(
+14 −0
Original line number Diff line number Diff line
@@ -270,4 +270,18 @@
    <string name="opening_hours_closes_at">Closes %1$s.</string>
    <string name="opening_hours_opens_again_at">Opens again %1$s.</string>
    <string name="opening_hours_opens_at">Opens %1$s.</string>
    <string name="opening_hours_title">Opening Hours</string>
    <string name="opening_hours_closed_all_day">Closed</string>
    <string name="content_description_expand_opening_hours">Expand opening hours</string>
    <string name="content_description_collapse_opening_hours">Collapse opening hours</string>

    <string name="day_monday">Monday</string>
    <string name="day_tuesday">Tuesday</string>
    <string name="day_wednesday">Wednesday</string>
    <string name="day_thursday">Thursday</string>
    <string name="day_friday">Friday</string>
    <string name="day_saturday">Saturday</string>
    <string name="day_sunday">Sunday</string>

    <string name="day_today">Today</string>
</resources>