1 Commits

Author SHA1 Message Date
melod1n 82fb78e9ea Release 0.2.0 (#150)
Release Notes

* Bumped haze, agp, and guava dependencies
* Implemented ordering functionality for friends list
* Added scroll to top feature in friends and conversations screens
* Improved messages handling
* Fixed coloring issues
* Cache improvements
* Implemented logout functionality
* Implemented new authorization flow (no auto-token re-request)
* Added support for sticker pack preview attachments
* Bump LongPoll to version 19
* Markdown support for messages bubbles
* Adjust app name font size based on screen width

---------

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 21:47:05 +03:00
175 changed files with 3861 additions and 7716 deletions
+20 -34
View File
@@ -1,10 +1,10 @@
name: Android CI Build
on:
workflow_dispatch:
permissions:
contents: read
push:
branches: [ "dev", "release/*", "hotfix/*" ]
pull_request:
branches: [ "dev", "release/*", "hotfix/*" ]
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@@ -12,15 +12,15 @@ env:
RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}
jobs:
build_apks:
build_apk_aab:
runs-on: ubuntu-24.04
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
@@ -29,34 +29,20 @@ jobs:
- name: Grant execute permission for gradlew
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" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v5
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" >> $GITHUB_ENV
echo "APK_NAME=$(basename $APK_PATH)" >> $GITHUB_ENV
- name: Upload APK with original name
uses: actions/upload-artifact@v5
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: ${{ env.APK_NAME }}
path: ${{ env.APK_PATH }}
name: app-debug.apk
path: app/build/outputs/apk/debug/app-debug.apk
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Upload release APK
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
+24 -9
View File
@@ -1,11 +1,8 @@
name: Android CI Release
permissions:
contents: read
on:
workflow_dispatch:
push:
branches: [ "release/*"]
pull_request:
branches: [ "master" ]
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
@@ -18,10 +15,10 @@ jobs:
name: Build artifacts
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
@@ -30,20 +27,38 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build and sign debug APK
run: ./gradlew assembleDebug
- name: Upload debug APK
uses: actions/upload-artifact@v4
with:
name: app-debug.apk
path: app/build/outputs/apk/debug/app-debug.apk
- name: Build and sign release APK
run: ./gradlew assembleRelease
- name: Upload release APK
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: app-release.apk
path: app/build/outputs/apk/release/app-release.apk
- name: Build and sign debug Bundle
run: ./gradlew bundleDebug
- name: Upload debug Bundle
uses: actions/upload-artifact@v4
with:
name: app-debug.aab
path: app/build/outputs/bundle/debug/app-debug.aab
- name: Build and sign release Bundle
run: ./gradlew bundleRelease
- name: Upload release Bundle
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: app-release.aab
path: app/build/outputs/bundle/release/app-release.aab
+12 -20
View File
@@ -7,17 +7,15 @@ Unofficial messenger for russian social network VKontakte
- [x] 2FA support
- [x] Resend otp
- [x] Captcha support
- [x] Support for new authorization with service and refresh tokens
- [ ] Handle token expiration
- [x] Ability to export/import tokens
- [ ] Support for new authorization with service and refresh tokens
- [x] Conversations list
- [x] Pagination
- [x] Manual refresh
- [x] Pin & unpin conversations
- [x] Delete conversations
- [x] Archive
- [x] View archived conversations
- [x] Archive & unarchive conversations
- [ ] Archive
- [ ] View archived conversations
- [ ] Archive & unarchive conversations
- [x] Friends list
- [x] Sort alphabetically, by priority or random
- [x] Separate tab with only friends who are online
@@ -32,23 +30,17 @@ Unofficial messenger for russian social network VKontakte
- [x] Read status
- [x] Edit status
- [x] Sending status
- [x] Message's attachments
- [x] Photo
- [x] Video
- [x] Audio
- [x] File
- [x] Link
- [x] Sticker
- [x] Reply
- [ ] Forwarded messages
- [ ] Wall post
- [ ] Comment in wall post
- [ ] Poll
- [ ] Message's attachments
- [ ] Photo
- [ ] Video
- [ ] Audio
- [ ] File
- [ ] Link
- [ ] TODO
- [x] Send messages
- [x] Pinned message
- [x] Pin & unpin messages
- [x] Reply to message
- [ ] Reply to message
- [x] Delete message
- [x] Select multiple messages
- [x] Delete
@@ -63,7 +55,7 @@ Unofficial messenger for russian social network VKontakte
- [x] View attachments
- [x] Open photo
- [x] Internal viewer
- [x] External viewer
- [ ] External viewer
- [ ] Open video in external player
- [ ] TODO
- [ ] Caching
+2 -15
View File
@@ -1,4 +1,3 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import java.util.Properties
plugins {
@@ -13,8 +12,8 @@ android {
defaultConfig {
applicationId = "dev.meloda.fastvk"
versionCode = 10
versionName = "0.2.2"
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
}
signingConfigs {
@@ -59,18 +58,6 @@ android {
}
}
applicationVariants.all {
outputs.all {
val date = System.currentTimeMillis() / 1000
val buildType = buildType.name
val appVersion = versionName
val appVersionCode = versionCode
val newApkName = "app-$buildType-v$appVersion($appVersionCode)-$date.apk"
(this as? BaseVariantOutputImpl)?.outputFileName = newApkName
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
Binary file not shown.
@@ -5,7 +5,6 @@ import android.content.res.Resources
import android.os.PowerManager
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
@@ -34,7 +33,6 @@ import org.koin.core.qualifier.qualifier
import org.koin.dsl.bind
import org.koin.dsl.module
@OptIn(ExperimentalCoilApi::class)
val applicationModule = module {
includes(domainModule)
includes(
@@ -68,7 +66,6 @@ val applicationModule = module {
ImageLoader.Builder(get())
.crossfade(true)
.build()
.also { it.diskCache?.directory?.toFile()?.listFiles() }
}
singleOf(::LongPollControllerImpl) bind LongPollController::class
@@ -10,7 +10,7 @@ import dev.meloda.fast.presentation.MainScreen
import dev.meloda.fast.profile.navigation.Profile
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.serialization.Serializable
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.R as UiR
@Serializable
object MainGraph
@@ -28,21 +28,21 @@ fun NavGraphBuilder.mainScreen(
) {
val navigationItems = ImmutableList.of(
BottomNavigationItem(
titleResId = R.string.title_friends,
selectedIconResId = R.drawable.baseline_people_alt_24,
unselectedIconResId = R.drawable.outline_people_alt_24,
titleResId = UiR.string.title_friends,
selectedIconResId = UiR.drawable.baseline_people_alt_24,
unselectedIconResId = UiR.drawable.outline_people_alt_24,
route = Friends,
),
BottomNavigationItem(
titleResId = R.string.title_conversations,
selectedIconResId = R.drawable.baseline_chat_24,
unselectedIconResId = R.drawable.outline_chat_24,
titleResId = UiR.string.title_conversations,
selectedIconResId = UiR.drawable.baseline_chat_24,
unselectedIconResId = UiR.drawable.outline_chat_24,
route = ConversationsGraph
),
BottomNavigationItem(
titleResId = R.string.title_profile,
selectedIconResId = R.drawable.baseline_account_circle_24,
unselectedIconResId = R.drawable.outline_account_circle_24,
titleResId = UiR.string.title_profile,
selectedIconResId = UiR.drawable.baseline_account_circle_24,
unselectedIconResId = UiR.drawable.outline_account_circle_24,
route = Profile
)
)
@@ -23,7 +23,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -41,17 +41,18 @@ import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.service.OnlineService
import dev.meloda.fast.service.longpolling.LongPollingService
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
import dev.meloda.fast.ui.theme.AppTheme
import dev.meloda.fast.ui.theme.LocalSizeConfig
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.theme.LocalUser
import dev.meloda.fast.ui.util.isNeedToEnableDarkMode
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.KoinContext
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
class MainActivity : AppCompatActivity() {
@@ -88,165 +89,167 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissions()
setContent {
val resources = LocalResources.current
KoinContext {
val context = LocalContext.current
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val userSettings: UserSettings = koinInject()
val longPollController: LongPollController = koinInject()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val longPollCurrentState by longPollController.currentState.collectAsStateWithLifecycle()
val longPollStateToApply by longPollController.stateToApply.collectAsStateWithLifecycle()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val viewModel: MainViewModel = koinViewModel<MainViewModelImpl>()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
val currentUser: VkUser? by viewModel.currentUser.collectAsStateWithLifecycle()
LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
LifecycleResumeEffect(true) {
viewModel.onAppResumed(intent)
onPauseOrDispose {}
}
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val permissionState =
rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS)
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
val isNeedToCheckPermission by viewModel.isNeedToCheckNotificationsPermission.collectAsStateWithLifecycle()
val isNeedToRequestPermission by viewModel.isNeedToRequestNotifications.collectAsStateWithLifecycle()
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
LaunchedEffect(isNeedToCheckPermission) {
if (isNeedToCheckPermission) {
viewModel.onPermissionCheckStatus(permissionState.status)
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
if (permissionState.status.isGranted) {
if (longPollCurrentState == LongPollState.InApp) {
toggleLongPollService(false)
}
toggleLongPollService(
enable = true,
inBackground = true
)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
enable = true,
inBackground = true
enable = longPollStateToApply.isLaunched(),
inBackground = longPollStateToApply == LongPollState.Background
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
context.resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
context.resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
}
LaunchedEffect(isNeedToRequestPermission) {
if (isNeedToRequestPermission) {
viewModel.onPermissionsRequested()
permissionState.launchPermissionRequest()
}
}
LifecycleResumeEffect(longPollStateToApply) {
Log.d("LongPollMainActivity", "longPollStateToApply: $longPollStateToApply")
if (longPollStateToApply != LongPollState.Background) {
if (longPollStateToApply.isLaunched() && longPollCurrentState.isLaunched()
&& longPollCurrentState != longPollStateToApply
) {
toggleLongPollService(false)
Log.d("LongPoll", "recreate()")
}
toggleLongPollService(
enable = longPollStateToApply.isLaunched(),
inBackground = longPollStateToApply == LongPollState.Background
)
}
onPauseOrDispose {}
}
val sendOnline by userSettings.sendOnlineStatus.collectAsStateWithLifecycle()
LifecycleResumeEffect(sendOnline) {
toggleOnlineService(sendOnline)
onPauseOrDispose {
toggleOnlineService(false)
}
}
val deviceWidthDp = remember(true) {
resources.displayMetrics.widthPixels.pxToDp()
}
val deviceHeightDp = remember(true) {
resources.displayMetrics.heightPixels.pxToDp()
}
val deviceWidthSize by remember(deviceWidthDp) {
derivedStateOf {
when {
deviceWidthDp <= 360 -> DeviceSize.Small
deviceWidthDp <= 600 -> DeviceSize.Compact
deviceWidthDp <= 840 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val deviceHeightSize by remember(deviceHeightDp) {
derivedStateOf {
when {
deviceHeightDp <= 480 -> DeviceSize.Small
deviceHeightDp <= 700 -> DeviceSize.Compact
deviceHeightDp <= 900 -> DeviceSize.Medium
else -> DeviceSize.Expanded
}
}
}
val sizeConfig by remember(deviceWidthSize, deviceHeightSize) {
mutableStateOf(
SizeConfig(
widthSize = deviceWidthSize,
heightSize = deviceHeightSize
)
)
}
val darkMode by userSettings.darkMode.collectAsStateWithLifecycle()
val dynamicColors by userSettings.enableDynamicColors.collectAsStateWithLifecycle()
val amoledDark by userSettings.enableAmoledDark.collectAsStateWithLifecycle()
val enableBlur by userSettings.useBlur.collectAsStateWithLifecycle()
val enableMultiline by userSettings.enableMultiline.collectAsStateWithLifecycle()
val useSystemFont by userSettings.useSystemFont.collectAsStateWithLifecycle()
val enableAnimations by userSettings.enableAnimations.collectAsStateWithLifecycle()
val setDarkMode = isNeedToEnableDarkMode(darkMode = darkMode)
val themeConfig by remember(
darkMode,
dynamicColors,
amoledDark,
enableBlur,
enableMultiline,
setDarkMode,
useSystemFont
) {
derivedStateOf {
ThemeConfig(
darkMode = setDarkMode,
dynamicColors = dynamicColors,
selectedColorScheme = 0,
amoledDark = amoledDark,
enableBlur = enableBlur,
enableMultiline = enableMultiline,
useSystemFont = useSystemFont,
enableAnimations = enableAnimations
)
}
}
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
CompositionLocalProvider(
LocalThemeConfig provides themeConfig,
LocalSizeConfig provides sizeConfig,
LocalUser provides currentUser
) {
RootScreen(viewModel = viewModel)
AppTheme(
useDarkTheme = themeConfig.darkMode,
useDynamicColors = themeConfig.dynamicColors,
selectedColorScheme = themeConfig.selectedColorScheme,
useAmoledBackground = themeConfig.amoledDark,
useSystemFont = themeConfig.useSystemFont
) {
RootScreen(viewModel = viewModel)
}
}
}
}
@@ -254,9 +257,9 @@ class MainActivity : AppCompatActivity() {
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val noCategoryName = getString(R.string.notification_channel_no_category_name)
val noCategoryName = getString(UiR.string.notification_channel_no_category_name)
val noCategoryDescriptionText =
getString(R.string.notification_channel_no_category_description)
getString(UiR.string.notification_channel_no_category_description)
val noCategoryChannel =
NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_UNCATEGORIZED,
@@ -266,9 +269,9 @@ class MainActivity : AppCompatActivity() {
description = noCategoryDescriptionText
}
val longPollName = getString(R.string.notification_channel_long_polling_service_name)
val longPollName = getString(UiR.string.notification_channel_long_polling_service_name)
val longPollDescriptionText =
getString(R.string.notification_channel_long_polling_service_description)
getString(UiR.string.notification_channel_long_polling_service_description)
val longPollChannel =
NotificationChannel(
AppConstants.NOTIFICATION_CHANNEL_LONG_POLLING,
@@ -1,8 +1,5 @@
package dev.meloda.fast.presentation
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -18,6 +15,7 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -38,7 +36,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.conversations.navigation.Conversations
import dev.meloda.fast.conversations.navigation.conversationsGraph
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen
@@ -65,30 +63,25 @@ fun MainScreen(
onMessageClicked: (userid: Long) -> Unit = {},
onNavigateToCreateChat: () -> Unit = {}
) {
val activity = LocalActivity.current as? AppCompatActivity ?: return
val theme = LocalThemeConfig.current
val hazeState = remember { HazeState(true) }
val hazeState = remember { HazeState() }
val navController = rememberNavController()
var selectedItemIndex by rememberSaveable {
mutableIntStateOf(1)
}
BackHandler(enabled = selectedItemIndex != 1) {
val currentRoute = navigationItems[selectedItemIndex].route
selectedItemIndex = 1
navController.navigate(navigationItems[selectedItemIndex].route) {
popUpTo(route = currentRoute) {
inclusive = true
}
}
val user = LocalUser.current
val profileImageUrl by remember(user) {
derivedStateOf { user?.photo100 }
}
val profileImageUrl = LocalUser.current?.photo100
var tabReselected by remember {
mutableStateOf(navigationItems.associate { it.route to false })
mutableStateOf(
navigationItems.associate {
it.route to false
}
)
}
Scaffold(
@@ -100,7 +93,7 @@ fun MainScreen(
if (theme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.regular(NavigationBarDefaults.containerColor)
style = HazeMaterials.thick()
)
} else Modifier
),
@@ -187,7 +180,6 @@ fun MainScreen(
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
friendsScreen(
activity = activity,
onError = onError,
onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked,
@@ -198,18 +190,16 @@ fun MainScreen(
},
)
conversationsGraph(
activity = activity,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also {
it[ConversationsGraph] = false
it[Conversations] = false
}
}
)
profileScreen(
activity = activity,
onError = onError,
onSettingsButtonClicked = onSettingsButtonClicked,
onPhotoClicked = onPhotoClicked
@@ -3,12 +3,9 @@ package dev.meloda.fast.presentation
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -16,10 +13,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.DialogProperties
@@ -41,7 +34,8 @@ import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
import dev.meloda.fast.messageshistory.navigation.navigateToMessagesHistory
import dev.meloda.fast.navigation.Main
import dev.meloda.fast.navigation.mainScreen
import dev.meloda.fast.photoviewer.presentation.PhotoViewDialog
import dev.meloda.fast.photoviewer.navigation.navigateToPhotoView
import dev.meloda.fast.photoviewer.navigation.photoViewScreen
import dev.meloda.fast.settings.navigation.navigateToSettings
import dev.meloda.fast.settings.navigation.settingsScreen
import dev.meloda.fast.ui.R
@@ -53,7 +47,6 @@ fun RootScreen(
navController: NavHostController = rememberNavController(),
viewModel: MainViewModel
) {
val activity = LocalActivity.current
val context = LocalContext.current
val startDestination by viewModel.startDestination.collectAsStateWithLifecycle()
val isNeedToOpenAuth by viewModel.isNeedToReplaceWithAuth.collectAsStateWithLifecycle()
@@ -125,75 +118,54 @@ fun RootScreen(
LocalNavRootController provides navController,
LocalNavController provides navController
) {
var photoViewerInfo by rememberSaveable {
mutableStateOf<Pair<List<String>, Int?>?>(null)
}
Box(modifier = Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = requireNotNull(startDestination),
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onNavigateToMain = {
viewModel.onUserAuthenticated()
navController.navigateToMain()
},
onNavigateToSettings = navController::navigateToSettings,
navController = navController
)
mainScreen(
onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> photoViewerInfo = listOf(url) to null },
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat
)
messagesHistoryScreen(
onError = viewModel::onError,
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = { photos, index ->
photoViewerInfo = photos to index
}
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url -> photoViewerInfo = listOf(url) to null }
)
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen(
onBack = navController::navigateUp,
onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker,
onRestartRequired = {
activity?.let {
val intent = Intent(activity, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
activity.startActivity(intent)
activity.finish()
}
}
)
languagePickerScreen(onBack = navController::navigateUp)
}
PhotoViewDialog(
photoViewerInfo = photoViewerInfo,
onDismiss = { photoViewerInfo = null }
NavHost(
navController = navController,
startDestination = requireNotNull(startDestination),
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) }
) {
authNavGraph(
onNavigateToMain = {
viewModel.onUserAuthenticated()
navController.navigateToMain()
},
navController = navController
)
mainScreen(
onError = viewModel::onError,
onSettingsButtonClicked = navController::navigateToSettings,
onNavigateToMessagesHistory = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
onMessageClicked = navController::navigateToMessagesHistory,
onNavigateToCreateChat = navController::navigateToCreateChat
)
messagesHistoryScreen(
onError = viewModel::onError,
onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials
)
chatMaterialsScreen(
onBack = navController::navigateUp,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }
)
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen(
onBack = navController::navigateUp,
onLogOutButtonClicked = { navController.navigateToAuth(true) },
onLanguageItemClicked = navController::navigateToLanguagePicker
)
languagePickerScreen(onBack = navController::navigateUp)
photoViewScreen(onBack = navController::navigateUp)
}
}
}
@@ -6,7 +6,7 @@ import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dev.meloda.fast.common.AppConstants
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.R as UiR
object NotificationsUtils {
@@ -28,7 +28,7 @@ object NotificationsUtils {
actions: List<NotificationCompat.Action> = emptyList(),
): NotificationCompat.Builder {
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_fast_logo)
.setSmallIcon(UiR.drawable.ic_fast_logo)
.setContentTitle(title)
.setPriority(priority.value)
.setContentIntent(contentIntent)
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<!-- Allow cleartext network traffic -->
<base-config
cleartextTrafficPermitted="false"
cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration">
<trust-anchors>
<!-- Trust pre-installed CAs -->
@@ -15,7 +15,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 36
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
}
}
}
@@ -12,7 +12,6 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
val extension = extensions.getByType<LibraryExtension>()
extension.androidResources.enable = false
configureAndroidCompose(extension)
}
}
@@ -2,6 +2,7 @@ import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import dev.meloda.fast.configureKotlinAndroid
import dev.meloda.fast.disableUnnecessaryAndroidTests
import dev.meloda.fast.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@@ -20,8 +21,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
androidResources.enable = false
defaultConfig.targetSdk = 36
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
extensions.configure<LibraryAndroidComponentsExtension> {
@@ -15,7 +15,7 @@ class AndroidTestConventionPlugin : Plugin<Project> {
extensions.configure<TestExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 36
defaultConfig.targetSdk = libs.findVersion("targetSdk").get().toString().toInt()
}
}
}
@@ -16,10 +16,10 @@ internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
compileSdk = 36
compileSdk = libs.findVersion("compileSdk").get().toString().toInt()
defaultConfig {
minSdk = 23
minSdk = libs.findVersion("minSdk").get().toString().toInt()
}
compileOptions {
@@ -5,8 +5,8 @@ object AppConstants {
const val INSTALL_APP_MIME_TYPE = "application/vnd.android.package-archive"
const val API_VERSION = "5.238"
const val URL_OAUTH = "https://oauth.vk.ru"
const val URL_API = "https://api.vk.ru/method"
const val URL_OAUTH = "https://oauth.vk.com"
const val URL_API = "https://api.vk.com/method"
const val NOTIFICATION_CHANNEL_UNCATEGORIZED = "uncategorized"
const val NOTIFICATION_CHANNEL_LONG_POLLING = "long_polling"
@@ -1,7 +1,5 @@
package dev.meloda.fast.common.extensions
import android.os.Build
import android.os.Bundle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -13,7 +11,6 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlin.reflect.KClass
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -26,20 +23,6 @@ fun <T> MutableList<T>.addIf(element: T, condition: () -> Boolean) {
if (condition.invoke()) add(element)
}
fun <T> MutableList<T>.removeIfCompat(condition: (T) -> Boolean): Boolean {
var removed = false
val each = iterator()
while (each.hasNext()) {
if (condition(each.next())) {
each.remove()
removed = true
}
}
return removed
}
fun <T> Flow<T>.listenValue(
coroutineScope: CoroutineScope,
action: suspend (T) -> Unit
@@ -106,7 +89,7 @@ fun Any.asInt(): Int {
}
fun Any.asLong(): Long {
return when (this) {
return when(this) {
is Number -> this.toLong()
else -> throw IllegalArgumentException("Object is not numeric")
@@ -120,21 +103,3 @@ fun <T> Any.toList(mapper: (old: Any) -> T): List<T> {
else -> emptyList()
}
}
fun <T> Bundle.getParcelableCompat(key: String, clazz: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
fun <T : Any> Bundle.getParcelableCompat(key: String, clazz: KClass<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(key, clazz.java)
} else {
@Suppress("DEPRECATION")
getParcelable(key)
}
}
@@ -1,5 +1,6 @@
package dev.meloda.fast.common.util
import android.content.res.Resources
import com.conena.nanokt.jvm.util.dayOfMonth
import com.conena.nanokt.jvm.util.hour
import com.conena.nanokt.jvm.util.hourOfDay
@@ -8,6 +9,7 @@ import com.conena.nanokt.jvm.util.minute
import com.conena.nanokt.jvm.util.month
import com.conena.nanokt.jvm.util.second
import com.conena.nanokt.jvm.util.year
import dev.meloda.fast.common.R
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
@@ -25,11 +27,7 @@ object TimeUtils {
}.timeInMillis
}
fun getLocalizedDate(
date: Long,
yesterday: () -> String,
today: () -> String
): String {
fun getLocalizedDate(resources: Resources, date: Long): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
@@ -38,50 +36,43 @@ object TimeUtils {
now.month != then.month -> "dd MMMM"
now.dayOfMonth != then.dayOfMonth -> {
if (now.dayOfMonth - then.dayOfMonth == 1) {
return yesterday()
return resources.getString(R.string.yesterday)
} else {
"dd MMMM"
}
}
else -> return today()
else -> return resources.getString(R.string.today)
}
return SimpleDateFormat(pattern, Locale.getDefault()).format(date)
}
fun getLocalizedTime(
date: Long,
yearShort: () -> String,
monthShort: () -> String,
weekShort: () -> String,
dayShort: () -> String,
now: () -> String
): String {
fun getLocalizedTime(resources: Resources, date: Long): String {
val now = Calendar.getInstance()
val then = Calendar.getInstance().also { it.timeInMillis = date }
return when {
now.year != then.year -> {
"${now.year - then.year}${yearShort().lowercase()}"
"${now.year - then.year}${resources.getString(R.string.year_short).lowercase()}"
}
now.month != then.month -> {
"${now.month - then.month}${monthShort().lowercase()}"
"${now.month - then.month}${resources.getString(R.string.month_short).lowercase()}"
}
now.dayOfMonth != then.dayOfMonth -> {
val change = now.dayOfMonth - then.dayOfMonth
if (change % 7 == 0) {
"${change / 7}${weekShort().lowercase()}"
"${change / 7}${resources.getString(R.string.week_short).lowercase()}"
} else {
"$change${dayShort().lowercase()}"
"$change${resources.getString(R.string.day_short).lowercase()}"
}
}
now.hour == then.hour && now.minute == then.minute -> {
now().lowercase()
resources.getString(R.string.time_now).lowercase()
}
else -> {
@@ -1,7 +0,0 @@
package dev.meloda.fast.common.util
import java.net.URLEncoder
fun String.urlEncode(encoding: String = "utf-8"): String {
return URLEncoder.encode(this, encoding)
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Вчера</string>
<string name="today">Сегодня</string>
<string name="year_short">Г</string>
<string name="month_short">М</string>
<string name="week_short">Н</string>
<string name="day_short">Д</string>
<string name="time_now">Сейчас</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="yesterday">Yesterday</string>
<string name="today">Today</string>
<string name="year_short">Y</string>
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</string>
<string name="time_now">Now</string>
</resources>
@@ -36,7 +36,7 @@ class VkUsersMap(
if (message.fromId > 0) map[message.fromId]
else null
fun user(userId: Long): VkUser? = map[userId]
fun user(userid: Long): VkUser? = map[userId]
companion object {
@@ -75,13 +75,7 @@ class ConversationsRepositoryImpl(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
actionGroup = groupsMap.messageActionGroup(message)
).also { VkMemoryCache[message.id] = it }
}
item.conversation.asDomain(lastMessage).let { conversation ->
@@ -32,9 +32,8 @@ interface MessagesRepository {
peerId: Long,
randomId: Long,
message: String?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
replyTo: Long?,
attachments: List<VkAttachment>?
): ApiResult<MessagesSendResponse, RestApiErrorDomain>
suspend fun markAsRead(
@@ -90,13 +90,7 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
actionGroup = groupsMap.messageActionGroup(message)
).also { VkMemoryCache[message.id] = it }
}
}
@@ -165,13 +159,7 @@ class MessagesRepositoryImpl(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
replyMessage = message.replyMessage?.asDomain()?.copy(
user = usersMap.messageUser(message),
group = groupsMap.messageGroup(message),
actionUser = usersMap.messageActionUser(message),
actionGroup = groupsMap.messageActionGroup(message),
)
actionGroup = groupsMap.messageActionGroup(message)
)
}
@@ -195,17 +183,15 @@ class MessagesRepositoryImpl(
peerId: Long,
randomId: Long,
message: String?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
replyTo: Long?,
attachments: List<VkAttachment>?
): ApiResult<MessagesSendResponse, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesSendRequest(
peerId = peerId,
randomId = randomId,
message = message,
forward = forward,
attachments = attachments,
formatData = formatData
replyTo = replyTo,
attachments = attachments
)
messagesService.send(requestModel.map).mapApiDefault()
@@ -320,12 +306,7 @@ class MessagesRepositoryImpl(
messagesIds = messageIds.orEmpty(),
important = important
)
messagesService.markAsImportant(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().marked.map { it.cmId }
},
errorMapper = { error -> error?.toDomain() }
)
messagesService.markAsImportant(requestModel.map).mapApiDefault()
}
override suspend fun delete(
@@ -1,58 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "ca007bca2ab4a9b901662792042770ad",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` TEXT, `trustedHash` TEXT, `exchangeToken` TEXT, PRIMARY KEY(`userId`))",
"fields": [
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fastToken",
"columnName": "fastToken",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "trustedHash",
"columnName": "trustedHash",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "exchangeToken",
"columnName": "exchangeToken",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"userId"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ca007bca2ab4a9b901662792042770ad')"
]
}
}
@@ -1,454 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "fa307a5eb2e1f7d601bd1374174635cd",
"entities": [
{
"tableName": "users",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `isOnlineMobile` INTEGER NOT NULL, `onlineAppId` INTEGER, `lastSeen` INTEGER, `lastSeenStatus` TEXT, `birthday` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `photo400Orig` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isOnline",
"columnName": "isOnline",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isOnlineMobile",
"columnName": "isOnlineMobile",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "onlineAppId",
"columnName": "onlineAppId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastSeen",
"columnName": "lastSeen",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "lastSeenStatus",
"columnName": "lastSeenStatus",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo400Orig",
"columnName": "photo400Orig",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `membersCount` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "screenName",
"columnName": "screenName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `conversationMessageId` INTEGER NOT NULL, `text` TEXT, `isOut` INTEGER NOT NULL, `peerId` INTEGER NOT NULL, `fromId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `randomId` INTEGER NOT NULL, `action` TEXT, `actionMemberId` INTEGER, `actionText` TEXT, `actionConversationMessageId` INTEGER, `actionMessage` TEXT, `updateTime` INTEGER, `important` INTEGER NOT NULL, `forwardIds` TEXT, `attachments` TEXT, `replyMessageId` INTEGER, `geoType` TEXT, `pinnedAt` INTEGER, `isPinned` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "conversationMessageId",
"columnName": "conversationMessageId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isOut",
"columnName": "isOut",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "peerId",
"columnName": "peerId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fromId",
"columnName": "fromId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "randomId",
"columnName": "randomId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "action",
"columnName": "action",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "actionMemberId",
"columnName": "actionMemberId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "actionText",
"columnName": "actionText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "actionConversationMessageId",
"columnName": "actionConversationMessageId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "actionMessage",
"columnName": "actionMessage",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "updateTime",
"columnName": "updateTime",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "important",
"columnName": "important",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "forwardIds",
"columnName": "forwardIds",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "replyMessageId",
"columnName": "replyMessageId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "geoType",
"columnName": "geoType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pinnedAt",
"columnName": "pinnedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "conversations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `localId` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo50` TEXT, `photo100` TEXT, `photo200` TEXT, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inReadCmId` INTEGER NOT NULL, `outReadCmId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `lastMessageId` INTEGER, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `canChangeInfo` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessageId` INTEGER, `peerType` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localId",
"columnName": "localId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ownerId",
"columnName": "ownerId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo50",
"columnName": "photo50",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo100",
"columnName": "photo100",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "photo200",
"columnName": "photo200",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isPhantom",
"columnName": "isPhantom",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastConversationMessageId",
"columnName": "lastConversationMessageId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inReadCmId",
"columnName": "inReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outReadCmId",
"columnName": "outReadCmId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inRead",
"columnName": "inRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "outRead",
"columnName": "outRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastMessageId",
"columnName": "lastMessageId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "unreadCount",
"columnName": "unreadCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "membersCount",
"columnName": "membersCount",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "canChangePin",
"columnName": "canChangePin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "canChangeInfo",
"columnName": "canChangeInfo",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "majorId",
"columnName": "majorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minorId",
"columnName": "minorId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "pinnedMessageId",
"columnName": "pinnedMessageId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "peerType",
"columnName": "peerType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fa307a5eb2e1f7d601bd1374174635cd')"
]
}
}
@@ -17,7 +17,7 @@ val databaseModule = module {
single {
Room.databaseBuilder(get(), CacheDatabase::class.java, "cache")
.fallbackToDestructiveMigration(true)
.fallbackToDestructiveMigration()
.build()
}
single { cacheDB().userDao() }
@@ -96,20 +96,6 @@ object AppSettings {
)
set(value) = put(SettingsKeys.KEY_SHOW_EMOJI_BUTTON, value)
var showAttachmentButton: Boolean
get() = get(
SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON,
SettingsKeys.DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON
)
set(value) = put(SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON, value)
var showManualRefreshOptions: Boolean
get() = get(
SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS,
SettingsKeys.DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS
)
set(value) = put(SettingsKeys.KEY_SHOW_MANUAL_REFRESH_OPTIONS, value)
var enableHaptic: Boolean
get() = get(
SettingsKeys.KEY_ENABLE_HAPTIC,
@@ -11,10 +11,6 @@ object SettingsKeys {
const val DEFAULT_VALUE_USE_CONTACT_NAMES = false
const val KEY_SHOW_EMOJI_BUTTON = "show_emoji_button"
const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false
const val KEY_SHOW_ATTACHMENT_BUTTON = "show_attachment_button"
const val DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON = false
const val KEY_SHOW_MANUAL_REFRESH_OPTIONS = "show_manual_refresh_options"
const val DEFAULT_VALUE_SHOW_MANUAL_REFRESH_OPTIONS = false
const val KEY_APPEARANCE = "appearance"
const val KEY_APPEARANCE_MULTILINE = "appearance_multiline"
@@ -50,9 +46,6 @@ object SettingsKeys {
const val DEFAULT_ENABLE_HAPTIC = true
const val KEY_DEBUG_NETWORK_LOG_LEVEL = "debug_network_log_level"
const val DEFAULT_NETWORK_LOG_LEVEL = 3
const val KEY_DEBUG_IMPORT_AUTH_DATA = "debug_import_auth_data"
const val KEY_DEBUG_EXPORT_AUTH_DATA = "debug_export_auth_data"
const val KEY_USE_SYSTEM_FONT = "use_system_font"
const val DEFAULT_USE_SYSTEM_FONT = false
const val KEY_MORE_ANIMATIONS = "more_animations"
@@ -14,10 +14,15 @@ interface UserSettings {
val enableDynamicColors: StateFlow<Boolean>
val appLanguage: StateFlow<String>
val fastText: StateFlow<String>
val sendOnlineStatus: StateFlow<Boolean>
val showAlertAfterCrash: StateFlow<Boolean>
val longPollInBackground: StateFlow<Boolean>
val useBlur: StateFlow<Boolean>
val showEmojiButton: StateFlow<Boolean>
val showTimeInActionMessages: StateFlow<Boolean>
val useSystemFont: StateFlow<Boolean>
val enableAnimations: StateFlow<Boolean>
val showDebugCategory: StateFlow<Boolean>
@@ -30,10 +35,15 @@ interface UserSettings {
fun onEnableDynamicColorsChanged(enable: Boolean)
fun onAppLanguageChanged(language: String)
fun onFastTextChanged(text: String)
fun onSendOnlineStatusChanged(send: Boolean)
fun onShowAlertAfterCrashChanged(show: Boolean)
fun onLongPollInBackgroundChanged(inBackground: Boolean)
fun onUseBlurChanged(use: Boolean)
fun onShowEmojiButtonChanged(show: Boolean)
fun onShowTimeInActionMessagesChanged(show: Boolean)
fun onUseSystemFontChanged(use: Boolean)
fun onShowDebugCategoryChanged(show: Boolean)
}
@@ -48,11 +58,16 @@ class UserSettingsImpl : UserSettings {
override val enableDynamicColors = MutableStateFlow(AppSettings.Appearance.enableDynamicColors)
override val appLanguage = MutableStateFlow(AppSettings.Appearance.appLanguage)
override val fastText = MutableStateFlow(AppSettings.Features.fastText)
override val sendOnlineStatus = MutableStateFlow(AppSettings.Activity.sendOnlineStatus)
override val longPollInBackground =
MutableStateFlow(AppSettings.Experimental.longPollInBackground)
override val showAlertAfterCrash = MutableStateFlow(AppSettings.Debug.showAlertAfterCrash)
override val longPollInBackground = MutableStateFlow(AppSettings.Experimental.longPollInBackground)
override val useBlur = MutableStateFlow(AppSettings.Experimental.useBlur)
override val showEmojiButton = MutableStateFlow(AppSettings.General.showEmojiButton)
override val showTimeInActionMessages =
MutableStateFlow(AppSettings.Experimental.showTimeInActionMessages)
override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont)
override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations)
override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory)
@@ -81,10 +96,18 @@ class UserSettingsImpl : UserSettings {
appLanguage.value = language
}
override fun onFastTextChanged(text: String) {
fastText.value = text
}
override fun onSendOnlineStatusChanged(send: Boolean) {
sendOnlineStatus.value = send
}
override fun onShowAlertAfterCrashChanged(show: Boolean) {
showAlertAfterCrash.value = show
}
override fun onLongPollInBackgroundChanged(inBackground: Boolean) {
longPollInBackground.value = inBackground
}
@@ -93,6 +116,14 @@ class UserSettingsImpl : UserSettings {
useBlur.value = use
}
override fun onShowEmojiButtonChanged(show: Boolean) {
showEmojiButton.value = show
}
override fun onShowTimeInActionMessagesChanged(show: Boolean) {
showTimeInActionMessages.value = show
}
override fun onUseSystemFontChanged(use: Boolean) {
useSystemFont.value = use
}
@@ -32,9 +32,8 @@ interface MessagesUseCase : BaseUseCase {
peerId: Long,
randomId: Long,
message: String?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
replyTo: Long?,
attachments: List<VkAttachment>?
): Flow<State<MessagesSendResponse>>
fun markAsRead(
@@ -56,17 +56,15 @@ class MessagesUseCaseImpl(
peerId: Long,
randomId: Long,
message: String?,
forward: String?,
attachments: List<VkAttachment>?,
formatData: VkMessage.FormatData?
replyTo: Long?,
attachments: List<VkAttachment>?
): Flow<State<MessagesSendResponse>> = flowNewState {
repository.send(
peerId = peerId,
randomId = randomId,
message = message,
forward = forward,
attachments = attachments,
formatData = formatData
replyTo = replyTo,
attachments = attachments
).mapToState()
}
@@ -1,8 +0,0 @@
package dev.meloda.fast.model
data class PhotoSize(
val height: Int,
val width: Int,
val type: String,
val url: String
)
@@ -6,8 +6,8 @@ enum class AttachmentType(var value: String) {
UNKNOWN("unknown"),
PHOTO("photo"),
VIDEO("video"),
FILE("doc"),
AUDIO("audio"),
FILE("doc"),
LINK("link"),
AUDIO_MESSAGE("audio_message"),
MINI_APP("mini_app"),
@@ -27,9 +27,7 @@ data class VkFileData(
) {
@JsonClass(generateAdapter = true)
data class Photo(
val sizes: List<Size>
) {
data class Photo(val sizes: List<Size>) {
@JsonClass(generateAdapter = true)
data class Size(
@@ -1,13 +1,12 @@
package dev.meloda.fast.model.api.data
import dev.meloda.fast.model.api.domain.VkGiftDomain
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.domain.VkGiftDomain
@JsonClass(generateAdapter = true)
data class VkGiftData(
@Json(name = "id") val id: Long,
@Json(name = "thumb_512") val thumb512: String?,
@Json(name = "thumb_256") val thumb256: String?,
@Json(name = "thumb_96") val thumb96: String?,
@Json(name = "thumb_48") val thumb48: String
@@ -15,7 +14,6 @@ data class VkGiftData(
fun toDomain() = VkGiftDomain(
id = id,
thumb512 = thumb512,
thumb256 = thumb256,
thumb96 = thumb96,
thumb48 = thumb48
@@ -105,7 +105,7 @@ fun VkMessageData.asDomain(): VkMessage = VkMessage(
actionConversationMessageId = action?.conversationMessageId,
actionMessage = action?.message,
geoType = geo?.type,
isImportant = important == true,
isImportant = important ?: false,
updateTime = updateTime,
forwards = fwdMessages.orEmpty().map(VkMessageData::asDomain),
attachments = attachments.map(VkAttachmentItemData::toDomain),
@@ -2,7 +2,6 @@ package dev.meloda.fast.model.api.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.PhotoSize
import dev.meloda.fast.model.api.domain.VkPhotoDomain
@JsonClass(generateAdapter = true)
@@ -36,14 +35,7 @@ data class VkPhotoData(
ownerId = ownerId,
hasTags = hasTags == true,
accessKey = accessKey,
sizes = sizes.map { size ->
PhotoSize(
height = size.height,
width = size.width,
type = size.type,
url = size.url
)
},
sizes = sizes,
text = text,
userId = userId
)
@@ -37,7 +37,7 @@ data class VkUserData(
@JsonClass(generateAdapter = true)
data class LastSeen(
@Json(name = "platform") val platform: Int?,
@Json(name = "platform") val platform: Int,
@Json(name = "time") val time: Int
)
@@ -23,7 +23,7 @@ data class VkVideoData(
@Json(name = "is_favorite") val isFavorite: Boolean?,
@Json(name = "image") val image: List<Image>?,
@Json(name = "first_frame") val firstFrame: List<FirstFrame>?,
@Json(name = "files") val files: File?,
@Json(name = "files") val files: File?
) : VkAttachmentData {
@JsonClass(generateAdapter = true)
@@ -73,7 +73,6 @@ data class VkVideoData(
accessKey = accessKey,
title = title,
views = views,
duration = duration,
isShortVideo = type == "short_video"
duration = duration
)
}
@@ -54,9 +54,9 @@ data class VkVideoMessageData(
@JsonClass(generateAdapter = true)
data class Image(
val height: Int,
val url: String,
val width: Int,
val height: Int?,
val url: String?,
val width: Int?,
val with_padding: Int?,
)
@@ -73,7 +73,6 @@ data class VkVideoMessageData(
)
fun toDomain(): VkVideoMessageDomain = VkVideoMessageDomain(
id = id,
image = image.orEmpty().filter { it.width / it.height == 1 }.maxByOrNull { it.width }?.url
id = id
)
}
@@ -9,10 +9,10 @@ data class VkWallReplyData(
val from_id: Long,
val date: Int,
val text: String,
val post_id: Long?,
val owner_id: Long?,
val parents_stack: List<Int>?,
val likes: Likes?,
val post_id: Long,
val owner_id: Long,
val parents_stack: List<Int>,
val likes: Likes,
val reply_to_user: Int?,
val reply_to_comment: Int?
) {
@@ -3,10 +3,6 @@ package dev.meloda.fast.model.api.domain
enum class FormatDataType {
BOLD, ITALIC, UNDERLINE, URL;
override fun toString(): String {
return super.toString().lowercase()
}
companion object {
fun parse(value: String): FormatDataType? =
entries.firstOrNull { it.name.lowercase() == value }
@@ -4,14 +4,10 @@ import dev.meloda.fast.model.api.data.AttachmentType
data class VkGiftDomain(
val id: Long,
val thumb512: String?,
val thumb256: String?,
val thumb96: String?,
val thumb48: String
) : VkAttachment {
override val type: AttachmentType = AttachmentType.GIFT
fun getMaxSizeThumb(): String = thumb512 ?: thumb256 ?: thumb96 ?: thumb48
fun getDefaultThumbSizeOrLess(): String = thumb256 ?: thumb96 ?: thumb48
}
@@ -35,7 +35,7 @@ data class VkMessage(
val user: VkUser?,
val group: VkGroupDomain?,
val actionUser: VkUser?,
val actionGroup: VkGroupDomain?,
val actionGroup: VkGroupDomain?
) {
fun isPeerChat() = peerId > 2_000_000_000
@@ -1,7 +1,7 @@
package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.PhotoSize
import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.data.VkPhotoData
import java.util.Stack
@@ -13,7 +13,7 @@ data class VkPhotoDomain(
val ownerId: Long,
val hasTags: Boolean,
val accessKey: String?,
val sizes: List<PhotoSize>,
val sizes: List<VkPhotoData.Size>,
val text: String?,
val userId: Long?
) : VkAttachment {
@@ -35,15 +35,11 @@ data class VkPhotoDomain(
sizesChars.push(SIZE_TYPE_2560_2048)
}
fun getMaxSize(): PhotoSize? {
fun getMaxSize(): VkPhotoData.Size? {
return getSizeOrSmaller(sizesChars.peek())
}
fun getDefault(): PhotoSize? {
return getSizeOrSmaller(SIZE_TYPE_1080_1024)
}
fun getSizeOrNull(type: Char): PhotoSize? {
fun getSizeOrNull(type: Char): VkPhotoData.Size? {
for (size in sizes) {
if (size.type == type.toString()) return size
}
@@ -51,7 +47,7 @@ data class VkPhotoDomain(
return null
}
fun getSizeOrSmaller(type: Char): PhotoSize? {
fun getSizeOrSmaller(type: Char): VkPhotoData.Size? {
val photoStack = sizesChars.clone() as Stack<*>
val sizeIndex = photoStack.search(type)
@@ -21,12 +21,4 @@ data class VkStickerDomain(
return null
}
fun getUrl(width: Int = 256, withBackground: Boolean = false): String? = when {
withBackground && backgroundImages != null -> {
backgroundImages.firstOrNull { it.width >= width }?.url
}
images != null -> images.firstOrNull { it.width >= width }?.url
else -> "https://vk.ru/sticker/1-${id}-${width}b"
}
}
@@ -13,8 +13,7 @@ data class VkVideoDomain(
val accessKey: String?,
val title: String,
val views: Int,
val duration: Int,
val isShortVideo: Boolean
val duration: Int
) : VkAttachment {
override val type: AttachmentType = AttachmentType.VIDEO
@@ -23,10 +22,6 @@ data class VkVideoDomain(
return images.find { it.width == width }
}
fun getDefault(): VideoImage? {
return imageForWidthAtLeast(720)
}
fun imageForWidthAtLeast(width: Int): VideoImage? {
var certainImages = images.sortedByDescending { it.width }
var containsVertical = false
@@ -41,11 +36,9 @@ data class VkVideoDomain(
certainImages = certainImages.filter { it.shapeKind == ShapeKind.Vertical }
}
val filteredCertainImages = certainImages.filter { it.width >= width }
certainImages = certainImages.filter { it.width >= width }
return filteredCertainImages
.ifEmpty { certainImages }
.firstOrNull()
return certainImages.firstOrNull()
}
@JsonClass(generateAdapter = true)
@@ -3,8 +3,7 @@ package dev.meloda.fast.model.api.domain
import dev.meloda.fast.model.api.data.AttachmentType
data class VkVideoMessageDomain(
val id: Long,
val image: String?
val id: Long
) : VkAttachment {
override val type: AttachmentType = AttachmentType.VIDEO_MESSAGE
@@ -2,7 +2,6 @@ package dev.meloda.fast.model.api.requests
import dev.meloda.fast.model.api.asInt
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkMessage
data class MessagesGetHistoryRequest(
val count: Int? = null,
@@ -34,13 +33,12 @@ data class MessagesSendRequest(
val message: String?,
val lat: Int? = null,
val lon: Int? = null,
val forward: String? = null,
val replyTo: Long? = null,
val stickerId: Long? = null,
val disableMentions: Boolean? = null,
val doNotParseLinks: Boolean? = null,
val silent: Boolean? = null,
val attachments: List<VkAttachment>? = null,
val formatData: VkMessage.FormatData? = null
val attachments: List<VkAttachment>? = null
) {
val map: Map<String, String>
@@ -51,18 +49,11 @@ data class MessagesSendRequest(
message?.let { this["message"] = it }
lat?.let { this["lat"] = it.toString() }
lon?.let { this["lon"] = it.toString() }
forward?.let { this["forward"] = it }
replyTo?.let { this["reply_to"] = it.toString() }
stickerId?.let { this["sticker_id"] = it.toString() }
disableMentions?.let { this["disable_mentions"] = it.asInt().toString() }
doNotParseLinks?.let { this["dont_parse_links"] = it.asInt().toString() }
silent?.let { this["silent"] = it.toString() }
formatData?.let {
this["format_data"] = "{\"version\":\"${formatData.version}\",\"items\":[" +
formatData.items.joinToString(separator = ", ") { item ->
"{\"type\":\"${item.type}\",\"offset\":${item.offset},\"length\":${item.length}}"
} +
"]}"
}
// TODO: 05/05/2024, Danil Nikolaev: add attachments
// attachments?.let {
@@ -35,7 +35,7 @@ data class AuthDirectRequest(
}
data class AuthWithAppRequest(
val redirectUrl: String = "https://oauth.vk.ru/blank.html",
val redirectUrl: String = "https://oauth.vk.com/blank.html",
val display: String = "page",
val responseType: String = "token",
val accessToken: String,
@@ -58,15 +58,3 @@ data class MessagesSendResponse(
@Json(name = "message_id") val messageId: Long,
@Json(name = "cmid") val cmId: Long
)
@JsonClass(generateAdapter = true)
data class MessagesMarkAsImportantResponse(
@Json(name = "marked") val marked: List<Mark>
) {
@JsonClass(generateAdapter = true)
data class Mark(
@Json(name = "cmid") val cmId: Long,
@Json(name = "message_id") val messageId: Long,
@Json(name = "peer_id") val peerId: Long
)
}
@@ -9,7 +9,6 @@ import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetConversationMembersResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse
import dev.meloda.fast.model.api.responses.MessagesMarkAsImportantResponse
import dev.meloda.fast.model.api.responses.MessagesSendResponse
import dev.meloda.fast.network.ApiResponse
import dev.meloda.fast.network.RestApiError
@@ -77,7 +76,7 @@ interface MessagesService {
@POST(MessagesUrls.MARK_AS_IMPORTANT)
suspend fun markAsImportant(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesMarkAsImportantResponse>, RestApiError>
): ApiResult<ApiResponse<List<Long>>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.DELETE)
-3
View File
@@ -6,7 +6,6 @@ plugins {
android {
namespace = "dev.meloda.fast.ui"
androidResources.enable = true
}
dependencies {
@@ -20,8 +19,6 @@ dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.bundles.nanokt)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
implementation(libs.koin.androidx.compose.navigation)
@@ -1,12 +0,0 @@
package dev.meloda.fast.ui.common
import androidx.compose.runtime.compositionLocalOf
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
val LocalSizeConfig = compositionLocalOf {
SizeConfig(
widthSize = DeviceSize.Compact,
heightSize = DeviceSize.Compact
)
}
@@ -20,12 +20,12 @@ 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.R as UiR
@Composable
fun ErrorView(
modifier: Modifier = Modifier,
iconResId: Int? = R.drawable.round_error_24,
iconResId: Int? = UiR.drawable.round_error_24,
text: String,
buttonText: String? = null,
onButtonClick: (() -> Unit)? = null,
@@ -1,34 +0,0 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
@Composable
fun FullScreenDialog(
modifier: Modifier = Modifier,
onDismiss: () -> Unit = {},
content: @Composable () -> Unit,
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false,
)
) {
Box(
modifier = modifier
.fillMaxSize()
.zIndex(10F),
contentAlignment = Alignment.Center
) {
content()
}
}
}
@@ -0,0 +1,28 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun FullScreenLoader(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@Preview
@Composable
private fun FullScreenLoaderPreview() {
FullScreenLoader()
}
@@ -1,16 +1,19 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Indication
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalUseFallbackRippleImplementation
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
@@ -20,9 +23,10 @@ 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.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun IconButton(
modifier: Modifier = Modifier,
@@ -35,18 +39,21 @@ fun IconButton(
) {
Box(
modifier =
modifier
.minimumInteractiveComponentSize()
.size(IconButtonTokens.StateLayerSize)
.clip(IconButtonTokens.StateLayerShape)
.background(color = colors.containerColor(enabled))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled,
interactionSource = interactionSource,
indication = ripple()
),
modifier
.minimumInteractiveComponentSize()
.size(IconButtonTokens.StateLayerSize)
.clip(IconButtonTokens.StateLayerShape)
.background(color = colors.containerColor(enabled))
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled,
interactionSource = interactionSource,
indication = rippleOrFallbackImplementation(
bounded = false,
radius = IconButtonTokens.StateLayerSize / 2
)
),
contentAlignment = Alignment.Center
) {
val contentColor = colors.contentColor(enabled)
@@ -54,6 +61,21 @@ fun IconButton(
}
}
@Suppress("DEPRECATION_ERROR")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun rippleOrFallbackImplementation(
bounded: Boolean = true,
radius: Dp = Dp.Unspecified,
color: Color = Color.Unspecified
): Indication {
return if (LocalUseFallbackRippleImplementation.current) {
rememberRipple(bounded, radius, color)
} else {
ripple(bounded, radius, color)
}
}
internal object IconButtonTokens {
val StateLayerShape = CircleShape
val StateLayerSize = 40.0.dp
@@ -1,79 +0,0 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.ContainedLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
@Composable
@Preview
fun FullScreenContainedLoader(
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.primary,
indicatorColor: Color = MaterialTheme.colorScheme.primaryContainer
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
ContainedLoader(
containerColor = containerColor,
indicatorColor = indicatorColor
)
}
}
@Preview
@Composable
fun FullScreenLoader(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary
) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
Loader(color = color)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview
fun ContainedLoader(
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.primary,
indicatorColor: Color = MaterialTheme.colorScheme.primaryContainer
) {
ContainedLoadingIndicator(
modifier = modifier,
containerColor = containerColor,
indicatorColor = indicatorColor
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview
fun Loader(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary
) {
LoadingIndicator(
modifier = modifier,
color = color
)
}
@@ -1,33 +0,0 @@
package dev.meloda.fast.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
@Composable
fun RippledClickContainer(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(4.dp),
onClick: () -> Unit,
content: @Composable () -> Unit
) {
Box(
modifier = modifier
.clip(shape)
.clickable(
interactionSource = null,
indication = ripple(),
onClick = onClick
),
contentAlignment = Alignment.Center
) {
content()
}
}
@@ -1,9 +0,0 @@
package dev.meloda.fast.ui.extensions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
@Composable
fun <T> ProvidableCompositionLocal<T?>.getOrThrow(): T {
return requireNotNull(current)
}
@@ -4,27 +4,112 @@ import android.app.Activity
import android.os.Build
import androidx.compose.animation.animateColorAsState
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeState
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.DeviceSize
import dev.meloda.fast.ui.model.SizeConfig
import dev.meloda.fast.ui.model.ThemeConfig
private val googleSansFonts = FontFamily(
Font(resId = R.font.google_sans_regular),
Font(
resId = R.font.google_sans_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.google_sans_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.google_sans_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
)
)
private val robotoFonts = FontFamily(
Font(
resId = R.font.roboto_thin,
weight = FontWeight.Thin
),
Font(
resId = R.font.roboto_thin_italic,
weight = FontWeight.Thin,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_light,
weight = FontWeight.Light
),
Font(
resId = R.font.roboto_light_italic,
weight = FontWeight.Light,
style = FontStyle.Italic
),
Font(resId = R.font.roboto_regular),
Font(
resId = R.font.roboto_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.roboto_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.roboto_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_black,
weight = FontWeight.Black
),
Font(
resId = R.font.roboto_black_italic,
weight = FontWeight.Black,
style = FontStyle.Italic
)
)
val LocalThemeConfig = compositionLocalOf {
ThemeConfig(
darkMode = false,
@@ -38,14 +123,25 @@ val LocalThemeConfig = compositionLocalOf {
)
}
val LocalHazeState = compositionLocalOf { HazeState(true) }
val LocalSizeConfig = compositionLocalOf {
SizeConfig(
widthSize = DeviceSize.Compact,
heightSize = DeviceSize.Compact
)
}
val LocalHazeState = compositionLocalOf { HazeState() }
val LocalBottomPadding = compositionLocalOf { 0.dp }
val LocalUser = compositionLocalOf<VkUser?> { null }
val LocalReselectedTab = compositionLocalOf { mapOf<Any, Boolean>() }
val LocalNavRootController = compositionLocalOf<NavController?> { null }
val LocalNavController = compositionLocalOf<NavController?> { null }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun <T: NavController> ProvidableCompositionLocal<T?>.getOrThrow(): T {
return requireNotNull(current)
}
@Composable
fun AppTheme(
predefinedColorScheme: ColorScheme? = null,
@@ -90,21 +186,21 @@ fun AppTheme(
MaterialTheme.typography
} else {
MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = GoogleSansFamily),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = GoogleSansFamily),
displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = GoogleSansFamily),
headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = GoogleSansFamily),
headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = GoogleSansFamily),
headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = GoogleSansFamily),
titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = RobotoFamily),
titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = RobotoFamily),
titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = RobotoFamily),
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = RobotoFamily),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = RobotoFamily),
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = RobotoFamily),
labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = RobotoFamily),
labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = RobotoFamily),
labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = RobotoFamily),
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = googleSansFonts),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = googleSansFonts),
displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = googleSansFonts),
headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = googleSansFonts),
headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = googleSansFonts),
headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = googleSansFonts),
titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = robotoFonts),
titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = robotoFonts),
titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = robotoFonts),
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = robotoFonts),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = robotoFonts),
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = robotoFonts),
labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = robotoFonts),
labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = robotoFonts),
labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = robotoFonts),
)
}
@@ -117,7 +213,7 @@ fun AppTheme(
}
}
MaterialExpressiveTheme(
MaterialTheme(
colorScheme = (predefinedColorScheme ?: colorScheme)
.copy(
primary = colorPrimary,
@@ -1,86 +0,0 @@
package dev.meloda.fast.ui.theme
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import dev.meloda.fast.ui.R
val GoogleSansFamily = FontFamily(
Font(resId = R.font.google_sans_regular),
Font(
resId = R.font.google_sans_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.google_sans_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.google_sans_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.google_sans_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
)
)
val RobotoFamily = FontFamily(
Font(
resId = R.font.roboto_thin,
weight = FontWeight.Thin
),
Font(
resId = R.font.roboto_thin_italic,
weight = FontWeight.Thin,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_light,
weight = FontWeight.Light
),
Font(
resId = R.font.roboto_light_italic,
weight = FontWeight.Light,
style = FontStyle.Italic
),
Font(resId = R.font.roboto_regular),
Font(
resId = R.font.roboto_italic,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_medium,
weight = FontWeight.Medium
),
Font(
resId = R.font.roboto_medium_italic,
weight = FontWeight.Medium,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_bold,
weight = FontWeight.Bold
),
Font(
resId = R.font.roboto_bold_italic,
weight = FontWeight.Bold,
style = FontStyle.Italic
),
Font(
resId = R.font.roboto_black,
weight = FontWeight.Black
),
Font(
resId = R.font.roboto_black_italic,
weight = FontWeight.Black,
style = FontStyle.Italic
)
)
@@ -3,7 +3,7 @@ package dev.meloda.fast.ui.util
import androidx.compose.runtime.Immutable
@Immutable
class ImmutableList<T>(val values: List<T>) : Collection<T> {
class ImmutableList<T>(val values: List<T>) : Iterable<T> {
constructor(size: Int, init: (index: Int) -> T) : this(MutableList(size, init))
@@ -25,18 +25,30 @@ class ImmutableList<T>(val values: List<T>) : Collection<T> {
return values.mapIndexed(transform).toImmutableList()
}
override fun isEmpty(): Boolean = values.isEmpty()
override val size: Int get() = values.size
override fun containsAll(elements: Collection<T>): Boolean {
return values.containsAll(elements)
fun singleOrNull(): T? {
return if (values.size == 1) this[0] else null
}
override fun contains(element: T): Boolean {
return values.contains(element)
fun isEmpty(): Boolean = values.isEmpty()
fun isNotEmpty(): Boolean = !isEmpty()
inline fun singleOrNull(predicate: (T) -> Boolean): T? {
var single: T? = null
var found = false
for (element in this) {
if (predicate(element)) {
if (found) return null
single = element
found = true
}
}
if (!found) return null
return single
}
val size: Int get() = values.size
companion object {
fun <T> copyOf(collection: Collection<T>): ImmutableList<T> =
ImmutableList(collection.toList())
@@ -55,7 +67,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()
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,11H7.83l4.88,-4.88c0.39,-0.39 0.39,-1.03 0,-1.42 -0.39,-0.39 -1.02,-0.39 -1.41,0l-6.59,6.59c-0.39,0.39 -0.39,1.02 0,1.41l6.59,6.59c0.39,0.39 1.02,0.39 1.41,0 0.39,-0.39 0.39,-1.02 0,-1.41L7.83,13H19c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1z" />
</vector>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M313,520L509,716Q521,728 520.5,744Q520,760 508,772Q496,783 480,783.5Q464,784 452,772L188,508Q182,502 179.5,495Q177,488 177,480Q177,472 179.5,465Q182,458 188,452L452,188Q463,177 479.5,177Q496,177 508,188Q520,200 520,216.5Q520,233 508,245L313,440L760,440Q777,440 788.5,451.5Q800,463 800,480Q800,497 788.5,508.5Q777,520 760,520L313,520Z" />
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M382,606L721,267Q733,255 749,255Q765,255 777,267Q789,279 789,295.5Q789,312 777,324L410,692Q398,704 382,704Q366,704 354,692L182,520Q170,508 170.5,491.5Q171,475 183,463Q195,451 211.5,451Q228,451 240,463L382,606Z"/>
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,536L284,732Q273,743 256,743Q239,743 228,732Q217,721 217,704Q217,687 228,676L424,480L228,284Q217,273 217,256Q217,239 228,228Q239,217 256,217Q273,217 284,228L480,424L676,228Q687,217 704,217Q721,217 732,228Q743,239 743,256Q743,273 732,284L536,480L732,676Q743,687 743,704Q743,721 732,732Q721,743 704,743Q687,743 676,732L480,536Z"/>
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM160,840Q143,840 131.5,828.5Q120,817 120,800L120,703Q120,687 126,672.5Q132,658 143,647L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L313,817Q302,828 287.5,834Q273,840 257,840L160,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z"/>
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,432L324,588Q313,599 296,599Q279,599 268,588Q257,577 257,560Q257,543 268,532L452,348Q464,336 480,336Q496,336 508,348L692,532Q703,543 703,560Q703,577 692,588Q681,599 664,599Q647,599 636,588L480,432Z"/>
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM800,320L501,507Q496,510 490.5,511.5Q485,513 480,513Q475,513 469.5,511.5Q464,510 459,507L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320ZM480,440L800,240L160,240L480,440ZM160,320L160,330Q160,325 160,317.5Q160,310 160,301Q160,281 160,271Q160,261 160,272L160,240L160,240L160,272Q160,261 160,271.5Q160,282 160,301Q160,311 160,318.5Q160,326 160,330L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z"/>
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/>
</vector>
@@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M8,19c1.1,0 2,-0.9 2,-2L10,7c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2v10c0,1.1 0.9,2 2,2zM14,7v10c0,1.1 0.9,2 2,2s2,-0.9 2,-2L18,7c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2z"/>
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M320,687L320,273Q320,256 332,244.5Q344,233 360,233Q365,233 370.5,234.5Q376,236 381,239L707,446Q716,452 720.5,461Q725,470 725,480Q725,490 720.5,499Q716,508 707,514L381,721Q376,724 370.5,725.5Q365,727 360,727Q344,727 332,715.5Q320,704 320,687ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z" />
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,200Q720,183 731.5,171.5Q743,160 760,160Q777,160 788.5,171.5Q800,183 800,200L800,400Q800,417 788.5,428.5Q777,440 760,440L560,440Q543,440 531.5,428.5Q520,417 520,400Q520,383 531.5,371.5Q543,360 560,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q548,720 604.5,685.5Q661,651 692,593Q700,579 714.5,573.5Q729,568 744,573Q760,578 767,594Q774,610 766,624Q725,704 649,752Q573,800 480,800Z"/>
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L273,480L417,624L360,680L120,440L360,200L417,256L273,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z" />
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#ffffff"
android:pathData="M433,880Q406,880 386.5,862Q367,844 363,818L354,752Q341,747 329.5,740Q318,733 307,725L245,751Q220,762 195,753Q170,744 156,721L109,639Q95,616 101,590Q107,564 128,547L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L128,413Q107,396 101,370Q95,344 109,321L156,239Q170,216 195,207Q220,198 245,209L307,235Q318,227 330,220Q342,213 354,208L363,142Q367,116 386.5,98Q406,80 433,80L527,80Q554,80 573.5,98Q593,116 597,142L606,208Q619,213 630.5,220Q642,227 653,235L715,209Q740,198 765,207Q790,216 804,239L851,321Q865,344 859,370Q853,396 832,413L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L831,547Q852,564 858,590Q864,616 850,639L802,721Q788,744 763,753Q738,762 713,751L653,725Q642,733 630,740Q618,747 606,752L597,818Q593,844 573.5,862Q554,880 527,880L433,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z"/>
</vector>
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,700Q555,700 607.5,647.5Q660,595 660,520Q660,445 607.5,392.5Q555,340 480,340Q405,340 352.5,392.5Q300,445 300,520Q300,595 352.5,647.5Q405,700 480,700ZM480,620Q438,620 409,591Q380,562 380,520Q380,478 409,449Q438,420 480,420Q522,420 551,449Q580,478 580,520Q580,562 551,591Q522,620 480,620ZM160,840Q127,840 103.5,816.5Q80,793 80,760L80,280Q80,247 103.5,223.5Q127,200 160,200L286,200L336,146Q347,134 362.5,127Q378,120 395,120L565,120Q582,120 597.5,127Q613,134 624,146L674,200L800,200Q833,200 856.5,223.5Q880,247 880,280L880,760Q880,793 856.5,816.5Q833,840 800,840L160,840ZM160,760L800,760Q800,760 800,760Q800,760 800,760L800,280Q800,280 800,280Q800,280 800,280L638,280L565,200L395,200L322,280L160,280Q160,280 160,280Q160,280 160,280L160,760Q160,760 160,760Q160,760 160,760ZM480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520L480,520L480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Z" />
</vector>
+1 -27
View File
@@ -107,7 +107,6 @@
<string name="message_attachments_files_few">%1$d файла</string>
<string name="message_attachments_files_many">%1$d файлов</string>
<string name="message_attachments_files_other">%1$d файлов</string>
<string name="message_attachments_clip">Клип</string>
<string name="message_attachments_audio_message">Голосовое сообщение</string>
<string name="message_attachments_link">Ссылка</string>
<string name="message_attachments_mini_app">Мини-приложение</string>
@@ -194,7 +193,6 @@
<string name="settings_general_title">Основное</string>
<string name="settings_general_contact_names_title">Использовать имена контактов</string>
<string name="settings_general_contact_names_summary">Приложение будет использовать доступные имена контактов для пользователей</string>
<string name="settings_general_show_attachment_button_summary">Показывать кнопку вложений на панели чата</string>
<string name="settings_general_enable_haptic_title">Включить тактильную отдачу</string>
<string name="settings_appearance_title">Внешний вид</string>
<string name="settings_appearance_multiline_title">Многострочные заголовки и сообщения</string>
@@ -223,7 +221,7 @@
<string name="settings_general_show_emoji_button_summary">Показывать кнопку эмоджи на панели чата</string>
<string name="settings_features_show_time_in_action_messages_title">Показывать время в сервисных сообщениях</string>
<string name="settings_experimental_use_blur_title">Использовать размытие</string>
<string name="settings_experimental_use_blur_summary">Добавлять размытие везде, где возможно</string>
<string name="settings_experimental_use_blur_summary">Добавлять размытие везде, где возможно.\\nРаботает только с 12 версии Android</string>
<string name="settings_experimental_more_animations_title">Больше анимаций</string>
<string name="warning_confirmation">Подтверждение</string>
<string name="captcha_exit_warning">Вы уверены? Процесс ввода капчи будет отменён</string>
@@ -265,28 +263,4 @@
<string name="conversation_context_action_archive">В архив</string>
<string name="confirm_archive_conversation">Архивировать чат?</string>
<string name="action_archive">В архив</string>
<string name="autofill">Автозаполнение</string>
<string name="bold">Жирный</string>
<string name="italic">Курсив</string>
<string name="underline">Подчёркнутый</string>
<string name="link">Ссылка</string>
<string name="regular">Обычный</string>
<string name="login_sign_up">Регистрация</string>
<string name="login_forgot_password">Забыли пароль?</string>
<string name="settings_general_show_attachment_button_title">Показывать кнопку вложений</string>
<string name="action_copy_link">Скопировать ссылку</string>
<string name="action_copy">Скопировать</string>
<string name="action_copy_image">Скопировать изображение</string>
<string name="action_open_in">Открыть в…</string>
<string name="action_share">Поделиться</string>
<string name="yesterday">Вчера</string>
<string name="today">Сегодня</string>
<string name="year_short">Г</string>
<string name="month_short">М</string>
<string name="week_short">Н</string>
<string name="day_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>
</resources>
+1 -29
View File
@@ -83,7 +83,6 @@
<string name="message_attachments_files_many">%1$d files</string>
<string name="message_attachments_files_other">%1$d files</string>
<string name="message_attachments_clip">Clip</string>
<string name="message_attachments_audio_message">Voice message</string>
<string name="message_attachments_link">Link</string>
<string name="message_attachments_mini_app">Mini App</string>
@@ -260,8 +259,6 @@
<string name="settings_general_contact_names_summary">App will use available contact names for users</string>
<string name="settings_general_show_emoji_button_title">Show emoji button</string>
<string name="settings_general_show_emoji_button_summary">Show emoji button in chat panel</string>
<string name="settings_general_show_attachment_button_title">Show attachment button</string>
<string name="settings_general_show_attachment_button_summary">Show attachment button in chat panel</string>
<string name="settings_general_enable_haptic_title">Enable haptic</string>
<string name="settings_appearance_title">Appearance</string>
<string name="settings_appearance_multiline_title">Multiline titles and messages</string>
@@ -272,7 +269,7 @@
<string name="settings_features_long_poll_in_background_summary">Your messages will be updating even when app is not on the screen</string>
<string name="settings_features_show_time_in_action_messages_title">Show time in action messages</string>
<string name="settings_experimental_use_blur_title">Use blur</string>
<string name="settings_experimental_use_blur_summary">Adds blur wherever possible</string>
<string name="settings_experimental_use_blur_summary">Adds blur wherever possible.\nWorks on android 12 and newer</string>
<string name="settings_experimental_more_animations_title">More animations</string>
<string name="settings_experimental_more_animations_summary">Use animations wherever possible</string>
@@ -341,29 +338,4 @@
<string name="unspam_message_title">Unmark as spam</string>
<string name="unspam_message_text">Are you sure you want to unmark this message as spam?</string>
<string name="copied_to_clipboard">Copied to clipboard</string>
<string name="autofill" tools:ignore="PrivateResource">Autofill</string>
<string name="bold">Bold</string>
<string name="italic">Italic</string>
<string name="underline">Underline</string>
<string name="link">Link</string>
<string name="regular">Regular</string>
<string name="login_sign_up">Sign up</string>
<string name="login_forgot_password">Forgot password?</string>
<string name="action_copy_link">Copy link</string>
<string name="action_copy">Copy</string>
<string name="action_copy_image">Copy image</string>
<string name="action_open_in">Open in…</string>
<string name="action_share">Share</string>
<string name="yesterday">Yesterday</string>
<string name="today">Today</string>
<string name="year_short">Y</string>
<string name="month_short">M</string>
<string name="week_short">W</string>
<string name="day_short">D</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>
</resources>
+8 -1
View File
@@ -17,7 +17,7 @@ plugins {
androidComponents {
onVariants { variant ->
variant.buildConfigFields?.apply {
variant.buildConfigFields.apply {
put(
"sdkPackage",
BuildConfigField(
@@ -46,6 +46,13 @@ androidComponents {
}
}
// TODO: 29-Mar-25, Danil Nikolaev: remove when autofill changes will be in release
configurations.all {
resolutionStrategy {
force(libs.compose.ui)
}
}
android {
namespace = "dev.meloda.fast.auth"
@@ -23,7 +23,6 @@ object AuthGraph
fun NavGraphBuilder.authNavGraph(
onNavigateToMain: () -> Unit,
onNavigateToSettings: () -> Unit,
navController: NavController
) {
navigation<AuthGraph>(startDestination = Login) {
@@ -55,7 +54,6 @@ fun NavGraphBuilder.authNavGraph(
)
)
},
onNavigateToSettings = onNavigateToSettings,
navController = navController
)
@@ -19,6 +19,9 @@ 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.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -47,16 +50,15 @@ 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 dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText
import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR
@Composable
fun CaptchaRoute(
@@ -64,7 +66,6 @@ fun CaptchaRoute(
onResult: (String) -> Unit,
viewModel: CaptchaViewModel = koinViewModel<CaptchaViewModelImpl>()
) {
LocalViewModelStoreOwner.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenLogin by viewModel.isNeedToOpenLogin.collectAsStateWithLifecycle()
@@ -115,11 +116,11 @@ fun CaptchaScreen(
if (showExitAlert) {
MaterialDialog(
onDismissRequest = { showExitAlert = false },
title = stringResource(id = R.string.warning_confirmation),
text = stringResource(id = R.string.captcha_exit_warning),
title = stringResource(id = UiR.string.warning_confirmation),
text = stringResource(id = UiR.string.captcha_exit_warning),
confirmAction = { confirmedExit = true },
confirmText = stringResource(id = R.string.yes),
cancelText = stringResource(id = R.string.no),
confirmText = stringResource(id = UiR.string.yes),
cancelText = stringResource(id = UiR.string.no),
actionInvokeDismiss = ActionInvokeDismiss.Always
)
}
@@ -146,7 +147,7 @@ fun CaptchaScreen(
},
icon = {
Icon(
painter = painterResource(R.drawable.round_close_24px),
imageVector = Icons.Rounded.Close,
contentDescription = "Close icon",
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
@@ -186,7 +187,7 @@ fun CaptchaScreen(
if (LocalView.current.isInEditMode) {
Image(
painter = painterResource(id = R.drawable.test_captcha),
painter = painterResource(id = UiR.drawable.test_captcha),
contentDescription = "Captcha image",
modifier = imageModifier
)
@@ -218,7 +219,7 @@ fun CaptchaScreen(
.clip(RoundedCornerShape(10.dp)),
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.round_qr_code_24),
painter = painterResource(id = UiR.drawable.round_qr_code_24),
contentDescription = "QR code icon",
tint = if (showError) {
MaterialTheme.colorScheme.error
@@ -249,7 +250,7 @@ fun CaptchaScreen(
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Icon(
painter = painterResource(R.drawable.round_check_24px),
imageVector = Icons.Rounded.Done,
contentDescription = "Done icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
@@ -1,6 +1,5 @@
package dev.meloda.fast.auth.login
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.ViewModel
@@ -25,7 +24,6 @@ import dev.meloda.fast.data.db.AccountsRepository
import dev.meloda.fast.data.processState
import dev.meloda.fast.data.success
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.LoadUserByIdUseCase
import dev.meloda.fast.domain.OAuthUseCase
import dev.meloda.fast.model.database.AccountEntity
@@ -35,44 +33,65 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class LoginViewModel(
interface LoginViewModel {
val screenState: StateFlow<LoginScreenState>
val loginDialog: StateFlow<LoginDialog?>
val validationArguments: StateFlow<LoginValidationArguments?>
val captchaArguments: StateFlow<CaptchaArguments?>
val userBannedArguments: StateFlow<LoginUserBannedArguments?>
val isNeedToOpenMain: StateFlow<Boolean>
val isNeedToClearCaptchaCode: StateFlow<Boolean>
val isNeedToClearValidationCode: StateFlow<Boolean>
fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle)
fun onDialogDismissed(dialog: LoginDialog)
fun onBackPressed()
fun onPasswordVisibilityButtonClicked()
fun onLoginInputChanged(newLogin: String)
fun onPasswordInputChanged(newPassword: String)
fun onSignInButtonClicked()
fun onNavigatedToMain()
fun onNavigatedToUserBanned()
fun onNavigatedToCaptcha()
fun onNavigatedToValidation()
fun onValidationCodeReceived(code: String?)
fun onValidationCodeCleared()
fun onCaptchaCodeReceived(code: String?)
fun onCaptchaCodeCleared()
}
class LoginViewModelImpl(
private val oAuthUseCase: OAuthUseCase,
private val authRepository: AuthRepository,
private val loadUserByIdUseCase: LoadUserByIdUseCase,
private val accountsRepository: AccountsRepository,
private val loginValidator: LoginValidator,
private val longPollController: LongPollController,
private val userSettings: UserSettings
) : ViewModel() {
private val _screenState = MutableStateFlow(LoginScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val longPollController: LongPollController
) : ViewModel(), LoginViewModel {
private val _loginDialog = MutableStateFlow<LoginDialog?>(null)
val loginDialog = _loginDialog.asStateFlow()
override val screenState = MutableStateFlow(LoginScreenState.EMPTY)
override val loginDialog = MutableStateFlow<LoginDialog?>(null)
private val _validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
val validationArguments = _validationArguments.asStateFlow()
override val validationArguments = MutableStateFlow<LoginValidationArguments?>(null)
override val captchaArguments = MutableStateFlow<CaptchaArguments?>(null)
override val userBannedArguments = MutableStateFlow<LoginUserBannedArguments?>(null)
override val isNeedToOpenMain = MutableStateFlow(false)
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()
override val isNeedToClearCaptchaCode = MutableStateFlow(false)
override val isNeedToClearValidationCode = MutableStateFlow(false)
private val validationState: StateFlow<List<LoginValidationResult>> =
screenState.map(loginValidator::validate)
@@ -96,7 +115,7 @@ class LoginViewModel(
}
}
fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) {
override fun onDialogConfirmed(dialog: LoginDialog, bundle: Bundle) {
onDialogDismissed(dialog)
when (dialog) {
@@ -104,24 +123,20 @@ class LoginViewModel(
}
}
fun onDialogDismissed(dialog: LoginDialog) {
when (dialog) {
is LoginDialog.Error -> Unit
}
_loginDialog.setValue { null }
override fun onDialogDismissed(dialog: LoginDialog) {
loginDialog.setValue { null }
}
fun onBackPressed() {
_screenState.setValue { old -> old.copy(showLogo = true) }
override fun onBackPressed() {
screenState.setValue { old -> old.copy(showLogo = true) }
}
fun onPasswordVisibilityButtonClicked() {
_screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
override fun onPasswordVisibilityButtonClicked() {
screenState.setValue { old -> old.copy(passwordVisible = !old.passwordVisible) }
}
fun onLoginInputChanged(newLogin: String) {
_screenState.setValue { old ->
override fun onLoginInputChanged(newLogin: String) {
screenState.setValue { old ->
old.copy(
login = newLogin.trim(),
loginError = false
@@ -129,8 +144,8 @@ class LoginViewModel(
}
}
fun onPasswordInputChanged(newPassword: String) {
_screenState.setValue { old ->
override fun onPasswordInputChanged(newPassword: String) {
screenState.setValue { old ->
old.copy(
password = newPassword.trim(),
passwordError = false
@@ -138,55 +153,47 @@ class LoginViewModel(
}
}
fun onSignInButtonClicked() {
override fun onSignInButtonClicked() {
if (screenState.value.isLoading) return
if (screenState.value.showLogo) {
_screenState.setValue { old -> old.copy(showLogo = false) }
screenState.setValue { old -> old.copy(showLogo = false) }
return
}
login()
}
fun onLogoClicked() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
userSettings.onEnableDynamicColorsChanged(
!userSettings.enableDynamicColors.value
)
}
override fun onNavigatedToMain() {
isNeedToOpenMain.update { false }
}
fun onNavigatedToMain() {
_isNeedToOpenMain.update { false }
override fun onNavigatedToUserBanned() {
userBannedArguments.update { null }
}
fun onNavigatedToUserBanned() {
_userBannedArguments.update { null }
override fun onNavigatedToCaptcha() {
captchaArguments.update { null }
}
fun onNavigatedToCaptcha() {
_captchaArguments.update { null }
override fun onNavigatedToValidation() {
validationArguments.update { null }
}
fun onNavigatedToValidation() {
_validationArguments.update { null }
}
fun onValidationCodeReceived(code: String?) {
override fun onValidationCodeReceived(code: String?) {
validationCode.update { code }
}
fun onValidationCodeCleared() {
_isNeedToClearValidationCode.update { false }
override fun onValidationCodeCleared() {
isNeedToClearValidationCode.update { false }
}
fun onCaptchaCodeReceived(code: String?) {
override fun onCaptchaCodeReceived(code: String?) {
captchaCode.update { code }
}
fun onCaptchaCodeCleared() {
_isNeedToClearCaptchaCode.update { false }
override fun onCaptchaCodeCleared() {
isNeedToClearCaptchaCode.update { false }
}
private fun login(forceSms: Boolean = false) {
@@ -203,7 +210,7 @@ class LoginViewModel(
processValidation()
if (!validationState.value.contains(LoginValidationResult.Valid)) return
_screenState.updateValue { copy(isLoading = true) }
screenState.updateValue { copy(isLoading = false) }
val currentValidationSid = validationSid.value
val currentValidationCode = validationCode.value?.takeIf { currentValidationSid != null }
@@ -222,7 +229,7 @@ class LoginViewModel(
error = { error ->
Log.d("LoginViewModelImpl", "login: error: $error")
_screenState.updateValue { copy(isLoading = false) }
screenState.updateValue { copy(isLoading = false) }
captchaSid.setValue { null }
parseError(error)
@@ -230,8 +237,8 @@ class LoginViewModel(
success = { response ->
val exceptionHandler =
CoroutineExceptionHandler { _, _ ->
_screenState.updateValue { copy(isLoading = false) }
_loginDialog.setValue { LoginDialog.Error() }
screenState.updateValue { copy(isLoading = false) }
loginDialog.setValue { LoginDialog.Error() }
}
viewModelScope.launch(Dispatchers.IO + exceptionHandler) {
@@ -257,8 +264,8 @@ class LoginViewModel(
}
if (exchangeToken == null) {
_screenState.updateValue { copy(isLoading = false) }
_loginDialog.setValue { LoginDialog.Error() }
screenState.updateValue { copy(isLoading = false) }
loginDialog.setValue { LoginDialog.Error() }
return@launch
}
@@ -296,15 +303,15 @@ class LoginViewModel(
).listenValue(viewModelScope) { state ->
state.processState(
any = {
_screenState.updateValue { copy(isLoading = false) }
screenState.updateValue { copy(isLoading = false) }
},
error = ::parseError,
success = { user ->
if (user == null) {
_loginDialog.update { LoginDialog.Error() }
loginDialog.update { LoginDialog.Error() }
} else {
_screenState.updateValue { copy(login = "", password = "") }
_isNeedToOpenMain.update { true }
screenState.updateValue { copy(login = "", password = "") }
isNeedToOpenMain.update { true }
}
}
)
@@ -327,7 +334,7 @@ class LoginViewModel(
validationType = error.validationType.value,
canResendSms = error.validationResend == "sms"
)
_validationArguments.update { arguments }
validationArguments.update { arguments }
validationSid.update { error.validationSid }
}
@@ -336,12 +343,12 @@ class LoginViewModel(
captchaSid = error.captchaSid,
captchaImageUrl = error.captchaImageUrl
)
_captchaArguments.update { arguments }
captchaArguments.update { arguments }
captchaSid.update { error.captchaSid }
}
OAuthErrorDomain.InvalidCredentialsError -> {
_loginDialog.setValue {
loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong login or password.")
}
}
@@ -353,33 +360,33 @@ class LoginViewModel(
restoreUrl = error.restoreUrl,
accessToken = error.accessToken
)
_userBannedArguments.update { arguments }
userBannedArguments.update { arguments }
}
OAuthErrorDomain.WrongValidationCode -> {
_isNeedToClearValidationCode.update { true }
isNeedToClearValidationCode.update { true }
validationCode.update { null }
_loginDialog.setValue {
loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code.")
}
}
OAuthErrorDomain.WrongValidationCodeFormat -> {
_isNeedToClearValidationCode.update { true }
isNeedToClearValidationCode.update { true }
validationCode.update { null }
_loginDialog.setValue {
loginDialog.setValue {
LoginDialog.Error(errorText = "Wrong validation code format.")
}
}
OAuthErrorDomain.TooManyTriesError -> {
_loginDialog.setValue {
loginDialog.setValue {
LoginDialog.Error(errorText = "Too many tries. Try in another hour or later.")
}
}
OAuthErrorDomain.UnknownError -> {
_loginDialog.setValue { LoginDialog.Error() }
loginDialog.setValue { LoginDialog.Error() }
}
}
}
@@ -392,11 +399,11 @@ class LoginViewModel(
validationState.value.forEach { result ->
when (result) {
LoginValidationResult.LoginEmpty -> {
_screenState.setValue { old -> old.copy(loginError = true) }
screenState.setValue { old -> old.copy(loginError = true) }
}
LoginValidationResult.PasswordEmpty -> {
_screenState.setValue { old -> old.copy(passwordError = true) }
screenState.setValue { old -> old.copy(passwordError = true) }
}
LoginValidationResult.Empty -> Unit
@@ -1,9 +1,9 @@
package dev.meloda.fast.auth.login.di
import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.validation.LoginValidator
import dev.meloda.fast.auth.login.LoginViewModelImpl
import dev.meloda.fast.domain.OAuthUseCase
import dev.meloda.fast.domain.OAuthUseCaseImpl
import dev.meloda.fast.auth.login.validation.LoginValidator
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.bind
@@ -11,6 +11,6 @@ import org.koin.dsl.module
val loginModule = module {
singleOf(::LoginValidator)
viewModelOf(::LoginViewModel)
viewModelOf(::LoginViewModelImpl) bind dev.meloda.fast.auth.login.LoginViewModel::class
singleOf(::OAuthUseCaseImpl) bind OAuthUseCase::class
}
@@ -3,12 +3,12 @@ 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.LoginViewModelImpl
import dev.meloda.fast.auth.login.model.CaptchaArguments
import dev.meloda.fast.auth.login.model.LoginUserBannedArguments
import dev.meloda.fast.auth.login.model.LoginValidationArguments
@@ -24,12 +24,11 @@ fun NavGraphBuilder.loginScreen(
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToMain: () -> Unit,
onNavigateToUserBanned: (LoginUserBannedArguments) -> Unit,
onNavigateToSettings: () -> Unit,
navController: NavController
) {
composable<Login> { backStackEntry ->
val viewModel: LoginViewModel =
backStackEntry.sharedViewModel<LoginViewModel>(navController = navController)
backStackEntry.sharedViewModel<LoginViewModelImpl>(navController = navController)
val clearValidationCode by viewModel.isNeedToClearValidationCode.collectAsStateWithLifecycle()
val clearCaptchaCode by viewModel.isNeedToClearCaptchaCode.collectAsStateWithLifecycle()
@@ -56,7 +55,6 @@ fun NavGraphBuilder.loginScreen(
onNavigateToMain = onNavigateToMain,
onNavigateToCaptcha = onNavigateToCaptcha,
onNavigateToValidation = onNavigateToValidation,
onNavigateToSettings = onNavigateToSettings,
validationCode = validationCode,
captchaCode = captchaCode,
viewModel = viewModel
@@ -1,6 +1,5 @@
package dev.meloda.fast.auth.login.presentation
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
@@ -9,7 +8,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -29,7 +27,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -39,10 +36,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@@ -53,23 +47,25 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.auth.login.LoginViewModel
import dev.meloda.fast.auth.login.LoginViewModelImpl
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
import dev.meloda.fast.auth.login.model.LoginValidationArguments
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText
import dev.meloda.fast.ui.theme.LocalSizeConfig
import dev.meloda.fast.ui.util.handleEnterKey
import dev.meloda.fast.ui.util.handleTabKey
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import dev.meloda.fast.ui.R as UiR
@Composable
fun LoginRoute(
@@ -77,10 +73,9 @@ fun LoginRoute(
onNavigateToMain: () -> Unit,
onNavigateToCaptcha: (CaptchaArguments) -> Unit,
onNavigateToValidation: (LoginValidationArguments) -> Unit,
onNavigateToSettings: () -> Unit,
validationCode: String?,
captchaCode: String?,
viewModel: LoginViewModel = koinViewModel()
viewModel: LoginViewModel = koinViewModel<LoginViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val isNeedToOpenMain by viewModel.isNeedToOpenMain.collectAsStateWithLifecycle()
@@ -133,9 +128,7 @@ fun LoginRoute(
onPasswordFieldEnterKeyClicked = viewModel::onSignInButtonClicked,
onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked,
onPasswordFieldGoAction = viewModel::onSignInButtonClicked,
onSignInButtonClicked = viewModel::onSignInButtonClicked,
onLogoClicked = viewModel::onLogoClicked,
onLogoLongClicked = onNavigateToSettings
onSignInButtonClicked = viewModel::onSignInButtonClicked
)
HandleDialogs(
@@ -153,22 +146,13 @@ fun LoginScreen(
onPasswordFieldEnterKeyClicked: () -> Unit = {},
onPasswordVisibilityButtonClicked: () -> Unit = {},
onPasswordFieldGoAction: () -> Unit = {},
onSignInButtonClicked: () -> Unit = {},
onLogoClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
onSignInButtonClicked: () -> Unit = {}
) {
val context = LocalContext.current
val size = LocalSizeConfig.current
val focusManager = LocalFocusManager.current
val titleSpacerSize by animateDpAsState(
targetValue = if (size.isHeightSmall) 24.dp else 58.dp,
label = "title spacer size"
)
val bottomPadding by animateDpAsState(
targetValue = if (size.isHeightSmall) 10.dp else 30.dp,
label = "bottom padding"
)
val titleSpacerSize by animateDpAsState(if (size.isHeightSmall) 24.dp else 58.dp)
val bottomPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp)
val (loginFocusable, passwordFocusable) =
FocusRequester.createRefs()
@@ -181,18 +165,15 @@ fun LoginScreen(
.padding(padding)
.padding(top = 30.dp)
.padding(horizontal = 30.dp)
.padding(bottom = bottomPadding)
.fillMaxSize()
) {
AnimatedVisibility(
visible = screenState.showLogo,
enter = fadeIn(),
exit = fadeOut(),
label = "Logo visibility"
exit = fadeOut()
) {
Logo(
onLogoClicked = onLogoClicked,
onLogoLongClicked = onLogoLongClicked
)
Logo()
}
AnimatedVisibility(
@@ -201,8 +182,7 @@ fun LoginScreen(
.align(Alignment.Center),
visible = !screenState.showLogo,
enter = fadeIn(),
exit = fadeOut(),
label = "Login visibility"
exit = fadeOut()
) {
Column(
modifier = Modifier
@@ -210,7 +190,7 @@ fun LoginScreen(
.align(Alignment.Center)
) {
Text(
text = stringResource(id = R.string.sign_in_to_vk),
text = stringResource(id = UiR.string.sign_in_to_vk),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.displayMedium
)
@@ -236,11 +216,11 @@ fun LoginScreen(
},
value = screenState.login,
onValueChange = onLoginInputChanged,
label = { Text(text = stringResource(id = R.string.login_hint)) },
placeholder = { Text(text = stringResource(id = R.string.login_hint)) },
label = { Text(text = stringResource(id = UiR.string.login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_round_person_24),
painter = painterResource(id = UiR.drawable.ic_round_person_24),
contentDescription = "Login icon",
tint = if (screenState.loginError) {
MaterialTheme.colorScheme.error
@@ -257,11 +237,8 @@ fun LoginScreen(
isError = screenState.loginError,
singleLine = true
)
AnimatedVisibility(
visible = screenState.loginError,
label = "Login error visibility"
) {
TextFieldErrorText(text = stringResource(id = R.string.error_empty_field))
AnimatedVisibility(visible = screenState.loginError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
Spacer(modifier = Modifier.height(16.dp))
@@ -280,11 +257,11 @@ fun LoginScreen(
.semantics { contentType = ContentType.Password },
value = screenState.password,
onValueChange = onPasswordInputChanged,
label = { Text(text = stringResource(id = R.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = R.string.password_login_hint)) },
label = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
placeholder = { Text(text = stringResource(id = UiR.string.password_login_hint)) },
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.round_vpn_key_24),
painter = painterResource(id = UiR.drawable.round_vpn_key_24),
contentDescription = "Password icon",
tint = if (screenState.passwordError) {
MaterialTheme.colorScheme.error
@@ -295,8 +272,8 @@ fun LoginScreen(
},
trailingIcon = {
val imagePainter = painterResource(
id = if (screenState.passwordVisible) R.drawable.round_visibility_off_24
else R.drawable.round_visibility_24
id = if (screenState.passwordVisible) UiR.drawable.round_visibility_off_24
else UiR.drawable.round_visibility_24
)
IconButton(onClick = onPasswordVisibilityButtonClicked) {
@@ -325,18 +302,16 @@ fun LoginScreen(
},
singleLine = true
)
AnimatedVisibility(
visible = screenState.passwordError,
label = "Password error visibility"
) {
TextFieldErrorText(text = stringResource(id = R.string.error_empty_field))
AnimatedVisibility(visible = screenState.passwordError) {
TextFieldErrorText(text = stringResource(id = UiR.string.error_empty_field))
}
}
}
Column(
Box(
modifier = Modifier.align(Alignment.BottomCenter),
horizontalAlignment = Alignment.CenterHorizontally
contentAlignment = Alignment.Center
) {
FloatingActionButton(
onClick = {
@@ -351,8 +326,7 @@ fun LoginScreen(
AnimatedVisibility(
visible = screenState.isLoading,
enter = fadeIn(),
exit = fadeOut(),
label = "Progress indicator visibility"
exit = fadeOut()
) {
CircularProgressIndicator()
}
@@ -360,67 +334,21 @@ fun LoginScreen(
AnimatedVisibility(
visible = !screenState.isLoading,
enter = fadeIn(),
exit = fadeOut(),
label = "Sign in icon visibility"
exit = fadeOut()
) {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_end),
painter = painterResource(id = UiR.drawable.ic_arrow_end),
contentDescription = "Sign in icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
AnimatedVisibility(
visible = screenState.showLogo,
label = "Bottom padding visibility"
) {
Spacer(Modifier.height(bottomPadding))
}
AnimatedVisibility(
visible = !screenState.showLogo,
label = "Spacer between fab and bottom text buttons visibility"
) {
Spacer(Modifier.height(4.dp))
}
AnimatedVisibility(
visible = !screenState.showLogo,
label = "Text button row visibility"
) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, "https://vk.ru/join".toUri())
)
}
) {
Text(stringResource(R.string.login_sign_up))
}
Text(
text = "",
color = MaterialTheme.colorScheme.primary
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, "https://vk.ru/restore".toUri())
)
}
) {
Text(stringResource(R.string.login_forgot_password))
}
}
}
}
}
}
}
@Composable
fun HandleDialogs(
loginDialog: LoginDialog?,
@@ -433,22 +361,12 @@ fun HandleDialogs(
is LoginDialog.Error -> {
MaterialDialog(
onDismissRequest = { onDismissed(loginDialog) },
title = stringResource(R.string.title_error),
title = stringResource(UiR.string.title_error),
text = loginDialog.errorTextResId?.let { stringResource(it) }
?: loginDialog.errorText
?: stringResource(R.string.unknown_error_occurred),
confirmText = stringResource(id = R.string.ok)
?: stringResource(UiR.string.unknown_error_occurred),
confirmText = stringResource(id = UiR.string.ok)
)
}
}
}
@Preview
@Composable
private fun LoginScreenPreview() {
LoginScreen(
screenState = LoginScreenState.EMPTY.copy(
showLogo = false
)
)
}
@@ -1,7 +1,9 @@
package dev.meloda.fast.auth.login.presentation
import android.os.Build
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,25 +22,25 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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 androidx.compose.ui.unit.sp
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.common.LocalSizeConfig
import dev.meloda.fast.ui.theme.LocalSizeConfig
import org.koin.compose.koinInject
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Logo(
modifier: Modifier = Modifier,
onLogoClicked: () -> Unit = {},
onLogoLongClicked: () -> Unit = {}
) {
fun Logo(modifier: Modifier = Modifier) {
val size = LocalSizeConfig.current
val iconWidth by animateDpAsState(if (size.isWidthSmall) 110.dp else 134.dp)
val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 32 else 38)
val appNameFontSize by animateIntAsState(if (size.isWidthSmall) 32 else 40)
val bottomAdditionalPadding by animateDpAsState(if (size.isHeightSmall) 10.dp else 30.dp)
val userSettings: UserSettings = koinInject()
Box(
modifier = modifier
.fillMaxSize()
@@ -61,8 +63,14 @@ fun Logo(
.combinedClickable(
interactionSource = null,
indication = null,
onLongClick = onLogoLongClicked,
onClick = onLogoClicked
onLongClick = null,
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
userSettings.onEnableDynamicColorsChanged(
!userSettings.enableDynamicColors.value
)
}
}
)
)
@@ -70,8 +78,7 @@ fun Logo(
Text(
text = stringResource(id = R.string.fast_messenger),
style = MaterialTheme.typography.displayMedium.copy(fontSize = appNameFontSize.sp),
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
color = MaterialTheme.colorScheme.onBackground
)
}
}
@@ -23,4 +23,5 @@ class LoginValidator {
return resultList
}
}
@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -14,7 +16,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -23,7 +24,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.meloda.fast.auth.userbanned.model.UserBannedScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.R as UiR
@Preview
@Composable
@@ -67,13 +68,13 @@ fun UserBannedScreen(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.round_arrow_back_24px),
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
title = {
Text(text = stringResource(id = R.string.warning))
Text(text = stringResource(id = UiR.string.warning))
}
)
}
@@ -85,7 +86,7 @@ fun UserBannedScreen(
.padding(vertical = 8.dp)
) {
Text(
text = stringResource(id = R.string.account_temporarily_blocked),
text = stringResource(id = UiR.string.account_temporarily_blocked),
style = MaterialTheme.typography.titleLarge,
)
@@ -93,7 +94,7 @@ fun UserBannedScreen(
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
append(stringResource(id = R.string.user_name))
append(stringResource(id = UiR.string.user_name))
append(": ")
}
@@ -103,7 +104,7 @@ fun UserBannedScreen(
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Medium)) {
append(stringResource(id = R.string.blocking_reason_title))
append(stringResource(id = UiR.string.blocking_reason_title))
append(": ")
}
append(screenState.message)
@@ -18,6 +18,9 @@ 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.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -53,11 +56,11 @@ 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.ui.R
import dev.meloda.fast.ui.components.ActionInvokeDismiss
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.TextFieldErrorText
import org.koin.androidx.compose.koinViewModel
import dev.meloda.fast.ui.R as UiR
@Composable
fun ValidationRoute(
@@ -139,11 +142,11 @@ fun ValidationScreen(
if (showExitAlert) {
MaterialDialog(
onDismissRequest = { showExitAlert = false },
title = stringResource(id = R.string.warning_confirmation),
text = stringResource(id = R.string.validation_exit_warning),
title = stringResource(id = UiR.string.warning_confirmation),
text = stringResource(id = UiR.string.validation_exit_warning),
confirmAction = { confirmedExit = true },
confirmText = stringResource(id = R.string.yes),
cancelText = stringResource(id = R.string.no),
confirmText = stringResource(id = UiR.string.yes),
cancelText = stringResource(id = UiR.string.no),
actionInvokeDismiss = ActionInvokeDismiss.Always
)
}
@@ -170,7 +173,7 @@ fun ValidationScreen(
},
icon = {
Icon(
painter = painterResource(R.drawable.round_close_24px),
imageVector = Icons.Rounded.Close,
contentDescription = "Close icon",
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
@@ -220,7 +223,7 @@ fun ValidationScreen(
.semantics { contentType = ContentType.SmsOtpCode },
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.round_qr_code_24),
painter = painterResource(id = UiR.drawable.round_qr_code_24),
contentDescription = "QR Code icon",
tint = if (screenState.codeError) {
MaterialTheme.colorScheme.error
@@ -268,7 +271,7 @@ fun ValidationScreen(
},
icon = {
Icon(
painter = painterResource(id = R.drawable.round_sms_24),
painter = painterResource(id = UiR.drawable.round_sms_24),
tint = MaterialTheme.colorScheme.onPrimary,
contentDescription = "SMS icon"
)
@@ -284,7 +287,7 @@ fun ValidationScreen(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
) {
Icon(
painter = painterResource(R.drawable.round_check_24px),
imageVector = Icons.Rounded.Done,
contentDescription = "Done icon",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
@@ -0,0 +1,67 @@
package dev.meloda.fast.chatmaterials.presentation
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
@Composable
fun ChatMaterialItem(
item: UiChatMaterial,
onClick: () -> Unit
) {
when (item) {
is UiChatMaterial.Photo -> {
AsyncImage(
model = item.previewUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clickable(onClick = onClick)
)
}
is UiChatMaterial.Video -> {
AsyncImage(
model = item.previewUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
}
is UiChatMaterial.Audio -> {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyLarge
)
Text(text = item.artist)
}
Text(text = item.duration)
}
}
is UiChatMaterial.File -> {}
is UiChatMaterial.Link -> {}
}
}
@@ -11,13 +11,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -32,7 +36,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -49,13 +52,13 @@ import dev.meloda.fast.chatmaterials.presentation.materials.FileMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.LinkMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.PhotoMaterialsScreen
import dev.meloda.fast.chatmaterials.presentation.materials.VideoMaterialsScreen
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.TabItem
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.core.qualifier.named
import dev.meloda.fast.ui.R as UiR
@Composable
fun ChatMaterialsRoute(
@@ -81,15 +84,15 @@ fun ChatMaterialsScreen(
) {
val scope = rememberCoroutineScope()
val currentTheme = LocalThemeConfig.current
val hazeState = remember { HazeState(true) }
val hazeState = remember { HazeState() }
val titles = remember {
listOf(
R.string.chat_attachment_photos,
R.string.chat_attachment_videos,
R.string.chat_attachment_music,
R.string.chat_attachment_files,
R.string.chat_attachment_links,
UiR.string.chat_attachment_photos,
UiR.string.chat_attachment_videos,
UiR.string.chat_attachment_music,
UiR.string.chat_attachment_files,
UiR.string.chat_attachment_links,
)
}
@@ -154,7 +157,7 @@ fun ChatMaterialsScreen(
TopAppBar(
title = {
Text(
text = stringResource(R.string.chat_materials_title),
text = stringResource(UiR.string.chat_materials_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
@@ -167,17 +170,23 @@ fun ChatMaterialsScreen(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.round_arrow_back_24px),
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
}
)
PrimaryScrollableTabRow(
ScrollableTabRow(
modifier = Modifier.fillMaxWidth(),
selectedTabIndex = selectedTabIndex,
containerColor = Color.Transparent,
edgePadding = 0.dp
edgePadding = 0.dp,
indicator = { tabPositions ->
TabRowDefaults.PrimaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]),
color = MaterialTheme.colorScheme.primary
)
}
) {
tabItems.forEachIndexed { index, item ->
Tab(
@@ -18,6 +18,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -51,13 +53,14 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -102,7 +105,7 @@ fun AudioMaterialsScreen(
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
@@ -155,7 +158,7 @@ fun AudioMaterialsScreen(
.background(MaterialTheme.colorScheme.primary)
.size(42.dp)
.padding(4.dp),
painter = painterResource(R.drawable.round_fill_play_arrow_24px),
painter = painterResource(UiR.drawable.round_play_arrow_24),
contentDescription = null,
tint = contentColorFor(MaterialTheme.colorScheme.primary)
)
@@ -203,7 +206,7 @@ fun AudioMaterialsScreen(
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
painter = painterResource(R.drawable.round_keyboard_arrow_up_24px),
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
@@ -19,6 +19,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -44,7 +46,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -62,7 +63,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
@@ -113,7 +114,7 @@ fun FileMaterialsScreen(
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
else -> {
PullToRefreshBox(
@@ -237,7 +238,7 @@ fun FileMaterialsScreen(
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
painter = painterResource(R.drawable.round_keyboard_arrow_up_24px),
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}

Some files were not shown because too many files have changed in this diff Show More