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..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 @@ -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,7 @@ class NewMeetingViewModelImpl @AssistedInject constructor( state = state.copy( startTime = meetingOccurrence.occurrenceStartTime, endTime = meetingOccurrence.occurrenceEndTime, + tzid = meetingOccurrence.meeting.tzid, repeatingInterval = meetingOccurrence.meeting.recurrence?.toRepeatingInterval(), selectedContacts = otherContacts, confirmedContacts = otherContacts, @@ -180,20 +185,30 @@ class NewMeetingViewModelImpl @AssistedInject constructor( override fun updateStartTime(startTime: Instant) { val currentDuration = state.endTime - state.startTime - val latestEndTime = startTime.latestEndTimeOnSameDay() + 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) } @@ -256,6 +271,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 +298,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 +338,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 +354,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 +377,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 +403,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..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 @@ -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() ) @@ -769,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 = @@ -788,11 +953,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 +983,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()) } @@ -827,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 } @@ -860,6 +1033,7 @@ class NewMeetingViewModelTest { fun arrange() = this to NewMeetingViewModelImpl( navArgs = NewMeetingNavArgs(type = newMeetingType), currentTimeProvider = currentTimeProvider, + currentTimeZoneProvider = currentTimeZoneProvider, createNewMeeting = createNewMeeting, updateMeeting = updateMeeting, getNextUnfinishedMeetingOccurrence = getNextUnfinishedMeetingOccurrence, @@ -869,15 +1043,21 @@ class NewMeetingViewModelTest { ) } + private val MEETING_TIME_ZONE_ID = "Europe/Berlin" private val MEETING_OCCURRENCE = MeetingOccurrence( meeting = Meeting( 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"), - recurrence = Meeting.Recurrence(frequency = Meeting.Recurrence.Frequency.DAILY, interval = 1L, until = null), + tzid = MEETING_TIME_ZONE_ID, + recurrence = Meeting.Recurrence( + frequency = MeetingItem.RepeatingInterval.Supported.first().frequency, + interval = MeetingItem.RepeatingInterval.Supported.first().interval.toLong(), + until = null + ), ), selfRole = MeetingOccurrence.SelfRole.Creator, conversationName = "Daily", @@ -888,14 +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"), - recurrence = Meeting.Recurrence( - frequency = MeetingItem.RepeatingInterval.Supported.first().frequency, - interval = MeetingItem.RepeatingInterval.Supported.first().interval.toLong(), - until = null - ), + title = MEETING_OCCURRENCE.meeting.title, + startTime = MEETING_OCCURRENCE.meeting.startTime, + endTime = MEETING_OCCURRENCE.meeting.endTime, + tzid = MEETING_TIME_ZONE_ID, + recurrence = MEETING_OCCURRENCE.meeting.recurrence, otherParticipants = listOf(UserId(CONTACT.id, CONTACT.domain)) ) } 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 22c8a47c01c..7bbfac1f917 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 @@ -324,6 +324,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 48c4db40ddf..734b8ec308d 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 48c4db40ddf6abe5708d0299d4fe7bbe125f4f91 +Subproject commit 734b8ec308df6a5bca78de67aeb5bd94a6109ec5