13 Commits

Author SHA1 Message Date
melod1n f11b8dc6f4 refactor: consolidate convos state and intent handling 2026-05-30 15:39:43 +03:00
melod1n 167f980f29 refactor StateFlow exposure in ConvosViewModel 2026-05-30 12:01:06 +03:00
melod1n d428af4ac4 refactor: simplify Profile feature state management and update ViewModel 2026-05-30 11:46:14 +03:00
melod1n 63bae014c5 feat: replace settings icon button with segmented buttons in ProfileScreen 2026-05-30 11:43:48 +03:00
melod1n ad54477d11 feat: add segmented buttons to friends screen 2026-05-30 11:39:10 +03:00
melod1n c8bd485724 feat: add custom segmented buttons and refactor conversation screen actions 2026-05-30 11:33:32 +03:00
melod1n 26a0630393 Replace ACRA with custom crash dialog
* Add uncaught exception handler that saves stacktraces to local crash log files
* Show a Compose crash dialog with stacktrace toggle and share action
* Register crash handler activity in a separate process with dialog theme
* Remove ACRA dependencies and configuration
* Add crash dialog strings and ignore `.hotswan/`
2026-05-23 21:59:12 +03:00
melod1n 3d153df79c Update README.md 2026-05-23 08:58:52 +03:00
melod1n 9061a39407 update Gitea workflow 2026-05-23 08:58:52 +03:00
melod1n d8c8820b32 add Gitea workflow 2026-05-22 20:45:42 +03:00
melod1n abfe25d051 fix signing 2026-05-22 17:46:56 +03:00
melod1n 574b230b26 feat: add channel message support and refactor UI components
- Implement `VkChannelMessage` domain and data models for channel message attachments
- Add `CHANNEL_MESSAGE` to `AttachmentType` and map it to relevant UI resources and strings
- Refactor `Sticker` and `Gift` composables to accept URL strings instead of domain objects for better decoupling
- Simplify `AppTheme` by removing redundant color animations and passing the color scheme directly to `MaterialExpressiveTheme`
- Update `LoginScreen` to include a theme toggle (classic vs. light) and improve back-button behavior by resetting error states
- Bump VK API version from 5.238 to 5.263
- Adjust layout and padding for sticker and gift attachment previews
2026-05-19 22:54:44 +03:00
melod1n b31c0f30c5 build: update gradle wrapper and build logic conventions 2026-05-19 13:28:10 +03:00
52 changed files with 1004 additions and 630 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Android CI
on:
workflow_dispatch:
permissions:
contents: read
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}
RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}
jobs:
android:
runs-on: android-jdk21
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Make Gradle executable
run: chmod +x ./gradlew
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Find generated release APK name
id: find_apk_release
run: |
APK_PATH=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Find generated debug APK name
id: find_apk_debug
run: |
APK_PATH=$(find app/build/outputs/apk/debug -name "*.apk" | head -n 1)
echo "APK_PATH=$APK_PATH" >> $GITEA_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITEA_ENV
- name: Upload APK with original name
uses: christopherhx/gitea-upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
+2
View File
@@ -15,3 +15,5 @@ build/
local.properties
.idea
/.kotlin
.hotswan/
.java-version
+1
View File
@@ -43,6 +43,7 @@ Unofficial messenger for russian social network VKontakte
- [ ] Forwarded messages
- [ ] Wall post
- [ ] Comment in wall post
- [ ] Comment in channel
- [ ] Poll
- [ ] TODO
- [x] Send messages
+1 -4
View File
@@ -47,7 +47,7 @@ android {
applicationIdSuffix = ".debug"
}
named("release") {
signingConfig = signingConfigs.getByName("release")
signingConfig = signingConfigs.getByName("debugSigning")
isMinifyEnabled = true
isShrinkResources = true
@@ -79,9 +79,6 @@ android {
}
dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials)
+8 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -37,6 +37,12 @@
</intent-filter>
</activity>
<activity
android:name="dev.meloda.fast.presentation.CrashActivity"
android:exported="false"
android:process=":error_handler"
android:theme="@style/CrashDialogTheme" />
<service
android:name="dev.meloda.fast.service.longpolling.LongPollingService"
android:enabled="true"
@@ -1,6 +1,8 @@
package dev.meloda.fast.common
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.preference.PreferenceManager
import coil.ImageLoader
import coil.ImageLoaderFactory
@@ -8,14 +10,14 @@ import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings
import org.acra.config.dialog
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import dev.meloda.fast.presentation.CrashActivity
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext.startKoin
import java.io.File
import java.io.FileOutputStream
import kotlin.system.exitProcess
class AppGlobal : Application(), ImageLoaderFactory {
@@ -27,7 +29,7 @@ class AppGlobal : Application(), ImageLoaderFactory {
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin()
initAcra()
initCrashHandler()
}
override fun newImageLoader(): ImageLoader = get()
@@ -40,20 +42,36 @@ class AppGlobal : Application(), ImageLoaderFactory {
}
}
private fun initAcra() {
initAcra {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
mailSender {
mailTo = "lischenkodev@gmail.com"
reportAsFile = true
reportFileName = "Crash.txt"
private fun initCrashHandler() {
val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
val crashLogsDirectory = File(filesDir.absolutePath + "/crashlogs")
if (!crashLogsDirectory.exists()) {
crashLogsDirectory.mkdirs()
}
dialog {
text = "App crashed"
enabled = true
val crashLogFileName = "crash_" + System.currentTimeMillis() + ".txt"
val crashLogFile = File(crashLogsDirectory.absolutePath + "/" + crashLogFileName)
FileOutputStream(crashLogFile).use { stream ->
stream.write(throwable.stackTraceToString().toByteArray())
}
if (AppSettings.Debug.showAlertAfterCrash) {
try {
val intent = Intent(this, CrashActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
intent.putExtra("CRASH_LOG_FILE_URI", Uri.fromFile(crashLogFile))
startActivity(intent)
exitProcess(0)
} catch (e: Exception) {
if (e !is RuntimeException) {
defaultExceptionHandler?.uncaughtException(thread, throwable)
}
}
} else {
defaultExceptionHandler?.uncaughtException(thread, throwable)
}
}
}
@@ -0,0 +1,35 @@
package dev.meloda.fast.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog
@Composable
fun AppCrashedDialog(
stacktrace: String,
onDismiss: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
) {
var showTrace by rememberSaveable { mutableStateOf(false) }
MaterialDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = stringResource(R.string.title_error),
text = if (showTrace) stacktrace else stringResource(R.string.error_occurred),
confirmText = stringResource(R.string.action_share),
confirmAction = onShare,
cancelText = stringResource(if (showTrace) R.string.action_hide_stacktrace else R.string.action_show_stacktrace),
cancelAction = { showTrace = !showTrace },
neutralText = stringResource(R.string.action_close),
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
)
}
@@ -0,0 +1,71 @@
package dev.meloda.fast.presentation
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.compose.koinInject
import java.io.File
class CrashActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val crashLogFileUri = intent.getParcelableExtra<Uri>("CRASH_LOG_FILE_URI") ?: run {
finish()
return
}
val crashLogFile = crashLogFileUri.toFile().takeIf(File::exists) ?: run {
finish()
return
}
val stacktrace = crashLogFile.bufferedReader().readText()
setContent {
val userSettings: UserSettings = koinInject()
AppTheme(
useDarkTheme = isNeedToEnableDarkMode(darkMode = userSettings.darkMode.collectAsState().value),
useDynamicColors = userSettings.enableDynamicColors.collectAsState().value,
selectedColorScheme = 0,
useAmoledBackground = userSettings.enableAmoledDark.collectAsState().value,
useSystemFont = userSettings.useSystemFont.collectAsState().value
) {
AppCrashedDialog(
stacktrace = stacktrace,
onDismiss = { finish() },
onShare = {
val uri = FileProvider.getUriForFile(
this,
"$packageName.provider",
crashLogFile
)
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
}
val chooserIntent = Intent.createChooser(sendIntent, null)
chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
startActivity(chooserIntent)
}
)
}
}
}
}
@@ -38,7 +38,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen
@@ -198,19 +198,20 @@ fun MainScreen(
},
)
convosGraph(
handleNavigationIntent = { intent ->
when (intent) {
ConvoNavigationIntent.Back -> {}
ConvoNavigationIntent.Archive -> {}
ConvoNavigationIntent.CreateChat -> onNavigateToCreateChat()
is ConvoNavigationIntent.MessagesHistory -> {
onNavigateToMessagesHistory(intent.convoId)
}
}
},
activity = activity,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also {
it[ConvoGraph] = false
}
}
)
profileScreen(
activity = activity,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked
)
@@ -19,6 +19,9 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
lint {
abortOnError = false
}
}
}
}
@@ -2,6 +2,7 @@ import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -20,7 +21,13 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
androidResources.enable = false
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
defaultConfig {
minSdk = getVersionInt("minSdk")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
lint {
abortOnError = false
}
}
extensions.configure<LibraryAndroidComponentsExtension> {
disableUnnecessaryAndroidTests(target)
@@ -4,7 +4,7 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.238"
const val API_VERSION = "5.263"
const val URL_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.ru/method"
@@ -598,6 +598,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
AttachmentType.VIDEO_MESSAGE -> null
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24
AttachmentType.STICKER_PACK_PREVIEW -> null
AttachmentType.CHANNEL_MESSAGE -> null
}?.let(UiImage::Resource)
}
@@ -687,6 +688,7 @@ fun getAttachmentUiText(
AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message
AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker
AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview
AttachmentType.CHANNEL_MESSAGE -> R.string.message_attachments_channel_message
}.let(UiText::Resource)
}
@@ -30,7 +30,8 @@ enum class AttachmentType(var value: String) {
ARTICLE("article"),
VIDEO_MESSAGE("video_message"),
GROUP_CHAT_STICKER("ugc_sticker"),
STICKER_PACK_PREVIEW("sticker_pack_preview")
STICKER_PACK_PREVIEW("sticker_pack_preview"),
CHANNEL_MESSAGE("channel_message")
;
fun isMultiple(): Boolean = this in listOf(PHOTO, VIDEO, AUDIO, FILE)
@@ -35,7 +35,8 @@ data class VkAttachmentItemData(
@Json(name = "article") val article: VkArticleData?,
@Json(name = "video_message") val videoMessage: VkVideoMessageData?,
@Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?,
@Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData?
@Json(name = "sticker_pack_preview") val stickerPackPreview: VkStickerPackPreviewData?,
@Json(name = "channel_message") val channelMessageData: VkChannelMessageData?
) {
fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) {
AttachmentType.UNKNOWN -> VkUnknownAttachment
@@ -66,5 +67,6 @@ data class VkAttachmentItemData(
AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain()
AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain()
AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain()
AttachmentType.CHANNEL_MESSAGE -> channelMessageData?.toDomain()
} ?: VkUnknownAttachment
}
@@ -0,0 +1,40 @@
package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkChannelMessage
@JsonClass(generateAdapter = true)
data class VkChannelMessageData(
@Json(name = "channel_id") val channelId: Long,
@Json(name = "cmid") val cmId: Long,
@Json(name = "author_id") val authorId: Long,
@Json(name = "channel_info") val channelInfo: ChannelInfo,
@Json(name = "channel_type") val channelType: String,
@Json(name = "guid") val guid: String,
@Json(name = "text") val text: String?,
@Json(name = "time") val time: Long,
@Json(name = "attachments") val attachments: List<VkAttachmentItemData> = emptyList(),
) : VkAttachmentData {
@JsonClass(generateAdapter = true)
data class ChannelInfo(
@Json(name = "photo_base") val photoBase: String?,
@Json(name = "title") val title: String
)
fun toDomain(): VkChannelMessage = VkChannelMessage(
channelId = channelId,
cmId = cmId,
authorId = authorId,
channelInfo = VkChannelMessage.ChannelInfo(
title = channelInfo.title,
photoBase = channelInfo.photoBase
),
channelType = channelType,
guid = guid,
text = text,
time = time,
attachments = attachments.map(VkAttachmentItemData::toDomain),
)
}
@@ -0,0 +1,23 @@
package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType
data class VkChannelMessage(
val channelId: Long,
val cmId: Long,
val authorId: Long,
val channelInfo: ChannelInfo,
val channelType: String,
val guid: String,
val text: String?,
val time: Long,
val attachments: List<VkAttachment>?,
) : VkAttachment {
data class ChannelInfo(
val title: String,
val photoBase: String?
)
override val type: AttachmentType = AttachmentType.CHANNEL_MESSAGE
}
@@ -1,6 +1,7 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Indication
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -20,6 +21,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -30,27 +33,32 @@ fun FastIconButton(
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors(),
containerColor: Color = colors.containerColor(enabled),
contentColor: Color = colors.contentColor(enabled),
size: Dp = IconButtonTokens.StateLayerSize,
shape: Shape = IconButtonTokens.StateLayerShape,
alignment: Alignment = Alignment.Center,
interactionSource: MutableInteractionSource? = null,
indication: Indication = ripple(),
content: @Composable () -> Unit
) {
Box(
modifier =
modifier
.minimumInteractiveComponentSize()
.size(IconButtonTokens.StateLayerSize)
.clip(IconButtonTokens.StateLayerShape)
.background(color = colors.containerColor(enabled))
.size(size)
.clip(shape)
.background(containerColor)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled,
interactionSource = interactionSource,
indication = ripple()
indication = indication
),
contentAlignment = Alignment.Center
contentAlignment = alignment
) {
val contentColor = colors.contentColor(enabled)
CompositionLocalProvider(LocalContentColor provides contentColor, content = content)
CompositionLocalProvider(LocalContentColor provides contentColor) { content() }
}
}
@@ -0,0 +1,116 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.util.ImmutableList
data class SegmentedButtonItem(
val key: String,
val iconResId: Int
)
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<SegmentedButtonItem>,
onClick: (index: Int) -> Unit,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
iconContainerWidth: Dp = 42.dp,
iconContainerHeight: Dp = 36.dp,
iconSize: Dp = 18.dp,
showDividers: Boolean = true
) {
SegmentedButtonsRow(
modifier = modifier.sizeIn(maxHeight = iconContainerHeight + borderSize),
items = items.mapIndexed { index, item ->
{
val first = index == 0
val last = index == items.lastIndex
if (showDividers && !first) {
VerticalDivider(modifier = Modifier.padding(vertical = iconContainerHeight / 4))
}
SegmentedButton(
onClick = { onClick(index) },
iconResId = item.iconResId,
modifier = Modifier.size(
iconContainerWidth,
iconContainerHeight
),
iconSize = iconSize,
shape = containerShape.copy(
topStart = if (!first) CornerSize(0.dp) else containerShape.topStart,
bottomStart = if (!first) CornerSize(0.dp) else containerShape.bottomStart,
topEnd = if (!last) CornerSize(0.dp) else containerShape.topEnd,
bottomEnd = if (!last) CornerSize(0.dp) else containerShape.bottomEnd
)
)
}
},
containerShape = containerShape,
containerColor = containerColor,
borderColor = borderColor,
borderSize = borderSize
)
}
@Composable
fun SegmentedButtonsRow(
items: ImmutableList<@Composable () -> Unit>,
modifier: Modifier = Modifier,
containerShape: CornerBasedShape = RoundedCornerShape(24.dp),
containerColor: Color = MaterialTheme.colorScheme.background,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant,
borderSize: Dp = 1.dp,
) {
Row(
modifier = modifier
.background(containerColor, containerShape)
.border(borderSize, borderColor, containerShape)
) {
items.forEach { it.invoke() }
}
}
@Composable
fun SegmentedButton(
onClick: () -> Unit,
iconResId: Int,
modifier: Modifier = Modifier,
iconSize: Dp = 18.dp,
shape: Shape = CircleShape
) {
FastIconButton(
onClick = onClick,
modifier = modifier,
shape = shape
) {
Icon(
modifier = Modifier.size(iconSize),
painter = painterResource(iconResId),
contentDescription = null
)
}
}
@@ -58,7 +58,7 @@ fun AppTheme(
) {
val context = LocalContext.current
val colorScheme: ColorScheme = when {
val colorScheme: ColorScheme = predefinedColorScheme ?: when {
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
@@ -82,10 +82,6 @@ fun AppTheme(
}
}
val colorPrimary by animateColorAsState(colorScheme.primary)
val colorSurface by animateColorAsState(colorScheme.surface)
val colorBackground by animateColorAsState(colorScheme.background)
val typography = if (useSystemFont) {
MaterialTheme.typography
} else {
@@ -118,12 +114,7 @@ fun AppTheme(
}
MaterialExpressiveTheme(
colorScheme = (predefinedColorScheme ?: colorScheme)
.copy(
primary = colorPrimary,
background = colorBackground,
surface = colorSurface
),
colorScheme = colorScheme,
typography = typography,
content = content
)
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
@@ -113,11 +114,12 @@ fun LazyListState.isScrollingUp(): Boolean {
@Composable
fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean {
val context = LocalContext.current
val configuration = LocalConfiguration.current
val appForceDarkMode = darkMode == DarkMode.ENABLED
val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY
val systemUiNightMode = context.resources.configuration.uiMode
val systemUiNightMode = configuration.uiMode
val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true
val isSystemUsingDarkTheme =
@@ -1,6 +1,7 @@
package dev.meloda.fast.ui.util
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Immutable
class ImmutableList<T>(val values: List<T>) : Collection<T> {
@@ -57,3 +58,9 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
fun <T> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements))
inline fun <T> buildImmutableList(builderAction: MutableList<T>.() -> Unit): ImmutableList<T> {
val mutableList = mutableListOf<T>()
mutableList.apply(builderAction)
return mutableList.toImmutableList()
}
+4
View File
@@ -88,6 +88,7 @@
<string name="message_attachments_video_message">Video message</string>
<string name="message_attachments_group_sticker">Group sticker</string>
<string name="message_attachments_sticker_pack_preview">Sticker pack preview</string>
<string name="message_attachments_channel_message">Channel message</string>
<string name="chat_interaction_uploading_file">Uploading file</string>
<string name="chat_interaction_uploading_photo">Uploading photo</string>
@@ -305,4 +306,7 @@
<string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string>
<string name="title_edit_message">Edit message</string>
<string name="action_close">Close</string>
<string name="action_hide_stacktrace">Hide stacktrace</string>
<string name="action_show_stacktrace">Show stacktrace</string>
</resources>
+9
View File
@@ -2,4 +2,13 @@
<resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" />
<style name="CrashDialogTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>
@@ -100,7 +100,13 @@ class LoginViewModel(
}
fun onBackPressed() {
_screenState.setValue { old -> old.copy(showLogo = true) }
_screenState.setValue { old ->
old.copy(
showLogo = true,
loginError = false,
passwordError = false
)
}
}
fun onPasswordVisibilityButtonClicked() {
@@ -31,9 +31,13 @@ import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
@@ -58,7 +62,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
@@ -67,6 +70,8 @@ import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.ClassicColorScheme
import dev.meloda.fast.ui.util.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel
@@ -114,6 +119,12 @@ fun LoginRoute(
viewModel.onValidationCodeReceived(validationCode)
}
var useClassic by rememberSaveable { mutableStateOf(true) }
AppTheme(
predefinedColorScheme = if (useClassic) ClassicColorScheme.lightScheme
else lightColorScheme(),
) {
LoginScreen(
screenState = screenState,
onLoginInputChanged = viewModel::onLoginInputChanged,
@@ -122,9 +133,13 @@ fun LoginRoute(
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = viewModel::onLogoClicked,
onLogoClicked = {
viewModel.onLogoClicked()
useClassic = !useClassic
},
onLogoLongClicked = onNavigateToSettings
)
}
HandleDialogs(
loginDialog = loginDialog,
@@ -3,6 +3,7 @@ package dev.meloda.fast.convos
import android.content.Context
import android.content.res.Resources
import android.os.Bundle
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
@@ -15,10 +16,12 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.model.InteractionJob
import dev.meloda.fast.convos.model.NewInteractionException
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
@@ -28,25 +31,23 @@ import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@Immutable
class ConvosViewModel(
updatesParser: LongPollUpdatesParser,
private val filter: ConvosFilter,
val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
@@ -55,43 +56,22 @@ class ConvosViewModel(
private val applicationContext: Context,
private val loadConvosByIdUseCase: LoadConvosByIdUseCase
) : ViewModel() {
private val _screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val _navigation = MutableStateFlow<ConvoNavigation?>(null)
val navigation = _navigation.asStateFlow()
private val screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenStateFlow get() = screenState.asStateFlow()
private val _dialog = MutableStateFlow<ConvoDialog?>(null)
val dialog = _dialog.asStateFlow()
private val navigationIntent = MutableStateFlow<ConvoNavigationIntent?>(null)
val navigationIntentFlow get() = navigationIntent.asStateFlow()
private val _convos = MutableStateFlow<List<VkConvo>>(emptyList())
val convos = _convos.asStateFlow()
private val convos: MutableList<VkConvo> = mutableListOf()
private val _uiConvos = MutableStateFlow<List<UiConvo>>(emptyList())
val uiConvos = _uiConvos.asStateFlow()
private val pinnedConvosCount get() = convos.count(VkConvo::isPinned)
private val pinnedConvosCount = convos.map { convos ->
convos.count(VkConvo::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
private val _currentOffset = MutableStateFlow(0)
val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConvoId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private var currentOffset = 0
private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init {
_screenState.updateValue { copy(isArchive = filter == ConvosFilter.ARCHIVE) }
loadConvos()
updatesParser.onNewMessage(::handleNewMessage)
@@ -109,100 +89,143 @@ class ConvosViewModel(
}
}
fun onNavigationConsumed() {
_navigation.setValue { null }
fun handleIntent(intent: ConvoIntent) {
when (intent) {
ConvoIntent.ArchiveClick -> {
navigationIntent.setValue { ConvoNavigationIntent.Archive }
}
fun onDialogConfirmed(dialog: ConvoDialog, bundle: Bundle) {
onDialogDismissed(dialog)
ConvoIntent.Back -> {
navigationIntent.setValue { ConvoNavigationIntent.Back }
}
ConvoIntent.ConsumeScrollToTop -> Unit
ConvoIntent.CreateChatClick -> {
navigationIntent.setValue { ConvoNavigationIntent.CreateChat }
}
ConvoIntent.ErrorActionButtonClick -> {
onRefresh()
}
is ConvoIntent.ItemClick -> {
onConvoItemClick(intent.convoId)
}
is ConvoIntent.ItemLongClick -> {
onConvoItemLongClick(intent.convoId)
}
is ConvoIntent.OptionItemClick -> {
onOptionClicked(intent.option)
}
ConvoIntent.PaginationConditionsMet -> {
onPaginationConditionsMet()
}
ConvoIntent.Refresh -> {
onRefresh()
}
is ConvoIntent.SetScrollIndex -> {
setScrollIndex(intent.index)
}
is ConvoIntent.SetScrollOffset -> {
setScrollOffset(intent.offset)
}
is ConvoIntent.Dialog -> {
when (intent) {
is ConvoIntent.Dialog.Cancel -> Unit
is ConvoIntent.Dialog.Confirm -> onDialogConfirmed(intent.bundle)
ConvoIntent.Dialog.Dismiss -> onDialogDismissed()
}
}
}
}
fun onNavigationConsumed() {
navigationIntent.setValue { null }
}
private fun onDialogConfirmed(bundle: Bundle?) {
val dialog = screenState.value.dialog ?: return
onDialogDismissed()
val convo = with(screenState.value) {
convos.find { it.id == expandedConvoId }
} ?: return
when (dialog) {
is ConvoDialog.ConvoDelete -> {
deleteConvo(dialog.convoId)
is ConvoDialog.Delete -> {
deleteConvo(convo.id)
}
is ConvoDialog.ConvoPin -> {
pinConvo(dialog.convoId, true)
is ConvoDialog.Pin -> {
pinConvo(convo.id, true)
}
is ConvoDialog.ConvoUnpin -> {
pinConvo(dialog.convoId, false)
is ConvoDialog.Unpin -> {
pinConvo(convo.id, false)
}
is ConvoDialog.ConvoArchive -> {
archiveConvo(dialog.convoId, true)
is ConvoDialog.Archive -> {
archiveConvo(convo.id, true)
}
is ConvoDialog.ConvoUnarchive -> {
archiveConvo(dialog.convoId, false)
is ConvoDialog.Unarchive -> {
archiveConvo(convo.id, false)
}
}
expandedConvoId.setValue { 0 }
collapseConvos(false)
syncUiConvos()
}
fun onDialogDismissed(dialog: ConvoDialog) {
_dialog.setValue { null }
private fun onDialogDismissed() {
screenState.updateValue { copy(dialog = null) }
}
fun onDialogItemPicked(dialog: ConvoDialog, bundle: Bundle) {
when (dialog) {
is ConvoDialog.ConvoDelete -> Unit
is ConvoDialog.ConvoPin -> Unit
is ConvoDialog.ConvoUnpin -> Unit
is ConvoDialog.ConvoArchive -> Unit
is ConvoDialog.ConvoUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseError.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
_currentOffset.update { convos.value.size }
private fun onPaginationConditionsMet() {
currentOffset = convos.size
loadConvos()
}
fun onRefresh() {
private fun onErrorConsumed() {
screenState.updateValue { copy(error = null) }
}
private fun onRefresh() {
onErrorConsumed()
loadConvos(offset = 0)
}
fun onConvoItemClick(convo: UiConvo) {
private fun onConvoItemClick(convoId: Long) {
collapseConvos()
_navigation.setValue { ConvoNavigation.MessagesHistory(peerId = convo.id) }
navigationIntent.setValue { ConvoNavigationIntent.MessagesHistory(convoId) }
}
fun onConvoItemLongClick(convo: UiConvo) {
expandedConvoId.setValue {
if (convo.isExpanded) 0
else convo.id
}
private fun onConvoItemLongClick(convoId: Long) {
val isExpanded = screenState.value.convos.find { it.id == convoId }?.isExpanded == true
screenState.updateValue { copy(expandedConvoId = if (isExpanded) 0L else convoId) }
syncUiConvos()
}
fun onOptionClicked(
convo: UiConvo,
option: ConvoOption
) {
private fun onOptionClicked(option: ConvoOption) {
val convo =
screenState.value.convos.find { it.id == screenState.value.expandedConvoId } ?: return
when (option) {
ConvoOption.Delete -> {
_dialog.setValue { ConvoDialog.ConvoDelete(convo.id) }
}
ConvoOption.Delete -> setDialog(ConvoDialog.Delete)
ConvoOption.MarkAsRead -> {
convo.lastMessageId?.let { lastMessageId ->
val lastMessageId =
screenState.value.convos.find { it.id == screenState.value.expandedConvoId }?.lastMessageId
if (lastMessageId != null) {
readConvo(
peerId = convo.id,
startMessageId = lastMessageId
@@ -211,48 +234,39 @@ class ConvosViewModel(
}
}
ConvoOption.Pin -> {
_dialog.setValue { ConvoDialog.ConvoPin(convo.id) }
}
ConvoOption.Unpin -> {
_dialog.setValue { ConvoDialog.ConvoUnpin(convo.id) }
}
ConvoOption.Archive -> {
_dialog.setValue { ConvoDialog.ConvoArchive(convo.id) }
}
ConvoOption.Unarchive -> {
_dialog.setValue { ConvoDialog.ConvoUnarchive(convo.id) }
}
ConvoOption.Pin -> setDialog(ConvoDialog.Pin)
ConvoOption.Unpin -> setDialog(ConvoDialog.Unpin)
ConvoOption.Archive -> setDialog(ConvoDialog.Archive)
ConvoOption.Unarchive -> setDialog(ConvoDialog.Unarchive)
}
}
fun onErrorConsumed() {
_baseError.setValue { null }
private fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollIndex = index) }
}
fun setScrollIndex(index: Int) {
_screenState.setValue { old -> old.copy(scrollIndex = index) }
private fun setScrollOffset(offset: Int) {
screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
fun setScrollOffset(offset: Int) {
_screenState.setValue { old -> old.copy(scrollOffset = offset) }
private fun setDialog(dialog: ConvoDialog?) {
screenState.updateValue { copy(dialog = dialog) }
}
fun onCreateChatButtonClicked() {
_navigation.setValue { ConvoNavigation.CreateChat }
private fun replaceConvos(newConvos: List<VkConvo>) {
convos.clear()
convos.addAll(newConvos)
}
private fun collapseConvos() {
expandedConvoId.setValue { 0 }
private fun collapseConvos(sync: Boolean = true) {
screenState.updateValue { copy(expandedConvoId = null) }
if (sync) {
syncUiConvos()
}
}
private fun loadConvos(
offset: Int = currentOffset.value
) {
private fun loadConvos(offset: Int = currentOffset) {
convoUseCase.getConvos(
count = LOAD_COUNT,
offset = offset,
@@ -260,23 +274,20 @@ class ConvosViewModel(
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
val newBaseError = VkUtils.parseError(error)
_baseError.update { newBaseError }
screenState.updateValue { copy(error = VkUtils.parseError(error)) }
},
success = { response ->
val convos = response
val fullConvos = if (offset == 0) {
convos
val newConvos = if (offset == 0) {
response
} else {
this.convos.value.plus(convos)
convos.plus(response)
}
val itemsCountSufficient = response.size == LOAD_COUNT
val paginationExhausted = !itemsCountSufficient &&
this.convos.value.isNotEmpty()
val paginationExhausted = !itemsCountSufficient && convos.isNotEmpty()
_screenState.updateValue {
screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted)
}
@@ -293,13 +304,14 @@ class ConvosViewModel(
convoUseCase.storeConvos(response)
_convos.emit(fullConvos)
replaceConvos(newConvos)
screenState.updateValue { copy(canPaginate = itemsCountSufficient) }
syncUiConvos()
_canPaginate.setValue { itemsCountSufficient }
}
)
_screenState.setValue { old ->
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
@@ -313,17 +325,17 @@ class ConvosViewModel(
state.processState(
error = {},
success = {
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@processState
newConvos.removeAt(convoIndex)
_convos.update { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
)
_screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
screenState.emit(screenStateFlow.value.copy(isLoading = state.isLoading()))
}
}
@@ -337,7 +349,7 @@ class ConvosViewModel(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = if (pin) {
pinnedConvosCount.value.plus(1) * 16
pinnedConvosCount.plus(1) * 16
} else {
0
}
@@ -346,7 +358,7 @@ class ConvosViewModel(
}
)
_screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
}
}
@@ -356,7 +368,7 @@ class ConvosViewModel(
state.processState(
error = {},
success = {
convos.value.find { it.id == peerId }?.let { convo ->
convos.find { it.id == peerId }?.let { convo ->
handleChatArchived(
LongPollParsedEvent.ChatArchived(
convo = convo,
@@ -373,7 +385,7 @@ class ConvosViewModel(
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == message.peerId }
@@ -391,8 +403,8 @@ class ConvosViewModel(
val convo = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
newConvos.add(pinnedConvosCount.value, convo)
_convos.update { newConvos.sorted() }
newConvos.add(pinnedConvosCount, convo)
replaceConvos(newConvos.sorted())
syncUiConvos()
}
)
@@ -428,19 +440,17 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvo
} else {
newConvos.removeAt(convoIndex)
val toPosition = pinnedConvosCount.value
newConvos.add(toPosition, newConvo)
newConvos.add(pinnedConvosCount, newConvo)
}
_convos.update { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) { // диалога нет в списке
@@ -452,13 +462,14 @@ class ConvosViewModel(
lastMessageId = message.id,
lastCmId = message.cmId
)
_convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
}
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -472,13 +483,13 @@ class ConvosViewModel(
unreadCount = event.unreadCount
)
_convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
}
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -492,7 +503,7 @@ class ConvosViewModel(
unreadCount = event.unreadCount
)
_convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
}
@@ -502,7 +513,7 @@ class ConvosViewModel(
val peerId = event.peerId
val userIds = event.userIds
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId }
@@ -513,7 +524,7 @@ class ConvosViewModel(
interactionIds = userIds
)
_convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
interactionsTimers[peerId]?.let { interactionJob ->
@@ -545,7 +556,7 @@ class ConvosViewModel(
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } ?: return
@@ -555,7 +566,7 @@ class ConvosViewModel(
interactionIds = emptyList()
)
_convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
interactionJob.timerJob.cancel()
@@ -563,7 +574,7 @@ class ConvosViewModel(
}
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -573,13 +584,13 @@ class ConvosViewModel(
newConvos[convoIndex] =
newConvos[convoIndex].copy(majorId = event.majorId)
_convos.setValue { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -589,13 +600,13 @@ class ConvosViewModel(
newConvos[convoIndex] =
newConvos[convoIndex].copy(minorId = event.minorId)
_convos.setValue { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -604,7 +615,7 @@ class ConvosViewModel(
} else {
newConvos.removeAt(convoIndex)
_convos.setValue { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
@@ -612,7 +623,7 @@ class ConvosViewModel(
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
val convo = event.convo
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
@@ -627,7 +638,7 @@ class ConvosViewModel(
newConvos.removeAt(index)
}
_convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
@@ -638,10 +649,10 @@ class ConvosViewModel(
newConvos.removeAt(index)
} else {
newConvos.add(pinnedConvosCount.value, convo)
newConvos.add(pinnedConvosCount, convo)
}
_convos.update { newConvos.sorted() }
replaceConvos(newConvos.sorted())
syncUiConvos()
}
}
@@ -655,7 +666,7 @@ class ConvosViewModel(
state.processState(
error = {},
success = {
val newConvos = convos.value.toMutableList()
val newConvos = convos.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue
@@ -663,7 +674,7 @@ class ConvosViewModel(
newConvos[convoIndex] =
newConvos[convoIndex].copy(inRead = startMessageId)
_convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos()
}
)
@@ -695,47 +706,44 @@ class ConvosViewModel(
}
private fun syncUiConvos(): List<UiConvo> {
val convos = convos.value
val newUiConvos = convos.map { convo ->
val options = mutableListOf<ConvoOption>()
convo.lastMessage?.run {
if (!convo.isRead() && !this.isOut) {
options += ConvoOption.MarkAsRead
val options: ImmutableList<ConvoOption> = buildImmutableList {
if (!convo.isRead() && convo.lastMessage != null && convo.lastMessage?.isOut == false) {
add(ConvoOption.MarkAsRead)
}
}
val convosSize = this.convos.value.size
val pinnedCount = pinnedConvosCount.value
val canPinOneMoreDialog =
convosSize > 4 && pinnedCount < 5 && !convo.isPinned()
if (convo.isPinned()) {
options += ConvoOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConvoOption.Pin
add(ConvoOption.Unpin)
}
if (convos.size > 4 && pinnedConvosCount < 5 && !convo.isPinned()) {
add(ConvoOption.Pin)
}
when (filter) {
ConvosFilter.ARCHIVE -> ConvoOption.Unarchive
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> add(ConvoOption.Unarchive)
ConvosFilter.UNREAD,
ConvosFilter.ALL -> ConvoOption.Archive
ConvosFilter.ALL,
ConvosFilter.UNREAD -> {
if (convo.id != UserConfig.userId) {
add(ConvoOption.Archive)
}
}
}
ConvosFilter.BUSINESS_NOTIFY -> null
}?.let(options::add)
options += ConvoOption.Delete
add(ConvoOption.Delete)
}
convo.asPresentation(
resources = resources,
useContactName = useContactNames,
isExpanded = expandedConvoId.value == convo.id,
options = options.toImmutableList()
useContactName = userSettings.useContactNames.value,
isExpanded = screenState.value.expandedConvoId == convo.id,
options = options
)
}
_uiConvos.setValue { newUiConvos }
screenState.updateValue { copy(convos = newUiConvos.toImmutableList()) }
return newUiConvos
}
@@ -4,9 +4,9 @@ import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoDialog {
data class ConvoPin(val convoId: Long) : ConvoDialog()
data class ConvoUnpin(val convoId: Long) : ConvoDialog()
data class ConvoDelete(val convoId: Long) : ConvoDialog()
data class ConvoArchive(val convoId: Long) : ConvoDialog()
data class ConvoUnarchive(val convoId: Long) : ConvoDialog()
data object Pin : ConvoDialog()
data object Unpin : ConvoDialog()
data object Delete : ConvoDialog()
data object Archive : ConvoDialog()
data object Unarchive : ConvoDialog()
}
@@ -0,0 +1,29 @@
package dev.meloda.fast.convos.model
import android.os.Bundle
import dev.meloda.fast.ui.model.vk.ConvoOption
sealed class ConvoIntent {
data class ItemClick(val convoId: Long) : ConvoIntent()
data class ItemLongClick(val convoId: Long) : ConvoIntent()
data class OptionItemClick(val option: ConvoOption) : ConvoIntent()
data object PaginationConditionsMet : ConvoIntent()
data object Back : ConvoIntent()
data object Refresh : ConvoIntent()
data object CreateChatClick : ConvoIntent()
data object ArchiveClick : ConvoIntent()
data class SetScrollIndex(val index: Int) : ConvoIntent()
data class SetScrollOffset(val offset: Int) : ConvoIntent()
data object ErrorActionButtonClick : ConvoIntent()
data object ConsumeScrollToTop : ConvoIntent()
sealed class Dialog : ConvoIntent() {
data object Dismiss : Dialog()
data class Confirm(val bundle: Bundle? = null) : Dialog()
data class Cancel(val bundle: Bundle? = null) : Dialog()
}
}
@@ -1,11 +0,0 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoNavigation {
data class MessagesHistory(val peerId: Long) : ConvoNavigation()
data object CreateChat : ConvoNavigation()
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.convos.model
sealed class ConvoNavigationIntent {
data object Back : ConvoNavigationIntent()
data class MessagesHistory(val convoId: Long) : ConvoNavigationIntent()
data object CreateChat : ConvoNavigationIntent()
data object Archive : ConvoNavigationIntent()
}
@@ -1,6 +1,10 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@Immutable
data class ConvosScreenState(
@@ -10,7 +14,13 @@ data class ConvosScreenState(
val profileImageUrl: String?,
val scrollIndex: Int,
val scrollOffset: Int,
val isArchive: Boolean
val canPaginate: Boolean,
val expandedConvoId: Long?,
val convos: ImmutableList<UiConvo>,
val dialog: ConvoDialog?,
// TODO: 30.05.2026, Danil Nikolaev: remove
val error: BaseError?
) {
companion object {
@@ -21,7 +31,11 @@ data class ConvosScreenState(
profileImageUrl = null,
scrollIndex = 0,
scrollOffset = 0,
isArchive = false
canPaginate = false,
expandedConvoId = null,
convos = emptyImmutableList(),
dialog = null,
error = null
)
}
}
@@ -1,12 +1,16 @@
package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.presentation.ConvosRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController
@@ -24,44 +28,56 @@ object Convos
object Archive
fun NavGraphBuilder.convosGraph(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) {
navigation<ConvoGraph>(
startDestination = Convos
) {
val convosViewModel: ConvosViewModel = with(activity) {
getViewModel(qualifier = named(ConvosFilter.ALL))
}
composable<Convos> {
val navController = LocalNavController.getOrThrow()
ConvosRoute(
viewModel = convosViewModel,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onNavigateToArchive = { navController.navigate(Archive) },
onScrolledToTop = onScrolledToTop
ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
viewModel = with(activity) {
getViewModel(named(ConvosFilter.ALL))
}
)
}
composable<Archive> {
val navController = LocalNavController.getOrThrow()
ConvosRoute(
ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
viewModel = with(activity) {
getViewModel<ConvosViewModel>(
qualifier = named(ConvosFilter.ARCHIVE)
)
},
onBack = navController::navigateUp,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onScrolledToTop = onScrolledToTop
getViewModel<ConvosViewModel>(named(ConvosFilter.ARCHIVE))
}
)
}
}
}
@Composable
private fun ConvosRootRoute(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
viewModel: ConvosViewModel
) {
val navController = LocalNavController.getOrThrow()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val navigationIntent by viewModel.navigationIntentFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationIntent) {
navigationIntent?.let {
when (navigationIntent) {
ConvoNavigationIntent.Back -> navController.navigateUp()
ConvoNavigationIntent.Archive -> navController.navigate(Archive)
else -> handleNavigationIntent(it)
}
viewModel.onNavigationConsumed()
}
}
ConvosRoute(
handleIntent = viewModel::handleIntent,
screenState = screenState,
isArchive = viewModel.filter == ConvosFilter.ARCHIVE,
)
}
@@ -1,82 +1,78 @@
package dev.meloda.fast.convos.presentation
import android.os.Bundle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.core.os.bundleOf
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog
@Composable
fun HandleDialogs(
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState,
dialog: ConvoDialog?,
onConfirmed: (ConvoDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConvoDialog) -> Unit = {},
onItemPicked: (ConvoDialog, Bundle) -> Unit = { _, _ -> }
) {
when (dialog) {
when (screenState.dialog) {
null -> Unit
is ConvoDialog.ConvoArchive -> {
is ConvoDialog.Archive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_archive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24)
)
}
is ConvoDialog.ConvoUnarchive -> {
is ConvoDialog.Unarchive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_unarchive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24)
)
}
is ConvoDialog.ConvoDelete -> {
is ConvoDialog.Delete -> {
val errorColor = MaterialTheme.colorScheme.error
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24),
iconTint = errorColor,
title = stringResource(id = R.string.confirm_delete_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_delete),
confirmContainerColor = errorColor,
cancelText = stringResource(id = R.string.cancel),
)
}
is ConvoDialog.ConvoPin -> {
is ConvoDialog.Pin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24),
title = stringResource(id = R.string.confirm_pin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = R.string.cancel),
)
}
is ConvoDialog.ConvoUnpin -> {
is ConvoDialog.Unpin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24),
title = stringResource(id = R.string.confirm_unpin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = R.string.cancel),
)
@@ -62,9 +62,9 @@ val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConvoItem(
onItemClick: (UiConvo) -> Unit,
onItemLongClick: (convo: UiConvo) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
onItemClick: (convoId: Long) -> Unit,
onItemLongClick: (convoId: Long) -> Unit,
onOptionClicked: (ConvoOption) -> Unit,
maxLines: Int,
isUserAccount: Boolean,
convo: UiConvo,
@@ -81,9 +81,9 @@ fun ConvoItem(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onItemClick(convo) },
onClick = { onItemClick(convo.id) },
onLongClick = {
onItemLongClick(convo)
onItemLongClick(convo.id)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
)
@@ -281,7 +281,7 @@ fun ConvoItem(
val builder =
AnnotatedString.Builder(convo.message.text)
convo.message.spanStyles.map { spanStyleRange ->
convo.message.spanStyles.forEach { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
@@ -378,7 +378,7 @@ fun ConvoItem(
}
ElevatedAssistChip(
onClick = { onOptionClicked(convo, option) },
onClick = { onOptionClicked(option) },
leadingIcon = {
option.icon.getResourcePainter()?.let { painter ->
Icon(
@@ -36,12 +36,12 @@ import kotlinx.coroutines.launch
fun ConvosList(
modifier: Modifier = Modifier,
convos: ImmutableList<UiConvo>,
onConvosClick: (UiConvo) -> Unit,
onConvosLongClick: (UiConvo) -> Unit,
onConvosClick: (Long) -> Unit,
onConvosLongClick: (Long) -> Unit,
screenState: ConvosScreenState,
state: LazyListState,
maxLines: Int,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
onOptionClicked: (ConvoOption) -> Unit,
padding: PaddingValues
) {
val theme = LocalThemeConfig.current
@@ -1,79 +1,23 @@
package dev.meloda.fast.convos.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState
@Composable
fun ConvosRoute(
viewModel: ConvosViewModel,
onBack: (() -> Unit)? = null,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState,
isArchive: Boolean,
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
val convos by viewModel.uiConvos.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
null -> false
is ConvoNavigation.CreateChat -> {
onNavigateToCreateChat?.invoke()
true
}
is ConvoNavigation.MessagesHistory -> {
onNavigateToMessagesHistory(navigation.peerId)
true
}
}
if (shouldBeConsumed) viewModel.onNavigationConsumed()
}
ConvosScreen(
onBack = { onBack?.invoke() },
handleIntent = handleIntent,
screenState = screenState,
convos = convos.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
onConvoItemClicked = viewModel::onConvoItemClick,
onConvoItemLongClicked = viewModel::onConvoItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
onConsumeReselection = onScrolledToTop,
onErrorViewButtonClicked = {
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
onError(requireNotNull(baseError))
} else {
viewModel.onErrorButtonClicked()
}
}
isArchive = isArchive,
)
HandleDialogs(
handleIntent = handleIntent,
screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
}
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
@@ -53,53 +52,39 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.skydoves.compose.stability.runtime.TraceRecomposition
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class,
ExperimentalHazeMaterialsApi::class,
ExperimentalMaterial3ExpressiveApi::class,
)
@Composable
fun ConvosScreen(
screenState: ConvosScreenState = ConvosScreenState.EMPTY,
convos: ImmutableList<UiConvo> = emptyImmutableList(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onConvoItemClicked: (convo: UiConvo) -> Unit = {},
onConvoItemLongClicked: (convo: UiConvo) -> Unit = {},
onOptionClicked: (UiConvo, ConvoOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onArchiveActionClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
onConsumeReselection: () -> Unit = {},
onErrorViewButtonClicked: () -> Unit = {}
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState,
isArchive: Boolean,
) {
val currentTheme = LocalThemeConfig.current
val maxLines = if (currentTheme.enableMultiline) 2 else 1
@@ -112,33 +97,33 @@ fun ConvosScreen(
val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true
LaunchedEffect(currentTabReselected) {
if (currentTabReselected) {
if (screenState.isArchive) {
onBack.invoke()
if (isArchive) {
handleIntent(ConvoIntent.Back)
} else {
if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14)
}
listState.animateScrollToItem(0)
onConsumeReselection()
handleIntent(ConvoIntent.ConsumeScrollToTop)
}
}
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L)
.collectLatest(setScrollIndex)
.debounce(500L.milliseconds)
.collectLatest { handleIntent(ConvoIntent.SetScrollIndex(it)) }
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L)
.collectLatest(setScrollOffset)
.debounce(500L.milliseconds)
.collectLatest { handleIntent(ConvoIntent.SetScrollOffset(it)) }
}
val paginationConditionMet by remember(canPaginate, listState) {
val paginationConditionMet by remember(screenState.canPaginate, listState) {
derivedStateOf {
canPaginate &&
screenState.canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
@@ -146,7 +131,7 @@ fun ConvosScreen(
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
handleIntent(ConvoIntent.PaginationConditionsMet)
}
}
@@ -181,7 +166,7 @@ fun ConvosScreen(
text = stringResource(
id = when {
screenState.isLoading -> R.string.title_loading
screenState.isArchive -> R.string.title_archive
isArchive -> R.string.title_archive
else -> R.string.title_convos
}
),
@@ -191,8 +176,8 @@ fun ConvosScreen(
)
},
navigationIcon = {
if (screenState.isArchive) {
IconButton(onClick = onBack) {
if (isArchive) {
IconButton(onClick = { handleIntent(ConvoIntent.Back) }) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back_round_24),
contentDescription = null
@@ -201,46 +186,38 @@ fun ConvosScreen(
}
},
actions = {
if (!screenState.isArchive) {
IconButton(onClick = onArchiveActionClicked) {
Icon(
painter = painterResource(R.drawable.ic_archive_round_24),
contentDescription = null
)
}
}
val dropDownItems: List<@Composable () -> Unit> = buildList {}
val dropDownItems = mutableListOf<@Composable () -> Unit>()
val items = buildImmutableList {
if (!isArchive) {
add(SegmentedButtonItem("archive", R.drawable.ic_archive_round_24))
}
if (AppSettings.General.showManualRefreshOptions) {
dropDownItems += {
DropdownMenuItem(
onClick = {
onRefresh()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(id = R.string.action_refresh))
},
leadingIcon = {
Icon(
painter = painterResource(R.drawable.ic_refresh_round_24),
contentDescription = null
)
}
)
}
add(SegmentedButtonItem("refresh", R.drawable.ic_refresh_round_24))
}
if (dropDownItems.isNotEmpty()) {
IconButton(onClick = { dropDownMenuExpanded = true }) {
Icon(
painter = painterResource(R.drawable.ic_more_vert_round_24),
contentDescription = null
)
add(SegmentedButtonItem("more", R.drawable.ic_more_vert_round_24))
}
}
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"archive" -> handleIntent(ConvoIntent.ArchiveClick)
"refresh" -> handleIntent(ConvoIntent.Refresh)
"more" -> dropDownMenuExpanded = true
else -> Unit
}
}
)
if (dropDownItems.isNotEmpty()) {
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
@@ -249,6 +226,7 @@ fun ConvosScreen(
) {
dropDownItems.forEach { it.invoke() }
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
@@ -268,7 +246,7 @@ fun ConvosScreen(
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && convos.isNotEmpty() }
derivedStateOf { screenState.isLoading && screenState.convos.isNotEmpty() }
}
AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -279,14 +257,14 @@ fun ConvosScreen(
}
},
floatingActionButton = {
if (!screenState.isArchive) {
if (!isArchive) {
val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600
)
Column {
FloatingActionButton(
onClick = onCreateChatButtonClicked,
onClick = { handleIntent(ConvoIntent.CreateChatClick) },
modifier = Modifier.offset {
IntOffset(0, offsetY)
}
@@ -303,14 +281,15 @@ fun ConvosScreen(
}
) { padding ->
when {
baseError != null -> {
VkErrorView(
baseError = baseError,
onButtonClick = onErrorViewButtonClicked
)
}
// TODO: 30.05.2026, Danil Nikolaev: move to UI State
// baseError != null -> {
// VkErrorView(
// baseError = baseError,
// onButtonClick = onErrorViewButtonClicked
// )
// }
screenState.isLoading && convos.isEmpty() -> FullScreenContainedLoader()
screenState.isLoading && screenState.convos.isEmpty() -> FullScreenContainedLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -323,7 +302,7 @@ fun ConvosScreen(
.padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
onRefresh = { handleIntent(ConvoIntent.Refresh) },
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
@@ -335,9 +314,9 @@ fun ConvosScreen(
}
) {
ConvosList(
convos = convos,
onConvosClick = onConvoItemClicked,
onConvosLongClick = onConvoItemLongClicked,
convos = screenState.convos,
onConvosClick = { handleIntent(ConvoIntent.ItemClick(it)) },
onConvosLongClick = { handleIntent(ConvoIntent.ItemLongClick(it)) },
screenState = screenState,
state = listState,
maxLines = maxLines,
@@ -346,14 +325,14 @@ fun ConvosScreen(
} else {
Modifier
}.fillMaxSize(),
onOptionClicked = onOptionClicked,
onOptionClicked = { handleIntent(ConvoIntent.OptionItemClick(it)) },
padding = padding
)
if (convos.isEmpty()) {
if (screenState.convos.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
onButtonClick = { handleIntent(ConvoIntent.Refresh) }
)
}
}
@@ -38,6 +38,7 @@ import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -47,7 +48,6 @@ fun FriendsScreen(
orderType: String,
padding: PaddingValues,
tabIndex: Int,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {},
setCanScrollBackward: (Boolean) -> Unit = {},
@@ -100,13 +100,13 @@ fun FriendsScreen(
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(250L)
.debounce(250L.milliseconds)
.collectLatest(viewModel::setScrollIndex)
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(250L)
.debounce(250L.milliseconds)
.collectLatest(viewModel::setScrollOffset)
}
@@ -9,12 +9,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold
@@ -34,7 +33,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -47,11 +45,14 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.components.SelectionType
import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -189,16 +190,24 @@ fun FriendsRoute(
),
modifier = Modifier.fillMaxWidth(),
actions = {
IconButton(
onClick = {
showOrderDialog = true
}
) {
Icon(
painter = painterResource(R.drawable.ic_filter_list_round_24),
contentDescription = null
val items = buildImmutableList {
add(
SegmentedButtonItem(
"filter",
R.drawable.ic_filter_list_round_24
)
)
}
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"filter" -> showOrderDialog = true
}
}
)
}
)
PrimaryTabRow(
@@ -234,7 +243,6 @@ fun FriendsRoute(
orderType = orderType,
padding = padding,
tabIndex = index,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
setCanScrollBackward = { canScrollBackward = it },
@@ -119,12 +119,15 @@ fun Attachments(
AttachmentType.STICKER -> {
Sticker(
item = attachment as VkStickerDomain
url = (attachment as VkStickerDomain).getUrl(
width = 256,
withBackground = false
)
)
}
AttachmentType.GIFT -> {
Gift(item = attachment as VkGiftDomain)
Gift(url = (attachment as VkGiftDomain).getDefaultThumbSizeOrLess())
}
AttachmentType.VIDEO_MESSAGE -> {
@@ -21,25 +21,24 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkGiftDomain
import dev.meloda.fast.ui.R
@Composable
fun Gift(
modifier: Modifier = Modifier,
item: VkGiftDomain
url: String
) {
Column(
modifier = modifier.width(192.dp),
modifier = modifier
.width(208.dp)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
AsyncImage(
model = item.getDefaultThumbSizeOrLess(),
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
modifier = Modifier.size(192.dp)
)
Row(
@@ -10,27 +10,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkStickerDomain
@Composable
fun Sticker(
modifier: Modifier = Modifier,
item: VkStickerDomain
url: String?
) {
Box(
modifier = modifier.size(192.dp),
modifier = modifier
.size(208.dp)
.padding(8.dp),
contentAlignment = Alignment.Center
) {
AsyncImage(
model = item.getUrl(
width = 256,
withBackground = false
),
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(8.dp)
.fillMaxSize()
modifier = Modifier.fillMaxSize()
)
}
}
@@ -5,49 +5,33 @@ import androidx.lifecycle.viewModelScope
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.State
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.profile.model.ProfileScreenState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
interface ProfileViewModel {
val screenState: StateFlow<ProfileScreenState>
val baseError: StateFlow<BaseError?>
}
class ProfileViewModelImpl(
class ProfileViewModel(
private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase,
private val loadUserByIdUseCase: LoadUserByIdUseCase
) : ViewModel(), ProfileViewModel {
) : ViewModel() {
override val screenState = MutableStateFlow(ProfileScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
private val screenState = MutableStateFlow(ProfileScreenState.EMPTY)
init {
getLocalAccountInfo()
}
fun screenStateFlow(): StateFlow<ProfileScreenState> = screenState.asStateFlow()
private fun getLocalAccountInfo() {
getLocalUserByIdUseCase(UserConfig.userId)
.listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
if (error is State.Error.ApiError) {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> Unit
}
}
error = {
screenState.setValue { old ->
old.copy(
avatarUrl = null,
@@ -1,9 +1,9 @@
package dev.meloda.fast.profile.di
import dev.meloda.fast.profile.ProfileViewModelImpl
import dev.meloda.fast.profile.ProfileViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val profileModule = module {
viewModelOf(::ProfileViewModelImpl)
viewModelOf(::ProfileViewModel)
}
@@ -1,11 +1,11 @@
package dev.meloda.fast.profile.navigation
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.profile.ProfileViewModel
import dev.meloda.fast.profile.ProfileViewModelImpl
import dev.meloda.fast.profile.presentation.ProfileRoute
import kotlinx.serialization.Serializable
import org.koin.androidx.viewmodel.ext.android.getViewModel
@@ -15,19 +15,18 @@ object Profile
fun NavGraphBuilder.profileScreen(
activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onSettingsButtonClicked: () -> Unit,
onPhotoClicked: (url: String) -> Unit
) {
val viewModel: ProfileViewModel = with(activity) {
getViewModel<ProfileViewModelImpl>()
}
val viewModel: ProfileViewModel = with(activity) { getViewModel() }
composable<Profile> {
val screenState by viewModel.screenStateFlow().collectAsStateWithLifecycle()
ProfileRoute(
onError = onError,
screenState = screenState,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked,
viewModel = viewModel
)
}
}
@@ -14,15 +14,12 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -31,28 +28,21 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.profile.ProfileViewModel
import dev.meloda.fast.profile.ProfileViewModelImpl
import dev.meloda.fast.profile.model.ProfileScreenState
import dev.meloda.fast.ui.R
import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.util.buildImmutableList
@Composable
fun ProfileRoute(
onError: (BaseError) -> Unit,
screenState: ProfileScreenState,
onSettingsButtonClicked: () -> Unit,
onPhotoClicked: (url: String) -> Unit,
viewModel: ProfileViewModel = koinViewModel<ProfileViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
ProfileScreen(
screenState = screenState,
baseError = baseError,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked
)
@@ -63,7 +53,6 @@ fun ProfileRoute(
@Composable
fun ProfileScreen(
screenState: ProfileScreenState = ProfileScreenState.EMPTY,
baseError: BaseError? = null,
onSettingsButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}
) {
@@ -72,12 +61,19 @@ fun ProfileScreen(
TopAppBar(
title = {},
actions = {
IconButton(onClick = onSettingsButtonClicked) {
Icon(
painter = painterResource(R.drawable.ic_settings_round_24),
contentDescription = null
)
val items = buildImmutableList {
add(SegmentedButtonItem("settings", R.drawable.ic_settings_round_24))
}
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"settings" -> onSettingsButtonClicked()
}
}
)
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
+1 -5
View File
@@ -14,7 +14,7 @@ kotlin = "2.3.21"
ksp = "2.3.7"
moduleGraph = "2.9.1"
versions = "0.54.0"
stability-analyzer = "0.7.4"
stability-analyzer = "0.7.5"
compose-bom = "2026.04.01"
koin = "4.2.1"
@@ -36,13 +36,9 @@ nanokt = "1.3.0"
androidx-navigation = "2.9.8"
serialization = "1.11.0"
acra = "5.13.1"
okhttp = "5.3.2"
[libraries]
acra-email = { module = "ch.acra:acra-mail", version.ref = "acra" }
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
Binary file not shown.
+2
View File
@@ -2,6 +2,8 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Regular → Executable
+5 -9
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -173,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -206,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
Vendored
+10 -22
View File
@@ -23,8 +23,8 @@
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%