diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0afd926..489b59d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,7 +12,7 @@ plugins { android { namespace = "me.floow.app" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "me.floow.app" @@ -77,7 +77,7 @@ dependencies { implementation(project(":feature:feed")) implementation(project(":feature:explore")) implementation(project(":feature:profile")) - implementation(project(":feature:usersearch")) + implementation(project(":feature:chatssearch")) implementation(libs.androidx.core.splashscreen) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fc439f0..a31a514 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools"> + + android:windowSoftInputMode="adjustNothing"> + + + + + + + + + - + - + + - + + diff --git a/app/src/main/java/me/floow/app/MainActivity.kt b/app/src/main/java/me/floow/app/MainActivity.kt index eeb2e84..c05c322 100644 --- a/app/src/main/java/me/floow/app/MainActivity.kt +++ b/app/src/main/java/me/floow/app/MainActivity.kt @@ -10,7 +10,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import com.demn.usersearch.di.usersearchModule +import me.floow.chatssearch.di.usersearchModule import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -26,6 +26,7 @@ import me.floow.app.di.mockModule import me.floow.app.navigation.AuthDestinationsCluster import me.floow.app.navigation.MainDestinationsCluster import me.floow.app.ui.App +import me.floow.chats.di.chatsModule import me.floow.domain.auth.AuthenticationManager import me.floow.login.di.loginModule import me.floow.profile.di.profileModule @@ -61,7 +62,8 @@ class MainActivity : ComponentActivity() { mockModule, loginModule, profileModule, - usersearchModule + usersearchModule, + chatsModule ) } else { modules( @@ -74,7 +76,8 @@ class MainActivity : ComponentActivity() { mockModule, loginModule, profileModule, - usersearchModule + usersearchModule, + chatsModule ) } } diff --git a/app/src/main/java/me/floow/app/navigation/FlowNavHost.kt b/app/src/main/java/me/floow/app/navigation/FlowNavHost.kt index 1e42165..4f51971 100644 --- a/app/src/main/java/me/floow/app/navigation/FlowNavHost.kt +++ b/app/src/main/java/me/floow/app/navigation/FlowNavHost.kt @@ -1,18 +1,25 @@ package me.floow.app.navigation +import android.content.Context import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.navigation +import androidx.navigation.navDeepLink import androidx.navigation.toRoute -import com.demn.usersearch.ui.SearchUsersRoute -import me.floow.chats.ui.ChatsRoute +import me.floow.chats.ChatRoute +import me.floow.chats.ChatRouteInitialData +import me.floow.chatssearch.ui.SearchUsersRoute +import me.floow.chats.ChatsRoute import me.floow.feed.ui.FeedRoute import me.floow.login.ui.createprofile.CreateProfileRoute import me.floow.login.ui.login.LoginRoute @@ -20,6 +27,7 @@ import me.floow.profile.ui.edit.EditProfileRoute import me.floow.profile.ui.edit.EditProfileRouteInitialData import me.floow.profile.ui.profile.ProfileRoute import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @Composable fun FlowNavHost( @@ -71,12 +79,18 @@ fun FlowNavHost( startDestination = FeedScreen ) { composable { - FeedRoute(onPostCreateClick = { TODO() }, modifier) + FeedRoute( + onPostCreateClick = { + showNotImplementedToast(context) + }, + modifier + ) } composable { val backStackEntry = navController.currentBackStackEntry - val editProfileScreen: EditProfileScreen? = backStackEntry?.toRoute() + val editProfileScreen: EditProfileScreen? = + backStackEntry?.toRoute() EditProfileRoute( initialData = EditProfileRouteInitialData( @@ -95,7 +109,22 @@ fun FlowNavHost( ) } - composable { + composable( + deepLinks = listOf( + navDeepLink( + basePath = profileDeeplinkUri + ) + ) + ) { + val username = navController.currentBackStackEntry + ?.toRoute()?.username + + Box(Modifier.fillMaxSize()) { + Text(text = username ?: "no username, invalid input") + } + } + + composable { ProfileRoute( goToProfileEditScreen = { name, username, description -> navController.navigate( @@ -106,7 +135,9 @@ fun FlowNavHost( ) ) }, - goToAddPostScreen = { TODO() }, + goToAddPostScreen = { + showNotImplementedToast(context) + }, shareProfile = { url -> // TODO @@ -125,16 +156,56 @@ fun FlowNavHost( } composable { - ChatsRoute(modifier) + ChatsRoute( + onChatClick = { chatToNavigate -> + navController.navigate( + ChatScreen( + interlocutorId = chatToNavigate.id, + interlocutorName = chatToNavigate.name.value, + interlocutorAvatarUri = chatToNavigate.avatarUrl.toString() + ) + ) + }, + onSearchClick = { + navController.navigate(SearchUsersScreen) + }, + vm = koinInject(), + modifier = modifier + ) + } + + composable { + val backStackEntry = navController.currentBackStackEntry + val chatScreen: ChatScreen? = + backStackEntry?.toRoute() + + ChatRoute( + initialData = ChatRouteInitialData( + chatInterlocutorId = chatScreen?.interlocutorId ?: -1L, + chatInterlocutorName = chatScreen?.interlocutorName ?: "", + chatInterlocutorAvatarUrl = chatScreen?.interlocutorAvatarUri?.let { Uri.parse(it) } + ), + vm = koinViewModel(), + modifier = modifier + ) } composable { SearchUsersRoute( - onUserPick = { TODO() }, + onBackClick = { + navController.popBackStack() + }, + onUserPick = { + showNotImplementedToast(context) + }, vm = koinViewModel(), modifier = modifier ) } } } +} + +private fun showNotImplementedToast(context: Context) { + Toast.makeText(context, "Фича ещё разрабатывается…", Toast.LENGTH_SHORT).show() } \ No newline at end of file diff --git a/app/src/main/java/me/floow/app/navigation/NavigationGraph.kt b/app/src/main/java/me/floow/app/navigation/NavigationGraph.kt index 9d14b67..8690009 100644 --- a/app/src/main/java/me/floow/app/navigation/NavigationGraph.kt +++ b/app/src/main/java/me/floow/app/navigation/NavigationGraph.kt @@ -23,6 +23,13 @@ data object LoginScreen : NavigationRoute @Serializable data object FeedScreen : NavigationRoute +@Serializable +data class ChatScreen( + val interlocutorId: Long = 0L, + val interlocutorName: String = "", + val interlocutorAvatarUri: String? = null, +) : NavigationRoute + @Serializable data object ChatsScreen : NavigationRoute @@ -30,7 +37,10 @@ data object ChatsScreen : NavigationRoute data object SearchUsersScreen : NavigationRoute @Serializable -data object ProfileScreen : NavigationRoute +data class ProfileScreen(val username: String) : NavigationRoute + +@Serializable +data object SelfProfileScreen : NavigationRoute @Serializable data class EditProfileScreen( @@ -41,7 +51,7 @@ data class EditProfileScreen( val bottomNavigationItems = listOf( BottomNavigationItem( - route = FeedScreen,//NavigationItem.Main.Feed.route, + route = FeedScreen, titleId = R.string.feed_bottom_nav_label, drawableIconId = me.floow.uikit.R.drawable.feed_icon, ), @@ -51,7 +61,7 @@ val bottomNavigationItems = listOf( drawableIconId = me.floow.uikit.R.drawable.chats_icon, ), BottomNavigationItem( - route = ProfileScreen, + route = SelfProfileScreen, titleId = R.string.profile_bottom_nav_label, drawableIconId = me.floow.uikit.R.drawable.profile_icon, ), @@ -60,5 +70,5 @@ val bottomNavigationItems = listOf( val mainBottomBarNavigationDestinations = listOf( FeedScreen, ChatsScreen, - ProfileScreen + SelfProfileScreen ) \ No newline at end of file diff --git a/app/src/main/java/me/floow/app/navigation/profileDeeplinkuri.kt b/app/src/main/java/me/floow/app/navigation/profileDeeplinkuri.kt new file mode 100644 index 0000000..b081627 --- /dev/null +++ b/app/src/main/java/me/floow/app/navigation/profileDeeplinkuri.kt @@ -0,0 +1,3 @@ +package me.floow.app.navigation + +const val profileDeeplinkUri = "https://floow.me/" \ No newline at end of file diff --git a/app/src/main/java/me/floow/app/ui/App.kt b/app/src/main/java/me/floow/app/ui/App.kt index 06bb309..091f9ea 100644 --- a/app/src/main/java/me/floow/app/ui/App.kt +++ b/app/src/main/java/me/floow/app/ui/App.kt @@ -3,6 +3,7 @@ package me.floow.app.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.* @@ -59,6 +60,7 @@ fun App( modifier = Modifier .fillMaxSize() .padding(innerPadding) + .consumeWindowInsets(innerPadding) ) } } diff --git a/app/src/main/res/drawable/ic_launcher_monochrome_foreground.xml b/app/src/main/res/drawable/ic_launcher_monochrome_foreground.xml index 1cb2479..e87dc12 100644 --- a/app/src/main/res/drawable/ic_launcher_monochrome_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_monochrome_foreground.xml @@ -4,18 +4,21 @@ android:viewportWidth="108" android:viewportHeight="108"> + android:pathData="M55.66,71.47L58.17,72.41L59.64,72.94L57.83,67.48L56.41,63.51C56.12,62.7 56.3,61.79 56.87,61.15L60.66,56.93L60.7,56.95L52.62,34.49L46.83,37.48L52.64,55.37C52.99,56.44 52.53,57.61 51.57,58.13C50.61,58.66 49.42,58.38 48.76,57.48L42.81,49.32L40.37,52.54L42.64,54.37L51.86,61.55C52.39,61.96 52.71,62.59 52.74,63.27C52.77,63.95 52.51,64.61 52.03,65.07L48.27,68.68L55.38,71.36" + android:fillColor="#DFC0B2" + android:fillType="evenOdd"/> - + android:pathData="M61.07,63.27L62.02,65.94L62.04,65.99L63.94,71.71L67.91,65.89L63.26,60.83L61.07,63.27Z" + android:fillColor="#DFC0B2" + android:fillType="evenOdd"/> + + + + diff --git a/core/api/src/main/java/me/floow/api/util/safeApiCall.kt b/core/api/src/main/java/me/floow/api/util/safeApiCall.kt index 314bb90..1460c7b 100644 --- a/core/api/src/main/java/me/floow/api/util/safeApiCall.kt +++ b/core/api/src/main/java/me/floow/api/util/safeApiCall.kt @@ -1,9 +1,9 @@ package me.floow.api.util import io.ktor.util.network.* -import io.ktor.utils.io.core.EOFException -import io.ktor.utils.io.errors.IOException import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.io.EOFException +import kotlinx.io.IOException suspend fun safeApiCall(errorResponse: T, apiCall: suspend () -> T): T { return try { @@ -17,4 +17,4 @@ suspend fun safeApiCall(errorResponse: T, apiCall: suspend () -> T): T { } catch (ex: IOException) { errorResponse } -} \ No newline at end of file +} diff --git a/core/data/src/main/java/me/floow/data/repos/ProfileRepositoryImpl.kt b/core/data/src/main/java/me/floow/data/repos/ProfileRepositoryImpl.kt index 9f4fd17..7281ffd 100644 --- a/core/data/src/main/java/me/floow/data/repos/ProfileRepositoryImpl.kt +++ b/core/data/src/main/java/me/floow/data/repos/ProfileRepositoryImpl.kt @@ -13,11 +13,13 @@ import me.floow.domain.utils.Logger import me.floow.domain.values.ProfileDescription import me.floow.domain.values.ProfileName import me.floow.domain.values.ProfileUsername +import me.floow.domain.values.util.RawValueObjectCreate class ProfileRepositoryImpl( private val logger: Logger, private val profileApi: ProfileApi, ) : ProfileRepository { + @OptIn(RawValueObjectCreate::class) override suspend fun getSelfData(): GetDataResponse { return when (val selfData = profileApi.getSelf()) { is GetSelfResponse.Success -> { @@ -25,9 +27,9 @@ class ProfileRepositoryImpl( GetDataResponse.Success( SelfProfile( - name = selfData.name?.let { ProfileName(it) }, - username = selfData.username?.let { ProfileUsername(it) }, - description = selfData.biography?.let { ProfileDescription(it) }, + name = selfData.name?.let { ProfileName.createRaw(it) }, + username = selfData.username?.let { ProfileUsername.createRaw(it) }, + description = selfData.biography?.let { ProfileDescription.createRaw(it) }, avatarUrl = selfData.avatarUrl, ) ) diff --git a/core/database/src/main/java/me/floow/database/sharedpref/ProfileCacheProviderImpl.kt b/core/database/src/main/java/me/floow/database/sharedpref/ProfileCacheProviderImpl.kt index 029cae4..6516f3f 100644 --- a/core/database/src/main/java/me/floow/database/sharedpref/ProfileCacheProviderImpl.kt +++ b/core/database/src/main/java/me/floow/database/sharedpref/ProfileCacheProviderImpl.kt @@ -6,6 +6,7 @@ import me.floow.domain.models.SelfProfile import me.floow.domain.values.ProfileDescription import me.floow.domain.values.ProfileName import me.floow.domain.values.ProfileUsername +import me.floow.domain.values.util.RawValueObjectCreate class ProfileCacheProviderImpl( context: Context @@ -22,6 +23,7 @@ class ProfileCacheProviderImpl( private val sharedPreferences = context.getSharedPreferences(PROFILE_CACHE_NAME, Context.MODE_PRIVATE) + @OptIn(RawValueObjectCreate::class) override fun getSelfProfile(): SelfProfile { val name = sharedPreferences.getString(PROFILE_NAME, null) val username = sharedPreferences.getString(PROFILE_NAME, null) @@ -29,10 +31,10 @@ class ProfileCacheProviderImpl( val avatarUrl = sharedPreferences.getString(PROFILE_AVATAR_URL, null) return SelfProfile( - name = name?.let { ProfileName(it) }, + name = name?.let { ProfileName.createRaw(it) }, avatarUrl = avatarUrl, - username = username?.let { ProfileUsername(it) }, - description = bio?.let { ProfileDescription(it) } + username = username?.let { ProfileUsername.createRaw(it) }, + description = bio?.let { ProfileDescription.createRaw(it) } ) } diff --git a/core/domain/src/main/java/me/floow/domain/values/ProfileDescription.kt b/core/domain/src/main/java/me/floow/domain/values/ProfileDescription.kt index 1bb7f55..6d48795 100644 --- a/core/domain/src/main/java/me/floow/domain/values/ProfileDescription.kt +++ b/core/domain/src/main/java/me/floow/domain/values/ProfileDescription.kt @@ -1,23 +1,31 @@ package me.floow.domain.values +import me.floow.domain.values.util.RawValueObjectCreate import me.floow.domain.values.util.ValidationError import me.floow.domain.values.util.ValueValidationResult @JvmInline -value class ProfileDescription( +value class ProfileDescription private constructor( val value: String ) { - init { - check(value.length <= 140) - } - companion object { - fun create(value: String): ValueValidationResult { + fun create(value: String): ProfileDescription { + check(value.length <= 140) + + return ProfileDescription(value) + } + + fun createWithValidation(value: String): ValueValidationResult { return if (value.length > 140) { ValueValidationResult.Invalid(ValidationError.TooLarge) } else { ValueValidationResult.Valid(ProfileDescription(value)) } } + + @RawValueObjectCreate + fun createRaw(value: String): ProfileDescription { + return ProfileDescription(value) + } } } diff --git a/core/domain/src/main/java/me/floow/domain/values/ProfileName.kt b/core/domain/src/main/java/me/floow/domain/values/ProfileName.kt index 320d93e..52f7fa9 100644 --- a/core/domain/src/main/java/me/floow/domain/values/ProfileName.kt +++ b/core/domain/src/main/java/me/floow/domain/values/ProfileName.kt @@ -1,22 +1,25 @@ package me.floow.domain.values +import me.floow.domain.values.util.RawValueObjectCreate import me.floow.domain.values.util.ValidationError import me.floow.domain.values.util.ValueValidationResult @JvmInline -value class ProfileName( +value class ProfileName private constructor( val value: String ) { - init { - check(value.isNotBlank()) { "Should not be blank" } + companion object { + fun create(value: String): ProfileName { + check(value.isNotBlank()) { "Should not be blank" } - check(!value.contains(Regex("[<>&\"']"))) { "Should not contain '<', '>', '&', '\"' and '''" } + check(!value.contains(Regex("[<>&\"']"))) { "Should not contain '<', '>', '&', '\"' and '''" } - check(value.length in 1..32) { "Length should be between 1 and 32 symbols" } - } + check(value.length in 1..32) { "Length should be between 1 and 32 symbols" } - companion object { - fun create(value: String): ValueValidationResult { + return ProfileName(value) + } + + fun createWithValidation(value: String): ValueValidationResult { return when (value.length) { 0 -> { ValueValidationResult.Invalid(ValidationError.ShouldNotBeBlank) @@ -35,5 +38,10 @@ value class ProfileName( } } } + + @RawValueObjectCreate + fun createRaw(value: String): ProfileName { + return ProfileName(value) + } } } diff --git a/core/domain/src/main/java/me/floow/domain/values/ProfileUsername.kt b/core/domain/src/main/java/me/floow/domain/values/ProfileUsername.kt index 331840b..236f772 100644 --- a/core/domain/src/main/java/me/floow/domain/values/ProfileUsername.kt +++ b/core/domain/src/main/java/me/floow/domain/values/ProfileUsername.kt @@ -1,22 +1,25 @@ package me.floow.domain.values +import me.floow.domain.values.util.RawValueObjectCreate import me.floow.domain.values.util.ValidationError import me.floow.domain.values.util.ValueValidationResult @JvmInline -value class ProfileUsername( +value class ProfileUsername private constructor( val value: String ) { - init { - check(value.isNotBlank()) { "Should not be blank" } + companion object { + fun create(value: String): ProfileUsername { + check(value.isNotBlank()) { "Should not be blank" } - check(value.contains(Regex("^[a-zA-Z0-9_]+$"))) { "Should contain only a-z, A-Z, 0-9 and underscores" } + check(value.contains(Regex("^[a-zA-Z0-9_]+$"))) { "Should contain only a-z, A-Z, 0-9 and underscores" } - check(value.length in 3..32) { "Length should be between 3 and 32 symbols" } - } + check(value.length in 3..32) { "Length should be between 3 and 32 symbols" } - companion object { - fun create(value: String): ValueValidationResult { + return ProfileUsername(value) + } + + fun createWithValidation(value: String): ValueValidationResult { return when (value.length) { 0 -> { ValueValidationResult.Invalid(ValidationError.ShouldNotBeBlank) @@ -39,5 +42,10 @@ value class ProfileUsername( } } } + + @RawValueObjectCreate + fun createRaw(value: String): ProfileUsername { + return ProfileUsername(value) + } } } diff --git a/core/domain/src/main/java/me/floow/domain/values/util/RawValueObjectCreate.kt b/core/domain/src/main/java/me/floow/domain/values/util/RawValueObjectCreate.kt new file mode 100644 index 0000000..748b526 --- /dev/null +++ b/core/domain/src/main/java/me/floow/domain/values/util/RawValueObjectCreate.kt @@ -0,0 +1,6 @@ +package me.floow.domain.values.util + +@RequiresOptIn(message = "Raw object creation can prove unsatisfying of business behaviours") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CONSTRUCTOR) +annotation class RawValueObjectCreate \ No newline at end of file diff --git a/core/mock/src/main/java/me/floow/mock/data/MockProfileRepository.kt b/core/mock/src/main/java/me/floow/mock/data/MockProfileRepository.kt index 87ca2ea..f4ca243 100644 --- a/core/mock/src/main/java/me/floow/mock/data/MockProfileRepository.kt +++ b/core/mock/src/main/java/me/floow/mock/data/MockProfileRepository.kt @@ -8,13 +8,15 @@ import me.floow.domain.models.SelfProfile import me.floow.domain.values.ProfileDescription import me.floow.domain.values.ProfileName import me.floow.domain.values.ProfileUsername +import me.floow.domain.values.util.RawValueObjectCreate class MockProfileRepository : ProfileRepository { + @OptIn(RawValueObjectCreate::class) private var userProfile = SelfProfile( - name = ProfileName("Demn"), + name = ProfileName.createRaw("Demn"), avatarUrl = "https://http.cat/images/200.jpg", - username = ProfileUsername("demndevel"), - description = ProfileDescription("Hi. My name is demn. I like coding: Kotlin, F#, Jetpack Compose, SwiftUI and etc. Welcome to my profile screen!") + username = ProfileUsername.createRaw("demndevel"), + description = ProfileDescription.createRaw("Hi. My name is demn. I like coding: Kotlin, F#, Jetpack Compose, SwiftUI and etc. Welcome to my profile screen!") ) override suspend fun getSelfData(): GetDataResponse { diff --git a/core/uikit/build.gradle.kts b/core/uikit/build.gradle.kts index 7d47440..c75b475 100644 --- a/core/uikit/build.gradle.kts +++ b/core/uikit/build.gradle.kts @@ -49,6 +49,8 @@ dependencies { api(libs.coil) api(libs.coil.compose) + api(libs.textflow.material3) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/core/uikit/src/main/java/me/floow/uikit/components/misc/BlankContentBox.kt b/core/uikit/src/main/java/me/floow/uikit/components/misc/BlankContentBox.kt new file mode 100644 index 0000000..555c6e4 --- /dev/null +++ b/core/uikit/src/main/java/me/floow/uikit/components/misc/BlankContentBox.kt @@ -0,0 +1,62 @@ +package me.floow.uikit.components.misc + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.floow.uikit.R +import me.floow.uikit.theme.LocalTypography + +@Composable +fun BlankContentBox(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + ) { + Image( + painter = painterResource(R.drawable.blank_girl), + contentDescription = null, + ) + + Spacer(Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.there_is_nothing), + style = LocalTypography.current.titleLarge.copy( + fontSize = 24.sp, + ), + ) + + Spacer(Modifier.height(10.dp)) + + Text( + text = stringResource(R.string.but_it_will_be_soon_we_promise), + style = LocalTypography.current.bodyMedium, + ) + } + } +} + +@Preview +@Composable +private fun BlankContentBoxPreview() { + BlankContentBox( + modifier = Modifier + .fillMaxSize() + ) +} diff --git a/core/uikit/src/main/java/me/floow/uikit/components/misc/ErrorWithButtonContentBox.kt b/core/uikit/src/main/java/me/floow/uikit/components/misc/ErrorWithButtonContentBox.kt new file mode 100644 index 0000000..0cba51b --- /dev/null +++ b/core/uikit/src/main/java/me/floow/uikit/components/misc/ErrorWithButtonContentBox.kt @@ -0,0 +1,105 @@ +package me.floow.uikit.components.misc + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.floow.uikit.R +import me.floow.uikit.theme.LocalTypography + +private val curiousPicsList = listOf( + R.drawable.curious_pic_a, + R.drawable.curious_pic_b, + R.drawable.curious_pic_c, + R.drawable.curious_pic_d, +) + +@Composable +fun ErrorWithButtonContentBox( + title: String, + description: String, + onButtonClick: () -> Unit, + buttonContent: @Composable () -> Unit, + modifier: Modifier = Modifier +) { + val curiousPicResource = remember { curiousPicsList.random() } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = painterResource(curiousPicResource), + contentDescription = null, + modifier = Modifier + .size(164.dp) + ) + + Spacer(Modifier.height(10.dp)) + + Text( + text = title, + style = LocalTypography.current.titleLarge.copy( + fontSize = 24.sp, + ), + ) + + Spacer(Modifier.height(10.dp)) + + Text( + text = description, + style = LocalTypography.current.bodyMedium, + ) + + Spacer(Modifier.height(20.dp)) + + FilledTonalButton( + onClick = onButtonClick, + modifier = Modifier, + ) { + buttonContent() + } + } + } +} + +@Preview +@Composable +private fun NotFoundScreenPreview() { + Surface( + modifier = Modifier + .fillMaxSize() + ) { + ErrorWithButtonContentBox( + title = "Страница не найдена :(", + description = "Страницы не существует или она была удалена.", + onButtonClick = {}, + buttonContent = { + Text("Назад", color = MaterialTheme.colorScheme.primary) + }, + modifier = Modifier + .fillMaxSize() + ) + } +} diff --git a/core/uikit/src/main/java/me/floow/uikit/components/pickers/AvatarAndBackgroundPicker.kt b/core/uikit/src/main/java/me/floow/uikit/components/pickers/AvatarAndBackgroundPicker.kt new file mode 100644 index 0000000..7616be7 --- /dev/null +++ b/core/uikit/src/main/java/me/floow/uikit/components/pickers/AvatarAndBackgroundPicker.kt @@ -0,0 +1,126 @@ +package me.floow.uikit.components.pickers + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.paint +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.uikit.R +import me.floow.uikit.theme.ElevanagonShape +import me.floow.uikit.theme.LocalTypography + +@Composable +fun AvatarAndBackgroundPicker( + avatarImagePainter: Painter? = null, + backgroundImagePainter: Painter? = null, + onAvatarPickerClick: () -> Unit, + onBackgroundPickerClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxWidth() + .height(160.dp) + .clip(RoundedCornerShape(16.dp)) + .setBackgroundBoxImage(backgroundImagePainter) + .clickable { + onBackgroundPickerClick() + }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(14.dp) + ) { + Text( + text = "изменить фон", + style = LocalTypography.current.labelMedium, + color = Color.White + ) + + Spacer(Modifier.width(8.dp)) + + Icon( + painter = painterResource(R.drawable.profile_background_image_icon), + tint = Color.White, + contentDescription = null + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(120.dp) + .clip(ElevanagonShape) + .setAvatarBoxImage(avatarImagePainter) + .clickable { + onAvatarPickerClick() + } + ) { + Icon( + painter = painterResource(R.drawable.photo_icon), + contentDescription = null, + tint = Color.White, + ) + } + } +} + +private fun Modifier.setAvatarBoxImage(avatarImagePainter: Painter?): Modifier { + return if (avatarImagePainter == null) { + this.then( + Modifier + .background(Color.LightGray) + ) + } else { + Modifier + .paint( + painter = avatarImagePainter, + contentScale = ContentScale.FillBounds + ) + } +} + +@Composable +private fun Modifier.setBackgroundBoxImage(backgroundImagePainter: Painter?): Modifier { + return if (backgroundImagePainter == null) { + this.then( + Modifier + .background(MaterialTheme.colorScheme.primaryContainer) + ) + } else { + Modifier + .paint( + painter = backgroundImagePainter, + contentScale = ContentScale.FillBounds + ) + } +} + +@Preview +@Composable +fun AvatarAndBackgroundPickerPreview() { + AvatarAndBackgroundPicker(null, null, {}, {}, Modifier.fillMaxWidth()) +} \ No newline at end of file diff --git a/core/uikit/src/main/java/me/floow/uikit/components/topbar/ProfileTopBar.kt b/core/uikit/src/main/java/me/floow/uikit/components/topbar/ProfileTopBar.kt index 20d94da..3840e66 100644 --- a/core/uikit/src/main/java/me/floow/uikit/components/topbar/ProfileTopBar.kt +++ b/core/uikit/src/main/java/me/floow/uikit/components/topbar/ProfileTopBar.kt @@ -42,7 +42,7 @@ fun ProfileTopBar( Column { Text( text = profileUsername, - style = LocalTypography.current.titleMedium + style = LocalTypography.current.titleLarge ) Text( diff --git a/core/uikit/src/main/java/me/floow/uikit/components/topbar/SearchTopBar.kt b/core/uikit/src/main/java/me/floow/uikit/components/topbar/SearchTopBar.kt index 6a83e53..423b18f 100644 --- a/core/uikit/src/main/java/me/floow/uikit/components/topbar/SearchTopBar.kt +++ b/core/uikit/src/main/java/me/floow/uikit/components/topbar/SearchTopBar.kt @@ -1,77 +1,133 @@ package me.floow.uikit.components.topbar -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.DockedSearchBar +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SearchBar -import androidx.compose.material3.SearchBarColors -import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.TextField +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.uikit.R +import me.floow.uikit.theme.LocalTypography -/** - * Material Design search. - * - * A search bar represents a floating search field that allows users to enter a keyword or phrase - * and get relevant information. It can be used as a way to navigate through an app via search - * queries. - * - * A search bar expands into a search "view" and can be used to display dynamic suggestions or - * search results. - * - * @see - * - * A [SearchBar] tries to occupy the entirety of its allowed size in the expanded state. For - * full-screen behavior as specified by Material guidelines, parent layouts of the [SearchBar] must - * not pass any [Constraints] that limit its size, and the host activity should set - * `WindowCompat.setDecorFitsSystemWindows(window, false)`. - * - * If this expansion behavior is undesirable, for example on large tablet screens, [DockedSearchBar] - * can be used instead. - * - * An example looks like: - * - * @sample androidx.compose.material3.samples.SearchBarSample - * - * @param inputField the input field of this search bar that allows entering a query, typically a - * [SearchBarDefaults.InputField]. - * @param expanded whether this search bar is expanded and showing search results. - * @param onExpandedChange the callback to be invoked when this search bar's expanded state is - * changed. - * @param modifier the [Modifier] to be applied to this search bar. - * @param shape the shape of this search bar when it is not [expanded]. When [expanded], the shape - * will always be [SearchBarDefaults.fullScreenShape]. - * @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar - * in different states. See [SearchBarDefaults.colors]. - * @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a - * translucent primary color overlay is applied on top of the container. A higher tonal elevation - * value will result in a darker color in light theme and lighter color in dark theme. See also: - * [Surface]. - * @param shadowElevation the elevation for the shadow below this search bar - * @param windowInsets the window insets that this search bar will respect - * @param content the content of this search bar to display search results below the [inputField]. - */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchTopBar( + onBackClick: () -> Unit, + placeholder: String, searchFieldValue: String, onSearchFieldUpdate: (String) -> Unit, modifier: Modifier = Modifier ) { - SearchBar( - inputField = { - TextField( + Column( + modifier = modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(80.dp) + .padding(horizontal = 24.dp), + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier.size(24.dp) + ) { + Icon( + painter = painterResource(R.drawable.nav_back_icon), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + + Spacer(Modifier.width(10.dp)) + + BasicTextField( value = searchFieldValue, onValueChange = onSearchFieldUpdate, + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground), + textStyle = LocalTypography.current.bodyMedium.copy( + color = MaterialTheme.colorScheme.onBackground + ), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .padding( + horizontal = 14.dp, + vertical = 20.dp + ) + .fillMaxWidth() + ) { + Box(Modifier.fillMaxWidth()) { + innerTextField() + + if (searchFieldValue.isEmpty()) { + Text( + text = placeholder, + style = LocalTypography.current.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier + ) + } + } + } + }, + modifier = Modifier + .weight(1f) + .height(58.dp) ) - }, - expanded = false, - onExpandedChange = {}, - ) { + } + HorizontalDivider() + } +} + +@Preview +@Composable +private fun SearchTopBarPreview() { + var searchBarValue by remember { mutableStateOf("") } + + Scaffold( + topBar = { + SearchTopBar( + onBackClick = {}, + searchFieldValue = searchBarValue, + placeholder = "Search…", + onSearchFieldUpdate = { searchBarValue = it } + ) + }, + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + Box( + Modifier + .padding(innerPadding) + .fillMaxSize() + .background(Color.LightGray) + ) } } \ No newline at end of file diff --git a/core/uikit/src/main/java/me/floow/uikit/components/topbar/TitleTopBarWithActionButton.kt b/core/uikit/src/main/java/me/floow/uikit/components/topbar/TitleTopBarWithActionButton.kt index 6b26bb6..6e8fc5a 100644 --- a/core/uikit/src/main/java/me/floow/uikit/components/topbar/TitleTopBarWithActionButton.kt +++ b/core/uikit/src/main/java/me/floow/uikit/components/topbar/TitleTopBarWithActionButton.kt @@ -34,7 +34,7 @@ fun TitleTopBarWithActionButton( Text( text = titleText, overflow = TextOverflow.Ellipsis, - style = LocalTypography.current.titleMedium, + style = LocalTypography.current.titleLarge, maxLines = 1, modifier = Modifier.weight(1f) ) diff --git a/core/uikit/src/main/java/me/floow/uikit/components/topbar/TitleTopBarWithActionButtonWithNavBack.kt b/core/uikit/src/main/java/me/floow/uikit/components/topbar/TitleTopBarWithActionButtonWithNavBack.kt index cc2e6c4..99bfb7f 100644 --- a/core/uikit/src/main/java/me/floow/uikit/components/topbar/TitleTopBarWithActionButtonWithNavBack.kt +++ b/core/uikit/src/main/java/me/floow/uikit/components/topbar/TitleTopBarWithActionButtonWithNavBack.kt @@ -55,7 +55,7 @@ fun TitleTopBarWithActionButtonWithNavBack( Text( text = titleText, - style = LocalTypography.current.titleMedium + style = LocalTypography.current.titleLarge ) Spacer(Modifier.weight(1f)) diff --git a/core/uikit/src/main/java/me/floow/uikit/theme/Theme.kt b/core/uikit/src/main/java/me/floow/uikit/theme/Theme.kt index 229e5cc..6df0f95 100644 --- a/core/uikit/src/main/java/me/floow/uikit/theme/Theme.kt +++ b/core/uikit/src/main/java/me/floow/uikit/theme/Theme.kt @@ -17,13 +17,20 @@ import androidx.core.view.WindowCompat @Immutable data class FlowTypography( - val titleMedium: TextStyle = TextStyle( + val titleLarge: TextStyle = TextStyle( fontFamily = roboto, fontWeight = FontWeight.Black, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = 0.38.sp ), + val titleMedium: TextStyle = TextStyle( + fontFamily = roboto, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 20.sp, + letterSpacing = (-0.32).sp + ), val bodyMedium: TextStyle = TextStyle( fontFamily = roboto, fontWeight = FontWeight.Normal, @@ -31,6 +38,20 @@ data class FlowTypography( lineHeight = 20.sp, letterSpacing = (-0.24).sp ), + val captionSmall: TextStyle = TextStyle( + fontFamily = roboto, + fontWeight = FontWeight.Normal, + fontSize = 9.sp, + lineHeight = 14.sp, + letterSpacing = 0.2.sp + ), + val captionMedium: TextStyle = TextStyle( + fontFamily = roboto, + fontWeight = FontWeight.Normal, + fontSize = 13.sp, + lineHeight = 14.sp, + letterSpacing = 0.sp + ), val labelMedium: TextStyle = TextStyle( fontFamily = roboto, fontWeight = FontWeight.Normal, diff --git a/core/uikit/src/main/res/drawable/blank_girl.xml b/core/uikit/src/main/res/drawable/blank_girl.xml new file mode 100644 index 0000000..48a1b49 --- /dev/null +++ b/core/uikit/src/main/res/drawable/blank_girl.xml @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/uikit/src/main/res/drawable/curious_pic_a.png b/core/uikit/src/main/res/drawable/curious_pic_a.png new file mode 100644 index 0000000..636b81a Binary files /dev/null and b/core/uikit/src/main/res/drawable/curious_pic_a.png differ diff --git a/core/uikit/src/main/res/drawable/curious_pic_b.png b/core/uikit/src/main/res/drawable/curious_pic_b.png new file mode 100644 index 0000000..111058b Binary files /dev/null and b/core/uikit/src/main/res/drawable/curious_pic_b.png differ diff --git a/core/uikit/src/main/res/drawable/curious_pic_c.png b/core/uikit/src/main/res/drawable/curious_pic_c.png new file mode 100644 index 0000000..e0b9a94 Binary files /dev/null and b/core/uikit/src/main/res/drawable/curious_pic_c.png differ diff --git a/core/uikit/src/main/res/drawable/curious_pic_d.png b/core/uikit/src/main/res/drawable/curious_pic_d.png new file mode 100644 index 0000000..11f9678 Binary files /dev/null and b/core/uikit/src/main/res/drawable/curious_pic_d.png differ diff --git a/core/uikit/src/main/res/drawable/dropdown_icon.xml b/core/uikit/src/main/res/drawable/dropdown_icon.xml new file mode 100644 index 0000000..450a6e0 --- /dev/null +++ b/core/uikit/src/main/res/drawable/dropdown_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/uikit/src/main/res/drawable/emoji_picker_icon.xml b/core/uikit/src/main/res/drawable/emoji_picker_icon.xml new file mode 100644 index 0000000..b9047dd --- /dev/null +++ b/core/uikit/src/main/res/drawable/emoji_picker_icon.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/uikit/src/main/res/drawable/profile_background_image_icon.xml b/core/uikit/src/main/res/drawable/profile_background_image_icon.xml new file mode 100644 index 0000000..64db391 --- /dev/null +++ b/core/uikit/src/main/res/drawable/profile_background_image_icon.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/core/uikit/src/main/res/drawable/reply_icon.xml b/core/uikit/src/main/res/drawable/reply_icon.xml new file mode 100644 index 0000000..60bb971 --- /dev/null +++ b/core/uikit/src/main/res/drawable/reply_icon.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/uikit/src/main/res/drawable/reply_out_icon.xml b/core/uikit/src/main/res/drawable/reply_out_icon.xml new file mode 100644 index 0000000..0f19f3b --- /dev/null +++ b/core/uikit/src/main/res/drawable/reply_out_icon.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/core/uikit/src/main/res/drawable/send_icon.xml b/core/uikit/src/main/res/drawable/send_icon.xml new file mode 100644 index 0000000..aace891 --- /dev/null +++ b/core/uikit/src/main/res/drawable/send_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/uikit/src/main/res/values-ru/strings.xml b/core/uikit/src/main/res/values-ru/strings.xml index 981299f..04bb993 100644 --- a/core/uikit/src/main/res/values-ru/strings.xml +++ b/core/uikit/src/main/res/values-ru/strings.xml @@ -13,4 +13,8 @@ подписчиков Нет подписчиков Вы можете использовать символы a–z, 0–9 и нижние подчеркивания - \ No newline at end of file + Онлайн + Оффлайн + Ничего нет. + Но скоро будет, обещаем! + diff --git a/core/uikit/src/main/res/values/strings.xml b/core/uikit/src/main/res/values/strings.xml index 0745eae..0ef3409 100644 --- a/core/uikit/src/main/res/values/strings.xml +++ b/core/uikit/src/main/res/values/strings.xml @@ -13,4 +13,8 @@ subscribers No subscribers You can use the characters a-z, 0-9, and underscores - \ No newline at end of file + Online + Offline + There is nothing. + But it will be soon, we promise! + diff --git a/feature/chats/build.gradle.kts b/feature/chats/build.gradle.kts index 272efdc..5f785da 100644 --- a/feature/chats/build.gradle.kts +++ b/feature/chats/build.gradle.kts @@ -1,46 +1,52 @@ @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { - alias(libs.plugins.androidLibrary) - alias(libs.plugins.kotlinAndroid) - alias(libs.plugins.composeCompiler) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.composeCompiler) } android { - namespace = "me.flowme.chats" - compileSdk = 34 - - defaultConfig { - minSdk = 28 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } - } - buildFeatures { compose = true } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } + namespace = "me.flowme.chats" + compileSdk = 35 + + defaultConfig { + minSdk = 28 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + buildFeatures { compose = true } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } } dependencies { - implementation(project(":core:uikit")) + implementation(project(":core:uikit")) + implementation(project(":core:domain")) - implementation(libs.appcompat) - implementation(libs.ui.tooling) - implementation(libs.ui.tooling.preview) + implementation(libs.appcompat) - testImplementation(libs.junit) + api(platform(libs.koin.bom)) + api(libs.koin.core) + api(libs.koin.android) - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.espresso.core) + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) } \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ChatRoute.kt b/feature/chats/src/main/java/me/floow/chats/ChatRoute.kt new file mode 100644 index 0000000..7c058c4 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ChatRoute.kt @@ -0,0 +1,64 @@ +package me.floow.chats + +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import me.floow.chats.ui.chat.ChatScreen +import me.floow.chats.uilogic.chat.ChatScreenViewModel +import me.floow.uikit.util.SetNavigationBarColor + +data class ChatRouteInitialData( + val chatInterlocutorId: Long, + val chatInterlocutorName: String, + val chatInterlocutorAvatarUrl: Uri? +) + +@Composable +fun ChatRoute( + initialData: ChatRouteInitialData, + vm: ChatScreenViewModel, + modifier: Modifier = Modifier +) { + val state by vm.state.collectAsState() + val context = LocalContext.current + + LaunchedEffect(Unit) { + vm.setInitialData( + initialData.chatInterlocutorId, + initialData.chatInterlocutorName, + initialData.chatInterlocutorAvatarUrl, + ) + + vm.loadData() + } + + ChatScreen( + onProfileClick = {}, + onTopBarDropdownClick = {}, + onChatBubbleClick = {}, + onReply = vm::addCurrentReply, + onReplyClick = {}, + onCurrentReplyClose = vm::closeCurrentReply, + onCurrentReplyClick = { showTodoToast(context) }, + onMessageInputFieldValueChange = vm::updateMessageInputField, + onEmojiPickerClick = { showTodoToast(context) }, + onSendClick = vm::sendMessage, + state = state, + modifier = modifier, + ) + + SetNavigationBarColor( + MaterialTheme.colorScheme.background + ) +} + +private fun showTodoToast(context: Context) { + Toast.makeText(context, "feature currently unavailable", Toast.LENGTH_SHORT).show() +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ChatsRoute.kt b/feature/chats/src/main/java/me/floow/chats/ChatsRoute.kt new file mode 100644 index 0000000..651c60f --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ChatsRoute.kt @@ -0,0 +1,37 @@ +package me.floow.chats + +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import me.floow.chats.ui.chats.ChatsScreen +import me.floow.chats.uilogic.chats.Chat +import me.floow.chats.uilogic.chats.ChatsScreenViewModel +import me.floow.uikit.util.SetNavigationBarColor + +@Composable +fun ChatsRoute( + onSearchClick: () -> Unit, + onChatClick: (Chat) -> Unit, + vm: ChatsScreenViewModel, + modifier: Modifier = Modifier +) { + val state by vm.state.collectAsState() + + LaunchedEffect(Unit) { + vm.load() + } + + ChatsScreen( + onSearchClick = onSearchClick, + state = state, + onChatClick = onChatClick, + modifier = modifier + ) + + SetNavigationBarColor( + NavigationBarDefaults.containerColor + ) +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/di/chatsModule.kt b/feature/chats/src/main/java/me/floow/chats/di/chatsModule.kt new file mode 100644 index 0000000..420e247 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/di/chatsModule.kt @@ -0,0 +1,11 @@ +package me.floow.chats.di + +import me.floow.chats.uilogic.chats.ChatsScreenViewModel +import me.floow.chats.uilogic.chat.ChatScreenViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val chatsModule = module { + viewModelOf(::ChatsScreenViewModel) + viewModelOf(::ChatScreenViewModel) +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/ChatsRoute.kt b/feature/chats/src/main/java/me/floow/chats/ui/ChatsRoute.kt deleted file mode 100644 index ebaa2f1..0000000 --- a/feature/chats/src/main/java/me/floow/chats/ui/ChatsRoute.kt +++ /dev/null @@ -1,19 +0,0 @@ -package me.floow.chats.ui - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBarDefaults -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import me.floow.uikit.util.SetNavigationBarColor - -@Composable -fun ChatsRoute( - modifier: Modifier = Modifier -) { - ChatsScreen(modifier) - - SetNavigationBarColor( - NavigationBarDefaults.containerColor - ) -} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/ChatsScreen.kt b/feature/chats/src/main/java/me/floow/chats/ui/ChatsScreen.kt deleted file mode 100644 index 7e7c191..0000000 --- a/feature/chats/src/main/java/me/floow/chats/ui/ChatsScreen.kt +++ /dev/null @@ -1,59 +0,0 @@ -package me.floow.chats.ui - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import me.floow.uikit.R -import me.floow.uikit.components.topbar.TitleTopBarWithActionButton - -@Composable -internal fun ChatsScreen( - modifier: Modifier = Modifier -) { - Scaffold( - topBar = { - TitleTopBarWithActionButton( - titleText = "Чаты", - onActionButtonClick = { TODO() }, - icon = { - Icon( - painterResource(R.drawable.search_icon), - null - ) - } - ) - }, - modifier = modifier - ) { innerPadding -> - Column( - Modifier - .padding(innerPadding) - .padding(8.dp) - ) { - Spacer(Modifier.height(24.dp)) - - Text( - text = "Flow!", - style = MaterialTheme.typography.titleLarge, - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = "Chats" - ) - } - } -} - -@Preview -@Composable -private fun ChatsScreenPreview() { - ChatsScreen( - modifier = Modifier.fillMaxSize() - ) -} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/ChatScreen.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..87ac0c9 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/ChatScreen.kt @@ -0,0 +1,126 @@ +package me.floow.chats.ui.chat + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import me.floow.chats.ui.chat.components.ChatScreenTopBar +import me.floow.chats.ui.chat.components.CurrentReply +import me.floow.chats.ui.chat.components.MessageInputField +import me.floow.chats.ui.chat.states.ErrorState +import me.floow.chats.ui.chat.states.HasDataState +import me.floow.chats.ui.chat.states.LoadingState +import me.floow.chats.ui.chat.states.NoMessagesState +import me.floow.chats.uilogic.chat.ChatMessage +import me.floow.chats.uilogic.chat.ChatScreenUiState +import me.floow.uikit.R +import me.floow.uikit.theme.ElevanagonShape + +@Composable +fun ChatScreen( + onProfileClick: () -> Unit, + onTopBarDropdownClick: () -> Unit, + onChatBubbleClick: (ChatMessage) -> Unit, + onReply: (ChatMessage) -> Unit, + onReplyClick: (ChatMessage) -> Unit, + onCurrentReplyClose: () -> Unit, + onCurrentReplyClick: () -> Unit, + onMessageInputFieldValueChange: (String) -> Unit, + onEmojiPickerClick: () -> Unit, + onSendClick: () -> Unit, + state: ChatScreenUiState, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + ChatScreenTopBar( + profileName = state.chatInterlocutorName, + isOnline = false, + profileAvatar = { modifier -> + Image( + painterResource(R.drawable.cute_girl), + null, + modifier + .clip(ElevanagonShape), + ) + }, + onDropdownClick = onTopBarDropdownClick, + onProfileClick = onProfileClick, + modifier = Modifier + ) + }, + contentWindowInsets = WindowInsets(0.dp), + modifier = modifier + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + ) { + val commonModifier = Modifier + .padding(innerPadding) + .fillMaxWidth() + .weight(1f) + .background(MaterialTheme.colorScheme.surfaceContainer) + + when (state) { + is ChatScreenUiState.Loading -> { + LoadingState(commonModifier) + } + + is ChatScreenUiState.Error -> { + ErrorState(commonModifier) + } + + is ChatScreenUiState.NoMessages -> { + NoMessagesState(commonModifier) + } + + is ChatScreenUiState.HasData -> { + HasDataState( + state = state, + onChatBubbleClick = onChatBubbleClick, + onReply = onReply, + onReplyClick = onReplyClick, + modifier = commonModifier + ) + } + } + + state.messageFieldReply?.let { reply -> + CurrentReply( + userNameToReply = reply.replyAuthorName, + replyMessageText = reply.replyMessageText, + onClose = onCurrentReplyClose, + onClick = onCurrentReplyClick, + modifier = Modifier + .fillMaxWidth() + ) + } + + HorizontalDivider() + + MessageInputField( + value = state.messageFieldValue, + onValueChange = onMessageInputFieldValueChange, + onEmojiPickerClick = onEmojiPickerClick, + onSendClick = onSendClick, + sendButtonActive = state.messageFieldValue.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + ) + } + } +} diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/ChatBubble.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/ChatBubble.kt new file mode 100644 index 0000000..02c9c22 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/ChatBubble.kt @@ -0,0 +1,243 @@ +package me.floow.chats.ui.chat.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.chats.uilogic.chat.ChatMessage +import me.floow.chats.uilogic.chat.ChatReplyMessage +import me.floow.chats.uilogic.chat.PrimaryInMessage +import me.floow.chats.uilogic.chat.PrimaryOutMessage +import me.floow.chats.uilogic.chat.ReplyInMessage +import me.floow.chats.uilogic.chat.ReplyOutMessage +import me.floow.uikit.R +import me.floow.uikit.theme.LocalTypography +import me.floow.uikit.util.ComponentPreviewBox +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +data class ChatBubbleColors( + val backgroundColor: Color, + val borderColor: Color, + val textColor: Color, + val timeColor: Color, + val replyColor: Color, +) { + companion object { + val Default: ChatBubbleColors + @Composable get() = ChatBubbleColors( + backgroundColor = MaterialTheme.colorScheme.inversePrimary, + borderColor = Color(0xFFBEBEBE), + textColor = MaterialTheme.colorScheme.onBackground, + timeColor = MaterialTheme.colorScheme.onBackground, + replyColor = Color(0xFFBEBEBE), + ) + + val Outlined: ChatBubbleColors + @Composable get() = ChatBubbleColors( + backgroundColor = MaterialTheme.colorScheme.surfaceContainer, + borderColor = Color(0xFFBEBEBE), + textColor = MaterialTheme.colorScheme.onBackground, + timeColor = Color(0xFFBEBEBE), + replyColor = Color(0xFFBEBEBE), + ) + } +} + +@Composable +fun ChatBubble( + chatMessage: ChatMessage, + onClick: (ChatMessage) -> Unit, + onReplyClick: (ChatMessage) -> Unit, + modifier: Modifier = Modifier +) { + val isOut: Boolean = chatMessage is PrimaryOutMessage || chatMessage is ReplyOutMessage + + val colors = if (!isOut) ChatBubbleColors.Outlined else ChatBubbleColors.Default + + Column( + horizontalAlignment = if (isOut) Alignment.End else Alignment.Start, + modifier = modifier, + ) { + Column( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) +// .clickable { onClick(chatMessage) } TODO + .background(colors.backgroundColor) + .addBorderIfIn(isOut, colors) + .padding(vertical = 8.dp, horizontal = 10.dp) + .widthIn(74.dp, 324.dp) + .width(IntrinsicSize.Max) + ) { + Text( + text = chatMessage.messageText, + color = colors.textColor, + style = LocalTypography.current.bodyMedium, + modifier = Modifier + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Text( + text = chatMessage.dateTime.format(DateTimeFormatter.ofPattern("hh:mm")), + color = colors.timeColor, + style = LocalTypography.current.labelMedium, + textAlign = TextAlign.End, + ) + } + } + + if (chatMessage is ChatReplyMessage) { + Spacer(Modifier.height(3.dp)) + + Row( + horizontalArrangement = if (isOut) Arrangement.End else Arrangement.Start, + modifier = Modifier + .widthIn(74.dp, 324.dp) + ) { + ReplyContent( + replyMessageText = chatMessage.replyMessageText, + color = colors.replyColor, + modifier = Modifier + .height(25.dp) + .clickable { onReplyClick(chatMessage) } + ) + } + } + } +} + +private fun Modifier.addBorderIfIn(out: Boolean, colors: ChatBubbleColors): Modifier { + return if (out) this + else this.then(Modifier.border(1.dp, colors.borderColor, RoundedCornerShape(20.dp))) +} + +@Composable +private fun ReplyContent( + replyMessageText: String, + color: Color, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.reply_out_icon), + contentDescription = null, + tint = color, + ) + + Spacer(Modifier.width(6.dp)) + + Text( + text = replyMessageText, + color = color, + style = LocalTypography.current.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + ) + } +} + +@Preview +@Composable +fun ChatBubblePreview_OutReply() { + val mockMessage = ReplyOutMessage( + id = 1L, + messageText = "This is a reply out message", + dateTime = LocalDateTime.now(), + replyMessageId = 2L, + replyMessageText = "This is the message being replied to" + ) + + ComponentPreviewBox(Modifier.fillMaxWidth()) { + ChatBubble( + chatMessage = mockMessage, + onClick = {}, + onReplyClick = {} + ) + } +} + +@Preview +@Composable +fun ChatBubblePreview_InReply() { + val mockMessage = ReplyInMessage( + id = 1L, + replyMessageId = 2L, + replyMessageText = "This is the message being replied to", + messageText = "This is a reply in message", + dateTime = LocalDateTime.now() + ) + + ComponentPreviewBox(Modifier.fillMaxWidth()) { + ChatBubble( + chatMessage = mockMessage, + onClick = {}, + onReplyClick = {} + ) + } +} + +@Preview +@Composable +fun ChatBubblePreview_OutPrimary() { + val mockMessage = PrimaryOutMessage( + id = 1L, + messageText = "This is a primary out message", + dateTime = LocalDateTime.now() + ) + + ComponentPreviewBox(Modifier.fillMaxWidth()) { + ChatBubble( + chatMessage = mockMessage, + onClick = {}, + onReplyClick = {} + ) + } +} + +@Preview +@Composable +fun ChatBubblePreview_InPrimary() { + val mockMessage = PrimaryInMessage( + id = 1L, + messageText = "This is a primary in message. By the way, This is a primary in message. Lorem ipsum dolor sit amet.. Yeahhh", + dateTime = LocalDateTime.now() + ) + + ComponentPreviewBox(Modifier.fillMaxWidth()) { + ChatBubble( + chatMessage = mockMessage, + onClick = {}, + onReplyClick = {} + ) + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/ChatMessageField.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/ChatMessageField.kt new file mode 100644 index 0000000..fc5bc98 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/ChatMessageField.kt @@ -0,0 +1,9 @@ +package me.floow.chats.ui.chat.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun ChatMessageField(modifier: Modifier = Modifier) { + // TODO +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/ChatScreenTopBar.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/ChatScreenTopBar.kt new file mode 100644 index 0000000..70d3e01 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/ChatScreenTopBar.kt @@ -0,0 +1,113 @@ +package me.floow.chats.ui.chat.components + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.uikit.R +import me.floow.uikit.components.buttons.WideOutlinedIconButton +import me.floow.uikit.theme.ElevanagonShape +import me.floow.uikit.theme.LocalTypography +import me.floow.uikit.util.ComponentPreviewBox + +@Composable +fun ChatScreenTopBar( + profileName: String, + isOnline: Boolean, + profileAvatar: @Composable (Modifier) -> Unit, + onProfileClick: () -> Unit, + onDropdownClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier) { + Row( + Modifier + .fillMaxWidth() + .height(80.dp) + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { onProfileClick() } + .weight(1f), + ) { + profileAvatar(Modifier.size(50.dp)) + + Spacer(Modifier.width(8.dp)) + + Column() { + Text( + text = profileName, + style = LocalTypography.current.titleLarge + ) + + Text( + text = stringResource(if (isOnline) R.string.online else R.string.offline), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = LocalTypography.current.bodyMedium + ) + } + + Spacer(Modifier.width(16.dp)) + } + + WideOutlinedIconButton( + onClick = onDropdownClick, + modifier = Modifier + ) { + Icon( + painterResource(R.drawable.dropdown_icon), + null + ) + } + } + + HorizontalDivider() + } +} + +@Preview +@Composable +private fun ChatScreenTopBarPreview() { + ComponentPreviewBox(Modifier.fillMaxSize()) { + ChatScreenTopBar( + profileName = "Alina", + isOnline = false, + profileAvatar = { modifier -> + Image( + painterResource(R.drawable.cute_girl), + null, + modifier + .clip(ElevanagonShape), + ) + }, + onDropdownClick = {}, + onProfileClick = {}, + modifier = Modifier + .fillMaxSize() + ) + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/CurrentReply.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/CurrentReply.kt new file mode 100644 index 0000000..291fa11 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/CurrentReply.kt @@ -0,0 +1,84 @@ +package me.floow.chats.ui.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.uikit.theme.LocalTypography +import me.flowme.chats.R + +@Composable +fun CurrentReply( + userNameToReply: String, + replyMessageText: String, + onClose: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clickable { onClick() } + .padding( + horizontal = 16.dp, + vertical = 8.dp + ) + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = "${stringResource(R.string.in_reply_to)} $userNameToReply", + style = LocalTypography.current.bodyMedium, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + ) + + Text( + text = replyMessageText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = LocalTypography.current.captionMedium, + color = Color.Gray + ) + } + + IconButton( + onClick = onClose + ) { + Icon( + painter = painterResource(R.drawable.current_reply_close_icon), + contentDescription = null + ) + } + } +} + +@Preview +@Composable +fun CurrentReplyPreview() { + CurrentReply( + userNameToReply = "Bogdan", + replyMessageText = "Компот не квасят, а культивируют. даун", + onClose = {}, + onClick = {}, + modifier = Modifier + .fillMaxWidth() + ) +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/DateSeparator.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/DateSeparator.kt new file mode 100644 index 0000000..e19898f --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/DateSeparator.kt @@ -0,0 +1,45 @@ +package me.floow.chats.ui.chat.components + +import android.view.RoundedCorner +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.uikit.theme.LocalTypography +import me.floow.uikit.util.ComponentPreviewBox + +@Composable +fun DateSeparator( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = LocalTypography.current.labelMedium, + color = MaterialTheme.colorScheme.outline, + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(20.dp)) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(20.dp)) + .padding( + horizontal = 10.dp, + vertical = 8.dp + ) + ) +} + +@Preview +@Composable +fun DateSeparatorPreview() { + ComponentPreviewBox { + DateSeparator( + text = "Today", + modifier = Modifier + ) + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/MessageInputField.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/MessageInputField.kt new file mode 100644 index 0000000..ab5c6a6 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/MessageInputField.kt @@ -0,0 +1,130 @@ +package me.floow.chats.ui.chat.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.uikit.theme.LocalTypography +import me.flowme.chats.R + +@Composable +fun MessageInputField( + value: String, + onValueChange: (String) -> Unit, + onEmojiPickerClick: () -> Unit, + onSendClick: () -> Unit, + sendButtonActive: Boolean, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .padding( + top = 13.dp, + bottom = 16.dp, + ) + .padding(horizontal = 16.dp) + ) { + Icon( + painter = painterResource( + me.floow.uikit.R.drawable.emoji_picker_icon, + ), + contentDescription = null, + tint = Color(0xFF8E959B), + modifier = Modifier + .clip(CircleShape) + .clickable { onEmojiPickerClick() } + ) + + Spacer( + Modifier + .width(11.dp) + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = LocalTypography.current.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ), + maxLines = 4, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground), + modifier = Modifier + .weight(1f) + ) { innerTextField -> + Box { + if (value.isEmpty()) { + Text( + text = stringResource(R.string.message_input_field_placeholder), + color = Color.Gray, + style = LocalTypography.current.titleMedium, + fontWeight = FontWeight.Normal + ) + } + + innerTextField() + } + } + + Spacer( + Modifier + .width(11.dp) + ) + + Icon( + painter = painterResource( + me.floow.uikit.R.drawable.send_icon, + ), + tint = sendButtonColor(sendButtonActive), + contentDescription = null, + modifier = Modifier + .clickable { + if (sendButtonActive) onSendClick() + } + ) + } +} + +@Composable +private fun sendButtonColor(sendButtonActive: Boolean) = + if (sendButtonActive) MaterialTheme.colorScheme.inversePrimary + else Color(0xFF868686) + +@Preview +@Composable +private fun MessageInputFieldPreview() { + var value by remember { mutableStateOf("") } + + MessageInputField( + value = value, + onValueChange = { value = it }, + onSendClick = {}, + onEmojiPickerClick = {}, + sendButtonActive = value.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/CustomReplyAnchoredDraggableState.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/CustomReplyAnchoredDraggableState.kt new file mode 100644 index 0000000..68c8f41 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/CustomReplyAnchoredDraggableState.kt @@ -0,0 +1,36 @@ +package me.floow.chats.ui.chat.components.replyable + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animate +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +internal class CustomReplyAnchoredDraggableState( + val initialValue: Float, + val replyOffset: Float +) { + var currentValue by mutableFloatStateOf(initialValue) + var isDragged by mutableStateOf(false) + var isAnimated by mutableStateOf(false) + + suspend fun animateTo( + targetOffset: Float, + animationVelocity: Float, + snapAnimationSpec: AnimationSpec + ) { + animate( + initialValue = currentValue, + targetValue = targetOffset, + initialVelocity = animationVelocity, + animationSpec = snapAnimationSpec + ) { value, _ -> + currentValue = value + } + } + + fun requireOffset(): Float { + return currentValue + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/ReplyMarker.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/ReplyMarker.kt new file mode 100644 index 0000000..c7b3e64 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/ReplyMarker.kt @@ -0,0 +1,32 @@ +package me.floow.chats.ui.chat.components.replyable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import me.floow.uikit.R + +@Composable +internal fun ReplyMarker( + modifier: Modifier = Modifier +) { + Icon( + painter = painterResource(R.drawable.reply_icon), + contentDescription = null, + tint = Color.Black, + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding( + horizontal = 8.dp, + vertical = 4.dp + ) + ) +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/ReplyableChatBubble.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/ReplyableChatBubble.kt new file mode 100644 index 0000000..db3f6bb --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/ReplyableChatBubble.kt @@ -0,0 +1,133 @@ +package me.floow.chats.ui.chat.components.replyable + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import me.floow.chats.ui.chat.components.ChatBubble +import me.floow.chats.uilogic.chat.ChatMessage +import me.floow.chats.uilogic.chat.PrimaryInMessage +import me.floow.chats.uilogic.chat.PrimaryOutMessage +import me.floow.chats.uilogic.chat.ReplyInMessage +import me.floow.chats.uilogic.chat.ReplyOutMessage +import me.floow.uikit.util.ComponentPreviewBox +import java.time.LocalDateTime +import kotlin.math.roundToInt + +@Composable +internal fun ReplyableChatBubble( + chatMessage: ChatMessage, + onClick: (ChatMessage) -> Unit, + onReplyClick: (ChatMessage) -> Unit, + onReply: (ChatMessage) -> Unit, + modifier: Modifier = Modifier +) { + val currentViewConfiguration = LocalViewConfiguration.current + val density = LocalDensity.current + val state = remember { + CustomReplyAnchoredDraggableState( + initialValue = 0f, + replyOffset = with(density) { -40.dp.toPx() } + ) + } + + val coroutineScope = rememberCoroutineScope() + + CompositionLocalProvider(touchSlopConfiguration(currentViewConfiguration)) { + Box( + modifier = modifier + .replyDraggable( + state = state, + coroutineScope = coroutineScope, + onReply = { onReply(chatMessage) } + ) + ) { + Box( + modifier = Modifier + .widthByBubbleType(chatMessage) + ) { + Box(Modifier.align(Alignment.TopEnd)) { + AnimatedVisibility( + visible = state.currentValue != 0f, + enter = scaleIn(tween(300)), + exit = scaleOut(tween(300)) + ) { + ReplyMarker() + } + } + + Box( + modifier = Modifier + .offset { + IntOffset( + x = state + .requireOffset() + .roundToInt(), + y = 0 + ) + }, + ) { + ChatBubble( + chatMessage = chatMessage, + onClick = onClick, + onReplyClick = onReplyClick, + modifier = modifier + ) + } + } + } + } +} + +private fun Modifier.widthByBubbleType(chatMessage: ChatMessage): Modifier { + return when (chatMessage) { + is PrimaryInMessage, is ReplyInMessage -> { + this.width(IntrinsicSize.Max) + } + + is PrimaryOutMessage, is ReplyOutMessage -> { + this.then( + Modifier.fillMaxWidth() + ) + } + } +} + +@Preview +@Composable +private fun ReplyableChatBubblePreview() { + ComponentPreviewBox(Modifier.fillMaxWidth()) { + ReplyableChatBubble( + chatMessage = PrimaryOutMessage( + id = 100L, + messageText = "Some awesome!!! Message. See you later.. probably", + dateTime = LocalDateTime.now(), + ), + onClick = {}, + onReplyClick = {}, + onReply = { + println("REPLY !!!") + println("REPLY !!!") + println("REPLY !!!") + }, + modifier = Modifier + ) + } +} + diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/replyDraggable.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/replyDraggable.kt new file mode 100644 index 0000000..cf065a8 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/replyDraggable.kt @@ -0,0 +1,66 @@ +package me.floow.chats.ui.chat.components.replyable + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +internal fun Modifier.replyDraggable( + state: CustomReplyAnchoredDraggableState, + coroutineScope: CoroutineScope, + onReply: () -> Unit, +): Modifier { + return this.then( + Modifier + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { + state.isDragged = true + }, + onDragEnd = { + state.isDragged = false + + coroutineScope.launch { + if (state.currentValue < state.replyOffset * 0.7 && !state.isAnimated) { + state.isAnimated = true + + state.animateTo( + targetOffset = state.replyOffset, + animationVelocity = 3f, + snapAnimationSpec = tween(50), + ) + + onReply() + + state.animateTo( + targetOffset = 0f, + animationVelocity = 3f, + snapAnimationSpec = tween(150), + ) + } else if (!state.isAnimated) { + state.isAnimated = true + + state.animateTo( + targetOffset = 0f, + animationVelocity = 3f, + snapAnimationSpec = tween(200), + ) + } + + state.isAnimated = false + } + }, + onHorizontalDrag = { change, dragAmount -> + change.consume() + val newValue = + (state.currentValue + dragAmount).coerceIn(state.replyOffset, 0f) + state.currentValue = newValue + } + ) + } + ) +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/touchSlopConfiguration.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/touchSlopConfiguration.kt new file mode 100644 index 0000000..2a852ff --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/components/replyable/touchSlopConfiguration.kt @@ -0,0 +1,12 @@ +package me.floow.chats.ui.chat.components.replyable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.ViewConfiguration + +@Composable +internal fun touchSlopConfiguration(current: ViewConfiguration) = + LocalViewConfiguration provides object : ViewConfiguration by current { + override val touchSlop: Float + get() = current.touchSlop * 2f + } \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/states/ErrorState.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/states/ErrorState.kt new file mode 100644 index 0000000..f0564fa --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/states/ErrorState.kt @@ -0,0 +1,19 @@ +package me.floow.chats.ui.chat.states + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun ErrorState(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text( + text = "error occured" + ) + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/states/HasDataState.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/states/HasDataState.kt new file mode 100644 index 0000000..89fe7e2 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/states/HasDataState.kt @@ -0,0 +1,183 @@ +package me.floow.chats.ui.chat.states + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.floow.chats.ui.chat.components.DateSeparator +import me.floow.chats.ui.chat.components.replyable.ReplyableChatBubble +import me.floow.chats.uilogic.chat.ChatMessage +import me.floow.chats.uilogic.chat.ChatScreenUiState +import me.floow.chats.uilogic.chat.PrimaryOutMessage +import me.floow.chats.uilogic.chat.ReplyOutMessage +import me.flowme.chats.R +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HasDataState( + state: ChatScreenUiState.HasData, + onChatBubbleClick: (ChatMessage) -> Unit, + onReply: (ChatMessage) -> Unit, + onReplyClick: (ChatMessage) -> Unit, + modifier: Modifier = Modifier +) { + val lazyListState = rememberLazyListState() + var dateSeparatorVisible by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + val firstVisibleItemScrollOffset by remember { derivedStateOf { lazyListState.firstVisibleItemScrollOffset } } + var dateSeparatorJob by remember { mutableStateOf(null) } + + LaunchedEffect(firstVisibleItemScrollOffset) { + if (dateSeparatorJob != null) { + dateSeparatorJob?.cancel() + } + + dateSeparatorJob = coroutineScope.launch { + dateSeparatorVisible = true + + delay(300L) + + dateSeparatorVisible = false + } + } + + val dateSeparatorModifier = Modifier + .fillMaxWidth() + .height(32.dp) + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + Modifier + .fillMaxWidth() + .weight(1f) + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + state.messages.forEach { (date, messageList) -> +// stickyHeader { +// Spacer(Modifier.height(8.dp)) +// +// Row( +// horizontalArrangement = Arrangement.Center, +// modifier = dateSeparatorModifier, +// ) { +// if (dateSeparatorVisible) { +// DateSeparator( +// text = getDateSeparatorText(date) +// ) +// } +// } +// +// Spacer11 + // (Modifier.height(8.dp)) +// } + + item { + Spacer(Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.Center, + modifier = dateSeparatorModifier, + ) { + DateSeparator( + text = getDateSeparatorText(date) + ) + } + + Spacer(Modifier.height(8.dp)) + } + + items(messageList) { message -> + val isOut: Boolean = + message is PrimaryOutMessage || message is ReplyOutMessage + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp), + horizontalArrangement = if (isOut) Arrangement.End else Arrangement.Start + ) { + ReplyableChatBubble( + chatMessage = message, + onClick = onChatBubbleClick, + onReplyClick = onReplyClick, + onReply = onReply, + modifier = Modifier + .fillMaxWidth() + ) + } + + Spacer(Modifier.height(8.dp)) + } + } + } + + Column { + Spacer(Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.Center, + modifier = dateSeparatorModifier, + ) { + AnimatedVisibility ( + visible = dateSeparatorVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + DateSeparator( + text = "Today" + ) + } + } + + Spacer(Modifier.height(8.dp)) + } + } + } +} + +@Composable +fun getDateSeparatorText(date: LocalDate): String { + return if (date == LocalDate.now()) { + stringResource(R.string.today) + } else { + date.format(DateTimeFormatter.ofPattern("dd.MM.yy")) + } +} diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/states/LoadingState.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/states/LoadingState.kt new file mode 100644 index 0000000..079827f --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/states/LoadingState.kt @@ -0,0 +1,19 @@ +package me.floow.chats.ui.chat.states + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingState(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(Modifier.size(48.dp)) + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chat/states/NoMessagesState.kt b/feature/chats/src/main/java/me/floow/chats/ui/chat/states/NoMessagesState.kt new file mode 100644 index 0000000..3f39fc9 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chat/states/NoMessagesState.kt @@ -0,0 +1,19 @@ +package me.floow.chats.ui.chat.states + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun NoMessagesState(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text( + text = "no messages im so sorry" + ) + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chats/ChatsScreen.kt b/feature/chats/src/main/java/me/floow/chats/ui/chats/ChatsScreen.kt new file mode 100644 index 0000000..8066ae1 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chats/ChatsScreen.kt @@ -0,0 +1,105 @@ +package me.floow.chats.ui.chats + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.chats.ui.chats.states.HasDataState +import me.floow.chats.uilogic.chats.Chat +import me.floow.chats.uilogic.chats.ChatsScreenUiState +import me.floow.uikit.R +import me.floow.uikit.components.topbar.TitleTopBarWithActionButton + +@Composable +internal fun ChatsScreen( + onSearchClick: () -> Unit, + state: ChatsScreenUiState, + onChatClick: (Chat) -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + TitleTopBarWithActionButton( + titleText = "Чаты", + onActionButtonClick = onSearchClick, + icon = { + Icon( + painterResource(R.drawable.search_icon), + null + ) + } + ) + }, + contentWindowInsets = WindowInsets(0.dp), + modifier = modifier + ) { innerPadding -> + val commonModifier = Modifier + .fillMaxSize() + .padding(innerPadding) + + when (state) { + is ChatsScreenUiState.Loading -> { + Box(commonModifier, Alignment.Center) { + CircularProgressIndicator() + } + } + + is ChatsScreenUiState.Error -> { + Box(commonModifier, Alignment.Center) { + Text(text = "error") + } + } + + is ChatsScreenUiState.NoChats -> { + Box(commonModifier, Alignment.Center) { + Text(text = "no chats sorry") + } + } + + is ChatsScreenUiState.HasData -> { + HasDataState( + state = state, + onChatClick = onChatClick, + modifier = commonModifier + ) + } + } + } +} + +@Preview +@Composable +private fun ChatsScreenPreview_Loading() { + ChatsScreen( + onSearchClick = {}, + state = ChatsScreenUiState.Loading, + onChatClick = {}, + modifier = Modifier.fillMaxSize() + ) +} + +@Preview +@Composable +private fun ChatsScreenPreview_Error() { + ChatsScreen( + onSearchClick = {}, + state = ChatsScreenUiState.Error, + onChatClick = {}, + modifier = Modifier.fillMaxSize() + ) +} + +@Preview +@Composable +private fun ChatsScreenPreview_HasData() { + ChatsScreen( + onSearchClick = {}, + state = ChatsScreenUiState.Loading, + onChatClick = {}, + modifier = Modifier.fillMaxSize() + ) +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chats/components/ChatListItem.kt b/feature/chats/src/main/java/me/floow/chats/ui/chats/components/ChatListItem.kt new file mode 100644 index 0000000..4b547a7 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chats/components/ChatListItem.kt @@ -0,0 +1,191 @@ +package me.floow.chats.ui.chats.components + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.chats.uilogic.chats.Chat +import me.floow.chats.uilogic.chats.LastSentMessageState +import me.floow.domain.values.ProfileName +import me.floow.uikit.theme.LocalTypography +import me.floow.uikit.util.ComponentPreviewBox +import me.flowme.chats.R +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Composable +internal fun ChatListItem( + chat: Chat, + onClick: (Chat) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .clickable { onClick(chat) } + .padding(horizontal = 14.dp, vertical = 8.dp) + .height(62.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarBox( + avatarUrl = chat.avatarUrl, + isOnline = chat.isOnline, + modifier = Modifier + ) + + Spacer(Modifier.width(12.dp)) + + Column( + modifier = Modifier + ) { + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = chat.name.value, + style = LocalTypography.current.titleMedium + ) + + if (chat.chatMuted) { + Spacer(Modifier.width(4.dp)) + + Icon( + painter = painterResource(R.drawable.chat_muted_icon), + contentDescription = null, + tint = Color.Unspecified + ) + } + + Spacer(Modifier.weight(1f)) + + if (chat.lastSentMessageState != null) { + when (chat.lastSentMessageState) { + LastSentMessageState.Sent -> { + // TODO + } + + LastSentMessageState.Read -> { + Icon( + painter = painterResource(R.drawable.chat_read_icon), + contentDescription = null, + tint = Color.Unspecified + ) + } + } + } + + Text( + text = chat.lastMessageDateTime.format(DateTimeFormatter.ofPattern("HH:mm")), + style = LocalTypography.current.labelMedium + ) + } + + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically + ) { + if (chat.attachedMediaUrl != null) { + Box( + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Color.LightGray) + .size(18.dp) + ) + + Spacer(Modifier.width(6.dp)) + } + + Text( + text = chat.lastMessageText, + style = LocalTypography.current.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.secondary + ) + + Spacer(Modifier.weight(1f)) + + if (chat.hasMention) { + Icon( + painter = painterResource(R.drawable.chat_mention_icon), + contentDescription = null, + tint = Color.Unspecified + ) + } + } + } + } +} + +@Composable +private fun AvatarBox(avatarUrl: Uri?, isOnline: Boolean, modifier: Modifier.Companion) { + Box(modifier = modifier) { + Box( + Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color.LightGray) + ) + + if (isOnline) { + Box( + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background) + .padding(3.dp) + .clip(CircleShape) + .size(12.dp) + .background(Color(0xFF5ACD30)) + .align(Alignment.BottomEnd) + ) + } + } +} + +@Preview +@Composable +private fun ChatListItemPreview() { + ComponentPreviewBox(Modifier.fillMaxSize()) { + ChatListItem( + chat = Chat( + id = 2L, + name = ProfileName.create("Demn"), + lastMessageText = "Some message text idk", + isOnline = true, + lastMessageDateTime = LocalDateTime.now().minusHours(3), + avatarUrl = null, + attachedMediaUrl = null, + chatMuted = true, + hasMention = false, + lastSentMessageState = LastSentMessageState.Read + ), + onClick = {}, + modifier = Modifier.fillMaxWidth() + ) + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chats/components/ChatsList.kt b/feature/chats/src/main/java/me/floow/chats/ui/chats/components/ChatsList.kt new file mode 100644 index 0000000..920aaf5 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chats/components/ChatsList.kt @@ -0,0 +1,36 @@ +package me.floow.chats.ui.chats.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import me.floow.chats.uilogic.chats.Chat + +@Composable +internal fun ChatsList( + chats: List, + onChatClick: (Chat) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier, + ) { + item { + Spacer(Modifier.height(8.dp)) + } + + items(chats) { chat -> + ChatListItem( + chat = chat, + onClick = onChatClick, + modifier = Modifier + .fillMaxWidth() + ) + } + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/ui/chats/states/HasDataState.kt b/feature/chats/src/main/java/me/floow/chats/ui/chats/states/HasDataState.kt new file mode 100644 index 0000000..fe6dc4d --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/ui/chats/states/HasDataState.kt @@ -0,0 +1,27 @@ +package me.floow.chats.ui.chats.states + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import me.floow.chats.ui.chats.components.ChatsList +import me.floow.chats.uilogic.chats.Chat +import me.floow.chats.uilogic.chats.ChatsScreenUiState + +@Composable +fun HasDataState( + state: ChatsScreenUiState.HasData, + onChatClick: (Chat) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier) { + ChatsList( + chats = state.chats, + onChatClick = onChatClick, + modifier = Modifier.fillMaxWidth() + ) + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/uilogic/chat/ChatMessage.kt b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/ChatMessage.kt new file mode 100644 index 0000000..bb36edb --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/ChatMessage.kt @@ -0,0 +1,42 @@ +package me.floow.chats.uilogic.chat + +import java.time.LocalDateTime + +sealed interface ChatMessage { + val id: Long + val messageText: String + val dateTime: LocalDateTime +} + +sealed interface ChatReplyMessage : ChatMessage { + val replyMessageId: Long + val replyMessageText: String +} + +data class PrimaryOutMessage( + override val id: Long, + override val messageText: String, + override val dateTime: LocalDateTime +) : ChatMessage + +data class ReplyOutMessage( + override val id: Long, + override val messageText: String, + override val dateTime: LocalDateTime, + override val replyMessageId: Long, + override val replyMessageText: String, +) : ChatReplyMessage + +data class PrimaryInMessage( + override val id: Long, + override val messageText: String, + override val dateTime: LocalDateTime +) : ChatMessage + +data class ReplyInMessage( + override val id: Long, + override val replyMessageId: Long, + override val replyMessageText: String, + override val messageText: String, + override val dateTime: LocalDateTime +) : ChatReplyMessage \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/uilogic/chat/ChatScreenUiState.kt b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/ChatScreenUiState.kt new file mode 100644 index 0000000..9a3ec55 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/ChatScreenUiState.kt @@ -0,0 +1,50 @@ +package me.floow.chats.uilogic.chat + +import android.net.Uri + +data class MessageFieldReply( + val replyId: Long, + val replyAuthorName: String, + val replyMessageText: String, +) + +interface ChatScreenUiState { + val chatInterlocutorId: Long + val chatInterlocutorName: String + val chatInterlocutorAvatarUrl: Uri? + val messageFieldValue: String + val messageFieldReply: MessageFieldReply? + + data class Loading( + override val chatInterlocutorId: Long, + override val chatInterlocutorAvatarUrl: Uri?, + override val messageFieldValue: String, + override val chatInterlocutorName: String, + override val messageFieldReply: MessageFieldReply?, + ) : ChatScreenUiState + + data class Error( + override val chatInterlocutorId: Long, + override val chatInterlocutorAvatarUrl: Uri?, + override val messageFieldValue: String, + override val chatInterlocutorName: String, + override val messageFieldReply: MessageFieldReply?, + ) : ChatScreenUiState + + data class NoMessages( + override val chatInterlocutorId: Long, + override val chatInterlocutorAvatarUrl: Uri?, + override val messageFieldValue: String, + override val chatInterlocutorName: String, + override val messageFieldReply: MessageFieldReply?, + ) : ChatScreenUiState + + data class HasData( + val messages: List, + override val chatInterlocutorId: Long, + override val chatInterlocutorAvatarUrl: Uri?, + override val messageFieldValue: String, + override val chatInterlocutorName: String, + override val messageFieldReply: MessageFieldReply?, + ) : ChatScreenUiState +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/uilogic/chat/ChatScreenViewModel.kt b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/ChatScreenViewModel.kt new file mode 100644 index 0000000..bad2088 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/ChatScreenViewModel.kt @@ -0,0 +1,172 @@ +package me.floow.chats.uilogic.chat + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class ChatScreenVmState( + val messageFieldValue: String = "", + val chatInterlocutorId: Long = -1L, + val chatInterlocutorName: String = "", + val chatInterlocutorAvatarUrl: Uri? = null, + val isLoading: Boolean = false, + val isError: Boolean = false, + val messages: List? = null, + val messageFieldReply: MessageFieldReply? = null +) { + fun toUiState(): ChatScreenUiState { + return when { + isLoading -> { + ChatScreenUiState.Loading( + chatInterlocutorId = chatInterlocutorId, + chatInterlocutorName = chatInterlocutorName, + chatInterlocutorAvatarUrl = chatInterlocutorAvatarUrl, + messageFieldValue = messageFieldValue, + messageFieldReply = messageFieldReply, + ) + } + + isError || messages == null -> { + ChatScreenUiState.Error( + chatInterlocutorId = chatInterlocutorId, + chatInterlocutorName = chatInterlocutorName, + chatInterlocutorAvatarUrl = chatInterlocutorAvatarUrl, + messageFieldValue = messageFieldValue, + messageFieldReply = messageFieldReply, + ) + } + + messages.isEmpty() -> { + ChatScreenUiState.NoMessages( + chatInterlocutorId = chatInterlocutorId, + chatInterlocutorName = chatInterlocutorName, + chatInterlocutorAvatarUrl = chatInterlocutorAvatarUrl, + messageFieldValue = messageFieldValue, + messageFieldReply = messageFieldReply, + ) + } + + else -> { + ChatScreenUiState.HasData( + chatInterlocutorId = chatInterlocutorId, + chatInterlocutorName = chatInterlocutorName, + chatInterlocutorAvatarUrl = chatInterlocutorAvatarUrl, + messageFieldValue = messageFieldValue, + messages = messages, + messageFieldReply = messageFieldReply, + ) + } + } + } +} + +class ChatScreenViewModel() : ViewModel() { + private val _state: MutableStateFlow = MutableStateFlow( + ChatScreenVmState() + ) + + val state: StateFlow = _state + .map(ChatScreenVmState::toUiState) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + ChatScreenUiState.Loading( + chatInterlocutorId = 0L, + chatInterlocutorAvatarUrl = null, + messageFieldValue = "", + chatInterlocutorName = "null", + messageFieldReply = null + ) + ) + + fun setInitialData( + chatInterlocutorId: Long, + chatInterlocutorName: String, + chatInterlocutorAvatarUrl: Uri? + ) { + _state.update { + it.copy( + chatInterlocutorId = chatInterlocutorId, + chatInterlocutorName = chatInterlocutorName, + chatInterlocutorAvatarUrl = chatInterlocutorAvatarUrl + ) + } + } + + fun loadData() { + viewModelScope.launch { + _state.update { + it.copy( + isLoading = true + ) + } + + delay(300L) + + _state.update { + it.copy( + isLoading = false, + messages = generateChatMessages() + .sortedBy { it.dateTime } + .groupBy { it.dateTime.toLocalDate() } + .map { (datetime, messages) -> + DatedChatMessages( + datetime = datetime, + messages = messages + ) + } + ) + } + } + } + + fun closeCurrentReply() { + _state.update { + it.copy( + messageFieldReply = null + ) + } + } + + fun updateMessageInputField(newValue: String) { + _state.update { + it.copy( + messageFieldValue = newValue + ) + } + } + + fun sendMessage() { + TODO("Not yet implemented") + } + + fun addCurrentReply(chatMessage: ChatMessage) { + val replyAuthorName = when (chatMessage) { + is PrimaryInMessage, is ReplyInMessage -> { + _state.value.chatInterlocutorName + } + + else -> { + "You" // TODO + } + } + + _state.update { + it.copy( + messageFieldReply = MessageFieldReply( + replyId = chatMessage.id, + replyAuthorName = replyAuthorName, + replyMessageText = chatMessage.messageText + ) + ) + } + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/uilogic/chat/DatedChatMessages.kt b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/DatedChatMessages.kt new file mode 100644 index 0000000..622a9af --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/DatedChatMessages.kt @@ -0,0 +1,8 @@ +package me.floow.chats.uilogic.chat + +import java.time.LocalDate + +data class DatedChatMessages( + val datetime: LocalDate, + val messages: List +) \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/uilogic/chat/generateChatMessages.kt b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/generateChatMessages.kt new file mode 100644 index 0000000..406a015 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/uilogic/chat/generateChatMessages.kt @@ -0,0 +1,55 @@ +package me.floow.chats.uilogic.chat + +import java.time.LocalDateTime +import kotlin.random.Random + +fun generateChatMessages(): List { + val messageTexts = listOf( + "Hello!", "How are you?", "ну молодец что", "Good morning!", "Good evening!", + "See you later!", "Thank you!", "You're welcome!", "Have a nice day!", + "Если нам снятся люди, предметы. Значит наши мозг знает как все это выглядит, значит он может это нарисовать не только в мозгу, но и на бумаге. Способный, просто не хочешь", + "Наш взгляд - человечен, а вот мир - бесчеловечен. Поэтому нужно нашим человеческим взглядом, обратить бесчеловечный мир в человечный", + "КАПЕЦ ТАМ СЕРГЕЙ ВОЛКОВ\nНИЦШЕ ЧИТАЛ", + "ИМБИЩЕЕЕЕ!!!", + "эээ", + "не", + "потом", + "да", + "ок", + "У меня и в тг видно бывает, но пропадает)) это скорей всего от фронталки съехало, но зачем там такое хз", + "Это в Яндекс музыке видно", + "Утечка слайдов из Google раскрыла некоторые из будущих планов компании в отношении чипсетов Tensor, в частности Tensor G6, и Google явно сосредоточилась на решении проблем с нагревом и эффективностью.", + "Для этого Tensor G6 будет физически меньше G5 (121 мм² против 105 мм²), а также будут удалены различные компоненты. Например, GPU откажется от поддержки трассировки лучей после того, как она появилась на поколение раньше. Среди других примеров - удаление ядер и уменьшение кэша по всему чипу. Разумеется, ничего из этого пока не утверждено.", + "Хз, кто-то из вас вообще предлагал медиа в s3 хранить" + ) + + val random = Random + val chatMessages = mutableListOf() + + for (i in 0..100) { + val id = i.toLong() + val messageText = messageTexts[random.nextInt(messageTexts.size)] + val dateTime = LocalDateTime.now().minusDays(random.nextLong(30)).withNano(0) + + when (i % 4) { + 0 -> { + val replyMessageId = (i - 1).toLong() + val replyMessageText = messageTexts[random.nextInt(messageTexts.size)] + chatMessages.add(ReplyInMessage(id, replyMessageId, replyMessageText, messageText, dateTime)) + } + 1 -> { + val replyMessageId = (i - 1).toLong() + val replyMessageText = messageTexts[random.nextInt(messageTexts.size)] + chatMessages.add(ReplyOutMessage(id, messageText, dateTime, replyMessageId, replyMessageText)) + } + 2 -> { + chatMessages.add(PrimaryInMessage(id, messageText, dateTime)) + } + 3 -> { + chatMessages.add(PrimaryOutMessage(id, messageText, dateTime)) + } + } + } + + return chatMessages +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/uilogic/chats/Chat.kt b/feature/chats/src/main/java/me/floow/chats/uilogic/chats/Chat.kt new file mode 100644 index 0000000..75a01ba --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/uilogic/chats/Chat.kt @@ -0,0 +1,23 @@ +package me.floow.chats.uilogic.chats + +import android.net.Uri +import me.floow.domain.values.ProfileName +import java.time.LocalDateTime + +enum class LastSentMessageState { + Sent, + Read, +} + +data class Chat( + val id: Long, + val name: ProfileName, + val lastMessageText: String, + val lastMessageDateTime: LocalDateTime, + val isOnline: Boolean, + val hasMention: Boolean, + val chatMuted: Boolean, + val avatarUrl: Uri?, + val attachedMediaUrl: Uri?, + val lastSentMessageState: LastSentMessageState? +) \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/uilogic/chats/ChatsScreenUiState.kt b/feature/chats/src/main/java/me/floow/chats/uilogic/chats/ChatsScreenUiState.kt new file mode 100644 index 0000000..a71b218 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/uilogic/chats/ChatsScreenUiState.kt @@ -0,0 +1,13 @@ +package me.floow.chats.uilogic.chats + +interface ChatsScreenUiState { + data object Loading : ChatsScreenUiState + + data object NoChats : ChatsScreenUiState + + data object Error : ChatsScreenUiState + + data class HasData( + val chats: List + ) : ChatsScreenUiState +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/uilogic/chats/ChatsScreenViewModel.kt b/feature/chats/src/main/java/me/floow/chats/uilogic/chats/ChatsScreenViewModel.kt new file mode 100644 index 0000000..49acb69 --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/uilogic/chats/ChatsScreenViewModel.kt @@ -0,0 +1,58 @@ +package me.floow.chats.uilogic.chats + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private data class ChatsScreenVmState( + val isLoading: Boolean = false, + val isError: Boolean = false, + val chats: List? = null +) { + fun toUiState(): ChatsScreenUiState { + if (isLoading) return ChatsScreenUiState.Loading + + return if (chats != null && !isError) { + ChatsScreenUiState.HasData(chats) + } else { + ChatsScreenUiState.Error + } + } +} + +class ChatsScreenViewModel : ViewModel() { + private val _state = MutableStateFlow(ChatsScreenVmState()) + + val state: StateFlow = _state + .map(ChatsScreenVmState::toUiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, ChatsScreenUiState.Loading) + + fun load() { + viewModelScope.launch { + _state.update { + it.copy( + isLoading = true, + isError = false, + ) + } + + delay(600L) + + + _state.update { + it.copy( + isLoading = false, + isError = false, + chats = generateRandomChats(50) + ) + } + } + } +} \ No newline at end of file diff --git a/feature/chats/src/main/java/me/floow/chats/uilogic/chats/generateRandomChats.kt b/feature/chats/src/main/java/me/floow/chats/uilogic/chats/generateRandomChats.kt new file mode 100644 index 0000000..d3ffc6b --- /dev/null +++ b/feature/chats/src/main/java/me/floow/chats/uilogic/chats/generateRandomChats.kt @@ -0,0 +1,74 @@ +package me.floow.chats.uilogic.chats + +import android.net.Uri +import me.floow.domain.values.ProfileName +import java.time.LocalDateTime +import kotlin.random.Random + +fun generateRandomChats(n: Int): List { + val firstNames = listOf( + "John", + "Jane", + "Alice", + "Bob", + "Charlie", + "Demn", + "Finsi", + "Mixno", + "Max", + "Vlad", + "Andrew", + "Саша", + "котик" + ) + val messages = listOf( + "Hello!", + "How are you?", + "See you later!", + "Good morning!", + "Have a nice day!", + "Смотри какие пельмени!", + "ИМБИЩЕЕЕЕ!!!", + "На видео Threads, написанный на Compose, а не приложение фейсбука", + "Try out the fastest crypto-to-crypto \uD83D\uDD01 Swaps in Telegram and share the \$10,000 prize fund in our contest for all users! Learn more ›" + ) + + val chats = mutableListOf() + + for (i in 1..n) { + val profileName = ProfileName.create( + value = firstNames.random(), + ) + val lastMessageText = messages.random() + val lastMessageDateTime = LocalDateTime.now() + .minusDays(Random.nextLong(0, 30)) + .minusHours(Random.nextLong(0, 24)) + .minusMinutes(Random.nextLong(0, 60)) + .minusSeconds(Random.nextLong(0, 60)) + val isOnline = Random.nextBoolean() + val hasMention = Random.nextBoolean() + val chatMuted = Random.nextBoolean() + val avatarUrl = if (Random.nextBoolean()) Uri.parse("https://example.com/avatar") else null + val attachedMediaUrl = + if (Random.nextBoolean()) Uri.parse("https://example.com/media") else null + val lastSentMessageState = + if (Random.nextBoolean()) LastSentMessageState.entries.toTypedArray().random() else null + + val chat = Chat( + id = Random.nextLong(1L, 100L), + name = profileName, + lastMessageText = lastMessageText, + lastMessageDateTime = lastMessageDateTime, + isOnline = isOnline, + hasMention = hasMention, + chatMuted = chatMuted, + avatarUrl = avatarUrl, + attachedMediaUrl = attachedMediaUrl, + lastSentMessageState = lastSentMessageState + ) + + chats.add(chat) + } + + return chats +} \ No newline at end of file diff --git a/feature/chats/src/main/res/drawable/chat_mention_icon.xml b/feature/chats/src/main/res/drawable/chat_mention_icon.xml new file mode 100644 index 0000000..21e0fbd --- /dev/null +++ b/feature/chats/src/main/res/drawable/chat_mention_icon.xml @@ -0,0 +1,15 @@ + + + + diff --git a/feature/chats/src/main/res/drawable/chat_muted_icon.xml b/feature/chats/src/main/res/drawable/chat_muted_icon.xml new file mode 100644 index 0000000..b6f5443 --- /dev/null +++ b/feature/chats/src/main/res/drawable/chat_muted_icon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/feature/chats/src/main/res/drawable/chat_read_icon.xml b/feature/chats/src/main/res/drawable/chat_read_icon.xml new file mode 100644 index 0000000..866a71b --- /dev/null +++ b/feature/chats/src/main/res/drawable/chat_read_icon.xml @@ -0,0 +1,13 @@ + + + diff --git a/feature/chats/src/main/res/drawable/current_reply_close_icon.xml b/feature/chats/src/main/res/drawable/current_reply_close_icon.xml new file mode 100644 index 0000000..5aa9399 --- /dev/null +++ b/feature/chats/src/main/res/drawable/current_reply_close_icon.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/feature/chats/src/main/res/values-ru/strings.xml b/feature/chats/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..c427453 --- /dev/null +++ b/feature/chats/src/main/res/values-ru/strings.xml @@ -0,0 +1,6 @@ + + + В ответ + Сообщение + Сегодня + \ No newline at end of file diff --git a/feature/chats/src/main/res/values/strings.xml b/feature/chats/src/main/res/values/strings.xml new file mode 100644 index 0000000..488971f --- /dev/null +++ b/feature/chats/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + In reply to + Message + Today + \ No newline at end of file diff --git a/feature/usersearch/.gitignore b/feature/chatssearch/.gitignore similarity index 100% rename from feature/usersearch/.gitignore rename to feature/chatssearch/.gitignore diff --git a/feature/usersearch/build.gradle.kts b/feature/chatssearch/build.gradle.kts similarity index 96% rename from feature/usersearch/build.gradle.kts rename to feature/chatssearch/build.gradle.kts index 7903a82..e56757f 100644 --- a/feature/usersearch/build.gradle.kts +++ b/feature/chatssearch/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "me.floow.usersearch" + namespace = "me.floow.chatssearch" compileSdk = 34 defaultConfig { diff --git a/feature/usersearch/consumer-rules.pro b/feature/chatssearch/consumer-rules.pro similarity index 100% rename from feature/usersearch/consumer-rules.pro rename to feature/chatssearch/consumer-rules.pro diff --git a/feature/usersearch/proguard-rules.pro b/feature/chatssearch/proguard-rules.pro similarity index 100% rename from feature/usersearch/proguard-rules.pro rename to feature/chatssearch/proguard-rules.pro diff --git a/feature/usersearch/src/androidTest/java/com/demn/usersearch/ExampleInstrumentedTest.kt b/feature/chatssearch/src/androidTest/java/me/floow/usersearch/ExampleInstrumentedTest.kt similarity index 95% rename from feature/usersearch/src/androidTest/java/com/demn/usersearch/ExampleInstrumentedTest.kt rename to feature/chatssearch/src/androidTest/java/me/floow/usersearch/ExampleInstrumentedTest.kt index 7df2a9d..059aacb 100644 --- a/feature/usersearch/src/androidTest/java/com/demn/usersearch/ExampleInstrumentedTest.kt +++ b/feature/chatssearch/src/androidTest/java/me/floow/usersearch/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.demn.usersearch +package me.floow.usersearch import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 diff --git a/feature/usersearch/src/main/AndroidManifest.xml b/feature/chatssearch/src/main/AndroidManifest.xml similarity index 100% rename from feature/usersearch/src/main/AndroidManifest.xml rename to feature/chatssearch/src/main/AndroidManifest.xml diff --git a/feature/usersearch/src/main/java/com/demn/usersearch/di/usersearchModule.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/di/usersearchModule.kt similarity index 61% rename from feature/usersearch/src/main/java/com/demn/usersearch/di/usersearchModule.kt rename to feature/chatssearch/src/main/java/me/floow/chatssearch/di/usersearchModule.kt index 0978c3f..1166681 100644 --- a/feature/usersearch/src/main/java/com/demn/usersearch/di/usersearchModule.kt +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/di/usersearchModule.kt @@ -1,8 +1,8 @@ -package com.demn.usersearch.di +package me.floow.chatssearch.di import org.koin.dsl.module import org.koin.core.module.dsl.viewModelOf -import com.demn.usersearch.uilogic.SearchUsersScreenViewModel +import me.floow.chatssearch.uilogic.SearchUsersScreenViewModel val usersearchModule = module { viewModelOf(::SearchUsersScreenViewModel) diff --git a/feature/usersearch/src/main/java/com/demn/usersearch/ui/SearchUsersRoute.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/SearchUsersRoute.kt similarity index 57% rename from feature/usersearch/src/main/java/com/demn/usersearch/ui/SearchUsersRoute.kt rename to feature/chatssearch/src/main/java/me/floow/chatssearch/ui/SearchUsersRoute.kt index 6dc41b9..a8da2c4 100644 --- a/feature/usersearch/src/main/java/com/demn/usersearch/ui/SearchUsersRoute.kt +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/SearchUsersRoute.kt @@ -1,20 +1,28 @@ -package com.demn.usersearch.ui +package me.floow.chatssearch.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import com.demn.usersearch.uilogic.SearchUsersScreenViewModel +import me.floow.chatssearch.uilogic.SearchUsersScreenViewModel @Composable fun SearchUsersRoute( + onBackClick: () -> Unit, onUserPick: () -> Unit, vm: SearchUsersScreenViewModel, modifier: Modifier = Modifier ) { val state by vm.state.collectAsState() + LaunchedEffect(Unit) { + vm.loadInitialData() + } + SearchUsersScreen( + onBackClick = onBackClick, + onSearchFieldUpdate = vm::updateSearchField, state = state, modifier = modifier ) diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/SearchUsersScreen.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/SearchUsersScreen.kt new file mode 100644 index 0000000..11fdb52 --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/SearchUsersScreen.kt @@ -0,0 +1,111 @@ +package me.floow.chatssearch.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.chatssearch.ui.components.SearchUsersScreenTopBar +import me.floow.chatssearch.ui.states.NoSearchInputState +import me.floow.chatssearch.ui.states.LoadingState +import me.floow.chatssearch.ui.states.SearchResultsState +import me.floow.chatssearch.uilogic.MessageResult +import me.floow.chatssearch.uilogic.SearchUsersScreenUiState +import me.floow.chatssearch.uilogic.UserSearchResult +import me.floow.domain.values.ProfileName +import me.floow.domain.values.ProfileUsername + +@Composable +internal fun SearchUsersScreen( + onBackClick: () -> Unit, + onSearchFieldUpdate: (String) -> Unit, + state: SearchUsersScreenUiState, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + SearchUsersScreenTopBar( + onBackClick = onBackClick, + searchFieldValue = state.searchField, + onSearchFieldUpdate = onSearchFieldUpdate, + ) + }, + contentWindowInsets = WindowInsets(0.dp), + modifier = modifier, + ) { innerPadding -> + val contentModifier = Modifier + .fillMaxSize() + .padding(innerPadding) + + when (state) { + is SearchUsersScreenUiState.Loading -> { + LoadingState( + modifier = contentModifier + ) + } + + is SearchUsersScreenUiState.NoSearchInput -> { + NoSearchInputState( + state = state, + modifier = contentModifier + ) + } + + is SearchUsersScreenUiState.HasResults -> { + SearchResultsState( + state = state, + modifier = contentModifier + ) + } + } + } +} + +@Preview +@Composable +private fun SearchUsersScreenPreview() { + SearchUsersScreen( + onBackClick = { }, + onSearchFieldUpdate = { }, + state = SearchUsersScreenUiState.HasResults( + searchField = "test", + userResults = listOf( + UserSearchResult( + name = ProfileName.create("Demn"), + username = ProfileUsername.create("demndevel"), + isOnline = false + ) + ), + messageResults = listOf( + MessageResult( + name = ProfileName.create("Finsi"), + messageText = "Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. " + ), + MessageResult( + name = ProfileName.create("Demn"), + messageText = "Some example text" + ), + MessageResult( + name = ProfileName.create("Finsi"), + messageText = "Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. " + ), + MessageResult( + name = ProfileName.create("Demn"), + messageText = "Some example text" + ), + MessageResult( + name = ProfileName.create("Finsi"), + messageText = "Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. " + ), + MessageResult( + name = ProfileName.create("Demn"), + messageText = "Some example text" + ), + ) + ), + modifier = Modifier.fillMaxSize() + ) +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/MessageResult.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/MessageResult.kt new file mode 100644 index 0000000..4ab9210 --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/MessageResult.kt @@ -0,0 +1,87 @@ +package me.floow.chatssearch.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.chatssearch.uilogic.MessageResult +import me.floow.domain.values.ProfileName +import me.floow.uikit.theme.LocalTypography +import me.floow.uikit.util.ComponentPreviewBox + +@Composable +fun MessageResult( + result: MessageResult, + onClick: (MessageResult) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .clickable { onClick(result) } + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color.LightGray) + ) + + Spacer(Modifier.width(9.dp)) + + Column( + modifier = Modifier, + verticalArrangement = Arrangement.Center + ) { + Text( + text = result.name.value, + style = LocalTypography.current.titleMedium, + ) + + Spacer(Modifier.height(2.dp)) + + Text( + text = result.messageText, + style = LocalTypography.current.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview +@Composable +fun MessageResultPreview() { + ComponentPreviewBox(Modifier.fillMaxSize()) { + MessageResult( + result = MessageResult( + name = ProfileName.create("Богдан"), + messageText = "Привет, дружище. Как у тебя дела? У меня всё отлично. Сегодня сделал огромный пласт задач по дизайну, сейчас тебе скину пару примеров." + ), + onClick = {} + ) + } +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/MessageResultsList.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/MessageResultsList.kt new file mode 100644 index 0000000..3a70a31 --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/MessageResultsList.kt @@ -0,0 +1,51 @@ +package me.floow.chatssearch.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import me.floow.chatssearch.uilogic.MessageResult +import me.floow.uikit.theme.LocalTypography +import me.floow.chatssearch.R + +fun LazyListScope.messageResultsList( + results: List, + onClick: (MessageResult) -> Unit, +) { + item { + Spacer(Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp), + ) { + Text( + text = stringResource(R.string.message_search), + style = LocalTypography.current.bodyMedium, + fontWeight = FontWeight.Bold + ) + } + + Spacer(Modifier.height(12.dp)) + } + + items(results) { result -> + MessageResult( + result = result, + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + ) + } +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/RecentUsersList.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/RecentUsersList.kt new file mode 100644 index 0000000..5b77e55 --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/RecentUsersList.kt @@ -0,0 +1,74 @@ +package me.floow.chatssearch.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import me.floow.chatssearch.uilogic.RecentUser +import me.floow.uikit.theme.LocalTypography + +@Composable +fun RecentUsersList( + recentUsers: List, + modifier: Modifier = Modifier +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Spacer(Modifier.width(2.dp)) + } + + items(recentUsers) { recentUser -> + RecentUsersListItem( + recentUser, + Modifier.fillMaxWidth() + ) + } + + item { + Spacer(Modifier.width(2.dp)) + } + } +} + +@Composable +fun RecentUsersListItem( + recentUser: RecentUser, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(Color.LightGray) + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = recentUser.name.value, + style = LocalTypography.current.bodyMedium + ) + } +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/SearchUsersScreenTopBar.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/SearchUsersScreenTopBar.kt new file mode 100644 index 0000000..b09ee39 --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/SearchUsersScreenTopBar.kt @@ -0,0 +1,23 @@ +package me.floow.chatssearch.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import me.floow.uikit.components.topbar.SearchTopBar +import me.floow.chatssearch.R + +@Composable +fun SearchUsersScreenTopBar( + onBackClick: () -> Unit, + searchFieldValue: String, + onSearchFieldUpdate: (String) -> Unit, + modifier: Modifier = Modifier +) { + SearchTopBar( + onBackClick = onBackClick, + searchFieldValue = searchFieldValue, + placeholder = stringResource(R.string.search_field_placeholder), + onSearchFieldUpdate = onSearchFieldUpdate, + modifier = modifier + ) +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/UserGlobalSearchResult.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/UserGlobalSearchResult.kt new file mode 100644 index 0000000..7fe2326 --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/UserGlobalSearchResult.kt @@ -0,0 +1,91 @@ +package me.floow.chatssearch.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.chatssearch.uilogic.UserSearchResult +import me.floow.domain.values.ProfileName +import me.floow.domain.values.ProfileUsername +import me.floow.uikit.R +import me.floow.uikit.theme.LocalTypography +import me.floow.uikit.util.ComponentPreviewBox + +@Composable +fun UserGlobalSearchResult( + userSearchResult: UserSearchResult, + onClick: (UserSearchResult) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .clickable { onClick(userSearchResult) } + .padding(horizontal = 20.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier + .size(50.dp) + .clip(CircleShape) + .background(Color.LightGray) + ) + + Spacer(Modifier.width(9.dp)) + + Column( + modifier = Modifier, + verticalArrangement = Arrangement.Center + ) { + Text( + text = userSearchResult.name.value, + style = LocalTypography.current.titleMedium, + ) + + Spacer(Modifier.height(2.dp)) + + Text( + text = stringResource(if (userSearchResult.isOnline) R.string.online else R.string.offline), + style = LocalTypography.current.bodyMedium, + color = MaterialTheme.colorScheme.secondary + ) + } + } +} + +@Preview +@Composable +private fun UserGlobalSearchResultPreview() { + ComponentPreviewBox(Modifier.fillMaxWidth()) { + UserGlobalSearchResult( + userSearchResult = UserSearchResult( + ProfileName.create("Demn"), + ProfileUsername.create( + "demndevel" + ), + isOnline = false + ), + onClick = {}, + modifier = Modifier.fillMaxWidth() + ) + } +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/globalSearchUsersList.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/globalSearchUsersList.kt new file mode 100644 index 0000000..209f5ca --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/components/globalSearchUsersList.kt @@ -0,0 +1,66 @@ +package me.floow.chatssearch.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import me.floow.chatssearch.uilogic.UserSearchResult +import me.floow.uikit.theme.LocalTypography +import me.floow.chatssearch.R + +fun LazyListScope.globalSearchUsersList( + isExpanded: Boolean, + onExpandedToggle: () -> Unit, + results: List, + onClick: (UserSearchResult) -> Unit, +) { + item { + Spacer(Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp), + ) { + Text( + text = stringResource(R.string.global_search), + style = LocalTypography.current.bodyMedium, + fontWeight = FontWeight.Bold + ) + + if (results.size > 3) { + Text( + text = stringResource(if (isExpanded) R.string.show_less else R.string.show_more), + style = LocalTypography.current.bodyMedium, + modifier = Modifier + .clickable { + onExpandedToggle() + } + ) + } + } + + Spacer(Modifier.height(12.dp)) + } + + val croppedResults = if (!isExpanded) results.take(3) else results + + items(croppedResults) { result -> + UserGlobalSearchResult( + result, + onClick = onClick, + modifier = Modifier.fillMaxWidth() + ) + } +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/states/LoadingState.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/states/LoadingState.kt new file mode 100644 index 0000000..52e9bf3 --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/states/LoadingState.kt @@ -0,0 +1,21 @@ +package me.floow.chatssearch.ui.states + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun LoadingState( + modifier: Modifier = Modifier +) { + Box( + modifier, + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(Modifier.size(64.dp)) + } +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/states/NoSearchInputState.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/states/NoSearchInputState.kt new file mode 100644 index 0000000..895dfa4 --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/states/NoSearchInputState.kt @@ -0,0 +1,37 @@ +package me.floow.chatssearch.ui.states + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import me.floow.chatssearch.ui.components.RecentUsersList +import me.floow.chatssearch.uilogic.SearchUsersScreenUiState +import me.floow.uikit.theme.LocalTypography + +@Composable +fun NoSearchInputState( + state: SearchUsersScreenUiState.NoSearchInput, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + ) { + if (state.recentUsers.isNotEmpty()) { + Text( + text = "Recent searches", + style = LocalTypography.current.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 12.dp) + ) + + RecentUsersList( + recentUsers = state.recentUsers, + modifier = Modifier + ) + } + } +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/states/SearchResultsState.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/states/SearchResultsState.kt new file mode 100644 index 0000000..1d0dcef --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/ui/states/SearchResultsState.kt @@ -0,0 +1,106 @@ +package me.floow.chatssearch.ui.states + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.floow.chatssearch.ui.components.globalSearchUsersList +import me.floow.chatssearch.ui.components.messageResultsList +import me.floow.chatssearch.uilogic.MessageResult +import me.floow.chatssearch.uilogic.SearchUsersScreenUiState +import me.floow.chatssearch.uilogic.UserSearchResult +import me.floow.domain.values.ProfileName +import me.floow.domain.values.ProfileUsername + +@Composable +fun SearchResultsState( + state: SearchUsersScreenUiState.HasResults, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var isGlobalUsersSearchExpanded by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + if (state.userResults.isNotEmpty()) { + globalSearchUsersList( + isExpanded = isGlobalUsersSearchExpanded, + onExpandedToggle = { + isGlobalUsersSearchExpanded = !isGlobalUsersSearchExpanded + }, + results = state.userResults, + onClick = { result -> + Toast.makeText(context, result.toString(), Toast.LENGTH_SHORT).show() + } + ) + + item { + Spacer(Modifier.height(12.dp)) + + HorizontalDivider() + } + } + + messageResultsList( + results = state.messageResults, + onClick = { result -> + Toast.makeText(context, result.toString(), Toast.LENGTH_SHORT).show() + } + ) + } + } +} + +@Preview +@Composable +private fun SearchResultsStatePreview() { + SearchResultsState( + state = SearchUsersScreenUiState.HasResults( + searchField = "test", + userResults = listOf( + UserSearchResult( + name = ProfileName.create("Demn"), + username = ProfileUsername.create("demndevel"), + isOnline = false + ) + ), + messageResults = listOf( + MessageResult( + name = ProfileName.create("Finsi"), + messageText = "Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. " + ), + MessageResult( + name = ProfileName.create("Demn"), + messageText = "Some example text" + ), + MessageResult( + name = ProfileName.create("Finsi"), + messageText = "Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. " + ), + MessageResult( + name = ProfileName.create("Demn"), + messageText = "Some example text" + ), + MessageResult( + name = ProfileName.create("Finsi"), + messageText = "Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. Some example text. " + ) + ) + ), + Modifier.fillMaxSize() + ) +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/uilogic/SearchUsersScreenUiState.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/uilogic/SearchUsersScreenUiState.kt new file mode 100644 index 0000000..8bd8d2a --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/uilogic/SearchUsersScreenUiState.kt @@ -0,0 +1,36 @@ +package me.floow.chatssearch.uilogic + +import me.floow.domain.values.ProfileName +import me.floow.domain.values.ProfileUsername + +data class UserSearchResult( + val name: ProfileName, + val username: ProfileUsername, + val isOnline: Boolean +) + +data class RecentUser( + val name: ProfileName, +) + +data class MessageResult( + val name: ProfileName, + val messageText: String, +) + +interface SearchUsersScreenUiState { + val searchField: String + + data class Loading(override val searchField: String) : SearchUsersScreenUiState + + data class NoSearchInput( + override val searchField: String, + val recentUsers: List + ) : SearchUsersScreenUiState + + data class HasResults( + override val searchField: String, + val userResults: List, + val messageResults: List + ) : SearchUsersScreenUiState +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/java/me/floow/chatssearch/uilogic/SearchUsersScreenViewModel.kt b/feature/chatssearch/src/main/java/me/floow/chatssearch/uilogic/SearchUsersScreenViewModel.kt new file mode 100644 index 0000000..511d4a3 --- /dev/null +++ b/feature/chatssearch/src/main/java/me/floow/chatssearch/uilogic/SearchUsersScreenViewModel.kt @@ -0,0 +1,208 @@ +package me.floow.chatssearch.uilogic + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import me.floow.domain.values.ProfileName +import me.floow.domain.values.ProfileUsername +import kotlin.random.Random + +private data class SearchUsersScreenVmState( + val searchField: String = "", + val isLoading: Boolean = false, + val globalSearchResults: List? = null, + val messageSearchResults: List? = null, + val recentUsers: List? = null, +) { + fun toUiState(): SearchUsersScreenUiState { + if (isLoading) return SearchUsersScreenUiState.Loading(searchField) + + if ((searchField.isBlank() || globalSearchResults == null) && recentUsers != null) return SearchUsersScreenUiState.NoSearchInput( + searchField = searchField, + recentUsers = recentUsers + ) + + if (globalSearchResults != null && messageSearchResults != null) { + return SearchUsersScreenUiState.HasResults( + searchField, + globalSearchResults, + messageSearchResults + ) + } + + return SearchUsersScreenUiState.Loading(searchField) + } +} + +class SearchUsersScreenViewModel : ViewModel() { + private val _state = MutableStateFlow(SearchUsersScreenVmState()) + + val state: StateFlow = _state + .map(SearchUsersScreenVmState::toUiState) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + SearchUsersScreenUiState.NoSearchInput("", emptyList()) + ) + + fun loadInitialData() { + viewModelScope.launch { + _state.update { + it.copy( + isLoading = true + ) + } + + delay(100L) + + _state.update { + it.copy( + recentUsers = generateRandomRecentUsers(), + isLoading = false + ) + } + } + } + + fun updateSearchField(newValue: String) { + _state.update { + it.copy( + searchField = newValue + ) + } + + viewModelScope.launch { + _state.update { + it.copy( + isLoading = true + ) + } + + delay(300L) + + _state.update { + it.copy( + globalSearchResults = generateRandomUserSearchResults(), + messageSearchResults = generateRandomMessageSearchResults(), + isLoading = false + ) + } + } + } + + private fun generateRandomMessageSearchResults(): List { + val names = listOf( + "Alice", + "Bob", + "Charlie", + "David", + "Eve", + "Frank", + "Grace", + "Heidi", + "Ivan", + "Judy" + ) + val messages = listOf( + "Привет, как дела?", + "Что нового?", + "Можем встретиться завтра?", + "Извините, я опаздываю!", + "Видел новый фильм?", + "Давай перекусим вместе.", + "Мне нужна твоя помощь.", + "Как прошли выходные?", + "Есть планы на вечер?", + "Нашел отличный новый ресторан.", + "Можешь прислать отчет?", + "Следующую неделю уезжаю в отпуск.", + "Хочешь присоединиться к нам на ужин?", + "Застрял в пробке, скоро буду.", + "Пробовал новую кофейню?", + "Нужно перенести нашу встречу.", + "Получил мое письмо?", + "Давай встретимся как-нибудь.", + "С нетерпением жду поездки!", + "Есть рекомендации по книгам?" + ) + + return List(Random.nextInt(200)) { + val randomName = names[Random.nextInt(names.size)] + val randomMessage = messages[Random.nextInt(messages.size)] + + MessageResult( + name = ProfileName.create(randomName), + messageText = randomMessage + ) + } + } + + private fun generateRandomUserSearchResults(): List { + val names = listOf( + "Alice", + "Bob", + "Charlie", + "David", + "Eve", + "Frank", + "Grace", + "Heidi", + "Ivan", + "Judy" + ) + val usernames = listOf( + "user1", + "user2", + "user3", + "user4", + "user5", + "user6", + "user7", + "user8", + "user9", + "user10" + ) + + return List(Random.nextInt(10)) { + val randomName = names[Random.nextInt(names.size)] + val randomUsername = usernames[Random.nextInt(usernames.size)] + val isOnline = Random.nextBoolean() + + UserSearchResult( + name = ProfileName.create(randomName), + username = ProfileUsername.create(randomUsername), + isOnline = isOnline + ) + } + } + + private fun generateRandomRecentUsers(): List { + val names = listOf( + "Alice", + "Bob", + "Charlie", + "David", + "Eve", + "Frank", + "Grace", + "Heidi", + "Ivan", + "Judy" + ) + + return List(Random.nextInt(15)) { + val randomName = names[Random.nextInt(names.size)] + + RecentUser( + name = ProfileName.create(randomName), + ) + } + } +} \ No newline at end of file diff --git a/feature/chatssearch/src/main/res/values-ru/strings.xml b/feature/chatssearch/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..8b0d487 --- /dev/null +++ b/feature/chatssearch/src/main/res/values-ru/strings.xml @@ -0,0 +1,8 @@ + + + Поиск + Глобальный поиск + Показать больше + Показать меньше + Сообщения + \ No newline at end of file diff --git a/feature/chatssearch/src/main/res/values/strings.xml b/feature/chatssearch/src/main/res/values/strings.xml new file mode 100644 index 0000000..c94ff75 --- /dev/null +++ b/feature/chatssearch/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Search + Global search + Show more + Show less + Message search + \ No newline at end of file diff --git a/feature/usersearch/src/test/java/com/demn/usersearch/ExampleUnitTest.kt b/feature/chatssearch/src/test/java/me/floow/usersearch/ExampleUnitTest.kt similarity index 91% rename from feature/usersearch/src/test/java/com/demn/usersearch/ExampleUnitTest.kt rename to feature/chatssearch/src/test/java/me/floow/usersearch/ExampleUnitTest.kt index f368351..0eac092 100644 --- a/feature/usersearch/src/test/java/com/demn/usersearch/ExampleUnitTest.kt +++ b/feature/chatssearch/src/test/java/me/floow/usersearch/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.demn.usersearch +package me.floow.usersearch import org.junit.Test diff --git a/feature/feed/src/main/java/me/floow/feed/ui/FeedScreen.kt b/feature/feed/src/main/java/me/floow/feed/ui/FeedScreen.kt index 9f116c2..ffeb196 100644 --- a/feature/feed/src/main/java/me/floow/feed/ui/FeedScreen.kt +++ b/feature/feed/src/main/java/me/floow/feed/ui/FeedScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import me.floow.uikit.R +import me.floow.uikit.components.misc.BlankContentBox import me.floow.uikit.components.topbar.TitleTopBarWithActionButton @Composable @@ -39,16 +40,7 @@ internal fun FeedScreen( .padding(8.dp) .padding(innerPadding) ) { - Text( - "Flow!", - style = MaterialTheme.typography.titleLarge, - ) - - Spacer(Modifier.height(4.dp)) - - Text( - text = "Feed" - ) + BlankContentBox(Modifier.fillMaxSize()) } } } @@ -60,4 +52,4 @@ private fun FeedScreenPreview() { onPostCreateClick = {}, modifier = Modifier.fillMaxSize() ) -} \ No newline at end of file +} diff --git a/feature/login/src/main/java/me/floow/login/ui/createprofile/CreateProfileRoute.kt b/feature/login/src/main/java/me/floow/login/ui/createprofile/CreateProfileRoute.kt index 79cfd32..7035a97 100644 --- a/feature/login/src/main/java/me/floow/login/ui/createprofile/CreateProfileRoute.kt +++ b/feature/login/src/main/java/me/floow/login/ui/createprofile/CreateProfileRoute.kt @@ -39,7 +39,9 @@ fun CreateProfileRoute( CreateProfileScreen( state = state, - onAvatarPickerClick = { TODO() }, + onAvatarPickerClick = { + Toast.makeText(context, "Фича ещё разрабатывается…", Toast.LENGTH_SHORT).show() + }, onNameChange = vm::updateName, onUsernameChange = vm::updateUsername, onBiographyChange = vm::updateBio, diff --git a/feature/login/src/main/java/me/floow/login/ui/createprofile/components/EditState.kt b/feature/login/src/main/java/me/floow/login/ui/createprofile/components/EditState.kt index 31dd5b8..7a6dc52 100644 --- a/feature/login/src/main/java/me/floow/login/ui/createprofile/components/EditState.kt +++ b/feature/login/src/main/java/me/floow/login/ui/createprofile/components/EditState.kt @@ -1,122 +1,93 @@ package me.floow.login.ui.createprofile.components -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import me.floow.login.uilogic.CreateProfileState import me.floow.uikit.components.input.TextFieldWithAdditionalText -import me.floow.uikit.theme.ElevanagonShape +import me.floow.uikit.components.pickers.AvatarAndBackgroundPicker import me.floow.uikit.theme.LocalTypography import me.floow.uikit.util.state.ValidatedField import me.flowme.login.R @Composable fun EditState( - state: CreateProfileState.Edit, - onAvatarPickerClick: () -> Unit = {}, - onNameChange: (String) -> Unit = {}, - onUsernameChange: (String) -> Unit = {}, - onBiographyChange: (String) -> Unit = {}, - modifier: Modifier = Modifier + state: CreateProfileState.Edit, + onAvatarPickerClick: () -> Unit = {}, + onNameChange: (String) -> Unit = {}, + onUsernameChange: (String) -> Unit = {}, + onBiographyChange: (String) -> Unit = {}, + modifier: Modifier = Modifier ) { - Column( - modifier = modifier - .padding(24.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .height(160.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .clickable { - onAvatarPickerClick() - }, - ) { - Image( - painter = painterResource(me.floow.uikit.R.drawable.cute_girl), - contentDescription = null, - modifier = Modifier - .size(120.dp) - .clip(ElevanagonShape) - ) + Column( + modifier = modifier + .padding(14.dp) + ) { + AvatarAndBackgroundPicker( + avatarImagePainter = null, + backgroundImagePainter = null, + onAvatarPickerClick = onAvatarPickerClick, + onBackgroundPickerClick = {}, + modifier = Modifier.fillMaxWidth() + ) - Icon( - painter = painterResource(me.floow.uikit.R.drawable.photo_icon), - contentDescription = null, - tint = Color.White, - ) - } + Spacer(Modifier.height(24.dp)) - Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.information).uppercase(), + style = LocalTypography.current.labelMedium, + color = MaterialTheme.colorScheme.secondary, + ) - Text( - text = stringResource(R.string.information).uppercase(), - style = LocalTypography.current.labelMedium, - color = MaterialTheme.colorScheme.secondary, - ) + Spacer(Modifier.height(8.dp)) - Spacer(Modifier.height(8.dp)) + TextFieldWithAdditionalText( + additionalText = "", + title = stringResource(R.string.name), + value = state.name.value, + isError = state.name is ValidatedField.Invalid, + placeholder = stringResource(R.string.your_name), + onValueChange = onNameChange, + modifier = Modifier + .fillMaxWidth() + ) - TextFieldWithAdditionalText( - additionalText = "", - title = stringResource(R.string.name), - value = state.name.value, - isError = state.name is ValidatedField.Invalid, - placeholder = stringResource(R.string.your_name), - onValueChange = onNameChange, - modifier = Modifier - .fillMaxWidth() - ) + Spacer(Modifier.height(8.dp)) - Spacer(Modifier.height(8.dp)) + TextFieldWithAdditionalText( + additionalText = stringResource(R.string.username_additional_text), + title = stringResource(R.string.user_id), + value = state.username.value, + isError = state.username is ValidatedField.Invalid, + placeholder = stringResource(R.string.username), + onValueChange = onUsernameChange, + supportingText = stringResource(me.floow.uikit.R.string.username_field_supporting_text), + modifier = Modifier + .fillMaxWidth() + ) - TextFieldWithAdditionalText( - additionalText = stringResource(R.string.username_additional_text), - title = stringResource(R.string.user_id), - value = state.username.value, - isError = state.username is ValidatedField.Invalid, - placeholder = stringResource(R.string.username), - onValueChange = onUsernameChange, - supportingText = stringResource(me.floow.uikit.R.string.username_field_supporting_text), - modifier = Modifier - .fillMaxWidth() - ) + Spacer(Modifier.height(8.dp)) - Spacer(Modifier.height(8.dp)) - - TextFieldWithAdditionalText( - additionalText = "", - title = stringResource(R.string.about), - value = state.bio.value, - isError = state.bio is ValidatedField.Invalid, - placeholder = stringResource(R.string.write_something_about_you), - minLines = 3, - maxLines = 10, - singleLine = false, - onValueChange = onBiographyChange, - modifier = Modifier - .fillMaxWidth() - ) - } + TextFieldWithAdditionalText( + additionalText = "", + title = stringResource(R.string.about), + value = state.bio.value, + isError = state.bio is ValidatedField.Invalid, + placeholder = stringResource(R.string.write_something_about_you), + minLines = 3, + maxLines = 10, + singleLine = false, + onValueChange = onBiographyChange, + modifier = Modifier + .fillMaxWidth() + ) + } } \ No newline at end of file diff --git a/feature/login/src/main/java/me/floow/login/uilogic/CreateProfileViewModel.kt b/feature/login/src/main/java/me/floow/login/uilogic/CreateProfileViewModel.kt index 71c0cbc..df02291 100644 --- a/feature/login/src/main/java/me/floow/login/uilogic/CreateProfileViewModel.kt +++ b/feature/login/src/main/java/me/floow/login/uilogic/CreateProfileViewModel.kt @@ -21,7 +21,6 @@ import me.floow.domain.values.util.ValidationError import me.floow.domain.values.util.ValueValidationResult import me.floow.uikit.util.state.ValidatedField import me.floow.uikit.util.state.ValidatedField.Companion.initialField -import java.lang.IllegalStateException data class CreateProfileVmState( val name: ValidatedField = initialField, @@ -59,7 +58,7 @@ class CreateProfileViewModel( val hapticFeedbackFlow: SharedFlow = _hapticFeedbackFlow fun updateName(newValue: String) { - val validationResult = ProfileName.create(newValue) + val validationResult = ProfileName.createWithValidation(newValue) if (validationResult is ValueValidationResult.Invalid) { viewModelScope.launch { @@ -87,7 +86,7 @@ class CreateProfileViewModel( } fun updateUsername(newValue: String) { - val validationResult = ProfileUsername.create(newValue) + val validationResult = ProfileUsername.createWithValidation(newValue) if (validationResult is ValueValidationResult.Invalid) { viewModelScope.launch { @@ -115,7 +114,7 @@ class CreateProfileViewModel( } fun updateBio(newValue: String) { - val validationResult = ProfileDescription.create(newValue) + val validationResult = ProfileDescription.createWithValidation(newValue) if (validationResult is ValueValidationResult.Invalid) { viewModelScope.launch { @@ -163,9 +162,9 @@ class CreateProfileViewModel( val result = _profileRepository.edit( data = EditProfileData( - name = ProfileName(_state.value.name.value), - username = ProfileUsername(_state.value.username.value), - description = ProfileDescription(_state.value.bio.value) + name = ProfileName.create(_state.value.name.value), + username = ProfileUsername.create(_state.value.username.value), + description = ProfileDescription.create(_state.value.bio.value) ) ) diff --git a/feature/profile/src/main/java/me/floow/profile/ui/edit/EditProfileRoute.kt b/feature/profile/src/main/java/me/floow/profile/ui/edit/EditProfileRoute.kt index 35e33fd..1eee7bd 100644 --- a/feature/profile/src/main/java/me/floow/profile/ui/edit/EditProfileRoute.kt +++ b/feature/profile/src/main/java/me/floow/profile/ui/edit/EditProfileRoute.kt @@ -62,7 +62,9 @@ fun EditProfileRoute( } ) }, - onAvatarPickerClick = { TODO() }, + onAvatarPickerClick = { + Toast.makeText(context, "Фича ещё разрабатывается…", Toast.LENGTH_SHORT).show() + }, onNameChange = vm::updateName, onUsernameChange = vm::updateUsername, onBiographyChange = vm::updateBiography, diff --git a/feature/profile/src/main/java/me/floow/profile/ui/edit/EditState.kt b/feature/profile/src/main/java/me/floow/profile/ui/edit/EditState.kt index afea00b..c696fc6 100644 --- a/feature/profile/src/main/java/me/floow/profile/ui/edit/EditState.kt +++ b/feature/profile/src/main/java/me/floow/profile/ui/edit/EditState.kt @@ -1,32 +1,21 @@ package me.floow.profile.ui.edit -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import me.floow.profile.R import me.floow.profile.uilogic.edit.EditProfileState import me.floow.uikit.components.input.TextFieldWithAdditionalText -import me.floow.uikit.theme.ElevanagonShape +import me.floow.uikit.components.pickers.AvatarAndBackgroundPicker import me.floow.uikit.theme.LocalTypography import me.floow.uikit.util.state.ValidatedField @@ -41,33 +30,15 @@ internal fun EditState( Column( modifier = Modifier .fillMaxSize() - .padding(24.dp) + .padding(14.dp) ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .height(160.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .clickable { - onAvatarPickerClick() - }, - ) { - Image( - painter = painterResource(me.floow.uikit.R.drawable.cute_girl), - contentDescription = null, - modifier = Modifier - .size(120.dp) - .clip(ElevanagonShape) - ) - - Icon( - painter = painterResource(me.floow.uikit.R.drawable.photo_icon), - contentDescription = null, - tint = Color.White, - ) - } + AvatarAndBackgroundPicker( + avatarImagePainter = null, + backgroundImagePainter = null, + onAvatarPickerClick = onAvatarPickerClick, + onBackgroundPickerClick = {}, + modifier = Modifier.fillMaxWidth() + ) Spacer(Modifier.height(24.dp)) diff --git a/feature/profile/src/main/java/me/floow/profile/ui/profile/segments/summary/AboutMeProfileSummaryPage.kt b/feature/profile/src/main/java/me/floow/profile/ui/profile/segments/summary/AboutMeProfileSummaryPage.kt index 7c2e505..f7e7db6 100644 --- a/feature/profile/src/main/java/me/floow/profile/ui/profile/segments/summary/AboutMeProfileSummaryPage.kt +++ b/feature/profile/src/main/java/me/floow/profile/ui/profile/segments/summary/AboutMeProfileSummaryPage.kt @@ -22,7 +22,7 @@ internal fun AboutMeProfileSummaryPage(description: String?, modifier: Modifier ) { Text( text = "обо мне", - style = LocalTypography.current.titleMedium, + style = LocalTypography.current.titleLarge, color = Color.White, ) diff --git a/feature/profile/src/main/java/me/floow/profile/ui/profile/segments/summary/AvatarUsernameProfileSummaryPage.kt b/feature/profile/src/main/java/me/floow/profile/ui/profile/segments/summary/AvatarUsernameProfileSummaryPage.kt index fe7f145..938bbf2 100644 --- a/feature/profile/src/main/java/me/floow/profile/ui/profile/segments/summary/AvatarUsernameProfileSummaryPage.kt +++ b/feature/profile/src/main/java/me/floow/profile/ui/profile/segments/summary/AvatarUsernameProfileSummaryPage.kt @@ -45,7 +45,7 @@ internal fun AvatarUsernameProfileSummaryPage( Text( text = displayName ?: stringResource(R.string.no_display_name), - style = LocalTypography.current.titleMedium, + style = LocalTypography.current.titleLarge, color = Color.White, ) diff --git a/feature/profile/src/main/java/me/floow/profile/uilogic/edit/EditProfileViewModel.kt b/feature/profile/src/main/java/me/floow/profile/uilogic/edit/EditProfileViewModel.kt index 8c2f4d8..eab43ca 100644 --- a/feature/profile/src/main/java/me/floow/profile/uilogic/edit/EditProfileViewModel.kt +++ b/feature/profile/src/main/java/me/floow/profile/uilogic/edit/EditProfileViewModel.kt @@ -23,8 +23,6 @@ import me.floow.domain.values.util.ValueValidationResult import me.floow.profile.ui.edit.EditProfileRouteInitialData import me.floow.uikit.util.state.ValidatedField import me.floow.uikit.util.state.ValidatedField.Companion.initialField -import me.floow.uikit.util.state.ValidationErrorType -import java.lang.IllegalStateException data class CreateProfileVmState( val name: ValidatedField = initialField, @@ -78,7 +76,7 @@ class EditProfileViewModel( } fun updateName(newValue: String) { - val validationResult = ProfileName.create(newValue) + val validationResult = ProfileName.createWithValidation(newValue) if (validationResult is ValueValidationResult.Invalid) { viewModelScope.launch { @@ -106,7 +104,7 @@ class EditProfileViewModel( } fun updateUsername(newValue: String) { - val validationResult = ProfileUsername.create(newValue) + val validationResult = ProfileUsername.createWithValidation(newValue) if (validationResult is ValueValidationResult.Invalid) { viewModelScope.launch { @@ -134,7 +132,7 @@ class EditProfileViewModel( } fun updateBiography(newValue: String) { - val validationResult = ProfileDescription.create(newValue) + val validationResult = ProfileDescription.createWithValidation(newValue) if (validationResult is ValueValidationResult.Invalid) { viewModelScope.launch { @@ -182,9 +180,9 @@ class EditProfileViewModel( val result = _profileRepository.edit( data = EditProfileData( - name = ProfileName(_state.value.name.value), - username = ProfileUsername(_state.value.username.value), - description = ProfileDescription(_state.value.bio.value) + name = ProfileName.create(_state.value.name.value), + username = ProfileUsername.create(_state.value.username.value), + description = ProfileDescription.create(_state.value.bio.value) ) ) diff --git a/feature/shared/build.gradle.kts b/feature/shared/build.gradle.kts new file mode 100644 index 0000000..4d3230c --- /dev/null +++ b/feature/shared/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.composeCompiler) +} + +android { + namespace = "me.floow.shared" + compileSdk = 34 + + defaultConfig { + minSdk = 28 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + buildFeatures { compose = true } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation(project(":core:uikit")) + implementation(project(":core:domain")) + + implementation(libs.appcompat) + + api(platform(libs.koin.bom)) + api(libs.koin.core) + api(libs.koin.android) + + testImplementation(libs.junit) + + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) +} diff --git a/feature/shared/src/main/kotlin/me/floow/shared/di/sharedModule.kt b/feature/shared/src/main/kotlin/me/floow/shared/di/sharedModule.kt new file mode 100644 index 0000000..54707f2 --- /dev/null +++ b/feature/shared/src/main/kotlin/me/floow/shared/di/sharedModule.kt @@ -0,0 +1,9 @@ +package me.floow.shared.di + +import org.koin.dsl.module +import org.koin.dsl.module +import org.koin.core.module.dsl.viewModelOf + +val sharedModule = module { + +} diff --git a/feature/usersearch/src/main/java/com/demn/usersearch/ui/SearchUsersScreen.kt b/feature/usersearch/src/main/java/com/demn/usersearch/ui/SearchUsersScreen.kt deleted file mode 100644 index a814f54..0000000 --- a/feature/usersearch/src/main/java/com/demn/usersearch/ui/SearchUsersScreen.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.demn.usersearch.ui - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.demn.usersearch.uilogic.SearchUsersScreenUiState - -@Composable -internal fun SearchUsersScreen( - state: SearchUsersScreenUiState, - modifier: Modifier = Modifier -) { - Scaffold( - topBar = { - - }, - modifier = modifier, - ) { innerPadding -> - Box(modifier = Modifier.padding(innerPadding)) { - - } - } -} \ No newline at end of file diff --git a/feature/usersearch/src/main/java/com/demn/usersearch/ui/components/SearchUsersScreenTopBar.kt b/feature/usersearch/src/main/java/com/demn/usersearch/ui/components/SearchUsersScreenTopBar.kt deleted file mode 100644 index 23de288..0000000 --- a/feature/usersearch/src/main/java/com/demn/usersearch/ui/components/SearchUsersScreenTopBar.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.demn.usersearch.ui.components - -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun SearchUsersScreenTopBar( - searchFieldValue: String, - onSearchFieldUpdate: (String) -> Unit, - modifier: Modifier = Modifier -) { - -} \ No newline at end of file diff --git a/feature/usersearch/src/main/java/com/demn/usersearch/ui/states/EmptySearchResultsState.kt b/feature/usersearch/src/main/java/com/demn/usersearch/ui/states/EmptySearchResultsState.kt deleted file mode 100644 index 5ab3ceb..0000000 --- a/feature/usersearch/src/main/java/com/demn/usersearch/ui/states/EmptySearchResultsState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.demn.usersearch.ui.states - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun EmptySearchResultsState(modifier: Modifier = Modifier) { - -} \ No newline at end of file diff --git a/feature/usersearch/src/main/java/com/demn/usersearch/ui/states/LoadingState.kt b/feature/usersearch/src/main/java/com/demn/usersearch/ui/states/LoadingState.kt deleted file mode 100644 index b4799ad..0000000 --- a/feature/usersearch/src/main/java/com/demn/usersearch/ui/states/LoadingState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.demn.usersearch.ui.states - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun LoadingState(modifier: Modifier = Modifier) { - -} \ No newline at end of file diff --git a/feature/usersearch/src/main/java/com/demn/usersearch/ui/states/SearchResultsState.kt b/feature/usersearch/src/main/java/com/demn/usersearch/ui/states/SearchResultsState.kt deleted file mode 100644 index 191d881..0000000 --- a/feature/usersearch/src/main/java/com/demn/usersearch/ui/states/SearchResultsState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.demn.usersearch.ui.states - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun SearchResultsState(modifier: Modifier = Modifier) { - -} \ No newline at end of file diff --git a/feature/usersearch/src/main/java/com/demn/usersearch/uilogic/SearchUsersScreenUiState.kt b/feature/usersearch/src/main/java/com/demn/usersearch/uilogic/SearchUsersScreenUiState.kt deleted file mode 100644 index d6142d1..0000000 --- a/feature/usersearch/src/main/java/com/demn/usersearch/uilogic/SearchUsersScreenUiState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.demn.usersearch.uilogic - -interface SearchUsersScreenUiState { - val searchField: String - - data class Loading(override val searchField: String) : SearchUsersScreenUiState - - data class EmptyResults(override val searchField: String) : SearchUsersScreenUiState -} \ No newline at end of file diff --git a/feature/usersearch/src/main/java/com/demn/usersearch/uilogic/SearchUsersScreenViewModel.kt b/feature/usersearch/src/main/java/com/demn/usersearch/uilogic/SearchUsersScreenViewModel.kt deleted file mode 100644 index f431a17..0000000 --- a/feature/usersearch/src/main/java/com/demn/usersearch/uilogic/SearchUsersScreenViewModel.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.demn.usersearch.uilogic - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn - -private data class SearchUsersScreenVmState( - val searchField: String = "" -) { - fun toUiState(): SearchUsersScreenUiState { - return SearchUsersScreenUiState.EmptyResults(searchField = searchField) - } -} - -class SearchUsersScreenViewModel : ViewModel() { - private val _state = MutableStateFlow(SearchUsersScreenVmState()) - - val state: StateFlow = _state - .map(SearchUsersScreenVmState::toUiState) - .stateIn(viewModelScope, SharingStarted.Eagerly, SearchUsersScreenUiState.Loading("")) -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 407fd14..3776266 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,34 +1,32 @@ [versions] -agp = "8.2.0" +agp = "8.6.1" androidxCoreSplashscreen = "1.0.1" -credentials = "1.2.2" graphics-shapes = "1.0.1" -kotlin = "2.0.20" -core-ktx = "1.13.1" +kotlin = "2.1.20" +core-ktx = "1.16.0" junit = "4.13.2" androidx-test-ext-junit = "1.2.1" espresso-core = "3.6.1" -lifecycle-runtime-ktx = "2.8.6" -activity-compose = "1.9.2" -compose-bom = "2024.09.02" -org-jetbrains-kotlin-jvm = "2.0.10" +lifecycle-runtime-ktx = "2.9.0" +activity-compose = "1.10.1" +compose-bom = "2025.05.00" +org-jetbrains-kotlin-jvm = "2.0.20" appcompat = "1.7.0" -androidx-navigation-compose = "2.8.3" -serialization = "1.7.1" -serialization-json = "1.7.1" -ui-tooling = "1.7.2" -ktor = "2.3.12" -coroutines = "1.9.0-RC.2" +androidx-navigation-compose = "2.9.0" +serialization = "1.8.1" +serialization-json = "1.8.1" +ui-tooling = "1.8.1" +ktor = "3.1.2" +coroutines = "1.10.2" gmsGoogleServices = "4.4.2" -firebaseCrashlytics = "3.0.2" -firebaseBom = "33.3.0" +firebaseCrashlytics = "3.0.3" +firebaseBom = "33.13.0" androidx-browser = "1.8.0" koin = "4.0.0-RC1" -material = "1.12.0" -room = "2.6.1" -ksp = "2.0.20-1.0.24" +room = "2.7.1" +ksp = "2.1.20-1.0.31" coil = "2.7.0" -materialVersion = "1.10.0" +textflow = "1.2.1" [libraries] androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidx-browser" } @@ -77,7 +75,8 @@ room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } -material = { group = "com.google.android.material", name = "material", version.ref = "materialVersion" } + +textflow-material3 = { group = "io.github.oleksandrbalan", name = "textflow-material3", version.ref = "textflow" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 87f01d1..ba950ed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,4 +34,5 @@ include(":feature:chats") include(":feature:explore") include(":feature:login") include(":feature:profile") -include(":feature:usersearch") +include(":feature:chatssearch") +include(":feature:shared")