13 Commits

Author SHA1 Message Date
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
melod1n cb653eddc2 refactor(auth): reuse network ValidationType for validation flow
Remove duplicate auth ValidationType and use the shared network model instead.
Add support for both "sms" and "2fa_sms" SMS validation values.
2026-05-03 06:53:56 +03:00
melod1n df2c61d8d7 feat(auth): add web captcha handling
- replace manual captcha screen with WebView-based VK captcha flow
- handle captcha error 14 by showing the captcha overlay and retrying with success_token
- pass captcha redirect/result state through AppSettings
- remove old captcha ViewModel, navigation, validation, and DI
- add ACRA crash reporting
- add WIP message edit mode UI/state
- update Gradle wrapper, SDK config, and dependencies
2026-05-03 05:49:16 +03:00
dependabot[bot] 97c59a85b6 Chore(deps): Bump actions/upload-artifact from 6 to 7 (#252)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 21:01:31 +03:00
melod1n 155a3666ad bump app version code and name (#251) 2026-02-16 16:32:10 +03:00
melod1n ce375c902c Refactor: Improve reply component layout and styling
This commit refactors the `Reply` composable for better layout consistency and simplifies its implementation.

The `Reply` component no longer uses a fixed height, allowing it to dynamically resize based on its content. The layout has been updated from a `Box` to a `Row` to properly align the side indicator bar with the height of the text content. Padding and corner rounding logic has been simplified and centralized within the `Reply` composable itself, removing redundant parameters from the `MessageBubble`.

Key changes:
- `Reply` composable now uses `Row` for its root layout instead of `Box`.
- Removed the fixed `48.dp` height to allow dynamic content sizing.
- The side indicator bar's height now matches the text content's height.
- Simplified padding and shape logic in `MessageBubble` by removing conditional parameters passed to `Reply`.
- Adjusted padding inside `MessageBubble` to accommodate the new `Reply` layout.
2026-02-06 22:58:03 +03:00
melod1n 96b4fc8539 ui: improve Compose stability and message UI
- Add minute/second abbreviations and kotlin.time-based relative time formatter
- Introduce FastPreview and update previews to use AppTheme with dark/dynamic colors
- Refactor attachments preview grid & waveform to use ImmutableList and reduce recompositions
- Tweak message bubble reply styling and swipe-to-reply animation/haptics
- Add Compose Stability Analyzer plugin and enable it in debug builds
- Cache shared images by sha256 and improve share intent/chooser text
- Minor UX polish (e.g., “No views”) and immutability annotations
2026-02-06 22:14:01 +03:00
97 changed files with 1474 additions and 985 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 -2
View File
@@ -40,7 +40,7 @@ jobs:
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
@@ -56,7 +56,7 @@ jobs:
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
run: ./gradlew assembleRelease
- name: Upload release APK
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
@@ -43,7 +43,7 @@ jobs:
run: ./gradlew bundleRelease
- name: Upload release Bundle
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: app-release.aab
path: app/build/outputs/bundle/release/app-release.aab
+1
View File
@@ -15,3 +15,4 @@ build/
local.properties
.idea
/.kotlin
.hotswan/
+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
+3 -3
View File
@@ -13,8 +13,8 @@ android {
defaultConfig {
applicationId = "dev.meloda.fastvk"
versionCode = 10
versionName = "0.2.2"
versionCode = 11
versionName = "0.2.3"
}
signingConfigs {
@@ -47,7 +47,7 @@ android {
applicationIdSuffix = ".debug"
}
named("release") {
signingConfig = signingConfigs.getByName("release")
signingConfig = signingConfigs.getByName("debugSigning")
isMinifyEnabled = true
isShrinkResources = true
+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,15 +1,23 @@
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
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 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 {
@@ -18,10 +26,14 @@ class AppGlobal : Application(), ImageLoaderFactory {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
AppSettings.init(preferences)
ComposeStabilityAnalyzer.setEnabled(BuildConfig.DEBUG)
initKoin()
initCrashHandler()
}
override fun newImageLoader(): ImageLoader = get()
private fun initKoin() {
startKoin {
androidLogger()
@@ -30,5 +42,37 @@ class AppGlobal : Application(), ImageLoaderFactory {
}
}
override fun newImageLoader(): ImageLoader = get()
private fun initCrashHandler() {
val defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
val crashLogsDirectory = File(filesDir.absolutePath + "/crashlogs")
if (!crashLogsDirectory.exists()) {
crashLogsDirectory.mkdirs()
}
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)
}
}
}
}
@@ -7,9 +7,7 @@ import androidx.preference.PreferenceManager
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.captcha.di.captchaModule
import dev.meloda.fast.auth.login.di.loginModule
import dev.meloda.fast.auth.validation.di.validationModule
import dev.meloda.fast.auth.authModule
import dev.meloda.fast.chatmaterials.di.chatMaterialsModule
import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.LongPollControllerImpl
@@ -38,9 +36,7 @@ import org.koin.dsl.module
val applicationModule = module {
includes(domainModule)
includes(
loginModule,
validationModule,
captchaModule,
authModule,
convosModule,
settingsModule,
messagesHistoryModule,
@@ -8,9 +8,9 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.BottomNavigationItem
import dev.meloda.fast.presentation.MainScreen
import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.serialization.Serializable
import dev.meloda.fast.ui.R
@Serializable
object MainGraph
@@ -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)
}
)
}
}
}
}
@@ -41,6 +41,7 @@ import com.google.accompanist.permissions.rememberPermissionState
import dev.meloda.fast.MainViewModel
import dev.meloda.fast.MainViewModelImpl
import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.captcha.presentation.CaptchaScreen
import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
@@ -48,6 +49,8 @@ import dev.meloda.fast.common.LongPollController
import dev.meloda.fast.common.model.LongPollState
import dev.meloda.fast.convos.navigation.createChatScreen
import dev.meloda.fast.convos.navigation.navigateToCreateChat
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.CaptchaTokenResult
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
@@ -69,9 +72,7 @@ import dev.meloda.fast.ui.theme.LocalNavController
import dev.meloda.fast.ui.theme.LocalNavRootController
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.immutableListOf
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@@ -309,9 +310,12 @@ fun RootScreen(
LocalNavController provides navController
) {
var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<ImmutableList<String>, Int?>?>(null)
mutableStateOf<Pair<List<String>, Int?>?>(null)
}
val captchaRedirectUri by AppSettings.getCaptchaRedirectUriFlow()
.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
@@ -333,10 +337,10 @@ fun RootScreen(
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null
photoViewerInfo = listOf(url) to null
},
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat
onNavigateToCreateChat = navController::navigateToCreateChat,
)
messagesHistoryScreen(
@@ -344,13 +348,13 @@ fun RootScreen(
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos.toImmutableList() to index
photoViewerInfo = photos to index
}
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url ->
photoViewerInfo = immutableListOf(url) to null
photoViewerInfo = listOf(url) to null
}
)
createChatScreen(
@@ -378,9 +382,23 @@ fun RootScreen(
}
PhotoViewDialog(
photoViewerInfo = photoViewerInfo,
photoViewerInfo = photoViewerInfo?.let { info ->
info.first.toImmutableList() to info.second
},
onDismiss = { photoViewerInfo = null }
)
CaptchaScreen(
captchaRedirectUri = captchaRedirectUri,
onBack = {
AppSettings.setCaptchaResult(CaptchaTokenResult.Cancelled)
},
onResult = { result ->
AppSettings.setCaptchaResult(
CaptchaTokenResult.Success(result)
)
},
)
}
}
}
@@ -32,10 +32,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koin.android.ext.android.inject
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Duration.Companion.seconds
class LongPollingService : Service() {
@@ -204,7 +204,7 @@ class LongPollingService : Service() {
}
}
private suspend fun getServerInfo(): VkLongPollData? = suspendCoroutine {
private suspend fun getServerInfo(): VkLongPollData? = suspendCancellableCoroutine {
longPollUseCase.getLongPollServer(
needPts = true,
version = VkConstants.LP_VERSION
@@ -224,7 +224,7 @@ class LongPollingService : Service() {
private suspend fun getUpdatesResponse(
server: VkLongPollData
): LongPollUpdates? = suspendCoroutine {
): LongPollUpdates? = suspendCancellableCoroutine {
longPollUseCase.getLongPollUpdates(
serverUrl = "https://${server.server}",
key = server.key,
@@ -10,6 +10,7 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
with(target) {
apply(plugin = "com.android.application")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension)
@@ -1,5 +1,6 @@
import com.android.build.api.dsl.ApplicationExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -14,9 +15,12 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig {
targetSdk = 36
compileSdk = 36
minSdk = 23
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
lint {
abortOnError = false
}
}
}
@@ -1,19 +1,26 @@
import com.android.build.api.dsl.LibraryExtension
import dev.meloda.fast.configureAndroidCompose
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.configure
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
apply(plugin = "com.github.skydoves.compose.stability.analyzer")
val extension = extensions.getByType<LibraryExtension>()
extension.androidResources.enable = false
configureAndroidCompose(extension)
extensions.configure<LibraryExtension> {
configureAndroidCompose(this)
androidResources.enable = false
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
}
}
}
}
}
@@ -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)
@@ -1,5 +1,6 @@
import com.android.build.api.dsl.TestExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.getVersionInt
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -13,7 +14,11 @@ class AndroidTestConventionPlugin : Plugin<Project> {
extensions.configure<TestExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 36
defaultConfig {
minSdk = getVersionInt("minSdk")
compileSdk = getVersionInt("compileSdk")
targetSdk = getVersionInt("targetSdk")
}
}
}
}
@@ -24,7 +24,7 @@ internal fun Project.configureKotlinAndroid(
}
commonExtension.apply {
compileSdk = 36
compileSdk = getVersionInt("compileSdk")
}
configureKotlin<KotlinAndroidProjectExtension>()
@@ -61,6 +61,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() =
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
"-Xannotation-default-target=param-property",
"-Xcontext-parameters"
)
}
}
@@ -7,3 +7,8 @@ import org.gradle.kotlin.dsl.getByType
val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
fun Project.getVersionInt(alias: String): Int {
return libs.findVersion(alias).get().requiredVersion.toInt()
}
+1
View File
@@ -9,4 +9,5 @@ plugins {
alias(libs.plugins.ksp) apply false
alias(libs.plugins.module.graph) apply true
alias(libs.plugins.versions) apply true
alias(libs.plugins.stability.analyzer) apply false
}
@@ -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"
@@ -1,7 +1,6 @@
package dev.meloda.fast.common.util
import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.hour
import com.conena.nanokt.jvm.util.hourOfDay
import com.conena.nanokt.jvm.util.millisecond
import com.conena.nanokt.jvm.util.minute
@@ -12,6 +11,12 @@ import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
object TimeUtils {
@@ -56,37 +61,23 @@ object TimeUtils {
monthShort: () -> String,
weekShort: () -> String,
dayShort: () -> String,
minuteShort: () -> String,
secondShort: () -> String,
now: () -> String
): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
val now = Clock.System.now()
val then = Instant.fromEpochMilliseconds(date)
val diff = now - then
return when {
now.year != then.year -> {
"${now.year - then.year}${yearShort().lowercase()}"
}
now.month != then.month -> {
"${now.month - then.month}${monthShort().lowercase()}"
}
now.dayOfMonth != then.dayOfMonth -> {
val change = now.dayOfMonth - then.dayOfMonth
if (change % 7 == 0) {
"${change / 7}${weekShort().lowercase()}"
} else {
"$change${dayShort().lowercase()}"
}
}
now.hour == then.hour && now.minute == then.minute -> {
now().lowercase()
}
else -> {
SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
}
diff > 365.days -> "${diff.inWholeDays / 365}${yearShort().lowercase()}"
diff > 30.days -> "${diff.inWholeDays / 30}${monthShort().lowercase()}"
diff > 7.days -> "${diff.inWholeDays / 7}${weekShort().lowercase()}"
diff > 1.days -> "${diff.inWholeDays}${dayShort().lowercase()}"
diff > 1.hours -> "${diff.inWholeHours}h"
diff > 1.minutes -> "${diff.inWholeMinutes}${minuteShort().lowercase()}"
diff > 1.seconds -> "${diff.inWholeSeconds}${secondShort().lowercase()}"
else -> now().lowercase()
}
}
}
@@ -1,7 +1,17 @@
package dev.meloda.fast.common.util
import java.net.URLEncoder
import java.security.MessageDigest
fun String.urlEncode(encoding: String = "utf-8"): String {
return URLEncoder.encode(this, encoding)
}
fun String.sha256() = this.hashString("SHA-256")
fun String.hashString(algorithm: String): String {
return MessageDigest
.getInstance(algorithm)
.digest(this.toByteArray())
.fold("") { str, it -> str + "%02x".format(it) }
}
@@ -23,5 +23,6 @@ interface OAuthRepository {
validationCode: String?,
captchaSid: String?,
captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
}
@@ -79,7 +79,8 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty()
captchaImageUrl = response.captchaImage.orEmpty(),
redirectUri = response.redirectUri
)
}
@@ -122,6 +123,7 @@ class OAuthRepositoryImpl(
validationCode: String?,
captchaSid: String?,
captchaKey: String?,
successToken: String?
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> =
withContext(Dispatchers.IO) {
val requestModel = AuthDirectRequest(
@@ -135,6 +137,7 @@ class OAuthRepositoryImpl(
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey,
successToken = successToken
)
oAuthService.getSilentToken(requestModel.map).mapResult(
@@ -175,7 +178,8 @@ class OAuthRepositoryImpl(
VkOAuthError.NEED_CAPTCHA -> {
OAuthErrorDomain.CaptchaRequiredError(
captchaSid = response.captchaSid.orEmpty(),
captchaImageUrl = response.captchaImage.orEmpty()
captchaImageUrl = response.captchaImage.orEmpty(),
redirectUri = response.redirectUri
)
}
@@ -4,13 +4,32 @@ import android.content.SharedPreferences
import androidx.core.content.edit
import dev.meloda.fast.common.model.DarkMode
import dev.meloda.fast.common.model.LogLevel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlin.properties.Delegates
import kotlin.reflect.KClass
sealed class CaptchaTokenResult {
data object Initial : CaptchaTokenResult()
data object Null : CaptchaTokenResult()
data object Cancelled : CaptchaTokenResult()
data class Success(val token: String) : CaptchaTokenResult()
}
object AppSettings {
private var preferences: SharedPreferences by Delegates.notNull()
private val captchaResult = MutableStateFlow<CaptchaTokenResult>(CaptchaTokenResult.Initial)
fun getCaptchaResultFlow(): StateFlow<CaptchaTokenResult> = captchaResult.asStateFlow()
fun setCaptchaResult(result: CaptchaTokenResult) = captchaResult.update { result }
private val captchaRedirectUri = MutableStateFlow<String?>(null)
fun getCaptchaRedirectUriFlow() = captchaRedirectUri.asStateFlow()
fun setCaptchaRedirectUri(redirectUri: String?) = captchaRedirectUri.update { redirectUri }
fun init(preferences: SharedPreferences) {
this.preferences = preferences
}
@@ -6,10 +6,7 @@ import dev.meloda.fast.model.database.AccountEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GetCurrentAccountUseCase(
private val accountsRepository: AccountsRepository
) {
class GetCurrentAccountUseCase(private val accountsRepository: AccountsRepository) {
suspend operator fun invoke(): AccountEntity? = withContext(Dispatchers.IO) {
accountsRepository.getAccountById(UserConfig.currentUserId)
}
@@ -21,7 +21,8 @@ interface OAuthUseCase {
password: String,
forceSms: Boolean,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
captchaSid: String? = null,
captchaKey: String? = null,
successToken: String? = null
): Flow<State<GetSilentTokenResponse>>
}
@@ -48,7 +48,8 @@ class OAuthUseCaseImpl(
forceSms: Boolean,
validationCode: String?,
captchaSid: String?,
captchaKey: String?
captchaKey: String?,
successToken: String?
): Flow<State<GetSilentTokenResponse>> = flow {
emit(State.Loading)
@@ -58,7 +59,8 @@ class OAuthUseCaseImpl(
forceSms = forceSms,
validationCode = validationCode,
captchaSid = captchaSid,
captchaKey = captchaKey
captchaKey = captchaKey,
successToken = successToken
).asState()
emit(newState)
@@ -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)
}
@@ -27,6 +27,8 @@ fun VkConvo.asPresentation(
monthShort = { resources.getString(R.string.month_short) },
weekShort = { resources.getString(R.string.week_short) },
dayShort = { resources.getString(R.string.day_short) },
minuteShort = { resources.getString(R.string.minute_short) },
secondShort = { resources.getString(R.string.second_short) },
now = { resources.getString(R.string.time_now) },
),
message = extractMessage(resources, lastMessage, id, peerType),
@@ -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),
)
}
@@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class VkWidgetData(
val id: Long
val id: Long?
) : VkAttachmentData {
fun toDomain() = VkWidgetDomain(id)
@@ -1,7 +1,9 @@
package dev.meloda.fast.model.api.domain
import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.api.data.AttachmentType
@Immutable
interface VkAttachment {
val type: AttachmentType
}
@@ -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
}
@@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType
data class VkWidgetDomain(
val id: Long
val id: Long?
) : VkAttachment {
override val type: AttachmentType = AttachmentType.WIDGET
@@ -12,7 +12,8 @@ data class AuthDirectRequest(
val validationCode: String? = null,
val captchaSid: String? = null,
val captchaKey: String? = null,
val trustedHash: String? = null
val trustedHash: String? = null,
val successToken: String? = null
) {
val map
@@ -31,6 +32,7 @@ data class AuthDirectRequest(
captchaSid?.let { this["captcha_sid"] = it }
captchaKey?.let { this["captcha_key"] = it }
trustedHash?.let { this["trusted_hash"] = it }
successToken?.let { this["success_token"] = it }
}
}
@@ -16,7 +16,8 @@ sealed class OAuthErrorDomain {
data class CaptchaRequiredError(
val captchaSid: String,
val captchaImageUrl: String
val captchaImageUrl: String,
val redirectUri: String?
) : OAuthErrorDomain()
data class UserBannedError(
@@ -53,6 +53,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
},
onFailure = { failure ->
if (failure is JsonDataException) {
Log.d("ResponseBodyConverter", "convertJsonDataException: $failure")
throw ApiException(
RestApiError(
errorCode = -1,
@@ -2,7 +2,8 @@ package dev.meloda.fast.network
enum class ValidationType(val value: String) {
APP("2fa_app"),
SMS("2fa_sms");
SMS("sms"),
SMS2("2fa_sms");
companion object {
fun parse(value: String): ValidationType =
@@ -11,6 +11,7 @@ import dev.meloda.fast.network.JsonConverter
import dev.meloda.fast.network.MoshiConverter
import dev.meloda.fast.network.OAuthResultCallFactory
import dev.meloda.fast.network.ResponseConverterFactory
import dev.meloda.fast.network.interceptor.Error14HandlingInterceptor
import dev.meloda.fast.network.interceptor.LanguageInterceptor
import dev.meloda.fast.network.interceptor.VersionInterceptor
import dev.meloda.fast.network.service.account.AccountService
@@ -45,6 +46,7 @@ val networkModule = module {
single { ChuckerInterceptor.Builder(get()).collector(get()).build() }
singleOf(::VersionInterceptor)
singleOf(::LanguageInterceptor)
singleOf(::Error14HandlingInterceptor)
single<OkHttpClient>(named("auth")) {
buildHttpClient(true)
@@ -101,6 +103,7 @@ private fun Scope.buildHttpClient(forAuth: Boolean): OkHttpClient {
addInterceptor(get(named("token_interceptor")) as Interceptor)
}
}
.addInterceptor(get<Error14HandlingInterceptor>())
.addInterceptor(get<VersionInterceptor>())
.addInterceptor(get<LanguageInterceptor>())
.addInterceptor(get<ChuckerInterceptor>())
@@ -0,0 +1,145 @@
package dev.meloda.fast.network.interceptor
import android.util.Log
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.CaptchaTokenResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
class Error14HandlingInterceptor(
// private val domains: Set<String> = emptySet(),
) : Interceptor {
private val cookie = AtomicReference<String?>(null)
private companion object {
private const val CAPTCHA_ERROR_CODE = 14
private const val CAPTCHA_ERROR_KIND = "need_captcha"
private val executor = Executors.newSingleThreadExecutor()
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().withCookie()
val response = chain.proceed(request)
response.parseCookie()
if (request.shouldSkipCaptcha()) return response
val redirectUri = response.getRedirectUri() ?: return response
val token = passCaptchaAndGetToken(redirectUri)
return chain.proceed(chain.request().withCookie().withSuccessToken(token))
}
private fun passCaptchaAndGetToken(redirectUri: String): String = synchronized(this) {
val tokenResult = AtomicReference<Result<String>>(Result.failure(Exception("No result")))
executor.submit {
AppSettings.setCaptchaRedirectUri(redirectUri)
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $redirectUri")
var job: Job? = null
job = AppSettings.getCaptchaResultFlow()
.listenValue(CoroutineScope(Dispatchers.IO)) {
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $it")
if (it != CaptchaTokenResult.Initial) {
synchronized(tokenResult) {
Log.d(
"Error14Interceptor",
"passCaptchaAndGetToken: SYNCHRONIZED: $it"
)
tokenResult.set(wrapResult(it))
tokenResult.notifyAll()
job?.cancel()
Log.d(
"Error14Interceptor",
"passCaptchaAndGetToken: NULL RESULT"
)
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
AppSettings.setCaptchaRedirectUri(null)
}
}
}
}
synchronized(tokenResult) {
if (tokenResult.get().getOrNull() == null) {
tokenResult.wait()
}
Log.d("Error14Interceptor", "passCaptchaAndGetToken: GET VALUE")
tokenResult.get().getOrThrow()
}
}
private fun wrapResult(result: CaptchaTokenResult): Result<String> {
return when (result) {
// TODO: 03/05/2026, Danil Nikolaev: check again?
CaptchaTokenResult.Null -> Result.success("")
CaptchaTokenResult.Cancelled, CaptchaTokenResult.Initial -> Result.success("")
is CaptchaTokenResult.Success -> Result.success(result.token)
}
}
private fun Request.withSuccessToken(token: String): Request {
return newBuilder()
.url(url.newBuilder().addQueryParameter("success_token", token).build())
.build()
}
private fun Response.getRedirectUri(): String? {
val responseBody = JSONObject(peekBody(Long.MAX_VALUE).string())
return if (responseBody.has("error")) {
val stringError = try {
responseBody.getString("error")
} catch (ignored: Exception) {
null
}
if (stringError != null) {
if (stringError == CAPTCHA_ERROR_KIND && responseBody.has("redirect_uri")) {
responseBody.getString("redirect_uri")
} else {
null
}
} else {
val error = responseBody.getJSONObject("error")
if (error.getInt("error_code") == CAPTCHA_ERROR_CODE) {
error.getString("redirect_uri")
} else {
null
}
}
} else {
null
}
}
private fun Request.shouldSkipCaptcha(): Boolean {
return false
// return !domains.contains(url.toUrl().host) && domains.isNotEmpty()
}
private fun Response.parseCookie() {
headers("Set-Cookie").firstOrNull { it.contains("remixstlid") }?.let(cookie::set)
}
private fun Request.withCookie(): Request {
return newBuilder().apply { cookie.get()?.let { addHeader("Cookie", it) } }.build()
}
}
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.wait() = (this as Object).wait()
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.notify() = (this as Object).notify()
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.notifyAll() = (this as Object).notifyAll()
@@ -0,0 +1,48 @@
package dev.meloda.fast.ui.common
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_TYPE_NORMAL
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers.BLUE_DOMINATED_EXAMPLE
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
import androidx.compose.ui.tooling.preview.Wallpapers.RED_DOMINATED_EXAMPLE
import androidx.compose.ui.tooling.preview.Wallpapers.YELLOW_DOMINATED_EXAMPLE
@Preview(name = "70%", fontScale = 0.70f)
@Preview(name = "85%", fontScale = 0.85f)
@Preview(name = "100%", fontScale = 1.0f)
@Preview(name = "115%", fontScale = 1.15f)
@Preview(name = "130%", fontScale = 1.3f)
@Preview(name = "150%", fontScale = 1.5f)
@Preview(name = "180%", fontScale = 1.8f)
@Preview(name = "200%", fontScale = 2f)
@Preview(name = "Light")
@Preview(name = "Red", wallpaper = RED_DOMINATED_EXAMPLE)
@Preview(name = "Blue", wallpaper = BLUE_DOMINATED_EXAMPLE)
@Preview(name = "Green", wallpaper = GREEN_DOMINATED_EXAMPLE)
@Preview(name = "Yellow", wallpaper = YELLOW_DOMINATED_EXAMPLE)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
@Preview(
name = "Dark Red",
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
wallpaper = RED_DOMINATED_EXAMPLE
)
@Preview(
name = "Dark Blue",
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
wallpaper = BLUE_DOMINATED_EXAMPLE
)
@Preview(
name = "Dark Green",
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
wallpaper = GREEN_DOMINATED_EXAMPLE
)
@Preview(
name = "Dark Yellow",
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
wallpaper = YELLOW_DOMINATED_EXAMPLE
)
annotation class FastPreview
@@ -2,6 +2,7 @@ package dev.meloda.fast.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -46,9 +47,14 @@ import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewDynamicColors
import androidx.compose.ui.tooling.preview.PreviewFontScale
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@@ -361,10 +367,10 @@ sealed class SelectionType {
data object None : SelectionType()
}
@Preview
@FastPreview
@Composable
private fun MaterialDialogPreview() {
AppTheme {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog(
onDismissRequest = {},
title = "Material Dialog",
@@ -376,10 +382,10 @@ private fun MaterialDialogPreview() {
}
}
@Preview
@FastPreview
@Composable
private fun MaterialDialogWithListPreview() {
AppTheme {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog(
onDismissRequest = {},
title = "Material Dialog",
@@ -393,10 +399,10 @@ private fun MaterialDialogWithListPreview() {
}
}
@Preview
@FastPreview
@Composable
private fun MaterialDialogWithCustomContent() {
AppTheme {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog(
onDismissRequest = {},
title = "Material Dialog",
@@ -425,10 +431,10 @@ private fun MaterialDialogWithCustomContent() {
}
}
@Preview
@FastPreview
@Composable
private fun MaterialDialogWithOnlyCustomContent() {
AppTheme {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
MaterialDialog(onDismissRequest = {}) {
Row(
modifier = Modifier
@@ -1,5 +1,6 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -8,15 +9,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.theme.AppTheme
@Composable
fun NoItemsView(
@@ -49,11 +52,15 @@ fun NoItemsView(
}
}
@Preview
@FastPreview
@Composable
private fun NoItemsViewPreview() {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
Surface {
NoItemsView(
customText = "Nothing here...",
buttonText = "Refresh"
)
}
}
}
@@ -9,6 +9,7 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.ParametersDefinition
import org.koin.core.qualifier.Qualifier
@Suppress("ParamsComparedByRef")
@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
navController: NavController,
@@ -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 =
@@ -9,10 +9,6 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
operator fun get(index: Int): T = values[index]
inline fun forEach(action: (T) -> Unit) {
for (element in values) action(element)
}
inline fun <R> map(transform: (T) -> R): ImmutableList<R> {
return values.map(transform).toImmutableList()
}
@@ -49,6 +45,8 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
if (elements.isNotEmpty()) copyOf(elements.asList()) else empty()
fun <T> of(element: T) = ImmutableList(listOf(element))
fun <T> ImmutableList<T>?.orEmpty(): ImmutableList<T> = this ?: emptyImmutableList()
}
override fun iterator(): Iterator<T> = values.listIterator()
@@ -59,5 +57,3 @@ 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))
fun <T> ImmutableList<T>?.orEmpty(): ImmutableList<T> = this ?: emptyImmutableList()
@@ -232,8 +232,10 @@
<string name="month_short">М</string>
<string name="week_short">Н</string>
<string name="day_short">Д</string>
<string name="second_short">С</string>
<string name="time_now">Сейчас</string>
<string name="confirm_chat_create_with_title">Вы действительно хотите создать чат «%s»?</string>
<string name="confirm_chat_create_empty_with_title">Вы действительно хотите создать чат «%s» только с собой?</string>
<string name="minute_short">М</string>
</resources>
+9 -1
View File
@@ -1,4 +1,4 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<string name="app_name" translatable="false">Fast</string>
<string name="fast_messenger" translatable="false">Fast Messenger</string>
@@ -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>
@@ -297,8 +298,15 @@
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="minute_short">M</string>
<string name="second_short">S</string>
<string name="time_now">Now</string>
<string name="confirm_chat_create_with_title">Are you sure you want to create chat «%s»?</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="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>
@@ -3,7 +3,6 @@ package dev.meloda.fast.auth.login
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import dev.meloda.fast.auth.login.presentation.LogoScreen
import org.junit.Rule
import org.junit.Test
@@ -15,7 +14,7 @@ class LogoScreenTest {
@Test
fun goNextButton_isClickable() {
composeTestRule.setContent {
LogoScreen()
}
composeTestRule.onNodeWithTag(testTag = "go_next_fab").assertHasClickAction()
@@ -3,9 +3,6 @@ package dev.meloda.fast.auth
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.navigation
import dev.meloda.fast.auth.captcha.navigation.captchaScreen
import dev.meloda.fast.auth.captcha.navigation.navigateToCaptcha
import dev.meloda.fast.auth.captcha.navigation.setCaptchaResult
import dev.meloda.fast.auth.login.navigation.Login
import dev.meloda.fast.auth.login.navigation.loginScreen
import dev.meloda.fast.auth.userbanned.model.UserBannedArguments
@@ -28,11 +25,6 @@ fun NavGraphBuilder.authNavGraph(
) {
navigation<AuthGraph>(startDestination = Login) {
loginScreen(
onNavigateToCaptcha = { arguments ->
navController.navigateToCaptcha(
captchaImageUrl = URLEncoder.encode(arguments.captchaImageUrl, "utf-8")
)
},
onNavigateToValidation = { arguments ->
navController.navigateToValidation(
ValidationArguments(
@@ -70,17 +62,6 @@ fun NavGraphBuilder.authNavGraph(
}
)
captchaScreen(
onBack = {
navController.setCaptchaResult(null)
navController.navigateUp()
},
onResult = { code ->
navController.setCaptchaResult(code)
navController.popBackStack()
}
)
userBannedRoute(onBack = navController::navigateUp)
}
}
@@ -1,6 +1,5 @@
package dev.meloda.fast.auth
import dev.meloda.fast.auth.captcha.di.captchaModule
import dev.meloda.fast.auth.validation.di.validationModule
import dev.meloda.fast.auth.login.di.loginModule
import org.koin.dsl.module
@@ -9,6 +8,5 @@ val authModule = module {
includes(
loginModule,
validationModule,
captchaModule,
)
}
@@ -1,69 +0,0 @@
package dev.meloda.fast.auth.captcha
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
import dev.meloda.fast.auth.captcha.navigation.Captcha
import dev.meloda.fast.auth.captcha.validation.CaptchaValidator
import dev.meloda.fast.common.extensions.setValue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.net.URLDecoder
interface CaptchaViewModel {
val screenState: StateFlow<CaptchaScreenState>
val isNeedToOpenLogin: StateFlow<Boolean>
fun onCodeInputChanged(newCode: String)
fun onTextFieldDoneAction()
fun onDoneButtonClicked()
fun onNavigatedToLogin()
}
class CaptchaViewModelImpl(
private val validator: CaptchaValidator,
savedStateHandle: SavedStateHandle
) : CaptchaViewModel, ViewModel() {
override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY)
override val isNeedToOpenLogin = MutableStateFlow(false)
init {
val captchaImage = Captcha.from(savedStateHandle).captchaImageUrl
screenState.setValue { old ->
old.copy(captchaImageUrl = URLDecoder.decode(captchaImage, "utf-8"))
}
}
override fun onCodeInputChanged(newCode: String) {
val newState = screenState.value.copy(code = newCode.trim())
screenState.update { newState }
processValidation()
}
override fun onTextFieldDoneAction() {
onDoneButtonClicked()
}
override fun onDoneButtonClicked() {
if (!processValidation()) return
isNeedToOpenLogin.update { true }
}
override fun onNavigatedToLogin() {
screenState.update { CaptchaScreenState.EMPTY }
isNeedToOpenLogin.update { false }
}
private fun processValidation(): Boolean {
val isValid = validator.validate(screenState.value).isValid()
screenState.setValue { old -> old.copy(codeError = !isValid) }
return isValid
}
}
@@ -1,14 +0,0 @@
package dev.meloda.fast.auth.captcha.di
import dev.meloda.fast.auth.captcha.CaptchaViewModel
import dev.meloda.fast.auth.captcha.CaptchaViewModelImpl
import dev.meloda.fast.auth.captcha.validation.CaptchaValidator
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind
import org.koin.dsl.module
val captchaModule = module {
singleOf(::CaptchaValidator)
viewModelOf(::CaptchaViewModelImpl) bind CaptchaViewModel::class
}
@@ -1,16 +0,0 @@
package dev.meloda.fast.auth.captcha.model
data class CaptchaScreenState(
val captchaImageUrl: String,
val code: String,
val codeError: Boolean
) {
companion object {
val EMPTY = CaptchaScreenState(
captchaImageUrl = "",
code = "",
codeError = false
)
}
}
@@ -1,8 +0,0 @@
package dev.meloda.fast.auth.captcha.model
sealed class CaptchaValidationResult {
data object Empty : CaptchaValidationResult()
data object Valid : CaptchaValidationResult()
fun isValid() = this == Valid
}
@@ -1,40 +0,0 @@
package dev.meloda.fast.auth.captcha.navigation
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import dev.meloda.fast.auth.captcha.presentation.CaptchaRoute
import kotlinx.serialization.Serializable
@Serializable
data class Captcha(val captchaImageUrl: String) {
companion object {
fun from(savedStateHandle: SavedStateHandle) = savedStateHandle.toRoute<Captcha>()
}
}
fun NavGraphBuilder.captchaScreen(
onBack: () -> Unit,
onResult: (String) -> Unit
) {
composable<Captcha> {
CaptchaRoute(
onBack = onBack,
onResult = onResult
)
}
}
fun NavController.navigateToCaptcha(captchaImageUrl: String) {
this.navigate(Captcha(captchaImageUrl))
}
fun NavController.setCaptchaResult(code: String?) {
this.previousBackStackEntry
?.savedStateHandle
?.set("captcha_code", code)
}
@@ -1,32 +1,22 @@
package dev.meloda.fast.auth.captcha.presentation
import android.graphics.Bitmap
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -36,62 +26,34 @@ 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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import coil.compose.AsyncImage
import dev.meloda.fast.auth.captcha.CaptchaViewModel
import dev.meloda.fast.auth.captcha.CaptchaViewModelImpl
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
import androidx.compose.ui.viewinterop.AndroidView
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.FullScreenDialog
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText
import org.koin.androidx.compose.koinViewModel
import org.json.JSONObject
@Composable
fun CaptchaRoute(
onBack: () -> Unit,
onResult: (String) -> Unit,
viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>()
) {
LocalViewModelStoreOwner.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToOpenLogin) {
if (isNeedToOpenLogin) {
viewModel.onNavigatedToLogin()
onResult(screenState.code)
}
}
CaptchaScreen(
screenState = screenState,
onBack = onBack,
onCodeInputChanged = viewModel::onCodeInputChanged,
onTextFieldDoneAction = viewModel::onTextFieldDoneAction,
onDoneButtonClicked = viewModel::onDoneButtonClicked
)
}
private const val TAG = "CaptchaScreen"
@Composable
fun CaptchaScreen(
screenState: CaptchaScreenState = CaptchaScreenState.EMPTY,
captchaRedirectUri: String?,
onBack: () -> Unit = {},
onCodeInputChanged: (String) -> Unit = {},
onTextFieldDoneAction: () -> Unit = {},
onDoneButtonClicked: () -> Unit = {}
onResult: (String) -> Unit = {}
) {
if (captchaRedirectUri != null) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(true) {
focusManager.clearFocus(true)
keyboardController?.hide()
}
var confirmedExit by remember {
mutableStateOf(false)
}
@@ -100,6 +62,10 @@ fun CaptchaScreen(
mutableStateOf(false)
}
var isWebViewLoading by remember {
mutableStateOf(true)
}
LaunchedEffect(confirmedExit) {
if (confirmedExit) {
onBack()
@@ -112,6 +78,7 @@ fun CaptchaScreen(
}
}
FullScreenDialog(onDismiss = { showExitAlert = true }) {
if (showExitAlert) {
MaterialDialog(
onDismissRequest = { showExitAlert = false },
@@ -124,146 +91,104 @@ fun CaptchaScreen(
)
}
val focusManager = LocalFocusManager.current
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime)
) { padding ->
Column(
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(30.dp),
verticalArrangement = Arrangement.SpaceBetween
.navigationBarsPadding()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { showExitAlert = true }
)
) {
ExtendedFloatingActionButton(
onClick = onBack,
text = {
Text(
text = "Cancel",
color = MaterialTheme.colorScheme.onPrimaryContainer
)
},
icon = {
Icon(
painter = painterResource(R.drawable.ic_close_round_24),
contentDescription = "Close icon",
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
)
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Captcha",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(38.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "To proceed with your action, enter a code from the picture",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(0.5f)
)
Spacer(modifier = Modifier.width(24.dp))
val imageModifier = Modifier
.border(
2.dp,
MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(10.dp)
)
.clip(RoundedCornerShape(10.dp))
.height(48.dp)
.width(130.dp)
if (LocalView.current.isInEditMode) {
Image(
painter = painterResource(id = R.drawable.img_test_captcha),
contentDescription = "Captcha image",
modifier = imageModifier
)
} else {
AsyncImage(
model = screenState.captchaImageUrl,
contentDescription = "Captcha image",
contentScale = ContentScale.FillBounds,
modifier = imageModifier
)
}
}
Spacer(modifier = Modifier.height(30.dp))
var code by remember { mutableStateOf(TextFieldValue(screenState.code)) }
val showError = screenState.codeError
TextField(
value = code,
onValueChange = { newText ->
code = newText
onCodeInputChanged(newText.text)
},
label = { Text(text = "Code") },
placeholder = { Text(text = "Code") },
AndroidView(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp)),
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_qr_code_round_24),
contentDescription = "QR code icon",
tint = if (showError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
}
)
},
shape = RoundedCornerShape(10.dp),
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
onTextFieldDoneAction()
}
),
isError = showError
)
AnimatedVisibility(visible = showError) {
TextFieldErrorText(text = "Field must not be empty")
}
.fillMaxSize()
.align(Alignment.BottomCenter),
factory = { context ->
val webview = WebView(context)
webview.setBackgroundColor(0)
webview.settings.javaScriptEnabled = true
webview.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
Log.i(TAG, "shouldOverrideUrlLoading: $request")
return false
}
FloatingActionButton(
onClick = onDoneButtonClicked,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.align(Alignment.CenterHorizontally)
override fun onPageStarted(
view: WebView?,
url: String?,
favicon: Bitmap?
) {
Icon(
painter = painterResource(R.drawable.ic_check_round_24),
contentDescription = "Done icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
super.onPageStarted(view, url, favicon)
isWebViewLoading = true
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
isWebViewLoading = false
}
}
webview.addJavascriptInterface(
WebCaptchaListener(
onSuccessTokenReceived = {
val response: String? = try {
JSONObject(it).getString("token")
} catch (e: Exception) {
e.printStackTrace()
null
}
if (response != null) {
onResult(response)
} else {
// TODO: 03/05/2026, Danil Nikolaev: show error
}
},
onCloseRequested = { showExitAlert = true }
),
"AndroidBridge"
)
// webview.loadUrl("https://id.vk.ru/not_robot_captcha?variant=block&session_token=test&domain=test.com")
webview.loadUrl(captchaRedirectUri)
webview
}
)
AnimatedVisibility(
visible = isWebViewLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center)
) {
CircularProgressIndicator(
modifier = Modifier.size(50.dp),
color = Color.White.copy(alpha = 0.85f)
)
}
}
}
}
}
@Preview
@Composable
private fun CaptchaScreenPreview() {
CaptchaScreen(
screenState = CaptchaScreenState.EMPTY.copy(
code = "zcuecz"
)
)
class WebCaptchaListener(
private val onSuccessTokenReceived: (String) -> Unit,
private val onCloseRequested: (String) -> Unit
) {
private val tag = "WebCaptchaListener"
@JavascriptInterface
fun VKCaptchaGetResult(arg: String) {
onSuccessTokenReceived(arg)
Log.i(tag, "VKCaptchaGetResult($arg)")
}
@JavascriptInterface
fun VKCaptchaCloseCaptcha(arg: String) {
onCloseRequested(arg)
Log.i(tag, "VKCaptchaCloseCaptcha($arg)")
}
}
@@ -1,14 +0,0 @@
package dev.meloda.fast.auth.captcha.validation
import dev.meloda.fast.auth.captcha.model.CaptchaScreenState
import dev.meloda.fast.auth.captcha.model.CaptchaValidationResult
class CaptchaValidator {
fun validate(screenState: CaptchaScreenState): CaptchaValidationResult {
return when {
screenState.code.trim().isEmpty() -> CaptchaValidationResult.Empty
else -> CaptchaValidationResult.Valid
}
}
}
@@ -59,18 +59,12 @@ class LoginViewModel(
private val _validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
val validationArguments = _validationArguments.asStateFlow()
private val _captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
val captchaArguments = _captchaArguments.asStateFlow()
private val _userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
val userBannedArguments = _userBannedArguments.asStateFlow()
private val _isNeedToOpenMain = MutableStateFlow(false)
val isNeedToOpenMain = _isNeedToOpenMain.asStateFlow()
private val _isNeedToClearCaptchaCode = MutableStateFlow(false)
val isNeedToClearCaptchaCode = _isNeedToClearCaptchaCode.asStateFlow()
private val _isNeedToClearValidationCode = MutableStateFlow(false)
val isNeedToClearValidationCode = _isNeedToClearValidationCode.asStateFlow()
@@ -78,17 +72,10 @@ class LoginViewModel(
screenState.map(loginValidator::validate)
.stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty))
private val captchaSid = MutableStateFlow<String?>(null)
private val captchaCode = MutableStateFlow<String?>(null)
private val validationSid = MutableStateFlow<String?>(null)
private val validationCode = MutableStateFlow<String?>(null)
init {
captchaCode.listenValue(viewModelScope) {
if (it != null) {
login()
}
}
validationCode.listenValue(viewModelScope) {
if (it != null) {
login()
@@ -113,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() {
@@ -165,10 +158,6 @@ class LoginViewModel(
_userBannedArguments.update { null }
}
fun onNavigatedToCaptcha() {
_captchaArguments.update { null }
}
fun onNavigatedToValidation() {
_validationArguments.update { null }
}
@@ -181,25 +170,9 @@ class LoginViewModel(
_isNeedToClearValidationCode.update { false }
}
fun onCaptchaCodeReceived(code: String?) {
captchaCode.update { code }
}
fun onCaptchaCodeCleared() {
_isNeedToClearCaptchaCode.update { false }
}
private fun login(forceSms: Boolean = false) {
val currentState = screenState.value.copy()
Log.d(
"LoginViewModel",
"auth: login: ${currentState.login}; " +
"password: ${currentState.password}; " +
"2fa code: ${validationCode.value}; " +
"captcha code: ${captchaCode.value}"
)
processValidation()
if (!validationState.value.contains(LoginValidationResult.Valid)) return
@@ -207,23 +180,18 @@ class LoginViewModel(
val currentValidationSid = validationSid.value
val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null }
val currentCaptchaSid = captchaSid.value
val currentCaptchaCode = captchaCode.value?.takeIf { currentCaptchaSid != null }
oAuthUseCase.getSilentToken(
login = currentState.login,
password = currentState.password,
forceSms = forceSms,
validationCode = currentValidationCode,
captchaSid = currentCaptchaSid,
captchaKey = currentCaptchaCode
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error")
_screenState.updateValue { copy(isLoading = false) }
captchaSid.setValue { null }
parseError(error)
},
@@ -286,7 +254,6 @@ class LoginViewModel(
startLongPoll()
captchaSid.update { null }
validationSid.update { null }
loadUserByIdUseCase(
@@ -333,11 +300,8 @@ class LoginViewModel(
is OAuthErrorDomain.CaptchaRequiredError -> {
val arguments = CaptchaArguments(
captchaSid = error.captchaSid,
captchaImageUrl = error.captchaImageUrl
redirectUri = error.redirectUri
)
_captchaArguments.update { arguments }
captchaSid.update { error.captchaSid }
}
OAuthErrorDomain.InvalidCredentialsError -> {
@@ -7,6 +7,5 @@ import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class CaptchaArguments(
val captchaSid: String,
val captchaImageUrl: String
val redirectUri: String?
) : Parcelable
@@ -3,13 +3,11 @@ package dev.meloda.fast.auth.login.navigation
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments
import dev.meloda.fast.auth.login.presentation.LoginRoute
@@ -20,7 +18,6 @@ import kotlinx.serialization.Serializable
object Login
fun NavGraphBuilder.loginScreen(
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
@@ -32,7 +29,6 @@ fun NavGraphBuilder.loginScreen(
backStackEntry.sharedViewModel<LoginViewModel>(navController = navController)
val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle()
val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle()
LaunchedEffect(clearValidationCode) {
if (clearValidationCode) {
@@ -41,24 +37,14 @@ fun NavGraphBuilder.loginScreen(
}
}
LaunchedEffect(clearCaptchaCode) {
if (clearCaptchaCode) {
backStackEntry.savedStateHandle["captcha_code"] = null
viewModel.onCaptchaCodeCleared()
}
}
val validationCode = backStackEntry.getValidationResult()
val captchaCode = backStackEntry.getCaptchaResult()
LoginRoute(
onNavigateToUserBanned = onNavigateToUserBanned,
onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToValidation = onNavigateToValidation,
onNavigateToSettings = onNavigateToSettings,
validationCode = validationCode,
captchaCode = captchaCode,
viewModel = viewModel
)
}
@@ -67,7 +53,3 @@ fun NavGraphBuilder.loginScreen(
fun NavBackStackEntry.getValidationResult(): String? {
return savedStateHandle["validation_code"]
}
fun NavBackStackEntry.getCaptchaResult(): String? {
return savedStateHandle["captcha_code"]
}
@@ -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
@@ -75,17 +80,14 @@ import org.koin.androidx.compose.koinViewModel
fun LoginRoute(
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToSettings: () -> Unit,
validationCode: String?,
captchaCode: String?,
viewModel: LoginViewModel = koinViewModel()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
val userBannedArguments by viewModel.userBannedArguments.collectAsStateWithLifecycle()
val captchaArguments by viewModel.captchaArguments.collectAsStateWithLifecycle()
val validationArguments by viewModel.validationArguments.collectAsStateWithLifecycle()
val loginDialog by viewModel.loginDialog.collectAsStateWithLifecycle()
@@ -107,12 +109,6 @@ fun LoginRoute(
onNavigateToUserBanned(arguments)
}
}
LaunchedEffect(captchaArguments) {
captchaArguments?.let { arguments ->
viewModel.onNavigatedToCaptcha()
onNavigateToCaptcha(arguments)
}
}
LaunchedEffect(validationArguments) {
validationArguments?.let { arguments ->
viewModel.onNavigatedToValidation()
@@ -122,10 +118,13 @@ fun LoginRoute(
LaunchedEffect(validationCode) {
viewModel.onValidationCodeReceived(validationCode)
}
LaunchedEffect(captchaCode) {
viewModel.onCaptchaCodeReceived(captchaCode)
}
var useClassic by rememberSaveable { mutableStateOf(true) }
AppTheme(
predefinedColorScheme = if (useClassic) ClassicColorScheme.lightScheme
else lightColorScheme(),
) {
LoginScreen(
screenState = screenState,
onLoginInputChanged = viewModel::onLoginInputChanged,
@@ -134,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,
@@ -4,7 +4,6 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.meloda.fast.auth.validation.model.ValidationScreenState
import dev.meloda.fast.auth.validation.model.ValidationType
import dev.meloda.fast.auth.validation.navigation.Validation
import dev.meloda.fast.auth.validation.validation.ValidationValidator
import dev.meloda.fast.common.extensions.createTimerFlow
@@ -12,6 +11,7 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.AuthUseCase
import dev.meloda.fast.network.ValidationType
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -1,10 +0,0 @@
package dev.meloda.fast.auth.validation.model
enum class ValidationType(val value: String) {
SMS("sms"), APP("2fa_app");
companion object {
fun parse(value: String): ValidationType = entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown validation type with value: $value")
}
}
@@ -3,6 +3,7 @@ package dev.meloda.fast.auth.validation.presentation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -46,17 +47,18 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.validation.ValidationViewModel
import dev.meloda.fast.auth.validation.ValidationViewModelImpl
import dev.meloda.fast.auth.validation.model.ValidationScreenState
import dev.meloda.fast.auth.validation.model.ValidationType
import dev.meloda.fast.network.ValidationType
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.AppTheme
import org.koin.androidx.compose.koinViewModel
@Composable
@@ -116,7 +118,7 @@ fun ValidationScreen(
val validationText by remember(validationType) {
mutableStateOf(
when (validationType) {
ValidationType.SMS -> "SMS with the code is sent to ${screenState.phoneMask}"
ValidationType.SMS, ValidationType.SMS2 -> "SMS with the code is sent to ${screenState.phoneMask}"
ValidationType.APP -> "Enter the code from the code generator application"
null -> ""
@@ -301,9 +303,10 @@ fun ValidationScreen(
}
}
@Preview
@FastPreview
@Composable
private fun ValidationScreenPreview() {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
ValidationScreen(
screenState = ValidationScreenState.EMPTY.copy(
phoneMask = "+7 (***) ***-**-21",
@@ -311,4 +314,5 @@ private fun ValidationScreenPreview() {
),
validationType = ValidationType.SMS
)
}
}
@@ -53,6 +53,7 @@ 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
@@ -19,7 +19,7 @@ interface MessagesHistoryViewModel {
val dialog: StateFlow<MessageDialog?>
val selectedMessages: StateFlow<List<VkMessage>>
val inputFieldFocusRequester: StateFlow<Boolean>
val showKeyboard: StateFlow<Boolean>
val isNeedToScrollToIndex: StateFlow<Int?>
@@ -54,6 +54,7 @@ interface MessagesHistoryViewModel {
fun onPinnedMessageClicked(messageId: Long)
fun onUnpinMessageClicked()
fun onEditSelectedMessageClicked()
fun onDeleteSelectedMessagesClicked()
fun onBoldClicked()
@@ -66,5 +67,7 @@ interface MessagesHistoryViewModel {
fun onRequestReplyToMessage(cmId: Long)
fun onKeyboardShown()
suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int
}
@@ -45,6 +45,7 @@ import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.domain.util.extractReplySummary
import dev.meloda.fast.domain.util.extractReplyTitle
import dev.meloda.fast.domain.util.extractTitle
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessageDialog
@@ -55,7 +56,6 @@ import dev.meloda.fast.messageshistory.navigation.MessagesHistory
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.network.VkErrorCode
@@ -65,6 +65,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
@@ -73,7 +74,6 @@ import kotlinx.serialization.json.put
import java.io.File
import java.io.FileOutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.math.abs
import kotlin.random.Random
@@ -94,7 +94,7 @@ class MessagesHistoryViewModelImpl(
override val dialog = MutableStateFlow<MessageDialog?>(null)
override val selectedMessages = MutableStateFlow<List<VkMessage>>(emptyList())
override val inputFieldFocusRequester = MutableStateFlow(false)
override val showKeyboard = MutableStateFlow(false)
override val isNeedToScrollToIndex = MutableStateFlow<Int?>(null)
@@ -115,6 +115,8 @@ class MessagesHistoryViewModelImpl(
private var replyToCmId: Long? = null
private var editMessage: VkMessage? = null
init {
val arguments = MessagesHistory.from(savedStateHandle).arguments
@@ -229,7 +231,7 @@ class MessagesHistoryViewModelImpl(
override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) {
when (dialog) {
is MessageDialog.MessageOptions -> {
val messageId = bundle.getLong("messageId")
// val messageId = bundle.getLong("messageId")
val cmId = bundle.getLong("cmId")
when (val option = bundle.getParcelableCompat("option", MessageOption::class)) {
@@ -289,7 +291,10 @@ class MessagesHistoryViewModelImpl(
}
}
MessageOption.Edit -> {}
MessageOption.Edit -> {
editMessage(cmId)
syncUiMessages()
}
MessageOption.Delete -> {
this.dialog.setValue {
@@ -313,7 +318,14 @@ class MessagesHistoryViewModelImpl(
}
override fun onCloseButtonClicked() {
if (selectedMessages.value.isNotEmpty()) {
selectedMessages.setValue { emptyList() }
}
if (screenState.value.editCmId != null) {
stopEditMessage()
}
syncUiMessages()
}
@@ -329,8 +341,20 @@ class MessagesHistoryViewModelImpl(
screenState.setValue { old ->
old.copy(
message = newText,
actionMode = if (newText.text.isBlank()) ActionMode.RECORD_AUDIO
else ActionMode.SEND
actionMode =
when {
screenState.value.editCmId != null -> {
// TODO: 13/03/2026, Danil Nikolaev: also check if attachments is empty
if (newText.text.trim().isEmpty()) {
ActionMode.DELETE
} else {
ActionMode.EDIT
}
}
newText.text.trim().isEmpty() -> ActionMode.RECORD_AUDIO
else -> ActionMode.SEND
}
)
}
updateStyles()
@@ -347,13 +371,9 @@ class MessagesHistoryViewModelImpl(
override fun onActionButtonClicked() {
when (screenState.value.actionMode) {
ActionMode.DELETE -> {
ActionMode.DELETE -> confirmDeleteCurrentEditMessage()
}
ActionMode.EDIT -> {
}
ActionMode.EDIT -> editCurrentEditMessage()
ActionMode.RECORD_AUDIO -> {
screenState.setValue { it.copy(actionMode = ActionMode.RECORD_VIDEO) }
@@ -429,6 +449,16 @@ class MessagesHistoryViewModelImpl(
}
}
override fun onEditSelectedMessageClicked() {
val cmId = selectedMessages.value.firstOrNull()?.cmId ?: return
selectedMessages.setValue { emptyList() }
editMessage(cmId)
syncUiMessages()
}
override fun onDeleteSelectedMessagesClicked() {
dialog.setValue {
MessageDialog.MessagesDelete(selectedMessages.value)
@@ -438,7 +468,7 @@ class MessagesHistoryViewModelImpl(
private fun replyToMessage(cmId: Long) {
val messageToReply = messages.value.find { it.cmId == cmId } ?: return
inputFieldFocusRequester.setValue { true }
showKeyboard.setValue { true }
replyToCmId = cmId
screenState.setValue { old ->
old.copy(
@@ -448,6 +478,56 @@ class MessagesHistoryViewModelImpl(
}
}
private fun editMessage(cmId: Long) {
this.screenState.setValue { old ->
old.copy(editCmId = cmId)
}
val messageToEdit = messages.value.firstOrNull { it.cmId == cmId } ?: return
editMessage = messageToEdit
lastMessageText = screenState.value.message.text
var newState = screenState.value.copy(
message = TextFieldValue(
text = messageToEdit.text.orEmpty(),
selection = TextRange(messageToEdit.text.orEmpty().length)
),
actionMode = ActionMode.EDIT
)
messageToEdit.replyMessage?.let { reply ->
replyToCmId = reply.cmId
newState = newState.copy(
replyTitle = reply.extractReplyTitle(),
replyText = reply.extractReplySummary(resourceProvider.resources)
)
}
showKeyboard.setValue { true }
screenState.setValue { newState }
}
private fun stopEditMessage() {
val lastText = lastMessageText.orEmpty().trim()
screenState.setValue { old ->
old.copy(
editCmId = null,
message = TextFieldValue(
text = lastText,
selection = TextRange(lastText.length)
),
actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO
else ActionMode.SEND,
// TODO: 13/03/2026, Danil Nikolaev: use last reply
replyTitle = null,
replyText = null
)
}
}
private var formatData = VkMessage.FormatData("1", emptyList())
private fun updateStyles() {
@@ -580,7 +660,12 @@ class MessagesHistoryViewModelImpl(
replyToMessage(cmId)
}
override suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int = suspendCoroutine {
override fun onKeyboardShown() {
showKeyboard.setValue { false }
}
override suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int =
suspendCancellableCoroutine {
viewModelScope.launch {
getMessageReadPeersUseCase
.invoke(peerId = peerId, cmId = cmId)
@@ -988,11 +1073,13 @@ class MessagesHistoryViewModelImpl(
message = newMessage.text,
forward = forward,
attachments = null,
formatData = newMessage.formatData
formatData = newMessage.formatData,
).listenValue(viewModelScope) { state ->
state.processState(
any = { sendingMessages.remove(newMessage) },
error = { error ->
Log.d("MessagesHistoryViewModelImpl", "sendMessage: ERROR: $error")
val failedId = -500_000L - failedMessages.size
val newFailedMessage = newMessage.copy(id = failedId)
failedMessages += newFailedMessage
@@ -1015,6 +1102,51 @@ class MessagesHistoryViewModelImpl(
}
}
private fun confirmDeleteCurrentEditMessage() {
val currentMessage = editMessage ?: return
this.dialog.setValue {
MessageDialog.MessageDelete(currentMessage)
}
}
private fun editCurrentEditMessage() {
replyToCmId = null
val newText = screenState.value.message.text
val lastText = lastMessageText.orEmpty().trim()
screenState.setValue { old ->
old.copy(
editCmId = null,
message = TextFieldValue(
text = lastText,
selection = TextRange(lastText.length)
),
actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO
else ActionMode.SEND,
// TODO: 13/03/2026, Danil Nikolaev: save last reply
replyTitle = null,
replyText = null
)
}
syncUiMessages()
// TODO: 13/03/2026, Danil Nikolaev: actually edit message
val newMessage = editMessage?.copy(
replyMessage = if (replyToCmId == null) null else editMessage?.replyMessage,
text = newText
) ?: return
// TODO: 13/03/2026, Danil Nikolaev: check if message is exact same, then do not edit
Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage")
}
private fun markAsImportant(
messageIds: List<Long>,
important: Boolean,
@@ -1118,29 +1250,6 @@ class MessagesHistoryViewModelImpl(
}
}
fun editMessage(
originalMessage: VkMessage,
peerid: Long,
messageid: Long,
newText: String? = null,
attachments: List<VkAttachment>? = null,
) {
viewModelScope.launch(Dispatchers.IO) {
// sendRequest {
// messagesRepository.edit(
// MessagesEditRequest(
// peerId = peerId,
// messageId = messageId,
// message = newText,
// attachments = attachments
// )
// )
// } ?: return@launch
// TODO: 25.08.2023, Danil Nikolaev: update message
}
}
private fun readMessage(message: VkMessage) {
messagesUseCase.markAsRead(
peerId = screenState.value.convoId,
@@ -1237,7 +1346,8 @@ class MessagesHistoryViewModelImpl(
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages,
convo = screenState.value.convo,
isSelected = selectedMessages.indexOfFirstOrNull { it.id == message.id } != null
isSelected = screenState.value.editCmId == message.cmId ||
selectedMessages.indexOfFirstOrNull { it.id == message.id } != null
)
}
uiMessages.setValue { newUiMessages }
@@ -26,7 +26,8 @@ data class MessagesHistoryScreenState(
val pinnedTitle: String?,
val pinnedSummary: AnnotatedString?,
val replyTitle: String?,
val replyText: AnnotatedString?
val replyText: AnnotatedString?,
val editCmId: Long?,
) {
companion object {
@@ -48,6 +49,7 @@ data class MessagesHistoryScreenState(
pinnedSummary = null,
replyTitle = null,
replyText = null,
editCmId = null,
)
}
}
@@ -81,7 +81,7 @@ fun InputBar(
actionMode: ActionMode,
replyTitle: String?,
replyText: AnnotatedString?,
inputFieldFocusRequester: Boolean,
showKeyboard: Boolean,
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {},
@@ -92,7 +92,8 @@ fun InputBar(
onEmojiButtonLongClicked: () -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {}
onReplyCloseClicked: () -> Unit = {},
onKeyboardShown: () -> Unit
) {
val view = LocalView.current
val context = LocalContext.current
@@ -106,8 +107,9 @@ fun InputBar(
val focusRequester = remember { FocusRequester() }
LaunchedEffect(inputFieldFocusRequester) {
if (inputFieldFocusRequester) {
LaunchedEffect(showKeyboard) {
if (showKeyboard) {
onKeyboardShown()
focusRequester.requestFocus()
}
}
@@ -360,6 +362,7 @@ private fun InputBarPreview() {
actionMode = ActionMode.SEND,
replyTitle = "Иннокентий Панфилович",
replyText = "Ого, ром!".annotated(),
inputFieldFocusRequester = false
showKeyboard = false,
onKeyboardShown = {}
)
}
@@ -3,6 +3,7 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
@@ -30,7 +32,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.messageshistory.presentation.attachments.Attachments
@@ -38,14 +39,12 @@ import dev.meloda.fast.messageshistory.presentation.attachments.Reply
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkStickerDomain
import dev.meloda.fast.model.api.domain.VkVideoMessageDomain
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.model.vk.SendingStatus
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.darken
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isDark
import dev.meloda.fast.ui.util.lighten
@Composable
fun MessageBubble(
@@ -119,21 +118,18 @@ fun MessageBubble(
) {
if (replyTitle != null) {
Reply(
modifier = Modifier
.padding(if (attachments == null || text != null) 0.dp else 4.dp)
.width(with(density) { containerWidth.toDp() }),
bottomPadding = if (attachments == null || text != null) 0.dp else 4.dp,
modifier = Modifier.width(with(density) { containerWidth.toDp() }),
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (attachments == null || text != null) 0.dp else 16.dp,
bottomEnd = if (attachments == null || text != null) 0.dp else 16.dp
),
onClick = onReplyClick,
title = replyTitle,
summary = replySummary,
backgroundColor = colors.container,
innerBackgroundColor = colors.replyContainer
backgroundColor = colors.replyContainer,
innerBackgroundColor = colors.replyInnerContainer,
titleColor = colors.replyTitle,
textColor = colors.replyText
)
}
@@ -211,17 +207,25 @@ fun MessageBubble(
attachmentsContainerWidth = it.size.width
}
.clip(
if (!shouldShowBubble) RoundedCornerShape(24.dp)
else RoundedCornerShape(
if (!shouldShowBubble) {
RoundedCornerShape(
topStart = if (replyTitle != null) 0.dp else 24.dp,
topEnd = if (replyTitle != null) 0.dp else 24.dp,
bottomEnd = 24.dp,
bottomStart = 24.dp,
)
} else RoundedCornerShape(
topStart = 0.dp,
topEnd = 0.dp
topEnd = 0.dp,
bottomEnd = 24.dp,
bottomStart = 24.dp,
)
)
.background(attachmentBackgroundColor)
) {
Attachments(
withText = text != null,
withReply = replyTitle != null,
modifier = Modifier,
attachments = attachments,
onClick = currentOnClick,
@@ -261,6 +265,9 @@ private data class MessageBubbleColors(
val container: Color,
val content: Color,
val replyContainer: Color,
val replyInnerContainer: Color,
val replyTitle: Color,
val replyText: Color
)
@Composable
@@ -268,31 +275,35 @@ private fun messageBubbleColors(isOut: Boolean): MessageBubbleColors {
return if (isOut) {
val containerColor = MaterialTheme.colorScheme.primaryContainer
val replyContainerColor = if (containerColor.isDark()) {
containerColor.lighten(0.15f)
} else {
containerColor.darken(0.075f)
}
MessageBubbleColors(
container = containerColor,
content = MaterialTheme.colorScheme.onPrimaryContainer,
replyContainer = replyContainerColor
replyContainer = containerColor,
replyInnerContainer = MaterialTheme.colorScheme.background.copy(
if (isSystemInDarkTheme()) 0.3f else 0.45f
),
replyTitle = MaterialTheme.colorScheme.primary,
replyText = MaterialTheme.colorScheme.onBackground
)
} else {
val containerColor = MaterialTheme.colorScheme.surfaceContainer
MessageBubbleColors(
container = MaterialTheme.colorScheme.surfaceContainer,
container = containerColor,
content = MaterialTheme.colorScheme.onSurface,
replyContainer = MaterialTheme.colorScheme.surfaceContainerHighest
replyContainer = containerColor,
replyInnerContainer = MaterialTheme.colorScheme.surfaceColorAtElevation(20.dp),
replyTitle = MaterialTheme.colorScheme.primary,
replyText = MaterialTheme.colorScheme.onBackground
)
}
}
@Preview
@FastPreview
@Composable
private fun Bubble() {
AppTheme(
useDarkTheme = true,
useDarkTheme = isSystemInDarkTheme(),
useDynamicColors = true
) {
Column {
@@ -188,7 +188,9 @@ fun MessageOptionsDialog(
}
MessageOptionItem(
title = viewCount?.let { "$it views" } ?: "...",
title = viewCount?.let {
if (it == 0) "No views" else "$it views"
} ?: "...",
iconResId = R.drawable.ic_visibility_round_24,
tintColor = primaryColor,
onClick = {}
@@ -29,7 +29,7 @@ fun MessagesHistoryRoute(
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val inputFieldFocusRequester by viewModel.inputFieldFocusRequester.collectAsStateWithLifecycle()
val showKeyboard by viewModel.showKeyboard.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val needToConsume = when (val navigation = navigationEvent) {
@@ -55,7 +55,7 @@ fun MessagesHistoryRoute(
canPaginate = canPaginate,
showEmojiButton = AppSettings.General.showEmojiButton,
showAttachmentButton = AppSettings.General.showAttachmentButton,
inputFieldFocusRequester = inputFieldFocusRequester,
showKeyboard = showKeyboard,
onBack = onBack,
onClose = viewModel::onCloseButtonClicked,
onScrolledToIndex = viewModel::onScrolledToIndex,
@@ -72,6 +72,7 @@ fun MessagesHistoryRoute(
onPhotoClicked = onNavigateToPhotoViewer,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onEditSelectedMessageClicked = viewModel::onEditSelectedMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
onBoldRequested = viewModel::onBoldClicked,
onItalicRequested = viewModel::onItalicClicked,
@@ -80,6 +81,7 @@ fun MessagesHistoryRoute(
onRegularRequested = viewModel::onRegularClicked,
onReplyCloseClicked = viewModel::onReplyCloseClicked,
onRequestReplyToMessage = viewModel::onRequestReplyToMessage,
onKeyboardShown = viewModel::onKeyboardShown
)
HandleDialogs(
@@ -31,12 +31,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.util.indexOfMessageByCmId
@@ -69,13 +71,14 @@ fun MessagesHistoryScreen(
canPaginate: Boolean = false,
showEmojiButton: Boolean = false,
showAttachmentButton: Boolean = false,
inputFieldFocusRequester: Boolean,
showKeyboard: Boolean,
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {},
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onTopBarClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onEditSelectedMessageClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
@@ -93,7 +96,8 @@ fun MessagesHistoryScreen(
onUnderlineRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {},
onRequestReplyToMessage: (cmId: Long) -> Unit = {}
onRequestReplyToMessage: (cmId: Long) -> Unit = {},
onKeyboardShown: () -> Unit
) {
val context = LocalContext.current
val view = LocalView.current
@@ -114,7 +118,7 @@ fun MessagesHistoryScreen(
}
BackHandler(
enabled = selectedMessages.isNotEmpty(),
enabled = selectedMessages.isNotEmpty() || screenState.editCmId != null,
onBack = onClose
)
@@ -162,6 +166,9 @@ fun MessagesHistoryScreen(
derivedStateOf { selectedMessages.size == 1 }
}
val isLoadingText = stringResource(R.string.title_loading)
val editMessageText = stringResource(R.string.title_edit_message)
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
@@ -169,7 +176,8 @@ fun MessagesHistoryScreen(
val topBarTitle by remember(screenState, selectedMessages) {
derivedStateOf {
when {
screenState.isLoading -> context.getString(R.string.title_loading)
screenState.isLoading -> isLoadingText
screenState.editCmId != null -> editMessageText
selectedMessages.isNotEmpty() -> "(${selectedMessages.size})"
else -> screenState.title
}
@@ -179,13 +187,16 @@ fun MessagesHistoryScreen(
MessagesHistoryTopBarContainer(
hazeState = hazeState,
showReplyAction = showReplyAction,
showEditAction = selectedMessages.size == 1,
topBarContainerColor = topBarContainerColor,
topBarContainerColorAlpha = topBarContainerColorAlpha,
isClickable = !(screenState.isLoading && messages.isEmpty()),
isMessagesSelecting = selectedMessages.isNotEmpty(),
isPeerAccount = screenState.convoId == UserConfig.userId,
avatar = screenState.avatar,
avatarUrl = screenState.avatar.takeIf { it is UiImage.Url }?.extractUrl(),
avatarResourceId = screenState.avatar.takeIf { it is UiImage.Resource }?.extractResId(),
title = topBarTitle,
isEditing = screenState.editCmId != null,
showHorizontalProgressBar = screenState.isLoading && messages.isNotEmpty(),
showPinnedContainer = !screenState.isLoading && pinnedMessage != null,
pinnedMessage = pinnedMessage,
@@ -196,6 +207,7 @@ fun MessagesHistoryScreen(
onBack = onBack,
onClose = onClose,
onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked,
onEditSelectedMessageClicked = onEditSelectedMessageClicked,
onRefresh = onRefresh,
onPinnedMessageClicked = onPinnedMessageClicked,
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
@@ -211,6 +223,7 @@ fun MessagesHistoryScreen(
) {
MessagesList(
modifier = Modifier.align(Alignment.BottomStart),
screenState = screenState,
hazeState = hazeState,
listState = listState,
hasPinnedMessage = pinnedMessage != null,
@@ -259,12 +272,13 @@ fun MessagesHistoryScreen(
actionMode = screenState.actionMode,
replyTitle = screenState.replyTitle,
replyText = screenState.replyText,
inputFieldFocusRequester = inputFieldFocusRequester,
showKeyboard = showKeyboard,
onSetMessageBarHeight = { messageBarHeight = it },
onEmojiButtonLongClicked = onEmojiButtonLongClicked,
onAttachmentButtonClicked = onAttachmentButtonClicked,
onActionButtonClicked = onActionButtonClicked,
onReplyCloseClicked = onReplyCloseClicked
onReplyCloseClicked = onReplyCloseClicked,
onKeyboardShown = onKeyboardShown
)
when {
@@ -2,6 +2,7 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -31,7 +32,6 @@ 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.painter.Painter
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -44,11 +44,9 @@ 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.common.model.UiImage
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.getImage
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
@@ -56,16 +54,20 @@ fun MessagesHistoryTopBar(
modifier: Modifier = Modifier,
hazeState: HazeState,
showReplyAction: Boolean,
showEditAction: Boolean,
isClickable: Boolean,
isMessagesSelecting: Boolean,
isPeerAccount: Boolean,
avatar: UiImage,
avatarUrl: String?,
avatarResourceId: Int?,
title: String,
isEditing: Boolean,
onTopBarClicked: () -> Unit = {},
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
onRefresh: () -> Unit = {},
onEditSelectedMessageClicked: () -> Unit = {}
) {
val view = LocalView.current
val theme = LocalThemeConfig.current
@@ -96,7 +98,8 @@ fun MessagesHistoryTopBar(
// modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
if (!isMessagesSelecting) {
AnimatedVisibility(!isMessagesSelecting && !isEditing) {
Row {
if (isPeerAccount) {
Box(
modifier = Modifier
@@ -114,19 +117,10 @@ fun MessagesHistoryTopBar(
)
}
} else {
val actualAvatar = avatar.getImage()
if (actualAvatar is Painter) {
Image(
painter = actualAvatar,
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
} else {
when {
avatarUrl != null -> {
AsyncImage(
model = actualAvatar,
model = avatarUrl,
contentDescription = "Profile Image",
modifier = Modifier
.size(36.dp)
@@ -134,12 +128,25 @@ fun MessagesHistoryTopBar(
placeholder = painterResource(id = R.drawable.ic_account_circle_fill_round_24),
)
}
avatarResourceId != null -> {
Image(
painter = painterResource(avatarResourceId),
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
}
}
}
Spacer(modifier = Modifier.width(12.dp))
}
}
Text(
modifier = Modifier.animateContentSize(),
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -150,11 +157,11 @@ fun MessagesHistoryTopBar(
navigationIcon = {
IconButton(
onClick = {
if (!isMessagesSelecting) onBack()
if (!isMessagesSelecting && !isEditing) onBack()
else onClose()
}
) {
Crossfade(targetState = !isMessagesSelecting) { state ->
Crossfade(targetState = !isMessagesSelecting && !isEditing) { state ->
Icon(
painter = painterResource(
if (state) {
@@ -210,6 +217,16 @@ fun MessagesHistoryTopBar(
contentDescription = null
)
}
AnimatedVisibility(showEditAction) {
IconButton(onClick = onEditSelectedMessageClicked) {
Icon(
painter = painterResource(R.drawable.ic_edit_round_24),
contentDescription = null
)
}
}
IconButton(onClick = onDeleteSelectedButtonClicked) {
Icon(
painter = painterResource(R.drawable.ic_delete_round_24),
@@ -16,7 +16,6 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.theme.LocalThemeConfig
@@ -26,13 +25,16 @@ fun MessagesHistoryTopBarContainer(
modifier: Modifier = Modifier,
hazeState: HazeState,
showReplyAction: Boolean,
showEditAction: Boolean,
topBarContainerColor: Color,
topBarContainerColorAlpha: Float,
isClickable: Boolean,
isMessagesSelecting: Boolean,
isPeerAccount: Boolean,
avatar: UiImage,
avatarUrl: String?,
avatarResourceId: Int?,
title: String,
isEditing: Boolean,
showHorizontalProgressBar: Boolean,
showPinnedContainer: Boolean,
pinnedMessage: VkMessage?,
@@ -44,6 +46,7 @@ fun MessagesHistoryTopBarContainer(
onClose: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onEditSelectedMessageClicked: () -> Unit = {},
onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}
) {
@@ -66,16 +69,20 @@ fun MessagesHistoryTopBarContainer(
modifier = modifier,
hazeState = hazeState,
showReplyAction = showReplyAction,
showEditAction = showEditAction,
isClickable = isClickable,
isMessagesSelecting = isMessagesSelecting,
isPeerAccount = isPeerAccount,
avatar = avatar,
avatarUrl = avatarUrl,
avatarResourceId = avatarResourceId,
title = title,
isEditing = isEditing,
onTopBarClicked = onTopBarClicked,
onBack = onBack,
onClose = onClose,
onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked,
onRefresh = onRefresh
onRefresh = onRefresh,
onEditSelectedMessageClicked = onEditSelectedMessageClicked
)
if (showHorizontalProgressBar) {
@@ -26,9 +26,12 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -40,10 +43,10 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkFileDomain
import dev.meloda.fast.model.api.domain.VkLinkDomain
@@ -58,6 +61,7 @@ import kotlinx.coroutines.launch
@Composable
fun MessagesList(
modifier: Modifier = Modifier,
screenState: MessagesHistoryScreenState,
hasPinnedMessage: Boolean,
hazeState: HazeState,
listState: LazyListState,
@@ -193,11 +197,22 @@ fun MessagesList(
}
)
val offsetX = remember { Animatable(0f) }
var animate by remember { mutableStateOf(false) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetDistinct by remember { mutableFloatStateOf(0f) }
val offsetAnimatable = remember { Animatable(0f) }
val offsetDistinct by snapshotFlow { offsetX.value }
LaunchedEffect(offsetX) {
if (!animate) {
offsetAnimatable.snapTo(offsetX)
}
}
LaunchedEffect(Unit) {
snapshotFlow { offsetX.minus(5f).coerceIn(-100f, 0f) }
.distinctUntilChanged()
.collectAsStateWithLifecycle(offsetX)
.collect { offsetDistinct = it }
}
LaunchedEffect(offsetDistinct) {
if (offsetDistinct == -100f && AppSettings.General.enableHaptic) {
@@ -213,44 +228,55 @@ fun MessagesList(
fadeOutSpec = null
) else Modifier
)
.then(
if (screenState.editCmId == null) {
Modifier
.combinedClickable(
onLongClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
}
onMessageLongClicked(item.id)
},
onClick = { onMessageClicked(item.id) }
)
.pointerInput(Unit) {
.pointerInput(item.cmId) {
detectHorizontalDragGestures(
onDragCancel = {
if (offsetX.value == -100f) {
if (offsetX == -100f) {
onRequestMessageReply(item.cmId)
}
scope.launch {
offsetX.animateTo(0f)
animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
}
},
onDragEnd = {
if (offsetX.value == -100f) {
if (offsetX == -100f) {
onRequestMessageReply(item.cmId)
}
scope.launch {
offsetX.animateTo(0f)
animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
}
},
onHorizontalDrag = { _, dragAmount ->
scope.launch {
offsetX.snapTo(
(offsetX.value + dragAmount).coerceIn(-100f, 0f)
)
}
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX =
(offsetX + dragAmount).coerceIn(-100f, 0f)
}
)
},
}
} else Modifier
),
color = backgroundColor
) {
if (item.isOut) {
@@ -278,7 +304,7 @@ fun MessagesList(
onRequestScrollToCmId(item.replyCmId!!)
}
},
offsetX = offsetX.value
offsetX = offsetAnimatable.value
)
} else {
IncomingMessageBubble(
@@ -305,7 +331,7 @@ fun MessagesList(
onRequestScrollToCmId(item.replyCmId!!)
}
},
offsetX = offsetX.value
offsetX = offsetAnimatable.value
)
}
}
@@ -16,7 +16,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,6 +52,8 @@ private val previewTypes = listOf(
@Composable
fun Attachments(
withText: Boolean,
withReply: Boolean,
modifier: Modifier = Modifier,
attachments: ImmutableList<out VkAttachment>,
onClick: (VkAttachment) -> Unit = {},
@@ -64,23 +65,20 @@ fun Attachments(
val currentOnLongClick by rememberUpdatedState(onLongClick)
Column(modifier = modifier) {
val previewAttachments by remember(attachments) {
derivedStateOf {
val previewAttachments = remember(attachments) {
attachments.values.filter { it.type in previewTypes }
}
}
val nonPreviewAttachments by remember(attachments) {
derivedStateOf {
attachments.values.filterNot { it.type in previewTypes }
.sortedBy { it.type.ordinal }
}
val nonPreviewAttachments = remember(attachments) {
attachments.values.filterNot { it.type in previewTypes }.sortedBy { it.type.ordinal }
}
if (previewAttachments.isNotEmpty()) {
Previews(
DynamicPreviewGrid(
withText = withText,
withReply = withReply,
modifier = Modifier,
photos = previewAttachments
previews = previewAttachments
.map(VkAttachment::asUiPhoto)
.toImmutableList(),
onClick = { index ->
@@ -121,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 -> {
@@ -187,7 +188,8 @@ fun Attachments(
.let(::downsampleWaveform)
.let(::downsampleWaveform)
.let { amplifyWaveform(it, audioMessage.waveform.max()) }
.map(::WaveForm),
.map(::WaveForm)
.toImmutableList(),
isPlaying = false,
onPlayClick = {}
)
@@ -26,11 +26,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.times
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FastIconButton
import dev.meloda.fast.ui.util.ImmutableList
import kotlin.collections.forEachIndexed
@Composable
fun AudioMessage(
waveform: List<WaveForm>,
waveform: ImmutableList<WaveForm>,
isPlaying: Boolean,
onPlayClick: () -> Unit,
modifier: Modifier = Modifier,
@@ -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(
@@ -1,6 +1,6 @@
package dev.meloda.fast.messageshistory.presentation.attachments
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -33,25 +33,12 @@ import dev.meloda.fast.ui.components.FastIconButton
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable
fun Previews(
modifier: Modifier = Modifier,
photos: ImmutableList<UiPreview>,
onClick: (index: Int) -> Unit = {},
onLongClick: (index: Int) -> Unit = {}
) {
DynamicPreviewGrid(
modifier = modifier,
photos = photos,
onClick = onClick,
onLongClick = onLongClick
)
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun DynamicPreviewGrid(
photos: ImmutableList<UiPreview>,
withText: Boolean,
withReply: Boolean,
previews: ImmutableList<UiPreview>,
modifier: Modifier = Modifier,
onClick: (index: Int) -> Unit = {},
onLongClick: (index: Int) -> Unit = {}
@@ -60,16 +47,27 @@ fun DynamicPreviewGrid(
val currentOnLongClick by rememberUpdatedState(onLongClick)
val spacing = 2.dp
val shape = RoundedCornerShape(8.dp)
val cornerRadius = 20.dp
val insideRadius = 4.dp
BoxWithConstraints(modifier = modifier) {
val calculateShape by rememberUpdatedState { outer: Int, inner: Int, outLast: Int, inLast: Int ->
RoundedCornerShape(
topStart = if (!withText && !withReply && outer == 0 && inner == 0) cornerRadius else insideRadius,
topEnd = if (!withText && !withReply && outer == 0 && inner == inLast) cornerRadius else insideRadius,
bottomStart = if (outer == outLast && inner == 0) cornerRadius else insideRadius,
bottomEnd = if (outer == outLast && inner == inLast) cornerRadius else insideRadius
)
}
BoxWithConstraints(modifier = modifier.padding(4.dp)) {
val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
val spacingPx = with(LocalDensity.current) { spacing.toPx() }
val rows = photos.chunked(3)
val rows = previews.chunked(3)
Log.d("ROWS", "DynamicPreviewGrid: ${rows.size}")
Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
rows.forEachIndexed { index, row ->
rows.forEachIndexed { outerIndex, row ->
val aspectRatios = row.map { it.width.toFloat() / it.height }
val totalAspect = aspectRatios.sum()
@@ -80,6 +78,8 @@ fun DynamicPreviewGrid(
val height = photoWidthPx / aspectRatios[index]
val heightDp = with(LocalDensity.current) { height.toDp() }
val shape = calculateShape(outerIndex, index, rows.lastIndex, row.lastIndex)
Box(
modifier = Modifier
.height(heightDp)
@@ -95,14 +95,14 @@ fun DynamicPreviewGrid(
.height(heightDp)
.clip(shape)
.combinedClickable(
onLongClick = { currentOnLongClick(index) },
onClick = { currentOnClick(index) }
onLongClick = { currentOnLongClick(outerIndex * 3 + index) },
onClick = { currentOnClick(outerIndex * 3 + index) }
)
)
if (preview.isVideo) {
FastIconButton(
onClick = { currentOnClick(index) },
onClick = { currentOnClick(outerIndex * 3 + index) },
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
@@ -146,6 +146,10 @@ fun PreviewDynamicPhotoGrid() {
.padding(8.dp),
contentAlignment = Alignment.Center
) {
DynamicPreviewGrid(photos = mockPhotos.take(10).toImmutableList())
DynamicPreviewGrid(
withText = false,
withReply = false,
previews = mockPhotos.take(10).toImmutableList()
)
}
}
@@ -3,13 +3,11 @@ package dev.meloda.fast.messageshistory.presentation.attachments
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -18,90 +16,99 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.layout.onGloballyPositioned
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.conena.nanokt.android.content.pxToDp
import dev.meloda.fast.domain.util.annotated
import dev.meloda.fast.domain.util.orEmpty
import dev.meloda.fast.ui.common.FastPreview
import dev.meloda.fast.ui.theme.AppTheme
@Composable
fun Reply(
onClick: () -> Unit,
bottomPadding: Dp,
shape: Shape,
backgroundColor: Color,
innerBackgroundColor: Color,
titleColor: Color,
textColor: Color,
title: String,
summary: AnnotatedString?,
modifier: Modifier = Modifier
) {
Box(
var innerContainerHeight by remember {
mutableIntStateOf(0)
}
Row(
modifier = modifier
.background(
color = backgroundColor,
shape = shape
)
.height(40.dp)
.padding(
top = 4.dp,
start = 4.dp,
end = 4.dp,
bottom = bottomPadding
)
) {
Row(
modifier = Modifier
.padding(4.dp)
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.fillMaxSize()
.background(innerBackgroundColor)
.background(innerBackgroundColor),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.onBackground)
.height(innerContainerHeight.dp + 12.dp)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.width(6.dp))
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Center
modifier = Modifier
.padding(vertical = 6.dp)
.padding(end = 9.dp)
.onGloballyPositioned { innerContainerHeight = it.size.height.pxToDp() }
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp,
color = titleColor
)
AnimatedVisibility(summary != null) {
Text(
text = summary.orEmpty(),
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp,
color = textColor
)
}
}
}
}
}
@Composable
private fun ReplyBasePreview(
backgroundColor: Color,
innerBackgroundColor: Color
innerBackgroundColor: Color,
titleColor: Color,
textColor: Color
) {
Reply(
modifier = Modifier.width(120.dp),
shape = RoundedCornerShape(
topStart = 12.dp,
topEnd = 12.dp
@@ -111,24 +118,41 @@ private fun ReplyBasePreview(
summary = "2 photos".annotated(),
backgroundColor = backgroundColor,
innerBackgroundColor = innerBackgroundColor,
bottomPadding = 0.dp
titleColor = titleColor,
textColor = textColor,
)
}
@Preview
@FastPreview
@Composable
private fun IncomingReplyPreview() {
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
ReplyBasePreview(
backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp),
innerBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(20.dp)
innerBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(20.dp),
titleColor = MaterialTheme.colorScheme.primary,
textColor = MaterialTheme.colorScheme.onBackground
)
}
}
@Preview
@FastPreview
@Composable
private fun OutgoingReplyPreview() {
ReplyBasePreview(
backgroundColor = MaterialTheme.colorScheme.primaryContainer,
innerBackgroundColor = MaterialTheme.colorScheme.inversePrimary
AppTheme(useDarkTheme = isSystemInDarkTheme(), useDynamicColors = true) {
val bg = MaterialTheme.colorScheme.primaryContainer
val inner = MaterialTheme.colorScheme.background.copy(
if (isSystemInDarkTheme()) 0.3f else 0.6f
)
val title = MaterialTheme.colorScheme.primary
val text = MaterialTheme.colorScheme.onBackground
ReplyBasePreview(
backgroundColor = bg,
innerBackgroundColor = inner,
titleColor = title,
textColor = text
)
}
}
@@ -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,7 +5,6 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmapOrNull
@@ -17,9 +16,11 @@ import coil.imageLoader
import coil.request.ImageRequest
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.util.sha256
import dev.meloda.fast.photoviewer.model.PhotoViewArguments
import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.photoviewer.navigation.PhotoView
import dev.meloda.fast.ui.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -28,9 +29,6 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.URLDecoder
import java.util.UUID
import dev.meloda.fast.ui.R
interface PhotoViewViewModel {
val screenState: StateFlow<PhotoViewScreenState>
@@ -99,9 +97,10 @@ class PhotoViewViewModelImpl(
type = "image/png"
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri(null, uri)
}
val chooserIntent = Intent.createChooser(intent, null)
val chooserIntent = Intent.createChooser(intent, "Share image via...")
chooserIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
chooserIntent
}
@@ -186,6 +185,13 @@ class PhotoViewViewModelImpl(
private suspend fun downloadAndStoreImageToCache(url: String): File? =
runCatching {
val imagesDir = File(applicationContext.cacheDir, "images")
if (!imagesDir.exists()) {
imagesDir.mkdirs()
}
val imageFile = File(imagesDir, "${url.sha256()}.png")
if (imageFile.exists()) return imageFile
withContext(Dispatchers.IO) {
screenState.setValue { old -> old.copy(isLoading = true) }
@@ -198,9 +204,6 @@ class PhotoViewViewModelImpl(
return@withContext null
}
val imagesDir = File(applicationContext.cacheDir, "images")
if (!imagesDir.exists()) imagesDir.mkdirs()
val imageFile = File(imagesDir, "shared_image_id${UUID.randomUUID()}.png")
FileOutputStream(imageFile).use {
drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it)
}
+26 -13
View File
@@ -1,34 +1,46 @@
[versions]
agp = "9.0.0"
#noinspection UnusedVersionCatalogEntry
minSdk = "23"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
targetSdk = "37"
agp = "9.2.0"
retrofit = "3.0.0"
eithernet = "2.0.0"
haze = "1.7.1"
kotlin = "2.3.0"
ksp = "2.3.4"
moduleGraph = "2.9.0"
versions = "0.53.0"
haze = "1.7.2"
kotlin = "2.3.21"
ksp = "2.3.7"
moduleGraph = "2.9.1"
versions = "0.54.0"
stability-analyzer = "0.7.5"
compose-bom = "2026.01.00"
koin = "4.1.1"
compose-bom = "2026.04.01"
koin = "4.2.1"
accompanist = "0.37.3"
coil = "2.7.0"
coroutines = "1.10.2"
junit = "4.13.2"
chucker = "4.3.0"
guava = "33.5.0-jre"
chucker = "4.3.1"
guava = "33.6.0-jre"
lifecycle = "2.10.0"
core-ktx = "1.17.0"
core-ktx = "1.18.0"
material = "1.13.0"
loggingInterceptor = "5.3.2"
moshi = "1.15.2"
room = "2.8.4"
preference-ktx = "1.2.1"
nanokt = "1.3.0"
androidx-navigation = "2.9.6"
serialization = "1.10.0"
androidx-navigation = "2.9.8"
serialization = "1.11.0"
okhttp = "5.3.2"
[libraries]
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" }
coil = { module = "io.coil-kt:coil", version.ref = "coil" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
@@ -117,6 +129,7 @@ room = { id = "androidx.room", version.ref = "room" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
module-graph = { id = "com.jraska.module.graph.assertion", version.ref = "moduleGraph" }
versions = { id = "com.github.ben-manes.versions", version.ref = "versions" }
stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "stability-analyzer" }
#project plugins
fast-android-application = { id = "fast.android.application", version = "unspecified" }
Binary file not shown.
+3 -1
View File
@@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
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%