diff --git a/.idea/artifacts/shared.xml b/.idea/artifacts/shared.xml index 59ff3f6..df2c7e6 100644 --- a/.idea/artifacts/shared.xml +++ b/.idea/artifacts/shared.xml @@ -1,6 +1,8 @@ $PROJECT_DIR$/utils/shared/build/libs - + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 98358d8..2f43577 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -8,5 +8,6 @@ + \ No newline at end of file diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml index c33c8ce..5cc166f 100644 --- a/app/android/src/main/AndroidManifest.xml +++ b/app/android/src/main/AndroidManifest.xml @@ -26,6 +26,15 @@ + + + + + + (null) private var startAtPublicChat by mutableStateOf(false) private var startAtDmConversationUserId by mutableStateOf(null) + private var startAtProfileUserId by mutableStateOf(null) + private var startAtProfileUsername by mutableStateOf(null) + private var profileLookupErrorMessage by mutableStateOf(null) private var prevIsPublicChatVisible: Boolean? = null + private val requestNotificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) {} - private fun handleIntent(intent: Intent?) { + private suspend fun buildLaunchState(intent: Intent?): ProfileDeepLinkResolution { + Logger.d( + "ProfileDeepLink", + "handleIntent: action=${intent?.action}, data=${intent?.dataString}, messageId=${intent?.getIntExtra(EXTRA_MESSAGE_ID, -1)}, " + + "chatType=${intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE)}, openDmUserId=${intent?.getIntExtra(EXTRA_OPEN_DM_USER_ID, -1)}" + ) val messageId = intent?.getIntExtra(EXTRA_MESSAGE_ID, -1) ?: -1 val chatType = intent?.getStringExtra(EXTRA_NOTIFICATION_CHAT_TYPE) ?: CHAT_TYPE_PUBLIC val dmConversationUserId = intent?.getIntExtra(EXTRA_OPEN_DM_USER_ID, -1) ?: -1 + val profileTarget = parseProfileDeepLink(intent) + Logger.d( + "ProfileDeepLink", + "handleIntent parsedProfileTarget: userId=${profileTarget?.userId}, username=${profileTarget?.username}, parseError=${profileTarget?.parseError}" + ) - scrollToMessageId = if (messageId != -1) messageId else null - startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM - startAtDmConversationUserId = if (chatType == CHAT_TYPE_DM && dmConversationUserId > 0) { - dmConversationUserId + val baseState = ProfileDeepLinkResolution( + scrollToMessageId = if (messageId != -1) messageId else null, + startAtPublicChat = messageId != -1 && chatType != CHAT_TYPE_DM, + startAtDmConversationUserId = if (chatType == CHAT_TYPE_DM && dmConversationUserId > 0) { + dmConversationUserId + } else { + null + } + ) + + if (profileTarget?.parseError != null) { + return baseState.copy(profileLookupErrorMessage = profileTarget.parseError) + } + + if (profileTarget == null) { + return baseState + } + + var lookupFailureMessage: String? = null + val resolvedProfileId = if (profileTarget.userId != null) { + runCatching { + ApiClient.getProfileById(profileTarget.userId).id + }.onFailure { err -> + Logger.d("ProfileDeepLink", "profile deep link lookup failed by id: ${err.message}") + lookupFailureMessage = getProfileLookupFailureMessage(err) + }.getOrNull() + } else if (!profileTarget.username.isNullOrBlank()) { + runCatching { + ApiClient.getProfileByUsername(profileTarget.username).id + }.onFailure { err -> + Logger.d("ProfileDeepLink", "profile deep link lookup failed by username: ${err.message}") + lookupFailureMessage = getProfileLookupFailureMessage(err) + }.getOrNull() } else { null } - // Mark messages as read if clicked from notification + if (resolvedProfileId == null) { + return baseState.copy(profileLookupErrorMessage = lookupFailureMessage ?: PROFILE_OPEN_FAILED_MESSAGE) + } + if (intent?.getBooleanExtra(EXTRA_MARK_MESSAGE_READ, false) == true) { markMessagesAsRead() } + + return baseState.copy( + startAtProfileUserId = resolvedProfileId, + startAtProfileUsername = null, + startAtDmConversationUserId = null, + startAtPublicChat = false + ) + } + + private fun applyLaunchState(launchState: ProfileDeepLinkResolution) { + scrollToMessageId = launchState.scrollToMessageId + startAtPublicChat = launchState.startAtPublicChat + startAtDmConversationUserId = launchState.startAtDmConversationUserId + startAtProfileUserId = launchState.startAtProfileUserId + startAtProfileUsername = launchState.startAtProfileUsername + profileLookupErrorMessage = launchState.profileLookupErrorMessage + } + + private fun parseProfileDeepLink(intent: Intent?): ProfileDeepLinkTarget? { + val data: Uri = intent?.data ?: return null + Logger.d("ProfileDeepLink", "parseProfileDeepLink intentData=${data.toString()}") + if (!data.scheme.equals("fromchat", ignoreCase = true)) return null + if (data.host != "u") return null + + val segments = data.pathSegments.filter { it.isNotBlank() } + Logger.d("ProfileDeepLink", "parseProfileDeepLink segments=${segments.joinToString(",")}") + if (segments.size != 1) { + return ProfileDeepLinkTarget(parseError = INVALID_PROFILE_DEEP_LINK_MESSAGE) + } + val segment = segments[0].let { Uri.decode(it) } + val trimmed = segment.trim() + if (trimmed.isBlank()) { + return ProfileDeepLinkTarget(parseError = INVALID_PROFILE_DEEP_LINK_MESSAGE) + } + + return trimmed.toLongOrNull()?.let { idLong -> + if (idLong in 1L..Int.MAX_VALUE.toLong()) { + Logger.d("ProfileDeepLink", "parseProfileDeepLink resolved as userId=$idLong") + ProfileDeepLinkTarget(userId = idLong.toInt()) + } else { + Logger.d("ProfileDeepLink", "parseProfileDeepLink large numeric treated as username=$trimmed") + ProfileDeepLinkTarget(username = trimmed) + } + } ?: run { + Logger.d("ProfileDeepLink", "parseProfileDeepLink resolved as username=$trimmed") + ProfileDeepLinkTarget(username = trimmed) + } } @OptIn(DelicateCoroutinesApi::class) @@ -110,30 +235,38 @@ class MainActivity : ComponentActivity() { installSplashScreen() enableEdgeToEdge() - // Handle initial intent - handleIntent(intent) + lifecycleScope.launch { + val launchState = buildLaunchState(intent) + applyLaunchState(launchState) + setContent { + App( + scrollToMessageId = scrollToMessageId, + startAtPublicChat = startAtPublicChat, + startAtDmConversationUserId = startAtDmConversationUserId, + startAtProfileUserId = startAtProfileUserId, + startAtProfileUsername = startAtProfileUsername, + profileLookupErrorMessage = profileLookupErrorMessage, + onProfileLookupErrorMessageConsumed = { + profileLookupErrorMessage = null + } + ) + } - setContent { - App( - scrollToMessageId = scrollToMessageId, - startAtPublicChat = startAtPublicChat, - startAtDmConversationUserId = startAtDmConversationUserId - ) + checkGooglePlayServices() } // Request POST_NOTIFICATIONS permission on Android 13+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) {}.launch(Manifest.permission.POST_NOTIFICATIONS) + requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } - - checkGooglePlayServices() } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - handleIntent(intent) + lifecycleScope.launch { + val launchState = buildLaunchState(intent) + applyLaunchState(launchState) + } } override fun onPause() { diff --git a/app/ios/iosApp/ContentView.swift b/app/ios/iosApp/ContentView.swift index ec34983..b129730 100644 --- a/app/ios/iosApp/ContentView.swift +++ b/app/ios/iosApp/ContentView.swift @@ -1,18 +1,99 @@ import UIKit +import Foundation import SwiftUI import ComposeApp struct ComposeView: UIViewControllerRepresentable { + let startAtProfileUserId: Int? + let startAtProfileUsername: String? + func makeUIViewController(context: Context) -> UIViewController { - MainViewControllerKt.MainViewController() + MainViewControllerKt.MainViewController( + startAtProfileUserId = startAtProfileUserId, + startAtProfileUsername = startAtProfileUsername + ) } func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} } struct ContentView: View { + @State private var startAtProfileUserId: Int? + @State private var startAtProfileUsername: String? + @State private var profileNonce = UUID() + @State private var showProfileLinkError = false + @State private var profileLinkErrorMessage: String? = nil + var body: some View { - ComposeView() - .ignoresSafeArea(.all) // Compose has own keyboard handler + ComposeView( + startAtProfileUserId: startAtProfileUserId, + startAtProfileUsername: startAtProfileUsername + ) + .id(profileNonce) + .ignoresSafeArea(.all) + .onOpenURL { url in + print("[ProfileLink:iOS] onOpenURL url=\(url.absoluteString)") + let target = Self.parseProfileDeepLink(url: url) + print("[ProfileLink:iOS] parse result userId=\(String(describing: target?.userId)) username=\(String(describing: target?.username)) error=\(String(describing: target?.error))") + guard let parsed = target else { return } + if let error = parsed.error { + profileLinkErrorMessage = error + showProfileLinkError = true + return + } + startAtProfileUserId = parsed.userId + startAtProfileUsername = parsed.username + profileNonce = UUID() + } + .alert("Profile link", isPresented: $showProfileLinkError) { + Button("OK", role: .cancel) { + showProfileLinkError = false + } + } message: { + Text(profileLinkErrorMessage ?? "Invalid profile link.") + } } + + struct ParsedProfileDeepLink { + let userId: Int? + let username: String? + let error: String? + } + + static func parseProfileDeepLink(url: URL) -> ParsedProfileDeepLink? { + print("[ProfileLink:iOS] parseProfileDeepLink start url=\(url)") + guard url.scheme?.lowercased() == "fromchat", + url.host == "u" else { + return nil + } + + let parts = url.path.split(separator: "/").filter { !$0.isEmpty } + print("[ProfileLink:iOS] scheme=\(url.scheme ?? "nil") host=\(url.host ?? "nil") path=\(url.path) parts=\(parts.map(String.init))") + guard parts.count == 1, let rawSegment = parts.first else { + return ParsedProfileDeepLink( + userId: nil, + username: nil, + error: "Invalid profile link. Use fromchat://u/." + ) + } + + let segment = String(rawSegment).removingPercentEncoding?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !segment.isEmpty else { + return ParsedProfileDeepLink( + userId: nil, + username: nil, + error: "Invalid profile link. Use fromchat://u/." + ) + } + print("[ProfileLink:iOS] parsed segment=\(segment)") + + if let id = Int64(segment), id >= 1 && id <= Int64(Int.max) { + print("[ProfileLink:iOS] resolved as userId=\(id)") + return ParsedProfileDeepLink(userId: Int(id), username: nil, error: nil) + } + + print("[ProfileLink:iOS] resolved as username=\(segment)") + return ParsedProfileDeepLink(userId: nil, username: segment, error: nil) + } + } diff --git a/app/ios/iosApp/Info.plist b/app/ios/iosApp/Info.plist index f366a8a..1f411e8 100644 --- a/app/ios/iosApp/Info.plist +++ b/app/ios/iosApp/Info.plist @@ -32,6 +32,19 @@ UIApplicationSupportsMultipleScenes + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + fromchat + CFBundleURLSchemes + + fromchat + + + UILaunchScreen UIRequiredDeviceCapabilities diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 9015818..56d7f1a 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -50,6 +50,7 @@ kotlin { implementation(libs.haze) implementation(libs.haze.materials) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) // Serialization implementation(libs.kotlinx.serialization.json) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.android.kt new file mode 100644 index 0000000..f095ec2 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.android.kt @@ -0,0 +1,9 @@ +package ru.fromchat + +import android.widget.Toast +import com.pr0gramm3r101.utils.UtilsLibrary + +actual fun showProfileLoadErrorMessage(message: String) { + Toast.makeText(UtilsLibrary.context, message, Toast.LENGTH_SHORT).show() +} + diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 3816f90..605a563 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -62,6 +62,8 @@ Отправить Профиль Не получилось загрузить профиль + Профиль не найден + Не удалось открыть профиль. Попробуйте снова. Настройки Написать Скопировать ссылку diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index b3c22f5..97f6b52 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -81,6 +81,8 @@ Profile Couldn’t load this profile + This profile could not be found + Could not open this profile. Please try again. Settings Chat Copy link diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.kt new file mode 100644 index 0000000..43e68c6 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.kt @@ -0,0 +1,4 @@ +package ru.fromchat + +expect fun showProfileLoadErrorMessage(message: String) + diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index d08303d..48ad5f4 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -307,6 +307,13 @@ object ApiClient { } .body() + suspend fun getProfileByUsername(username: String): UserProfile = + http + .get("${Config.apiBaseUrl}/user/$username") { + contentType(ContentType.Application.Json) + } + .body() + suspend fun getRegisteredUserCount(): Int = http .get("${Config.apiBaseUrl}/user/stats/registered-count") { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index eef170f..810ae6a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -1,14 +1,17 @@ package ru.fromchat.ui +import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import coil3.ImageLoader -import coil3.compose.setSingletonImageLoaderFactory -import coil3.svg.SvgDecoder +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -18,27 +21,42 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.IntOffset import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner -import ru.fromchat.AppForeground +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +import coil3.svg.SvgDecoder +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import ru.fromchat.AppForeground import ru.fromchat.api.ApiClient -import ru.fromchat.api.UserStatusStore import ru.fromchat.api.ProfileCache import ru.fromchat.api.UpdateSyncManager +import ru.fromchat.api.UserStatusStore import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketUpdatesData -import ru.fromchat.net.NetworkConnectivity +import ru.fromchat.core.Logger import ru.fromchat.core.config.Config +import ru.fromchat.net.NetworkConnectivity import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.chat.PublicChatScreen @@ -47,20 +65,8 @@ import ru.fromchat.ui.debug.DebugSearchBarDocsScreen import ru.fromchat.ui.dm.DmChatRoute import ru.fromchat.ui.dm.DmNav import ru.fromchat.ui.dm.DmProfileRoute -import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.ChatsSearchScreen -import ru.fromchat.ui.profile.ProfileScreen -import ru.fromchat.ui.setup.ServerConfigScreen -import ru.fromchat.ui.LocalSystemBarsVisibility -import ru.fromchat.ui.rememberSystemBarsController -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavGraphBuilder -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch +import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.settings.SettingsAccountScreen import ru.fromchat.ui.main.settings.SettingsAppearanceScreen import ru.fromchat.ui.main.settings.SettingsDevicesScreen @@ -69,7 +75,8 @@ import ru.fromchat.ui.main.settings.SettingsRoutes import ru.fromchat.ui.main.settings.SettingsSecurityHubScreen import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen import ru.fromchat.ui.main.settings.SettingsServerToolsScreen -import kotlinx.serialization.json.* +import ru.fromchat.ui.profile.ProfileScreen +import ru.fromchat.ui.setup.ServerConfigScreen val LocalNavController = compositionLocalOf { error("NavController not provided") } @@ -156,7 +163,11 @@ private fun NavGraphBuilder.settingsSlideComposable( fun App( scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false, - startAtDmConversationUserId: Int? = null + startAtDmConversationUserId: Int? = null, + startAtProfileUserId: Int? = null, + startAtProfileUsername: String? = null, + profileLookupErrorMessage: String? = null, + onProfileLookupErrorMessageConsumed: () -> Unit = {} ) { setSingletonImageLoaderFactory { context -> ImageLoader.Builder(context) @@ -204,7 +215,7 @@ fun App( } // Foreground → WebSocket reconnect; background → pause reconnect attempts (see [WebSocketManager]). - val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { fun syncForeground() { AppForeground.setForeground( @@ -242,25 +253,70 @@ fun App( FromChatTheme { SharedTransitionLayout { val navController = rememberNavController() + val profileLookupSnackbarHostState = remember { SnackbarHostState() } - // Handle navigation to the target chat when launched from notification - LaunchedEffect(startAtDmConversationUserId, startAtPublicChat, startDestination) { + LaunchedEffect(profileLookupErrorMessage) { + profileLookupErrorMessage?.let { message -> + Logger.d("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message") + profileLookupSnackbarHostState.showSnackbar( + message = message, + withDismissAction = true, + duration = SnackbarDuration.Short + ) + onProfileLookupErrorMessageConsumed() + } + } + + // Handle startup/deep-link navigation targets (notification chat/profile) + LaunchedEffect( + startAtDmConversationUserId, + startAtPublicChat, + startAtProfileUserId, + startAtProfileUsername, + startDestination + ) { + Logger.d( + "ProfileDeepLink", + "startup nav check: startDestination=$startDestination, startAtProfileUserId=$startAtProfileUserId, " + + "startAtProfileUsername=$startAtProfileUsername, startAtDmConversationUserId=$startAtDmConversationUserId, " + + "startAtPublicChat=$startAtPublicChat, scrollToMessageId=$scrollToMessageId" + ) if (startDestination == null || startDestination == "login") { return@LaunchedEffect } - if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) { - navController.navigate( - DmNav.chatRoute( - otherUserId = startAtDmConversationUserId, - sourceMessageId = scrollToMessageId + if (startAtProfileUserId != null && startAtProfileUserId > 0) { + Logger.d( + "ProfileDeepLink", + "navigating by deep link userId=$startAtProfileUserId" + ) + navController.navigate("profile/$startAtProfileUserId?fromDeepLink=true") + } else { + val trimmedUsername = startAtProfileUsername?.trim() + if (!trimmedUsername.isNullOrBlank()) { + Logger.d( + "ProfileDeepLink", + "navigating by deep link username=$trimmedUsername" ) - ) { - launchSingleTop = true - } - } else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") { - navController.navigate("chats/publicChat") { - launchSingleTop = true + navController.navigate("profile/$trimmedUsername?fromDeepLink=true") + } else if (startAtDmConversationUserId != null && startAtDmConversationUserId > 0) { + Logger.d( + "ProfileDeepLink", + "navigating by notification chat route user=$startAtDmConversationUserId messageId=$scrollToMessageId" + ) + navController.navigate( + DmNav.chatRoute( + otherUserId = startAtDmConversationUserId, + sourceMessageId = scrollToMessageId + ) + ) { + launchSingleTop = true + } + } else if (startAtPublicChat && navController.currentDestination?.route != "chats/publicChat") { + Logger.d("ProfileDeepLink", "navigating to public chat route") + navController.navigate("chats/publicChat") { + launchSingleTop = true + } } } } @@ -268,7 +324,7 @@ fun App( // Set up global auth error handler LaunchedEffect(navController) { ApiClient.onAuthError = { - ru.fromchat.core.Logger.d("App", "Global auth error handler triggered, navigating to login") + Logger.d("App", "Global auth error handler triggered, navigating to login") navController.navigate("login") { popUpTo("chat") { inclusive = true } } @@ -281,7 +337,6 @@ fun App( ) { if (startDestination != null) { val animationSpec = tween(400) - NavHost( navController = navController, startDestination = startDestination!!, @@ -310,217 +365,253 @@ fun App( ) } ) { - composable("serverConfig") { - ServerConfigScreen() - } - - composable("login") { - LoginScreen( - onLoginSuccess = { - WebSocketManager.connect(forceRestart = true) - navController.navigate("chat") { - popUpTo("login") { inclusive = true } - } - }, - onNavigateToRegister = { navController.navigate("register") } - ) - } - - composable("register") { - RegisterScreen( - onRegistered = { - navController.navigate("chat") { - popUpTo("login") { inclusive = true } - } - } - ) - } - - composable("chat") { - MainScreen( - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this - ) - } - - composable("chats/publicChat") { - PublicChatScreen( - scrollToMessageId = scrollToMessageId, - sharedTransitionScope = this@SharedTransitionLayout, - animatedContentScope = this@composable - ) - } - - val searchScreenFade = tween(260) - composable( - route = "search/conversations", - enterTransition = { fadeIn(animationSpec = searchScreenFade) }, - exitTransition = { fadeOut(animationSpec = searchScreenFade) }, - popEnterTransition = { fadeIn(animationSpec = searchScreenFade) }, - popExitTransition = { fadeOut(animationSpec = searchScreenFade) } - ) { - ChatsSearchScreen( - onBack = { navController.popBackStack() }, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - onOpenProfile = { userId: Int -> - if (userId != 0) { - navController.navigate("profile/$userId") - } - }, - onOpenConversation = { userId: Int -> - if (userId != 0) { - navController.navigate(DmNav.chatRoute(userId)) - } - } - ) - } - - composable("debug") { - DebugApiScreen() - } - composable("debug/searchbar-docs") { - DebugSearchBarDocsScreen() - } - - composable( - route = "profile/{userId}?useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}", - arguments = listOf( - navArgument("userId") { type = NavType.StringType }, - navArgument("useSharedElement") { - type = NavType.StringType - defaultValue = "false" - }, - navArgument("sourceMessageId") { - type = NavType.StringType - defaultValue = "-1" - } - ) - ) { backStackEntry -> - val handle = backStackEntry.savedStateHandle - val userId = handle.get("userId")?.toIntOrNull() - val useSharedElement = handle.get("useSharedElement") == "true" - val sourceMessageId = handle.get("sourceMessageId")?.toIntOrNull() ?: -1 - ProfileScreen( - userId = userId, - onBack = { navController.navigateUp() }, - onChat = { navController.navigate(DmNav.chatRoute(it)) }, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this@composable, - useSharedElementFromNavigation = useSharedElement, - sharedSourceMessageId = sourceMessageId - ) - } - - val dmChatProfileFade = tween(durationMillis = 280) - - composable( - route = DmNav.CHAT_ROUTE, - arguments = listOf( - navArgument("otherUserId") { type = NavType.StringType }, - navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 }, - ), - enterTransition = { - when (initialState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) - else -> slideIntoContainer(Start, animationSpec = animationSpec) - } - }, - exitTransition = { - when (targetState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) - else -> slideOutOfContainer(Start, animationSpec = animationSpec) - } - }, - popEnterTransition = { - when (initialState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) - else -> slideIntoContainer(End, animationSpec = animationSpec) - } - }, - popExitTransition = { - when (targetState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) - else -> slideOutOfContainer(End, animationSpec = animationSpec) - } - }, - ) { entry -> - val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 - val sourceMessageId = entry.savedStateHandle.get("sourceMessageId") ?: -1 - if (otherUserId <= 0) return@composable - DmChatRoute( - otherUserId = otherUserId, - scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null, - navController = navController, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - ) - } - - composable( - route = DmNav.PROFILE_ROUTE, - arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }), - enterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, - exitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, - popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, - popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, - ) { entry -> - val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 - if (otherUserId <= 0) return@composable - DmProfileRoute( - otherUserId = otherUserId, - navController = navController, - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - ) - } - - settingsSlideComposable("about", animationSpec) { - AboutScreen() - } - - val navigateToLoginClearingChat = { - navController.navigate("login") { - popUpTo("chat") { inclusive = true } + composable("serverConfig") { + ServerConfigScreen() } - } - settingsSlideComposable(SettingsRoutes.Appearance, animationSpec) { - SettingsAppearanceScreen(onBack = { navController.navigateUp() }) - } - settingsSlideComposable(SettingsRoutes.ServerTools, animationSpec) { - SettingsServerToolsScreen( - onBack = { navController.navigateUp() }, - outerNav = navController - ) - } - settingsSlideComposable(SettingsRoutes.Notifications, animationSpec) { - SettingsNotificationsScreen(onBack = { navController.navigateUp() }) - } - settingsSlideComposable(SettingsRoutes.Devices, animationSpec) { - SettingsDevicesScreen(onBack = { navController.navigateUp() }) - } - settingsSlideComposable(SettingsRoutes.Security, animationSpec) { - SettingsSecurityHubScreen( - onBack = { navController.navigateUp() }, - onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } - ) - } - settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, animationSpec) { - SettingsSecurityPasswordFlowScreen( - onBack = { navController.navigateUp() }, - onDonePopToHub = { - navController.popBackStack() + composable("login") { + LoginScreen( + onLoginSuccess = { + WebSocketManager.connect(forceRestart = true) + navController.navigate("chat") { + popUpTo("login") { inclusive = true } + } + }, + onNavigateToRegister = { navController.navigate("register") } + ) + } + + composable("register") { + RegisterScreen( + onRegistered = { + navController.navigate("chat") { + popUpTo("login") { inclusive = true } + } + } + ) + } + + composable("chat") { + MainScreen( + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + snackbarHostState = profileLookupSnackbarHostState + ) + } + + composable("chats/publicChat") { + PublicChatScreen( + scrollToMessageId = scrollToMessageId, + sharedTransitionScope = this@SharedTransitionLayout, + animatedContentScope = this@composable + ) + } + + val searchScreenFade = tween(260) + composable( + route = "search/conversations", + enterTransition = { fadeIn(animationSpec = searchScreenFade) }, + exitTransition = { fadeOut(animationSpec = searchScreenFade) }, + popEnterTransition = { fadeIn(animationSpec = searchScreenFade) }, + popExitTransition = { fadeOut(animationSpec = searchScreenFade) } + ) { + ChatsSearchScreen( + onBack = { navController.popBackStack() }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + onOpenProfile = { userId: Int -> + if (userId != 0) { + navController.navigate("profile/$userId") + } + }, + onOpenConversation = { userId: Int -> + if (userId != 0) { + navController.navigate(DmNav.chatRoute(userId)) + } + } + ) + } + + composable("debug") { + DebugApiScreen() + } + composable("debug/searchbar-docs") { + DebugSearchBarDocsScreen() + } + + composable( + route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}", + arguments = listOf( + navArgument("userId") { type = NavType.StringType }, + navArgument("fromDeepLink") { + type = NavType.BoolType + defaultValue = false + }, + navArgument("useSharedElement") { + type = NavType.StringType + defaultValue = "false" + }, + navArgument("sourceMessageId") { + type = NavType.StringType + defaultValue = "-1" + } + ) + ) { backStackEntry -> + val args = backStackEntry.savedStateHandle + val userIdParam = args.get("userId") + val parsedUserId = userIdParam?.toIntOrNull() + val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null + val profileUsername = if (userId == null) { + userIdParam?.trim()?.takeIf { it.isNotBlank() } + } else { + null } - ) - } - settingsSlideComposable(SettingsRoutes.Account, animationSpec) { - SettingsAccountScreen( - onBack = { navController.navigateUp() }, - onLogout = navigateToLoginClearingChat, - onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } - ) - } + val useSharedElement = when (val rawUseSharedElement = args.get("useSharedElement")) { + is Boolean -> rawUseSharedElement + is String -> rawUseSharedElement == "true" + else -> false + } + val sourceMessageId = when (val rawSourceMessageId = args.get("sourceMessageId")) { + is Int -> rawSourceMessageId + is String -> rawSourceMessageId.toIntOrNull() ?: -1 + is Long -> rawSourceMessageId.toInt() + else -> -1 + } + val fromDeepLink = when (val rawFromDeepLink = args.get("fromDeepLink")) { + is Boolean -> rawFromDeepLink + is String -> rawFromDeepLink == "true" + else -> false + } + + Logger.d( + "ProfileRoute", + "profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " + + "resolvedUsername=$profileUsername sourceMessageId=$sourceMessageId fromDeepLink=$fromDeepLink " + + "useSharedElement=$useSharedElement currentRoute=${backStackEntry.destination.route}" + ) + + ProfileScreen( + userId = userId, + username = profileUsername, + onBack = { navController.navigateUp() }, + onChat = { navController.navigate(DmNav.chatRoute(it)) }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@composable, + useSharedElementFromNavigation = useSharedElement, + sharedSourceMessageId = sourceMessageId, + showErrorAsToast = fromDeepLink + ) + } + + val dmChatProfileFade = tween(durationMillis = 280) + + composable( + route = DmNav.CHAT_ROUTE, + arguments = listOf( + navArgument("otherUserId") { type = NavType.StringType }, + navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 }, + ), + enterTransition = { + when (initialState.destination.route) { + DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) + else -> slideIntoContainer(Start, animationSpec = animationSpec) + } + }, + exitTransition = { + when (targetState.destination.route) { + DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) + else -> slideOutOfContainer(Start, animationSpec = animationSpec) + } + }, + popEnterTransition = { + when (initialState.destination.route) { + DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) + else -> slideIntoContainer(End, animationSpec = animationSpec) + } + }, + popExitTransition = { + when (targetState.destination.route) { + DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) + else -> slideOutOfContainer(End, animationSpec = animationSpec) + } + }, + ) { entry -> + val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 + val sourceMessageId = entry.savedStateHandle.get("sourceMessageId") ?: -1 + if (otherUserId <= 0) return@composable + DmChatRoute( + otherUserId = otherUserId, + scrollToMessageId = if (sourceMessageId > 0) sourceMessageId else null, + navController = navController, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + ) + } + + composable( + route = DmNav.PROFILE_ROUTE, + arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }), + enterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, + exitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, + popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, + popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, + ) { entry -> + val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 + if (otherUserId <= 0) return@composable + DmProfileRoute( + otherUserId = otherUserId, + navController = navController, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + ) + } + + settingsSlideComposable("about", animationSpec) { + AboutScreen() + } + + val navigateToLoginClearingChat = { + navController.navigate("login") { + popUpTo("chat") { inclusive = true } + } + } + + settingsSlideComposable(SettingsRoutes.Appearance, animationSpec) { + SettingsAppearanceScreen(onBack = { navController.navigateUp() }) + } + settingsSlideComposable(SettingsRoutes.ServerTools, animationSpec) { + SettingsServerToolsScreen( + onBack = { navController.navigateUp() }, + outerNav = navController + ) + } + settingsSlideComposable(SettingsRoutes.Notifications, animationSpec) { + SettingsNotificationsScreen(onBack = { navController.navigateUp() }) + } + settingsSlideComposable(SettingsRoutes.Devices, animationSpec) { + SettingsDevicesScreen(onBack = { navController.navigateUp() }) + } + settingsSlideComposable(SettingsRoutes.Security, animationSpec) { + SettingsSecurityHubScreen( + onBack = { navController.navigateUp() }, + onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } + ) + } + settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, animationSpec) { + SettingsSecurityPasswordFlowScreen( + onBack = { navController.navigateUp() }, + onDonePopToHub = { + navController.popBackStack() + } + ) + } + settingsSlideComposable(SettingsRoutes.Account, animationSpec) { + SettingsAccountScreen( + onBack = { navController.navigateUp() }, + onLogout = navigateToLoginClearingChat, + onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) } + ) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index 4a6b3a0..4b6cf88 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -22,8 +22,11 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import org.jetbrains.compose.resources.stringResource @@ -47,7 +50,9 @@ private const val PAGE_COUNT = 4 fun MainScreen( sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + snackbarHostState: SnackbarHostState? = null ) { + val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() } val navController = LocalNavController.current val pagerState = rememberPagerState( initialPage = PAGE_CHATS, @@ -60,6 +65,7 @@ fun MainScreen( val isChatsPage = selectedPage == PAGE_CHATS Scaffold( + snackbarHost = { SnackbarHost(hostState = effectiveSnackbarHostState) }, bottomBar = { NavigationBar { NavigationBarItem( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 3751979..b3b9b4b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -69,9 +69,11 @@ import com.pr0gramm3r101.components.ListItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import io.ktor.client.plugins.ClientRequestException import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res import ru.fromchat.* +import ru.fromchat.core.Logger import ru.fromchat.api.ApiClient import ru.fromchat.api.ProfileCache import ru.fromchat.api.UserProfile @@ -114,6 +116,7 @@ private val profileActionCardPressSpring = spring( @Composable fun ProfileScreen( userId: Int?, + username: String? = null, onBack: () -> Unit, onChat: (Int) -> Unit, hideAvatar: Boolean = false, @@ -126,6 +129,7 @@ fun ProfileScreen( useSharedElementFromNavigation: Boolean = false, sharedSourceMessageId: Int = -1, initialDisplayName: String? = null, + showErrorAsToast: Boolean = false, onOpenSettings: () -> Unit = {} ) { val clipboardManager: ClipboardManager = LocalClipboardManager.current @@ -134,6 +138,7 @@ fun ProfileScreen( val profileTitle = stringResource(Res.string.profile_title) val cdBack = stringResource(Res.string.back) val profileLoadFailed = stringResource(Res.string.profile_load_failed) + val profileNotFound = stringResource(Res.string.profile_not_found) val labelSettings = stringResource(Res.string.action_open_settings) val labelChat = stringResource(Res.string.action_chat) val labelLink = stringResource(Res.string.action_copy_link) @@ -147,11 +152,27 @@ fun ProfileScreen( val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support) val hideBackButton = navController.currentDestination?.route == "chat" val targetUserId = userId.takeIf { it != null && it > 0 } + val targetUsername = username?.trim()?.takeIf { it.isNotBlank() } val ownUserId = ApiClient.user?.id?.takeIf { it > 0 } - val cacheLookupId = targetUserId ?: ownUserId + val cacheLookupId = when { + targetUserId != null -> targetUserId + targetUsername != null -> null + else -> ownUserId + } + val isTargetProfileLookup = targetUserId != null || targetUsername != null + val lookupMode = if (targetUserId != null) "id" else if (targetUsername != null) "username" else "own" + val lookupIdentifier = when { + targetUserId != null -> targetUserId.toString() + !targetUsername.isNullOrBlank() -> targetUsername + else -> "self" + } - var state by remember(targetUserId, ownUserId) { + var state by remember(targetUserId, targetUsername, ownUserId) { val cached = cacheLookupId?.let { ProfileCache.get(it) } + Logger.d( + "ProfileScreen", + "state init: cacheLookupId=$cacheLookupId cachedId=${cached?.id} cachedUsername=${cached?.username} cachedDisplay=${cached?.displayName}" + ) mutableStateOf( ProfileUiState( profile = cached, @@ -164,17 +185,25 @@ fun ProfileScreen( val latestUi by rememberUpdatedState(state) val lifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(cacheLookupId, targetUserId, lifecycleOwner) { + LaunchedEffect(cacheLookupId, targetUserId, targetUsername, lifecycleOwner) { + Logger.d( + "ProfileScreen", + "load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId " + + "lifecycleStarted=${lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)}" + ) lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { runCatching { - if (targetUserId == null) { + if (targetUserId == null && targetUsername == null) { ApiClient.getOwnProfile() + } else if (targetUsername != null) { + ApiClient.getProfileByUsername(targetUsername) } else { - ApiClient.getProfileById(targetUserId) + ApiClient.getProfileById(targetUserId!!) } }.onSuccess { profile -> if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) } + Logger.d("ProfileScreen", "load success but blank identity for id=${profile.id}, dropping as unusable preview") state = latestUi.copy( profile = null, isLoading = false, @@ -182,16 +211,52 @@ fun ProfileScreen( ) return@onSuccess } + Logger.d( + "ProfileScreen", + "load success: mode=$lookupMode identifier=$lookupIdentifier -> id=${profile.id}, username='${profile.username}', display='${profile.displayName}', deleted=${profile.deleted}, suspended=${profile.suspended}" + ) ProfileCache.put(profile) state = latestUi.copy(profile = profile, isLoading = false, error = null) }.onFailure { err -> - val fallbackId = targetUserId ?: ownUserId + val fallbackId = if (targetUsername != null) null else targetUserId ?: ownUserId fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) } val fallback = fallbackId?.let { ProfileCache.get(it) } + Logger.d( + "ProfileScreen", + "load failure fallback lookup: fallbackId=$fallbackId fallbackFound=${fallback != null}" + ) + val resolvedErrorMessage = when { + err is ClientRequestException && err.response.status.value == 404 -> profileNotFound + else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed + } + if (err is ClientRequestException) { + Logger.d( + "ProfileScreen", + "load failed: mode=$lookupMode identifier=$lookupIdentifier " + + "status=${err.response.status.value} fallbackId=$fallbackId error=${err.message}" + ) + } else { + Logger.d( + "ProfileScreen", + "load failed: mode=$lookupMode identifier=$lookupIdentifier " + + "errorType=${err::class.simpleName} message=${err.message}" + ) + } + if ( + showErrorAsToast && + isTargetProfileLookup && + latestUi.profile == null && + fallback == null + ) { + showProfileLoadErrorMessage(resolvedErrorMessage) + } state = latestUi.copy( error = if (latestUi.profile == null && fallback == null) { - err.message?.takeIf { it.isNotBlank() }?.let { ProfileLoadError.Message(it) } - ?: ProfileLoadError.Generic + if (resolvedErrorMessage == profileLoadFailed) { + ProfileLoadError.Generic + } else { + ProfileLoadError.Message(resolvedErrorMessage) + } } else { null }, diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt index 548d109..eb7e297 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/MainViewController.kt @@ -4,4 +4,16 @@ import androidx.compose.ui.window.ComposeUIViewController import ru.fromchat.ui.App @Suppress("unused") -fun MainViewController() = ComposeUIViewController { App() } \ No newline at end of file +fun MainViewController( + startAtProfileUserId: Int?, + startAtProfileUsername: String? +) = ComposeUIViewController { + App( + startAtProfileUserId = startAtProfileUserId, + startAtProfileUsername = startAtProfileUsername + ) +} + +fun MainViewController() = ComposeUIViewController { + App() +} \ No newline at end of file diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.ios.kt new file mode 100644 index 0000000..062cc7f --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ProfileLoadErrorMessage.ios.kt @@ -0,0 +1,6 @@ +package ru.fromchat + +actual fun showProfileLoadErrorMessage(message: String) { + // iOS currently relies on screen-level error UI for deep-link failures. +} + diff --git a/gradle.properties b/gradle.properties index 5270d82..325ab5e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,4 +24,5 @@ android.nonTransitiveRClass=true android.uniquePackageNames=false android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false -android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false \ No newline at end of file +android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ddb8064..5c5ffc9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.1.0" +agp = "9.1.1" androidx-activityCompose = "1.13.0" androidx-appcompat = "1.7.1" androidx-core-ktx = "1.18.0" @@ -20,13 +20,13 @@ kotlinxCoroutinesCore = "1.10.2" kotlinxIoCore = "0.9.0" multiplatformSettings = "1.3.0" serialization = "2.3.20" -serialization-json = "1.10.0" +serialization-json = "1.11.0" material = "1.13.0" activityKtx = "1.13.0" navigationCompose = "2.9.2" datastore = "1.2.1" security-crypto = "1.1.0" -ktor = "3.4.1" +ktor = "3.4.2" slf4j = "1.7.36" kotlinxDatetime = "0.7.1" lifecycleRuntimeKtx = "2.10.0" @@ -37,7 +37,7 @@ composeComponents = "1.10.3" playServicesBase = "18.10.0" tweetnaclJava = "1.1.3" androidxWork = "2.11.2" -cryptography-kotlin = "0.5.0" +cryptography-kotlin = "0.6.0" krypto = "4.0.10" sqldelight = "2.3.2" @@ -46,6 +46,7 @@ androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", versi androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" }