From d99c6ae68a784c61e659013bb7bf862aea241e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 2 Sep 2026 14:00:25 +0200 Subject: [PATCH 1/4] feat: handle meeting tzid [WPB-27614] --- ...ageAssetMessagesFromConversationUseCase.kt | 8 ++--- ...ssetMessagesFromConversationUseCaseTest.kt | 6 ++-- .../util/time/CurrentTimeZoneProvider.kt | 12 ++++++-- .../meetings/ui/create/NewMeetingScreen.kt | 15 +++++----- .../meetings/ui/create/NewMeetingViewModel.kt | 30 ++++++++++++++----- .../meetings/mapper/MeetingMapperTest.kt | 1 + .../ui/create/NewMeetingViewModelTest.kt | 16 ++++++++-- .../ui/list/MeetingListViewModelTest.kt | 1 + .../MeetingOptionsMenuViewModelTest.kt | 1 + kalium | 2 +- 10 files changed, 64 insertions(+), 28 deletions(-) rename app/src/main/kotlin/com/wire/android/util/time/TimeZoneProvider.kt => core/ui-common/src/main/kotlin/com/wire/android/util/time/CurrentTimeZoneProvider.kt (73%) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCase.kt index 55b85ce11cb..fbeb3a2282c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCase.kt @@ -25,21 +25,21 @@ import com.wire.android.mapper.UIAssetMapper import com.wire.android.ui.common.monthYearHeader import com.wire.android.ui.home.conversations.model.messagetypes.asset.UIAssetMessage import com.wire.android.util.dispatchers.DispatcherProvider -import com.wire.android.util.time.TimeZoneProvider +import com.wire.android.util.time.CurrentTimeZoneProvider import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.asset.ObservePaginatedAssetImageMessages +import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.datetime.toLocalDateTime -import dev.zacsweers.metro.Inject import kotlin.math.max class ObserveImageAssetMessagesFromConversationUseCase @Inject constructor( private val getAssetMessages: ObservePaginatedAssetImageMessages, private val assetMapper: UIAssetMapper, private val dispatchers: DispatcherProvider, - private val timeZoneProvider: TimeZoneProvider + private val timeZoneProvider: CurrentTimeZoneProvider ) { /** @@ -63,7 +63,7 @@ class ObserveImageAssetMessagesFromConversationUseCase @Inject constructor( startingOffset = max(0, initialOffset - PREFETCH_DISTANCE).toLong(), pagingConfig = pagingConfig ).map { pagingData -> - val currentTime = timeZoneProvider.currentSystemDefault() + val currentTime = timeZoneProvider() pagingData.map { assetMessage -> UIImageAssetPagingItem.Asset(assetMapper.toUIAsset(assetMessage)) }.insertSeparators { before: UIImageAssetPagingItem.Asset?, after: UIImageAssetPagingItem.Asset? -> diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCaseTest.kt index 923c32f4fd2..2ba94040b25 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/ObserveImageAssetMessagesFromConversationUseCaseTest.kt @@ -22,7 +22,7 @@ import androidx.paging.testing.asSnapshot import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.mapper.UIAssetMapper -import com.wire.android.util.time.TimeZoneProvider +import com.wire.android.util.time.CurrentTimeZoneProvider import com.wire.kalium.logic.data.asset.AssetMessage import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.asset.ObservePaginatedAssetImageMessages @@ -77,7 +77,7 @@ class ObserveImageAssetMessagesFromConversationUseCaseTest { lateinit var getAssetMessages: ObservePaginatedAssetImageMessages @MockK - lateinit var timeZoneProvider: TimeZoneProvider + lateinit var timeZoneProvider: CurrentTimeZoneProvider private val useCase: ObserveImageAssetMessagesFromConversationUseCase by lazy { ObserveImageAssetMessagesFromConversationUseCase( @@ -90,7 +90,7 @@ class ObserveImageAssetMessagesFromConversationUseCaseTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { timeZoneProvider.currentSystemDefault() } returns TimeZone.of("UTC") + every { timeZoneProvider.invoke() } returns TimeZone.of("UTC") } suspend fun sendPagingData(pagingItems: List) = apply { diff --git a/app/src/main/kotlin/com/wire/android/util/time/TimeZoneProvider.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/time/CurrentTimeZoneProvider.kt similarity index 73% rename from app/src/main/kotlin/com/wire/android/util/time/TimeZoneProvider.kt rename to core/ui-common/src/main/kotlin/com/wire/android/util/time/CurrentTimeZoneProvider.kt index 6a2de333106..a6470fff209 100644 --- a/app/src/main/kotlin/com/wire/android/util/time/TimeZoneProvider.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/time/CurrentTimeZoneProvider.kt @@ -17,9 +17,15 @@ */ package com.wire.android.util.time -import kotlinx.datetime.TimeZone import dev.zacsweers.metro.Inject +import kotlinx.datetime.TimeZone + +class CurrentTimeZoneProvider(val currentTimeZone: () -> TimeZone) { + @Inject constructor() : this(TimeZone::currentSystemDefault) + + operator fun invoke(): TimeZone = currentTimeZone() -class TimeZoneProvider @Inject constructor() { - fun currentSystemDefault(): TimeZone = TimeZone.currentSystemDefault() + companion object { + val Preview: CurrentTimeZoneProvider = CurrentTimeZoneProvider() + } } diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt index b2912c82dec..a7d60209f04 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingScreen.kt @@ -106,6 +106,7 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.CurrentTimeProvider import com.wire.android.util.DateAndTimeParsers import com.wire.android.util.EMPTY +import com.wire.android.util.time.CurrentTimeZoneProvider import com.wire.kalium.logic.data.id.MeetingId import com.wire.kalium.logic.data.user.ConnectionState import kotlinx.collections.immutable.ImmutableSet @@ -570,7 +571,7 @@ fun PreviewNewMeetingScreen_MeetNow() = WireTheme { NewMeetingContent( titleState = rememberTextFieldState("Meeting with 9 users"), type = NewMeetingType.MeetNow, - state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( + state = NewMeetingState.initialState(CurrentTimeProvider.Preview, CurrentTimeZoneProvider.Preview).copy( confirmedContacts = buildContacts(names.size), continueButtonEnabled = true, initialLoading = NewMeetingState.InitialLoadingState.Loaded, @@ -584,9 +585,9 @@ fun PreviewNewMeetingScreen_Schedule() = WireTheme { NewMeetingContent( titleState = rememberTextFieldState(), type = NewMeetingType.Schedule, - state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( - startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()), - endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()).plus(1.hours), + state = NewMeetingState.initialState(CurrentTimeProvider.Preview, CurrentTimeZoneProvider.Preview).copy( + startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke(), CurrentTimeZoneProvider.Preview.invoke()), + endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke(), CurrentTimeZoneProvider.Preview.invoke()).plus(1.hours), repeatingInterval = MeetingItem.RepeatingInterval.Supported.first(), initialLoading = NewMeetingState.InitialLoadingState.Loaded, ), @@ -599,9 +600,9 @@ fun PreviewNewMeetingScreen_Edit() = WireTheme { NewMeetingContent( titleState = rememberTextFieldState(), type = NewMeetingType.Edit(MeetingId("meetingId", "domain")), - state = NewMeetingState.initialState(CurrentTimeProvider.Preview).copy( - startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()), - endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke()).plus(1.hours), + state = NewMeetingState.initialState(CurrentTimeProvider.Preview, CurrentTimeZoneProvider.Preview).copy( + startTime = getNextFullHour(CurrentTimeProvider.Preview.invoke(), CurrentTimeZoneProvider.Preview.invoke()), + endTime = getNextFullHour(CurrentTimeProvider.Preview.invoke(), CurrentTimeZoneProvider.Preview.invoke()).plus(1.hours), repeatingInterval = MeetingItem.RepeatingInterval.Supported.first(), initialLoading = NewMeetingState.InitialLoadingState.Loaded, ), diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt index 80c99e73274..a3cd44c4c41 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt @@ -37,6 +37,7 @@ import com.wire.android.ui.common.ActionsManager import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.common.textfield.textAsFlow import com.wire.android.util.CurrentTimeProvider +import com.wire.android.util.time.CurrentTimeZoneProvider import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.meeting.Meeting import com.wire.kalium.logic.data.meeting.UpsertMeeting @@ -69,6 +70,7 @@ import kotlin.time.Duration.Companion.hours interface NewMeetingViewModel : ActionsManager { val currentTimeProvider: CurrentTimeProvider + val currentTimeZoneProvider: CurrentTimeZoneProvider val type: NewMeetingType val titleTextState: TextFieldState val state: NewMeetingState @@ -93,8 +95,9 @@ class NewMeetingViewModelPreview( override val type: NewMeetingType ) : NewMeetingViewModel { override val currentTimeProvider: CurrentTimeProvider = CurrentTimeProvider.Preview + override val currentTimeZoneProvider: CurrentTimeZoneProvider = CurrentTimeZoneProvider.Preview override val titleTextState: TextFieldState = TextFieldState() - override val state: NewMeetingState = initialState(currentTimeProvider) + override val state: NewMeetingState = initialState(currentTimeProvider, currentTimeZoneProvider) } @Suppress("TooManyFunctions") @@ -102,6 +105,7 @@ class NewMeetingViewModelPreview( class NewMeetingViewModelImpl @AssistedInject constructor( @Assisted val navArgs: NewMeetingNavArgs, override val currentTimeProvider: CurrentTimeProvider, + override val currentTimeZoneProvider: CurrentTimeZoneProvider, private val createNewMeeting: CreateNewMeetingUseCase, private val updateMeeting: UpdateMeetingUseCase, private val getNextUnfinishedMeetingOccurrence: GetNextUnfinishedMeetingOccurrenceUseCase, @@ -116,7 +120,7 @@ class NewMeetingViewModelImpl @AssistedInject constructor( override val type: NewMeetingType = navArgs.type override val titleTextState: TextFieldState = TextFieldState() - override var state: NewMeetingState by mutableStateOf(initialState(currentTimeProvider)) + override var state: NewMeetingState by mutableStateOf(initialState(currentTimeProvider, currentTimeZoneProvider)) private set init { @@ -138,6 +142,9 @@ class NewMeetingViewModelImpl @AssistedInject constructor( state = state.copy( startTime = meetingOccurrence.occurrenceStartTime, endTime = meetingOccurrence.occurrenceEndTime, + // always use the current local tzid, not the initial meeting's tzid, as it could be created in different tzid + // so when current user updates times and sets their local ones so it should also inherit their local tzid + tzid = currentTimeZoneProvider().id, repeatingInterval = meetingOccurrence.meeting.recurrence?.toRepeatingInterval(), selectedContacts = otherContacts, confirmedContacts = otherContacts, @@ -180,7 +187,7 @@ class NewMeetingViewModelImpl @AssistedInject constructor( override fun updateStartTime(startTime: Instant) { val currentDuration = state.endTime - state.startTime - val latestEndTime = startTime.latestEndTimeOnSameDay() + val latestEndTime = startTime.latestEndTimeOnSameDay(currentTimeZoneProvider()) state = state.copy( startTime = startTime, // adjust end time based on the new start time but try to keep the same duration, unless it extends into the next day @@ -256,6 +263,7 @@ class NewMeetingViewModelImpl @AssistedInject constructor( title = titleTextState.text.trim().toString(), startTime = state.startTime, endTime = state.endTime, + tzid = state.tzid, recurrence = state.repeatingInterval?.let { Meeting.Recurrence(it.frequency, it.interval.toLong(), null) }, otherParticipants = state.confirmedContacts.map { UserId(it.id, it.domain) } ) @@ -282,6 +290,7 @@ class NewMeetingViewModelImpl @AssistedInject constructor( title = titleTextState.text.trim().toString(), startTime = state.startTime, endTime = state.endTime, + tzid = state.tzid, recurrence = state.repeatingInterval?.let { Meeting.Recurrence(it.frequency, it.interval.toLong(), null) }, otherParticipants = state.confirmedContacts.map { UserId(it.id, it.domain) } ) @@ -321,7 +330,7 @@ class NewMeetingViewModelImpl @AssistedInject constructor( } } -internal fun getNextFullHour(now: Instant, timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant { +internal fun getNextFullHour(now: Instant, timeZone: TimeZone): Instant { val futureHour = now.plus(1, DateTimeUnit.HOUR, timeZone) val localFuture = futureHour.toLocalDateTime(timeZone) return LocalDateTime( @@ -337,7 +346,7 @@ internal fun getNextFullHour(now: Instant, timeZone: TimeZone = TimeZone.current // Find the latest possible end time on the same day as the given Instant, in the given time zone. // The latest possible end time is 23:59:00 on the same day in the given time zone. -private fun Instant.latestEndTimeOnSameDay(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant { +private fun Instant.latestEndTimeOnSameDay(timeZone: TimeZone): Instant { val localStartTime = toLocalDateTime(timeZone) return LocalDateTime( year = localStartTime.year, @@ -360,6 +369,7 @@ data class NewMeetingState( val startTimeError: TimeError? = null, val endTime: Instant, val endTimeError: TimeError? = null, + val tzid: String, val repeatingInterval: MeetingItem.RepeatingInterval? = null, val submitError: SubmitError? = null, val isSubmitting: Boolean = false, @@ -385,9 +395,13 @@ data class NewMeetingState( } companion object { - fun initialState(currentTimeProvider: CurrentTimeProvider): NewMeetingState { - val startTime = getNextFullHour(currentTimeProvider()) - return NewMeetingState(startTime = startTime, endTime = startTime.plus(1.hours)) + fun initialState(currentTimeProvider: CurrentTimeProvider, currentTimeZoneProvider: CurrentTimeZoneProvider): NewMeetingState { + val startTime = getNextFullHour(currentTimeProvider(), currentTimeZoneProvider()) + return NewMeetingState( + startTime = startTime, + endTime = startTime.plus(1.hours), + tzid = currentTimeZoneProvider().id, + ) } } } diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/mapper/MeetingMapperTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/mapper/MeetingMapperTest.kt index 506ad989d9f..37872401ebd 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/mapper/MeetingMapperTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/mapper/MeetingMapperTest.kt @@ -119,6 +119,7 @@ class MeetingMapperTest { title = TITLE, startTime = startTime, endTime = endTime, + tzid = "Europe/Berlin", recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), ), occurrenceId = "$MEETING_ID-occurrence", diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt index b401ca4e9d1..29188d69da1 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt @@ -28,6 +28,7 @@ import com.wire.android.mapper.ContactMapper import com.wire.android.model.Contact import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.util.CurrentTimeProvider +import com.wire.android.util.time.CurrentTimeZoneProvider import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.MemberDetails @@ -106,6 +107,7 @@ class NewMeetingViewModelTest { assertNull(viewModel.state.titleError) assertNull(viewModel.state.startTimeError) assertNull(viewModel.state.endTimeError) + assertEquals(MEETING_TIME_ZONE_ID, viewModel.state.tzid) } @Test @@ -204,7 +206,7 @@ class NewMeetingViewModelTest { @Test fun givenStartTimeChanges_whenEndTimeWouldMoveToNextDay_thenEndTimeIsCappedAt2359() = runTest(dispatcher) { - val timeZone = TimeZone.currentSystemDefault() + val timeZone = TimeZone.of(MEETING_TIME_ZONE_ID) val currentTime = LocalDateTime(2026, 1, 1, 12, 0).toInstant(timeZone) val newStartTime = LocalDateTime(2026, 1, 1, 23, 30).toInstant(timeZone) val latestEndTime = LocalDateTime(2026, 1, 1, 23, 59).toInstant(timeZone) @@ -309,6 +311,7 @@ class NewMeetingViewModelTest { title = "Quick sync", startTime = currentTime, endTime = currentTime + 1.hours, + tzid = MEETING_TIME_ZONE_ID, recurrence = null, otherParticipants = emptyList() ) @@ -434,6 +437,7 @@ class NewMeetingViewModelTest { title = "Retrospective sync", startTime = pastStartTime, endTime = pastEndTime, + tzid = MEETING_TIME_ZONE_ID, recurrence = null, otherParticipants = emptyList() ) @@ -788,11 +792,14 @@ class NewMeetingViewModelTest { connectionState = ConnectionState.ACCEPTED, ) - private class Arrangement(private val dispatcher: TestDispatcher) { + private inner class Arrangement(private val dispatcher: TestDispatcher) { var currentTimeProvider = CurrentTimeProvider { Instant.fromEpochMilliseconds(dispatcher.scheduler.currentTime) } + @MockK + lateinit var currentTimeZoneProvider: CurrentTimeZoneProvider + @MockK lateinit var createNewMeeting: CreateNewMeetingUseCase @@ -815,6 +822,7 @@ class NewMeetingViewModelTest { init { MockKAnnotations.init(this) + every { currentTimeZoneProvider.invoke() } returns TimeZone.of(MEETING_OCCURRENCE.meeting.tzid) coEvery { getNextUnfinishedMeetingOccurrence(any(), any()) } returns null coEvery { observeConversationMembers(any()) } returns flowOf(emptyList()) } @@ -860,6 +868,7 @@ class NewMeetingViewModelTest { fun arrange() = this to NewMeetingViewModelImpl( navArgs = NewMeetingNavArgs(type = newMeetingType), currentTimeProvider = currentTimeProvider, + currentTimeZoneProvider = currentTimeZoneProvider, createNewMeeting = createNewMeeting, updateMeeting = updateMeeting, getNextUnfinishedMeetingOccurrence = getNextUnfinishedMeetingOccurrence, @@ -869,6 +878,7 @@ class NewMeetingViewModelTest { ) } + private val MEETING_TIME_ZONE_ID = "Europe/Berlin" private val MEETING_OCCURRENCE = MeetingOccurrence( meeting = Meeting( meetingId = MeetingId("meeting-id", "domain"), @@ -877,6 +887,7 @@ class NewMeetingViewModelTest { title = "Daily", startTime = Instant.parse("2026-01-01T09:00:00Z"), endTime = Instant.parse("2026-01-01T10:00:00Z"), + tzid = MEETING_TIME_ZONE_ID, recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), ), selfRole = MeetingOccurrence.SelfRole.Creator, @@ -891,6 +902,7 @@ class NewMeetingViewModelTest { title = "Weekly sync", startTime = Instant.parse("2026-01-01T09:00:00Z"), endTime = Instant.parse("2026-01-01T10:00:00Z"), + tzid = MEETING_TIME_ZONE_ID, recurrence = Meeting.Recurrence( frequency = MeetingItem.RepeatingInterval.Supported.first().frequency, interval = MeetingItem.RepeatingInterval.Supported.first().interval.toLong(), diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/list/MeetingListViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/list/MeetingListViewModelTest.kt index 1ade27a00f4..8d84d26cbfa 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/list/MeetingListViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/list/MeetingListViewModelTest.kt @@ -194,6 +194,7 @@ class MeetingListViewModelTest { title = "Meeting", startTime = startTime, endTime = startTime + 30.minutes, + tzid = "Europe/Berlin", recurrence = null, ), occurrenceId = "$meetingId-occurrence", diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt index 07534a6d238..1bcfdc4398e 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/options/MeetingOptionsMenuViewModelTest.kt @@ -273,6 +273,7 @@ class MeetingOptionsMenuViewModelTest { title = MEETING_TITLE, startTime = occurrenceStartTime, endTime = occurrenceEndTime, + tzid = "Europe/Berlin", recurrence = null, ), occurrenceId = OCCURRENCE_ID, diff --git a/kalium b/kalium index ddcdaba4c10..a625324094a 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit ddcdaba4c1034080f65eef9456cee1a23e512dac +Subproject commit a625324094a3829905e125f53073c3c6fe067643 From 91d3550d5959ad0aaf111221d58e79853e428345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 2 Sep 2026 16:13:19 +0200 Subject: [PATCH 2/4] pass tzid when updating --- .../meetings/ui/create/NewMeetingViewModel.kt | 18 +- .../ui/create/NewMeetingViewModelTest.kt | 185 +++++++++++++++++- 2 files changed, 188 insertions(+), 15 deletions(-) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt index a3cd44c4c41..4be0e251126 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/NewMeetingViewModel.kt @@ -142,9 +142,7 @@ class NewMeetingViewModelImpl @AssistedInject constructor( state = state.copy( startTime = meetingOccurrence.occurrenceStartTime, endTime = meetingOccurrence.occurrenceEndTime, - // always use the current local tzid, not the initial meeting's tzid, as it could be created in different tzid - // so when current user updates times and sets their local ones so it should also inherit their local tzid - tzid = currentTimeZoneProvider().id, + tzid = meetingOccurrence.meeting.tzid, repeatingInterval = meetingOccurrence.meeting.recurrence?.toRepeatingInterval(), selectedContacts = otherContacts, confirmedContacts = otherContacts, @@ -188,19 +186,29 @@ class NewMeetingViewModelImpl @AssistedInject constructor( override fun updateStartTime(startTime: Instant) { val currentDuration = state.endTime - state.startTime val latestEndTime = startTime.latestEndTimeOnSameDay(currentTimeZoneProvider()) + val updatedEndTime = minOf(startTime.plus(currentDuration), latestEndTime) state = state.copy( startTime = startTime, // adjust end time based on the new start time but try to keep the same duration, unless it extends into the next day - endTime = minOf(startTime.plus(currentDuration), latestEndTime) + endTime = updatedEndTime, + tzid = tzidAfterTimeChange(startTime != state.startTime || updatedEndTime != state.endTime) ) validateStartAndEndTime() } override fun updateEndTime(endTime: Instant) { - state = state.copy(endTime = endTime) + state = state.copy( + endTime = endTime, + tzid = tzidAfterTimeChange(endTime != state.endTime) + ) validateStartAndEndTime() } + // While editing, keep the original meeting tzid unless the user changes a time value. + // Time picker changes are made in the user's local timezone, so edited times should carry the local tzid. + private fun tzidAfterTimeChange(timeChanged: Boolean): String = + if (type is NewMeetingType.Edit && !timeChanged) state.tzid else currentTimeZoneProvider().id + override fun updateRepeatingInterval(interval: MeetingItem.RepeatingInterval?) { state = state.copy(repeatingInterval = interval) } diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt index 29188d69da1..e0e9a28e335 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/NewMeetingViewModelTest.kt @@ -773,6 +773,167 @@ class NewMeetingViewModelTest { assertEquals(NewMeetingState.InitialLoadingState.Error, viewModel.state.initialLoading) } + @Test + fun givenLocalTimeZone_whenSubmitCreationIsCalled_thenCurrentLocalTzidIsPassed() = runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val localTimeZoneID = "Europe/Berlin" + val createMeeting = UPSERT_MEETING.copy( + startTime = currentTime + 2.hours, + endTime = currentTime + 3.hours, + tzid = localTimeZoneID, + recurrence = null, + otherParticipants = emptyList() + ) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(NewMeetingType.Schedule) + .withCurrentTimeProvider { currentTime } + .withCurrentTimeZone(localTimeZoneID) + .withCreateMeetingResult(CreateNewMeetingUseCase.Result.Success) + ) + enterTitle(viewModel, createMeeting.title) + viewModel.updateStartTime(createMeeting.startTime) + viewModel.updateEndTime(createMeeting.endTime) + + viewModel.actions.test { + viewModel.submitCreation() + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.createNewMeeting(createMeeting) } + assertEquals(NewMeetingViewActions.Success, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun givenEditTypeAndMeetingInDifferentTimeZone_whenViewModelIsCreated_thenStateRetainsOriginalMeetingTzid() = + runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val meetingTimeZoneId = "America/New_York" + val localTimeZoneID = "Europe/Berlin" + val nextOccurrence = MEETING_OCCURRENCE.copy(meeting = MEETING_OCCURRENCE.meeting.copy(tzid = meetingTimeZoneId)) + val editType = NewMeetingType.Edit(nextOccurrence.meeting.meetingId) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withCurrentTimeProvider { currentTime } + .withCurrentTimeZone(localTimeZoneID) + .withNextUnfinishedMeetingOccurrence(nextOccurrence) + ) + + coVerify(exactly = 1) { arrangement.getNextUnfinishedMeetingOccurrence(editType.id, currentTime) } + assertEquals(meetingTimeZoneId, viewModel.state.tzid) + assertEquals(NewMeetingState.InitialLoadingState.Loaded, viewModel.state.initialLoading) + } + + @Test + fun givenEditTypeAndMeetingInDifferentTimeZone_whenSubmitUpdateWithoutTimeChanges_thenOriginalMeetingTzidIsPassed() = + runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val meetingTimeZoneId = "America/New_York" + val localTimeZoneID = "Europe/Berlin" + val nextOccurrence = MEETING_OCCURRENCE.copy(meeting = MEETING_OCCURRENCE.meeting.copy(tzid = meetingTimeZoneId)) + val editType = NewMeetingType.Edit(nextOccurrence.meeting.meetingId) + val updateMeeting = UPSERT_MEETING.copy( + startTime = nextOccurrence.occurrenceStartTime, + endTime = nextOccurrence.occurrenceEndTime, + tzid = meetingTimeZoneId, // keep the original tzid since no time changes are made + otherParticipants = emptyList() + ) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withCurrentTimeProvider { currentTime } + .withCurrentTimeZone(localTimeZoneID) + .withNextUnfinishedMeetingOccurrence(nextOccurrence) + .withUpdateMeetingResult(nextOccurrence.meeting.meetingId, UpdateMeetingUseCase.Result.Success) + ) + + // only update the title, keep the original start and end times, so no time changes are made and no tzid change is expected + enterTitle(viewModel, updateMeeting.title) + + viewModel.actions.test { + viewModel.submitUpdate() + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, updateMeeting) } + assertEquals(NewMeetingViewActions.Success, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun givenEditTypeAndMeetingInDifferentTimeZone_whenSubmitUpdateWithStartTimeChanges_thenCurrentLocalTzidIsPassed() = + runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val meetingTimeZoneId = "America/New_York" + val localTimeZoneID = "Europe/Berlin" + val updatedStartTime = currentTime + 3.hours + val nextOccurrence = MEETING_OCCURRENCE.copy(meeting = MEETING_OCCURRENCE.meeting.copy(tzid = meetingTimeZoneId)) + val editType = NewMeetingType.Edit(nextOccurrence.meeting.meetingId) + val updateMeeting = UPSERT_MEETING.copy( + startTime = updatedStartTime, + endTime = updatedStartTime + 1.hours, // it's recalculated based on the original duration of the meeting + tzid = localTimeZoneID, // since time changes are made, the tzid should also be updated to the current local tzid + otherParticipants = emptyList() + ) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withCurrentTimeProvider { currentTime } + .withCurrentTimeZone(localTimeZoneID) + .withNextUnfinishedMeetingOccurrence(nextOccurrence) + .withUpdateMeetingResult(nextOccurrence.meeting.meetingId, UpdateMeetingUseCase.Result.Success) + ) + + viewModel.updateStartTime(updatedStartTime) + + viewModel.actions.test { + viewModel.submitUpdate() + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, updateMeeting) } + assertEquals(NewMeetingViewActions.Success, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun givenEditTypeAndMeetingInDifferentTimeZone_whenSubmitUpdateWithEndTimeChanges_thenCurrentLocalTzidIsPassed() = + runTest(dispatcher) { + val currentTime = Instant.parse("2026-01-01T12:00:00Z") + val meetingTimeZoneId = "America/New_York" + val localTimeZoneID = "Europe/Berlin" + val nextOccurrence = MEETING_OCCURRENCE.copy(meeting = MEETING_OCCURRENCE.meeting.copy(tzid = meetingTimeZoneId)) + val updatedEndTime = nextOccurrence.occurrenceStartTime + 2.hours + val editType = NewMeetingType.Edit(nextOccurrence.meeting.meetingId) + val updateMeeting = UPSERT_MEETING.copy( + startTime = nextOccurrence.occurrenceStartTime, + endTime = updatedEndTime, + tzid = localTimeZoneID, // since time changes are made, the tzid should also be updated to the current local tzid + otherParticipants = emptyList() + ) + val (arrangement, viewModel) = arrangeViewModel( + Arrangement(dispatcher) + .withNewMeetingType(editType) + .withCurrentTimeProvider { currentTime } + .withCurrentTimeZone(localTimeZoneID) + .withNextUnfinishedMeetingOccurrence(nextOccurrence) + .withUpdateMeetingResult(nextOccurrence.meeting.meetingId, UpdateMeetingUseCase.Result.Success) + ) + + viewModel.updateEndTime(updatedEndTime) + + viewModel.actions.test { + viewModel.submitUpdate() + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.updateMeeting(editType.id, updateMeeting) } + assertEquals(NewMeetingViewActions.Success, awaitItem()) + cancelAndConsumeRemainingEvents() + } + } + private fun TestScope.arrangeViewModel( arrangement: Arrangement = Arrangement(dispatcher) ): Pair = @@ -835,6 +996,10 @@ class NewMeetingViewModelTest { currentTimeProvider = CurrentTimeProvider(currentTime) } + fun withCurrentTimeZone(timeZoneId: String) = apply { + every { currentTimeZoneProvider.invoke() } returns TimeZone.of(timeZoneId) + } + fun withCreateMeetingResult(result: CreateNewMeetingUseCase.Result) = apply { coEvery { createNewMeeting(any()) } returns result } @@ -884,11 +1049,15 @@ class NewMeetingViewModelTest { meetingId = MeetingId("meeting-id", "domain"), conversationId = ConversationId("conversation-id", "domain"), creatorId = UserId("creator-id", "domain"), - title = "Daily", + title = "Repeating meeting", startTime = Instant.parse("2026-01-01T09:00:00Z"), endTime = Instant.parse("2026-01-01T10:00:00Z"), tzid = MEETING_TIME_ZONE_ID, - recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), + recurrence = Meeting.Recurrence( + frequency = MeetingItem.RepeatingInterval.Supported.first().frequency, + interval = MeetingItem.RepeatingInterval.Supported.first().interval.toLong(), + until = null + ), ), selfRole = MeetingOccurrence.SelfRole.Creator, conversationName = "Daily", @@ -899,15 +1068,11 @@ class NewMeetingViewModelTest { ) private val CONTACT = contact("contact-1") private val UPSERT_MEETING = UpsertMeeting( - title = "Weekly sync", - startTime = Instant.parse("2026-01-01T09:00:00Z"), - endTime = Instant.parse("2026-01-01T10:00:00Z"), + title = MEETING_OCCURRENCE.meeting.title, + startTime = MEETING_OCCURRENCE.meeting.startTime, + endTime = MEETING_OCCURRENCE.meeting.endTime, tzid = MEETING_TIME_ZONE_ID, - recurrence = Meeting.Recurrence( - frequency = MeetingItem.RepeatingInterval.Supported.first().frequency, - interval = MeetingItem.RepeatingInterval.Supported.first().interval.toLong(), - until = null - ), + recurrence = MEETING_OCCURRENCE.meeting.recurrence, otherParticipants = listOf(UserId(CONTACT.id, CONTACT.domain)) ) } From 661cf8a814190fa5ccd6e981f715b6fc4d2dcae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 3 Sep 2026 17:44:14 +0200 Subject: [PATCH 3/4] update kalium --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 987be77ff9f..c196f7e285d 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 987be77ff9f0fcfe2cbf47c0ef11f9fb8390c97e +Subproject commit c196f7e285d49cf9ced0720769528c50551a871b From 9b78c3c648a63e0c93dce7e505101009b5ed176c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Tue, 8 Sep 2026 13:47:13 +0200 Subject: [PATCH 4/4] update kalium --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index c196f7e285d..734b8ec308d 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit c196f7e285d49cf9ced0720769528c50551a871b +Subproject commit 734b8ec308df6a5bca78de67aeb5bd94a6109ec5