5 Commits

Author SHA1 Message Date
melod1n 389d3f9e52 Style: Update message bubble colors
Updates the container colors for incoming and outgoing message bubbles to align with Material 3 design tokens.

- The outgoing message bubble container color is changed from `surfaceColorAtElevation(2.dp)` to `surfaceContainer`.
- The reply container color within an outgoing message is changed from `primaryContainer` to `surfaceContainerHighest`.

Additionally, the `@Preview` for `MessageBubble` is updated to display both an incoming and an outgoing message for better design validation.
2025-12-15 23:08:42 +03:00
melod1n 69a50f8fcd Refactor: Encapsulate MessageBubble colors
This commit refactors the `MessageBubble` composable by extracting the color logic into a private `messageBubbleColors` function. This function returns an immutable `MessageBubbleColors` data class, which holds the container, content, and reply container colors.

This change cleans up the main composable, improves readability, and centralizes color definitions for both incoming and outgoing messages. Additionally, the background color logic for attachments has been simplified to make it transparent for media types like stickers and videos.
2025-12-15 23:01:38 +03:00
melod1n 8839015249 Fix: Ensure sender's name truncates correctly in incoming messages
This commit resolves an issue where the sender's name in incoming message bubbles would not truncate properly, potentially breaking the layout. The fix ensures the name text correctly adapts to the width of the message bubble.

Additionally, this change introduces `ImmutableList` for message attachments to improve performance and refactors where the conversion to `ImmutableList` happens, moving it into the `MessageMapper`.

Key changes:
- The `MessageBubble` now reports its width, allowing the sender's name `Text` to be constrained correctly.
- Sender's name now uses `labelMedium` typography.
- Enabled showing the sender's name by default in `MessagesHistoryViewModelImpl`.
- Changed `UiItem.Message.attachments` from `List` to `ImmutableList` for better Compose performance.
- Moved the `toImmutableList()` conversion for attachments into the `MessageMapper`.
2025-12-15 22:58:50 +03:00
melod1n 478639e427 Refactor: Extract RootScreen from MainActivity and fix reply message user
This commit refactors the UI composition logic by extracting it from `MainActivity` into a new, dedicated `RootScreen` composable. This improves the separation of concerns and simplifies `MainActivity`.

Additionally, a bug has been fixed where a replied-to message would incorrectly display the author of the parent message instead of its own author.

Key changes:
- Moved theme setup, permission handling, Long-Poll/Online service management, and navigation graph hosting into the new `RootScreen.kt`.
- `MainActivity` is now significantly simplified, delegating its UI composition to `RootScreen`.
- Corrected the user and group assignment for `replyMessage` in `MessagesRepositoryImpl` to ensure the correct author is displayed.
- Introduced `OnlineFriendsViewModel` to the `FriendsRoute` to separate the logic for online friends.
- Replaced `List` with a custom `ImmutableList` for `photoViewerInfo` state to improve Compose stability.
2025-12-15 22:24:17 +03:00
dependabot[bot] dcbfd43896 Chore(deps): Bump actions/upload-artifact from 5 to 6 (#249)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 21:39:44 +03:00
14 changed files with 473 additions and 398 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
@@ -56,7 +56,7 @@ jobs:
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
run: ./gradlew assembleRelease
- name: Upload release APK
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
@@ -43,7 +43,7 @@ jobs:
run: ./gradlew bundleRelease
- name: Upload release Bundle
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: app-release.aab
path: app/build/outputs/bundle/release/app-release.aab
@@ -4,7 +4,6 @@ import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
@@ -15,43 +14,20 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.CompositionLocalProvider
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.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalResources
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.service.OnlineService
import dev.meloda.fast.service.longpolling.LongPollingService
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
class MainActivity : AppCompatActivity() {
@@ -88,168 +64,26 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions()
setContent {
val resources = LocalResources.current
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "onCreate: viewModel: $viewModel")
}
LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false)
}
RootScreen(
toggleLongPollService = { enable, inBackground ->
toggleLongPollService(
enable = true,
inBackground = true
enable = enable,
inBackground = inBackground ?: AppSettings.Experimental.longPollInBackground
)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
enable = longPollStateToApply.isLaunched(),
inBackground = longPollStateToApply == LongPollState.Background
},
toggleOnlineService = ::toggleOnlineService
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
RootScreen(viewModel = viewModel)
}
}
}
}
private fun createNotificationChannels() {
@@ -279,7 +113,7 @@ class MainActivity : AppCompatActivity() {
}
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannels(
listOf(
@@ -1,8 +1,10 @@
package dev.meloda.fast.presentation
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -15,44 +17,225 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.conena.nanokt.android.content.pxToDp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.conversations.navigation.createChatScreen
import dev.meloda.fast.conversations.navigation.navigateToCreateChat
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.navigation.Main
import dev.meloda.fast.navigation.mainScreen
import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.settings.navigation.navigateToSettings
import dev.meloda.fast.settings.navigation.settingsScreen
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.LocalNavRootController
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.immutableListOf
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun RootScreen(
navController: NavHostController = rememberNavController(),
viewModel: MainViewModel
toggleLongPollService: (enable: Boolean, inBackground: Boolean?) -> Unit,
toggleOnlineService: (enable: Boolean) -> Unit
) {
val resources = LocalResources.current
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
LaunchedEffect(viewModel) {
Log.d("VM_CREATE", "RootScreen(): viewModel: $viewModel")
}
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false, null)
}
toggleLongPollService(true, true)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false, null)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
longPollStateToApply.isLaunched(),
longPollStateToApply == LongPollState.Background
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
val navController: NavHostController = rememberNavController()
val activity = LocalActivity.current
val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
@@ -126,7 +309,7 @@ fun RootScreen(
LocalNavController provides navController
) {
var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<List<String>, Int?>?>(null)
mutableStateOf<Pair<ImmutableList<String>, Int?>?>(null)
}
Box(modifier = Modifier.fillMaxSize()) {
@@ -149,7 +332,9 @@ fun RootScreen(
onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> photoViewerInfo = listOf(url) to null },
onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null
},
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat
)
@@ -159,12 +344,14 @@ fun RootScreen(
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos to index
photoViewerInfo = photos.toImmutableList() to index
}
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url -> photoViewerInfo = listOf(url) to null }
onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null
}
)
createChatScreen(
onChatCreated = { conversationId ->
@@ -197,6 +384,8 @@ fun RootScreen(
}
}
}
}
}
}
fun NavController.navigateToMain() {
@@ -92,12 +92,14 @@ class MessagesRepositoryImpl(
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage.let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
).also { VkMemoryCache[message.id] = it }
}
}
@@ -167,12 +169,14 @@ class MessagesRepositoryImpl(
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.asDomain()?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.asDomain().let { replyMessage ->
replyMessage?.copy(
user = usersMap.messageUser(replyMessage),
group = groupsMap.messageGroup(replyMessage),
actionUser = usersMap.messageActionUser(replyMessage),
actionGroup = groupsMap.messageActionGroup(replyMessage),
)
}
)
}
@@ -5,6 +5,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.friends.FriendsViewModel
import dev.meloda.fast.friends.FriendsViewModelImpl
import dev.meloda.fast.friends.OnlineFriendsViewModelImpl
import dev.meloda.fast.friends.presentation.FriendsRoute
import dev.meloda.fast.model.BaseError
import kotlinx.serialization.Serializable
@@ -20,14 +21,14 @@ fun NavGraphBuilder.friendsScreen(
onMessageClicked: (userId: Long) -> Unit,
onScrolledToTop: () -> Unit
) {
val friendsViewModel: FriendsViewModel = with(activity) {
getViewModel<FriendsViewModelImpl>()
}
val friendsViewModel: FriendsViewModel = activity.getViewModel<FriendsViewModelImpl>()
val onlineFriendsViewModel =
activity.getViewModel<OnlineFriendsViewModelImpl>()
composable<Friends> {
FriendsRoute(
activity = activity,
friendsViewModel = friendsViewModel,
onlineFriendsViewModel = onlineFriendsViewModel,
onError = onError,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
@@ -1,6 +1,5 @@
package dev.meloda.fast.friends.presentation
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.animateFloatAsState
@@ -54,17 +53,16 @@ import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.getViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun FriendsRoute(
activity: AppCompatActivity,
friendsViewModel: FriendsViewModel,
onlineFriendsViewModel: OnlineFriendsViewModelImpl,
onError: (BaseError) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userid: Long) -> Unit,
onScrolledToTop: () -> Unit
onScrolledToTop: () -> Unit,
) {
val scope = rememberCoroutineScope()
val currentTheme = LocalThemeConfig.current
@@ -232,9 +230,7 @@ fun FriendsRoute(
modifier = Modifier.fillMaxSize(),
) { index ->
FriendsScreen(
viewModel = if (index == 0) friendsViewModel else with(activity) {
getViewModel<OnlineFriendsViewModelImpl>()
},
viewModel = if (index == 0) friendsViewModel else onlineFriendsViewModel,
orderType = orderType,
padding = padding,
tabIndex = index,
@@ -1231,7 +1231,7 @@ class MessagesHistoryViewModelImpl(
val newUiMessages = messages.mapIndexed { index, message ->
message.asPresentation(
resourceProvider = resourceProvider,
showName = false,
showName = true,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages,
@@ -1,14 +1,17 @@
package dev.meloda.fast.messageshistory.model
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.util.ImmutableList
sealed class UiItem(
open val id: Long,
open val cmId: Long
) {
@Stable
data class Message(
override val id: Long,
override val cmId: Long,
@@ -29,12 +32,13 @@ sealed class UiItem(
val isSelected: Boolean,
val isPinned: Boolean,
val isImportant: Boolean,
val attachments: List<VkAttachment>?,
val attachments: ImmutableList<VkAttachment>?,
val replyCmId: Long?,
val replyTitle: String?,
val replySummary: String?
) : UiItem(id, cmId)
@Stable
data class ActionMessage(
override val id: Long,
override val cmId: Long,
@@ -16,6 +16,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -27,6 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
@@ -58,7 +60,8 @@ fun MessageBubble(
replySummary: String? = null,
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {}
onReplyClick: () -> Unit = {},
onBubbleWidthChange: (Int) -> Unit = {},
) {
val density = LocalDensity.current
@@ -66,22 +69,7 @@ fun MessageBubble(
val currentOnLongClick by rememberUpdatedState(onLongClick)
val theme = LocalThemeConfig.current
val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
} else {
MaterialTheme.colorScheme.primaryContainer
}
val replyBackgroundColor = if (!isOut) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.inversePrimary
}
val contentColor = if (!isOut) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onPrimaryContainer
}
val colors = messageBubbleColors(isOut = isOut)
val shouldShowBubble = !text.isNullOrEmpty()
@@ -115,7 +103,7 @@ fun MessageBubble(
label = "dateContainerWidth"
)
CompositionLocalProvider(LocalContentColor provides contentColor) {
CompositionLocalProvider(LocalContentColor provides colors.content) {
Column(
modifier = modifier
.wrapContentWidth()
@@ -138,8 +126,8 @@ fun MessageBubble(
onClick = onReplyClick,
title = replyTitle,
summary = replySummary,
backgroundColor = backgroundColor,
innerBackgroundColor = replyBackgroundColor
backgroundColor = colors.container,
innerBackgroundColor = colors.replyContainer
)
}
@@ -149,6 +137,9 @@ fun MessageBubble(
.onGloballyPositioned {
bubbleContainerWidth = it.size.width
}
.onSizeChanged {
onBubbleWidthChange(it.width)
}
.widthIn(min = if (shouldFill) attachmentsContainerWidth.dp else 56.dp)
.clip(
RoundedCornerShape(
@@ -158,7 +149,7 @@ fun MessageBubble(
bottomEnd = if (attachments != null) 0.dp else 24.dp
)
)
.background(backgroundColor)
.background(colors.container)
.padding(
start = 8.dp,
end = 8.dp,
@@ -199,6 +190,15 @@ fun MessageBubble(
}
if (attachments != null) {
val firstAttachment = attachments.firstOrNull()
val isMediaAttachment = firstAttachment is VkStickerDomain ||
firstAttachment is VkVideoMessageDomain
val attachmentBackgroundColor = if (isMediaAttachment) {
Color.Transparent
} else {
colors.container
}
Box(
modifier = Modifier
.onGloballyPositioned {
@@ -213,18 +213,7 @@ fun MessageBubble(
topEnd = 0.dp
)
)
.background(
backgroundColor.copy(
alpha = if ((attachments.firstOrNull()?.javaClass
?: Nothing::class.java)
in listOf(
VkStickerDomain::class.java,
VkVideoMessageDomain::class.java
)
) 0f
else 1f
)
)
.background(attachmentBackgroundColor)
) {
Attachments(
modifier = Modifier,
@@ -236,7 +225,7 @@ fun MessageBubble(
val dateStatusBackground = if (theme.darkMode) Color.Black.copy(alpha = 0.5f)
else Color.White.copy(alpha = 0.5f)
CompositionLocalProvider(LocalContentColor provides contentColor) {
CompositionLocalProvider(LocalContentColor provides colors.content) {
DateStatus(
modifier = Modifier
.align(Alignment.BottomEnd)
@@ -261,9 +250,34 @@ fun MessageBubble(
}
}
@Immutable
private data class MessageBubbleColors(
val container: Color,
val content: Color,
val replyContainer: Color,
)
@Composable
private fun messageBubbleColors(isOut: Boolean): MessageBubbleColors {
return if (isOut) {
MessageBubbleColors(
container = MaterialTheme.colorScheme.primaryContainer,
content = MaterialTheme.colorScheme.onPrimaryContainer,
replyContainer = MaterialTheme.colorScheme.inversePrimary
)
} else {
MessageBubbleColors(
container = MaterialTheme.colorScheme.surfaceContainer,
content = MaterialTheme.colorScheme.onSurface,
replyContainer = MaterialTheme.colorScheme.surfaceContainerHighest
)
}
}
@Preview
@Composable
private fun Bubble() {
Column {
MessageBubble(
modifier = Modifier,
text = AnnotatedString("Some cool text"),
@@ -281,4 +295,23 @@ private fun Bubble() {
onClick = {},
onLongClick = {},
)
MessageBubble(
modifier = Modifier,
text = AnnotatedString("Some cool text"),
isOut = false,
date = "19:01",
isEdited = true,
isRead = true,
sendingStatus = SendingStatus.SENT,
isPinned = true,
isImportant = true,
isSelected = false,
attachments = emptyImmutableList(),
replyTitle = "Danil Nikolaev",
replySummary = "2 photos",
onClick = {},
onLongClick = {},
)
}
}
@@ -20,12 +20,16 @@ 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.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
@@ -36,7 +40,6 @@ import com.conena.nanokt.android.content.dpInPx
import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlin.math.roundToInt
@Composable
@@ -49,10 +52,16 @@ fun IncomingMessageBubble(
onLongClick: (VkAttachment) -> Unit = {},
onReplyClick: () -> Unit = {}
) {
val density = LocalDensity.current
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
val currentOnReplyClick by rememberUpdatedState(onReplyClick)
var bubbleContainerWidth by remember {
mutableStateOf(0.dp)
}
Row(
modifier = modifier
.fillMaxWidth()
@@ -103,9 +112,12 @@ fun IncomingMessageBubble(
Text(
modifier = Modifier
.padding(start = 12.dp)
.widthIn(max = 140.dp),
.widthIn(
max = (bubbleContainerWidth.takeIf { it > 0.dp }
?: 140.dp) - 24.dp
),
text = message.name,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -123,14 +135,16 @@ fun IncomingMessageBubble(
isPinned = message.isPinned,
isImportant = message.isImportant,
isSelected = message.isSelected,
attachments = message.attachments?.toImmutableList(),
attachments = message.attachments,
replyTitle = message.replyTitle,
replySummary = message.replySummary,
onClick = currentOnClick,
onLongClick = currentOnLongClick,
onReplyClick = currentOnReplyClick
onReplyClick = currentOnReplyClick,
onBubbleWidthChange = {
bubbleContainerWidth = with(density) { it.toDp() }
}
)
}
}
Spacer(modifier = Modifier.fillMaxWidth(0.25f))
@@ -80,7 +80,7 @@ fun OutgoingMessageBubble(
isPinned = message.isPinned,
isImportant = message.isImportant,
isSelected = message.isSelected,
attachments = message.attachments?.toImmutableList(),
attachments = message.attachments,
replyTitle = message.replyTitle,
replySummary = message.replySummary,
onClick = currentOnClick,
@@ -24,6 +24,7 @@ import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import java.text.SimpleDateFormat
import java.util.Locale
@@ -156,7 +157,7 @@ fun VkMessage.asPresentation(
isSelected = isSelected,
isPinned = isPinned,
isImportant = isImportant,
attachments = attachments?.ifEmpty { null },
attachments = attachments?.ifEmpty { null }?.toImmutableList(),
replyCmId = replyMessage?.cmId,
replyTitle = extractReplyTitle(),
replySummary = extractReplySummary()
@@ -1,6 +1,5 @@
package dev.meloda.fast.photoviewer.presentation
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
@@ -59,9 +58,9 @@ import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenDialog
import dev.meloda.fast.ui.components.Loader
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.getImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import java.net.URLEncoder
@@ -69,7 +68,7 @@ import kotlin.math.abs
@Composable
fun PhotoViewDialog(
photoViewerInfo: Pair<List<String>, Int?>?,
photoViewerInfo: Pair<ImmutableList<String>, Int?>?,
modifier: Modifier = Modifier,
onDismiss: () -> Unit
) {
@@ -85,7 +84,7 @@ fun PhotoViewDialog(
arguments = PhotoViewArguments(
imageUrls = photoViewerInfo.first.map {
URLEncoder.encode(it, "utf-8")
},
}.toList(),
selectedIndex = photoViewerInfo.second
),
applicationContext = applicationContext