CalendarModel.kt

/*
 * Copyright 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.material3

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import java.util.Locale

/**
 * Creates a [CalendarModel] to be used by the date picker.
 */
@ExperimentalMaterial3Api
internal expect fun CalendarModel(): CalendarModel

/**
 * Formats a UTC timestamp into a string with a given date format skeleton.
 *
 * A skeleton is similar to, and uses the same format characters as described in
 * [Unicode Technical Standard #35](https://unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)
 *
 * One difference is that order is irrelevant. For example, "MMMMd" will return "MMMM d" in the
 * en_US locale, but "d. MMMM" in the de_CH locale.
 *
 * @param utcTimeMillis a UTC timestamp to format (milliseconds from epoch)
 * @param skeleton a date format skeleton
 * @param locale the [Locale] to use when formatting the given timestamp
 */
@ExperimentalMaterial3Api
internal expect fun formatWithSkeleton(
    utcTimeMillis: Long,
    skeleton: String,
    locale: Locale = Locale.getDefault()
): String

/**
 * A composable function that returns the default [Locale].
 *
 * When running on an Android platform, it will be recomposed when the `Configuration` gets updated.
 */
@Composable
@ReadOnlyComposable
@ExperimentalMaterial3Api
internal expect fun defaultLocale(): Locale

@ExperimentalMaterial3Api
internal interface CalendarModel {

    /**
     * A [CalendarDate] representing the current day.
     */
    val today: CalendarDate

    /**
     * Hold the first day of the week at the current `Locale` as an integer. The integer value
     * follows the ISO-8601 standard and refer to Monday as 1, and Sunday as 7.
     */
    val firstDayOfWeek: Int

    /**
     * Holds a list of weekday names, starting from Monday as the first day in the list.
     *
     * Each item in this list is a [Pair] that holds the full name of the day, and its short
     * abbreviation letter(s).
     *
     * Newer APIs (i.e. API 26+), a [Pair] will hold a full name and the first letter of the
     * day.
     * Older APIs that predate API 26 will hold a full name and the first three letters of the day.
     */
    val weekdayNames: List<Pair<String, String>>

    /**
     * Returns a [DateInputFormat] for the given [Locale].
     *
     * The input format represents the date with two digits for the day and the month, and
     * four digits for the year.
     *
     * For example, the input format patterns, including delimiters, will hold 10-characters strings
     * in one of the following variations:
     *  - yyyy/MM/dd
     *  - yyyy-MM-dd
     *  - yyyy.MM.dd
     *  - dd/MM/yyyy
     *  - dd-MM-yyyy
     *  - dd.MM.yyyy
     *  - MM/dd/yyyy
     */
    fun getDateInputFormat(locale: Locale = Locale.getDefault()): DateInputFormat

    /**
     * Returns a [CalendarDate] from a given _UTC_ time in milliseconds.
     *
     * The returned date will hold milliseconds value that represent the start of the day, which may
     * be different than the one provided to this function.
     *
     * @param timeInMillis UTC milliseconds from the epoch
     */
    fun getCanonicalDate(timeInMillis: Long): CalendarDate

    /**
     * Returns a [CalendarMonth] from a given _UTC_ time in milliseconds.
     *
     * @param timeInMillis UTC milliseconds from the epoch for the first day the month
     */
    fun getMonth(timeInMillis: Long): CalendarMonth

    /**
     * Returns a [CalendarMonth] from a given [CalendarDate].
     *
     * Note: This function ignores the [CalendarDate.dayOfMonth] value and just uses the date's
     * year and month to resolve a [CalendarMonth].
     *
     * @param date a [CalendarDate] to resolve into a month
     */
    fun getMonth(date: CalendarDate): CalendarMonth

    /**
     * Returns a [CalendarMonth] from a given [year] and [month].
     *
     * @param year the month's year
     * @param month an integer representing a month (e.g. JANUARY as 1, December as 12)
     */
    fun getMonth(year: Int, /* @IntRange(from = 1, to = 12) */ month: Int): CalendarMonth

    /**
     * Returns a day of week from a given [CalendarDate].
     *
     * @param date a [CalendarDate] to resolve
     */
    fun getDayOfWeek(date: CalendarDate): Int

    /**
     * Returns a [CalendarMonth] that is computed by adding a number of months, given as
     * [addedMonthsCount], to a given month.
     *
     * @param from the [CalendarMonth] to add to
     * @param addedMonthsCount the number of months to add
     */
    fun plusMonths(from: CalendarMonth, addedMonthsCount: Int): CalendarMonth

    /**
     * Returns a [CalendarMonth] that is computed by subtracting a number of months, given as
     * [subtractedMonthsCount], from a given month.
     *
     * @param from the [CalendarMonth] to subtract from
     * @param subtractedMonthsCount the number of months to subtract
     */
    fun minusMonths(from: CalendarMonth, subtractedMonthsCount: Int): CalendarMonth

    /**
     * Formats a [CalendarMonth] into a string with a given date format skeleton.
     *
     * @param month a [CalendarMonth] to format
     * @param skeleton a date format skeleton
     * @param locale the [Locale] to use when formatting the given month
     */
    fun formatWithSkeleton(
        month: CalendarMonth,
        skeleton: String,
        locale: Locale = Locale.getDefault()
    ): String =
        formatWithSkeleton(month.startUtcTimeMillis, skeleton, locale)

    /**
     * Formats a [CalendarDate] into a string with a given date format skeleton.
     *
     * @param date a [CalendarDate] to format
     * @param skeleton a date format skeleton
     * @param locale the [Locale] to use when formatting the given date
     */
    fun formatWithSkeleton(
        date: CalendarDate,
        skeleton: String,
        locale: Locale = Locale.getDefault()
    ): String = formatWithSkeleton(date.utcTimeMillis, skeleton, locale)

    /**
     * Formats a UTC timestamp into a string with a given date format pattern.
     *
     * @param utcTimeMillis a UTC timestamp to format (milliseconds from epoch)
     * @param pattern a date format pattern
     * @param locale the [Locale] to use when formatting the given timestamp
     */
    fun formatWithPattern(utcTimeMillis: Long, pattern: String, locale: Locale): String

    /**
     * Parses a date string into a [CalendarDate].
     *
     * @param date a date string
     * @param pattern the expected date pattern to be used for parsing the date string
     * @return a [CalendarDate], or a `null` in case the parsing failed
     */
    fun parse(date: String, pattern: String): CalendarDate?
}

/**
 * Represents a calendar date.
 *
 * @param year the date's year
 * @param month the date's month
 * @param dayOfMonth the date's day of month
 * @param utcTimeMillis the date representation in _UTC_ milliseconds from the epoch
 */
@ExperimentalMaterial3Api
internal data class CalendarDate(
    val year: Int,
    val month: Int,
    val dayOfMonth: Int,
    val utcTimeMillis: Long
) : Comparable<CalendarDate> {
    override operator fun compareTo(other: CalendarDate): Int =
        this.utcTimeMillis.compareTo(other.utcTimeMillis)

    /**
     * Formats the date into a string with the given skeleton format and a [Locale].
     */
    fun format(
        calendarModel: CalendarModel,
        skeleton: String,
        locale: Locale = Locale.getDefault()
    ): String =
        calendarModel.formatWithSkeleton(this, skeleton, locale)
}

/**
 * Represents a calendar month.
 *
 * @param year the month's year
 * @param month the calendar month as an integer (e.g. JANUARY as 1, December as 12)
 * @param numberOfDays the number of days in the month
 * @param daysFromStartOfWeekToFirstOfMonth the number of days from the start of the week to the
 * first day of the month
 * @param startUtcTimeMillis the first day of the month in _UTC_ milliseconds from the epoch
 */
@ExperimentalMaterial3Api
internal data class CalendarMonth(
    val year: Int,
    val month: Int,
    val numberOfDays: Int,
    val daysFromStartOfWeekToFirstOfMonth: Int,
    val startUtcTimeMillis: Long
) {

    /**
     * The last _UTC_ milliseconds from the epoch of the month (i.e. the last millisecond of the
     * last day of the month)
     */
    val endUtcTimeMillis: Long = startUtcTimeMillis + (numberOfDays * MillisecondsIn24Hours) - 1

    /**
     * Returns the position of a [CalendarMonth] within given years range.
     */
    fun indexIn(years: IntRange): Int {
        return (year - years.first) * 12 + month - 1
    }

    /**
     * Formats the month into a string with the given skeleton format and a [Locale].
     */
    fun format(
        calendarModel: CalendarModel,
        skeleton: String,
        locale: Locale = Locale.getDefault()
    ): String =
        calendarModel.formatWithSkeleton(this, skeleton, locale)
}

/**
 * Holds the date input format pattern information.
 *
 * This data class hold the delimiter that is used by the current [Locale] when representing dates
 * in a short format, as well as a date pattern with and without a delimiter.
 */
@ExperimentalMaterial3Api
@Immutable
internal data class DateInputFormat(
    val patternWithDelimiters: String,
    val delimiter: Char
) {
    val patternWithoutDelimiters: String = patternWithDelimiters.replace(delimiter.toString(), "")
}

/**
 * Receives a given local date format string and returns a string that can be displayed to the user
 * and parsed by the date parser.
 *
 * This function:
 *  - Removes all characters that don't match `d`, `M` and `y`, or any of the date format delimiters
 *    `.`, `/` and `-`.
 *  - Ensures that the format is for two digits day and month, and four digits year.
 *
 * The output of this cleanup is always a 10 characters string in one of the following variations:
 *  - yyyy/MM/dd
 *  - yyyy-MM-dd
 *  - yyyy.MM.dd
 *  - dd/MM/yyyy
 *  - dd-MM-yyyy
 *  - dd.MM.yyyy
 *  - MM/dd/yyyy
 */
@ExperimentalMaterial3Api
internal fun datePatternAsInputFormat(localeFormat: String): DateInputFormat {
    val patternWithDelimiters = localeFormat.replace(Regex("[^dMy/\-.]"), "")
        .replace(Regex("d{1,2}"), "dd")
        .replace(Regex("M{1,2}"), "MM")
        .replace(Regex("y{1,4}"), "yyyy")
        .replace("My", "M/y") // Edge case for the Kako locale
        .removeSuffix(".") // Removes a dot suffix that appears in some formats

    val delimiterRegex = Regex("[/\-.]")
    val delimiterMatchResult = delimiterRegex.find(patternWithDelimiters)
    val delimiterIndex = delimiterMatchResult!!.groups[0]!!.range.first
    val delimiter = patternWithDelimiters.substring(delimiterIndex, delimiterIndex + 1)
    return DateInputFormat(
        patternWithDelimiters = patternWithDelimiters,
        delimiter = delimiter[0]
    )
}

internal const val DaysInWeek: Int = 7
internal const val MillisecondsIn24Hours = 86400000L