diff --git a/README.md b/README.md index 6fb643b..96d9499 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Make new friends! -Private social network to make meeting friends easier. You can learn more on our page: [getfriend.ly](https://github.com/friendly-social/android). +Private social network to make meeting friends easier. You can learn more on our page: [getfriend.ly](https://getfriend.ly). ## Download diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db9bc9e..d4c561d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -49,6 +49,10 @@ kotlin { freeCompilerArgs.add("-Xcontext-sensitive-resolution") freeCompilerArgs.add("-Xnested-type-aliases") optIn.add("kotlin.time.ExperimentalTime") + optIn.add("androidx.compose.material3.ExperimentalMaterial3Api") + optIn.add( + "androidx.compose.material3.ExperimentalMaterial3ExpressiveApi", + ) } } diff --git a/app/src/main/java/friendly/android/AddFriendByTokenScreen.kt b/app/src/main/java/friendly/android/AddFriendByTokenScreen.kt index 3275dfa..f849eb8 100644 --- a/app/src/main/java/friendly/android/AddFriendByTokenScreen.kt +++ b/app/src/main/java/friendly/android/AddFriendByTokenScreen.kt @@ -96,13 +96,10 @@ private fun NetworkError( friendToken: FriendToken, modifier: Modifier = Modifier, ) { - Column(modifier) { - Text(text = stringResource(R.string.network_error_occurred)) - Spacer(Modifier.height(16.dp)) - OutlinedButton(onClick = { vm.add(userId, friendToken) }) { - Text(text = "Retry") - } - } + NetworkErrorBox( + onRetry = { vm.add(userId, friendToken) }, + modifier = modifier, + ) } @Composable diff --git a/app/src/main/java/friendly/android/FeedScreen.kt b/app/src/main/java/friendly/android/FeedScreen.kt index 7fa12a8..6bf7eb8 100644 --- a/app/src/main/java/friendly/android/FeedScreen.kt +++ b/app/src/main/java/friendly/android/FeedScreen.kt @@ -1,5 +1,6 @@ package friendly.android +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -7,6 +8,7 @@ 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.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -51,75 +53,93 @@ sealed interface FeedScreenUiState { fun FeedScreen(vm: FeedScreenViewModel, modifier: Modifier = Modifier) { val state by vm.state.collectAsState() val isRefreshing = isRefreshing(state) + val pullToRefreshState = rememberPullToRefreshState() LaunchedEffect(Unit) { vm.loadInitial() } - Scaffold( - modifier = modifier.fillMaxSize(), - ) { innerPadding -> - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - ) { - when (val state = state) { - is FeedScreenUiState.Idle -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize(), - ) { - EmptyFeed( - isRefreshing = false, - onRefresh = vm::refresh, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) - - IndicatedCardFeed( - currentItems = state.currentFeedItems, - like = vm::like, - dislike = vm::dislike, - modifier = Modifier.fillMaxSize(), - ) - } - } - - is FeedScreenUiState.NetworkError -> { - NetworkError( - onRefresh = vm::refresh, - onRetry = vm::retry, - isRefreshing = isRefreshing, - modifier = Modifier.fillMaxSize(), - ) - } + PullToRefreshBox( + isRefreshing = isRefreshing, + state = pullToRefreshState, + onRefresh = vm::refresh, + indicator = { + PullToRefreshDefaults.LoadingIndicator( + modifier = Modifier + .safeDrawingPadding() + .align(Alignment.TopCenter), + isRefreshing = isRefreshing, + containerColor = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.onPrimaryContainer, + state = pullToRefreshState, + ) + }, + modifier = modifier, + ) { + Scaffold( + modifier = modifier.fillMaxSize(), + ) { innerPadding -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + ScaffoldContent(vm, state) + } + } + } +} - is FeedScreenUiState.EmptyFeed -> { +@Composable +private fun ScaffoldContent( + vm: FeedScreenViewModel, + state: FeedScreenUiState, +) { + AnimatedContent( + targetState = state, + ) { state -> + when (val state = state) { + is FeedScreenUiState.Idle -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { EmptyFeed( - isRefreshing = isRefreshing, - onRefresh = vm::refresh, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(16.dp), ) - } - is FeedScreenUiState.ServerError -> { - ServerError( - onRefresh = vm::refresh, - isRefreshing = isRefreshing, + IndicatedCardFeed( + currentItems = state.currentFeedItems, + like = vm::like, + dislike = vm::dislike, modifier = Modifier.fillMaxSize(), ) } + } - is FeedScreenUiState.Loading -> { - LoadingState() - } + is FeedScreenUiState.NetworkError -> { + NetworkError( + onRetry = vm::retry, + modifier = Modifier.fillMaxSize().padding(16.dp), + ) + } + + is FeedScreenUiState.EmptyFeed -> { + EmptyFeed( + modifier = Modifier.fillMaxSize().padding(16.dp), + ) + } + + is FeedScreenUiState.ServerError -> { + ServerError( + modifier = Modifier.fillMaxSize().padding(16.dp), + ) + } + + is FeedScreenUiState.Loading -> { + LoadingState() } } } @@ -134,7 +154,6 @@ private fun isRefreshing(state: FeedScreenUiState): Boolean = when (state) { } @Composable -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun LoadingState() { Box( contentAlignment = Alignment.Center, @@ -146,117 +165,55 @@ private fun LoadingState() { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun EmptyFeed( - isRefreshing: Boolean, - onRefresh: () -> Unit, - modifier: Modifier = Modifier, -) { - val pullToRefreshState = rememberPullToRefreshState() - PullToRefreshBox( - isRefreshing = isRefreshing, - state = pullToRefreshState, - onRefresh = onRefresh, - indicator = { - PullToRefreshDefaults.LoadingIndicator( - modifier = Modifier.align(Alignment.TopCenter), - isRefreshing = isRefreshing, - containerColor = MaterialTheme.colorScheme.primaryContainer, - color = MaterialTheme.colorScheme.onPrimaryContainer, - state = pullToRefreshState, - ) - }, +private fun EmptyFeed(modifier: Modifier = Modifier) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier, ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - Icon( - painter = painterResource(R.drawable.ic_inbox), - contentDescription = null, - tint = MaterialTheme.colorScheme.outline, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.you_re_all_caught_up), - style = MaterialTheme.typography.headlineSmall, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.add_more_friends_feed_text), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.outline, - ) - } + Icon( + painter = painterResource(R.drawable.ic_inbox), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(64.dp), + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.you_re_all_caught_up), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.add_more_friends_feed_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ServerError( - isRefreshing: Boolean, - onRefresh: () -> Unit, - modifier: Modifier = Modifier, -) { - val pullToRefreshState = rememberPullToRefreshState() - PullToRefreshBox( - isRefreshing = isRefreshing, - state = pullToRefreshState, - onRefresh = onRefresh, - indicator = { - PullToRefreshDefaults.LoadingIndicator( - modifier = Modifier.align(Alignment.TopCenter), - isRefreshing = isRefreshing, - containerColor = MaterialTheme.colorScheme.primaryContainer, - color = MaterialTheme.colorScheme.onPrimaryContainer, - state = pullToRefreshState, - ) - }, - modifier = modifier, +private fun ServerError(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) { - Text("Server error") - } + Text("Server error") } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun NetworkError( - onRefresh: () -> Unit, - onRetry: () -> Unit, - isRefreshing: Boolean, - modifier: Modifier = Modifier, -) { - val pullToRefreshState = rememberPullToRefreshState() - PullToRefreshBox( - isRefreshing = isRefreshing, - state = pullToRefreshState, - onRefresh = onRefresh, - indicator = { - PullToRefreshDefaults.LoadingIndicator( - modifier = Modifier.align(Alignment.TopCenter), - isRefreshing = isRefreshing, - containerColor = MaterialTheme.colorScheme.primaryContainer, - color = MaterialTheme.colorScheme.onPrimaryContainer, - state = pullToRefreshState, - ) - }, - modifier = modifier, - ) { - NetworkErrorBox( - onRetry = onRetry, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) - } +private fun NetworkError(onRetry: () -> Unit, modifier: Modifier = Modifier) { + NetworkErrorBox( + onRetry = onRetry, + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) } diff --git a/app/src/main/java/friendly/android/FriendlyApp.kt b/app/src/main/java/friendly/android/FriendlyApp.kt index e5d313f..be94fca 100644 --- a/app/src/main/java/friendly/android/FriendlyApp.kt +++ b/app/src/main/java/friendly/android/FriendlyApp.kt @@ -1,9 +1,12 @@ package friendly.android +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold @@ -13,6 +16,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination @@ -63,10 +67,11 @@ fun FriendlyApp( BottomNavigationBar( navController = navController, navigationItems = homeNavigationItems, + ) }, - modifier = modifier - .fillMaxSize(), + modifier = modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.navigationBars, ) { innerPadding -> FriendlyNavGraph( navController = navController, @@ -98,6 +103,7 @@ fun BottomNavigationBar( if (isHome) { NavigationBar( + containerColor = MaterialTheme.colorScheme.surfaceContainer, modifier = modifier, ) { for (item in navigationItems) { @@ -117,7 +123,16 @@ fun BottomNavigationBar( restoreState = true } }, - label = { Text(stringResource(item.titleResource)) }, + label = { + Text( + text = stringResource(item.titleResource), + fontWeight = if (selected) { + FontWeight.Bold + } else { + FontWeight.Normal + }, + ) + }, icon = { Icon( painter = if (selected) { diff --git a/app/src/main/java/friendly/android/Navigation.kt b/app/src/main/java/friendly/android/Navigation.kt index a32b833..bb4e009 100644 --- a/app/src/main/java/friendly/android/Navigation.kt +++ b/app/src/main/java/friendly/android/Navigation.kt @@ -1,7 +1,8 @@ package friendly.android -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel @@ -68,8 +69,8 @@ fun FriendlyNavGraph( NavHost( navController = navController, startDestination = firstDestination, - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None }, + enterTransition = { fadeIn(animationSpec = tween(150)) }, + exitTransition = { fadeOut(animationSpec = tween(150)) }, modifier = modifier, ) { composable { diff --git a/app/src/main/java/friendly/android/NetworkErrorBox.kt b/app/src/main/java/friendly/android/NetworkErrorBox.kt index 3155f6e..8279f54 100644 --- a/app/src/main/java/friendly/android/NetworkErrorBox.kt +++ b/app/src/main/java/friendly/android/NetworkErrorBox.kt @@ -4,9 +4,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -15,7 +16,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -// TODO: adjust UI accordingly to guidelines @Composable fun NetworkErrorBox(onRetry: () -> Unit, modifier: Modifier = Modifier) { Column( @@ -26,19 +26,33 @@ fun NetworkErrorBox(onRetry: () -> Unit, modifier: Modifier = Modifier) { Icon( painter = painterResource(R.drawable.ic_network_error), contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(64.dp), ) Spacer(Modifier.height(16.dp)) Text( - text = stringResource(R.string.network_error_occurred), - style = MaterialTheme.typography.bodyLarge, + text = stringResource(R.string.network_error_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(R.string.network_error_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(16.dp)) - OutlinedButton(onClick = onRetry) { - Text(stringResource(R.string.retry)) + Button( + onClick = onRetry, + modifier = Modifier.height(48.dp), + ) { + Text( + text = stringResource(R.string.retry), + style = MaterialTheme.typography.labelLarge, + ) } } } diff --git a/app/src/main/java/friendly/android/NetworkScreen.kt b/app/src/main/java/friendly/android/NetworkScreen.kt index 10d33cc..67721f2 100644 --- a/app/src/main/java/friendly/android/NetworkScreen.kt +++ b/app/src/main/java/friendly/android/NetworkScreen.kt @@ -1,44 +1,56 @@ +@file:OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, +) + package friendly.android import android.net.Uri import android.util.Log -import androidx.compose.foundation.clickable +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility 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.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets 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.safeDrawingPadding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeFlexibleTopAppBar +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.LoadingIndicator import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import friendly.android.NetworkScreenUiState.Success.FriendItem import friendly.sdk.Nickname @@ -76,10 +88,6 @@ sealed interface NetworkScreenUiState { } } -@OptIn( - ExperimentalMaterial3Api::class, - ExperimentalMaterial3ExpressiveApi::class, -) @Composable fun NetworkScreen( vm: NetworkScreenViewModel, @@ -95,53 +103,122 @@ fun NetworkScreen( val state by vm.state.collectAsState() val pullToRefreshState = rememberPullToRefreshState() + val topAppBar = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState(), + ) - LaunchedEffect(state.isRefreshing) { - println("state.isRefreshing: ${state.isRefreshing}") - } - - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(stringResource(R.string.network)) }, - actions = { - IconButton(onClick = onShare) { - Icon( - painter = painterResource(R.drawable.ic_share), - contentDescription = null, - ) - } - }, + PullToRefreshBox( + isRefreshing = state.isRefreshing, + state = pullToRefreshState, + onRefresh = { vm.refresh() }, + indicator = { + LoadingIndicator( + modifier = Modifier + .safeDrawingPadding() + .align(Alignment.TopCenter), + isRefreshing = state.isRefreshing, + containerColor = MaterialTheme.colorScheme.primaryContainer, + color = MaterialTheme.colorScheme.onPrimaryContainer, + state = pullToRefreshState, ) }, modifier = modifier.fillMaxSize(), - ) { innerPadding -> - PullToRefreshBox( - isRefreshing = state.isRefreshing, - state = pullToRefreshState, - onRefresh = { - vm.refresh() + ) { + Scaffold( + topBar = { + TopAppBar( + state = state, + topAppBar = topAppBar, + onShare = onShare, + ) }, - indicator = { - LoadingIndicator( - modifier = Modifier.align(Alignment.TopCenter), - isRefreshing = state.isRefreshing, - containerColor = MaterialTheme.colorScheme.primaryContainer, - color = MaterialTheme.colorScheme.onPrimaryContainer, - state = pullToRefreshState, + modifier = Modifier.fillMaxSize() + .nestedScroll(topAppBar.nestedScrollConnection), + ) { innerPadding -> + ScaffoldContent( + vm = vm, + state = state, + padding = innerPadding, + onProfile = onProfile, + onShare = onShare, + ) + } + } +} + +@Composable +private fun TopAppBar( + state: NetworkScreenUiState, + onShare: () -> Unit, + topAppBar: TopAppBarScrollBehavior, +) { + LargeFlexibleTopAppBar( + title = { + Text( + text = stringResource(R.string.network_title), + maxLines = 1, + overflow = Ellipsis, + ) + }, + subtitle = { + AnimatedVisibility(state is Success) { + Text( + text = stringResource(R.string.network_add_friends_hint), + maxLines = 1, + overflow = Ellipsis, + ) + } + }, + actions = { + IconButton(onClick = onShare) { + Icon( + painter = painterResource(R.drawable.ic_share), + contentDescription = null, ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + scrollBehavior = topAppBar, + ) +} + +@Composable +private fun ScaffoldContent( + vm: NetworkScreenViewModel, + state: NetworkScreenUiState, + padding: PaddingValues, + onProfile: (UserId, UserAccessHash) -> Unit, + onShare: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding), + ) { + AnimatedContent( + targetState = state, + contentKey = { state -> + when (state) { + is AuthFailure -> 0 + is NetworkFailure -> 1 + is Other -> 2 + is Loading -> 3 + is NoFriends -> 4 + is Success -> 5 + } }, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) { + ) { state -> when (val state = state) { is NetworkScreenUiState.AuthFailure -> { - Box( - contentAlignment = Alignment.Center, + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxSize(), + .fillMaxSize() + .verticalScroll(rememberScrollState()), ) { Text(stringResource(R.string.network_auth_failure_text)) } @@ -150,9 +227,7 @@ fun NetworkScreen( is NetworkScreenUiState.Loading -> { Box( contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxSize(), ) { LoadingIndicator(Modifier.size(64.dp)) } @@ -168,11 +243,12 @@ fun NetworkScreen( } is NetworkScreenUiState.Other -> { - Box( - contentAlignment = Alignment.Center, + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, modifier = Modifier - .verticalScroll(rememberScrollState()) - .fillMaxSize(), + .fillMaxSize() + .verticalScroll(rememberScrollState()), ) { Text( text = @@ -188,24 +264,27 @@ fun NetworkScreen( verticalArrangement = Arrangement.Center, modifier = Modifier .fillMaxSize() - .padding(horizontal = 48.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 48.dp), ) { Icon( painter = painterResource(R.drawable.ic_group_add), contentDescription = null, - tint = MaterialTheme.colorScheme.secondary, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(64.dp), ) - Spacer(Modifier.height(8.dp)) + + Spacer(Modifier.height(16.dp)) + Text( text = stringResource(R.string.no_friends_title), - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, ) - Spacer(Modifier.height(8.dp)) Text( text = stringResource(R.string.no_friends_text), - color = MaterialTheme.colorScheme.outline, - textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -230,12 +309,23 @@ private fun Success( onFriendClick: (FriendItem) -> Unit, modifier: Modifier = Modifier, ) { - LazyColumn(modifier) { - items( + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + itemsIndexed( items = state.friends, - key = { friend -> friend.id.long }, - ) { friend -> + key = { _, friend -> friend.id.long }, + ) { i, friend -> + if (i == 0) { + Spacer(Modifier.height(8.dp)) + } else { + Spacer(Modifier.height(2.dp)) + } FriendItem( + index = i, + count = state.friends.size, avatarUri = friend.avatar, nickname = friend.nickname, userId = friend.id, @@ -244,49 +334,39 @@ private fun Success( .fillMaxWidth() .animateItem(), ) - } - item(key = -1) { - Text( - text = "Add more friends to expand your network!", - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier - .fillMaxWidth() - .animateItem() - .padding(vertical = 16.dp), - ) + if (i == state.friends.lastIndex) { + Spacer(Modifier.height(8.dp)) + } } } } @Composable private fun FriendItem( + index: Int, + count: Int, avatarUri: Uri?, nickname: Nickname, userId: UserId, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .clickable { onClick() } - .fillMaxWidth() - .padding(16.dp), + SegmentedListItem( + onClick = onClick, + shapes = ListItemDefaults.segmentedShapes(index, count), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + leadingContent = { + UserAvatar( + nickname = nickname, + userId = userId, + uri = avatarUri, + style = Small, + ) + }, ) { - UserAvatar( - nickname = nickname, - userId = userId, - uri = avatarUri, - modifier = Modifier, - ) - - Spacer(Modifier.width(16.dp)) - - Text( - text = nickname.string, - style = MaterialTheme.typography.headlineSmall, - ) + Text(nickname.string) } } diff --git a/app/src/main/java/friendly/android/ProfileScreen.kt b/app/src/main/java/friendly/android/ProfileScreen.kt index 678f181..8125a64 100644 --- a/app/src/main/java/friendly/android/ProfileScreen.kt +++ b/app/src/main/java/friendly/android/ProfileScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -39,6 +38,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -110,8 +111,8 @@ fun ProfileScreen( Scaffold( topBar = { - CenterAlignedTopAppBar( - title = { Text(stringResource(R.string.profile)) }, + TopAppBar( + title = {}, navigationIcon = { if (source is ProfileScreenSource.FriendProfile) { IconButton(onClick = onHome) { @@ -150,6 +151,10 @@ fun ProfileScreen( ) } }, + colors = TopAppBarDefaults.topAppBarColors( + scrolledContainerColor = + MaterialTheme.colorScheme.surfaceContainer, + ), ) }, modifier = modifier.fillMaxSize(), @@ -397,7 +402,7 @@ fun LoadedProfileState( nickname = state.profile.nickname, userId = state.profile.userId, uri = state.profile.avatar, - modifier = Modifier.size(128.dp), + style = Large, ) Spacer(Modifier.height(24.dp)) diff --git a/app/src/main/java/friendly/android/Theme.kt b/app/src/main/java/friendly/android/Theme.kt index 12ab97b..e586353 100644 --- a/app/src/main/java/friendly/android/Theme.kt +++ b/app/src/main/java/friendly/android/Theme.kt @@ -2,7 +2,7 @@ package friendly.android import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -33,7 +33,7 @@ fun FriendlyTheme( else -> lightColorScheme() } - MaterialTheme( + MaterialExpressiveTheme( colorScheme = colorScheme, content = content, ) diff --git a/app/src/main/java/friendly/android/UserAvatar.kt b/app/src/main/java/friendly/android/UserAvatar.kt index e0a74b0..9f365b2 100644 --- a/app/src/main/java/friendly/android/UserAvatar.kt +++ b/app/src/main/java/friendly/android/UserAvatar.kt @@ -5,33 +5,69 @@ import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.toShape 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.layout.ContentScale -import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import coil3.compose.SubcomposeAsyncImage import friendly.sdk.Nickname import friendly.sdk.UserId +private val avatarShapes = listOf( + MaterialShapes.Circle, + MaterialShapes.Cookie9Sided, + MaterialShapes.Pentagon, + MaterialShapes.Sunny, + MaterialShapes.Cookie4Sided, + MaterialShapes.Square, + MaterialShapes.Arch, + MaterialShapes.Slanted, + MaterialShapes.Gem, + MaterialShapes.Ghostish, +) + +class UserAvatarStyle( + val size: Dp, + // The size of single letter, it must not be affected by font settings, so + // it uses dp instead of sp + val noAvatarSize: Dp, +) { + companion object { + val Large: UserAvatarStyle = UserAvatarStyle( + size = 128.dp, + noAvatarSize = 54.dp, + ) + val Medium: UserAvatarStyle = UserAvatarStyle( + size = 64.dp, + noAvatarSize = 24.dp, + ) + val Small: UserAvatarStyle = UserAvatarStyle( + size = 40.dp, + noAvatarSize = 16.dp, + ) + } +} + @Composable fun UserAvatar( nickname: Nickname, userId: UserId, uri: Uri?, - minNoAvatarTextSize: TextUnit = 36.sp, - maxNoAvatarTextSize: TextUnit = 92.sp, - noAvatarTextStepSize: TextUnit = 24.sp, + style: UserAvatarStyle, modifier: Modifier = Modifier, ) { + val shapeIndex = (userId.long % avatarShapes.size).toInt() + val shape = avatarShapes[shapeIndex].toShape() SubcomposeAsyncImage( model = uri, loading = { Box(Modifier.shimmer()) }, @@ -39,16 +75,14 @@ fun UserAvatar( EmptyAvatar( nickname = nickname, userId = userId, - minNoAvatarTextSize = minNoAvatarTextSize, - maxNoAvatarTextSize = maxNoAvatarTextSize, - noAvatarTextStepSize = noAvatarTextStepSize, + style = style, ) }, contentDescription = null, contentScale = ContentScale.FillBounds, modifier = modifier - .size(64.dp) - .clip(CircleShape), + .size(style.size) + .clip(shape), ) } @@ -60,9 +94,7 @@ fun UserAvatar( private fun EmptyAvatar( nickname: Nickname, userId: UserId, - minNoAvatarTextSize: TextUnit, - maxNoAvatarTextSize: TextUnit, - noAvatarTextStepSize: TextUnit, + style: UserAvatarStyle, modifier: Modifier = Modifier, ) { val textColor = MaterialTheme.colorScheme.onBackground @@ -70,23 +102,21 @@ private fun EmptyAvatar( Box( contentAlignment = Alignment.Center, modifier = modifier - .clip(CircleShape) .background( Color.pastelFromLong( long = userId.long, useDark = isSystemInDarkTheme(), ), ) - .size(64.dp), + .size(style.size), ) { + val fontSize = with(LocalDensity.current) { + style.noAvatarSize.toSp() + } BasicText( text = getLettersFromNickname(nickname), color = { textColor }, - autoSize = TextAutoSize.StepBased( - minFontSize = minNoAvatarTextSize, - maxFontSize = maxNoAvatarTextSize, - stepSize = noAvatarTextStepSize, - ), + style = TextStyle(fontSize = fontSize), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb27960..c0ae7ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,12 +14,15 @@ generate Feed Network + Friends + Add more friends to expand your network! Profile Copy Copy social link Back Friend token has been expired. Try generating new QR code - Network error occurred. + Connection problem + Looks like we can\'t reach our servers... You should authorize to start adding friends Unknown error occurred. Success. Soon here would be navigation to the user you were added, but for now you can go home @@ -28,13 +31,13 @@ You should authorize before you be able to see your network Retry Share your profile - Add more friends and ask them to add their friends to make feed more interesting + Now you can talk to the people you\'ve met The feed is empty Refresh MockDataActivity - You have no friends - Add them via QR code to extend your network - You\'re all caught up + No one is here + Add friends by sharing your QR code in the top corner + You\'re all caught up! Expand Cancel Log out diff --git a/cards/build.gradle.kts b/cards/build.gradle.kts index 3465947..34ebafd 100644 --- a/cards/build.gradle.kts +++ b/cards/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { freeCompilerArgs.add("-Xcontext-sensitive-resolution") freeCompilerArgs.add("-Xnested-type-aliases") optIn.add("kotlin.time.ExperimentalTime") + optIn.add("androidx.compose.material3.ExperimentalMaterial3Api") } } diff --git a/cards/src/main/kotlin/friendly/cards/LazySwipeableCards.kt b/cards/src/main/kotlin/friendly/cards/LazySwipeableCards.kt index ba9ecac..3ede2e0 100644 --- a/cards/src/main/kotlin/friendly/cards/LazySwipeableCards.kt +++ b/cards/src/main/kotlin/friendly/cards/LazySwipeableCards.kt @@ -98,10 +98,7 @@ fun LazySwipeableCards( .onGloballyPositioned { state.onSizeChange(it.size) } - .padding( - end = properties.padding, - top = properties.padding.div(2), - ), + .padding(properties.padding), itemProvider = { itemProvider }, ) { constraints -> diff --git a/cards/src/main/kotlin/friendly/cards/SwipeableCardsProperties.kt b/cards/src/main/kotlin/friendly/cards/SwipeableCardsProperties.kt index 413faf4..3367671 100644 --- a/cards/src/main/kotlin/friendly/cards/SwipeableCardsProperties.kt +++ b/cards/src/main/kotlin/friendly/cards/SwipeableCardsProperties.kt @@ -1,5 +1,6 @@ package friendly.cards +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -11,7 +12,7 @@ object SwipeableCardsDefaults { const val DRAGGING_ACCELERATION = 1f val STACKED_CARDS_OFFSET = 30.dp val SWIPE_THRESHOLD = 100.dp - val PADDING = 10.dp + val PADDING = PaddingValues(16.dp) } /** @@ -26,7 +27,7 @@ object SwipeableCardsDefaults { * @property draggingAcceleration Multiplier for drag gesture sensitivity. Higher values make cards more responsive to drag gestures. Defaults to [SwipeableCardsDefaults.DRAGGING_ACCELERATION]. */ data class SwipeableCardsProperties( - val padding: Dp = SwipeableCardsDefaults.PADDING, + val padding: PaddingValues = SwipeableCardsDefaults.PADDING, val swipeThreshold: Dp = SwipeableCardsDefaults.SWIPE_THRESHOLD, val lockBelowCardDragging: Boolean = SwipeableCardsDefaults.LOCK_BELOW_CARD_DRAGGING,