From 45b8570f0355beb2b000a93ee8b69be7c7706c96 Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Fri, 28 Aug 2026 13:02:35 +0200 Subject: [PATCH] fix(navigation): restore meeting edit and unify home actions --- .../runtime/WireNavigation3Contributions.kt | 10 +-- .../WireNavigation3ProductionActions.kt | 43 ++++++------ .../android/ui/home/HomeNavigation3Entry.kt | 28 ++++---- .../ui/home/HomeNavigation3TopLevelContent.kt | 35 ++++------ .../android/ui/home/archive/ArchiveScreen.kt | 12 ++-- .../ConversationNavigation3Entries.kt | 14 ++-- .../ConversationsScreenContent.kt | 14 +++- .../all/AllConversationsScreen.kt | 12 ++-- .../ui/home/meetings/MeetingsScreen.kt | 10 ++- .../ui/home/whatsnew/WhatsNewScreen.kt | 25 ++++--- .../WireNavigation3ContributionsTest.kt | 7 +- .../WireNavigation3ProductionActionsTest.kt | 26 +++++-- .../ui/home/HomeTopLevelNavigation3Test.kt | 67 ++++++++++++++++--- .../home/meetings/MeetingsScreenSourceTest.kt | 42 ++++++++++++ app/stability/app-devDebug.stability | 19 +++--- ...o-navigation-3-with-kmp-ready-contracts.md | 9 +++ .../ui/MeetingsHomeNavigationActions.kt | 18 +++++ .../ui/create/MeetingsNavigation3Entries.kt | 1 + .../create/MeetingsNavigation3SourceTest.kt | 12 ++++ 19 files changed, 288 insertions(+), 116 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/meetings/MeetingsScreenSourceTest.kt create mode 100644 features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsHomeNavigationActions.kt diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt index 25e94144d14..ad061270856 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3Contributions.kt @@ -31,7 +31,7 @@ import com.wire.android.ui.home.appLock.AppLockNavigation3Actions import com.wire.android.ui.home.appLock.AppLockNavigation3Contribution import com.wire.android.ui.home.conversations.ConversationAuxNavigation3Actions import com.wire.android.ui.home.conversations.ConversationAuxNavigation3Contribution -import com.wire.android.ui.home.conversations.ConversationNavigation3Actions +import com.wire.android.ui.home.conversations.ConversationEntryNavigation3Actions import com.wire.android.ui.home.conversations.ConversationNavigation3Contribution import com.wire.android.ui.home.conversations.details.ConversationDetailsNavigation3Actions import com.wire.android.ui.home.conversations.details.ConversationDetailsNavigation3Contribution @@ -50,9 +50,9 @@ import com.wire.android.ui.userprofile.teammigration.TeamMigrationNavigation3Con /** * The one host boundary required by every currently migrated Navigation 3 contribution. * - * [HomeNavigation3Actions] deliberately exposes its existing nested callback bundles for the - * Conversations and Cells top-level content. Its top-level and settings interfaces, however, are - * this same host contract, avoiding a second action implementation with subtly different state. + * [HomeNavigation3Actions] exposes one aggregate of feature-owned top-level contracts. This + * composite supplies entry-coupled host interfaces directly. Framework-neutral Home-root action + * bundles remain explicit properties implemented once by the production host. */ internal interface WireNavigation3CompositeActions : AuthenticationNavigation3Actions, @@ -63,7 +63,7 @@ internal interface WireNavigation3CompositeActions : DeviceE2EINavigation3Actions, UserProfileNavigation3Actions, TeamMigrationNavigation3Actions, - ConversationNavigation3Actions, + ConversationEntryNavigation3Actions, ConversationAuxNavigation3Actions, ConversationDetailsNavigation3Actions, MediaNavigation3Actions, diff --git a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt index 5c36b81e2d3..1ba53f2fc06 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActions.kt @@ -27,6 +27,7 @@ import com.wire.android.feature.cells.navigation.ConversationFilesRoute import com.wire.android.feature.cells.navigation.PublicLinkRoute import com.wire.android.feature.cells.navigation.SearchRoute import com.wire.android.feature.cells.navigation.VideoPlayerRoute +import com.wire.android.feature.meetings.ui.MeetingsHomeNavigationActions import com.wire.android.feature.meetings.ui.create.MeetingParticipantId import com.wire.android.feature.meetings.ui.create.NewMeetingDetailsRoute import com.wire.android.navigation.LoginTypeSelector @@ -60,13 +61,14 @@ import com.wire.android.ui.home.conversations.AddMembersSearchRoute import com.wire.android.ui.home.conversations.toNavigation3 import com.wire.android.ui.home.conversations.details.ConversationDetailsId import com.wire.android.ui.home.conversations.details.participants.model.UIParticipant -import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions +import com.wire.android.ui.home.conversationslist.ConversationListNavigationActions import com.wire.android.ui.home.newconversation.NewConversationSearchPeopleRoute import com.wire.android.ui.home.settings.AboutThisAppRoute import com.wire.android.ui.home.settings.AppSettingsRoute import com.wire.android.ui.home.settings.MyAccountRoute import com.wire.android.ui.home.settings.SettingsNavigation3Destination -import com.wire.android.ui.home.whatsnew.WhatsNewNavigation3Target +import com.wire.android.ui.home.whatsnew.WhatsNewNavigationActions +import com.wire.android.ui.home.whatsnew.WhatsNewNavigationTarget import com.wire.android.ui.settings.devices.DeviceDetailsRoute import com.wire.android.ui.settings.devices.DeviceTargetUserId import com.wire.android.ui.settings.devices.SelfDevicesRoute @@ -80,7 +82,6 @@ import com.wire.android.ui.userprofile.self.SelfUserProfileRoute import com.wire.android.ui.userprofile.service.ServiceDetailsRoute import com.wire.android.ui.userprofile.service.ServiceProfileTarget import com.wire.android.ui.userprofile.teammigration.TeamMigrationTeamPlanRoute -import com.wire.android.feature.meetings.ui.create.NewMeetingType import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID @@ -196,10 +197,10 @@ internal class WireNavigation3ProductionActions( ) }, ) - override val conversations: ConversationsNavigationActions = ConversationsNavigationActions( + override val conversationList: ConversationListNavigationActions = ConversationListNavigationActions( openConversation = { openConversation(it.toProfileId()) }, openUserProfile = { openUserProfile(it.value, it.domain) }, - startConversation = ::openNewConversation, + startConversation = { navigate(NewConversationSearchPeopleRoute.start(requireSession())) }, browseChannels = { navigate(BrowseChannelsRoute(requireSession())) }, openConversationFolders = {}, promoteAdmin = { args -> @@ -213,6 +214,23 @@ internal class WireNavigation3ProductionActions( }, openDebugMenu = { navigate(DebugConversationRoute(requireSession(), it.conversationId.toAuxId())) }, ) + override val meetings: MeetingsHomeNavigationActions = MeetingsHomeNavigationActions( + openNewMeeting = { type -> + navigate(NewMeetingDetailsRoute.start(requireSession(), type)) + }, + ) + override val whatsNew: WhatsNewNavigationActions = WhatsNewNavigationActions( + openWhatsNew = { target -> + when (target) { + WhatsNewNavigationTarget.Welcome -> + activity.openIntent(WireNavigation3ExternalIntent.WELCOME_ANDROID) + WhatsNewNavigationTarget.AllAndroidReleaseNotes -> + activity.openIntent(WireNavigation3ExternalIntent.ANDROID_RELEASE_NOTES) + is WhatsNewNavigationTarget.ExternalReleaseNote -> + activity.openUrl(target.url) + } + }, + ) override fun canUseNewLogin() = loginTypeSelector.canUseNewLogin() override fun exitAuthentication() = activity.finish() @@ -223,9 +241,6 @@ internal class WireNavigation3ProductionActions( override fun onRequirement(requirement: HomeRequirement) = authenticationRouter.homeRequirement(requirement, currentSessionId()).let { Unit } - override fun openNewConversation() = - navigate(NewConversationSearchPeopleRoute.start(requireSession())) - override fun openSelfProfile() = navigate(SelfUserProfileRoute(requireSession())) override fun openExternal(destination: HomeExternalDestination) = @@ -236,18 +251,6 @@ internal class WireNavigation3ProductionActions( } ) - override fun openWhatsNew(target: WhatsNewNavigation3Target) = - when (target) { - WhatsNewNavigation3Target.Welcome -> - activity.openIntent(WireNavigation3ExternalIntent.WELCOME_ANDROID) - WhatsNewNavigation3Target.AllAndroidReleaseNotes -> - activity.openIntent(WireNavigation3ExternalIntent.ANDROID_RELEASE_NOTES) - is WhatsNewNavigation3Target.ExternalReleaseNote -> - activity.openUrl(target.url) - } - override fun openNewMeeting(type: NewMeetingType) = - navigate(NewMeetingDetailsRoute.start(requireSession(), type)) - override fun exitFlow() = activity.finish() override fun openUserProfile(userIdValue: String, userIdDomain: String) = navigate(OtherUserProfileRoute(requireSession(), UserProfileQualifiedId(userIdValue, userIdDomain))) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt index 4da1fc6987f..9e0e0a64104 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt @@ -68,7 +68,6 @@ import com.wire.android.ui.home.conversations.ConversationRouteId import com.wire.android.ui.home.drawer.HomeDrawerViewModel import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversationslist.filter.toTopBarTitle -import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions import com.wire.android.ui.home.conversationslist.all.AllConversationsContent import com.wire.android.ui.analyticsUsageViewModel import com.wire.android.util.ui.LazyListStateProvider @@ -146,14 +145,13 @@ internal fun HomeTopLevelDestination.backDestination(): HomeTopLevelDestination? * The typed boundary between the Home shell and children that are migrated independently. * * No generated direction or NavController crosses this API. A child can therefore move to a typed - * route without changing the drawer/top-bar owner. + * route without changing the drawer/top-bar owner. Home-owned shell and chrome actions stay here; + * child-specific navigation is available only through [topLevel]. */ internal interface HomeNavigation3Actions { - val conversations: ConversationsNavigationActions val topLevel: HomeTopLevelNavigation3Actions fun onRequirement(requirement: HomeRequirement) - fun openNewConversation() fun openSelfProfile() fun openExternal(destination: HomeExternalDestination) } @@ -282,8 +280,10 @@ private fun HomeNavigation3Entry( } } - val conversationsNavigationActions = remember(route.sessionId, runtime, actions.conversations) { - actions.conversations.copy( + val baseTopLevelActions = actions.topLevel + val baseConversationListActions = baseTopLevelActions.conversationList + val conversationListNavigationActions = remember(route.sessionId, runtime, baseConversationListActions) { + baseConversationListActions.copy( openConversation = { conversationId -> val requestId = runtime.navigateForResult( destination = ConversationRoute( @@ -293,7 +293,7 @@ private fun HomeNavigation3Entry( resultType = ConversationCompletionNavigation3ResultType, ) if (requestId == null) { - actions.conversations.openConversation(conversationId) + baseConversationListActions.openConversation(conversationId) } else { conversationRequestIdValue = requestId.value } @@ -307,7 +307,7 @@ private fun HomeNavigation3Entry( resultType = ConnectionRequestIgnoredNavigation3ResultType, ) if (requestId == null) { - actions.conversations.openUserProfile(userId) + baseConversationListActions.openUserProfile(userId) } else { userProfileRequestIdValue = requestId.value } @@ -328,6 +328,11 @@ private fun HomeNavigation3Entry( }, ) } + val topLevelActions = remember(baseTopLevelActions, conversationListNavigationActions) { + object : HomeTopLevelNavigation3Actions by baseTopLevelActions { + override val conversationList = conversationListNavigationActions + } + } if (analyticsUsageViewModel.state.shouldDisplayDialog) { AnalyticsUsageDialog( @@ -340,7 +345,7 @@ private fun HomeNavigation3Entry( homeState = homeViewModel.homeState, homeDrawerState = homeDrawerViewModel.drawerState, homeStateHolder = shellState, - onNewConversationClick = actions::openNewConversation, + onNewConversationClick = topLevelActions.conversationList.startConversation, onSelfUserClick = actions::openSelfProfile, onNavigateToHomeItem = { item -> when (val target = item.toNavigation3Target()) { @@ -352,7 +357,7 @@ private fun HomeNavigation3Entry( when (shellState.selectedDestination) { HomeTopLevelDestination.CONVERSATIONS -> AllConversationsContent( homeShellState = shellState, - navigationActions = conversationsNavigationActions, + navigationActions = topLevelActions.conversationList, ) else -> HomeNavigation3TopLevelContent( @@ -360,8 +365,7 @@ private fun HomeNavigation3Entry( shellState = shellState, sessionId = route.sessionId, runtime = runtime, - actions = actions.topLevel, - conversationsNavigationActions = conversationsNavigationActions, + actions = topLevelActions, ) } }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt index 581a49c8121..955caca1b8d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt @@ -25,40 +25,34 @@ import com.wire.android.feature.cells.ui.AllFilesNavigationActions import com.wire.android.feature.cells.ui.CellFilesNavArgs import com.wire.android.feature.cells.ui.CellViewModel import com.wire.android.feature.cells.ui.cellViewModel -import com.wire.android.feature.meetings.ui.create.NewMeetingType +import com.wire.android.feature.meetings.ui.MeetingsHomeNavigationActions import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.navigation3.WireNavigation3Runtime import com.wire.android.ui.home.archive.ArchiveScreen import com.wire.android.ui.home.cell.GlobalCellsScreen -import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions +import com.wire.android.ui.home.conversationslist.ConversationListNavigationActions import com.wire.android.ui.home.meetings.MeetingsScreen import com.wire.android.ui.home.settings.SettingsNavigation3Actions import com.wire.android.ui.home.settings.SettingsNavigation3Root import com.wire.android.ui.home.vault.VaultScreen -import com.wire.android.ui.home.whatsnew.WhatsNewNavigation3Target +import com.wire.android.ui.home.whatsnew.WhatsNewNavigationActions import com.wire.android.ui.home.whatsnew.WhatsNewScreen import com.wire.navigation.WireSessionId /** - * Cross-batch actions emitted by Home top-level children. + * Single composition contract for navigation emitted by Home top-level children. * - * Generated directions and a Nav2 controller are intentionally excluded. The production host can - * map these actions to typed feature routes as each owning batch lands. + * Each interactive child exposes one feature-owned semantic contract as a property here. Do not + * add feature-specific flat functions to this aggregate. Children without navigation actions need + * no property, while roots sharing the same action surface reuse one contract (Archive reuses + * Conversations). */ internal interface HomeTopLevelNavigation3Actions { + val conversationList: ConversationListNavigationActions val settings: SettingsNavigation3Actions - - /** - * Temporary semantic boundary for Cells details whose typed contracts are owned by the Cells - * migration: Search, Public Link, Add/Remove Tags, Image Viewer and Video Player. - */ val cells: AllFilesNavigationActions - - /** Welcome/release-note detail contracts are migrated by the What's New detail batch. */ - fun openWhatsNew(target: WhatsNewNavigation3Target) - - /** New Meeting is owned by the Meetings feature graph and is migrated with that graph. */ - fun openNewMeeting(type: NewMeetingType) + val whatsNew: WhatsNewNavigationActions + val meetings: MeetingsHomeNavigationActions } /** @@ -74,7 +68,6 @@ internal fun HomeNavigation3TopLevelContent( sessionId: WireSessionId, runtime: WireNavigation3Runtime, actions: HomeTopLevelNavigation3Actions, - conversationsNavigationActions: ConversationsNavigationActions, ) { when (destination) { HomeTopLevelDestination.CONVERSATIONS -> @@ -91,19 +84,19 @@ internal fun HomeNavigation3TopLevelContent( HomeTopLevelDestination.ARCHIVE -> ArchiveScreen( homeShellState = shellState, - navigationActions = conversationsNavigationActions, + navigationActions = actions.conversationList, ) HomeTopLevelDestination.WHATS_NEW -> WhatsNewScreen( homeShellState = shellState, - onOpenTarget = actions::openWhatsNew, + navigationActions = actions.whatsNew, ) HomeTopLevelDestination.CELLS -> Navigation3GlobalCells(actions.cells) HomeTopLevelDestination.MEETINGS -> MeetingsScreen( homeShellState = shellState, - onOpenNewMeeting = actions::openNewMeeting, + navigationActions = actions.meetings, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt index 447ea0519b2..79ee07abd39 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/archive/ArchiveScreen.kt @@ -24,7 +24,7 @@ import com.wire.android.navigation.HomeDestination import com.wire.android.ui.common.search.rememberSearchbarState import com.wire.android.ui.home.HomeShellState import com.wire.android.ui.home.conversationslist.ConversationListViewModelPreview -import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions +import com.wire.android.ui.home.conversationslist.ConversationListNavigationActions import com.wire.android.ui.home.conversationslist.ConversationsScreenContent import com.wire.android.ui.home.conversationslist.common.previewConversationItemsFlow import com.wire.android.ui.home.conversationslist.model.ConversationsSource @@ -34,7 +34,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes @Composable internal fun ArchiveScreen( homeShellState: HomeShellState, - navigationActions: ConversationsNavigationActions, + navigationActions: ConversationListNavigationActions, ) { with(homeShellState) { ConversationsScreenContent( @@ -54,7 +54,7 @@ internal fun ArchiveScreen( @Composable fun PreviewArchiveEmptyScreen() = WireTheme { ConversationsScreenContent( - navigationActions = previewConversationsNavigationActions(), + navigationActions = previewConversationListNavigationActions(), searchBarState = rememberSearchbarState(), conversationsSource = ConversationsSource.ARCHIVE, emptyListContent = { ArchiveEmptyContent() }, @@ -66,7 +66,7 @@ fun PreviewArchiveEmptyScreen() = WireTheme { @Composable fun PreviewArchiveEmptySearchScreen() = WireTheme { ConversationsScreenContent( - navigationActions = previewConversationsNavigationActions(), + navigationActions = previewConversationListNavigationActions(), searchBarState = rememberSearchbarState(initialIsSearchActive = true, searchQueryTextState = TextFieldState(initialText = "er")), conversationsSource = ConversationsSource.ARCHIVE, emptyListContent = { ArchiveEmptyContent() }, @@ -78,7 +78,7 @@ fun PreviewArchiveEmptySearchScreen() = WireTheme { @Composable fun PreviewArchiveScreen() = WireTheme { ConversationsScreenContent( - navigationActions = previewConversationsNavigationActions(), + navigationActions = previewConversationListNavigationActions(), searchBarState = rememberSearchbarState(initialIsSearchActive = true, searchQueryTextState = TextFieldState(initialText = "er")), conversationsSource = ConversationsSource.ARCHIVE, emptyListContent = { ArchiveEmptyContent() }, @@ -86,7 +86,7 @@ fun PreviewArchiveScreen() = WireTheme { ) } -private fun previewConversationsNavigationActions() = ConversationsNavigationActions( +private fun previewConversationListNavigationActions() = ConversationListNavigationActions( openConversation = {}, openUserProfile = {}, startConversation = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt index 0f09cdbda64..17e88003738 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt @@ -58,10 +58,12 @@ internal val ConversationCompletionNavigation3ResultType = WireNavigation3Result ) /** - * The drawing destination is owned by a feature module that is migrated independently. - * This explicit semantic bridge keeps its generated destination out of the Navigation 3 entry. + * Host actions for the Navigation 3 conversation entry and its result lifecycle. + * + * Unlike reusable screen action bundles, this contract intentionally includes `Navigation3` in + * its name because it completes a typed Navigation 3 result and closes the owning entry. */ -internal interface ConversationNavigation3Actions { +internal interface ConversationEntryNavigation3Actions { fun exitConversation() fun completeConversation(result: ConversationCompletionResult) } @@ -72,13 +74,13 @@ internal object ConversationNavigation3Contribution { fun entryProviderInstallers( runtime: WireNavigation3Runtime, - actions: ConversationNavigation3Actions, + actions: ConversationEntryNavigation3Actions, ): List = listOf(conversationNavigation3Entries(runtime, actions)) } internal fun conversationNavigation3Entries( runtime: WireNavigation3Runtime, - actions: ConversationNavigation3Actions, + actions: ConversationEntryNavigation3Actions, ): WireEntryProviderInstaller = { // ConversationScreen had no destination override and inherited WireRoot's horizontal motion. wireEntry(presentation = WireEntryPresentation.Slide) { route -> @@ -90,7 +92,7 @@ internal fun conversationNavigation3Entries( private fun ConversationNavigation3Entry( route: ConversationRoute, runtime: WireNavigation3Runtime, - actions: ConversationNavigation3Actions, + actions: ConversationEntryNavigation3Actions, ) { val viewModelArgs = remember(route) { route.toViewModelArgs() } var groupRequestId by rememberSaveable(route.entryId.value) { mutableStateOf(null) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 8ba130b2580..0cc4eccebd3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -73,8 +73,16 @@ import com.wire.android.util.ui.collectAsLazyPagingItemsWithLifecycle import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId +/** + * Navigation-neutral semantic actions shared by conversation-list roots. + * + * The name intentionally omits `Navigation3`: the list UI can be hosted by any navigation + * implementation. Framework-specific result handling belongs to the Navigation 3 Home owner, + * which decorates this bundle before exposing it through + * [com.wire.android.ui.home.HomeTopLevelNavigation3Actions]. + */ @Suppress("LongParameterList") -data class ConversationsNavigationActions( +data class ConversationListNavigationActions( val openConversation: (ConversationId) -> Unit, val openUserProfile: (UserId) -> Unit, val startConversation: () -> Unit, @@ -91,7 +99,7 @@ data class ConversationsNavigationActions( @Suppress("ComplexMethod", "NestedBlockDepth", "Wrapping", "SlotReused", "LongParameterList") @Composable fun ConversationsScreenContent( - navigationActions: ConversationsNavigationActions, + navigationActions: ConversationListNavigationActions, searchBarState: SearchBarState, modifier: Modifier = Modifier, emptyListContent: @Composable (domain: String) -> Unit = {}, @@ -304,7 +312,7 @@ private const val TAG = "BaseConversationsScreen" @Composable fun PreviewConversationsScreenContent() = WireTheme { ConversationsScreenContent( - navigationActions = ConversationsNavigationActions( + navigationActions = ConversationListNavigationActions( openConversation = {}, openUserProfile = {}, startConversation = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt index 4eab3ff5272..5a9cae41f4d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt @@ -30,7 +30,7 @@ import com.wire.android.ui.home.conversations.folder.ConversationFoldersStateArg import com.wire.android.ui.home.conversations.folder.ConversationFoldersVM import com.wire.android.ui.home.conversationslist.ConversationListViewModelPreview import com.wire.android.ui.home.conversationslist.ConversationsScreenContent -import com.wire.android.ui.home.conversationslist.ConversationsNavigationActions +import com.wire.android.ui.home.conversationslist.ConversationListNavigationActions import com.wire.android.ui.home.conversationslist.common.previewConversationItemsFlow import com.wire.android.ui.home.conversationslist.filter.ConversationFilterSheetContent import com.wire.android.ui.home.conversationslist.model.ConversationsSource @@ -41,7 +41,7 @@ import com.wire.kalium.logic.data.conversation.ConversationFilter @Composable internal fun AllConversationsContent( homeShellState: HomeShellState, - navigationActions: ConversationsNavigationActions, + navigationActions: ConversationListNavigationActions, foldersViewModel: ConversationFoldersVM = conversationFoldersViewModel(ConversationFoldersStateArgs(null)), ) { @@ -93,7 +93,7 @@ internal fun AllConversationsContent( @Composable fun PreviewAllConversationsEmptyScreen() = WireTheme { ConversationsScreenContent( - navigationActions = previewConversationsNavigationActions(), + navigationActions = previewConversationListNavigationActions(), searchBarState = rememberSearchbarState(), conversationsSource = ConversationsSource.MAIN, emptyListContent = { ConversationsEmptyContent(onBrowseChannels = {}) }, @@ -105,7 +105,7 @@ fun PreviewAllConversationsEmptyScreen() = WireTheme { @Composable fun PreviewAllConversationsEmptySearchScreen() = WireTheme { ConversationsScreenContent( - navigationActions = previewConversationsNavigationActions(), + navigationActions = previewConversationListNavigationActions(), searchBarState = rememberSearchbarState(initialIsSearchActive = true, searchQueryTextState = TextFieldState(initialText = "er")), conversationsSource = ConversationsSource.MAIN, emptyListContent = { ConversationsEmptyContent(onBrowseChannels = {}) }, @@ -117,7 +117,7 @@ fun PreviewAllConversationsEmptySearchScreen() = WireTheme { @Composable fun PreviewAllConversationsSearchScreen() = WireTheme { ConversationsScreenContent( - navigationActions = previewConversationsNavigationActions(), + navigationActions = previewConversationListNavigationActions(), searchBarState = rememberSearchbarState(initialIsSearchActive = true, searchQueryTextState = TextFieldState(initialText = "er")), conversationsSource = ConversationsSource.MAIN, emptyListContent = { ConversationsEmptyContent(onBrowseChannels = {}) }, @@ -125,7 +125,7 @@ fun PreviewAllConversationsSearchScreen() = WireTheme { ) } -private fun previewConversationsNavigationActions() = ConversationsNavigationActions( +private fun previewConversationListNavigationActions() = ConversationListNavigationActions( openConversation = {}, openUserProfile = {}, startConversation = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt index 83a9187a67b..dd320d0eb92 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import com.wire.android.feature.meetings.ui.AllMeetingsScreen +import com.wire.android.feature.meetings.ui.MeetingsHomeNavigationActions import com.wire.android.feature.meetings.ui.NewMeetingBottomSheet import com.wire.android.navigation.HomeDestination import com.wire.android.ui.common.dimensions @@ -38,7 +39,7 @@ import com.wire.kalium.logic.data.conversation.Conversation @Composable internal fun MeetingsScreen( homeShellState: HomeShellState, - onOpenNewMeeting: (NewMeetingType) -> Unit, + navigationActions: MeetingsHomeNavigationActions, viewModel: MeetingsCallViewModel = meetingsCallViewModel(), ) { val context = LocalContext.current @@ -64,6 +65,9 @@ internal fun MeetingsScreen( ) ) }, + editMeeting = { meetingId -> + navigationActions.openNewMeeting(NewMeetingType.Edit(meetingId)) + }, ) viewModel.callManager.actions.HandleActions() @@ -73,12 +77,12 @@ internal fun MeetingsScreen( sheetState = homeShellState.newMeetingBottomSheetState, onMeetNowClick = { homeShellState.newMeetingBottomSheetState.hide { - onOpenNewMeeting(NewMeetingType.MeetNow) + navigationActions.openNewMeeting(NewMeetingType.MeetNow) } }, onScheduleClick = { homeShellState.newMeetingBottomSheetState.hide { - onOpenNewMeeting(NewMeetingType.Schedule) + navigationActions.openNewMeeting(NewMeetingType.Schedule) } } ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewScreen.kt index 52f0bd84ec2..07df083f7d3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/whatsnew/WhatsNewScreen.kt @@ -40,16 +40,21 @@ import com.wire.android.ui.home.HomeShellState import com.wire.android.util.ui.sectionWithElements import com.wire.android.util.ui.UIText -internal sealed interface WhatsNewNavigation3Target { - data object Welcome : WhatsNewNavigation3Target - data object AllAndroidReleaseNotes : WhatsNewNavigation3Target - data class ExternalReleaseNote(val url: String) : WhatsNewNavigation3Target +internal sealed interface WhatsNewNavigationTarget { + data object Welcome : WhatsNewNavigationTarget + data object AllAndroidReleaseNotes : WhatsNewNavigationTarget + data class ExternalReleaseNote(val url: String) : WhatsNewNavigationTarget } -internal fun WhatsNewItem.toNavigation3Target(): WhatsNewNavigation3Target = when (this) { - WhatsNewItem.WelcomeToNewAndroidApp -> WhatsNewNavigation3Target.Welcome - is WhatsNewItem.AllAndroidReleaseNotes -> WhatsNewNavigation3Target.AllAndroidReleaseNotes - is WhatsNewItem.AndroidReleaseNotes -> WhatsNewNavigation3Target.ExternalReleaseNote(url) +/** Semantic navigation boundary owned by the What's New Home root. */ +internal data class WhatsNewNavigationActions( + val openWhatsNew: (WhatsNewNavigationTarget) -> Unit, +) + +internal fun WhatsNewItem.toNavigationTarget(): WhatsNewNavigationTarget = when (this) { + WhatsNewItem.WelcomeToNewAndroidApp -> WhatsNewNavigationTarget.Welcome + is WhatsNewItem.AllAndroidReleaseNotes -> WhatsNewNavigationTarget.AllAndroidReleaseNotes + is WhatsNewItem.AndroidReleaseNotes -> WhatsNewNavigationTarget.ExternalReleaseNote(url) } /** @@ -58,13 +63,13 @@ internal fun WhatsNewItem.toNavigation3Target(): WhatsNewNavigation3Target = whe @Composable internal fun WhatsNewScreen( homeShellState: HomeShellState, - onOpenTarget: (WhatsNewNavigation3Target) -> Unit, + navigationActions: WhatsNewNavigationActions, whatsNewViewModel: WhatsNewViewModel = whatsNewViewModel(), ) { WhatsNewScreenContent( state = whatsNewViewModel.state, lazyListState = homeShellState.lazyListStateFor(HomeDestination.WhatsNew), - onItemClicked = { onOpenTarget(it.toNavigation3Target()) }, + onItemClicked = { navigationActions.openWhatsNew(it.toNavigationTarget()) }, ) } diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt index 584a365b209..3517a55f27b 100644 --- a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ContributionsTest.kt @@ -108,8 +108,13 @@ class WireNavigation3ContributionsTest { assertFalse(source.contains(forbidden), "Aggregator must not reference $forbidden") } assertTrue(source.contains("interface WireNavigation3CompositeActions")) + assertTrue(source.contains("ConversationEntryNavigation3Actions")) + assertTrue(source.contains("MeetingsNavigation3Actions")) assertTrue(source.contains("override val topLevel: HomeTopLevelNavigation3Actions")) assertTrue(source.contains("override val settings: SettingsNavigation3Actions")) + assertFalse(source.contains("WhatsNewNavigationActions")) + assertFalse(source.contains("override val meetings:")) + assertFalse(source.contains("override val whatsNew:")) } private fun appSource(relativePath: String): File { @@ -117,7 +122,7 @@ class WireNavigation3ContributionsTest { } private fun projectSource(relativePath: String): File { - val projectDir = generateSequence(File(System.getProperty("user.dir"))) { it.parentFile } + val projectDir = generateSequence(File(checkNotNull(System.getProperty("user.dir")))) { it.parentFile } .first { File(it, "app/src/main/kotlin").isDirectory } return File(projectDir, relativePath).also { assertTrue(it.isFile, "Missing source file $relativePath") diff --git a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsTest.kt index c64eacca9f2..227397cb6fe 100644 --- a/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsTest.kt +++ b/app/src/test/kotlin/com/wire/android/navigation/runtime/WireNavigation3ProductionActionsTest.kt @@ -31,8 +31,10 @@ import com.wire.android.ui.e2eiEnrollment.E2EIEnrollmentRoute import com.wire.android.ui.home.HomeRequirement import com.wire.android.ui.home.conversations.ConversationRoute import com.wire.android.ui.home.conversations.details.ConversationDetailsId +import com.wire.android.ui.home.newconversation.NewConversationSearchPeopleRoute import com.wire.android.ui.home.settings.SettingsNavigation3Destination import com.wire.android.ui.settings.devices.SelfDevicesRoute +import com.wire.kalium.logic.data.id.MeetingId import com.wire.kalium.logic.data.user.UserId import com.wire.navigation.WireBackStackMode import com.wire.navigation.WireNavigationCommand @@ -135,21 +137,37 @@ internal class WireNavigation3ProductionActionsTest { assertEquals(WireBackStackMode.REMOVE_CURRENT_AND_REPLACE, command.backStackMode) } + @Test + fun `given conversation list start action, when invoked, then typed new-conversation route is navigated`() { + val (actions, navigator) = productionActions() + val command = slot() + every { navigator.navigate(capture(command)) } returns true + + actions.conversationList.startConversation() + + val route = assertInstanceOf(NewConversationSearchPeopleRoute::class.java, command.captured.destination) + assertEquals(Session, route.sessionId) + assertEquals(WireBackStackMode.NONE, command.captured.backStackMode) + } + @Test fun `given meeting type, when opening meeting flow, then typed details route is navigated directly`() { val (actions, navigator) = productionActions() val command = slot() + val meetingId = MeetingId("meeting", "wire.test") every { navigator.navigate(capture(command)) } returns true listOf( - NewMeetingType.MeetNow to NewMeetingRouteType.MEET_NOW, - NewMeetingType.Schedule to NewMeetingRouteType.SCHEDULE, - ).forEach { (legacyType, expectedType) -> - actions.openNewMeeting(legacyType) + Triple(NewMeetingType.MeetNow, NewMeetingRouteType.MEET_NOW, null), + Triple(NewMeetingType.Schedule, NewMeetingRouteType.SCHEDULE, null), + Triple(NewMeetingType.Edit(meetingId), NewMeetingRouteType.EDIT, meetingId), + ).forEach { (legacyType, expectedType, expectedMeetingId) -> + actions.meetings.openNewMeeting(legacyType) val route = assertInstanceOf(NewMeetingDetailsRoute::class.java, command.captured.destination) assertEquals(Session, route.sessionId) assertEquals(expectedType, route.type) + assertEquals(expectedMeetingId, route.meetingId) assertEquals(WireBackStackMode.NONE, command.captured.backStackMode) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/HomeTopLevelNavigation3Test.kt b/app/src/test/kotlin/com/wire/android/ui/home/HomeTopLevelNavigation3Test.kt index 37f44c19268..b386d82ca31 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/HomeTopLevelNavigation3Test.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/HomeTopLevelNavigation3Test.kt @@ -19,8 +19,8 @@ package com.wire.android.ui.home import com.wire.android.ui.home.whatsnew.WhatsNewItem -import com.wire.android.ui.home.whatsnew.WhatsNewNavigation3Target -import com.wire.android.ui.home.whatsnew.toNavigation3Target +import com.wire.android.ui.home.whatsnew.WhatsNewNavigationTarget +import com.wire.android.ui.home.whatsnew.toNavigationTarget import com.wire.android.util.ui.UIText import java.io.File import org.junit.jupiter.api.Assertions.assertEquals @@ -31,14 +31,14 @@ import org.junit.jupiter.api.Test class HomeTopLevelNavigation3Test { @Test - fun givenWhatsNewItems_whenMapped_thenOnlySemanticNavigation3TargetsCrossTheHomeBoundary() { + fun givenWhatsNewItems_whenMapped_thenOnlySemanticTargetsCrossTheHomeBoundary() { assertEquals( - WhatsNewNavigation3Target.Welcome, - WhatsNewItem.WelcomeToNewAndroidApp.toNavigation3Target(), + WhatsNewNavigationTarget.Welcome, + WhatsNewItem.WelcomeToNewAndroidApp.toNavigationTarget(), ) assertEquals( - WhatsNewNavigation3Target.AllAndroidReleaseNotes, - WhatsNewItem.AllAndroidReleaseNotes().toNavigation3Target(), + WhatsNewNavigationTarget.AllAndroidReleaseNotes, + WhatsNewItem.AllAndroidReleaseNotes().toNavigationTarget(), ) val releaseNote = WhatsNewItem.AndroidReleaseNotes( @@ -47,8 +47,8 @@ class HomeTopLevelNavigation3Test { url = "https://wire.com/release", ) assertEquals( - WhatsNewNavigation3Target.ExternalReleaseNote("https://wire.com/release"), - releaseNote.toNavigation3Target(), + WhatsNewNavigationTarget.ExternalReleaseNote("https://wire.com/release"), + releaseNote.toNavigationTarget(), ) } @@ -67,6 +67,46 @@ class HomeTopLevelNavigation3Test { } } + @Test + fun givenHomeTopLevelActions_whenDefiningChildNavigation_thenEveryChildUsesAnOwnedContract() { + val contract = sourceFile().readText() + .substringAfter("internal interface HomeTopLevelNavigation3Actions {") + .substringBefore("\n}") + + listOf( + "val conversationList: ConversationListNavigationActions", + "val settings: SettingsNavigation3Actions", + "val cells: AllFilesNavigationActions", + "val whatsNew: WhatsNewNavigationActions", + "val meetings: MeetingsHomeNavigationActions", + ).forEach { property -> + assertTrue(contract.contains(property), "Missing top-level child contract: $property") + } + assertFalse(contract.contains("fun "), "Feature-specific actions must not be flattened into Home") + } + + @Test + fun givenHomeShellActions_whenDefiningNavigation_thenChildNavigationIsOnlyExposedThroughTopLevel() { + val contract = homeEntrySourceFile().readText() + .substringAfter("internal interface HomeNavigation3Actions {") + .substringBefore("\n}") + val declarations = contract.lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .toList() + + assertEquals( + listOf( + "val topLevel: HomeTopLevelNavigation3Actions", + "fun onRequirement(requirement: HomeRequirement)", + "fun openSelfProfile()", + "fun openExternal(destination: HomeExternalDestination)", + ), + declarations, + "Home child navigation must be exposed only through the top-level aggregate", + ) + } + private fun sourceFile(): File { val relative = "src/main/kotlin/com/wire/android/ui/home/HomeNavigation3TopLevelContent.kt" return sequenceOf( @@ -76,6 +116,15 @@ class HomeTopLevelNavigation3Test { ).first(File::isFile) } + private fun homeEntrySourceFile(): File { + val relative = "src/main/kotlin/com/wire/android/ui/home/HomeNavigation3Entry.kt" + return sequenceOf( + File(relative), + File("app/$relative"), + File("../app/$relative"), + ).first(File::isFile) + } + private companion object { val forbiddenLegacyTokens = listOf( "DestinationsNavHost", diff --git a/app/src/test/kotlin/com/wire/android/ui/home/meetings/MeetingsScreenSourceTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/meetings/MeetingsScreenSourceTest.kt new file mode 100644 index 00000000000..76512f4b2a0 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/meetings/MeetingsScreenSourceTest.kt @@ -0,0 +1,42 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.meetings + +import java.io.File +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class MeetingsScreenSourceTest { + + @Test + fun `editing a meeting opens the typed Navigation 3 edit flow`() { + val source = sourceFile().readText().filterNot(Char::isWhitespace) + + assertTrue("navigationActions:MeetingsHomeNavigationActions" in source) + assertTrue( + "editMeeting={meetingId->navigationActions.openNewMeeting(NewMeetingType.Edit(meetingId))}," in source + ) + } + + private fun sourceFile(): File = + generateSequence(File(checkNotNull(System.getProperty("user.dir"))).absoluteFile) { it.parentFile } + .map { File(it, "app/src/main/kotlin/com/wire/android/ui/home/meetings/MeetingsScreen.kt") } + .firstOrNull(File::isFile) + ?: error("Unable to locate MeetingsScreen.kt") +} diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index 1017799bdaa..df2194c2e57 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -4027,7 +4027,7 @@ private fun com.wire.android.ui.home.HomeNavigation3Entry(route: com.wire.androi - analyticsUsageViewModel: UNSTABLE (has mutable properties or unstable members) @Composable -internal fun com.wire.android.ui.home.HomeNavigation3TopLevelContent(destination: com.wire.android.ui.home.HomeTopLevelDestination, shellState: com.wire.android.ui.home.HomeShellState, sessionId: com.wire.navigation.WireSessionId, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.HomeTopLevelNavigation3Actions, conversationsNavigationActions: com.wire.android.ui.home.conversationslist.ConversationsNavigationActions): kotlin.Unit +internal fun com.wire.android.ui.home.HomeNavigation3TopLevelContent(destination: com.wire.android.ui.home.HomeTopLevelDestination, shellState: com.wire.android.ui.home.HomeShellState, sessionId: com.wire.navigation.WireSessionId, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.HomeTopLevelNavigation3Actions): kotlin.Unit skippable: false restartable: true params: @@ -4036,7 +4036,6 @@ internal fun com.wire.android.ui.home.HomeNavigation3TopLevelContent(destination - sessionId: STABLE (class with no mutable properties) - runtime: STABLE (marked @Stable or @Immutable) - actions: RUNTIME (requires runtime check) - - conversationsNavigationActions: STABLE (class with no mutable properties) @Composable internal fun com.wire.android.ui.home.HomeScaffold(homeState: com.wire.android.ui.home.HomeState, state: com.wire.android.ui.home.HomeScaffoldState, drawerState: androidx.compose.material3.DrawerState, focusRequesters: com.wire.android.ui.home.HomeScaffoldFocusRequesters, actions: com.wire.android.ui.home.HomeScaffoldActions, content: @[Composable] androidx.compose.runtime.internal.ComposableFunction0): kotlin.Unit @@ -4238,7 +4237,7 @@ public fun com.wire.android.ui.home.archive.ArchiveEmptyContent(modifier: androi - modifier: STABLE (marked @Stable or @Immutable) @Composable -internal fun com.wire.android.ui.home.archive.ArchiveScreen(homeShellState: com.wire.android.ui.home.HomeShellState, navigationActions: com.wire.android.ui.home.conversationslist.ConversationsNavigationActions): kotlin.Unit +internal fun com.wire.android.ui.home.archive.ArchiveScreen(homeShellState: com.wire.android.ui.home.HomeShellState, navigationActions: com.wire.android.ui.home.conversationslist.ConversationListNavigationActions): kotlin.Unit skippable: false restartable: true params: @@ -4374,7 +4373,7 @@ internal fun com.wire.android.ui.home.conversations.ConversationMessageComposer( - isWireCellsEnabled: STABLE (primitive type) @Composable -private fun com.wire.android.ui.home.conversations.ConversationNavigation3Entry(route: com.wire.android.ui.home.conversations.ConversationRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.conversations.ConversationNavigation3Actions): kotlin.Unit +private fun com.wire.android.ui.home.conversations.ConversationNavigation3Entry(route: com.wire.android.ui.home.conversations.ConversationRoute, runtime: com.wire.android.navigation.navigation3.WireNavigation3Runtime, actions: com.wire.android.ui.home.conversations.ConversationEntryNavigation3Actions): kotlin.Unit skippable: false restartable: true params: @@ -7615,7 +7614,7 @@ public fun com.wire.android.ui.home.conversations.updateChannelAccessViewModel(a - args: STABLE (class with no mutable properties) @Composable -public fun com.wire.android.ui.home.conversationslist.ConversationsScreenContent(navigationActions: com.wire.android.ui.home.conversationslist.ConversationsNavigationActions, searchBarState: com.wire.android.ui.common.search.SearchBarState, modifier: androidx.compose.ui.Modifier, emptyListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, lazyListState: androidx.compose.foundation.lazy.LazyListState, loadingListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, conversationsSource: com.wire.android.ui.home.conversationslist.model.ConversationsSource, emptySearchResultFocusRequester: androidx.compose.ui.focus.FocusRequester?, firstConversationFocusRequester: androidx.compose.ui.focus.FocusRequester?, onConversationOpened: kotlin.Function0, conversationListCallViewModel: com.wire.android.ui.home.conversationslist.ConversationListCallViewModel, conversationListViewModel: com.wire.android.ui.home.conversationslist.ConversationListViewModel): kotlin.Unit +public fun com.wire.android.ui.home.conversationslist.ConversationsScreenContent(navigationActions: com.wire.android.ui.home.conversationslist.ConversationListNavigationActions, searchBarState: com.wire.android.ui.common.search.SearchBarState, modifier: androidx.compose.ui.Modifier, emptyListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, lazyListState: androidx.compose.foundation.lazy.LazyListState, loadingListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, conversationsSource: com.wire.android.ui.home.conversationslist.model.ConversationsSource, emptySearchResultFocusRequester: androidx.compose.ui.focus.FocusRequester?, firstConversationFocusRequester: androidx.compose.ui.focus.FocusRequester?, onConversationOpened: kotlin.Function0, conversationListCallViewModel: com.wire.android.ui.home.conversationslist.ConversationListCallViewModel, conversationListViewModel: com.wire.android.ui.home.conversationslist.ConversationListViewModel): kotlin.Unit skippable: false restartable: true params: @@ -7633,7 +7632,7 @@ public fun com.wire.android.ui.home.conversationslist.ConversationsScreenContent - conversationListViewModel: RUNTIME (requires runtime check) @Composable -internal fun com.wire.android.ui.home.conversationslist.all.AllConversationsContent(homeShellState: com.wire.android.ui.home.HomeShellState, navigationActions: com.wire.android.ui.home.conversationslist.ConversationsNavigationActions, foldersViewModel: com.wire.android.ui.home.conversations.folder.ConversationFoldersVM): kotlin.Unit +internal fun com.wire.android.ui.home.conversationslist.all.AllConversationsContent(homeShellState: com.wire.android.ui.home.HomeShellState, navigationActions: com.wire.android.ui.home.conversationslist.ConversationListNavigationActions, foldersViewModel: com.wire.android.ui.home.conversations.folder.ConversationFoldersVM): kotlin.Unit skippable: false restartable: true params: @@ -8044,12 +8043,12 @@ public fun com.wire.android.ui.home.homeViewModel(): com.wire.android.ui.home.Ho params: @Composable -internal fun com.wire.android.ui.home.meetings.MeetingsScreen(homeShellState: com.wire.android.ui.home.HomeShellState, onOpenNewMeeting: kotlin.Function1, viewModel: com.wire.android.ui.home.meetings.MeetingsCallViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.meetings.MeetingsScreen(homeShellState: com.wire.android.ui.home.HomeShellState, navigationActions: com.wire.android.feature.meetings.ui.MeetingsHomeNavigationActions, viewModel: com.wire.android.ui.home.meetings.MeetingsCallViewModel): kotlin.Unit skippable: false restartable: true params: - homeShellState: RUNTIME (requires runtime check) - - onOpenNewMeeting: STABLE (function type) + - navigationActions: STABLE (class with no mutable properties) - viewModel: UNSTABLE (has mutable properties or unstable members) @Composable @@ -10180,12 +10179,12 @@ public fun com.wire.android.ui.home.whatsnew.WhatsNewItem(modifier: androidx.com - isLoading: STABLE (primitive type) @Composable -internal fun com.wire.android.ui.home.whatsnew.WhatsNewScreen(homeShellState: com.wire.android.ui.home.HomeShellState, onOpenTarget: kotlin.Function1, whatsNewViewModel: com.wire.android.ui.home.whatsnew.WhatsNewViewModel): kotlin.Unit +internal fun com.wire.android.ui.home.whatsnew.WhatsNewScreen(homeShellState: com.wire.android.ui.home.HomeShellState, navigationActions: com.wire.android.ui.home.whatsnew.WhatsNewNavigationActions, whatsNewViewModel: com.wire.android.ui.home.whatsnew.WhatsNewViewModel): kotlin.Unit skippable: false restartable: true params: - homeShellState: RUNTIME (requires runtime check) - - onOpenTarget: STABLE (function type) + - navigationActions: STABLE (class with no mutable properties) - whatsNewViewModel: UNSTABLE (has mutable properties or unstable members) @Composable diff --git a/docs/adr/0014-migrate-to-navigation-3-with-kmp-ready-contracts.md b/docs/adr/0014-migrate-to-navigation-3-with-kmp-ready-contracts.md index 959de5f6c3d..5246dacb11e 100644 --- a/docs/adr/0014-migrate-to-navigation-3-with-kmp-ready-contracts.md +++ b/docs/adr/0014-migrate-to-navigation-3-with-kmp-ready-contracts.md @@ -30,6 +30,15 @@ We will replace Compose Destinations with Navigation 3 and Wire-owned navigation with the feature that owns them. - Features contribute typed `wireEntry` providers and expose semantic actions instead of receiving generated navigators or `NavHostController`. +- The Home shell aggregates one feature-owned semantic action contract for every interactive + top-level child. Feature-specific actions are not flattened into the Home contract; roots sharing + an action surface reuse its contract, and roots without navigation actions expose none. +- A reusable UI action contract is named after its role and omits the framework version, for example + `ConversationListNavigationActions` or `MeetingsHomeNavigationActions`. The `Navigation3` suffix + is reserved for contracts coupled to a Navigation 3 entry, runtime or result lifecycle, for example + `ConversationEntryNavigation3Actions` or `MeetingsNavigation3Actions`. Starting a flow belongs to + the framework-neutral Home-root contract; actions performed inside its entries belong to the + `Navigation3` contract. - Navigation 3 owns back-stack entries and their `ViewModelStoreOwner` lifecycle. - Wire resolves the Metro graph independently from the typed route. A session route always carries the session identity from which its dependencies must be resolved. diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsHomeNavigationActions.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsHomeNavigationActions.kt new file mode 100644 index 00000000000..d4ef3925c36 --- /dev/null +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/MeetingsHomeNavigationActions.kt @@ -0,0 +1,18 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package com.wire.android.feature.meetings.ui + +import com.wire.android.feature.meetings.ui.create.NewMeetingType + +/** Framework-neutral navigation emitted by the Meetings Home root. */ +data class MeetingsHomeNavigationActions( + val openNewMeeting: (NewMeetingType) -> Unit, +) diff --git a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Entries.kt b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Entries.kt index 78da2a85daa..c7f91458c0a 100644 --- a/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Entries.kt +++ b/features/meetings/src/main/java/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3Entries.kt @@ -21,6 +21,7 @@ import com.wire.android.ui.common.HandleActions import com.wire.navigation.WireNavigationCommand import com.wire.navigation.WireViewModelOwner +/** Host actions used by entries inside the Navigation 3 meeting flow. */ interface MeetingsNavigation3Actions { fun exitMeetingFlow() fun openUserProfile(userId: MeetingParticipantId) diff --git a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3SourceTest.kt b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3SourceTest.kt index 44683513d97..635995f27ca 100644 --- a/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3SourceTest.kt +++ b/features/meetings/src/test/kotlin/com/wire/android/feature/meetings/ui/create/MeetingsNavigation3SourceTest.kt @@ -24,6 +24,7 @@ internal class MeetingsNavigation3SourceTest { assertFalse(source.contains("com.ramcosta.composedestinations")) assertFalse(source.contains("WireNavigator")) assertFalse(source.contains("SavedStateHandle")) + assertFalse(source.contains("openNewMeeting"), "Starting a meeting belongs to the Home root, not flow entries") assertTrue(source.contains("wireEntry")) assertTrue(source.contains("wireEntry")) assertTrue(source.contains("wireViewModelStoreOwner(WireViewModelOwner.Flow(flowId))")) @@ -31,6 +32,17 @@ internal class MeetingsNavigation3SourceTest { assertTrue(source.contains("newMeetingFlowViewModel(route.type, route.meetingId, route.flowId)")) } + @Test + fun `meetings Home root owns a framework neutral action bundle`() { + val source = File( + "src/main/java/com/wire/android/feature/meetings/ui/MeetingsHomeNavigationActions.kt" + ).readText() + + assertTrue(source.contains("data class MeetingsHomeNavigationActions")) + assertTrue(source.contains("val openNewMeeting: (NewMeetingType) -> Unit")) + assertFalse(source.contains("Navigation3")) + } + @Test fun `typed meeting view model path passes arguments through Metro assisted factory`() { val graph = File(