Implement month separator for the Gallery.
Improve day separator rendering in the timeline. Use Today, Yesterday, and the name of the day if less than 7 days and do not render the year for the current year. Improve date format for the media viewer. Rework how date and time are computed. ActionListView: Time can take more space, so update the layout.
This commit is contained in:
parent
5515c938d4
commit
da272ddb07
60 changed files with 1271 additions and 351 deletions
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.safeCapitalize
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
interface DateFormatterDay {
|
||||
fun format(
|
||||
timestamp: Long,
|
||||
useRelative: Boolean,
|
||||
): String
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultDateFormatterDay @Inject constructor(
|
||||
private val localDateTimeProvider: LocalDateTimeProvider,
|
||||
private val dateFormatters: DateFormatters,
|
||||
) : DateFormatterDay {
|
||||
override fun format(
|
||||
timestamp: Long,
|
||||
useRelative: Boolean,
|
||||
): String {
|
||||
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
|
||||
val today = localDateTimeProvider.providesNow()
|
||||
return if (useRelative) {
|
||||
val dayDiff = today.date.toEpochDays() - dateToFormat.date.toEpochDays()
|
||||
when (dayDiff) {
|
||||
0 -> dateFormatters.getRelativeDay(timestamp, "Today")
|
||||
1 -> dateFormatters.getRelativeDay(timestamp, "Yesterday")
|
||||
else -> if (dayDiff < 7) {
|
||||
dateFormatters.formatDateWithDay(dateToFormat)
|
||||
} else {
|
||||
if (today.year == dateToFormat.year) {
|
||||
dateFormatters.formatDateWithFullFormatNoYear(dateToFormat)
|
||||
} else {
|
||||
dateFormatters.formatDateWithFullFormat(dateToFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (today.year == dateToFormat.year) {
|
||||
dateFormatters.formatDateWithFullFormatNoYear(dateToFormat)
|
||||
} else {
|
||||
dateFormatters.formatDateWithFullFormat(dateToFormat)
|
||||
}
|
||||
}
|
||||
.safeCapitalize()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
class DateFormatterFull @Inject constructor(
|
||||
private val stringProvider: StringProvider,
|
||||
private val localDateTimeProvider: LocalDateTimeProvider,
|
||||
private val dateFormatters: DateFormatters,
|
||||
private val dateFormatterDay: DateFormatterDay,
|
||||
) {
|
||||
fun format(
|
||||
timestamp: Long,
|
||||
useRelative: Boolean,
|
||||
): String {
|
||||
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
|
||||
val time = dateFormatters.formatTime(dateToFormat)
|
||||
return if (useRelative) {
|
||||
val now = localDateTimeProvider.providesNow()
|
||||
if (now.date == dateToFormat.date) {
|
||||
time
|
||||
} else {
|
||||
val dateStr = dateFormatterDay.format(timestamp, true)
|
||||
stringProvider.getString(R.string.common_date_date_at_time, dateStr, time)
|
||||
}
|
||||
} else {
|
||||
val dateStr = dateFormatters.formatDateWithFullFormat(dateToFormat)
|
||||
stringProvider.getString(R.string.common_date_date_at_time, dateStr, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import io.element.android.libraries.core.extensions.safeCapitalize
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import javax.inject.Inject
|
||||
|
||||
class DateFormatterMonth @Inject constructor(
|
||||
private val stringProvider: StringProvider,
|
||||
private val localDateTimeProvider: LocalDateTimeProvider,
|
||||
private val dateFormatters: DateFormatters,
|
||||
) {
|
||||
fun format(
|
||||
timestamp: Long,
|
||||
useRelative: Boolean,
|
||||
): String {
|
||||
val today = localDateTimeProvider.providesNow()
|
||||
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
|
||||
return if (useRelative && dateToFormat.month == today.month && dateToFormat.year == today.year) {
|
||||
stringProvider.getString(R.string.common_date_this_month)
|
||||
} else {
|
||||
dateFormatters.formatDateWithMonthAndYear(dateToFormat)
|
||||
}
|
||||
.safeCapitalize()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
|
|
@ -7,18 +7,16 @@
|
|||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLastMessageTimestampFormatter @Inject constructor(
|
||||
class DateFormatterTime @Inject constructor(
|
||||
private val localDateTimeProvider: LocalDateTimeProvider,
|
||||
private val dateFormatters: DateFormatters,
|
||||
) : LastMessageTimestampFormatter {
|
||||
override fun format(timestamp: Long?): String {
|
||||
if (timestamp == null) return ""
|
||||
) {
|
||||
fun format(
|
||||
timestamp: Long,
|
||||
useRelative: Boolean,
|
||||
): String {
|
||||
val currentDate = localDateTimeProvider.providesNow()
|
||||
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
|
||||
val isSameDay = currentDate.date == dateToFormat.date
|
||||
|
|
@ -30,7 +28,7 @@ class DefaultLastMessageTimestampFormatter @Inject constructor(
|
|||
dateFormatters.formatDate(
|
||||
dateToFormat = dateToFormat,
|
||||
currentDate = currentDate,
|
||||
useRelative = true
|
||||
useRelative = useRelative,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
class DateFormatterTimeOnly @Inject constructor(
|
||||
private val localDateTimeProvider: LocalDateTimeProvider,
|
||||
private val dateFormatters: DateFormatters,
|
||||
) {
|
||||
fun format(
|
||||
timestamp: Long,
|
||||
): String {
|
||||
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
|
||||
return dateFormatters.formatTime(dateToFormat)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,57 +7,63 @@
|
|||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import android.text.format.DateUtils
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import timber.log.Timber
|
||||
import java.time.Period
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
class DateFormatters @Inject constructor(
|
||||
private val locale: Locale,
|
||||
localeChangeObserver: LocaleChangeObserver,
|
||||
private val clock: Clock,
|
||||
private val timeZoneProvider: TimezoneProvider,
|
||||
) {
|
||||
private val onlyTimeFormatter: DateTimeFormatter by lazy {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
) : LocaleChangeListener {
|
||||
init {
|
||||
localeChangeObserver.addListener(this)
|
||||
}
|
||||
|
||||
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
|
||||
val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM"
|
||||
DateTimeFormatter.ofPattern(pattern, locale)
|
||||
}
|
||||
private var dateTimeFormatters: DateTimeFormatters = DateTimeFormatters(Locale.getDefault())
|
||||
|
||||
private val dateWithYearFormatter: DateTimeFormatter by lazy {
|
||||
val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy"
|
||||
DateTimeFormatter.ofPattern(pattern, locale)
|
||||
}
|
||||
|
||||
private val dateWithFullFormatFormatter: DateTimeFormatter by lazy {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||
override fun onLocaleChange() {
|
||||
Timber.w("Locale changed, updating formatters")
|
||||
dateTimeFormatters = DateTimeFormatters(Locale.getDefault())
|
||||
}
|
||||
|
||||
internal fun formatTime(localDateTime: LocalDateTime): String {
|
||||
return onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
return dateTimeFormatters.onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
||||
internal fun formatDateWithMonthAndYear(localDateTime: LocalDateTime): String {
|
||||
return dateTimeFormatters.dateWithMonthAndYearFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
||||
internal fun formatDateWithMonth(localDateTime: LocalDateTime): String {
|
||||
return dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
return dateTimeFormatters.dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
||||
internal fun formatDateWithDay(localDateTime: LocalDateTime): String {
|
||||
return dateTimeFormatters.dateWithDayFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
||||
internal fun formatDateWithYear(localDateTime: LocalDateTime): String {
|
||||
return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
return dateTimeFormatters.dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
||||
internal fun formatDateWithFullFormat(localDateTime: LocalDateTime): String {
|
||||
return dateWithFullFormatFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
return dateTimeFormatters.dateWithFullFormatFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
||||
internal fun formatDateWithFullFormatNoYear(localDateTime: LocalDateTime): String {
|
||||
return dateTimeFormatters.dateWithFullFormatNoYearFormatter.format(localDateTime.toJavaLocalDateTime())
|
||||
}
|
||||
|
||||
internal fun formatDate(
|
||||
|
|
@ -75,12 +81,12 @@ class DateFormatters @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun getRelativeDay(ts: Long): String {
|
||||
internal fun getRelativeDay(ts: Long, default: String = ""): String {
|
||||
return DateUtils.getRelativeTimeSpanString(
|
||||
ts,
|
||||
clock.now().toEpochMilliseconds(),
|
||||
DateUtils.DAY_IN_MILLIS,
|
||||
DateUtils.FORMAT_SHOW_WEEKDAY
|
||||
)?.toString() ?: ""
|
||||
)?.toString() ?: default
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
class DateTimeFormatters(
|
||||
private val locale: Locale,
|
||||
) {
|
||||
val onlyTimeFormatter: DateTimeFormatter by lazy {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
}
|
||||
|
||||
val dateWithMonthAndYearFormatter: DateTimeFormatter by lazy {
|
||||
val pattern = bestDateTimePattern("MMMM YYYY")
|
||||
DateTimeFormatter.ofPattern(pattern, locale)
|
||||
}
|
||||
|
||||
val dateWithMonthFormatter: DateTimeFormatter by lazy {
|
||||
val pattern = bestDateTimePattern("d MMM")
|
||||
DateTimeFormatter.ofPattern(pattern, locale)
|
||||
}
|
||||
|
||||
val dateWithDayFormatter: DateTimeFormatter by lazy {
|
||||
val pattern = bestDateTimePattern("EEEE")
|
||||
DateTimeFormatter.ofPattern(pattern, locale)
|
||||
}
|
||||
|
||||
val dateWithYearFormatter: DateTimeFormatter by lazy {
|
||||
val pattern = bestDateTimePattern("dd.MM.yyyy")
|
||||
DateTimeFormatter.ofPattern(pattern, locale)
|
||||
}
|
||||
|
||||
val dateWithFullFormatFormatter: DateTimeFormatter by lazy {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale)
|
||||
}
|
||||
|
||||
val dateWithFullFormatNoYearFormatter: DateTimeFormatter by lazy {
|
||||
val pattern = DateFormat.getBestDateTimePattern(locale, "EEEE d MMMM") ?: "EEEE d MMMM"
|
||||
DateTimeFormatter.ofPattern(pattern, locale)
|
||||
}
|
||||
|
||||
private fun bestDateTimePattern(pattern: String): String {
|
||||
return DateFormat.getBestDateTimePattern(locale, pattern) ?: pattern
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultDateFormatter @Inject constructor(
|
||||
private val dateFormatterFull: DateFormatterFull,
|
||||
private val dateFormatterMonth: DateFormatterMonth,
|
||||
private val dateFormatterDay: DateFormatterDay,
|
||||
private val dateFormatterTime: DateFormatterTime,
|
||||
private val dateFormatterTimeOnly: DateFormatterTimeOnly,
|
||||
) : DateFormatter {
|
||||
override fun format(
|
||||
timestamp: Long?,
|
||||
mode: DateFormatterMode,
|
||||
useRelative: Boolean,
|
||||
): String {
|
||||
timestamp ?: return ""
|
||||
return when (mode) {
|
||||
DateFormatterMode.Full -> {
|
||||
dateFormatterFull.format(timestamp, useRelative)
|
||||
}
|
||||
DateFormatterMode.Month -> {
|
||||
dateFormatterMonth.format(timestamp, useRelative)
|
||||
}
|
||||
DateFormatterMode.Day -> {
|
||||
dateFormatterDay.format(timestamp, useRelative)
|
||||
}
|
||||
DateFormatterMode.TimeOrDate -> {
|
||||
dateFormatterTime.format(timestamp, useRelative)
|
||||
}
|
||||
DateFormatterMode.TimeOnly -> {
|
||||
dateFormatterTimeOnly.format(timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultDaySeparatorFormatter @Inject constructor(
|
||||
private val localDateTimeProvider: LocalDateTimeProvider,
|
||||
private val dateFormatters: DateFormatters,
|
||||
) : DaySeparatorFormatter {
|
||||
override fun format(timestamp: Long): String {
|
||||
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
|
||||
// TODO use relative formatting once iOS uses it too
|
||||
return dateFormatters.formatDateWithFullFormat(dateToFormat)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.dateformatter.impl
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import javax.inject.Inject
|
||||
|
||||
fun interface LocaleChangeObserver {
|
||||
fun addListener(listener: LocaleChangeListener)
|
||||
}
|
||||
|
||||
interface LocaleChangeListener {
|
||||
fun onLocaleChange()
|
||||
}
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLocaleChangeObserver @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : LocaleChangeObserver {
|
||||
init {
|
||||
registerReceiver(object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
listeners.forEach(LocaleChangeListener::onLocaleChange)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private val listeners = mutableSetOf<LocaleChangeListener>()
|
||||
|
||||
override fun addListener(listener: LocaleChangeListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
private fun registerReceiver(receiver: BroadcastReceiver) {
|
||||
val filter = IntentFilter()
|
||||
filter.addAction(Intent.ACTION_LOCALE_CHANGED)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
filter.addAction(Intent.ACTION_APPLICATION_LOCALE_CHANGED)
|
||||
}
|
||||
context.registerReceiver(receiver, filter)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ import io.element.android.libraries.dateformatter.impl.TimezoneProvider
|
|||
import io.element.android.libraries.di.AppScope
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.Locale
|
||||
|
||||
@Module
|
||||
@ContributesTo(AppScope::class)
|
||||
|
|
@ -22,9 +21,6 @@ object DateFormatterModule {
|
|||
@Provides
|
||||
fun providesClock(): Clock = Clock.System
|
||||
|
||||
@Provides
|
||||
fun providesLocale(): Locale = Locale.getDefault()
|
||||
|
||||
@Provides
|
||||
fun providesTimezone(): TimezoneProvider = TimezoneProvider { TimeZone.currentSystemDefault() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="common_date_date_at_time">"%1$s à %2$s"</string>
|
||||
<string name="common_date_this_month">"Ce mois-ci"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="common_date_date_at_time">"%1$s at %2$s"</string>
|
||||
<string name="common_date_this_month">"This month"</string>
|
||||
</resources>
|
||||
Loading…
Add table
Add a link
Reference in a new issue