├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── co │ │ └── zsmb │ │ └── cleanchat │ │ ├── MainActivity.kt │ │ ├── data │ │ ├── Message.kt │ │ ├── RandomData.kt │ │ └── User.kt │ │ └── ui │ │ ├── Common.kt │ │ ├── Contacts.kt │ │ ├── Messages.kt │ │ └── theme │ │ ├── Colors.kt │ │ └── Theme.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── font │ ├── metropolis_bold.otf │ ├── metropolis_light.otf │ ├── metropolis_medium.otf │ ├── metropolis_regular.otf │ └── metropolis_regular_italic.otf │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ └── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml ├── build.gradle ├── docs ├── contacts.jpg └── messages.jpg ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clean Chat 2 | 3 | A Jetpack Compose implementation of Mickael Guillaume's *[Contacts & Messages](https://dribbble.com/shots/14115486-Contacts-Messages/)* design from Dribbble, as shown off in [Build an Android Chat app with Jetpack Compose](https://proandroiddev.com/android-chat-app-jetpack-compose-dec472140ff1). 4 | 5 | > **Note that Stream now ships a dedicated [Jetpack Compose SDK](https://getstream.io/chat/sdk/compose/).** 6 | 7 |

8 | 9 | 10 |

11 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdk 30 8 | buildToolsVersion "30.0.3" 9 | 10 | defaultConfig { 11 | applicationId "co.zsmb.cleanchat" 12 | minSdk 26 13 | targetSdk 30 14 | versionCode 1 15 | versionName "1.0" 16 | 17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 18 | vectorDrawables { 19 | useSupportLibrary true 20 | } 21 | } 22 | 23 | buildTypes { 24 | release { 25 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility JavaVersion.VERSION_1_8 31 | targetCompatibility JavaVersion.VERSION_1_8 32 | } 33 | kotlinOptions { 34 | jvmTarget = '1.8' 35 | useIR = true 36 | } 37 | buildFeatures { 38 | compose true 39 | } 40 | composeOptions { 41 | kotlinCompilerExtensionVersion compose_version 42 | kotlinCompilerVersion '1.4.32' 43 | } 44 | } 45 | 46 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { 47 | kotlinOptions { 48 | freeCompilerArgs += [ 49 | '-progressive', 50 | '-Xopt-in=kotlin.RequiresOptIn', 51 | ] 52 | } 53 | } 54 | 55 | dependencies { 56 | implementation "com.google.accompanist:accompanist-coil:0.8.1" 57 | implementation 'org.jetbrains.kotlin:kotlin-reflect' 58 | 59 | implementation 'androidx.core:core-ktx:1.5.0' 60 | implementation 'androidx.appcompat:appcompat:1.3.0' 61 | implementation 'com.google.android.material:material:1.3.0' 62 | implementation "androidx.compose.ui:ui:$compose_version" 63 | implementation "androidx.compose.material:material:$compose_version" 64 | implementation "androidx.compose.ui:ui-tooling:$compose_version" 65 | implementation "androidx.navigation:navigation-compose:1.0.0-alpha10" 66 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' 67 | implementation 'androidx.activity:activity-compose:1.3.0-alpha07' 68 | } 69 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 15 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/java/co/zsmb/cleanchat/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package co.zsmb.cleanchat 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.navigation.compose.NavHost 7 | import androidx.navigation.compose.composable 8 | import androidx.navigation.compose.rememberNavController 9 | import co.zsmb.cleanchat.ui.Contacts 10 | import co.zsmb.cleanchat.ui.Messages 11 | import co.zsmb.cleanchat.ui.theme.CleanChatTheme 12 | 13 | class MainActivity : ComponentActivity() { 14 | override fun onCreate(savedInstanceState: Bundle?) { 15 | super.onCreate(savedInstanceState) 16 | setContent { 17 | CleanChatTheme { 18 | val navController = rememberNavController() 19 | NavHost(navController, startDestination = "contacts") { 20 | composable("contacts") { 21 | Contacts(navController) 22 | } 23 | composable("messages") { 24 | Messages() 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/co/zsmb/cleanchat/data/Message.kt: -------------------------------------------------------------------------------- 1 | package co.zsmb.cleanchat.data 2 | 3 | data class Message( 4 | val user: User, 5 | val text: String, 6 | val imageUrl: String? = null, 7 | ) 8 | -------------------------------------------------------------------------------- /app/src/main/java/co/zsmb/cleanchat/data/RandomData.kt: -------------------------------------------------------------------------------- 1 | package co.zsmb.cleanchat.data 2 | 3 | import kotlin.random.Random 4 | import java.time.Instant 5 | import java.time.temporal.ChronoUnit 6 | import java.util.Date 7 | 8 | fun randomMessageList(size: Int = Random.nextInt(4, 10)): List { 9 | return List(size) { randomMessage() } 10 | } 11 | 12 | fun randomMessage(): Message { 13 | val messages = listOf( 14 | "Cake or pie? I can tell a lot about you by which one you pick. It may seem silly, but cake people and pie people are really different. I know which one I hope you are, but that's not for me to decide. So, what is it? Cake or pie?", 15 | "The young man wanted a role model. He looked long and hard in his youth, but that role model never materialized. His only choice was to embrace all the people in his life he didn't want to be like.", 16 | "The headphones were on. They had been utilized on purpose. She could hear her mom yelling in the background, but couldn't make out exactly what the yelling was about. That was exactly why she had put them on. She knew her mom would enter her room at any minute, and she could pretend that she hadn't heard any of the previous yelling.", 17 | "She had been an angel for coming up on 10 years and in all that time nobody had told her this was possible. The fact that it could ever happen never even entered her mind. Yet there she stood, with the undeniable evidence sitting on the ground before her. Angels could lose their wings.", 18 | "It went through such rapid contortions that the little bear was forced to change his hold on it so many times he became confused in the darkness, and could not, for the life of him, tell whether he held the sheep right side up, or upside down.", 19 | "Sometimes there isn't a good answer. No matter how you try to rationalize the outcome, it doesn't make sense. And instead of an answer, you are simply left with a question. Why?", 20 | "It was their first date and she had been looking forward to it the entire week. She had her eyes on him for months, and it had taken a convoluted scheme with several friends to make it happen, but he'd finally taken the hint and asked her out. After all the time and effort she'd invested into it, she never thought that it would be anything but wonderful.", 21 | "It wasn't quite yet time to panic. There was still time to salvage the situation. At least that is what she was telling himself. The reality was that it was time to panic and there wasn't time to salvage the situation, but he continued to delude himself into believing there was.", 22 | "The lone lamp post of the one-street town flickered, not quite dead but definitely on its way out. Suitcase by her side, she paid no heed to the light, the street or the town. A car was coming down the street and with her arm outstretched and thumb in the air, she had a plan.", 23 | ) 24 | val imageIds = listOf( 25 | null, 26 | null, 27 | null, 28 | null, 29 | null, 30 | null, 31 | "1620504395830-1d1c57f46151", 32 | "1618340338709-027f57b98a16", 33 | "1619732514485-2f6896f9e34c", 34 | "1581503039137-1cc062a9097b", 35 | "1619229725896-1b2ca516a6d8", 36 | "1619963168631-f9dcc897e348", 37 | ) 38 | return Message( 39 | user = randomUser(), 40 | text = messages.random(), 41 | imageUrl = imageIds.random() 42 | ?.let { id -> "https://images.unsplash.com/photo-${id}?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=800&ixlib=rb-1.2.1&q=80&w=1200" }, 43 | ) 44 | } 45 | 46 | fun randomUserList(size: Int = 30): List { 47 | return List(size) { randomUser() } 48 | .sortedByDescending { it.lastOnline } 49 | .sortedByDescending { it.isOnline } 50 | } 51 | 52 | fun randomUser(): User { 53 | val names = listOf( 54 | "Marc", 55 | "Sandy", 56 | "Lucie", 57 | "Laura", 58 | "Pierre", 59 | "Fanny", 60 | "Celine", 61 | "Alice", 62 | "Martin", 63 | "Pauline" 64 | ) 65 | return User( 66 | name = names.random(), 67 | avatarUrl = randomAvatarUrl(), 68 | isOnline = Random.nextFloat() < 0.4, 69 | lastOnline = Date( 70 | Random.nextLong( 71 | minTime.toEpochMilli(), 72 | maxTime.toEpochMilli() 73 | ) 74 | ) 75 | ) 76 | } 77 | 78 | private val maxTime = Instant.now() 79 | private val minTime = maxTime.minus(2, ChronoUnit.DAYS) 80 | 81 | fun randomAvatarUrl(): String { 82 | val category = listOf("men", "women").random() 83 | val index = (0..99).random() 84 | return "https://randomuser.me/api/portraits/$category/$index.jpg" 85 | } 86 | 87 | -------------------------------------------------------------------------------- /app/src/main/java/co/zsmb/cleanchat/data/User.kt: -------------------------------------------------------------------------------- 1 | package co.zsmb.cleanchat.data 2 | 3 | import java.util.Date 4 | 5 | data class User( 6 | val name: String, 7 | val avatarUrl: String, 8 | val isOnline: Boolean, 9 | val lastOnline: Date, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/co/zsmb/cleanchat/ui/Common.kt: -------------------------------------------------------------------------------- 1 | package co.zsmb.cleanchat.ui 2 | 3 | import android.text.format.DateUtils 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.foundation.shape.CircleShape 8 | import androidx.compose.foundation.shape.RoundedCornerShape 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.ReadOnlyComposable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.clip 15 | import androidx.compose.ui.res.stringResource 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.compose.ui.unit.dp 19 | import androidx.compose.ui.unit.sp 20 | import co.zsmb.cleanchat.R 21 | import co.zsmb.cleanchat.data.User 22 | import co.zsmb.cleanchat.data.randomUserList 23 | import co.zsmb.cleanchat.ui.theme.AppColors 24 | import com.google.accompanist.coil.rememberCoilPainter 25 | 26 | @Composable 27 | fun Avatar( 28 | url: String, 29 | modifier: Modifier = Modifier, 30 | ) { 31 | Image( 32 | painter = rememberCoilPainter(url), 33 | contentDescription = null, 34 | modifier = modifier 35 | .size(64.dp) 36 | .clip(RoundedCornerShape(12.dp)) 37 | ) 38 | } 39 | 40 | @Composable 41 | fun UserRow(user: User, modifier: Modifier = Modifier) { 42 | @Composable 43 | fun OnlineIndicator() { 44 | Box( 45 | Modifier 46 | .size(12.dp) 47 | .clip(CircleShape) 48 | .background(AppColors.onlineIndicator) 49 | ) 50 | } 51 | 52 | Row( 53 | verticalAlignment = Alignment.CenterVertically, 54 | modifier = modifier.padding(24.dp), 55 | ) { 56 | Avatar(url = user.avatarUrl, modifier = Modifier.padding(end = 16.dp)) 57 | 58 | Column(modifier = Modifier.weight(1f, fill = true)) { 59 | Text( 60 | user.name, 61 | fontWeight = FontWeight.Bold, 62 | color = AppColors.textDark, 63 | ) 64 | Text( 65 | lastActiveStatus(user), 66 | fontWeight = FontWeight.Medium, 67 | modifier = Modifier.padding(top = 4.dp), 68 | color = AppColors.textMedium, 69 | fontSize = 14.sp, 70 | ) 71 | } 72 | 73 | if (user.isOnline) { 74 | OnlineIndicator() 75 | } 76 | } 77 | } 78 | 79 | @Composable 80 | @ReadOnlyComposable 81 | private fun lastActiveStatus(user: User): String { 82 | return when { 83 | user.isOnline -> stringResource(R.string.user_online) 84 | else -> stringResource( 85 | R.string.user_last_activity, 86 | DateUtils.getRelativeTimeSpanString(user.lastOnline.time) 87 | ) 88 | } 89 | } 90 | 91 | @Preview( 92 | showBackground = true, 93 | ) 94 | @Composable 95 | fun UserRowPreview() { 96 | Column { 97 | randomUserList(5).forEach { user -> 98 | UserRow(user = user) 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/java/co/zsmb/cleanchat/ui/Contacts.kt: -------------------------------------------------------------------------------- 1 | package co.zsmb.cleanchat.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.gestures.Orientation 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.foundation.lazy.* 8 | import androidx.compose.foundation.shape.CircleShape 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material.* 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.filled.Add 13 | import androidx.compose.material.icons.filled.Search 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.remember 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.draw.alpha 19 | import androidx.compose.ui.draw.clip 20 | import androidx.compose.ui.geometry.Offset 21 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 22 | import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher 23 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource 24 | import androidx.compose.ui.input.nestedscroll.nestedScroll 25 | import androidx.compose.ui.platform.LocalDensity 26 | import androidx.compose.ui.res.stringResource 27 | import androidx.compose.ui.text.font.FontWeight 28 | import androidx.compose.ui.unit.Dp 29 | import androidx.compose.ui.unit.Velocity 30 | import androidx.compose.ui.unit.dp 31 | import androidx.compose.ui.unit.sp 32 | import androidx.navigation.NavController 33 | import androidx.navigation.compose.navigate 34 | import co.zsmb.cleanchat.R 35 | import co.zsmb.cleanchat.data.User 36 | import co.zsmb.cleanchat.data.randomUserList 37 | import co.zsmb.cleanchat.ui.theme.AppColors 38 | import kotlin.math.pow 39 | import kotlin.math.roundToInt 40 | 41 | private val HEADER_SIZE = 360.dp 42 | 43 | @OptIn(ExperimentalMaterialApi::class) 44 | @Composable 45 | fun Contacts(navController: NavController) { 46 | val swipeableState = rememberSwipeableState(1f) 47 | val headerSizePx = with(LocalDensity.current) { HEADER_SIZE.toPx() } 48 | val anchors = mapOf(0f to 0f, headerSizePx to 1f) 49 | 50 | val onNewDelta: (Float) -> Float = { delta -> 51 | swipeableState.performDrag(delta) 52 | } 53 | val nestedScrollDispatcher = remember { NestedScrollDispatcher() } 54 | val nestedScrollConnection = remember { 55 | object : NestedScrollConnection { 56 | override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { 57 | if (available.y > 0) { 58 | return Offset.Zero 59 | } 60 | val vertical = available.y 61 | val weConsumed = onNewDelta(vertical) 62 | return Offset(x = 0f, y = weConsumed) 63 | } 64 | 65 | override fun onPostScroll( 66 | consumed: Offset, 67 | available: Offset, 68 | source: NestedScrollSource 69 | ): Offset { 70 | if (available.y < 0) { 71 | return Offset.Zero 72 | } 73 | val vertical = available.y 74 | val weConsumed = onNewDelta(vertical) 75 | return Offset(x = 0f, y = weConsumed) 76 | } 77 | 78 | override suspend fun onPostFling( 79 | consumed: Velocity, 80 | available: Velocity 81 | ): Velocity { 82 | swipeableState.performFling(available.y) 83 | return available 84 | } 85 | } 86 | } 87 | 88 | val progress: Float = (swipeableState.offset.value) / headerSizePx 89 | 90 | Box( 91 | Modifier 92 | .fillMaxSize() 93 | .nestedScroll(nestedScrollConnection, nestedScrollDispatcher) 94 | .swipeable( 95 | state = swipeableState, 96 | anchors = anchors, 97 | thresholds = { _, _ -> FractionalThreshold(0.4f) }, 98 | orientation = Orientation.Vertical, 99 | ) 100 | .background(AppColors.bgDark) 101 | ) { 102 | Column(Modifier.alpha(progress)) { 103 | Toolbar(onButtonClicked = { navController.navigate("messages") }) 104 | Searchbar() 105 | Favorites() 106 | } 107 | 108 | val currentOffset: Dp = with(LocalDensity.current) { 109 | swipeableState.offset.value.roundToInt().coerceAtLeast(0).toDp() 110 | } 111 | 112 | ContactList(currentOffset) 113 | } 114 | } 115 | 116 | @Composable 117 | private fun ContactList(topOffset: Dp) { 118 | val users = remember { randomUserList(size = 20) } 119 | 120 | val state: LazyListState = rememberLazyListState() 121 | 122 | val cornerRatio = (topOffset / (HEADER_SIZE / 2) - 1f).coerceAtLeast(0f) 123 | val currentCorner = when { 124 | cornerRatio <= 1f -> 36.dp * cornerRatio 125 | else -> 36.dp * cornerRatio.pow(6) 126 | } 127 | 128 | LazyColumn( 129 | state = state, 130 | modifier = Modifier 131 | .offset(y = topOffset) 132 | .clip(RoundedCornerShape(currentCorner)) 133 | .background(AppColors.bgLight), 134 | contentPadding = PaddingValues(vertical = 24.dp, horizontal = 24.dp), 135 | verticalArrangement = Arrangement.spacedBy(16.dp) 136 | ) { 137 | items(users) { user -> 138 | Card( 139 | modifier = Modifier.fillMaxWidth(), 140 | shape = RoundedCornerShape(16.dp), 141 | elevation = 0.5.dp 142 | ) { 143 | UserRow(user) 144 | } 145 | } 146 | } 147 | } 148 | 149 | @Composable 150 | private fun Toolbar(onButtonClicked: () -> Unit) { 151 | Row( 152 | Modifier 153 | .padding(top = 16.dp) 154 | .padding(horizontal = 24.dp, vertical = 16.dp) 155 | ) { 156 | Text( 157 | stringResource(R.string.contacts_title), 158 | color = AppColors.textLight, 159 | fontSize = 28.sp, 160 | modifier = Modifier.weight(1f, fill = true), 161 | fontWeight = FontWeight.Medium 162 | ) 163 | Box( 164 | modifier = Modifier 165 | .clip(RoundedCornerShape(6.dp)) 166 | .size(32.dp) 167 | .background(AppColors.bgLight) 168 | .clickable(onClick = onButtonClicked), 169 | contentAlignment = Alignment.Center, 170 | ) { 171 | Icon(Icons.Default.Add, null) 172 | } 173 | } 174 | } 175 | 176 | @Composable 177 | private fun Searchbar() { 178 | Row( 179 | verticalAlignment = Alignment.CenterVertically, 180 | modifier = Modifier 181 | .fillMaxWidth() 182 | .padding(vertical = 16.dp, horizontal = 24.dp) 183 | .clip(RoundedCornerShape(percent = 50)) 184 | .background(AppColors.bgMedium) 185 | .padding(12.dp) 186 | ) { 187 | Icon( 188 | Icons.Default.Search, 189 | contentDescription = null, 190 | tint = AppColors.textMedium, 191 | modifier = Modifier.padding(8.dp) 192 | ) 193 | Text(stringResource(R.string.search_hint), color = AppColors.textMedium) 194 | } 195 | } 196 | 197 | @Composable 198 | private fun Favorites() { 199 | Column(Modifier.padding(top = 16.dp, bottom = 16.dp)) { 200 | Text( 201 | stringResource(R.string.favorites_title), 202 | color = AppColors.textLight, 203 | fontSize = 16.sp, 204 | fontWeight = FontWeight.Medium, 205 | modifier = Modifier.padding(start = 24.dp, bottom = 8.dp) 206 | ) 207 | val users = remember { randomUserList(size = 10) } 208 | LazyRow( 209 | horizontalArrangement = Arrangement.spacedBy(32.dp), 210 | contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp) 211 | ) { 212 | items(users) { user -> 213 | FavoriteUser(user) 214 | } 215 | } 216 | } 217 | } 218 | 219 | @Composable 220 | private fun FavoriteUser(user: User) { 221 | Column( 222 | horizontalAlignment = Alignment.CenterHorizontally, 223 | modifier = Modifier.padding(vertical = 16.dp) 224 | ) { 225 | Box(contentAlignment = Alignment.BottomCenter) { 226 | Avatar( 227 | url = user.avatarUrl, 228 | modifier = Modifier.padding(bottom = 5.dp) 229 | ) 230 | if (user.isOnline) { 231 | Box( 232 | Modifier 233 | .size(10.dp) 234 | .clip(CircleShape) 235 | .background(AppColors.bgDark), 236 | contentAlignment = Alignment.Center, 237 | ) { 238 | Box( 239 | Modifier 240 | .size(8.dp) 241 | .clip(CircleShape) 242 | .background(AppColors.onlineIndicator) 243 | ) 244 | } 245 | } 246 | } 247 | Text( 248 | user.name, 249 | color = AppColors.textLight, 250 | fontWeight = FontWeight.Medium, 251 | fontSize = 14.sp, 252 | letterSpacing = 0.1.sp 253 | ) 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /app/src/main/java/co/zsmb/cleanchat/ui/Messages.kt: -------------------------------------------------------------------------------- 1 | package co.zsmb.cleanchat.ui 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.LazyListState 8 | import androidx.compose.foundation.lazy.items 9 | import androidx.compose.foundation.lazy.rememberLazyListState 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material.Card 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.draw.alpha 18 | import androidx.compose.ui.draw.clip 19 | import androidx.compose.ui.platform.LocalDensity 20 | import androidx.compose.ui.res.stringResource 21 | import androidx.compose.ui.text.font.FontWeight 22 | import androidx.compose.ui.unit.dp 23 | import androidx.compose.ui.unit.em 24 | import androidx.compose.ui.unit.sp 25 | import co.zsmb.cleanchat.R 26 | import co.zsmb.cleanchat.data.Message 27 | import co.zsmb.cleanchat.data.randomMessageList 28 | import co.zsmb.cleanchat.ui.theme.AppColors 29 | import com.google.accompanist.coil.rememberCoilPainter 30 | 31 | @Composable 32 | fun Messages() { 33 | val messages = remember { randomMessageList() } 34 | 35 | Box( 36 | contentAlignment = Alignment.TopCenter, 37 | modifier = Modifier 38 | .fillMaxSize() 39 | .background(AppColors.bgLight), 40 | ) { 41 | val listState = rememberLazyListState() 42 | MessageList(messages, listState) 43 | Toolbar(messages.size, listState) 44 | } 45 | } 46 | 47 | @Composable 48 | private fun Toolbar(messageCount: Int, listState: LazyListState) { 49 | val toolbarHeight = with(LocalDensity.current) { 60.dp.toPx() }.toFloat() 50 | val alpha = if (listState.firstVisibleItemIndex > 0) { 51 | 0f 52 | } else { 53 | 1 - (listState.firstVisibleItemScrollOffset / toolbarHeight) 54 | }.coerceAtLeast(0f) 55 | 56 | Row( 57 | modifier = Modifier 58 | .alpha(alpha) 59 | .background(AppColors.bgLight) 60 | .padding(top = 16.dp) 61 | .padding(vertical = 16.dp, horizontal = 24.dp), 62 | verticalAlignment = Alignment.CenterVertically, 63 | ) { 64 | Text( 65 | stringResource(R.string.messages_title), 66 | modifier = Modifier.weight(1f, fill = true), 67 | color = AppColors.textDark, 68 | fontSize = 28.sp, 69 | fontWeight = FontWeight.Medium 70 | ) 71 | Text( 72 | stringResource(R.string.messages_new_count, messageCount), 73 | modifier = Modifier.padding(top = 4.dp), 74 | color = AppColors.textMedium, 75 | fontSize = 14.sp, 76 | ) 77 | } 78 | } 79 | 80 | @Composable 81 | private fun MessageList(messages: List, listState: LazyListState) { 82 | LazyColumn( 83 | state = listState, 84 | verticalArrangement = Arrangement.spacedBy(16.dp), 85 | contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 32.dp, top = 80.dp) 86 | ) { 87 | items(messages) { message -> 88 | MessageCard(message) 89 | } 90 | } 91 | } 92 | 93 | @Composable 94 | private fun MessageCard(message: Message) { 95 | Card( 96 | shape = RoundedCornerShape(16.dp), 97 | modifier = Modifier.padding(vertical = 12.dp), 98 | elevation = 0.5.dp 99 | ) { 100 | Column { 101 | UserRow(user = message.user) 102 | MessageBody( 103 | message, 104 | modifier = Modifier.padding( 105 | start = 24.dp, 106 | end = 24.dp, 107 | bottom = 16.dp, 108 | ) 109 | ) 110 | if (message.imageUrl != null) { 111 | MessageImageAttachment( 112 | message, 113 | modifier = Modifier 114 | .padding(top = 12.dp, start = 24.dp, end = 24.dp, bottom = 24.dp) 115 | .clip(RoundedCornerShape(12.dp)) 116 | ) 117 | } 118 | } 119 | } 120 | } 121 | 122 | @Composable 123 | private fun MessageImageAttachment(message: Message, modifier: Modifier) { 124 | Image( 125 | painter = rememberCoilPainter(message.imageUrl), 126 | contentDescription = null, 127 | modifier = modifier 128 | ) 129 | } 130 | 131 | @Composable 132 | private fun MessageBody(message: Message, modifier: Modifier = Modifier) { 133 | Text( 134 | text = message.text, 135 | color = AppColors.textMedium, 136 | modifier = modifier, 137 | lineHeight = 1.7f.em, 138 | fontWeight = FontWeight.Medium, 139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /app/src/main/java/co/zsmb/cleanchat/ui/theme/Colors.kt: -------------------------------------------------------------------------------- 1 | package co.zsmb.cleanchat.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | object AppColors { 6 | val bgLight = Color.White 7 | val bgDark = Color(0xFF1A1A1A) 8 | val bgMedium = Color(0xFF323232) 9 | 10 | val textLight = Color.White 11 | val textDark = Color(0xFF393E46) 12 | val textMedium = Color(0xFF929599) 13 | 14 | val onlineIndicator = Color(0xFF19D42B) 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/co/zsmb/cleanchat/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package co.zsmb.cleanchat.ui.theme 2 | 3 | import androidx.compose.material.MaterialTheme 4 | import androidx.compose.material.Typography 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.text.TextStyle 7 | import androidx.compose.ui.text.font.Font 8 | import androidx.compose.ui.text.font.FontFamily 9 | import androidx.compose.ui.text.font.FontStyle 10 | import androidx.compose.ui.text.font.FontWeight 11 | import androidx.compose.ui.unit.sp 12 | import co.zsmb.cleanchat.R 13 | 14 | private val Metropolis = FontFamily( 15 | Font(R.font.metropolis_light, FontWeight.Light), 16 | Font(R.font.metropolis_regular, FontWeight.Normal), 17 | Font(R.font.metropolis_regular_italic, FontWeight.Normal, FontStyle.Italic), 18 | Font(R.font.metropolis_medium, FontWeight.Medium), 19 | Font(R.font.metropolis_bold, FontWeight.Bold) 20 | ) 21 | 22 | private val Typography = Typography( 23 | body1 = TextStyle( 24 | fontFamily = Metropolis, 25 | fontWeight = FontWeight.Normal, 26 | fontSize = 16.sp 27 | ) 28 | ) 29 | 30 | @Composable 31 | fun CleanChatTheme( 32 | content: @Composable () -> Unit 33 | ) { 34 | MaterialTheme( 35 | typography = Typography, 36 | content = content 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/font/metropolis_bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/font/metropolis_bold.otf -------------------------------------------------------------------------------- /app/src/main/res/font/metropolis_light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/font/metropolis_light.otf -------------------------------------------------------------------------------- /app/src/main/res/font/metropolis_medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/font/metropolis_medium.otf -------------------------------------------------------------------------------- /app/src/main/res/font/metropolis_regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/font/metropolis_regular.otf -------------------------------------------------------------------------------- /app/src/main/res/font/metropolis_regular_italic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/font/metropolis_regular_italic.otf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zsmb13/CleanChatCompose/ac9f5afb0c47f17038a865dd8f0c7b5bed87cc37/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | 11 | #FF1A1A1A 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Clean Chat 3 | 4 | 5 | Online 6 | Last activity %s 7 | 8 | 9 | Contacts 10 | Favorites 11 | Search 12 | 13 | 14 | Messages 15 | %d new messages 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 11 |