9 Commits

Author SHA1 Message Date
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
36 changed files with 577 additions and 172 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 local.properties
.idea .idea
/.kotlin /.kotlin
.hotswan/
.java-version
+1
View File
@@ -43,6 +43,7 @@ Unofficial messenger for russian social network VKontakte
- [ ] Forwarded messages - [ ] Forwarded messages
- [ ] Wall post - [ ] Wall post
- [ ] Comment in wall post - [ ] Comment in wall post
- [ ] Comment in channel
- [ ] Poll - [ ] Poll
- [ ] TODO - [ ] TODO
- [x] Send messages - [x] Send messages
+1 -4
View File
@@ -47,7 +47,7 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
} }
named("release") { named("release") {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("debugSigning")
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -79,9 +79,6 @@ android {
} }
dependencies { dependencies {
implementation(libs.acra.email)
implementation(libs.acra.dialog)
implementation(projects.feature.auth) implementation(projects.feature.auth)
implementation(projects.feature.chatmaterials) implementation(projects.feature.chatmaterials)
+8 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
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.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -37,6 +37,12 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="dev.meloda.fast.presentation.CrashActivity"
android:exported="false"
android:process=":error_handler"
android:theme="@style/CrashDialogTheme" />
<service <service
android:name="dev.meloda.fast.service.longpolling.LongPollingService" android:name="dev.meloda.fast.service.longpolling.LongPollingService"
android:enabled="true" android:enabled="true"
@@ -1,6 +1,8 @@
package dev.meloda.fast.common package dev.meloda.fast.common
import android.app.Application import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
@@ -8,14 +10,14 @@ import com.skydoves.compose.stability.runtime.ComposeStabilityAnalyzer
import dev.meloda.fast.auth.BuildConfig import dev.meloda.fast.auth.BuildConfig
import dev.meloda.fast.common.di.applicationModule import dev.meloda.fast.common.di.applicationModule
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import org.acra.config.dialog import dev.meloda.fast.presentation.CrashActivity
import org.acra.config.mailSender
import org.acra.data.StringFormat
import org.acra.ktx.initAcra
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext.startKoin import org.koin.core.context.GlobalContext.startKoin
import java.io.File
import java.io.FileOutputStream
import kotlin.system.exitProcess
class AppGlobal : Application(), ImageLoaderFactory { class AppGlobal : Application(), ImageLoaderFactory {
@@ -27,7 +29,7 @@ class AppGlobal : Application(), ImageLoaderFactory {
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG) ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin() initKoin()
initAcra() initCrashHandler()
} }
override fun newImageLoader(): ImageLoader = get() override fun newImageLoader(): ImageLoader = get()
@@ -40,20 +42,36 @@ class AppGlobal : Application(), ImageLoaderFactory {
} }
} }
private fun initAcra() { private fun initCrashHandler() {
initAcra { val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
buildConfigClass = BuildConfig::class.java Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
reportFormat = StringFormat.JSON val crashLogsDirectory = File(filesDir.absolutePath + "/crashlogs")
if (!crashLogsDirectory.exists()) {
mailSender { crashLogsDirectory.mkdirs()
mailTo = "lischenkodev@gmail.com"
reportAsFile = true
reportFileName = "Crash.txt"
} }
dialog { val crashLogFileName = "crash_" + System.currentTimeMillis() + ".txt"
text = "App crashed" val crashLogFile = File(crashLogsDirectory.absolutePath + "/" + crashLogFileName)
enabled = true
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)
}
)
}
}
}
}
@@ -19,6 +19,9 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
compileSdk = getVersionInt("compileSdk") compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk") 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 com.android.build.api.variant.LibraryAndroidComponentsExtension
import dev.meloda.fast.configureKotlinAndroid import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
@@ -20,7 +21,13 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
androidResources.enable = false androidResources.enable = false
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" defaultConfig {
minSdk = getVersionInt("minSdk")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
lint {
abortOnError = false
}
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
disableUnnecessaryAndroidTests(target) disableUnnecessaryAndroidTests(target)
@@ -4,7 +4,7 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive" 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_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.ru/method" const val URL_API = "https://api.vk.ru/method"
@@ -598,6 +598,7 @@ private fun getAttachmentIconByType(attachmentType: AttachmentType): UiImage? {
AttachmentType.VIDEO_MESSAGE -> null AttachmentType.VIDEO_MESSAGE -> null
AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24 AttachmentType.GROUP_CHAT_STICKER -> R.drawable.ic_sticker_fill_round_24
AttachmentType.STICKER_PACK_PREVIEW -> null AttachmentType.STICKER_PACK_PREVIEW -> null
AttachmentType.CHANNEL_MESSAGE -> null
}?.let(UiImage::Resource) }?.let(UiImage::Resource)
} }
@@ -687,6 +688,7 @@ fun getAttachmentUiText(
AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message AttachmentType.VIDEO_MESSAGE -> R.string.message_attachments_video_message
AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker AttachmentType.GROUP_CHAT_STICKER -> R.string.message_attachments_group_sticker
AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview AttachmentType.STICKER_PACK_PREVIEW -> R.string.message_attachments_sticker_pack_preview
AttachmentType.CHANNEL_MESSAGE -> R.string.message_attachments_channel_message
}.let(UiText::Resource) }.let(UiText::Resource)
} }
@@ -30,7 +30,8 @@ enum class AttachmentType(var value: String) {
ARTICLE("article"), ARTICLE("article"),
VIDEO_MESSAGE("video_message"), VIDEO_MESSAGE("video_message"),
GROUP_CHAT_STICKER("ugc_sticker"), 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) 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 = "article") val article: VkArticleData?,
@Json(name = "video_message") val videoMessage: VkVideoMessageData?, @Json(name = "video_message") val videoMessage: VkVideoMessageData?,
@Json(name = "ugc_sticker") val groupSticker: VkGroupStickerData?, @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)) { fun toDomain(): VkAttachment = when (AttachmentType.parse(type)) {
AttachmentType.UNKNOWN -> VkUnknownAttachment AttachmentType.UNKNOWN -> VkUnknownAttachment
@@ -66,5 +67,6 @@ data class VkAttachmentItemData(
AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain() AttachmentType.VIDEO_MESSAGE -> videoMessage?.toDomain()
AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain() AttachmentType.GROUP_CHAT_STICKER -> groupSticker?.toDomain()
AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain() AttachmentType.STICKER_PACK_PREVIEW -> stickerPackPreview?.toDomain()
AttachmentType.CHANNEL_MESSAGE -> channelMessageData?.toDomain()
} ?: VkUnknownAttachment } ?: 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 package dev.meloda.fast.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Indication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -20,6 +21,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -30,27 +33,32 @@ fun FastIconButton(
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
enabled: Boolean = true, enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), 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, interactionSource: MutableInteractionSource? = null,
indication: Indication = ripple(),
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
Box( Box(
modifier = modifier =
modifier modifier
.minimumInteractiveComponentSize() .minimumInteractiveComponentSize()
.size(IconButtonTokens.StateLayerSize) .size(size)
.clip(IconButtonTokens.StateLayerShape) .clip(shape)
.background(color = colors.containerColor(enabled)) .background(containerColor)
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
enabled = enabled, enabled = enabled,
interactionSource = interactionSource, interactionSource = interactionSource,
indication = ripple() indication = indication
), ),
contentAlignment = Alignment.Center contentAlignment = alignment
) { ) {
val contentColor = colors.contentColor(enabled) CompositionLocalProvider(LocalContentColor provides contentColor) { content() }
CompositionLocalProvider(LocalContentColor provides contentColor, content = 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 context = LocalContext.current
val colorScheme: ColorScheme = when { val colorScheme: ColorScheme = predefinedColorScheme ?: when {
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (useDarkTheme) dynamicDarkColorScheme(context) if (useDarkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(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) { val typography = if (useSystemFont) {
MaterialTheme.typography MaterialTheme.typography
} else { } else {
@@ -118,12 +114,7 @@ fun AppTheme(
} }
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = (predefinedColorScheme ?: colorScheme) colorScheme = colorScheme,
.copy(
primary = colorPrimary,
background = colorBackground,
surface = colorSurface
),
typography = typography, typography = typography,
content = content content = content
) )
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -113,11 +114,12 @@ fun LazyListState.isScrollingUp(): Boolean {
@Composable @Composable
fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean { fun isNeedToEnableDarkMode(darkMode: DarkMode): Boolean {
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current
val appForceDarkMode = darkMode == DarkMode.ENABLED val appForceDarkMode = darkMode == DarkMode.ENABLED
val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY val appBatterySaver = darkMode == DarkMode.AUTO_BATTERY
val systemUiNightMode = context.resources.configuration.uiMode val systemUiNightMode = configuration.uiMode
val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true val isSystemBatterySaver = context.getSystemService<PowerManager>()?.isPowerSaveMode == true
val isSystemUsingDarkTheme = val isSystemUsingDarkTheme =
@@ -1,6 +1,7 @@
package dev.meloda.fast.ui.util package dev.meloda.fast.ui.util
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Immutable @Immutable
class ImmutableList<T>(val values: List<T>) : Collection<T> { 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> emptyImmutableList(): ImmutableList<T> = ImmutableList(emptyList())
fun <T> immutableListOf(vararg elements: T) = ImmutableList(listOf(elements = elements)) 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_video_message">Video message</string>
<string name="message_attachments_group_sticker">Group sticker</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_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_file">Uploading file</string>
<string name="chat_interaction_uploading_photo">Uploading photo</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="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="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> </resources>
+9
View File
@@ -2,4 +2,13 @@
<resources> <resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar" /> <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> </resources>
@@ -100,7 +100,13 @@ class LoginViewModel(
} }
fun onBackPressed() { fun onBackPressed() {
_screenState.setValue { old -> old.copy(showLogo = true) } _screenState.setValue { old ->
old.copy(
showLogo = true,
loginError = false,
passwordError = false
)
}
} }
fun onPasswordVisibilityButtonClicked() { fun onPasswordVisibilityButtonClicked() {
@@ -31,9 +31,13 @@ import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.autofill.ContentType
@@ -58,7 +62,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel 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.LoginDialog
import dev.meloda.fast.auth.login.model.LoginScreenState import dev.meloda.fast.auth.login.model.LoginScreenState
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments 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.common.LocalSizeConfig
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText 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.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -114,6 +119,12 @@ fun LoginRoute(
viewModel.onValidationCodeReceived(validationCode) viewModel.onValidationCodeReceived(validationCode)
} }
var useClassic by rememberSaveable { mutableStateOf(true) }
AppTheme(
predefinedColorScheme = if (useClassic) ClassicColorScheme.lightScheme
else lightColorScheme(),
) {
LoginScreen( LoginScreen(
screenState = screenState, screenState = screenState,
onLoginInputChanged = viewModel::onLoginInputChanged, onLoginInputChanged = viewModel::onLoginInputChanged,
@@ -122,9 +133,13 @@ fun LoginRoute(
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked, onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked, onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked, onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = viewModel::onLogoClicked, onLogoClicked = {
viewModel.onLogoClicked()
useClassic = !useClassic
},
onLogoLongClicked = onNavigateToSettings onLogoLongClicked = onNavigateToSettings
) )
}
HandleDialogs( HandleDialogs(
loginDialog = loginDialog, loginDialog = loginDialog,
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@@ -53,7 +52,6 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.skydoves.compose.stability.runtime.TraceRecomposition
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
@@ -65,6 +63,8 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.vk.ConvoOption import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo import dev.meloda.fast.ui.model.vk.UiConvo
@@ -73,10 +73,12 @@ import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isScrollingUp import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
@@ -126,13 +128,13 @@ fun ConvosScreen(
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L) .debounce(500L.milliseconds)
.collectLatest(setScrollIndex) .collectLatest(setScrollIndex)
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset } snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L) .debounce(500L.milliseconds)
.collectLatest(setScrollOffset) .collectLatest(setScrollOffset)
} }
@@ -201,46 +203,38 @@ fun ConvosScreen(
} }
}, },
actions = { actions = {
if (!screenState.isArchive) { val dropDownItems: List<@Composable () -> Unit> = buildList {}
IconButton(onClick = onArchiveActionClicked) {
Icon(
painter = painterResource(R.drawable.ic_archive_round_24),
contentDescription = null
)
}
}
val dropDownItems = mutableListOf<@Composable () -> Unit>() val items = buildImmutableList {
if (!screenState.isArchive) {
add(SegmentedButtonItem("archive", R.drawable.ic_archive_round_24))
}
if (AppSettings.General.showManualRefreshOptions) { if (AppSettings.General.showManualRefreshOptions) {
dropDownItems += { add(SegmentedButtonItem("refresh", R.drawable.ic_refresh_round_24))
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
)
}
)
}
} }
if (dropDownItems.isNotEmpty()) { if (dropDownItems.isNotEmpty()) {
IconButton(onClick = { dropDownMenuExpanded = true }) { add(SegmentedButtonItem("more", R.drawable.ic_more_vert_round_24))
Icon(
painter = painterResource(R.drawable.ic_more_vert_round_24),
contentDescription = null
)
} }
} }
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"archive" -> onArchiveActionClicked()
"refresh" -> onRefresh()
"more" -> dropDownMenuExpanded = true
else -> Unit
}
}
)
if (dropDownItems.isNotEmpty()) {
DropdownMenu( DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp), modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded, expanded = dropDownMenuExpanded,
@@ -249,6 +243,7 @@ fun ConvosScreen(
) { ) {
dropDownItems.forEach { it.invoke() } dropDownItems.forEach { it.invoke() }
} }
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy( containerColor = toolbarContainerColor.copy(
@@ -38,6 +38,7 @@ import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -47,7 +48,6 @@ fun FriendsScreen(
orderType: String, orderType: String,
padding: PaddingValues, padding: PaddingValues,
tabIndex: Int, tabIndex: Int,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userid: Long) -> Unit = {}, onMessageClicked: (userid: Long) -> Unit = {},
setCanScrollBackward: (Boolean) -> Unit = {}, setCanScrollBackward: (Boolean) -> Unit = {},
@@ -100,13 +100,13 @@ fun FriendsScreen(
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(250L) .debounce(250L.milliseconds)
.collectLatest(viewModel::setScrollIndex) .collectLatest(viewModel::setScrollIndex)
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset } snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(250L) .debounce(250L.milliseconds)
.collectLatest(viewModel::setScrollOffset) .collectLatest(viewModel::setScrollOffset)
} }
@@ -9,12 +9,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -34,7 +33,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog 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.components.SelectionType
import dev.meloda.fast.ui.model.TabItem import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -189,16 +190,24 @@ fun FriendsRoute(
), ),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
actions = { actions = {
IconButton( val items = buildImmutableList {
onClick = { add(
showOrderDialog = true SegmentedButtonItem(
} "filter",
) { R.drawable.ic_filter_list_round_24
Icon( )
painter = painterResource(R.drawable.ic_filter_list_round_24),
contentDescription = null
) )
} }
SegmentedButtonsRow(
modifier = Modifier.padding(end = 8.dp),
items = items,
onClick = { index ->
when (items[index].key) {
"filter" -> showOrderDialog = true
}
}
)
} }
) )
PrimaryTabRow( PrimaryTabRow(
@@ -234,7 +243,6 @@ fun FriendsRoute(
orderType = orderType, orderType = orderType,
padding = padding, padding = padding,
tabIndex = index, tabIndex = index,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
setCanScrollBackward = { canScrollBackward = it }, setCanScrollBackward = { canScrollBackward = it },
@@ -119,12 +119,15 @@ fun Attachments(
AttachmentType.STICKER -> { AttachmentType.STICKER -> {
Sticker( Sticker(
item = attachment as VkStickerDomain url = (attachment as VkStickerDomain).getUrl(
width = 256,
withBackground = false
)
) )
} }
AttachmentType.GIFT -> { AttachmentType.GIFT -> {
Gift(item = attachment as VkGiftDomain) Gift(url = (attachment as VkGiftDomain).getDefaultThumbSizeOrLess())
} }
AttachmentType.VIDEO_MESSAGE -> { AttachmentType.VIDEO_MESSAGE -> {
@@ -21,25 +21,24 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkGiftDomain
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
@Composable @Composable
fun Gift( fun Gift(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: VkGiftDomain url: String
) { ) {
Column( Column(
modifier = modifier.width(192.dp), modifier = modifier
.width(208.dp)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
AsyncImage( AsyncImage(
model = item.getDefaultThumbSizeOrLess(), model = url,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.size(192.dp)
.padding(8.dp)
.fillMaxWidth()
) )
Row( Row(
@@ -10,27 +10,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.model.api.domain.VkStickerDomain
@Composable @Composable
fun Sticker( fun Sticker(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: VkStickerDomain url: String?
) { ) {
Box( Box(
modifier = modifier.size(192.dp), modifier = modifier
.size(208.dp)
.padding(8.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
AsyncImage( AsyncImage(
model = item.getUrl( model = url,
width = 256,
withBackground = false
),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.fillMaxSize()
.padding(8.dp)
.fillMaxSize()
) )
} }
} }
+1 -5
View File
@@ -14,7 +14,7 @@ kotlin = "2.3.21"
ksp = "2.3.7" ksp = "2.3.7"
moduleGraph = "2.9.1" moduleGraph = "2.9.1"
versions = "0.54.0" versions = "0.54.0"
stability-analyzer = "0.7.4" stability-analyzer = "0.7.5"
compose-bom = "2026.04.01" compose-bom = "2026.04.01"
koin = "4.2.1" koin = "4.2.1"
@@ -36,13 +36,9 @@ nanokt = "1.3.0"
androidx-navigation = "2.9.8" androidx-navigation = "2.9.8"
serialization = "1.11.0" serialization = "1.11.0"
acra = "5.13.1"
okhttp = "5.3.2" okhttp = "5.3.2"
[libraries] [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" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } 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 distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored Regular → Executable
+5 -9
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (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. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # 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 APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -115,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # 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 # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -206,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # 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. # 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 # * 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. # treated as '${Hostname}' itself on the command line.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.
Vendored
+10 -22
View File
@@ -23,8 +23,8 @@
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @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 :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
: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