diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80aac07f..39128ba6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,7 @@ -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +@file:Suppress("UnstableApiUsage") + import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties val sdkPackage: String = gradleLocalProperties(rootDir).getProperty("sdkPackage", "\"\"") val sdkFingerprint: String = gradleLocalProperties(rootDir).getProperty("sdkFingerprint", "\"\"") @@ -17,13 +19,14 @@ plugins { id("kotlin-android") id("kotlin-kapt") id("kotlin-parcelize") - id("dagger.hilt.android.plugin") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") } android { namespace = "com.meloda.fast" - compileSdk = 32 + compileSdk = 34 applicationVariants.all { outputs.all { @@ -34,14 +37,14 @@ android { defaultConfig { applicationId = "com.meloda.fast" - minSdk = 23 - targetSdk = 32 + minSdk = 24 + targetSdk = 34 versionCode = 1 versionName = "alpha" javaCompileOptions { annotationProcessorOptions { - arguments += mapOf("room.schemaLocation" to "$projectDir/schemas") +// arguments += mapOf("room.schemaLocation" to "$projectDir/schemas") } } } @@ -73,18 +76,51 @@ android { } } + val flavorDimension = "version" + + flavorDimensions += flavorDimension + + productFlavors { + create("dev") { + resourceConfigurations += listOf("en", "xxhdpi") + + dimension = flavorDimension + applicationIdSuffix = ".dev" + versionNameSuffix = "-dev" + } + create("full") { + dimension = flavorDimension + } + } + compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - freeCompilerArgs = listOf("-Xjvm-default=compatibility", "-opt-in=kotlin.RequiresOptIn") + jvmTarget = JavaVersion.VERSION_17.toString() + freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn", "-Xcontext-receivers") } buildFeatures { viewBinding = true + compose = true } + + composeOptions { + kotlinCompilerExtensionVersion = "1.4.5" + useLiveLiterals = true + } + packagingOptions { + jniLibs { + useLegacyPackaging = false + } + } +} + +kapt { + correctErrorTypes = true } fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion" @@ -92,74 +128,99 @@ fun getVersionName() = "$majorVersion.$minorVersion.$patchVersion" val currentTime get() = (System.currentTimeMillis() / 1000).toInt() dependencies { - implementation(kotlin("reflect", "1.6.10")) - implementation(libs.androidx.core) - implementation(libs.androidx.lifecycle.viewmodel) - implementation(libs.androidx.lifecycle.livedata) - implementation(libs.androidx.lifecycle.runtime) - implementation(libs.androidx.lifecycle.viewmodel.savedstate) - implementation(libs.androidx.lifecycle.common.java8) + // DI zone + implementation("io.insert-koin:koin-android:3.4.0") + // end of DI zone - implementation(libs.androidx.splashScreen) + implementation("com.github.skydoves:cloudy:0.1.2") - implementation(libs.androidx.dataStore) + implementation("io.coil-kt:coil-compose:2.3.0") + implementation("io.coil-kt:coil:2.3.0") - implementation(libs.androidx.appCompat) + implementation("com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2") + implementation("com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2") - implementation(libs.androidx.activity) + implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.21") - implementation(libs.androidx.fragment) + implementation("androidx.core:core-ktx:1.10.1") - implementation(libs.androidx.preference) + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") - implementation(libs.androidx.swipeRefreshLayout) + implementation("androidx.core:core-splashscreen:1.0.1") - implementation(libs.androidx.recyclerView) + implementation("androidx.appcompat:appcompat:1.6.1") - implementation(libs.androidx.cardView) + implementation("androidx.activity:activity-ktx:1.7.2") - implementation(libs.androidx.constraintLayout) + implementation("androidx.fragment:fragment-ktx:1.6.1") - implementation(libs.androidx.room) - implementation(libs.androidx.room.runtime) - kapt(libs.androidx.room.compiler) + implementation("androidx.preference:preference-ktx:1.2.0") - implementation(libs.cicerone) + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - implementation(libs.waveformSeekBar) + implementation("androidx.recyclerview:recyclerview:1.3.1") - implementation(libs.glide) - kapt(libs.glide.compiler) + implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation(libs.kPermissions) - implementation(libs.kPermissions.coroutines) + implementation("com.google.accompanist:accompanist-systemuicontroller:0.27.0") - implementation(libs.appCenter.analytics) - implementation(libs.appCenter.crashes) + implementation("androidx.room:room-ktx:2.5.2") + implementation("androidx.room:room-runtime:2.5.2") + ksp("androidx.room:room-compiler:2.5.2") - implementation(libs.hilt) - kapt(libs.hilt.compiler) + implementation("com.github.terrakok:cicerone:7.1") - implementation(libs.retrofit) - implementation(libs.retrofit.gson.converter) + implementation("com.github.massoudss:waveformSeekBar:5.0.0") - implementation(libs.okhttp3) - implementation(libs.okhttp3.interceptor) + implementation("com.github.bumptech.glide:glide:4.15.1") + ksp("com.github.bumptech.glide:compiler:4.15.1") - implementation(libs.coroutines.core) - implementation(libs.coroutines.android) + implementation("com.github.fondesa:kpermissions:3.4.0") + implementation("com.github.fondesa:kpermissions-coroutines:3.4.0") - implementation(libs.viewBindingDelegate) + implementation("com.microsoft.appcenter:appcenter-analytics:5.0.1") + implementation("com.microsoft.appcenter:appcenter-crashes:5.0.1") - implementation(libs.google.gson) + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") - implementation(libs.google.guava) + implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11") - implementation(libs.google.material) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.7.1") - implementation(libs.jsoup) + implementation("com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.9") - implementation(libs.chucker) -} \ No newline at end of file + implementation("com.google.code.gson:gson:2.10.1") + + implementation("com.google.guava:guava:31.1-jre") + + implementation("com.google.android.material:material:1.9.0") + + implementation("com.github.chuckerteam.chucker:library:3.5.2") + + implementation("dev.chrisbanes.insetter:insetter:0.6.1") + + // Compose zone + implementation(platform("androidx.compose:compose-bom:2023.04.01")) + + implementation("androidx.compose.material3:material3:1.1.1") +// implementation("androidx.compose.material:material:1.4.3") + implementation("androidx.compose.ui:ui:1.4.3") + + implementation("androidx.compose.ui:ui-tooling-preview:1.4.3") + debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") + + implementation("androidx.compose.material3:material3-window-size-class:1.1.1") + + implementation("androidx.activity:activity-compose:1.7.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1") + + implementation("androidx.compose.runtime:runtime-saveable:1.6.0-alpha02") + // end of Compose zone +} diff --git a/app/schemas/com.meloda.fast.database.AppDatabase/33.json b/app/schemas/com.meloda.fast.database.AppDatabase/33.json deleted file mode 100644 index 135b8b4d..00000000 --- a/app/schemas/com.meloda.fast.database.AppDatabase/33.json +++ /dev/null @@ -1,582 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 33, - "identityHash": "ab075cc511743c47de441d484159b088", - "entities": [ - { - "tableName": "accounts", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` 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 - } - ], - "primaryKey": { - "columnNames": [ - "userId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "conversations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo200` TEXT, `type` TEXT NOT NULL, `callInProgress` INTEGER NOT NULL, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `isMarkedUnread` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessage_id` INTEGER, `pinnedMessage_text` TEXT, `pinnedMessage_isOut` INTEGER, `pinnedMessage_peerId` INTEGER, `pinnedMessage_fromId` INTEGER, `pinnedMessage_date` INTEGER, `pinnedMessage_randomId` INTEGER, `pinnedMessage_action` TEXT, `pinnedMessage_actionMemberId` INTEGER, `pinnedMessage_actionText` TEXT, `pinnedMessage_actionConversationMessageId` INTEGER, `pinnedMessage_actionMessage` TEXT, `pinnedMessage_important` INTEGER, `pinnedMessage_forwards` TEXT, `pinnedMessage_attachments` TEXT, `pinnedMessage_replyMessage` TEXT, `pinnedMessage_geo` TEXT, `lastMessage_id` INTEGER, `lastMessage_text` TEXT, `lastMessage_isOut` INTEGER, `lastMessage_peerId` INTEGER, `lastMessage_fromId` INTEGER, `lastMessage_date` INTEGER, `lastMessage_randomId` INTEGER, `lastMessage_action` TEXT, `lastMessage_actionMemberId` INTEGER, `lastMessage_actionText` TEXT, `lastMessage_actionConversationMessageId` INTEGER, `lastMessage_actionMessage` TEXT, `lastMessage_important` INTEGER, `lastMessage_forwards` TEXT, `lastMessage_attachments` TEXT, `lastMessage_replyMessage` TEXT, `lastMessage_geo` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ownerId", - "columnName": "ownerId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "photo200", - "columnName": "photo200", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "callInProgress", - "columnName": "callInProgress", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhantom", - "columnName": "isPhantom", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastConversationMessageId", - "columnName": "lastConversationMessageId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "inRead", - "columnName": "inRead", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outRead", - "columnName": "outRead", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMarkedUnread", - "columnName": "isMarkedUnread", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastMessageId", - "columnName": "lastMessageId", - "affinity": "INTEGER", - "notNull": true - }, - { - "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": "majorId", - "columnName": "majorId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "minorId", - "columnName": "minorId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pinnedMessage.id", - "columnName": "pinnedMessage_id", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.text", - "columnName": "pinnedMessage_text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.isOut", - "columnName": "pinnedMessage_isOut", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.peerId", - "columnName": "pinnedMessage_peerId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.fromId", - "columnName": "pinnedMessage_fromId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.date", - "columnName": "pinnedMessage_date", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.randomId", - "columnName": "pinnedMessage_randomId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.action", - "columnName": "pinnedMessage_action", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.actionMemberId", - "columnName": "pinnedMessage_actionMemberId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.actionText", - "columnName": "pinnedMessage_actionText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.actionConversationMessageId", - "columnName": "pinnedMessage_actionConversationMessageId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.actionMessage", - "columnName": "pinnedMessage_actionMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.important", - "columnName": "pinnedMessage_important", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.forwards", - "columnName": "pinnedMessage_forwards", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.attachments", - "columnName": "pinnedMessage_attachments", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.replyMessage", - "columnName": "pinnedMessage_replyMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.geo", - "columnName": "pinnedMessage_geo", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.id", - "columnName": "lastMessage_id", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.text", - "columnName": "lastMessage_text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.isOut", - "columnName": "lastMessage_isOut", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.peerId", - "columnName": "lastMessage_peerId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.fromId", - "columnName": "lastMessage_fromId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.date", - "columnName": "lastMessage_date", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.randomId", - "columnName": "lastMessage_randomId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.action", - "columnName": "lastMessage_action", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.actionMemberId", - "columnName": "lastMessage_actionMemberId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.actionText", - "columnName": "lastMessage_actionText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.actionConversationMessageId", - "columnName": "lastMessage_actionConversationMessageId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.actionMessage", - "columnName": "lastMessage_actionMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.important", - "columnName": "lastMessage_important", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.forwards", - "columnName": "lastMessage_forwards", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.attachments", - "columnName": "lastMessage_attachments", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.replyMessage", - "columnName": "lastMessage_replyMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.geo", - "columnName": "lastMessage_geo", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "messages", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` 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, `important` INTEGER NOT NULL, `forwards` TEXT, `attachments` TEXT, `replyMessage` TEXT, `geo` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "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": "important", - "columnName": "important", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "forwards", - "columnName": "forwards", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "attachments", - "columnName": "attachments", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "replyMessage", - "columnName": "replyMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "geo", - "columnName": "geo", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `online` INTEGER NOT NULL, `photo200` TEXT, `lastSeen` INTEGER, `lastSeenStatus` 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": "online", - "columnName": "online", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "photo200", - "columnName": "photo200", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastSeen", - "columnName": "lastSeen", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastSeenStatus", - "columnName": "lastSeenStatus", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "groups", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `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": "photo200", - "columnName": "photo200", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "membersCount", - "columnName": "membersCount", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "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, 'ab075cc511743c47de441d484159b088')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.meloda.fast.database.AppDatabase/34.json b/app/schemas/com.meloda.fast.database.AppDatabase/34.json deleted file mode 100644 index 52e135b7..00000000 --- a/app/schemas/com.meloda.fast.database.AppDatabase/34.json +++ /dev/null @@ -1,600 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 34, - "identityHash": "2c202b1fce1b5f6c6ab0da756e0590a6", - "entities": [ - { - "tableName": "accounts", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `accessToken` TEXT NOT NULL, `fastToken` 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 - } - ], - "primaryKey": { - "columnNames": [ - "userId" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "conversations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `ownerId` INTEGER, `title` TEXT, `photo200` TEXT, `type` TEXT NOT NULL, `callInProgress` INTEGER NOT NULL, `isPhantom` INTEGER NOT NULL, `lastConversationMessageId` INTEGER NOT NULL, `inRead` INTEGER NOT NULL, `outRead` INTEGER NOT NULL, `isMarkedUnread` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `membersCount` INTEGER, `canChangePin` INTEGER NOT NULL, `majorId` INTEGER NOT NULL, `minorId` INTEGER NOT NULL, `pinnedMessage_id` INTEGER, `pinnedMessage_text` TEXT, `pinnedMessage_isOut` INTEGER, `pinnedMessage_peerId` INTEGER, `pinnedMessage_fromId` INTEGER, `pinnedMessage_date` INTEGER, `pinnedMessage_randomId` INTEGER, `pinnedMessage_action` TEXT, `pinnedMessage_actionMemberId` INTEGER, `pinnedMessage_actionText` TEXT, `pinnedMessage_actionConversationMessageId` INTEGER, `pinnedMessage_actionMessage` TEXT, `pinnedMessage_updateTime` INTEGER, `pinnedMessage_important` INTEGER, `pinnedMessage_forwards` TEXT, `pinnedMessage_attachments` TEXT, `pinnedMessage_replyMessage` TEXT, `pinnedMessage_geo` TEXT, `lastMessage_id` INTEGER, `lastMessage_text` TEXT, `lastMessage_isOut` INTEGER, `lastMessage_peerId` INTEGER, `lastMessage_fromId` INTEGER, `lastMessage_date` INTEGER, `lastMessage_randomId` INTEGER, `lastMessage_action` TEXT, `lastMessage_actionMemberId` INTEGER, `lastMessage_actionText` TEXT, `lastMessage_actionConversationMessageId` INTEGER, `lastMessage_actionMessage` TEXT, `lastMessage_updateTime` INTEGER, `lastMessage_important` INTEGER, `lastMessage_forwards` TEXT, `lastMessage_attachments` TEXT, `lastMessage_replyMessage` TEXT, `lastMessage_geo` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ownerId", - "columnName": "ownerId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "photo200", - "columnName": "photo200", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "callInProgress", - "columnName": "callInProgress", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isPhantom", - "columnName": "isPhantom", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastConversationMessageId", - "columnName": "lastConversationMessageId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "inRead", - "columnName": "inRead", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outRead", - "columnName": "outRead", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isMarkedUnread", - "columnName": "isMarkedUnread", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastMessageId", - "columnName": "lastMessageId", - "affinity": "INTEGER", - "notNull": true - }, - { - "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": "majorId", - "columnName": "majorId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "minorId", - "columnName": "minorId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pinnedMessage.id", - "columnName": "pinnedMessage_id", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.text", - "columnName": "pinnedMessage_text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.isOut", - "columnName": "pinnedMessage_isOut", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.peerId", - "columnName": "pinnedMessage_peerId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.fromId", - "columnName": "pinnedMessage_fromId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.date", - "columnName": "pinnedMessage_date", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.randomId", - "columnName": "pinnedMessage_randomId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.action", - "columnName": "pinnedMessage_action", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.actionMemberId", - "columnName": "pinnedMessage_actionMemberId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.actionText", - "columnName": "pinnedMessage_actionText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.actionConversationMessageId", - "columnName": "pinnedMessage_actionConversationMessageId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.actionMessage", - "columnName": "pinnedMessage_actionMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.updateTime", - "columnName": "pinnedMessage_updateTime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.important", - "columnName": "pinnedMessage_important", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.forwards", - "columnName": "pinnedMessage_forwards", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.attachments", - "columnName": "pinnedMessage_attachments", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.replyMessage", - "columnName": "pinnedMessage_replyMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "pinnedMessage.geo", - "columnName": "pinnedMessage_geo", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.id", - "columnName": "lastMessage_id", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.text", - "columnName": "lastMessage_text", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.isOut", - "columnName": "lastMessage_isOut", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.peerId", - "columnName": "lastMessage_peerId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.fromId", - "columnName": "lastMessage_fromId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.date", - "columnName": "lastMessage_date", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.randomId", - "columnName": "lastMessage_randomId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.action", - "columnName": "lastMessage_action", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.actionMemberId", - "columnName": "lastMessage_actionMemberId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.actionText", - "columnName": "lastMessage_actionText", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.actionConversationMessageId", - "columnName": "lastMessage_actionConversationMessageId", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.actionMessage", - "columnName": "lastMessage_actionMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.updateTime", - "columnName": "lastMessage_updateTime", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.important", - "columnName": "lastMessage_important", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastMessage.forwards", - "columnName": "lastMessage_forwards", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.attachments", - "columnName": "lastMessage_attachments", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.replyMessage", - "columnName": "lastMessage_replyMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastMessage.geo", - "columnName": "lastMessage_geo", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "messages", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` 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, `forwards` TEXT, `attachments` TEXT, `replyMessage` TEXT, `geo` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "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": "forwards", - "columnName": "forwards", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "attachments", - "columnName": "attachments", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "replyMessage", - "columnName": "replyMessage", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "geo", - "columnName": "geo", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `online` INTEGER NOT NULL, `photo200` TEXT, `lastSeen` INTEGER, `lastSeenStatus` 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": "online", - "columnName": "online", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "photo200", - "columnName": "photo200", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "lastSeen", - "columnName": "lastSeen", - "affinity": "INTEGER", - "notNull": false - }, - { - "fieldPath": "lastSeenStatus", - "columnName": "lastSeenStatus", - "affinity": "TEXT", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "groups", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `screenName` TEXT NOT NULL, `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": "photo200", - "columnName": "photo200", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "membersCount", - "columnName": "membersCount", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": false - }, - "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, '2c202b1fce1b5f6c6ab0da756e0590a6')" - ] - } -} \ No newline at end of file diff --git a/app/src/dev/res/mipmap-hdpi/ic_launcher.png b/app/src/dev/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..037e657b Binary files /dev/null and b/app/src/dev/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/dev/res/mipmap-mdpi/ic_launcher.png b/app/src/dev/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..e0ee3795 Binary files /dev/null and b/app/src/dev/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/dev/res/mipmap-xhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..a14bfbf5 Binary files /dev/null and b/app/src/dev/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..8b839a55 Binary files /dev/null and b/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..c3b67b16 Binary files /dev/null and b/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/dev/res/values/ic_launcher_background.xml b/app/src/dev/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..30bd17f3 --- /dev/null +++ b/app/src/dev/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #5B37DD + diff --git a/app/src/dev/res/values/strings.xml b/app/src/dev/res/values/strings.xml new file mode 100644 index 00000000..ede9730a --- /dev/null +++ b/app/src/dev/res/values/strings.xml @@ -0,0 +1,6 @@ + + + + Fast Dev + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a388ef3c..1d0023bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,36 +3,35 @@ xmlns:tools="http://schemas.android.com/tools"> - - - - - - + + + tools:targetApi="tiramisu"> + + + @@ -40,15 +39,64 @@ + + + android:exported="false" + android:foregroundServiceType="dataSync" /> + android:exported="false" + android:foregroundServiceType="dataSync" /> + + + + + + + + + + + + + + + + + + + + + + + + + - - \ No newline at end of file + diff --git a/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt index cee1e1b5..bbdad63b 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/ApiEvent.kt @@ -7,8 +7,6 @@ enum class ApiEvent(val value: Int) { MessageEdit(5), MessageReadIncoming(6), MessageReadOutgoing(7), - FriendOnline(8), - FriendOffline(9), MessagesDeleted(13), PinUnpinConversation(20), PrivateTyping(61), @@ -25,4 +23,4 @@ enum class ApiEvent(val value: Int) { fun parse(value: Int) = values().firstOrNull { it.value == value } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt b/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt index d693636a..e0d69f7c 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/UserConfig.kt @@ -1,10 +1,10 @@ package com.meloda.fast.api import androidx.core.content.edit -import androidx.lifecycle.MutableLiveData import com.meloda.fast.api.model.VkUser import com.meloda.fast.common.AppGlobal import com.meloda.fast.model.AppAccount +import kotlinx.coroutines.flow.MutableStateFlow object UserConfig { @@ -42,6 +42,6 @@ object UserConfig { return currentUserId > 0 && userId > 0 && accessToken.isNotBlank() } - val vkUser = MutableLiveData(null) + val vkUser: MutableStateFlow = MutableStateFlow(null) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt index 3f8dd237..e675fe2b 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/VKConstants.kt @@ -8,11 +8,11 @@ object VKConstants { const val GROUP_FIELDS = "description,members_count,counters,status,verified" const val USER_FIELDS = - "photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info" + "photo_50,photo_100,photo_200,status,screen_name,online,online_mobile,last_seen,verified,sex,online_info,bdate" const val ALL_FIELDS = "$USER_FIELDS,$GROUP_FIELDS" - const val API_VERSION = "5.189" + const val API_VERSION = "5.173" const val LP_VERSION = 10 const val VK_APP_ID = "2274003" @@ -53,4 +53,4 @@ object VKConstants { VkVoiceMessage::class.java, VkWidget::class.java ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt index 0beda05a..d55a408c 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/VkUtils.kt @@ -4,20 +4,27 @@ import android.content.Context import android.graphics.Typeface import android.graphics.drawable.Drawable import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextPaint +import android.text.style.ClickableSpan import android.text.style.StyleSpan +import android.view.View import androidx.core.content.ContextCompat import com.google.gson.Gson import com.meloda.fast.R import com.meloda.fast.api.base.ApiError -import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.* import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.api.model.base.attachments.BaseVkAttachmentItem +import com.meloda.fast.api.model.domain.VkConversationDomain import com.meloda.fast.api.network.* -import com.meloda.fast.extensions.orDots +import com.meloda.fast.ext.orDots +import com.meloda.fast.model.base.UiImage +import com.meloda.fast.model.base.UiText @Suppress("MemberVisibilityCanBePrivate") object VkUtils { @@ -27,7 +34,7 @@ object VkUtils { id: Int, ownerId: Int, withAccessKey: Boolean, - accessKey: String? + accessKey: String?, ): String { val type = when (attachmentClass) { VkAudio::class.java -> "audio" @@ -52,15 +59,25 @@ object VkUtils { else profiles[message.fromId]).also { message.user = it } } + fun getMessageActionUser(message: VkMessage, profiles: Map): VkUser? { + return if (message.actionMemberId == null || message.actionMemberId <= 0) null + else profiles[message.actionMemberId] + } + fun getMessageGroup(message: VkMessage, groups: Map): VkGroup? { return (if (!message.isGroup()) null else groups[message.fromId]).also { message.group = it } } + fun getMessageActionGroup(message: VkMessage, groups: Map): VkGroup? { + return if (message.actionMemberId == null || message.actionMemberId >= 0) null + else groups[message.actionMemberId] + } + fun getMessageAvatar( message: VkMessage, messageUser: VkUser?, - messageGroup: VkGroup? + messageGroup: VkGroup?, ): String? { return when { message.isUser() -> messageUser?.photo200 @@ -74,7 +91,7 @@ object VkUtils { defMessageUser: VkUser? = null, defMessageGroup: VkGroup? = null, profiles: Map? = null, - groups: Map? = null + groups: Map? = null, ): String? { val messageUser: VkUser? = defMessageUser ?: if (profiles == null) null @@ -91,37 +108,43 @@ object VkUtils { } } - fun getConversationUser(conversation: VkConversation, profiles: Map): VkUser? { - return (if (!conversation.isUser()) null - else profiles[conversation.id]).also { conversation.user.postValue(it) } + fun getConversationUser( + conversation: VkConversationDomain, + profiles: Map + ): VkUser? { + return if (!conversation.isUser()) null + else profiles[conversation.id] } - fun getConversationGroup(conversation: VkConversation, groups: Map): VkGroup? { - return (if (!conversation.isGroup()) null - else groups[conversation.id]).also { conversation.group.postValue(it) } + fun getConversationGroup( + conversation: VkConversationDomain, + groups: Map + ): VkGroup? { + return if (!conversation.isGroup()) null + else groups[conversation.id] } fun getConversationAvatar( - conversation: VkConversation, + conversation: VkConversationDomain, conversationUser: VkUser?, - conversationGroup: VkGroup? + conversationGroup: VkGroup?, ): String? { return when { conversation.isAccount() -> null conversation.isUser() -> conversationUser?.photo200 conversation.isGroup() -> conversationGroup?.photo200 - conversation.isChat() -> conversation.photo200 + conversation.isChat() -> conversation.conversationPhoto else -> null } } fun getConversationTitle( context: Context, - conversation: VkConversation, + conversation: VkConversationDomain, defConversationUser: VkUser? = null, defConversationGroup: VkGroup? = null, profiles: Map? = null, - groups: Map? = null + groups: Map? = null, ): String? { val conversationUser: VkUser? = defConversationUser ?: if (profiles == null) null @@ -133,7 +156,7 @@ object VkUtils { return when { conversation.isAccount() -> context.getString(R.string.favorites) - conversation.isChat() -> conversation.title + conversation.isChat() -> conversation.conversationTitle conversation.isUser() -> conversationUser?.fullName conversation.isGroup() -> conversationGroup?.name else -> null @@ -141,9 +164,9 @@ object VkUtils { } fun getConversationUserGroup( - conversation: VkConversation, + conversation: VkConversationDomain, profiles: Map, - groups: Map + groups: Map, ): Pair { val user: VkUser? = getConversationUser(conversation, profiles) val group: VkGroup? = getConversationGroup(conversation, groups) @@ -152,21 +175,45 @@ object VkUtils { } fun getMessageUserGroup( - message: VkMessage, + message: VkMessage?, profiles: Map, - groups: Map + groups: Map, ): Pair { + if (message == null) return null to null + val user: VkUser? = getMessageUser(message, profiles) val group: VkGroup? = getMessageGroup(message, groups) return user to group } - fun prepareMessageText(text: String, forConversations: Boolean? = null): String { - return text.apply { - if (forConversations == true) replace("\n", "") + fun getMessageActionUserGroup( + message: VkMessage?, + profiles: Map, + groups: Map, + ): Pair { + if (message == null) return null to null - replace("&", "&") + val user: VkUser? = getMessageActionUser(message, profiles) + val group: VkGroup? = getMessageActionGroup(message, groups) + + return user to group + } + + fun prepareMessageText(text: String, forConversations: Boolean = false): String { + return text.apply { + if (forConversations) { + replace("\n", "") + } + + replace("&", "&") + replace(""", "\"") + replace("
", "\n") + replace(">", ">") + replace("<", "<") + replace("
", "\n") + replace("–", "-") + trim() } } @@ -205,78 +252,97 @@ object VkUtils { val photo = baseAttachment.photo ?: continue attachments += photo.asVkPhoto() } + BaseVkAttachmentItem.AttachmentType.Video -> { val video = baseAttachment.video ?: continue attachments += video.asVkVideo() } + BaseVkAttachmentItem.AttachmentType.Audio -> { val audio = baseAttachment.audio ?: continue attachments += audio.asVkAudio() } + BaseVkAttachmentItem.AttachmentType.File -> { val file = baseAttachment.file ?: continue attachments += file.asVkFile() } + BaseVkAttachmentItem.AttachmentType.Link -> { val link = baseAttachment.link ?: continue attachments += link.asVkLink() } + BaseVkAttachmentItem.AttachmentType.MiniApp -> { val miniApp = baseAttachment.miniApp ?: continue attachments += miniApp.asVkMiniApp() } + BaseVkAttachmentItem.AttachmentType.Voice -> { val voiceMessage = baseAttachment.voiceMessage ?: continue attachments += voiceMessage.asVkVoiceMessage() } + BaseVkAttachmentItem.AttachmentType.Sticker -> { val sticker = baseAttachment.sticker ?: continue attachments += sticker.asVkSticker() } + BaseVkAttachmentItem.AttachmentType.Gift -> { val gift = baseAttachment.gift ?: continue attachments += gift.asVkGift() } + BaseVkAttachmentItem.AttachmentType.Wall -> { val wall = baseAttachment.wall ?: continue attachments += wall.asVkWall() } + BaseVkAttachmentItem.AttachmentType.Graffiti -> { val graffiti = baseAttachment.graffiti ?: continue attachments += graffiti.asVkGraffiti() } + BaseVkAttachmentItem.AttachmentType.Poll -> { val poll = baseAttachment.poll ?: continue attachments += poll.asVkPoll() } + BaseVkAttachmentItem.AttachmentType.WallReply -> { val wallReply = baseAttachment.wallReply ?: continue attachments += wallReply.asVkWallReply() } + BaseVkAttachmentItem.AttachmentType.Call -> { val call = baseAttachment.call ?: continue attachments += call.asVkCall() } + BaseVkAttachmentItem.AttachmentType.GroupCallInProgress -> { val groupCall = baseAttachment.groupCall ?: continue attachments += groupCall.asVkGroupCall() } + BaseVkAttachmentItem.AttachmentType.Curator -> { val curator = baseAttachment.curator ?: continue attachments += curator.asVkCurator() } + BaseVkAttachmentItem.AttachmentType.Event -> { val event = baseAttachment.event ?: continue attachments += event.asVkEvent() } + BaseVkAttachmentItem.AttachmentType.Story -> { val story = baseAttachment.story ?: continue attachments += story.asVkStory() } + BaseVkAttachmentItem.AttachmentType.Widget -> { val widget = baseAttachment.widget ?: continue attachments += widget.asVkWidget() } + else -> continue } } @@ -285,16 +351,229 @@ object VkUtils { } fun getActionMessageText( - context: Context, - message: VkMessage, + message: VkMessage?, youPrefix: String, - profiles: Map? = null, - groups: Map? = null, - messageUser: VkUser? = null, - messageGroup: VkGroup? = null + messageUser: VkUser?, + messageGroup: VkGroup?, + action: VkMessage.Action?, + actionUser: VkUser?, + actionGroup: VkGroup?, + ): UiText? { + if (message == null) return null + + return when (action) { + VkMessage.Action.CHAT_CREATE -> { + val text = message.actionText ?: return null + + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isGroup() -> messageGroup?.name + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams( + R.string.message_action_chat_created, + listOf(prefix, text) + ) + } + + VkMessage.Action.CHAT_TITLE_UPDATE -> { + val text = message.actionText ?: return null + + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isGroup() -> messageGroup?.name + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams( + R.string.message_action_chat_renamed, + listOf(prefix, text) + ) + } + + VkMessage.Action.CHAT_PHOTO_UPDATE -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isGroup() -> messageGroup?.name + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams(R.string.message_action_chat_photo_update, listOf(prefix)) + } + + VkMessage.Action.CHAT_PHOTO_REMOVE -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isGroup() -> messageGroup?.name + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams(R.string.message_action_chat_photo_remove, listOf(prefix)) + } + + VkMessage.Action.CHAT_KICK_USER -> { + val memberId = message.actionMemberId ?: return null + val isUser = memberId > 0 + val isGroup = memberId < 0 + + if (isUser && actionUser == null) return null + if (isGroup && actionGroup == null) return null + + if (memberId == message.fromId) { + val prefix = if (memberId == UserConfig.userId) youPrefix + else actionUser.toString() + + UiText.ResourceParams(R.string.message_action_chat_user_left, listOf(prefix)) + } else { + val prefix = + if (message.fromId == UserConfig.userId) youPrefix + else messageUser?.toString() ?: messageGroup?.toString().orDots() + + val postfix = + if (memberId == UserConfig.userId) youPrefix.lowercase() + else actionUser.toString() + + UiText.ResourceParams( + R.string.message_action_chat_user_kicked, listOf(prefix, postfix) + ) + } + } + + VkMessage.Action.CHAT_INVITE_USER -> { + val memberId = message.actionMemberId ?: 0 + val isUser = memberId > 0 + val isGroup = memberId < 0 + + if (isUser && actionUser == null) return null + if (isGroup && actionGroup == null) return null + + if (memberId == message.fromId) { + val prefix = if (memberId == UserConfig.userId) youPrefix + else actionUser.toString() + + UiText.ResourceParams( + R.string.message_action_chat_user_returned, + listOf(prefix) + ) + } else { + val prefix = if (message.fromId == UserConfig.userId) youPrefix + else messageUser?.toString() ?: messageGroup?.toString().orDots() + + val postfix = + if (memberId == UserConfig.userId) youPrefix.lowercase() + else actionUser.toString() + + UiText.ResourceParams( + R.string.message_action_chat_user_invited, + listOf(prefix, postfix) + ) + } + } + + VkMessage.Action.CHAT_INVITE_USER_BY_LINK -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams( + R.string.message_action_chat_user_joined_by_link, + listOf(prefix) + ) + } + + VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams( + R.string.message_action_chat_user_joined_by_call, + listOf(prefix) + ) + } + + VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams( + R.string.message_action_chat_user_joined_by_call_link, + listOf(prefix) + ) + } + + VkMessage.Action.CHAT_PIN_MESSAGE -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isGroup() -> messageGroup?.name + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams(R.string.message_action_chat_pin_message, listOf(prefix)) + } + + VkMessage.Action.CHAT_UNPIN_MESSAGE -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isGroup() -> messageGroup?.name + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams(R.string.message_action_chat_unpin_message, listOf(prefix)) + } + + VkMessage.Action.CHAT_SCREENSHOT -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isGroup() -> messageGroup?.name + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams(R.string.message_action_chat_screenshot, listOf(prefix)) + } + + VkMessage.Action.CHAT_STYLE_UPDATE -> { + val prefix = when { + message.fromId == UserConfig.userId -> youPrefix + message.isUser() -> messageUser?.toString() + else -> return null + } ?: return null + + UiText.ResourceParams(R.string.message_action_chat_style_update, listOf(prefix)) + } + + null -> null + } + } + + fun getActionMessageText( + context: Context, + message: VkMessage?, + youPrefix: String, + messageUser: VkUser?, + messageGroup: VkGroup?, + action: VkMessage.Action?, + actionUser: VkUser?, + actionGroup: VkGroup?, ): SpannableString? { - @Suppress("REDUNDANT_ELSE_IN_WHEN") - return when (message.getPreparedAction()) { + if (message == null) return null + + return when (action) { VkMessage.Action.CHAT_CREATE -> { val text = message.actionText ?: return null @@ -319,6 +598,7 @@ object VkUtils { ) } } + VkMessage.Action.CHAT_TITLE_UPDATE -> { val text = message.actionText ?: return null @@ -340,6 +620,7 @@ object VkUtils { ) } } + VkMessage.Action.CHAT_PHOTO_UPDATE -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -355,6 +636,7 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_PHOTO_REMOVE -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -370,14 +652,12 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_KICK_USER -> { val memberId = message.actionMemberId ?: return null val isUser = memberId > 0 val isGroup = memberId < 0 - val actionUser = profiles?.get(memberId) - val actionGroup = groups?.get(memberId) - if (isUser && actionUser == null) return null if (isGroup && actionGroup == null) return null @@ -416,14 +696,12 @@ object VkUtils { } } } + VkMessage.Action.CHAT_INVITE_USER -> { val memberId = message.actionMemberId ?: 0 val isUser = memberId > 0 val isGroup = memberId < 0 - val actionUser = profiles?.get(memberId) - val actionGroup = groups?.get(memberId) - if (isUser && actionUser == null) return null if (isGroup && actionGroup == null) return null @@ -461,6 +739,7 @@ object VkUtils { } } } + VkMessage.Action.CHAT_INVITE_USER_BY_LINK -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -475,6 +754,7 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_INVITE_USER_BY_CALL -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -489,6 +769,7 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_INVITE_USER_BY_CALL_LINK -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -503,6 +784,7 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_PIN_MESSAGE -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -518,6 +800,7 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_UNPIN_MESSAGE -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -533,6 +816,7 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_SCREENSHOT -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -548,6 +832,7 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + VkMessage.Action.CHAT_STYLE_UPDATE -> { val prefix = when { message.fromId == UserConfig.userId -> youPrefix @@ -562,74 +847,112 @@ object VkUtils { it.setSpan(StyleSpan(Typeface.BOLD), 0, prefix.length, 0) } } + null -> null - else -> SpannableString("[${message.action}]") } } fun getActionConversationText( - context: Context, - message: VkMessage, + message: VkMessage?, youPrefix: String, - profiles: HashMap? = null, - groups: HashMap? = null, messageUser: VkUser? = null, - messageGroup: VkGroup? = null + messageGroup: VkGroup? = null, + action: VkMessage.Action?, + actionUser: VkUser?, + actionGroup: VkGroup?, + ): UiText? { + return getActionMessageText( + message = message, + youPrefix = youPrefix, + messageUser = messageUser, + messageGroup = messageGroup, + action = action, + actionUser = actionUser, + actionGroup = actionGroup, + ) + } + + fun getActionConversationText( + context: Context, + message: VkMessage?, + youPrefix: String, + messageUser: VkUser? = null, + messageGroup: VkGroup? = null, + action: VkMessage.Action?, + actionUser: VkUser?, + actionGroup: VkGroup?, ): String? { return getActionMessageText( context = context, message = message, youPrefix = youPrefix, - profiles = profiles, - groups = groups, messageUser = messageUser, - messageGroup = messageGroup + messageGroup = messageGroup, + action = action, + actionUser = actionUser, + actionGroup = actionGroup, )?.toString() } - fun getForwardsText(context: Context, message: VkMessage): String? { - if (message.forwards.isNullOrEmpty()) return null + fun getForwardsText(message: VkMessage?): UiText? { + if (message?.forwards.isNullOrEmpty()) return null - return message.forwards?.let { forwards -> - context.getString( + return message?.forwards?.let { forwards -> + UiText.Resource( if (forwards.size == 1) R.string.forwarded_message else R.string.forwarded_messages ) } } - fun getAttachmentText(context: Context, message: VkMessage): String? { - message.geo?.let { + fun getAttachmentText(message: VkMessage?): UiText? { + message?.geo?.let { return when (it.type) { - "point" -> context.getString(R.string.message_geo_point) - else -> context.getString(R.string.message_geo) + "point" -> UiText.Resource(R.string.message_geo_point) + else -> UiText.Resource(R.string.message_geo) } } - if (message.attachments.isNullOrEmpty()) return null + if (message?.attachments.isNullOrEmpty()) return null - return message.attachments?.let { attachments -> + return message?.attachments?.let { attachments -> if (attachments.size == 1) { getAttachmentTypeByClass(attachments[0])?.let { - getAttachmentTextByType( - context, - it - ) + getAttachmentTextByType(it) } } else { if (isAttachmentsHaveOneType(attachments)) { getAttachmentTypeByClass(attachments[0])?.let { - getAttachmentTextByType( - context, it, attachments.size - ) + getAttachmentTextByType(it, attachments.size) } } else { - context.getString(R.string.message_attachments_many) + UiText.Resource(R.string.message_attachments_many) } } } } - fun getAttachmentConversationIcon(context: Context, message: VkMessage): Drawable? { + fun getAttachmentConversationIcon(message: VkMessage?): UiImage? { + return message?.attachments?.let { attachments -> + if (attachments.isEmpty()) return null + if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) { + message.geo?.let { + return UiImage.Resource(R.drawable.ic_map_marker) + } + + getAttachmentTypeByClass(attachments[0])?.let { + getAttachmentIconByType(it) + } + } else { + UiImage.Resource(R.drawable.ic_baseline_attach_file_24) + } + } + } + + fun getAttachmentConversationIcon( + context: Context, + message: VkMessage?, + ): Drawable? { + if (message == null) return null return message.attachments?.let { attachments -> if (attachments.size == 1 || isAttachmentsHaveOneType(attachments)) { message.geo?.let { @@ -650,9 +973,32 @@ object VkUtils { } } + fun getAttachmentIconByType(attachmentType: BaseVkAttachmentItem.AttachmentType): UiImage? { + return when (attachmentType) { + BaseVkAttachmentItem.AttachmentType.Photo -> R.drawable.ic_attachment_photo + BaseVkAttachmentItem.AttachmentType.Video -> R.drawable.ic_attachment_video + BaseVkAttachmentItem.AttachmentType.Audio -> R.drawable.ic_attachment_audio + BaseVkAttachmentItem.AttachmentType.File -> R.drawable.ic_attachment_file + BaseVkAttachmentItem.AttachmentType.Link -> R.drawable.ic_attachment_link + BaseVkAttachmentItem.AttachmentType.Voice -> R.drawable.ic_attachment_voice + BaseVkAttachmentItem.AttachmentType.MiniApp -> R.drawable.ic_attachment_mini_app + BaseVkAttachmentItem.AttachmentType.Sticker -> R.drawable.ic_attachment_sticker + BaseVkAttachmentItem.AttachmentType.Gift -> R.drawable.ic_attachment_gift + BaseVkAttachmentItem.AttachmentType.Wall -> R.drawable.ic_attachment_wall + BaseVkAttachmentItem.AttachmentType.Graffiti -> R.drawable.ic_attachment_graffiti + BaseVkAttachmentItem.AttachmentType.Poll -> R.drawable.ic_attachment_poll + BaseVkAttachmentItem.AttachmentType.WallReply -> R.drawable.ic_attachment_wall_reply + BaseVkAttachmentItem.AttachmentType.Call -> R.drawable.ic_attachment_call + BaseVkAttachmentItem.AttachmentType.GroupCallInProgress -> R.drawable.ic_attachment_group_call + BaseVkAttachmentItem.AttachmentType.Story -> R.drawable.ic_attachment_story + else -> null + }?.let(UiImage::Resource) + } + + @Deprecated("Use new with UiImage") fun getAttachmentIconByType( context: Context, - attachmentType: BaseVkAttachmentItem.AttachmentType + attachmentType: BaseVkAttachmentItem.AttachmentType, ): Drawable? { val resId = when (attachmentType) { BaseVkAttachmentItem.AttachmentType.Photo -> R.drawable.ic_attachment_photo @@ -716,50 +1062,68 @@ object VkUtils { } fun getAttachmentTextByType( - context: Context, attachmentType: BaseVkAttachmentItem.AttachmentType, - size: Int = 1 - ): String { + size: Int = 1, + ): UiText { return when (attachmentType) { BaseVkAttachmentItem.AttachmentType.Photo -> - context.resources.getQuantityString(R.plurals.attachment_photos, size, size) + UiText.QuantityResource(R.plurals.attachment_photos, size) + BaseVkAttachmentItem.AttachmentType.Video -> - context.resources.getQuantityString(R.plurals.attachment_videos, size, size) + UiText.QuantityResource(R.plurals.attachment_videos, size) + BaseVkAttachmentItem.AttachmentType.Audio -> - context.resources.getQuantityString(R.plurals.attachment_audios, size, size) + UiText.QuantityResource(R.plurals.attachment_audios, size) + BaseVkAttachmentItem.AttachmentType.File -> - context.resources.getQuantityString(R.plurals.attachment_files, size, size) + UiText.QuantityResource(R.plurals.attachment_files, size) + BaseVkAttachmentItem.AttachmentType.Link -> - context.resources.getString(R.string.message_attachments_link) + UiText.Resource(R.string.message_attachments_link) + BaseVkAttachmentItem.AttachmentType.Voice -> - context.resources.getString(R.string.message_attachments_voice) + UiText.Resource(R.string.message_attachments_voice) + BaseVkAttachmentItem.AttachmentType.MiniApp -> - context.resources.getString(R.string.message_attachments_mini_app) + UiText.Resource(R.string.message_attachments_mini_app) + BaseVkAttachmentItem.AttachmentType.Sticker -> - context.resources.getString(R.string.message_attachments_sticker) + UiText.Resource(R.string.message_attachments_sticker) + BaseVkAttachmentItem.AttachmentType.Gift -> - context.resources.getString(R.string.message_attachments_gift) + UiText.Resource(R.string.message_attachments_gift) + BaseVkAttachmentItem.AttachmentType.Wall -> - context.resources.getString(R.string.message_attachments_wall) + UiText.Resource(R.string.message_attachments_wall) + BaseVkAttachmentItem.AttachmentType.Graffiti -> - context.resources.getString(R.string.message_attachments_graffiti) + UiText.Resource(R.string.message_attachments_graffiti) + BaseVkAttachmentItem.AttachmentType.Poll -> - context.resources.getString(R.string.message_attachments_poll) + UiText.Resource(R.string.message_attachments_poll) + BaseVkAttachmentItem.AttachmentType.WallReply -> - context.resources.getString(R.string.message_attachments_wall_reply) + UiText.Resource(R.string.message_attachments_wall_reply) + BaseVkAttachmentItem.AttachmentType.Call -> - context.resources.getString(R.string.message_attachments_call) + UiText.Resource(R.string.message_attachments_call) + BaseVkAttachmentItem.AttachmentType.GroupCallInProgress -> - context.resources.getString(R.string.message_attachments_call_in_progress) + UiText.Resource(R.string.message_attachments_call_in_progress) + BaseVkAttachmentItem.AttachmentType.Event -> - context.resources.getString(R.string.message_attachments_event) + UiText.Resource(R.string.message_attachments_event) + BaseVkAttachmentItem.AttachmentType.Curator -> - context.resources.getString(R.string.message_attachments_curator) + UiText.Resource(R.string.message_attachments_curator) + BaseVkAttachmentItem.AttachmentType.Story -> - context.resources.getString(R.string.message_attachments_story) + UiText.Resource(R.string.message_attachments_story) + BaseVkAttachmentItem.AttachmentType.Widget -> - context.resources.getString(R.string.message_attachments_widget) - else -> attachmentType.value + UiText.Resource(R.string.message_attachments_widget) + + else -> UiText.Simple(attachmentType.value) } } @@ -775,18 +1139,43 @@ object VkUtils { authorizationError } + + VkErrorCodes.AccessTokenExpired.toString() -> { + val tokenExpiredError = + gson.fromJson(errorString, TokenExpiredError::class.java) + + tokenExpiredError + } + VkErrors.NeedValidation -> { val validationError = - gson.fromJson(errorString, ValidationRequiredError::class.java) + gson.fromJson( + errorString, + if (defaultError.errorMessage == VkErrorMessages.UserBanned) { + UserBannedError::class.java + } else { + ValidationRequiredError::class.java + } + ) validationError } + VkErrors.NeedCaptcha -> { val captchaRequiredError = gson.fromJson(errorString, CaptchaRequiredError::class.java) captchaRequiredError } + + VkErrors.InvalidRequest -> { + when (defaultError.errorType) { + VkErrorTypes.OtpFormatIncorrect -> WrongTwoFaCodeFormatError + VkErrorTypes.WrongOtp -> WrongTwoFaCodeError + else -> defaultError + } + } + else -> defaultError } @@ -795,4 +1184,102 @@ object VkUtils { return ApiAnswer.Error(ApiError(throwable = e)) } } -} \ No newline at end of file + + fun visualizeMentions( + messageText: String, + mentionColor: Int, + onMentionClick: ((id: Int) -> Unit)? = null, + ): SpannableStringBuilder { + if (messageText.isEmpty()) { + return SpannableStringBuilder("") + } + + var newMessageText = messageText + + val idsIndexes = mutableListOf>() + val mentions = mutableListOf>() + + var startFrom = 0 + + while (true) { + val leftBracketIndex = newMessageText.indexOf('[', startFrom) + val verticalLineIndex = newMessageText.indexOf('|', startFrom) + val rightBracketIndex = newMessageText.indexOf(']', startFrom) + + if (leftBracketIndex == -1 || + verticalLineIndex == -1 || + rightBracketIndex == -1 + ) break + + val idPart = newMessageText.substring(leftBracketIndex + 1, verticalLineIndex) + + val actualId = idPart.substring(2, idPart.length).toIntOrNull() ?: -1 + + if (!idPart.matches(Regex("^id(\\d+)\$")) || rightBracketIndex - verticalLineIndex < 2) { + break + } + + val text = newMessageText.substring(verticalLineIndex + 1, rightBracketIndex) + + val str = "[$idPart|$text]" + + mentions += str to text + + idsIndexes += Triple(actualId, leftBracketIndex, leftBracketIndex + text.length) + + startFrom = rightBracketIndex + 1 + } + + idsIndexes.reverse() + + mentions.forEachIndexed { index, pair -> + val old = pair.first + val new = pair.second + + val oldIndexStart = newMessageText.indexOf(old) + + idsIndexes[index].copy( + second = oldIndexStart, + third = oldIndexStart + new.length + ).let { idsIndexes[index] = it } + + newMessageText = newMessageText.replace(old, new) + } + + val spanBuilder = SpannableStringBuilder(newMessageText) + + idsIndexes.forEach { triple -> + val id = triple.first + val start = triple.second + val end = triple.third + + spanBuilder.setSpan( + createClickableSpan(id, mentionColor, onMentionClick), + start, + end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + return spanBuilder + } + + private fun createClickableSpan( + id: Int, + mentionColor: Int, + onMentionClick: ((id: Int) -> Unit)? = null, + ): ClickableSpan { + return object : ClickableSpan() { + override fun onClick(widget: View) { + widget.cancelPendingInputEvents() + + onMentionClick?.invoke(id) + } + + override fun updateDrawState(ds: TextPaint) { + ds.color = mentionColor +// ds.typeface = Typeface.defaultFromStyle(Typeface.BOLD) + } + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt b/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt index 677a22bc..ae38560f 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/base/ApiError.kt @@ -9,6 +9,8 @@ open class ApiError( val error: String? = null, @SerializedName("error_msg", alternate = ["error_description"]) open val errorMessage: String? = null, + @SerializedName("error_type") + val errorType: String? = null, val throwable: Throwable? = null ) : IOException() { diff --git a/app/src/main/kotlin/com/meloda/fast/api/base/AttachmentClassNameIsEmptyException.kt b/app/src/main/kotlin/com/meloda/fast/api/base/AttachmentClassNameIsEmptyException.kt new file mode 100644 index 00000000..2aad4520 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/base/AttachmentClassNameIsEmptyException.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.api.base + +import com.meloda.fast.api.model.attachments.VkAttachment +import okio.IOException + +class AttachmentClassNameIsEmptyException(attachment: VkAttachment) : + IOException( + "attachment ${attachment.javaClass.name} does not have declared field \"className\"" + ) diff --git a/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt index 2d117e92..3637bd5f 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollEvent.kt @@ -9,12 +9,26 @@ sealed class LongPollEvent { data class VkMessageNewEvent( val message: VkMessage, val profiles: HashMap, - val groups: HashMap + val groups: HashMap, ) : LongPollEvent() data class VkMessageEditEvent(val message: VkMessage) : LongPollEvent() - data class VkMessageReadIncomingEvent(val peerId: Int, val messageId: Int) : LongPollEvent() - data class VkMessageReadOutgoingEvent(val peerId: Int, val messageId: Int) : LongPollEvent() + data class VkMessageReadIncomingEvent( + val peerId: Int, + val messageId: Int, + val unreadCount: Int, + ) : LongPollEvent() -} \ No newline at end of file + data class VkMessageReadOutgoingEvent( + val peerId: Int, + val messageId: Int, + val unreadCount: Int, + ) : LongPollEvent() + + data class VkConversationPinStateChangedEvent( + val peerId: Int, + val majorId: Int, + ) : LongPollEvent() + +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt index f419b3de..324d446f 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/longpoll/LongPollUpdatesParser.kt @@ -10,7 +10,12 @@ import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.messages.MessagesGetByIdRequest import com.meloda.fast.base.viewmodel.VkEventCallback import com.meloda.fast.data.messages.MessagesRepository -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -47,10 +52,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) ApiEvent.MessageEdit -> parseMessageEdit(eventType, event) ApiEvent.MessageReadIncoming -> parseMessageReadIncoming(eventType, event) ApiEvent.MessageReadOutgoing -> parseMessageReadOutgoing(eventType, event) - ApiEvent.FriendOnline -> parseFriendOnline(eventType, event) - ApiEvent.FriendOffline -> parseFriendOffline(eventType, event) ApiEvent.MessagesDeleted -> parseMessagesDeleted(eventType, event) - ApiEvent.PinUnpinConversation -> onNewEvent(eventType, event) + ApiEvent.PinUnpinConversation -> parseConversationPinStateChanged(eventType, event) ApiEvent.PrivateTyping -> onNewEvent(eventType, event) ApiEvent.ChatTyping -> onNewEvent(eventType, event) ApiEvent.OneMoreTyping -> onNewEvent(eventType, event) @@ -67,6 +70,27 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) Log.d("LongPollUpdatesParser", "newEvent: $eventType: $event") } + private fun parseConversationPinStateChanged(eventType: ApiEvent, event: JsonArray) { + Log.d("LongPollUpdatesParser", "$eventType: $event") + + val peerId = event[1].asInt + val majorId = event[2].asInt + + launch { + listenersMap[ApiEvent.PinUnpinConversation]?.let { listeners -> + listeners.forEach { vkEventCallback -> + (vkEventCallback as VkEventCallback) + .onEvent( + LongPollEvent.VkConversationPinStateChangedEvent( + peerId = peerId, + majorId = majorId + ) + ) + } + } + } + } + private fun parseMessageSetFlags(eventType: ApiEvent, event: JsonArray) { Log.d("LongPollUpdatesParser", "$eventType: $event") } @@ -119,6 +143,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) Log.d("LongPollUpdatesParser", "$eventType: $event") val peerId = event[1].asInt val messageId = event[2].asInt + val unreadCount = event[3].asInt launch { listenersMap[ApiEvent.MessageReadIncoming]?.let { listeners -> @@ -127,7 +152,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) .onEvent( LongPollEvent.VkMessageReadIncomingEvent( peerId = peerId, - messageId = messageId + messageId = messageId, + unreadCount = unreadCount ) ) } @@ -139,6 +165,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) Log.d("LongPollUpdatesParser", "$eventType: $event") val peerId = event[1].asInt val messageId = event[2].asInt + val unreadCount = event[3].asInt launch { listenersMap[ApiEvent.MessageReadOutgoing]?.let { listeners -> @@ -147,7 +174,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) .onEvent( LongPollEvent.VkMessageReadOutgoingEvent( peerId = peerId, - messageId = messageId + messageId = messageId, + unreadCount = unreadCount ) ) } @@ -155,21 +183,13 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) } } - private fun parseFriendOnline(eventType: ApiEvent, event: JsonArray) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - } - - private fun parseFriendOffline(eventType: ApiEvent, event: JsonArray) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - } - private fun parseMessagesDeleted(eventType: ApiEvent, event: JsonArray) { Log.d("LongPollUpdatesParser", "$eventType: $event") } private suspend fun loadNormalMessage(eventType: ApiEvent, messageId: Int) = coroutineScope { - suspendCoroutine { + suspendCoroutine { launch { val normalMessageResponse = messagesRepository.getById( MessagesGetByIdRequest( @@ -179,7 +199,7 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) ) ) - if (!normalMessageResponse.isSuccessful()) { + if (normalMessageResponse.isError()) { normalMessageResponse.error.throwable?.run { throw this } } @@ -195,12 +215,12 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) val profiles = hashMapOf() messagesResponse.profiles?.forEach { baseUser -> - baseUser.asVkUser().let { user -> profiles[user.id] = user } + baseUser.mapToDomain().let { user -> profiles[user.id] = user } } val groups = hashMapOf() messagesResponse.groups?.forEach { baseGroup -> - baseGroup.asVkGroup().let { group -> groups[group.id] = group } + baseGroup.mapToDomain().let { group -> groups[group.id] = group } } val resumeValue: LongPollEvent? = when (eventType) { @@ -228,6 +248,14 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) } } + fun onConversationPinStateChanged(listener: VkEventCallback) { + registerListener(ApiEvent.PinUnpinConversation, listener) + } + + fun onConversationPinStateChanged(block: (LongPollEvent.VkConversationPinStateChangedEvent) -> Unit) { + onConversationPinStateChanged(assembleEventCallback(block)) + } + fun onMessageIncomingRead(listener: VkEventCallback) { registerListener(ApiEvent.MessageReadIncoming, listener) } @@ -265,8 +293,8 @@ class LongPollUpdatesParser(private val messagesRepository: MessagesRepository) } } -internal inline fun assembleEventCallback(crossinline block: (R) -> Unit): VkEventCallback { - return object : VkEventCallback { - override fun onEvent(event: R) = block.invoke(event) - } -} \ No newline at end of file +internal inline fun assembleEventCallback( + crossinline block: (R) -> Unit, +): VkEventCallback { + return VkEventCallback { event -> block.invoke(event) } +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/ActionState.kt b/app/src/main/kotlin/com/meloda/fast/api/model/ActionState.kt new file mode 100644 index 00000000..ece31811 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/model/ActionState.kt @@ -0,0 +1,17 @@ +package com.meloda.fast.api.model + +sealed class ActionState { + object Phantom : ActionState() + object CallInProgress : ActionState() + object None : ActionState() + + companion object { + fun parse(isPhantom: Boolean, isCallInProgress: Boolean): ActionState { + return when { + isPhantom -> Phantom + isCallInProgress -> CallInProgress + else -> None + } + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/ConversationPeerType.kt b/app/src/main/kotlin/com/meloda/fast/api/model/ConversationPeerType.kt new file mode 100644 index 00000000..25c3bb53 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/model/ConversationPeerType.kt @@ -0,0 +1,25 @@ +package com.meloda.fast.api.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class ConversationPeerType : Parcelable { + object User : ConversationPeerType() + object Group : ConversationPeerType() + object Chat : ConversationPeerType() + + fun isUser() = this == User + fun isGroup() = this == Group + fun isChat() = this == Chat + + companion object { + fun parse(type: String): ConversationPeerType { + return when (type) { + "user" -> User + "group" -> Group + else -> Chat + } + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkChat.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkChat.kt new file mode 100644 index 00000000..c75b45be --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkChat.kt @@ -0,0 +1,53 @@ +package com.meloda.fast.api.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class VkChat( + val type: String, + val title: String, + val adminId: Int, + val membersCount: Int, + val id: Int, + val members: List = emptyList(), + val photo50: String, + val photo100: String, + val photo200: String, + val isDefaultPhoto: Boolean +) : Parcelable { + + + @Parcelize + data class ChatMember( + val id: Int, + val type: ChatMemberType, + val isOnline: Boolean?, + val lastSeen: Int?, + val name: String?, + val firstName: String?, + val lastName: String?, + val invitedBy: Int, + val photo50: String?, + val photo100: String?, + val photo200: String?, + val isOwner: Boolean, + val isAdmin: Boolean, + val canKick: Boolean + ) : Parcelable { + + fun isProfile(): Boolean = type == ChatMemberType.Profile + + fun isGroup(): Boolean = type == ChatMemberType.Group + + enum class ChatMemberType(val value: String) { + Profile("profile"), Group("group"); + + companion object { + fun parse(value: String) = values().first { it.value == value } + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkChatMember.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkChatMember.kt new file mode 100644 index 00000000..1a513309 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkChatMember.kt @@ -0,0 +1,14 @@ +package com.meloda.fast.api.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class VkChatMember( + val memberId: Int, + val invitedBy: Int, + val joinDate: Int, + val isAdmin: Boolean, + val isOwner: Boolean, + val canKick: Boolean +) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt deleted file mode 100644 index a5c7118a..00000000 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkConversation.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.meloda.fast.api.model - -import androidx.lifecycle.MutableLiveData -import androidx.room.Embedded -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.PrimaryKey -import com.meloda.fast.api.UserConfig -import com.meloda.fast.model.SelectableItem -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -@Entity(tableName = "conversations") -@Parcelize -data class VkConversation( - @PrimaryKey(autoGenerate = false) - var id: Int, - var ownerId: Int?, - var title: String?, - var photo200: String?, - var type: String, - var callInProgress: Boolean, - var isPhantom: Boolean, - var lastConversationMessageId: Int, - var inRead: Int, - var outRead: Int, - var isMarkedUnread: Boolean, - var lastMessageId: Int, - var unreadCount: Int, - var membersCount: Int?, - var canChangePin: Boolean, - var majorId: Int, - var minorId: Int, - - @Embedded(prefix = "pinnedMessage_") - var pinnedMessage: VkMessage? = null, - - @Embedded(prefix = "lastMessage_") - var lastMessage: VkMessage? = null, -) : SelectableItem(id) { - - @Ignore - @IgnoredOnParcel - val user = MutableLiveData() - - @Ignore - @IgnoredOnParcel - val group = MutableLiveData() - - fun isChat() = type == "chat" - fun isUser() = type == "user" - fun isGroup() = type == "group" - - fun isInUnread() = inRead - lastMessageId < 0 - fun isOutUnread() = outRead - lastMessageId < 0 - - fun isUnread() = isInUnread() || isOutUnread() - - fun isAccount() = id == UserConfig.userId - - fun isPinned() = majorId > 0 - -} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt index e4b8e8fc..55373d72 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkMessage.kt @@ -7,11 +7,13 @@ import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants import com.meloda.fast.api.model.attachments.VkAttachment import com.meloda.fast.api.model.base.BaseVkMessage +import com.meloda.fast.api.model.domain.VkConversationDomain import com.meloda.fast.model.SelectableItem import com.meloda.fast.util.TimeUtils import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +// TODO: 05.08.2023, Danil Nikolaev: create other class for storing in database @Entity(tableName = "messages") @Parcelize data class VkMessage constructor( @@ -38,7 +40,7 @@ data class VkMessage constructor( var replyMessage: VkMessage? = null, val geo: BaseVkMessage.Geo? = null, -) : SelectableItem(id) { +) : SelectableItem() { @Ignore @IgnoredOnParcel @@ -48,6 +50,14 @@ data class VkMessage constructor( @IgnoredOnParcel var group: VkGroup? = null + @Ignore + @IgnoredOnParcel + var actionUser: VkUser? = null + + @Ignore + @IgnoredOnParcel + var actionGroup: VkGroup? = null + @Ignore @IgnoredOnParcel var state: State = State.Sent @@ -58,7 +68,7 @@ data class VkMessage constructor( fun isGroup() = fromId < 0 - fun isRead(conversation: VkConversation) = + fun isRead(conversation: VkConversationDomain) = if (isOut) { conversation.outRead - id >= 0 } else { diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt b/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt index 93d59131..fdd2f9e2 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/VkUser.kt @@ -15,11 +15,12 @@ data class VkUser( val online: Boolean, val photo200: String?, val lastSeen: Int?, - val lastSeenStatus: String? + val lastSeenStatus: String?, + val birthday: String? ) : Parcelable { override fun toString() = fullName val fullName get() = "$firstName $lastName".trim() -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt index c8fee250..71702bfb 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAttachment.kt @@ -1,16 +1,12 @@ package com.meloda.fast.api.model.attachments import android.os.Parcelable -import com.meloda.fast.model.DataItem import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize -open class VkAttachment : DataItem(), Parcelable { - - @IgnoredOnParcel - override val dataItemId: Int = -1 +open class VkAttachment : Parcelable { open fun asString(withAccessKey: Boolean = true) = "" -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAudio.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAudio.kt index f2283066..1427cf2f 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAudio.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkAudio.kt @@ -25,4 +25,4 @@ data class VkAudio( withAccessKey = withAccessKey, accessKey = accessKey ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCall.kt index c55143da..7a6c4002 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCall.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCall.kt @@ -16,4 +16,4 @@ data class VkCall( @IgnoredOnParcel val className: String = this::class.java.name -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCurator.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCurator.kt index b0a2089e..dd656e4d 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCurator.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkCurator.kt @@ -1,8 +1,13 @@ package com.meloda.fast.api.model.attachments +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize data class VkCurator( - val id: Int -) : VkAttachment() \ No newline at end of file + val id: Int, +) : VkAttachment() { + + @IgnoredOnParcel + val className: String = this::class.java.name +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkEvent.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkEvent.kt index 8a1f9801..debda085 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkEvent.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkEvent.kt @@ -5,4 +5,4 @@ import kotlinx.parcelize.Parcelize @Parcelize data class VkEvent( val id: Int -) : VkAttachment() \ No newline at end of file +) : VkAttachment() diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt index e1ccb61b..2766e61b 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkFile.kt @@ -27,4 +27,4 @@ data class VkFile( withAccessKey = withAccessKey, accessKey = accessKey ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGraffiti.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGraffiti.kt index 9ce65371..2f4ddae9 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGraffiti.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGraffiti.kt @@ -15,4 +15,4 @@ data class VkGraffiti( @IgnoredOnParcel val className: String = this::class.java.name -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt index 562cf467..2a0aa581 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkGroupCall.kt @@ -10,4 +10,4 @@ data class VkGroupCall( @IgnoredOnParcel val className: String = this::class.java.name -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkMiniApp.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkMiniApp.kt index 8e6e2860..a65fb1f1 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkMiniApp.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkMiniApp.kt @@ -10,4 +10,4 @@ data class VkMiniApp( @IgnoredOnParcel val className: String = this::class.java.name -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPhoto.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPhoto.kt index ef95a6f0..8d64b274 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPhoto.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPhoto.kt @@ -94,4 +94,4 @@ data class VkPhoto( return null } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPoll.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPoll.kt index 25c17860..4bee6a71 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPoll.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkPoll.kt @@ -10,4 +10,4 @@ data class VkPoll( @IgnoredOnParcel val className: String = this::class.java.name -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkStory.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkStory.kt index 55e968c6..c6862706 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkStory.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkStory.kt @@ -1,5 +1,6 @@ package com.meloda.fast.api.model.attachments +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize @@ -14,4 +15,7 @@ data class VkStory( fun isFromGroup() = ownerId < 0 -} \ No newline at end of file + @IgnoredOnParcel + val className: String = this::class.java.name + +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt index f3061a55..b73fab8c 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVideo.kt @@ -13,7 +13,7 @@ data class VkVideo( val images: List, val firstFrames: List?, val accessKey: String?, - val title: String + val title: String, ) : VkAttachment() { @IgnoredOnParcel @@ -47,11 +47,11 @@ data class VkVideo( val width: Int, val height: Int, val url: String, - val withPadding: Boolean + val withPadding: Boolean, ) : Parcelable { @IgnoredOnParcel - var shapeKind: ShapeKind + var shapeKind: ShapeKind? = null init { val ratio = width.toFloat() / height.toFloat() @@ -64,10 +64,21 @@ data class VkVideo( } } - sealed class ShapeKind { - object Vertical : ShapeKind() - object Horizontal : ShapeKind() - object Square : ShapeKind() + open class ShapeKind(val value: Int) { + object Square : ShapeKind(0) + object Vertical : ShapeKind(1) + object Horizontal : ShapeKind(2) + + companion object { + + + fun parse(value: Int) = when (value) { + 0 -> Square + 1 -> Vertical + 2 -> Horizontal + else -> throw IllegalArgumentException("Unknown value: $value") + } + } } override fun asString(withAccessKey: Boolean) = VkUtils.attachmentToString( @@ -78,4 +89,4 @@ data class VkVideo( accessKey = accessKey ) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVoiceMessage.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVoiceMessage.kt index 4c72603b..557f4c77 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVoiceMessage.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkVoiceMessage.kt @@ -19,4 +19,4 @@ data class VkVoiceMessage( @IgnoredOnParcel val className: String = this::class.java.name -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt index 68ae06be..bd24073a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWall.kt @@ -22,4 +22,4 @@ data class VkWall( @IgnoredOnParcel val className: String = this::class.java.name -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWallReply.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWallReply.kt index dff2966b..110145eb 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWallReply.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWallReply.kt @@ -10,4 +10,4 @@ data class VkWallReply( @IgnoredOnParcel val className: String = this::class.java.name -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWidget.kt b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWidget.kt index 51949fd0..7edb329f 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWidget.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/attachments/VkWidget.kt @@ -1,8 +1,13 @@ package com.meloda.fast.api.model.attachments +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize data class VkWidget( val id: Int -) : VkAttachment() +) : VkAttachment() { + + @IgnoredOnParcel + val className: String = this::class.java.name +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChat.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChat.kt new file mode 100644 index 00000000..1f10f4c5 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChat.kt @@ -0,0 +1,38 @@ +package com.meloda.fast.api.model.base + +import android.os.Parcelable +import com.meloda.fast.api.model.VkChat +import kotlinx.parcelize.Parcelize + +@Parcelize +data class BaseVkChat( + val type: String, + val title: String, + val admin_id: Int, + val members_count: Int, + val id: Int, + val photo_50: String, + val photo_100: String, + val photo_200: String, + val is_default_photo: Boolean, + val push_settings: PushSettings +) : Parcelable { + + fun asVkChat() = VkChat( + type = type, + title = title, + adminId = admin_id, + membersCount = members_count, + id = id, + photo50 = photo_50, + photo100 = photo_100, + photo200 = photo_200, + isDefaultPhoto = is_default_photo + ) + + @Parcelize + data class PushSettings( + val sound: Int, + val disabled_until: Int + ) : Parcelable +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChatMember.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChatMember.kt new file mode 100644 index 00000000..139664d9 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkChatMember.kt @@ -0,0 +1,26 @@ +package com.meloda.fast.api.model.base + +import android.os.Parcelable +import com.meloda.fast.api.model.VkChatMember +import kotlinx.parcelize.Parcelize + +@Parcelize +data class BaseVkChatMember( + val member_id: Int, + val invited_by: Int, + val join_date: Int, + val is_admin: Boolean?, + val is_owner: Boolean?, + val can_kick: Boolean? +) : Parcelable { + + fun asVkChatMember() = VkChatMember( + memberId = member_id, + invitedBy = invited_by, + joinDate = join_date, + isAdmin = is_admin == true, + isOwner = is_owner == true, + canKick = can_kick == true + ) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkGroup.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkGroup.kt index eb2b0a6a..e76b3ea7 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkGroup.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkGroup.kt @@ -20,7 +20,7 @@ data class BaseVkGroup( val members_count: Int? ) : Parcelable { - fun asVkGroup() = VkGroup( + fun mapToDomain() = VkGroup( id = -id, name = name, screenName = screen_name, diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkUser.kt b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkUser.kt index e3cfb731..322ce4d2 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkUser.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkUser.kt @@ -18,7 +18,8 @@ data class BaseVkUser( val photo_200: String?, val online: Int?, val online_info: OnlineInfo?, - val screen_name: String + val screen_name: String, + val bdate: String? //...other fields ) : Parcelable { @@ -32,14 +33,15 @@ data class BaseVkUser( val app_id: Int? ) : Parcelable - fun asVkUser() = VkUser( + fun mapToDomain() = VkUser( id = id, firstName = first_name, lastName = last_name, online = online == 1, photo200 = photo_200, lastSeen = online_info?.last_seen, - lastSeenStatus = online_info?.status + lastSeenStatus = online_info?.status, + birthday = bdate ) } diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt b/app/src/main/kotlin/com/meloda/fast/api/model/data/BaseVkConversation.kt similarity index 67% rename from app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt rename to app/src/main/kotlin/com/meloda/fast/api/model/data/BaseVkConversation.kt index 96e09aee..933f778b 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/model/base/BaseVkConversation.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/model/data/BaseVkConversation.kt @@ -1,9 +1,12 @@ -package com.meloda.fast.api.model.base +package com.meloda.fast.api.model.data import android.os.Parcelable -import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.api.model.base.attachments.BaseVkGroupCall +import com.meloda.fast.api.model.domain.VkConversationDomain import kotlinx.parcelize.Parcelize @Parcelize @@ -12,6 +15,8 @@ data class BaseVkConversation( val last_message_id: Int, val in_read: Int, val out_read: Int, + val in_read_cmid: Int, + val out_read_cmid: Int, val sort_id: SortId, val last_conversation_message_id: Int, val is_marked_unread: Boolean, @@ -22,43 +27,20 @@ data class BaseVkConversation( val can_receive_money: Boolean, val chat_settings: ChatSettings?, val call_in_progress: CallInProgress?, - val unread_count: Int? + val unread_count: Int?, ) : Parcelable { - fun asVkConversation(lastMessage: VkMessage? = null) = VkConversation( - id = peer.id, - title = chat_settings?.title, - photo200 = chat_settings?.photo?.photo_200, - type = peer.type, - callInProgress = call_in_progress != null, - isPhantom = chat_settings?.is_disappearing == true, - lastConversationMessageId = last_conversation_message_id, - inRead = in_read, - outRead = out_read, - isMarkedUnread = is_marked_unread, - lastMessageId = last_message_id, - unreadCount = unread_count ?: 0, - membersCount = chat_settings?.members_count, - ownerId = chat_settings?.owner_id, - majorId = sort_id.major_id, - minorId = sort_id.minor_id, - canChangePin = chat_settings?.acl?.can_change_pin == true - ).apply { - this.lastMessage = lastMessage - this.pinnedMessage = chat_settings?.pinned_message?.asVkMessage() - } - @Parcelize data class Peer( val id: Int, val type: String, - val local_id: Int + val local_id: Int, ) : Parcelable @Parcelize data class SortId( val major_id: Int, - val minor_id: Int + val minor_id: Int, ) : Parcelable @Parcelize @@ -66,12 +48,12 @@ data class BaseVkConversation( val disabled_forever: Boolean, val no_sound: Boolean, val disabled_mentions: Boolean, - val disabled_mass_mentions: Boolean + val disabled_mass_mentions: Boolean, ) : Parcelable @Parcelize data class CanWrite( - val allowed: Boolean + val allowed: Boolean, ) : Parcelable @Parcelize @@ -89,7 +71,7 @@ data class BaseVkConversation( val is_disappearing: Boolean, val is_service: Boolean, val theme: String?, - val pinned_message: BaseVkMessage? + val pinned_message: BaseVkMessage?, ) : Parcelable { @Parcelize @@ -104,7 +86,7 @@ data class BaseVkConversation( val can_copy_chat: Boolean, val can_call: Boolean, val can_use_mass_mentions: Boolean, - val can_change_style: Boolean + val can_change_style: Boolean, ) : Parcelable @Parcelize @@ -112,21 +94,54 @@ data class BaseVkConversation( val photo_50: String?, val photo_100: String?, val photo_200: String?, - val is_default_photo: Boolean + val is_default_photo: Boolean, ) : Parcelable } @Parcelize data class CallInProgress( val participants: BaseVkGroupCall.Participants, - val join_link: String + val join_link: String, ) : Parcelable { @Parcelize data class Participants( val list: List, - val count: Int + val count: Int, ) : Parcelable } -} \ No newline at end of file + + fun mapToDomain( + lastMessage: VkMessage? = null, + conversationUser: VkUser? = null, + conversationGroup: VkGroup? = null, + ) = VkConversationDomain( + id = peer.id, + localId = peer.local_id, + conversationTitle = chat_settings?.title, + conversationPhoto = chat_settings?.photo?.photo_200, + type = peer.type, + isCallInProgress = call_in_progress != null, + isPhantom = chat_settings?.is_disappearing == true, + lastConversationMessageId = last_conversation_message_id, + inRead = in_read, + outRead = out_read, + lastMessageId = last_message_id, + unreadCount = unread_count ?: 0, + membersCount = chat_settings?.members_count, + ownerId = chat_settings?.owner_id, + majorId = sort_id.major_id, + minorId = sort_id.minor_id, + canChangePin = chat_settings?.acl?.can_change_pin == true, + canChangeInfo = chat_settings?.acl?.can_change_info == true, + pinnedMessageId = chat_settings?.pinned_message?.id, + inReadCmId = in_read_cmid, + outReadCmId = out_read_cmid, + ).also { + it.lastMessage = lastMessage + it.pinnedMessage = chat_settings?.pinned_message?.asVkMessage() + it.conversationUser = conversationUser + it.conversationGroup = conversationGroup + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/domain/VkConversationDomain.kt b/app/src/main/kotlin/com/meloda/fast/api/model/domain/VkConversationDomain.kt new file mode 100644 index 00000000..89f4d731 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/model/domain/VkConversationDomain.kt @@ -0,0 +1,245 @@ +package com.meloda.fast.api.model.domain + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.VkUtils +import com.meloda.fast.api.model.ActionState +import com.meloda.fast.api.model.ConversationPeerType +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkMessage +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.api.model.presentation.VkConversationUi +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.ext.isFalse +import com.meloda.fast.ext.isTrue +import com.meloda.fast.ext.orDots +import com.meloda.fast.model.base.UiImage +import com.meloda.fast.model.base.UiText +import com.meloda.fast.model.base.parseString +import com.meloda.fast.util.TimeUtils +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import java.util.Calendar + +@Suppress("MemberVisibilityCanBePrivate") +@Entity(tableName = "conversations") +@Parcelize +data class VkConversationDomain( + @PrimaryKey(autoGenerate = false) + val id: Int, + val localId: Int, + val ownerId: Int?, + val conversationTitle: String?, + val conversationPhoto: String?, + val isCallInProgress: Boolean, + val isPhantom: Boolean, + val lastConversationMessageId: Int, + val inReadCmId: Int, + val outReadCmId: Int, + val inRead: Int, + val outRead: Int, + val lastMessageId: Int, + val unreadCount: Int, + val membersCount: Int?, + val canChangePin: Boolean, + val canChangeInfo: Boolean, + val majorId: Int, + val minorId: Int, + val pinnedMessageId: Int?, + val type: String, +) : Parcelable { + + @Ignore + @IgnoredOnParcel + var peerType: ConversationPeerType = ConversationPeerType.parse(type) + + @Ignore + @IgnoredOnParcel + var lastMessage: VkMessage? = null + + @Ignore + @IgnoredOnParcel + var pinnedMessage: VkMessage? = null + + @Ignore + @IgnoredOnParcel + var conversationUser: VkUser? = null + + @Ignore + @IgnoredOnParcel + var conversationGroup: VkGroup? = null + + fun isChat() = peerType.isChat() + fun isUser() = peerType.isUser() + fun isGroup() = peerType.isGroup() + + fun isInUnread() = inRead - lastMessageId < 0 + fun isOutUnread() = outRead - lastMessageId < 0 + + fun isUnread() = isInUnread() || isOutUnread() + + fun isAccount() = id == UserConfig.userId + + fun isPinned() = majorId > 0 + + fun extractAvatar(): UiImage { + val placeholderImage = UiImage.Resource(R.drawable.ic_account_circle_cut) + + val avatarLink = when { + peerType.isUser() -> { + if (id == UserConfig.userId) { + null + } else { + conversationUser?.photo200 + } + } + + peerType.isGroup() -> conversationGroup?.photo200 + peerType.isChat() -> conversationPhoto + else -> null + } + + return avatarLink?.let(UiImage::Url) ?: placeholderImage + } + + fun extractTitle(): UiText { + return when { + isAccount() -> UiText.Resource(R.string.favorites) + peerType.isChat() -> UiText.Simple(conversationTitle ?: "...") + peerType.isUser() -> UiText.Simple(conversationUser?.fullName ?: "...") + peerType.isGroup() -> UiText.Simple(conversationGroup?.name ?: "...") + else -> UiText.Simple("...") + } + } + + fun extractUnreadCounterText(): String? { + if (lastMessage?.isOut.isFalse && !isInUnread()) return null + + return when (unreadCount) { + in 1..999 -> unreadCount.toString() + 0 -> null + else -> "%dK".format(unreadCount / 1000) + } + } + + // TODO: 07.01.2023, Danil Nikolaev: rewrite + fun extractMessage(): String { + val actionMessage = VkUtils.getActionConversationText( + message = lastMessage, + youPrefix = "You", + messageUser = lastMessage?.user, + messageGroup = lastMessage?.group, + action = lastMessage?.getPreparedAction(), + actionUser = lastMessage?.actionUser, + actionGroup = lastMessage?.actionGroup + ) + + val attachmentIcon: UiImage? = when { + lastMessage?.text == null -> null + !lastMessage?.forwards.isNullOrEmpty() -> { + if (lastMessage?.forwards?.size == 1) { + UiImage.Resource(R.drawable.ic_attachment_forwarded_message) + } else { + UiImage.Resource(R.drawable.ic_attachment_forwarded_messages) + } + } + + else -> VkUtils.getAttachmentConversationIcon(lastMessage) + } + + val attachmentText = (if (attachmentIcon == null) VkUtils.getAttachmentText( + message = lastMessage + ) else null) + + val forwardsMessage = (if (lastMessage?.text == null) VkUtils.getForwardsText( + message = lastMessage + ) else null) + + val messageText = lastMessage?.text?.let(UiText::Simple) + + var prefix = when { + actionMessage != null -> "" + lastMessage?.isOut.isTrue -> "You: " + else -> + when { + lastMessage?.user != null && lastMessage?.user?.firstName?.isNotBlank().isTrue -> { + "${lastMessage?.user?.firstName}: " + } + + lastMessage?.group != null && lastMessage?.group?.name?.isNotBlank().isTrue -> { + "${lastMessage?.group?.name}: " + } + + else -> "" + } + } + + if ((!peerType.isChat() && lastMessage?.isOut.isFalse) || id == UserConfig.userId) + prefix = "" + + val finalText = + (actionMessage ?: forwardsMessage ?: attachmentText ?: messageText) + ?.parseString(AppGlobal.Instance) + ?.let(VkUtils::prepareMessageText) + ?.let { text -> "$prefix$text" } + + + return finalText.orDots() + } + + fun extractAttachmentImage(): UiImage? { + if (lastMessage?.text == null) return null + return VkUtils.getAttachmentConversationIcon(lastMessage) + } + + fun extractReadCondition(): Boolean { + return (lastMessage?.isOut.isTrue && isOutUnread()) || + (lastMessage?.isOut.isFalse && isInUnread()) + } + + fun extractDate(): String { + return TimeUtils.getLocalizedTime(AppGlobal.Instance, (lastMessage?.date ?: -1) * 1000L) + } + + // TODO: 05.08.2023, Danil Nikolaev: rewrite + fun extractBirthday(): Boolean { + val birthday = conversationUser?.birthday ?: return false + val splitBirthday = birthday.split(".") + + return if (splitBirthday.size > 1) { + val birthdayCalendar = Calendar.getInstance().apply { + this[Calendar.DAY_OF_MONTH] = splitBirthday.first().toIntOrNull() ?: -1 + this[Calendar.MONTH] = (splitBirthday[1].toIntOrNull() ?: 0) - 1 + } + val nowCalendar = Calendar.getInstance() + + (nowCalendar[Calendar.DAY_OF_MONTH] == birthdayCalendar[Calendar.DAY_OF_MONTH] + && nowCalendar[Calendar.MONTH] == birthdayCalendar[Calendar.MONTH]) + } else false + } + + fun mapToPresentation() = VkConversationUi( + conversationId = id, + lastMessageId = lastMessageId, + avatar = extractAvatar(), + title = extractTitle(), + unreadCount = extractUnreadCounterText(), + date = extractDate(), + message = extractMessage(), + attachmentImage = extractAttachmentImage(), + isPinned = majorId > 0, + actionState = ActionState.parse(isPhantom, isCallInProgress), + isBirthday = extractBirthday(), + isUnread = extractReadCondition(), + isAccount = isAccount(), + isOnline = !isAccount() && conversationUser?.online == true, + lastMessage = lastMessage, + conversationUser = conversationUser, + conversationGroup = conversationGroup, + peerType = peerType + ) +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/model/presentation/VkConversationUi.kt b/app/src/main/kotlin/com/meloda/fast/api/model/presentation/VkConversationUi.kt new file mode 100644 index 00000000..2f731153 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/api/model/presentation/VkConversationUi.kt @@ -0,0 +1,33 @@ +package com.meloda.fast.api.model.presentation + +import com.meloda.fast.api.model.ActionState +import com.meloda.fast.api.model.ConversationPeerType +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkMessage +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.model.base.AdapterDiffItem +import com.meloda.fast.model.base.UiImage +import com.meloda.fast.model.base.UiText + +data class VkConversationUi( + val conversationId: Int, + val lastMessageId: Int, + val avatar: UiImage, + val title: UiText, + val unreadCount: String?, + val date: String, + val message: String, + val attachmentImage: UiImage?, + val isPinned: Boolean, + val actionState: ActionState, + val isBirthday: Boolean, + val isUnread: Boolean, + val isAccount: Boolean, + val isOnline: Boolean, + val lastMessage: VkMessage?, + val conversationUser: VkUser?, + val conversationGroup: VkGroup?, + val peerType: ConversationPeerType, +) : AdapterDiffItem { + override val id = conversationId +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt index 9b760e70..057ebb6c 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ApiErrors.kt @@ -43,9 +43,10 @@ object VkErrorCodes { const val InvalidDocId = 1150 const val InvalidDocTitle = 1152 const val AccessToDocDenied = 1153 + + const val AccessTokenExpired = 1117 } -@Suppress("unused") object VkErrors { const val Unknown = "unknown_error" @@ -55,7 +56,18 @@ object VkErrors { } -class AuthorizationError : ApiError() +object VkErrorTypes { + const val OtpFormatIncorrect = "otp_format_is_incorrect" + const val WrongOtp = "wrong_otp" +} + +object VkErrorMessages { + const val UserBanned = "user has been banned" +} + +open class AuthorizationError : ApiError() + +class TokenExpiredError : AuthorizationError() data class ValidationRequiredError( @SerializedName("validation_type") @@ -75,4 +87,24 @@ data class CaptchaRequiredError( val captchaSid: String, @SerializedName("captcha_img") val captchaImg: String -) : ApiError() \ No newline at end of file +) : ApiError() + +object WrongTwoFaCodeFormatError : ApiError() + +object WrongTwoFaCodeError : ApiError() + +data class UserBannedError( + @SerializedName("ban_info") + val banInfo: BanInfo +) : ApiError() { + + data class BanInfo( + @SerializedName("member_name") + val memberName: String, + val message: String, + @SerializedName("access_token") + val accessToken: String, + @SerializedName("restore_url") + val restoreUrl: String + ) +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt b/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt index 1c740012..32c4ca2f 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/AuthInterceptor.kt @@ -3,6 +3,7 @@ package com.meloda.fast.api.network import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants import com.meloda.fast.api.network.account.AccountUrls +import com.meloda.fast.api.network.ota.OtaUrls import okhttp3.Interceptor import okhttp3.Response import java.net.URLEncoder @@ -14,7 +15,7 @@ class AuthInterceptor : Interceptor { val url = builder.build().toUrl().toString() - if (!url.contains("upload.php")) { + if (!url.contains("upload.php") && !url.contains(OtaUrls.GetActualUrl)) { builder.addQueryParameter("v", URLEncoder.encode(VKConstants.API_VERSION, "utf-8")) } diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt b/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt index db06da8d..2cde43e2 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/ResultCallFactory.kt @@ -14,11 +14,11 @@ import java.lang.reflect.Type import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract -class ResultCallFactory : CallAdapter.Factory() { +class ResultCallFactory(private val gson: Gson) : CallAdapter.Factory() { override fun get( returnType: Type, annotations: Array, - retrofit: Retrofit + retrofit: Retrofit, ): CallAdapter<*, *>? { val rawReturnType: Class<*> = getRawType(returnType) if (rawReturnType == Call::class.java) { @@ -27,9 +27,9 @@ class ResultCallFactory : CallAdapter.Factory() { if (getRawType(callInnerType) == ApiAnswer::class.java) { if (callInnerType is ParameterizedType) { val resultInnerType = getParameterUpperBound(0, callInnerType) - return ResultCallAdapter(resultInnerType) + return ResultCallAdapter(resultInnerType, gson) } - return ResultCallAdapter(Nothing::class.java) + return ResultCallAdapter(Nothing::class.java, gson) } } } @@ -58,30 +58,29 @@ internal abstract class CallDelegate(protected val proxy: Call) : C abstract fun cloneImpl(): Call } -private class ResultCallAdapter(private val type: Type) : CallAdapter>> { +private class ResultCallAdapter(private val type: Type, private val gson: Gson) : CallAdapter>> { override fun responseType() = type - override fun adapt(call: Call): Call> = ResultCall(call) + override fun adapt(call: Call): Call> = ResultCall(call, gson) } -internal class ResultCall(proxy: Call) : CallDelegate>(proxy) { +internal class ResultCall(proxy: Call, private val gson: Gson) : CallDelegate>(proxy) { override fun enqueueImpl(callback: Callback>) { - proxy.enqueue(ResultCallback(this, callback)) + proxy.enqueue(ResultCallback(this, callback, gson)) } override fun cloneImpl(): ResultCall { - return ResultCall(proxy.clone()) + return ResultCall(proxy.clone(), gson) } private class ResultCallback( private val proxy: ResultCall, - private val callback: Callback> + private val callback: Callback>, + private val gson: Gson ) : Callback { - val gson = Gson() - override fun onResponse(call: Call, response: Response) { val result: ApiAnswer = if (response.isSuccessful) { @@ -117,13 +116,11 @@ internal class ResultCall(proxy: Call) : CallDelegate>(pro } private fun checkErrors(call: Call, result: ApiAnswer<*>): Boolean { - if (!result.isSuccessful()) { + if (result.isError()) { result.error.throwable?.run { onFailure(call, this) return true } - } else { - return false } return false @@ -143,8 +140,16 @@ sealed class ApiAnswer { @OptIn(ExperimentalContracts::class) fun isSuccessful(): Boolean { contract { - returns(false) implies (this@ApiAnswer is Error) + returns(true) implies (this@ApiAnswer is Success) } return this is Success } -} \ No newline at end of file + + @OptIn(ExperimentalContracts::class) + fun isError(): Boolean { + contract { + returns(true) implies (this@ApiAnswer is Error) + } + return this is Error + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/VkUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/VkUrls.kt index dcbcb39c..a38e540e 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/VkUrls.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/VkUrls.kt @@ -4,37 +4,6 @@ object VkUrls { const val OAUTH = "https://oauth.vk.com" const val API = "https://api.vk.com/method" - - object Auth { - const val DirectAuth = "$OAUTH/token" - const val SendSms = "$API/auth.validatePhone" - } - - object Conversations { - const val Get = "$API/messages.getConversations" - const val Delete = "$API/messages.deleteConversation" - const val Pin = "$API/messages.pinConversation" - const val Unpin = "$API/messages.unpinConversation" - const val ReorderPinned = "$API/messages.reorderPinnedConversations" - } - - object Users { - const val GetById = "$API/users.get" - } - - object Messages { - const val GetHistory = "$API/messages.getHistory" - const val Send = "$API/messages.send" - const val MarkAsImportant = "$API/messages.markAsImportant" - const val GetLongPollServer = "$API/messages.getLongPollServer" - const val GetLongPollHistory = "$API/messages.getLongPollHistory" - const val Pin = "$API/messages.pin" - const val Unpin = "$API/messages.unpin" - const val Delete = "$API/messages.delete" - const val Edit = "$API/messages.edit" - } - - } diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthResponse.kt b/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthResponse.kt index 6e66db70..f206f415 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthResponse.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/auth/AuthResponse.kt @@ -6,10 +6,15 @@ import kotlinx.parcelize.Parcelize @Parcelize data class AuthDirectResponse( - @SerializedName("access_token") val accessToken: String? = null, - @SerializedName("user_id") val userId: Int? = null, - @SerializedName("trusted_hash") val twoFaHash: String? = null, - @SerializedName("validation_sid") val validationSid: String? = null + @SerializedName("access_token") val accessToken: String?, + @SerializedName("user_id") val userId: Int?, + @SerializedName("trusted_hash") val twoFaHash: String?, + @SerializedName("validation_sid") val validationSid: String?, + @SerializedName("validation_type") val validationType: String?, + @SerializedName("phone_mask") val phoneMask: String?, + @SerializedName("redirect_uri") val redirectUrl: String?, + @SerializedName("validation_resend") val validationResend: String?, + @SerializedName("cant_get_code_open_restore") val isCanNotGetCodeNeedToOpenRestore: Boolean ) : Parcelable @Parcelize @@ -18,4 +23,4 @@ data class SendSmsResponse( @SerializedName("delay") val delay: Int?, @SerializedName("validation_type") val validationType: String?, @SerializedName("validation_resend") val validationResend: String? -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsResponse.kt b/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsResponse.kt index c49d24e1..c8f461a4 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsResponse.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/conversations/ConversationsResponse.kt @@ -2,7 +2,7 @@ package com.meloda.fast.api.network.conversations import android.os.Parcelable import com.google.gson.annotations.SerializedName -import com.meloda.fast.api.model.base.BaseVkConversation +import com.meloda.fast.api.model.data.BaseVkConversation import com.meloda.fast.api.model.base.BaseVkGroup import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.api.model.base.BaseVkUser @@ -23,4 +23,4 @@ data class ConversationsResponseItems( val conversation: BaseVkConversation, @SerializedName("last_message") val lastMessage: BaseVkMessage? -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt index 5e619b27..2c71ae35 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesRequest.kt @@ -189,5 +189,51 @@ data class MessagesGetByIdRequest( extended?.let { this["extended"] = it.intString } fields?.let { this["fields"] = it } } +} +@Parcelize +data class MessagesGetChatRequest( + val chatId: Int, + val fields: String? = null +) : Parcelable { + + val map + get() = mutableMapOf( + "chat_id" to chatId.toString() + ).apply { + fields?.let { this["fields"] = it } + } +} + +@Parcelize +data class MessagesGetConversationMembersRequest( + val peerId: Int, + val offset: Int? = null, + val count: Int? = null, + val extended: Boolean? = null, + val fields: String? = null +) : Parcelable { + + val map + get() = mutableMapOf( + "peer_id" to peerId.toString() + ).apply { + offset?.let { this["offset"] = it.toString() } + count?.let { this["count"] = it.toString() } + extended?.let { this["extended"] = it.toString() } + fields?.let { this["fields"] = it } + } + +} + +@Parcelize +data class MessagesRemoveChatUserRequest( + val chatId: Int, + val memberId: Int +) : Parcelable { + val map + get() = mutableMapOf( + "chat_id" to chatId.toString(), + "member_id" to memberId.toString() + ) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesResponse.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesResponse.kt index b881d145..86dd9a6a 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesResponse.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesResponse.kt @@ -1,10 +1,8 @@ package com.meloda.fast.api.network.messages import android.os.Parcelable -import com.meloda.fast.api.model.base.BaseVkConversation -import com.meloda.fast.api.model.base.BaseVkGroup -import com.meloda.fast.api.model.base.BaseVkMessage -import com.meloda.fast.api.model.base.BaseVkUser +import com.meloda.fast.api.model.base.* +import com.meloda.fast.api.model.data.BaseVkConversation import kotlinx.parcelize.Parcelize @Parcelize @@ -22,4 +20,12 @@ data class MessagesGetByIdResponse( val items: List = emptyList(), val profiles: List?, val groups: List? -) : Parcelable \ No newline at end of file +) : Parcelable + +@Parcelize +data class MessagesGetConversationMembersResponse( + val count: Int, + val items: List = emptyList(), + val profiles: List?, + val groups: List? +) : Parcelable diff --git a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt index 66c3acd2..829c76c6 100644 --- a/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt +++ b/app/src/main/kotlin/com/meloda/fast/api/network/messages/MessagesUrls.kt @@ -15,5 +15,8 @@ object MessagesUrls { const val Edit = "${VkUrls.API}/messages.edit" const val GetById = "${VkUrls.API}/messages.getById" const val MarkAsRead = "${VkUrls.API}/messages.markAsRead" + const val GetChat = "${VkUrls.API}/messages.getChat" + const val GetConversationMembers = "${VkUrls.API}/messages.getConversationMembers" + const val RemoveChatUser = "${VkUrls.API}/messages.removeChatUser" } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/BaseActivity.kt b/app/src/main/kotlin/com/meloda/fast/base/BaseActivity.kt index dba3f764..3113c485 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/BaseActivity.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/BaseActivity.kt @@ -8,5 +8,4 @@ abstract class BaseActivity : AppCompatActivity { constructor() : super() constructor(@LayoutRes resId: Int) : super(resId) - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt index 867e7485..20d217db 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/BaseFragment.kt @@ -1,47 +1,11 @@ package com.meloda.fast.base -import android.os.Bundle -import android.view.View import androidx.annotation.LayoutRes import androidx.fragment.app.Fragment -import com.meloda.fast.screens.main.MainActivity abstract class BaseFragment : Fragment { constructor() : super() constructor(@LayoutRes resId: Int) : super(resId) - - protected var shouldNavBarShown: Boolean = true - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - if (arguments == null) arguments = Bundle() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - (requireActivity() as? MainActivity)?.run { - toggleNavBarVisibility(shouldNavBarShown) - } - } - - val activityRouter - get() = run { - if (requireActivity() is MainActivity) { - (requireActivity() as MainActivity).router - } else { - null - } - } - - fun requireActivityRouter() = requireNotNull(activityRouter) - - fun startProgress() = toggleProgress(true) - fun stopProgress() = toggleProgress(false) - - protected open fun toggleProgress(isProgressing: Boolean) {} - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/AsyncDiffItemAdapter.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/AsyncDiffItemAdapter.kt new file mode 100644 index 00000000..9167b253 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/AsyncDiffItemAdapter.kt @@ -0,0 +1,52 @@ +package com.meloda.fast.base.adapter + +import androidx.recyclerview.widget.DiffUtil +import com.hannesdorfmann.adapterdelegates4.AdapterDelegate +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import com.meloda.fast.model.base.AdapterDiffItem + +class AsyncDiffItemAdapter( + customDiffCallback: DiffUtil.ItemCallback? = null, + vararg delegates: AdapterDelegate>, +) : AsyncListDifferDelegationAdapter(customDiffCallback ?: DIFF_CALLBACK) { + + constructor( + vararg delegates: AdapterDelegate>, + ) : this(customDiffCallback = null) { + delegates.forEach(::addDelegate) + } + + init { + delegates.forEach(::addDelegate) + } + + fun addDelegates(vararg delegates: AdapterDelegate>) { + delegates.forEach(::addDelegate) + } + + @Suppress("UNCHECKED_CAST") + fun addDelegate(delegate: AdapterDelegate>) { + (delegate as? AdapterDelegate>)?.let(delegatesManager::addDelegate) + } + + fun isEmpty() = itemCount == 0 + fun isNotEmpty() = itemCount > 0 + + companion object { + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: AdapterDiffItem, + newItem: AdapterDiffItem, + ): Boolean { + return oldItem.areItemsTheSame(newItem) + } + + override fun areContentsTheSame( + oldItem: AdapterDiffItem, + newItem: AdapterDiffItem, + ): Boolean { + return oldItem.areContentsTheSame(newItem) + } + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt index 0aea22ec..22ac45b8 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/BaseAdapter.kt @@ -9,12 +9,11 @@ import android.widget.Filter import android.widget.Filterable import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter -import com.meloda.fast.model.DataItem import kotlinx.coroutines.* import kotlin.properties.Delegates @Suppress("MemberVisibilityCanBePrivate", "unused", "UNCHECKED_CAST") -abstract class BaseAdapter, VH : BaseHolder> constructor( +abstract class BaseAdapter constructor( var context: Context, diffUtil: DiffUtil.ItemCallback, preAddedValues: List = emptyList(), @@ -59,27 +58,18 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( fun add( item: T, position: Int? = null, - beforeFooter: Boolean = false, commitCallback: (() -> Unit)? = null - ) = addAll(listOf(item), position, beforeFooter, commitCallback) + ) = addAll(listOf(item), position, commitCallback) fun addAll( items: List, position: Int? = null, - beforeFooter: Boolean = false, commitCallback: (() -> Unit)? = null ) { adapterScope.launch { val newList = cloneCurrentList() if (position == null) { val mutableItems = items.toMutableList() - if (beforeFooter && newList.lastOrNull() is DataItem.Footer) { - newList.removeLastOrNull() - } - - if (beforeFooter) { - mutableItems += DataItem.Footer as T - } newList.addAll(mutableItems) cleanList.addAll(mutableItems) @@ -100,40 +90,34 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( fun removeAll(items: List, commitCallback: (() -> Unit)? = null) { val newList = cloneCurrentList() newList.removeAll(items) - submitList(newList, commitCallback) - cleanList.removeAll(items) + + submitList(newList, commitCallback) } fun removeAt(index: Int, commitCallback: (() -> Unit)? = null) { val newList = cloneCurrentList() newList.removeAt(index) - submitList(newList, commitCallback) - cleanList.removeAt(index) + + submitList(newList, commitCallback) } fun clear(commitCallback: (() -> Unit)? = null) = removeAll(currentList, commitCallback) fun setItem( item: T, - withHeader: Boolean = false, - withFooter: Boolean = false, commitCallback: (() -> Unit)? = null - ) = setItems(listOf(item), withHeader, withFooter, commitCallback) + ) = setItems(listOf(item), commitCallback) @Suppress("UNCHECKED_CAST") fun setItems( list: List?, - withHeader: Boolean = false, - withFooter: Boolean = false, commitCallback: (() -> Unit)? = null ) { adapterScope.launch { val items = mutableListOf() - if (withHeader) items.add(DataItem.Header as T) if (!list.isNullOrEmpty()) items.addAll(list) - if (withFooter) items.add(DataItem.Footer as T) withContext(Dispatchers.Main) { if (items == currentList) { @@ -165,9 +149,9 @@ abstract class BaseAdapter, VH : BaseHolder> constructor( fun setItem(position: Int, item: T, commitCallback: (() -> Unit)? = null) { val newList = cloneCurrentList() newList[position] = item - submitList(newList, commitCallback) - cleanList[position] = item + + submitList(newList, commitCallback) } fun isEmpty() = currentList.isEmpty() diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/EmptyHeaderAdapter.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/EmptyHeaderAdapter.kt deleted file mode 100644 index 7fa4b4ac..00000000 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/EmptyHeaderAdapter.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.meloda.fast.base.adapter - -import android.content.Context -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isInvisible -import androidx.recyclerview.widget.RecyclerView -import com.meloda.fast.extensions.dpToPx -import com.meloda.fast.util.AndroidUtils -import kotlin.math.roundToInt - -class EmptyHeaderAdapter( - var context: Context -) : RecyclerView.Adapter() { - - inner class Holder(itemView: View) : RecyclerView.ViewHolder(itemView) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Holder(generateHeaderView()) - - override fun onBindViewHolder(holder: Holder, position: Int) { - } - - override fun getItemCount() = 1 - - private fun generateHeaderView() = View(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - 56.dpToPx() - ) - isClickable = false - isEnabled = false - isFocusable = false - isInvisible = true - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/base/adapter/Listeners.kt b/app/src/main/kotlin/com/meloda/fast/base/adapter/Listeners.kt index 5fdbccb7..204ec669 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/adapter/Listeners.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/adapter/Listeners.kt @@ -1,9 +1,9 @@ package com.meloda.fast.base.adapter -interface OnItemClickListener { - fun onItemClick(position: Int) +fun interface OnItemClickListener { + fun onItemClick(item: T) } -interface OnItemLongClickListener { - fun onItemLongClick(position: Int) -} \ No newline at end of file +fun interface OnItemLongClickListener { + fun onLongItemClick(item: T): Boolean +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/screen/AppScreen.kt b/app/src/main/kotlin/com/meloda/fast/base/screen/AppScreen.kt new file mode 100644 index 00000000..2fd269af --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/base/screen/AppScreen.kt @@ -0,0 +1,23 @@ +package com.meloda.fast.base.screen + +import com.github.terrakok.cicerone.Router +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow + +interface AppScreen { + val resultFlow: MutableSharedFlow + + var args: ArgType + + fun show(router: Router, args: ArgType) + + fun getArguments(): ArgType = args +} + +@Suppress("unused") +fun AppScreen.createResultFlow(): MutableSharedFlow { + return MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt index 7388207c..9509b8c4 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModel.kt @@ -1,110 +1,97 @@ package com.meloda.fast.base.viewmodel import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.meloda.fast.api.base.ApiError -import com.meloda.fast.api.network.ApiAnswer -import com.meloda.fast.api.network.AuthorizationError -import com.meloda.fast.api.network.CaptchaRequiredError -import com.meloda.fast.api.network.ValidationRequiredError -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch +import com.meloda.fast.api.network.* +import com.meloda.fast.ext.isTrue +import com.meloda.fast.ext.notNull -@Suppress("MemberVisibilityCanBePrivate") abstract class BaseViewModel : ViewModel() { - var unknownErrorDefaultText: String = "" + open suspend fun sendSingleEvent(event: VkEvent) {} - protected val tasksEventChannel = Channel() - val tasksEvent = tasksEventChannel.receiveAsFlow() + suspend fun sendRequestNotNull( + onError: ErrorHandler? = null, + request: suspend () -> ApiAnswer + ): T = sendRequest(onError, request).notNull() - protected val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - viewModelScope.launch { onException(throwable) } - } - - fun launch(block: suspend CoroutineScope.() -> Unit): Job { - return viewModelScope.launch(exceptionHandler, block = block) - } - - protected suspend fun makeSuspendJob( - job: suspend () -> ApiAnswer, onAnswer: suspend (T) -> Unit = {}, - onStart: (suspend () -> Unit)? = null, - onEnd: (suspend () -> Unit)? = null, - onError: (suspend (Throwable) -> Unit)? = null - ): ApiAnswer { - onStart?.invoke() ?: onStart() - val response = job() - - when (response) { - is ApiAnswer.Success -> onAnswer(response.data) + suspend fun sendRequest( + onError: ErrorHandler? = null, + request: suspend () -> ApiAnswer, + ): T? { + return when (val response = request()) { + is ApiAnswer.Success -> response.data is ApiAnswer.Error -> { - onError?.invoke(response.error) ?: checkErrors(response.error) - } - } + val error = response.error - onEnd?.invoke() + if (!onError?.handleError(error).isTrue) { + checkErrors(error) + } - return response - } - - protected fun makeJob( - job: suspend () -> ApiAnswer, - onAnswer: suspend (T) -> Unit = {}, - onStart: (suspend () -> Unit)? = null, - onEnd: (suspend () -> Unit)? = null, - onError: (suspend (Throwable) -> Unit)? = null - ): Job = viewModelScope.launch { - onStart?.invoke() ?: onStart() - when (val response = job()) { - is ApiAnswer.Success -> onAnswer(response.data) - is ApiAnswer.Error -> { - onError?.invoke(response.error) ?: checkErrors(response.error) - } - } - }.also { - it.invokeOnCompletion { - viewModelScope.launch { - onEnd?.invoke() ?: onStop() + null } } } - protected open suspend fun onException(throwable: Throwable) { - checkErrors(throwable) - } - - protected suspend fun onStart() { - sendEvent(StartProgressEvent) - } - - protected suspend fun onStop() { - sendEvent(StopProgressEvent) - } - - protected suspend fun sendEvent(event: T) = tasksEventChannel.send(event) - protected suspend fun checkErrors(throwable: Throwable) { when (throwable) { + is TokenExpiredError -> { + sendSingleEvent(TokenExpiredErrorEvent) + } is AuthorizationError -> { - sendEvent(AuthorizationErrorEvent) + sendSingleEvent(AuthorizationErrorEvent) + } + is UserBannedError -> { + throwable.banInfo.let { banInfo -> + sendSingleEvent( + UserBannedEvent( + memberName = banInfo.memberName, + message = banInfo.message, + restoreUrl = banInfo.restoreUrl, + accessToken = banInfo.accessToken + ) + ) + } } is ValidationRequiredError -> { - sendEvent(ValidationRequiredEvent(throwable.validationSid)) + sendSingleEvent( + ValidationRequiredEvent( + sid = throwable.validationSid, + redirectUri = throwable.redirectUri, + phoneMask = throwable.phoneMask, + validationType = throwable.validationType, + canResendSms = throwable.validationResend == "sms", + codeError = null + ) + ) } is CaptchaRequiredError -> { - sendEvent(CaptchaRequiredEvent(throwable.captchaSid, throwable.captchaImg)) + sendSingleEvent( + CaptchaRequiredEvent( + sid = throwable.captchaSid, + image = throwable.captchaImg + ) + ) } + is ApiError -> { - sendEvent(ErrorTextEvent(errorText = throwable.errorMessage ?: unknownErrorDefaultText)) + sendSingleEvent( + if (throwable.errorMessage == null) { + UnknownErrorEvent + } else { + ErrorTextEvent(errorText = requireNotNull(throwable.errorMessage)) + } + ) } else -> { - sendEvent(ErrorTextEvent(throwable.message ?: unknownErrorDefaultText)) + sendSingleEvent( + if (throwable.message == null) { + UnknownErrorEvent + } else { + ErrorTextEvent(requireNotNull(throwable.message)) + } + ) } } } - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt index de979b7b..cb9f8b38 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/BaseViewModelFragment.kt @@ -5,10 +5,10 @@ import android.view.View import androidx.annotation.LayoutRes import androidx.lifecycle.lifecycleScope import com.meloda.fast.base.BaseFragment -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -abstract class BaseViewModelFragment : BaseFragment { +@Deprecated("", ReplaceWith("BaseFragment")) +abstract class BaseViewModelFragment : BaseFragment { constructor() : super() @@ -25,10 +25,10 @@ abstract class BaseViewModelFragment : BaseFragment { ViewModelUtils.parseEvent(this, event) } - protected fun subscribeToViewModel(viewModel: T) { + protected fun subscribeToViewModel(viewModel: T) { lifecycleScope.launch { viewModel.tasksEvent.collect { onEvent(it) } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/DeprecatedBaseViewModel.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/DeprecatedBaseViewModel.kt new file mode 100644 index 00000000..b88206ac --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/DeprecatedBaseViewModel.kt @@ -0,0 +1,139 @@ +package com.meloda.fast.base.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.meloda.fast.api.base.ApiError +import com.meloda.fast.api.network.* +import com.meloda.fast.ext.isTrue +import com.meloda.fast.ext.notNull +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +@Deprecated("rewrite") +abstract class DeprecatedBaseViewModel : ViewModel() { + + private val tasksEventChannel = Channel() + val tasksEvent = tasksEventChannel.receiveAsFlow() + + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + viewModelScope.launch { onException(throwable) } + } + + fun launch(block: suspend CoroutineScope.() -> Unit): Job { + return viewModelScope.launch(exceptionHandler, block = block) + } + + suspend fun sendRequestNotNull( + onError: ErrorHandler? = null, + request: suspend () -> ApiAnswer + ): T = sendRequest(onError, request).notNull() + + suspend fun sendRequest( + onError: ErrorHandler? = null, + request: suspend () -> ApiAnswer, + ): T? { + return when (val response = request()) { + is ApiAnswer.Success -> response.data + is ApiAnswer.Error -> { + val error = response.error + + if (!onError?.handleError(error).isTrue) { + checkErrors(error) + } + + null + } + } + } + + // TODO: 05.04.2023, Danil Nikolaev: переписать makeJob на sendRequest (oh boy, писать дохуя) + // TODO: 05.04.2023, Danil Nikolaev: переписать Conversations Screen на новую архитектуру, пока что оставить View + + protected fun makeJob( + job: suspend () -> ApiAnswer, + onAnswer: suspend (T) -> Unit = {}, + onStart: (suspend () -> Unit)? = null, + onEnd: (suspend () -> Unit)? = null, + onError: (suspend (Throwable) -> Unit)? = null, + onAnyResult: (suspend () -> Unit)? = null, + ): Job = viewModelScope.launch { + onStart?.invoke() + when (val response = job()) { + is ApiAnswer.Success -> { + onAnswer(response.data) + onAnyResult?.invoke() + } + is ApiAnswer.Error -> { + onError?.invoke(response.error) ?: checkErrors(response.error) + onAnyResult?.invoke() + } + } + }.also { + it.invokeOnCompletion { + viewModelScope.launch { + onEnd?.invoke() + } + } + } + + protected open suspend fun onException(throwable: Throwable) { + checkErrors(throwable) + } + + protected suspend fun sendEvent(event: T) = tasksEventChannel.send(event) + + protected suspend fun checkErrors(throwable: Throwable) { + when (throwable) { + is TokenExpiredError -> sendEvent(TokenExpiredErrorEvent) + is AuthorizationError -> sendEvent(AuthorizationErrorEvent) + is UserBannedError -> { + val banInfo = throwable.banInfo + sendEvent( + UserBannedEvent( + memberName = banInfo.memberName, + message = banInfo.message, + restoreUrl = banInfo.restoreUrl, + accessToken = banInfo.accessToken + ) + ) + } + is ValidationRequiredError -> { + sendEvent( + ValidationRequiredEvent( + sid = throwable.validationSid, + redirectUri = throwable.redirectUri, + phoneMask = throwable.phoneMask, + validationType = throwable.validationType, + canResendSms = throwable.validationResend == "sms", + codeError = null + ) + ) + } + is CaptchaRequiredError -> sendEvent( + CaptchaRequiredEvent( + sid = throwable.captchaSid, + image = throwable.captchaImg + ) + ) + + is ApiError -> sendEvent( + if (throwable.errorMessage == null) { + UnknownErrorEvent + } else { + ErrorTextEvent(errorText = requireNotNull(throwable.errorMessage)) + } + ) + else -> sendEvent( + if (throwable.message == null) { + UnknownErrorEvent + } else { + ErrorTextEvent(requireNotNull(throwable.message)) + } + ) + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ErrorHandler.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ErrorHandler.kt new file mode 100644 index 00000000..aa702965 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ErrorHandler.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.base.viewmodel + +fun interface ErrorHandler { + + /** + * @return true if error has been handled manually + */ + suspend fun handleError(error: Throwable): Boolean +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt index b958c069..02acae53 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/Events.kt @@ -1,18 +1,30 @@ package com.meloda.fast.base.viewmodel -abstract class VkEvent -abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent() -abstract class VkProgressEvent : VkEvent() +import com.meloda.fast.model.base.UiText +abstract class VkEvent + +abstract class VkErrorEvent(open val errorText: String? = null) : VkEvent() + +object UnknownErrorEvent : VkErrorEvent() open class ErrorTextEvent(override val errorText: String) : VkErrorEvent() object AuthorizationErrorEvent : VkErrorEvent() +object TokenExpiredErrorEvent : VkErrorEvent() data class CaptchaRequiredEvent(val sid: String, val image: String) : VkErrorEvent() -data class ValidationRequiredEvent(val sid: String) : VkErrorEvent() +data class ValidationRequiredEvent( + val sid: String, + val redirectUri: String, + val phoneMask: String, + val validationType: String, + val canResendSms: Boolean, + val codeError: UiText? +) : VkErrorEvent() -object StartProgressEvent : VkProgressEvent() -object StopProgressEvent : VkProgressEvent() +data class UserBannedEvent( + val memberName: String, val message: String, val restoreUrl: String, val accessToken: String, +) : VkErrorEvent() -interface VkEventCallback { +fun interface VkEventCallback { fun onEvent(event: T) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt index 3bf4c2cc..716c143f 100644 --- a/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/base/viewmodel/ViewModelUtils.kt @@ -6,12 +6,13 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.meloda.fast.R import com.meloda.fast.api.UserConfig -import com.meloda.fast.base.BaseFragment -import com.meloda.fast.screens.main.MainActivity -import com.meloda.fast.util.ViewUtils.showErrorDialog +import com.meloda.fast.ext.showDialog +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.main.activity.MainActivity object ViewModelUtils { + @Deprecated("rewrite") @Suppress("MemberVisibilityCanBePrivate") fun parseEvent(activity: FragmentActivity, event: VkEvent) { when (event) { @@ -24,26 +25,47 @@ object ViewModelUtils { activity.finishAffinity() activity.startActivity(Intent(activity, MainActivity::class.java)) } + is TokenExpiredErrorEvent -> { + Toast.makeText( + activity, R.string.token_expired, Toast.LENGTH_LONG + ).show() + UserConfig.clear() + activity.finishAffinity() + activity.startActivity(Intent(activity, MainActivity::class.java)) + } + is UserBannedEvent -> { + // TODO: 17.04.2023, Danil Nikolaev: handle banned event +// (activity as? MainActivity)?.accessRouter()?.newRootScreen( +// Screens.UserBanned( +// memberName = event.memberName, +// message = event.message, +// restoreUrl = event.restoreUrl, +// accessToken = event.accessToken +// ) +// ) + } + is UnknownErrorEvent -> { + activity.showDialog( + title = UiText.Resource(R.string.title_error), + message = UiText.Resource(R.string.unknown_error_occurred), + positiveText = UiText.Resource(R.string.ok) + ) + } is VkErrorEvent -> { event.errorText?.run { - activity.showErrorDialog(this) + activity.showDialog( + title = UiText.Resource(R.string.title_error), + message = UiText.Simple(this), + positiveText = UiText.Resource(R.string.ok) + ) } } } } + @Deprecated("rewrite") fun parseEvent(fragment: Fragment, event: VkEvent) { - if (event is VkProgressEvent) { - if (fragment is BaseFragment) { - if (event is StartProgressEvent) { - fragment.startProgress() - } else if (event is StopProgressEvent) { - fragment.stopProgress() - } - } - } else { - parseEvent(fragment.requireActivity(), event) - } + parseEvent(fragment.requireActivity(), event) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt b/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt index 5b821eb1..e8c3b4f8 100644 --- a/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt +++ b/app/src/main/kotlin/com/meloda/fast/common/AppGlobal.kt @@ -1,94 +1,83 @@ package com.meloda.fast.common import android.app.Application -import android.app.DownloadManager -import android.content.ClipboardManager import android.content.Context import android.content.SharedPreferences import android.content.pm.PackageManager import android.content.res.Resources -import android.net.ConnectivityManager -import android.util.Log -import android.view.inputmethod.InputMethodManager +import android.media.AudioManager +import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.pm.PackageInfoCompat import androidx.preference.PreferenceManager -import androidx.room.Room -import com.meloda.fast.database.AppDatabase -import dagger.hilt.android.HiltAndroidApp +import com.google.android.material.color.DynamicColors +import com.meloda.fast.common.di.applicationModule +import com.meloda.fast.screens.settings.SettingsFragment +import com.meloda.fast.util.AndroidUtils +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.GlobalContext.startKoin import kotlin.math.roundToInt -import kotlin.math.sqrt +import kotlin.properties.Delegates -@HiltAndroidApp class AppGlobal : Application() { - companion object { - - lateinit var inputMethodManager: InputMethodManager - lateinit var connectivityManager: ConnectivityManager - lateinit var clipboardManager: ClipboardManager - lateinit var downloadManager: DownloadManager - - lateinit var preferences: SharedPreferences - lateinit var resources: Resources - lateinit var packageName: String - private lateinit var instance: AppGlobal - - lateinit var appDatabase: AppDatabase - - lateinit var packageManager: PackageManager - - var versionName = "" - var versionCode = 0 - - var screenWidth = 0 - var screenHeight = 0 - - var screenWidth80 = 0 - - val Instance get() = instance - } - override fun onCreate() { super.onCreate() + instance = this - appDatabase = Room.databaseBuilder(this, AppDatabase::class.java, "cache") -// .fallbackToDestructiveMigration() - .build() - - preferences = PreferenceManager.getDefaultSharedPreferences(this) + if (preferences.getBoolean( + SettingsFragment.KEY_USE_DYNAMIC_COLORS, + SettingsFragment.DEFAULT_VALUE_USE_DYNAMIC_COLORS + ) + ) { + DynamicColors.applyToActivitiesIfAvailable(this) + } val info = packageManager.getPackageInfo(this.packageName, PackageManager.GET_ACTIVITIES) versionName = info.versionName versionCode = PackageInfoCompat.getLongVersionCode(info).toInt() - Companion.resources = resources - Companion.packageName = packageName - Companion.packageManager = packageManager + screenWidth80 = (AndroidUtils.getDisplayWidth() * 0.8).roundToInt() - screenWidth = resources.displayMetrics.widthPixels - screenHeight = resources.displayMetrics.heightPixels + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - screenWidth80 = (screenWidth * 0.8).roundToInt() + applyDarkTheme() - val density = resources.displayMetrics.density - val densityDpi = resources.displayMetrics.densityDpi - val densityScaled = resources.displayMetrics.scaledDensity - val xDpi = resources.displayMetrics.xdpi - val yDpi = resources.displayMetrics.ydpi - - val diagonal = sqrt( - (screenWidth * screenWidth - screenHeight * screenHeight).toFloat() - ) - - Log.i( - "Fast::DeviceInfo", - "width: $screenWidth; 70% width: $screenWidth80; height: $screenHeight; density: $density; diagonal: $diagonal; dpiDensity: $densityDpi; scaledDensity: $densityScaled; xDpi: $xDpi; yDpi: $yDpi" - ) - - inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + initKoin() } -} \ No newline at end of file + + private fun applyDarkTheme() { + val nightMode = preferences.getInt( + SettingsFragment.KEY_APPEARANCE_DARK_THEME, + SettingsFragment.DEFAULT_VALUE_APPEARANCE_DARK_THEME + ) + AppCompatDelegate.setDefaultNightMode(nightMode) + } + + private fun initKoin() { + startKoin { + androidLogger() + androidContext(this@AppGlobal) + modules(applicationModule) + } + } + + companion object { + private lateinit var instance: AppGlobal + + val preferences: SharedPreferences by lazy { + PreferenceManager.getDefaultSharedPreferences(instance) + } + + var versionName = "" + var versionCode = 0 + var screenWidth80 = 0 + + val Instance: AppGlobal get() = instance + val resources: Resources get() = Instance.resources + val packageManager: PackageManager get() = Instance.packageManager + + var audioManager: AudioManager by Delegates.notNull() + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt b/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt deleted file mode 100644 index 4f0bcc26..00000000 --- a/app/src/main/kotlin/com/meloda/fast/common/AppSettings.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.meloda.fast.common - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job - - -object AppSettings { - - val keyUseNavigationDrawer = booleanPreferencesKey("use_nav_drawer") - -} - -val Context.dataStore: DataStore by preferencesDataStore( - name = "settings", - corruptionHandler = null, - scope = CoroutineScope(Dispatchers.IO + Job()) -) - diff --git a/app/src/main/kotlin/com/meloda/fast/common/Screens.kt b/app/src/main/kotlin/com/meloda/fast/common/Screens.kt index f2411903..b0719a89 100644 --- a/app/src/main/kotlin/com/meloda/fast/common/Screens.kt +++ b/app/src/main/kotlin/com/meloda/fast/common/Screens.kt @@ -1,39 +1,37 @@ package com.meloda.fast.common import com.github.terrakok.cicerone.androidx.FragmentScreen -import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser +import com.meloda.fast.api.model.domain.VkConversationDomain import com.meloda.fast.model.UpdateItem +import com.meloda.fast.screens.chatinfo.ChatInfoFragment import com.meloda.fast.screens.conversations.ConversationsFragment import com.meloda.fast.screens.login.LoginFragment import com.meloda.fast.screens.main.MainFragment import com.meloda.fast.screens.messages.ForwardedMessagesFragment import com.meloda.fast.screens.messages.MessagesHistoryFragment -import com.meloda.fast.screens.settings.SettingsRootFragment +import com.meloda.fast.screens.settings.SettingsFragment import com.meloda.fast.screens.updates.UpdatesFragment +import com.meloda.fast.screens.userbanned.UserBannedFragment @Suppress("FunctionName") object Screens { - fun Main() = FragmentScreen { MainFragment() } + fun Main() = FragmentScreen { MainFragment.newInstance() } - fun Login( - getFastToken: Boolean = false - ) = FragmentScreen { - LoginFragment.newInstance(getFastToken) - } + fun Login() = FragmentScreen { LoginFragment.newInstance() } fun Conversations() = FragmentScreen { ConversationsFragment() } fun MessagesHistory( - conversation: VkConversation, + conversation: VkConversationDomain, user: VkUser?, group: VkGroup? ) = FragmentScreen { MessagesHistoryFragment.newInstance(conversation, user, group) } fun ForwardedMessages( - conversation: VkConversation, + conversation: VkConversationDomain, messages: List, profiles: HashMap = hashMapOf(), groups: HashMap = hashMapOf() @@ -43,8 +41,25 @@ object Screens { ) } + fun ChatInfo( + conversation: VkConversationDomain, + user: VkUser?, + group: VkGroup? + ) = FragmentScreen { ChatInfoFragment.newInstance(conversation, user, group) } + fun Updates(updateItem: UpdateItem? = null) = FragmentScreen { UpdatesFragment.newInstance(updateItem) } - fun Settings() = FragmentScreen { SettingsRootFragment() } -} \ No newline at end of file + fun Settings() = FragmentScreen { SettingsFragment.newInstance() } + + fun UserBanned( + memberName: String, + message: String, + restoreUrl: String, + accessToken: String + ) = FragmentScreen { + UserBannedFragment.newInstance( + memberName, message, restoreUrl, accessToken + ) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt b/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt index 930aab68..5ea0d868 100644 --- a/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt +++ b/app/src/main/kotlin/com/meloda/fast/common/UpdateManager.kt @@ -1,37 +1,39 @@ package com.meloda.fast.common -import androidx.lifecycle.MutableLiveData import com.meloda.fast.BuildConfig import com.meloda.fast.api.base.ApiResponse import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.ota.OtaGetLatestReleaseResponse import com.meloda.fast.data.ota.OtaApi -import com.meloda.fast.extensions.setIfNotEquals import com.meloda.fast.model.UpdateActualUrl import com.meloda.fast.model.UpdateItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.net.URLEncoder import kotlin.coroutines.CoroutineContext -class UpdateManager(private val repo: OtaApi) : CoroutineScope { +interface UpdateManager { + val stateFlow: Flow - override val coroutineContext: CoroutineContext - get() = Dispatchers.Default + fun checkUpdates(): Job +} - companion object { - val newUpdate = MutableLiveData(null) - val updateError = MutableLiveData(null) +class UpdateManagerImpl(private val repo: OtaApi) : UpdateManager { - var otaBaseUrl: String? = null - private set - } + private val coroutineContext: CoroutineContext + get() = Dispatchers.IO - private var listener: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null + private val coroutineScope = CoroutineScope(coroutineContext) - private fun getActualUrl() = launch { + private var otaBaseUrl: String? = null + + override val stateFlow = MutableStateFlow(UpdateManagerState.EMPTY) + + override fun checkUpdates() = coroutineScope.launch { val job: suspend () -> ApiAnswer = { repo.getActualUrl() } when (val jobResponse = job()) { @@ -44,47 +46,55 @@ class UpdateManager(private val repo: OtaApi) : CoroutineScope { is ApiAnswer.Error -> { otaBaseUrl = null val throwable = jobResponse.error.throwable - listener?.invoke(null, throwable) - withContext(Dispatchers.Main) { - updateError.setIfNotEquals(throwable) - } + val newForm = stateFlow.value.copy( + updateItem = null, + throwable = throwable + ) + stateFlow.emit(newForm) } } } - private fun getLatestRelease() = launch { + private fun getLatestRelease() = coroutineScope.launch { val url = "$otaBaseUrl/releases-latest" val job: suspend () -> ApiAnswer> = { repo.getLatestRelease(url = url, secretCode = getOtaSecret()) } - withContext(Dispatchers.Main) { - when (val jobResponse = job()) { - is ApiAnswer.Success -> { - val response = jobResponse.data.response ?: return@withContext - val latestRelease = response.release + when (val jobResponse = job()) { + is ApiAnswer.Success -> { + val response = jobResponse.data.response ?: return@launch + val latestRelease = response.release - if (latestRelease != null && - (AppGlobal.versionName - .split("_") - .getOrNull(1) != latestRelease.versionName || - AppGlobal.versionCode < latestRelease.versionCode) - ) { - newUpdate.setIfNotEquals(latestRelease) - listener?.invoke(latestRelease, null) - } else { - newUpdate.setIfNotEquals(null) - listener?.invoke(null, null) - } + val updateItem = if (latestRelease != null && + (AppGlobal.versionName + .split("_") + .getOrNull(1) != latestRelease.versionName || + AppGlobal.versionCode < latestRelease.versionCode) + ) { + latestRelease + } else { + null } - is ApiAnswer.Error -> { - val throwable = jobResponse.error.throwable - updateError.setIfNotEquals(throwable) - listener?.invoke(null, throwable) - } + val newForm = stateFlow.value.copy( + updateItem = updateItem, + throwable = null + ) + + stateFlow.emit(newForm) + } + + is ApiAnswer.Error -> { + val throwable = jobResponse.error.throwable + + val newForm = stateFlow.value.copy( + updateItem = null, + throwable = throwable + ) + stateFlow.emit(newForm) } } } @@ -92,9 +102,15 @@ class UpdateManager(private val repo: OtaApi) : CoroutineScope { private fun getOtaSecret(): String { return URLEncoder.encode(BuildConfig.otaSecretCode, "utf-8") } +} - fun checkUpdates(block: ((item: UpdateItem?, error: Throwable?) -> Unit)? = null) = launch { - this@UpdateManager.listener = block - getActualUrl() +data class UpdateManagerState( + val updateItem: UpdateItem?, + val throwable: Throwable?, +) { + companion object { + val EMPTY = UpdateManagerState( + updateItem = null, throwable = null + ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/common/di/ApplicationModule.kt b/app/src/main/kotlin/com/meloda/fast/common/di/ApplicationModule.kt new file mode 100644 index 00000000..58784bac --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/common/di/ApplicationModule.kt @@ -0,0 +1,40 @@ +package com.meloda.fast.common.di + +import com.meloda.fast.di.apiModule +import com.meloda.fast.di.dataModule +import com.meloda.fast.di.databaseModule +import com.meloda.fast.di.navigationModule +import com.meloda.fast.di.networkModule +import com.meloda.fast.di.otaModule +import com.meloda.fast.screens.captcha.di.captchaModule +import com.meloda.fast.screens.chatinfo.di.chatInfoModule +import com.meloda.fast.screens.conversations.di.conversationsModule +import com.meloda.fast.screens.login.di.loginModule +import com.meloda.fast.screens.main.di.mainModule +import com.meloda.fast.screens.messages.di.messagesHistoryModule +import com.meloda.fast.screens.photos.di.photoViewModule +import com.meloda.fast.screens.settings.di.settingsModule +import com.meloda.fast.screens.twofa.di.twoFaModule +import com.meloda.fast.screens.updates.di.updatesModule +import org.koin.dsl.module + +val applicationModule = module { + includes( + navigationModule, + databaseModule, + dataModule, + otaModule, + networkModule, + apiModule, + loginModule, + twoFaModule, + captchaModule, + mainModule, + conversationsModule, + chatInfoModule, + settingsModule, + updatesModule, + messagesHistoryModule, + photoViewModule, + ) +} diff --git a/app/src/main/kotlin/com/meloda/fast/compose/Dialogs.kt b/app/src/main/kotlin/com/meloda/fast/compose/Dialogs.kt new file mode 100644 index 00000000..4f03a816 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/compose/Dialogs.kt @@ -0,0 +1,163 @@ +package com.meloda.fast.compose + +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.meloda.fast.ext.getString +import com.meloda.fast.model.base.UiText +import com.meloda.fast.ui.AppTheme + +@Composable +fun MaterialDialog( + onDismissAction: (() -> Unit), + title: UiText? = null, + message: UiText? = null, + positiveText: UiText? = null, + positiveAction: (() -> Unit)? = null, + negativeText: UiText? = null, + negativeAction: (() -> Unit)? = null, + neutralText: UiText? = null, + neutralAction: (() -> Unit)? = null, + content: (@Composable () -> Unit)? = null +) { + var isVisible by remember { + mutableStateOf(true) + } + val onDismissRequest = { + onDismissAction.invoke() + isVisible = false + } + + AppTheme { + // TODO: 08.04.2023, Danil Nikolaev: implement animation + AlertAnimation(visible = isVisible) { + Dialog(onDismissRequest = onDismissRequest) { + val scrollState = rememberScrollState() + val canScrollBackward by remember { derivedStateOf { scrollState.value > 0 } } + val canScrollForward by remember { derivedStateOf { scrollState.value < scrollState.maxValue } } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = AlertDialogDefaults.containerColor, + shape = AlertDialogDefaults.shape, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + modifier = Modifier.padding( + start = 20.dp, + top = 20.dp, + end = 20.dp, + bottom = 10.dp + ) + ) { + Row { + title?.getString()?.let { title -> + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = title, + style = MaterialTheme.typography.headlineSmall + ) + } + } + + if (canScrollBackward) { + Divider(modifier = Modifier.fillMaxWidth()) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(scrollState) + ) { + Spacer(modifier = Modifier.height(8.dp)) + Row { + message?.getString()?.let { message -> + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + content?.let { content -> + Spacer(modifier = Modifier.height(4.dp)) + content.invoke() + Spacer(modifier = Modifier.height(10.dp)) + } + } + + if (canScrollForward) { + Divider(modifier = Modifier.fillMaxWidth()) + } + + Row { + neutralText?.getString()?.let { text -> + TextButton( + onClick = { + onDismissRequest.invoke() + neutralAction?.invoke() + } + ) { + Text(text = text) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + negativeText?.getString()?.let { text -> + TextButton( + onClick = { + onDismissRequest.invoke() + negativeAction?.invoke() + } + ) { + Text(text = text) + } + } + + Spacer(modifier = Modifier.width(2.dp)) + + positiveText?.getString()?.let { text -> + TextButton( + onClick = { + onDismissRequest.invoke() + positiveAction?.invoke() + } + ) { + Text(text = text) + } + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun AlertAnimation( + visible: Boolean, + content: @Composable AnimatedVisibilityScope.() -> Unit +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(400)) + + scaleIn(animationSpec = tween(400)), + exit = fadeOut(animationSpec = tween(150)), + content = content + ) +} diff --git a/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt index ed138f4e..bbd3f79f 100644 --- a/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/account/AccountsDao.kt @@ -15,4 +15,7 @@ interface AccountsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(values: List) -} \ No newline at end of file + @Query("DELETE FROM accounts WHERE userId = :userId") + suspend fun deleteById(userId: Int) + +} diff --git a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt index 57a1aa5e..0804a222 100644 --- a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsDao.kt @@ -4,15 +4,15 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.model.domain.VkConversationDomain @Dao interface ConversationsDao { @Query("SELECT * FROM conversations") - suspend fun getAll(): List + suspend fun getAll(): List @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(values: List) + suspend fun insert(values: List) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt index c9161d2e..9fd2049e 100644 --- a/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/conversations/ConversationsRepository.kt @@ -1,11 +1,10 @@ package com.meloda.fast.data.conversations -import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.model.domain.VkConversationDomain import com.meloda.fast.api.network.conversations.ConversationsDeleteRequest import com.meloda.fast.api.network.conversations.ConversationsGetRequest import com.meloda.fast.api.network.conversations.ConversationsPinRequest import com.meloda.fast.api.network.conversations.ConversationsUnpinRequest -import kotlinx.coroutines.sync.Mutex class ConversationsRepository( private val conversationsApi: ConversationsApi, @@ -20,6 +19,6 @@ class ConversationsRepository( suspend fun unpin(params: ConversationsUnpinRequest) = conversationsApi.unpin(params.map) - suspend fun store(conversations: List) = conversationsDao.insert(conversations) + suspend fun store(conversations: List) = conversationsDao.insert(conversations) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt index e8faab88..c12d1fd0 100644 --- a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesApi.kt @@ -1,10 +1,12 @@ package com.meloda.fast.data.messages import com.meloda.fast.api.base.ApiResponse +import com.meloda.fast.api.model.base.BaseVkChat import com.meloda.fast.api.model.base.BaseVkLongPoll import com.meloda.fast.api.model.base.BaseVkMessage import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.messages.MessagesGetByIdResponse +import com.meloda.fast.api.network.messages.MessagesGetConversationMembersResponse import com.meloda.fast.api.network.messages.MessagesGetHistoryResponse import com.meloda.fast.api.network.messages.MessagesUrls import retrofit2.http.FieldMap @@ -53,4 +55,16 @@ interface MessagesApi { @POST(MessagesUrls.MarkAsRead) suspend fun markAsRead(@FieldMap params: Map): ApiAnswer> + @FormUrlEncoded + @POST(MessagesUrls.GetChat) + suspend fun getChat(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(MessagesUrls.GetConversationMembers) + suspend fun getConversationMembers(@FieldMap params: Map): ApiAnswer> + + @FormUrlEncoded + @POST(MessagesUrls.RemoveChatUser) + suspend fun removeChatUser(@FieldMap params: Map): ApiAnswer> + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt index f3d9c653..f1c70b60 100644 --- a/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt +++ b/app/src/main/kotlin/com/meloda/fast/data/messages/MessagesRepository.kt @@ -2,15 +2,28 @@ package com.meloda.fast.data.messages import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.network.longpoll.LongPollGetUpdatesRequest +import com.meloda.fast.api.network.messages.MessagesDeleteRequest +import com.meloda.fast.api.network.messages.MessagesEditRequest +import com.meloda.fast.api.network.messages.MessagesGetByIdRequest +import com.meloda.fast.api.network.messages.MessagesGetChatRequest +import com.meloda.fast.api.network.messages.MessagesGetConversationMembersRequest +import com.meloda.fast.api.network.messages.MessagesGetHistoryRequest +import com.meloda.fast.api.network.messages.MessagesGetLongPollServerRequest +import com.meloda.fast.api.network.messages.MessagesMarkAsImportantRequest +import com.meloda.fast.api.network.messages.MessagesPinMessageRequest +import com.meloda.fast.api.network.messages.MessagesRemoveChatUserRequest +import com.meloda.fast.api.network.messages.MessagesSendRequest +import com.meloda.fast.api.network.messages.MessagesUnPinMessageRequest import com.meloda.fast.data.longpoll.LongPollApi -import com.meloda.fast.api.network.messages.* class MessagesRepository( private val messagesApi: MessagesApi, private val messagesDao: MessagesDao, - private val longPollApi: LongPollApi + private val longPollApi: LongPollApi, ) { + suspend fun store(message: VkMessage) = store(listOf(message)) + suspend fun store(messages: List) = messagesDao.insert(messages) suspend fun getCached(peerId: Int) = messagesDao.getByPeerId(peerId) @@ -41,7 +54,7 @@ class MessagesRepository( suspend fun getLongPollUpdates( serverUrl: String, - params: LongPollGetUpdatesRequest + params: LongPollGetUpdatesRequest, ) = longPollApi.getResponse(serverUrl, params.map) suspend fun getById(params: MessagesGetByIdRequest) = @@ -50,7 +63,7 @@ class MessagesRepository( suspend fun markAsRead( peerId: Int, messagesIds: List? = null, - startMessageId: Int? = null + startMessageId: Int? = null, ) = messagesApi.markAsRead( mutableMapOf("peer_id" to peerId.toString()).apply { messagesIds?.let { @@ -62,4 +75,30 @@ class MessagesRepository( } ) -} \ No newline at end of file + suspend fun getChat( + chatId: Int, + fields: String? = null, + ) = messagesApi.getChat(MessagesGetChatRequest(chatId, fields).map) + + suspend fun getConversationMembers( + peerId: Int, + offset: Int? = null, + count: Int? = null, + extended: Boolean? = null, + fields: String? = null, + ) = messagesApi.getConversationMembers( + MessagesGetConversationMembersRequest( + peerId, + offset, + count, + extended, + fields + ).map + ) + + suspend fun removeChatUser( + chatId: Int, + memberId: Int, + ) = messagesApi.removeChatUser(MessagesRemoveChatUserRequest(chatId, memberId).map) + +} diff --git a/app/src/main/kotlin/com/meloda/fast/database/AccountsDatabase.kt b/app/src/main/kotlin/com/meloda/fast/database/AccountsDatabase.kt new file mode 100644 index 00000000..a5ec4ef4 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/database/AccountsDatabase.kt @@ -0,0 +1,15 @@ +package com.meloda.fast.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.model.AppAccount + +@Database( + entities = [AppAccount::class], + version = 1, + exportSchema = false +) +abstract class AccountsDatabase : RoomDatabase() { + abstract val accountsDao: AccountsDao +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt b/app/src/main/kotlin/com/meloda/fast/database/CacheDatabase.kt similarity index 64% rename from app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt rename to app/src/main/kotlin/com/meloda/fast/database/CacheDatabase.kt index a2640d66..fa8df9ea 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/AppDatabase.kt +++ b/app/src/main/kotlin/com/meloda/fast/database/CacheDatabase.kt @@ -1,41 +1,33 @@ package com.meloda.fast.database -import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser -import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.api.model.domain.VkConversationDomain import com.meloda.fast.data.conversations.ConversationsDao import com.meloda.fast.data.groups.GroupsDao import com.meloda.fast.data.messages.MessagesDao import com.meloda.fast.data.users.UsersDao -import com.meloda.fast.model.AppAccount @Database( entities = [ - AppAccount::class, - VkConversation::class, + VkConversationDomain::class, VkMessage::class, VkUser::class, VkGroup::class ], - version = 34, - exportSchema = true, - autoMigrations = [ - AutoMigration(from = 33, to = 34) - ] + version = 42, + exportSchema = false ) @TypeConverters(Converters::class) -abstract class AppDatabase : RoomDatabase() { +abstract class CacheDatabase : RoomDatabase() { - abstract val accountsDao: AccountsDao abstract val conversationsDao: ConversationsDao abstract val messagesDao: MessagesDao abstract val usersDao: UsersDao abstract val groupsDao: GroupsDao -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/database/Converters.kt b/app/src/main/kotlin/com/meloda/fast/database/Converters.kt index 73472b37..5b852294 100644 --- a/app/src/main/kotlin/com/meloda/fast/database/Converters.kt +++ b/app/src/main/kotlin/com/meloda/fast/database/Converters.kt @@ -2,6 +2,7 @@ package com.meloda.fast.database import androidx.room.TypeConverter import com.google.gson.Gson +import com.meloda.fast.api.base.AttachmentClassNameIsEmptyException import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.attachments.VkAttachment import com.meloda.fast.api.model.base.BaseVkMessage @@ -18,25 +19,37 @@ class Converters { fun fromGeoToString(geo: BaseVkMessage.Geo?): String? { if (geo == null) return null - val string = Gson().toJson(geo) + return try { + val string = Gson().toJson(geo) - return string + return string + } catch (e: Exception) { + e.printStackTrace() + null + } } @TypeConverter fun fromStringToGeo(string: String?): BaseVkMessage.Geo? { if (string == null) return null - val geo = Gson().fromJson(string, BaseVkMessage.Geo::class.java) + return try { + val geo = Gson().fromJson(string, BaseVkMessage.Geo::class.java) - return geo + return geo + } catch (e: Exception) { + e.printStackTrace() + null + } } @TypeConverter fun fromListVkMessageToString(messages: List?): String? { if (messages == null) return null - val string = messages.map { fromVkMessageToString(it)!! }.joinToString { CACHE_SEPARATOR } + val string = messages + .mapNotNull(::fromVkMessageToString) + .joinToString(separator = CACHE_SEPARATOR) return string } @@ -46,40 +59,52 @@ class Converters { if (string == null) return null if (string.contains(CACHE_SEPARATOR)) { - val messages = - string.split(CACHE_SEPARATOR).map { fromStringToVkMessage(it)!! } + val messages = string + .split(CACHE_SEPARATOR) + .mapNotNull(::fromStringToVkMessage) return messages } - val message = fromStringToVkMessage(string)!! - - return listOf(message) + val message = fromStringToVkMessage(string) + return message?.let { listOf(it) } } @TypeConverter fun fromVkMessageToString(message: VkMessage?): String? { if (message == null) return null - return Gson().toJson(message) + return try { + val string = Gson().toJson(message) + + return string + } catch (e: Exception) { + e.printStackTrace() + null + } } @TypeConverter fun fromStringToVkMessage(string: String?): VkMessage? { if (string == null) return null - val message = Gson().fromJson(string, VkMessage::class.java) + return try { + val message = Gson().fromJson(string, VkMessage::class.java) - return message + return message + } catch (e: Exception) { + e.printStackTrace() + null + } } @TypeConverter fun fromListVkAttachmentToString(attachments: List?): String? { if (attachments == null) return null - val string = - attachments.map { fromVkAttachmentToString(it)!! }.joinToString { CACHE_SEPARATOR } - + val string = attachments + .mapNotNull(::fromVkAttachmentToString) + .joinToString(separator = CACHE_SEPARATOR) return string } @@ -88,34 +113,48 @@ class Converters { if (string == null) return null if (string.contains(CACHE_SEPARATOR)) { - val attachments = - string.split(CACHE_SEPARATOR).map { fromStringToVkAttachment(it)!! } + val attachments = string + .split(CACHE_SEPARATOR) + .mapNotNull(::fromStringToVkAttachment) return attachments } + val attachment = fromStringToVkAttachment(string) - val attachment = fromStringToVkAttachment(string)!! - - return listOf(attachment) + return attachment?.let { listOf(it) } } @TypeConverter fun fromVkAttachmentToString(attachment: VkAttachment?): String? { if (attachment == null) return null - val string = Gson().toJson(attachment) - - return string + try { + attachment.javaClass.getDeclaredField("className") + } catch (e: NoSuchFieldException) { + throw AttachmentClassNameIsEmptyException(attachment) + } + return try { + val string = Gson().toJson(attachment) + string + } catch (e: Exception) { + e.printStackTrace() + null + } } @TypeConverter fun fromStringToVkAttachment(string: String?): VkAttachment? { - if (string == null) return null + if (string.isNullOrBlank()) return null - val className = JSONObject(string).optString("className") + return try { + val className = JSONObject(string).optString("className") - val attachment = Gson().fromJson(string, Class.forName(className)) as? VkAttachment? + val attachment = Gson().fromJson(string, Class.forName(className)) as? VkAttachment? - return attachment + return attachment + } catch (e: Exception) { + e.printStackTrace() + null + } } } diff --git a/app/src/main/kotlin/com/meloda/fast/di/ApiModule.kt b/app/src/main/kotlin/com/meloda/fast/di/ApiModule.kt new file mode 100644 index 00000000..275c38b5 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/di/ApiModule.kt @@ -0,0 +1,35 @@ +package com.meloda.fast.di + +import com.meloda.fast.api.longpoll.LongPollUpdatesParser +import com.meloda.fast.data.account.AccountApi +import com.meloda.fast.data.audios.AudiosApi +import com.meloda.fast.data.auth.AuthApi +import com.meloda.fast.data.conversations.ConversationsApi +import com.meloda.fast.data.files.FilesApi +import com.meloda.fast.data.longpoll.LongPollApi +import com.meloda.fast.data.messages.MessagesApi +import com.meloda.fast.data.ota.OtaApi +import com.meloda.fast.data.photos.PhotosApi +import com.meloda.fast.data.users.UsersApi +import com.meloda.fast.data.videos.VideosApi +import org.koin.core.module.dsl.singleOf +import org.koin.core.scope.Scope +import org.koin.dsl.module + +val apiModule = module { + single { api(AuthApi::class.java) } + single { api(ConversationsApi::class.java) } + single { api(UsersApi::class.java) } + single { api(MessagesApi::class.java) } + single { api(LongPollApi::class.java) } + single { api(AccountApi::class.java) } + single { api(OtaApi::class.java) } + single { api(PhotosApi::class.java) } + single { api(VideosApi::class.java) } + single { api(AudiosApi::class.java) } + single { api(FilesApi::class.java) } + + singleOf(::LongPollUpdatesParser) +} + +internal fun Scope.api(className: Class): T = retrofit().create(className) diff --git a/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt b/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt index 890f763f..9397fd8a 100644 --- a/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt +++ b/app/src/main/kotlin/com/meloda/fast/di/DataModule.kt @@ -1,103 +1,26 @@ package com.meloda.fast.di -import com.meloda.fast.data.longpoll.LongPollApi -import com.meloda.fast.data.account.AccountApi -import com.meloda.fast.data.account.AccountsDao import com.meloda.fast.data.account.AccountsRepository -import com.meloda.fast.data.audios.AudiosApi import com.meloda.fast.data.audios.AudiosRepository -import com.meloda.fast.data.auth.AuthApi import com.meloda.fast.data.auth.AuthRepository -import com.meloda.fast.data.conversations.ConversationsApi -import com.meloda.fast.data.conversations.ConversationsDao import com.meloda.fast.data.conversations.ConversationsRepository -import com.meloda.fast.data.files.FilesApi import com.meloda.fast.data.files.FilesRepository -import com.meloda.fast.data.groups.GroupsDao -import com.meloda.fast.data.groups.GroupsRepository -import com.meloda.fast.data.messages.MessagesApi -import com.meloda.fast.data.messages.MessagesDao import com.meloda.fast.data.messages.MessagesRepository -import com.meloda.fast.data.photos.PhotosApi import com.meloda.fast.data.photos.PhotosRepository -import com.meloda.fast.data.users.UsersApi -import com.meloda.fast.data.users.UsersDao import com.meloda.fast.data.users.UsersRepository -import com.meloda.fast.data.videos.VideosApi import com.meloda.fast.data.videos.VideosRepository -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module -@InstallIn(SingletonComponent::class) -@Module -object DataModule { - - @Singleton - @Provides - fun provideConversationsRepository( - conversationsApi: ConversationsApi, - conversationsDao: ConversationsDao - ): ConversationsRepository = ConversationsRepository(conversationsApi, conversationsDao) - - @Singleton - @Provides - fun provideMessagesRepository( - messagesApi: MessagesApi, - messagesDao: MessagesDao, - longPollApi: LongPollApi - ): MessagesRepository = MessagesRepository(messagesApi, messagesDao, longPollApi) - - @Singleton - @Provides - fun provideUsersRepository( - usersApi: UsersApi, - usersDao: UsersDao - ): UsersRepository = UsersRepository(usersApi, usersDao) - - @Singleton - @Provides - fun provideGroupsRepository( - groupsDao: GroupsDao - ): GroupsRepository = GroupsRepository(groupsDao) - - @Singleton - @Provides - fun provideAuthRepository( - authApi: AuthApi - ): AuthRepository = AuthRepository(authApi) - - @Singleton - @Provides - fun provideAccountsRepository( - accountApi: AccountApi, - accountsDao: AccountsDao - ): AccountsRepository = AccountsRepository(accountApi, accountsDao) - - @Singleton - @Provides - fun providePhotosRepository( - photosApi: PhotosApi - ): PhotosRepository = PhotosRepository(photosApi) - - @Singleton - @Provides - fun provideVideosRepository( - videosApi: VideosApi - ): VideosRepository = VideosRepository(videosApi) - - @Singleton - @Provides - fun provideAudiosRepository( - audiosApi: AudiosApi - ): AudiosRepository = AudiosRepository(audiosApi) - - @Singleton - @Provides - fun provideFilesRepository( - filesApi: FilesApi - ): FilesRepository = FilesRepository(filesApi) - -} \ No newline at end of file +// TODO: 17.04.2023, Danil Nikolaev: use specific repositories in local DI modules +val dataModule = module { + singleOf(::ConversationsRepository) + singleOf(::MessagesRepository) + singleOf(::UsersRepository) + singleOf(::AuthRepository) + singleOf(::AccountsRepository) + singleOf(::PhotosRepository) + singleOf(::VideosRepository) + singleOf(::AudiosRepository) + singleOf(::FilesRepository) +} diff --git a/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt b/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt index af8962e8..bd5615ce 100644 --- a/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt +++ b/app/src/main/kotlin/com/meloda/fast/di/DatabaseModule.kt @@ -1,50 +1,28 @@ package com.meloda.fast.di +import androidx.room.Room import com.meloda.fast.common.AppGlobal -import com.meloda.fast.data.account.AccountsDao -import com.meloda.fast.data.conversations.ConversationsDao -import com.meloda.fast.data.groups.GroupsDao -import com.meloda.fast.data.messages.MessagesDao -import com.meloda.fast.data.users.UsersDao -import com.meloda.fast.database.AppDatabase -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton +import com.meloda.fast.database.AccountsDatabase +import com.meloda.fast.database.CacheDatabase +import org.koin.core.scope.Scope +import org.koin.dsl.module -@InstallIn(SingletonComponent::class) -@Module -object DatabaseModule { +val databaseModule = module { + single { + Room.databaseBuilder(AppGlobal.Instance, CacheDatabase::class.java, "cache") + .fallbackToDestructiveMigration() + .build() + } + single { + Room.databaseBuilder(AppGlobal.Instance, AccountsDatabase::class.java, "accounts") + .build() + } + single { cache().conversationsDao } + single { cache().messagesDao } + single { cache().usersDao } + single { cache().groupsDao } + single { accounts().accountsDao } +} - @Provides - @Singleton - fun provideAppDatabase(): AppDatabase = - AppGlobal.appDatabase - - @Provides - @Singleton - fun provideAccountsDao(appDatabase: AppDatabase): AccountsDao = - appDatabase.accountsDao - - @Provides - @Singleton - fun provideConversationsDao(appDatabase: AppDatabase): ConversationsDao = - appDatabase.conversationsDao - - @Provides - @Singleton - fun provideMessagesDao(appDatabase: AppDatabase): MessagesDao = - appDatabase.messagesDao - - @Provides - @Singleton - fun provideUsersDao(appDatabase: AppDatabase): UsersDao = - appDatabase.usersDao - - @Provides - @Singleton - fun provideGroupsDao(appDatabase: AppDatabase): GroupsDao = - appDatabase.groupsDao - -} \ No newline at end of file +private fun Scope.cache(): CacheDatabase = get() +private fun Scope.accounts(): AccountsDatabase = get() diff --git a/app/src/main/kotlin/com/meloda/fast/di/NavigationModule.kt b/app/src/main/kotlin/com/meloda/fast/di/NavigationModule.kt index a996124d..3b130ede 100644 --- a/app/src/main/kotlin/com/meloda/fast/di/NavigationModule.kt +++ b/app/src/main/kotlin/com/meloda/fast/di/NavigationModule.kt @@ -2,24 +2,19 @@ package com.meloda.fast.di import com.github.terrakok.cicerone.Cicerone import com.github.terrakok.cicerone.Router -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton +import com.meloda.fast.screens.captcha.screen.CaptchaScreen +import com.meloda.fast.screens.twofa.screen.TwoFaScreen +import org.koin.core.module.dsl.singleOf +import org.koin.core.scope.Scope +import org.koin.dsl.module -@InstallIn(SingletonComponent::class) -@Module -object NavigationModule { - @Provides - @Singleton - fun getCicerone(): Cicerone = Cicerone.create() +val navigationModule = module { + single { Cicerone.create() } + single { cicerone().router } + single { cicerone().getNavigatorHolder() } - @Provides - @Singleton - fun getRouter(cicerone: Cicerone) = cicerone.router + singleOf(::CaptchaScreen) + singleOf(::TwoFaScreen) +} - @Provides - @Singleton - fun getNavigationHolder(cicerone: Cicerone) = cicerone.getNavigatorHolder() -} \ No newline at end of file +private fun Scope.cicerone(): Cicerone = get() diff --git a/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt index ecb81be5..b7a546ab 100644 --- a/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt +++ b/app/src/main/kotlin/com/meloda/fast/di/NetworkModule.kt @@ -2,96 +2,34 @@ package com.meloda.fast.di import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerInterceptor -import com.google.gson.Gson import com.google.gson.GsonBuilder -import com.meloda.fast.api.longpoll.LongPollUpdatesParser import com.meloda.fast.api.network.AuthInterceptor import com.meloda.fast.api.network.ResultCallFactory import com.meloda.fast.api.network.VkUrls -import com.meloda.fast.common.AppGlobal -import com.meloda.fast.common.UpdateManager -import com.meloda.fast.data.account.AccountApi -import com.meloda.fast.data.audios.AudiosApi -import com.meloda.fast.data.auth.AuthApi -import com.meloda.fast.data.conversations.ConversationsApi -import com.meloda.fast.data.files.FilesApi -import com.meloda.fast.data.longpoll.LongPollApi -import com.meloda.fast.data.messages.MessagesApi -import com.meloda.fast.data.messages.MessagesRepository -import com.meloda.fast.data.ota.OtaApi -import com.meloda.fast.data.photos.PhotosApi -import com.meloda.fast.data.users.UsersApi -import com.meloda.fast.data.videos.VideosApi -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import org.koin.core.module.dsl.singleOf +import org.koin.core.scope.Scope +import org.koin.dsl.module import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit -import javax.inject.Singleton -@InstallIn(SingletonComponent::class) -@Module -object NetworkModule { - - /* - - val chuckerCollector = ChuckerCollector( - context = this, - // Toggles visibility of the notification - showNotification = true, - // Allows to customize the retention period of collected data - retentionPeriod = RetentionManager.Period.ONE_HOUR -) - -// Create the Interceptor -val chuckerInterceptor = ChuckerInterceptor.Builder(context) - // The previously created Collector - .collector(chuckerCollector) - // The max body content length in bytes, after this responses will be truncated. - .maxContentLength(250_000L) - // List of headers to replace with ** in the Chucker UI - .redactHeaders("Auth-Token", "Bearer") - // Read the whole response body even when the client does not consume the response completely. - // This is useful in case of parsing errors or when the response body - // is closed before being read like in Retrofit with Void and Unit types. - .alwaysReadResponseBody(true) - // Use decoder when processing request and response bodies. When multiple decoders are installed they - // are applied in an order they were added. - .addBodyDecoder(decoder) - // Controls Android shortcut creation. Available in SNAPSHOTS versions only at the moment - .createShortcut(true) - .build() - */ - - @Singleton - @Provides - fun provideChuckerCollector(): ChuckerCollector = - ChuckerCollector(AppGlobal.Instance) - - @Singleton - @Provides - fun provideChuckerInterceptor( - chuckerCollector: ChuckerCollector - ): ChuckerInterceptor = - ChuckerInterceptor.Builder(AppGlobal.Instance) - .collector(chuckerCollector) - .build() - - @Singleton - @Provides - fun provideOkHttpClient( - authInterceptor: AuthInterceptor, - chuckerInterceptor: ChuckerInterceptor - ): OkHttpClient = +val networkModule = module { + single { ChuckerCollector(get()) } + single { ChuckerInterceptor.Builder(get()).collector(get()).build() } + singleOf(::AuthInterceptor) + single { GsonBuilder().setLenient().create() } + single { OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) - .addInterceptor(authInterceptor) - .addInterceptor(chuckerInterceptor) + .addInterceptor(authInterceptor()) + .addInterceptor( + chuckerInterceptor().apply { + redactHeader("Secret-Code") + } + ) .followRedirects(true) .followSslRedirects(true) .addInterceptor( @@ -99,92 +37,17 @@ val chuckerInterceptor = ChuckerInterceptor.Builder(context) level = HttpLoggingInterceptor.Level.BODY } ).build() + } + single { + Retrofit.Builder() + .baseUrl("${VkUrls.API}/") + .addConverterFactory(GsonConverterFactory.create(get())) + .addCallAdapterFactory(ResultCallFactory(get())) + .client(get()) + .build() + } +} - @Singleton - @Provides - fun provideGson(): Gson = GsonBuilder() - .setLenient() - .create() - - @Singleton - @Provides - fun provideRetrofit( - client: OkHttpClient, - gson: Gson - ): Retrofit = Retrofit.Builder() - .baseUrl("${VkUrls.API}/") - .addConverterFactory(GsonConverterFactory.create(gson)) - .addCallAdapterFactory(ResultCallFactory()) - .client(client) - .build() - - @Provides - @Singleton - fun provideAuthInterceptor(): AuthInterceptor = AuthInterceptor() - - @Provides - @Singleton - fun provideAuthApi(retrofit: Retrofit): AuthApi = - retrofit.create(AuthApi::class.java) - - @Provides - @Singleton - fun provideConversationsApi(retrofit: Retrofit): ConversationsApi = - retrofit.create(ConversationsApi::class.java) - - @Provides - @Singleton - fun provideUsersApi(retrofit: Retrofit): UsersApi = - retrofit.create(UsersApi::class.java) - - @Provides - @Singleton - fun provideMessagesApi(retrofit: Retrofit): MessagesApi = - retrofit.create(MessagesApi::class.java) - - @Provides - @Singleton - fun provideLongPollApi(retrofit: Retrofit): LongPollApi = - retrofit.create(LongPollApi::class.java) - - @Provides - @Singleton - fun provideLongPollUpdatesParser(messagesRepository: MessagesRepository): LongPollUpdatesParser = - LongPollUpdatesParser(messagesRepository) - - @Provides - @Singleton - fun provideAccountApi(retrofit: Retrofit): AccountApi = - retrofit.create(AccountApi::class.java) - - @Provides - @Singleton - fun provideOtaApi(retrofit: Retrofit): OtaApi = - retrofit.create(OtaApi::class.java) - - @Provides - @Singleton - fun provideUpdateManager(otaApi: OtaApi): UpdateManager = - UpdateManager(otaApi) - - @Provides - @Singleton - fun providePhotosApi(retrofit: Retrofit): PhotosApi = - retrofit.create(PhotosApi::class.java) - - @Provides - @Singleton - fun provideVideosApi(retrofit: Retrofit): VideosApi = - retrofit.create(VideosApi::class.java) - - @Provides - @Singleton - fun provideAudiosApi(retrofit: Retrofit): AudiosApi = - retrofit.create(AudiosApi::class.java) - - @Provides - @Singleton - fun provideFilesApi(retrofit: Retrofit): FilesApi = - retrofit.create(FilesApi::class.java) - -} \ No newline at end of file +internal fun Scope.retrofit(): Retrofit = get() +private fun Scope.authInterceptor(): AuthInterceptor = get() +private fun Scope.chuckerInterceptor(): ChuckerInterceptor = get() diff --git a/app/src/main/kotlin/com/meloda/fast/di/OtaModule.kt b/app/src/main/kotlin/com/meloda/fast/di/OtaModule.kt new file mode 100644 index 00000000..413a16b3 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/di/OtaModule.kt @@ -0,0 +1,13 @@ +package com.meloda.fast.di + +import com.meloda.fast.common.UpdateManager +import com.meloda.fast.common.UpdateManagerImpl +import com.meloda.fast.data.ota.OtaApi +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val otaModule = module { + single { api(OtaApi::class.java) } + singleOf(::UpdateManagerImpl) { bind() } +} diff --git a/app/src/main/kotlin/com/meloda/fast/ext/ActivityExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/ActivityExt.kt new file mode 100644 index 00000000..f75afa5a --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/ActivityExt.kt @@ -0,0 +1,14 @@ +package com.meloda.fast.ext + +import android.app.Activity +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.Flow + +fun Activity.edgeToEdge() { + WindowCompat.setDecorFitsSystemWindows(window, false) +} + +context(AppCompatActivity) +fun Flow.listenValue(action: suspend (T) -> Unit) = listenValue(lifecycleScope, action) diff --git a/app/src/main/kotlin/com/meloda/fast/ext/AndroidVersionsExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/AndroidVersionsExt.kt new file mode 100644 index 00000000..c65f885c --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/AndroidVersionsExt.kt @@ -0,0 +1,54 @@ +package com.meloda.fast.ext + +import android.os.Build + +fun isSdkAtLeast(sdkInt: Int, action: (() -> Unit)? = null): Boolean { + return if (Build.VERSION.SDK_INT >= sdkInt) { + action?.invoke() + true + } else { + false + } +} + +fun sdkAndUp(sdkInt: Int, action: () -> Unit): Boolean? { + return if (Build.VERSION.SDK_INT >= sdkInt) { + action.invoke() + true + } else null +} + +fun isSdkAtLeastOr( + sdkInt: Int, + action: (() -> Unit)? = null, + orAction: (() -> Unit)? = null +): Boolean { + return if (Build.VERSION.SDK_INT >= sdkInt) { + action?.invoke() + true + } else { + orAction?.invoke() + false + } +} + +fun sdk26AndUp(action: () -> Unit): Boolean? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + action.invoke() + true + } else null +} + +fun sdk30AndUp(action: () -> Unit): Boolean? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + action.invoke() + true + } else null +} + +fun sdk33AndUp(action: () -> Unit): Boolean? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + action.invoke() + true + } else null +} diff --git a/app/src/main/kotlin/com/meloda/fast/ext/BooleanExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/BooleanExt.kt new file mode 100644 index 00000000..a1007d57 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/BooleanExt.kt @@ -0,0 +1,5 @@ +package com.meloda.fast.ext + +val Boolean?.isTrue: Boolean get() = this == true + +val Boolean?.isFalse: Boolean get() = this == false diff --git a/app/src/main/kotlin/com/meloda/fast/ext/BundleExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/BundleExt.kt new file mode 100644 index 00000000..bc31d952 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/BundleExt.kt @@ -0,0 +1,36 @@ +package com.meloda.fast.ext + +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import java.io.Serializable + +@Suppress("UNCHECKED_CAST", "DEPRECATION") +fun Bundle.getParcelableArrayListCompat( + key: String?, + clazz: Class +): java.util.ArrayList? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableArrayList(key, clazz) + } else { + getParcelableArrayList(key) as ArrayList + } +} + +@Suppress("DEPRECATION") +fun Bundle.getParcelableCompat(key: String?, clazz: Class): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelable(key, clazz) + } else { + getParcelable(key) + } +} + +@Suppress("DEPRECATION", "UNCHECKED_CAST") +fun Bundle.getSerializableCompat(key: String?, clazz: Class): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializable(key, clazz) + } else { + getSerializable(key) as? T + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/ext/ComposeExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/ComposeExt.kt new file mode 100644 index 00000000..8ab3fa82 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/ComposeExt.kt @@ -0,0 +1,146 @@ +package com.meloda.fast.ext + +import android.content.res.Configuration +import android.media.AudioManager +import android.view.KeyEvent +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Indication +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.model.base.UiText +import com.meloda.fast.model.base.parseString +import com.meloda.fast.screens.settings.SettingsFragment +import com.meloda.fast.util.AndroidUtils + +@ExperimentalFoundationApi +fun Modifier.clickableSound( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: (() -> Unit)? = null +): Modifier = this.clickable( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = { + AppGlobal.audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK) + onClick?.invoke() + } +) + +@ExperimentalFoundationApi +fun Modifier.combinedClickableSound( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onLongClickLabel: String? = null, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + onClick: (() -> Unit)? = null +): Modifier = composed { + this.combinedClickableSound( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onLongClickLabel = onLongClickLabel, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + onClick = { + AppGlobal.audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK) + onClick?.invoke() + } + ) +} + +@ExperimentalFoundationApi +fun Modifier.combinedClickableSound( + interactionSource: MutableInteractionSource, + indication: Indication?, + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onLongClickLabel: String? = null, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + onClick: (() -> Unit)? = null +): Modifier = this.combinedClickable( + interactionSource = interactionSource, + indication = indication, + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onLongClickLabel = onLongClickLabel, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + onClick = { + AppGlobal.audioManager.playSoundEffect(AudioManager.FX_KEY_CLICK) + onClick?.invoke() + } +) + +fun Modifier.handleTabKey( + action: () -> Boolean +): Modifier = this.onKeyEvent { event -> + if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_TAB) { + action.invoke() + } else false +} + +fun Modifier.handleEnterKey( + action: () -> Boolean +): Modifier = this.onKeyEvent { event -> + if (event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) { + action.invoke() + } else false +} + +@Composable +fun UiText?.getString(): String? { + return this.parseString(LocalContext.current) +} + +@Composable +fun isUsingDarkTheme(): Boolean { + if (LocalView.current.isInEditMode) { + return false + } + + val nightThemeMode = AppGlobal.preferences.getInt( + SettingsFragment.KEY_APPEARANCE_DARK_THEME, + SettingsFragment.DEFAULT_VALUE_APPEARANCE_DARK_THEME + ) + val appForceDarkMode = nightThemeMode == AppCompatDelegate.MODE_NIGHT_YES + val appBatterySaver = nightThemeMode == AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + + val systemUiNightMode = AppGlobal.resources.configuration.uiMode + + val isSystemBatterySaver = AndroidUtils.isBatterySaverOn() + val isSystemUsingDarkTheme = + systemUiNightMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + + return appForceDarkMode || (appBatterySaver && isSystemBatterySaver) || (!appBatterySaver && isSystemUsingDarkTheme && nightThemeMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) +} + +@Composable +fun isUsingDynamicColors(): Boolean = + if (LocalView.current.isInEditMode) true + else { + AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_USE_DYNAMIC_COLORS, + SettingsFragment.DEFAULT_VALUE_USE_DYNAMIC_COLORS + ) + } diff --git a/app/src/main/kotlin/com/meloda/fast/ext/ContextExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/ContextExt.kt new file mode 100644 index 00000000..a7fd3e66 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/ContextExt.kt @@ -0,0 +1,90 @@ +package com.meloda.fast.ext + +import android.content.Context +import android.view.View +import androidx.appcompat.app.AlertDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.meloda.fast.model.base.UiText +import com.meloda.fast.model.base.parseString + +fun Context.showDialog( + title: UiText? = null, + message: UiText? = null, + isCancelable: Boolean = true, + positiveText: UiText? = null, + positiveAction: (() -> Unit)? = null, + negativeText: UiText? = null, + negativeAction: (() -> Unit)? = null, + neutralText: UiText? = null, + neutralAction: (() -> Unit)? = null, + onDismissAction: (() -> Unit)? = null, + view: View? = null, + items: List? = null, + itemsChoiceType: ItemsChoiceType = ItemsChoiceType.None, + itemsClickAction: ((index: Int, value: String) -> Unit)? = null, + itemsMultiChoiceClickAction: ((index: Int, value: String, isChecked: Boolean) -> Unit)? = null, + checkedItems: List? = null +): AlertDialog { + val builder = MaterialAlertDialogBuilder(this) + .setCancelable(isCancelable) + .setOnDismissListener { onDismissAction?.invoke() } + + title?.asString()?.let(builder::setTitle) + message?.asString()?.let(builder::setMessage) + + view?.let(builder::setView) + + positiveText?.let { text -> + builder.setPositiveButton(text.asString()) { _, _ -> positiveAction?.invoke() } + } + negativeText?.let { text -> + builder.setNegativeButton(text.asString()) { _, _ -> negativeAction?.invoke() } + } + neutralText?.let { text -> + builder.setNeutralButton(text.asString()) { _, _ -> neutralAction?.invoke() } + } + + items?.mapNotNull { it.asString() }?.let { stringItems -> + when (itemsChoiceType) { + ItemsChoiceType.None -> { + builder.setItems( + stringItems.toTypedArray() + ) { dialog, which -> + dialog.dismiss() + itemsClickAction?.invoke(which, stringItems[which]) + } + } + + ItemsChoiceType.SingleChoice -> { + builder.setSingleChoiceItems( + stringItems.toTypedArray(), + checkedItems?.first() ?: -1 + ) { _, which -> + itemsClickAction?.invoke(which, stringItems[which]) + } + } + + ItemsChoiceType.MultiChoice -> { + builder.setMultiChoiceItems( + stringItems.toTypedArray(), + BooleanArray(stringItems.size) { index -> checkedItems?.contains(index).isTrue } + ) { _, which, isChecked -> + itemsMultiChoiceClickAction?.invoke(which, stringItems[which], isChecked) + } + } + } + } + + return builder.show() +} + +sealed class ItemsChoiceType { + object None : ItemsChoiceType() + object SingleChoice : ItemsChoiceType() + object MultiChoice : ItemsChoiceType() +} + +context(Context) +fun UiText?.asString(): String? { + return this.parseString(this@Context) +} diff --git a/app/src/main/kotlin/com/meloda/fast/ext/Ext.kt b/app/src/main/kotlin/com/meloda/fast/ext/Ext.kt new file mode 100644 index 00000000..160e5890 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/Ext.kt @@ -0,0 +1,148 @@ +package com.meloda.fast.ext + +import android.content.res.Configuration +import android.content.res.Resources +import android.util.DisplayMetrics +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.common.net.MediaType +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.screens.settings.SettingsFragment +import com.meloda.fast.util.AndroidUtils +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Deprecated("use resources or rewrite in Compose") +fun Int.dpToPx(): Int { + val metrics = Resources.getSystem().displayMetrics + return (this * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt() +} + +@Deprecated("use resources or rewrite in Compose") +fun Float.dpToPx(): Int { + val metrics = Resources.getSystem().displayMetrics + return (this * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt() +} + +val MediaType.mimeType: String get() = "${type()}/${subtype()}" + +@Throws(NullPointerException::class) +fun T?.notNull(lazyMessage: (() -> Any)? = null): T { + return if (lazyMessage != null) { + requireNotNull(this, lazyMessage) + } else { + requireNotNull(this) + } +} + +inline fun Iterable.findIndex(predicate: (T) -> Boolean): Int? { + return indexOf(firstOrNull(predicate)).let { if (it == -1) null else it } +} + +inline fun > Iterable.toMap( + destination: M, + keySelector: (T) -> K, +): M { + for (element in this) { + val key = keySelector(element) + destination[key] = element + } + return destination +} + +fun MutableList.addIf(element: T, condition: () -> Boolean) { + if (condition.invoke()) add(element) +} + +context(ViewModel) +fun Flow.listenValue(action: suspend (T) -> Unit) = listenValue(viewModelScope, action) + +fun Flow.listenValue( + coroutineScope: CoroutineScope, + action: suspend (T) -> Unit +): Job = onEach(action::invoke).launchIn(coroutineScope) + +fun isSystemUsingDarkMode(): Boolean { + val nightThemeMode = AppGlobal.preferences.getInt( + SettingsFragment.KEY_APPEARANCE_DARK_THEME, + SettingsFragment.DEFAULT_VALUE_APPEARANCE_DARK_THEME + ) + val appForceDarkMode = nightThemeMode == AppCompatDelegate.MODE_NIGHT_YES + val appBatterySaver = nightThemeMode == AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + + val systemUiNightMode = AppGlobal.resources.configuration.uiMode + + val isSystemBatterySaver = AndroidUtils.isBatterySaverOn() + val isSystemUsingDarkTheme = + systemUiNightMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + + return appForceDarkMode || (appBatterySaver && isSystemBatterySaver) || (!appBatterySaver && isSystemUsingDarkTheme && nightThemeMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) +} + +fun createTimerFlow( + time: Int, + onStartAction: suspend () -> Unit, + onTickAction: suspend (remainedTime: Int) -> Unit, + onTimeoutAction: suspend () -> Unit, + interval: Duration = 1.seconds +): Flow = (time downTo 0) + .asSequence() + .asFlow() + .onStart { onStartAction() } + .onEach { timeLeft -> + onTickAction(timeLeft) + if (timeLeft == 0) { + onTimeoutAction() + } else { + delay(interval) + } + } + +fun createTimerFlow( + isNeedToEndCondition: suspend () -> Boolean, + onStartAction: (suspend () -> Unit)? = null, + onTickAction: (suspend () -> Unit)? = null, + onEndAction: (suspend () -> Unit)? = null, + interval: Duration = 1.seconds +): Flow = flow { + while (true) { + val isNeedToEnd = isNeedToEndCondition() + emit(isNeedToEnd) + if (isNeedToEnd) break + } +} + .onStart { onStartAction?.invoke() } + .onEach { isNeedToEnd -> + onTickAction?.invoke() + if (isNeedToEnd) { + onEndAction?.invoke() + } else { + delay(interval) + } + } + +context(ViewModel) +fun MutableSharedFlow.emitOnMainScope(value: T) = emitOnScope(value, Dispatchers.Main) + +context(ViewModel) +fun MutableSharedFlow.emitOnScope( + value: T, + dispatcher: CoroutineDispatcher = Dispatchers.Default, +) { + viewModelScope.launch(dispatcher) { + emit(value) + } +} + +context(CoroutineScope) +suspend fun MutableSharedFlow.emitWithMain(value: T) { + withContext(Dispatchers.Main) { + emit(value) + } +} + +context(ViewModel) +fun MutableStateFlow.updateValue(newValue: T) = this.update { newValue } diff --git a/app/src/main/kotlin/com/meloda/fast/ext/FragmentExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/FragmentExt.kt new file mode 100644 index 00000000..9663cd05 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/FragmentExt.kt @@ -0,0 +1,47 @@ +package com.meloda.fast.ext + +import android.graphics.drawable.Drawable +import android.widget.Toast +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.meloda.fast.model.base.UiText +import com.meloda.fast.model.base.parseString +import kotlinx.coroutines.flow.Flow + +context(Fragment) +fun Flow.listenValue( + action: suspend (T) -> Unit +) = listenValue(lifecycleScope, action) + + +context(Fragment) +fun String.toast(duration: Int = Toast.LENGTH_LONG) = toast(requireContext(), duration) + +context(Fragment) +fun color(@ColorRes resId: Int): Int { + return ContextCompat.getColor(requireContext(), resId) +} + +context(Fragment) +fun drawable(@DrawableRes resId: Int): Drawable? { + return ContextCompat.getDrawable(requireContext(), resId) +} + +context(Fragment) +fun string(@StringRes resId: Int): String { + return getString(resId) +} + +context(Fragment) +fun string(@StringRes resId: Int, vararg args: Any?): String { + return getString(resId, *args) +} + +context(Fragment) +fun UiText?.asString(): String? { + return this.parseString(this@Fragment.requireContext()) +} diff --git a/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/GlideExt.kt similarity index 51% rename from app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt rename to app/src/main/kotlin/com/meloda/fast/ext/GlideExt.kt index 04cf5275..6f8c2e73 100644 --- a/app/src/main/kotlin/com/meloda/fast/extensions/GlideExt.kt +++ b/app/src/main/kotlin/com/meloda/fast/ext/GlideExt.kt @@ -1,4 +1,4 @@ -package com.meloda.fast.extensions +package com.meloda.fast.ext import android.graphics.Bitmap import android.graphics.drawable.ColorDrawable @@ -27,71 +27,85 @@ object ImageLoader { this.setImageDrawable(null) } - fun ImageView.loadWithGlide( - url: String? = null, - uri: Uri? = null, - drawableRes: Int? = null, - drawable: Drawable? = null, - placeholderDrawable: Drawable? = null, - placeholderColor: Int? = null, - errorDrawable: Drawable? = placeholderDrawable, - errorColor: Int? = null, - crossFade: Boolean = false, - crossFadeDuration: Int? = null, - asCircle: Boolean = false, - transformations: List = emptyList(), - onLoadedAction: (() -> Unit)? = null, - onFailedAction: (() -> Unit)? = null, - priority: Priority = Priority.NORMAL, - cacheStrategy: DiskCacheStrategy = DiskCacheStrategy.ALL - ) { + fun ImageView.loadWithGlide(block: GlideParams.() -> Unit) { + val params = GlideParams() + block.invoke(params) + loadWithGlide(params) + } + + fun ImageView.loadWithGlide(params: GlideParams) { val request = Glide.with(this) var builder = when { - url != null -> request.load(url) - uri != null -> request.load(uri) - drawableRes != null -> request.load(drawableRes) + params.imageUrl != null -> request.load(params.imageUrl) + params.imageUri != null -> request.load(params.imageUri) + params.drawableRes != null -> request.load(params.drawableRes) drawable != null -> request.load(drawable) else -> request.load(null as Drawable?) } - val transforms = transformations.toMutableList() - if (asCircle) { + val transforms = params.transformations.toMutableList() + if (params.asCircle) { transforms += TypeTransformations.CircleCrop } builder = builder .apply(TypeTransformations.createRequestOptions(transforms)) .error( - errorDrawable - ?: if (errorColor != null) ColorDrawable(errorColor) else null + params.errorDrawable + ?: if (params.errorColor != null) { + ColorDrawable(requireNotNull(params.errorColor)) + } else null ) .placeholder( - placeholderDrawable - ?: if (placeholderColor != null) ColorDrawable(placeholderColor) else null + params.placeholderDrawable + ?: if (params.placeholderColor != null) { + ColorDrawable(requireNotNull(params.placeholderColor)) + } else null ) - .addListener(ImageLoadRequestListener(onLoadedAction, onFailedAction)) - .diskCacheStrategy(cacheStrategy) - .priority(priority) + .addListener(ImageLoadRequestListener(params.onLoadedAction, params.onFailedAction)) + .addListener(ImageLoadDoneListener(params.onDoneAction)) + .diskCacheStrategy(params.cacheStrategy) + .priority(params.loadPriority) - if (crossFade || crossFadeDuration != null) { - builder = builder.transition(withCrossFade(crossFadeDuration ?: 200)) + if (params.crossFade || params.crossFadeDuration != null) { + builder = builder.transition(withCrossFade(params.crossFadeDuration ?: 200)) } builder.into(this) } } +data class GlideParams( + var imageUrl: String? = null, + var imageUri: Uri? = null, + var drawableRes: Int? = null, + var imageDrawable: Drawable? = null, + var placeholderDrawable: Drawable? = null, + var placeholderColor: Int? = null, + var errorDrawable: Drawable? = placeholderDrawable, + var errorColor: Int? = null, + var crossFade: Boolean = false, + var crossFadeDuration: Int? = null, + var asCircle: Boolean = false, + var transformations: List = emptyList(), + var onLoadedAction: (() -> Unit)? = null, + var onFailedAction: (() -> Unit)? = null, + var onDoneAction: (() -> Unit)? = null, + var loadPriority: Priority = Priority.NORMAL, + var cacheStrategy: DiskCacheStrategy = DiskCacheStrategy.ALL, +) + class ImageLoadRequestListener( private val onLoadedAction: (() -> Unit)?, - private val onFailedAction: (() -> Unit)? + private val onFailedAction: (() -> Unit)?, ) : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target?, - isFirstResource: Boolean + isFirstResource: Boolean, ): Boolean { onFailedAction?.invoke() return false @@ -102,13 +116,36 @@ class ImageLoadRequestListener( model: Any?, target: Target?, dataSource: DataSource?, - isFirstResource: Boolean + isFirstResource: Boolean, ): Boolean { onLoadedAction?.invoke() return false } } +class ImageLoadDoneListener(private val onDoneAction: (() -> Unit)?) : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + onDoneAction?.invoke() + return false + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + onDoneAction?.invoke() + return false + } +} + sealed class TypeTransformations { object CenterCrop : TypeTransformations() @@ -123,7 +160,7 @@ sealed class TypeTransformations { val topLeft: Float, val topRight: Float, val bottomRight: Float, - val bottomLeft: Float + val bottomLeft: Float, ) : TypeTransformations() fun toGlideTransform(): Transformation = when (this) { @@ -149,4 +186,4 @@ sealed class TypeTransformations { return RequestOptions().transform(* mappedTransformations) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/ext/NumbersExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/NumbersExt.kt new file mode 100644 index 00000000..e8b16cb1 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/NumbersExt.kt @@ -0,0 +1 @@ +package com.meloda.fast.ext diff --git a/app/src/main/kotlin/com/meloda/fast/ext/StringExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/StringExt.kt new file mode 100644 index 00000000..3c615a2f --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/StringExt.kt @@ -0,0 +1,30 @@ +package com.meloda.fast.ext + +import android.content.Context +import android.widget.Toast +import com.meloda.fast.model.base.UiText + +inline fun String?.ifEmpty(defaultValue: () -> String?): String? = + if (this?.isEmpty() == true) defaultValue() else this + +fun String?.orDots(count: Int = 3): String { + return this ?: ("." * count) +} + +operator fun String.times(count: Int): String { + val builder = StringBuilder() + for (i in 0 until count) { + builder.append(this) + } + + return builder.toString() +} + +fun String.toast(context: Context, duration: Int = Toast.LENGTH_LONG) { + Toast.makeText(context, this, duration).show() +} + +context (Context) +fun String.toast(duration: Int = Toast.LENGTH_LONG) = toast(this@Context, duration) + +fun String.asUiText(): UiText = UiText.Simple(this) diff --git a/app/src/main/kotlin/com/meloda/fast/ext/ViewExt.kt b/app/src/main/kotlin/com/meloda/fast/ext/ViewExt.kt new file mode 100644 index 00000000..4c131c38 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ext/ViewExt.kt @@ -0,0 +1,196 @@ +package com.meloda.fast.ext + +import android.content.Context +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.Px +import androidx.appcompat.widget.Toolbar +import androidx.core.view.* +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.meloda.fast.R +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.databinding.ToolbarMenuItemAvatarBinding +import com.meloda.fast.ext.ImageLoader.loadWithGlide + +val EditText.trimmedText: String get() = text.toString().trim() +fun EditText.selectLast() { + setSelection(text.length) +} + +inline fun EditText.onDone(crossinline callback: () -> Unit) { + imeOptions = EditorInfo.IME_ACTION_DONE + maxLines = 1 + setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + callback.invoke() + return@setOnEditorActionListener true + } + false + } +} + +@Deprecated("use InsetManager") +fun View.showKeyboard(flags: Int = 0) { + (AppGlobal.Instance.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) + .showSoftInput(this, flags) +} + +@Deprecated("use InsetManager") +fun View.hideKeyboard(focusedView: View? = null, flags: Int = 0) { + (AppGlobal.Instance.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) + .hideSoftInputFromWindow(focusedView?.windowToken ?: this.windowToken, flags) +} + +fun TextInputLayout.clearError() { + if (error != null) error = null +} + +fun TextInputLayout.toggleError(errorText: String, isNeedToShow: Boolean) { + if (isNeedToShow) { + this.error = errorText + } else { + clearError() + } +} + +fun TextInputLayout.clearTextOnErrorIconClick(textField: TextInputEditText) { + setErrorIconOnClickListener { + textField.text = null + textField.showKeyboard() + } +} + +@JvmOverloads +fun ImageView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) { + visibility = if (drawable != null) View.VISIBLE else visibilityWhenFalse +} + +@JvmOverloads +fun TextView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) { + visibility = if (!text.isNullOrEmpty()) View.VISIBLE else visibilityWhenFalse +} + +fun View.setMarginsPx( + @Px leftMargin: Int? = null, + @Px topMargin: Int? = null, + @Px rightMargin: Int? = null, + @Px bottomMargin: Int? = null, +) { + (layoutParams as? ViewGroup.MarginLayoutParams)?.let { params -> + leftMargin?.run { params.leftMargin = this } + topMargin?.run { params.topMargin = this } + rightMargin?.run { params.rightMargin = this } + bottomMargin?.run { params.bottomMargin = this } + + requestLayout() + } +} + +fun TextView.clear() { + text = null +} + +fun View.invisible() = run { isInvisible = true } +fun View.visible() = run { isVisible = true } +fun View.gone() = run { isGone = true } + +@JvmOverloads +fun View.toggleVisibility(visible: Boolean?, visibilityWhenFalse: Int = View.GONE) = + run { visibility = if (visible == true) View.VISIBLE else visibilityWhenFalse } + +fun Toolbar.tintMenuItemIcons(@ColorInt colorToTint: Int) { + menu.forEach { item -> + item.icon?.setTint(colorToTint) + } +} + +fun Toolbar.addAvatarMenuItem(urlToLoad: String? = null, drawable: Drawable? = null): MenuItem { + val avatarMenuItemBinding = ToolbarMenuItemAvatarBinding.inflate( + LayoutInflater.from(context), null, false + ) + + val avatarMenuItem = menu.add(context.getString(R.string.navigation_profile)) + avatarMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) + avatarMenuItem.actionView = avatarMenuItemBinding.root + + val imageView = avatarMenuItemBinding.avatar + + when { + urlToLoad != null -> { + imageView.loadWithGlide { + imageUrl = urlToLoad + transformations = ImageLoader.userAvatarTransformations + } + } + + drawable != null -> { + imageView.loadWithGlide { + imageDrawable = drawable + transformations = ImageLoader.userAvatarTransformations + } + } + } + + return avatarMenuItem +} + +fun View.doOnApplyWindowInsets( + block: ( + view: View, + insets: WindowInsetsCompat, + paddings: Rect, + margins: Rect + ) -> WindowInsetsCompat +) { + val initialPaddings = recordInitialPaddingsForView(this) + val initialMargins = recordInitialMarginsForView(this) + + ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> + block(view, insets, initialPaddings, initialMargins) + } + + requestApplyInsetsWhenAttached() +} + +private fun recordInitialPaddingsForView(view: View) = + Rect(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom) + +private fun recordInitialMarginsForView(view: View) = + Rect(view.marginStart, view.marginTop, view.marginEnd, view.marginBottom) + +fun View.requestApplyInsetsWhenAttached() { + if (isAttachedToWindow) { + requestApplyInsets() + } else { + doOnAttach { requestApplyInsets() } + } +} + +fun EditText.updateTextIfDiffer(text: String?) { + if (this.text?.toString() == text) return + setText(text) +} + +fun ViewGroup.bulkIsEnabled(isEnabled: Boolean) { + this.isEnabled = isEnabled + toggleChildrenIsEnabled(isEnabled) +} + +fun ViewGroup.toggleChildrenIsEnabled(isEnabled: Boolean) { + children.forEach { view -> view.toggleIsEnabled(isEnabled) } +} + +fun View.toggleIsEnabled(isEnabled: Boolean) { + this.isEnabled = isEnabled +} diff --git a/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt b/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt deleted file mode 100644 index 1f2eb77b..00000000 --- a/app/src/main/kotlin/com/meloda/fast/extensions/Ext.kt +++ /dev/null @@ -1,185 +0,0 @@ -package com.meloda.fast.extensions - -import android.animation.ValueAnimator -import android.content.res.Resources -import android.graphics.drawable.Drawable -import android.os.Parcelable -import android.util.DisplayMetrics -import android.util.SparseArray -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.EditText -import android.widget.ImageView -import android.widget.TextView -import androidx.annotation.ColorInt -import androidx.annotation.Px -import androidx.appcompat.widget.Toolbar -import androidx.core.view.children -import androidx.core.view.forEach -import androidx.lifecycle.MutableLiveData -import com.google.common.net.MediaType -import com.meloda.fast.common.AppGlobal -import com.meloda.fast.databinding.ToolbarMenuItemAvatarBinding -import com.meloda.fast.extensions.ImageLoader.loadWithGlide - -fun Int.dpToPx(): Int { - val metrics = Resources.getSystem().displayMetrics - return (this * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt() -} - -fun Float.dpToPx(): Int { - val metrics = Resources.getSystem().displayMetrics - return (this * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).toInt() -} - -fun TextView.clear() { - text = null -} - -fun ViewGroup.saveChildViewStates(): SparseArray { - val childViewStates = SparseArray() - children.forEach { child -> child.saveHierarchyState(childViewStates) } - return childViewStates -} - -fun ViewGroup.restoreChildViewStates(childViewStates: SparseArray) { - children.forEach { child -> child.restoreHierarchyState(childViewStates) } -} - -fun View.invisible() = run { visibility = View.INVISIBLE } - -fun View.visible() = run { visibility = View.VISIBLE } -fun View.gone() = run { visibility = View.GONE } - -@JvmOverloads -fun View.toggleVisibility(visible: Boolean?, visibilityWhenFalse: Int = View.GONE) = - run { visibility = if (visible == true) View.VISIBLE else visibilityWhenFalse } - -fun ValueAnimator.startWithIntValues(from: Int, to: Int) { - setIntValues(from, to) - start() -} - -fun ValueAnimator.startWithFloatValues(from: Float, to: Float) { - setFloatValues(from, to) - start() -} - -fun View.setMarginsPx( - @Px leftMargin: Int? = null, - @Px topMargin: Int? = null, - @Px rightMargin: Int? = null, - @Px bottomMargin: Int? = null -) { - if (layoutParams is ViewGroup.MarginLayoutParams) { - val params = layoutParams as ViewGroup.MarginLayoutParams - leftMargin?.run { params.leftMargin = this } - topMargin?.run { params.topMargin = this } - rightMargin?.run { params.rightMargin = this } - bottomMargin?.run { params.bottomMargin = this } - requestLayout() - } -} - -inline fun Pair.runIfElementsNotNull(block: (T, K) -> Unit) { - val firstCopy = first - val secondCopy = second - if (firstCopy != null && secondCopy != null) { - block(firstCopy, secondCopy) - } -} - -@JvmOverloads -fun ImageView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) { - visibility = if (drawable != null) View.VISIBLE else visibilityWhenFalse -} - -@JvmOverloads -fun TextView.toggleVisibilityIfHasContent(visibilityWhenFalse: Int = View.GONE) { - visibility = if (!text.isNullOrEmpty()) View.VISIBLE else visibilityWhenFalse -} - -fun View.showKeyboard(flags: Int = 0) { - AppGlobal.inputMethodManager.showSoftInput(this, flags) -} - -fun View.hideKeyboard(focusedView: View? = null, flags: Int = 0) { - AppGlobal.inputMethodManager.hideSoftInputFromWindow( - focusedView?.windowToken ?: this.windowToken, flags - ) -} - -fun Toolbar.tintMenuItemIcons(@ColorInt colorToTint: Int) { - menu.forEach { item -> - item.icon?.setTint(colorToTint) - } -} - -fun Toolbar.addAvatarMenuItem(urlToLoad: String? = null, drawable: Drawable? = null): MenuItem { - val avatarMenuItemBinding = ToolbarMenuItemAvatarBinding.inflate( - LayoutInflater.from(context), null, false - ) - - val avatarMenuItem = menu.add("Profile") - avatarMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) - avatarMenuItem.actionView = avatarMenuItemBinding.root - - val imageView = avatarMenuItemBinding.avatar - - when { - urlToLoad != null -> { - imageView.loadWithGlide( - url = urlToLoad, - transformations = ImageLoader.userAvatarTransformations - ) - } - drawable != null -> { - imageView.loadWithGlide( - drawable = drawable, - transformations = ImageLoader.userAvatarTransformations - ) - } - } - - return avatarMenuItem -} - -fun MutableLiveData.notifyObservers() { - this.value = this.value -} - -fun MutableLiveData.setIfNotEquals(item: T) { - if (this.value != item) this.value = item -} - -fun MutableLiveData.requireValue(): T { - return this.value!! -} - -val EditText.trimmedText: String get() = text.toString().trim() - -val MediaType.mimeType: String get() = "${type()}/${subtype()}" - -fun EditText.selectLast() { - setSelection(text.length) -} - -fun T?.requireNotNull(): T { - return requireNotNull(this) -} - - -fun String?.orDots(count: Int = 3): String { - return this ?: ("." * count) -} - -private operator fun String.times(count: Int): String { - val builder = StringBuilder() - for (i in 0 until count) { - builder.append(this) - } - - return builder.toString() -} diff --git a/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt b/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt deleted file mode 100644 index 965aa08c..00000000 --- a/app/src/main/kotlin/com/meloda/fast/model/ListModels.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.meloda.fast.model - -abstract class DataItem { - abstract val dataItemId: IdType - - object Header : DataItem() { - override val dataItemId = Int.MIN_VALUE - } - - object Footer : DataItem() { - override val dataItemId = Int.MIN_VALUE + 1 - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/model/SelectableItem.kt b/app/src/main/kotlin/com/meloda/fast/model/SelectableItem.kt index e17b0aa4..1eb415c4 100644 --- a/app/src/main/kotlin/com/meloda/fast/model/SelectableItem.kt +++ b/app/src/main/kotlin/com/meloda/fast/model/SelectableItem.kt @@ -6,17 +6,10 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Parcelize -open class SelectableItem constructor( - @Ignore - val selectableItemId: Int = 0 -) : DataItem(), Parcelable { +open class SelectableItem : Parcelable { @Ignore @IgnoredOnParcel var isSelected: Boolean = false - @Ignore - @IgnoredOnParcel - override val dataItemId = selectableItemId - } \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt b/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt index 208b2b9a..e293551f 100644 --- a/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt +++ b/app/src/main/kotlin/com/meloda/fast/model/UpdateItem.kt @@ -18,7 +18,7 @@ data class UpdateItem( val originalName: String, val fileSize: Int, val preRelease: Int, - val downloadLink: String + val downloadLink: String, ) : Parcelable { fun isMandatory(): Boolean = mandatory == 1 @@ -29,7 +29,25 @@ data class UpdateItem( return Gson().toJson(this) } + companion object { + val EMPTY = UpdateItem( + id = 0, + versionName = "1.0.0", + versionCode = 2, + mandatory = 1, + changelog = "Some kind of simple changelog", + enabled = 1, + fileName = "bruhmeme.apk", + date = System.currentTimeMillis(), + extension = "", + originalName = "", + fileSize = 0, + preRelease = 0, + downloadLink = "https://c4.kemono.party/data/98/8c/988cf166f1ee9cd318e2407e6bfbabf60bffa53ed229ea0b2434009f1598e039.png?f=JessieGym002b4pt.png" + ) + } + } @Parcelize -data class UpdateActualUrl(val url: String) : Parcelable \ No newline at end of file +data class UpdateActualUrl(val url: String) : Parcelable diff --git a/app/src/main/kotlin/com/meloda/fast/model/base/AdapterDiffItem.kt b/app/src/main/kotlin/com/meloda/fast/model/base/AdapterDiffItem.kt new file mode 100644 index 00000000..015fde04 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/model/base/AdapterDiffItem.kt @@ -0,0 +1,14 @@ +package com.meloda.fast.model.base + +interface AdapterDiffItem { + + val id: Int + + fun areItemsTheSame(newItem: AdapterDiffItem): Boolean { + return id == newItem.id + } + + fun areContentsTheSame(newItem: AdapterDiffItem): Boolean { + return this == newItem + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/model/base/DisplayableItem.kt b/app/src/main/kotlin/com/meloda/fast/model/base/DisplayableItem.kt new file mode 100644 index 00000000..96878c4e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/model/base/DisplayableItem.kt @@ -0,0 +1,3 @@ +package com.meloda.fast.model.base + +interface DisplayableItem diff --git a/app/src/main/kotlin/com/meloda/fast/model/base/UiImage.kt b/app/src/main/kotlin/com/meloda/fast/model/base/UiImage.kt new file mode 100644 index 00000000..f5042622 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/model/base/UiImage.kt @@ -0,0 +1,94 @@ +package com.meloda.fast.model.base + +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.widget.ImageView +import androidx.annotation.ColorInt +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import com.meloda.fast.ext.GlideParams +import com.meloda.fast.ext.ImageLoader.loadWithGlide + +sealed class UiImage { + + data class Resource(@DrawableRes val resId: Int) : UiImage() + + data class Simple(val drawable: Drawable?) : UiImage() + + data class Color(@ColorInt val color: Int) : UiImage() + + data class ColorResource(@ColorRes val resId: Int) : UiImage() + + data class Url(val url: String) : UiImage() + + fun extractUrl(): String? = when (this) { + is Url -> this.url + else -> null + } + + fun getResourceId(): Int? = when(this) { + is Resource -> this.resId + else -> null + } +} + +fun ImageView.setImage(image: UiImage, glideBlock: GlideParams.() -> Unit) { + val glideParams = GlideParams() + glideBlock.invoke(glideParams) + this.setImage(image, glideParams) +} + +fun ImageView.setImage(image: UiImage, glideParams: GlideParams? = null) { + image.attachTo(this, glideParams) +} + +fun UiImage?.attachTo(imageView: ImageView, glideBlock: GlideParams.() -> Unit) { + val glideParams = GlideParams() + glideBlock.invoke(glideParams) + this.attachTo(imageView, glideParams) +} + +fun UiImage?.attachTo(imageView: ImageView, glideParams: GlideParams? = null) { + when (this) { + is UiImage.Simple -> imageView.setImageDrawable(drawable) + is UiImage.Resource -> imageView.setImageResource(resId) + is UiImage.Color -> imageView.setImageDrawable(ColorDrawable(color)) + is UiImage.ColorResource -> imageView.setImageDrawable( + ColorDrawable(ContextCompat.getColor(imageView.context, resId)) + ) + + is UiImage.Url -> glideParams?.let { params -> + params.imageUrl = url + imageView.loadWithGlide(params) + } + + else -> Unit + } +} + +fun UiImage?.asDrawable(context: Context): Drawable? { + return when (this) { + is UiImage.Simple -> drawable + is UiImage.Resource -> ContextCompat.getDrawable(context, resId) + is UiImage.Color -> ColorDrawable(color) + is UiImage.ColorResource -> ColorDrawable(ContextCompat.getColor(context, resId)) + else -> null + } +} + +@Composable +fun UiImage?.getImage(): Any? { + val context = LocalContext.current + return when(this) { + is UiImage.Color -> ColorDrawable(color) + is UiImage.ColorResource -> ColorDrawable(ContextCompat.getColor(context, resId)) + is UiImage.Resource -> ContextCompat.getDrawable(context, resId) + is UiImage.Simple -> drawable + is UiImage.Url -> url + null -> null + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/model/base/UiText.kt b/app/src/main/kotlin/com/meloda/fast/model/base/UiText.kt new file mode 100644 index 00000000..6fdcac24 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/model/base/UiText.kt @@ -0,0 +1,42 @@ +package com.meloda.fast.model.base + +import android.content.Context +import android.os.Parcelable +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue + +@Parcelize +sealed class UiText : Parcelable { + + data class Resource(@StringRes val resId: Int) : UiText() + + data class ResourceParams( + @StringRes val value: Int, + val args: List<@RawValue Any?>, + ) : UiText() + + data class Simple(val text: String) : UiText() + + data class QuantityResource(@PluralsRes val resId: Int, val quantity: Int) : UiText() +} + +fun UiText?.parseString(context: Context): String? { + return when (this) { + is UiText.Resource -> context.getString(resId) + is UiText.ResourceParams -> { + val processedArgs = args.map { any -> + when (any) { + is UiText -> any.parseString(context) + else -> any + } + } + context.getString(value, *processedArgs.toTypedArray()) + } + + is UiText.QuantityResource -> context.resources.getQuantityString(resId, quantity, quantity) + is UiText.Simple -> text + else -> null + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/receiver/StopLongPollServiceReceiver.kt b/app/src/main/kotlin/com/meloda/fast/receiver/StopLongPollServiceReceiver.kt new file mode 100644 index 00000000..6379f645 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/receiver/StopLongPollServiceReceiver.kt @@ -0,0 +1,36 @@ +package com.meloda.fast.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.edit +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.screens.main.activity.LongPollState +import com.meloda.fast.screens.main.activity.MainActivity +import com.meloda.fast.screens.settings.SettingsFragment +import kotlinx.coroutines.flow.update + +class StopLongPollServiceReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_STOP) { + val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1) + + if (notificationId != -1) { + NotificationManagerCompat.from(context).cancel(notificationId) + } + + AppGlobal.preferences.edit { + putBoolean(SettingsFragment.KEY_FEATURES_LONG_POLL_IN_BACKGROUND, false) + } + + MainActivity.longPollState.update { LongPollState.Stop } + MainActivity.longPollState.update { LongPollState.DefaultService } + } + } + + companion object { + const val ACTION_STOP = "stop_long_poll" + const val NOTIFICATION_ID = "notification_id" + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/CaptchaScreens.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/CaptchaScreens.kt new file mode 100644 index 00000000..e6542189 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/CaptchaScreens.kt @@ -0,0 +1,11 @@ +package com.meloda.fast.screens.captcha + +import com.github.terrakok.cicerone.androidx.FragmentScreen +import com.meloda.fast.screens.captcha.presentation.CaptchaFragment + +object CaptchaScreens { + + fun captchaScreen() = FragmentScreen(key = "CaptchaScreen") { + CaptchaFragment.newInstance() + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/di/CaptchaDI.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/di/CaptchaDI.kt new file mode 100644 index 00000000..90de93a8 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/di/CaptchaDI.kt @@ -0,0 +1,36 @@ +package com.meloda.fast.screens.captcha.di + +import com.meloda.fast.di.navigationModule +import com.meloda.fast.screens.captcha.presentation.CaptchaCoordinator +import com.meloda.fast.screens.captcha.presentation.CaptchaCoordinatorImpl +import com.meloda.fast.screens.captcha.presentation.CaptchaViewModelImpl +import com.meloda.fast.screens.captcha.screen.CaptchaScreen +import com.meloda.fast.screens.captcha.validation.CaptchaValidator +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.core.module.dsl.singleOf +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope +import org.koin.dsl.bind +import org.koin.dsl.module + +val captchaModule = module { + val moduleQualifier = named("captcha") + + includes(navigationModule) + + single(moduleQualifier) { screen().resultFlow } + single { screen().getArguments() } + + single { + CaptchaCoordinatorImpl( + resultFlow = get(moduleQualifier), + router = get() + ) + } bind CaptchaCoordinator::class + + singleOf(::CaptchaValidator) + viewModelOf(::CaptchaViewModelImpl) +} + +private fun Scope.screen(): CaptchaScreen = get() + diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaScreenState.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaScreenState.kt new file mode 100644 index 00000000..ba8062b7 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaScreenState.kt @@ -0,0 +1,18 @@ +package com.meloda.fast.screens.captcha.model + +data class CaptchaScreenState( + val captchaSid: String, + val captchaImage: String, + val captchaCode: String, + val codeError: Boolean +) { + + companion object { + val EMPTY = CaptchaScreenState( + captchaSid = "", + captchaImage = "", + captchaCode = "", + codeError = false + ) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaValidationResult.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaValidationResult.kt new file mode 100644 index 00000000..6fddec93 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/model/CaptchaValidationResult.kt @@ -0,0 +1,8 @@ +package com.meloda.fast.screens.captcha.model + +sealed class CaptchaValidationResult { + object Empty : CaptchaValidationResult() + object Valid : CaptchaValidationResult() + + fun isValid() = this == Valid +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaCoordinator.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaCoordinator.kt new file mode 100644 index 00000000..45164b7e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaCoordinator.kt @@ -0,0 +1,21 @@ +package com.meloda.fast.screens.captcha.presentation + +import com.github.terrakok.cicerone.Router +import com.meloda.fast.screens.captcha.screen.CaptchaResult +import kotlinx.coroutines.flow.MutableSharedFlow + +interface CaptchaCoordinator { + + fun finishWithResult(result: CaptchaResult) +} + +class CaptchaCoordinatorImpl constructor( + val resultFlow: MutableSharedFlow, + val router: Router +) : CaptchaCoordinator { + + override fun finishWithResult(result: CaptchaResult) { + resultFlow.tryEmit(result) + router.exit() + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaFragment.kt new file mode 100644 index 00000000..292da0f2 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaFragment.kt @@ -0,0 +1,234 @@ +package com.meloda.fast.screens.captcha.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.request.ImageRequest +import com.meloda.fast.R +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.screens.captcha.model.CaptchaScreenState +import com.meloda.fast.ui.* +import com.meloda.fast.ui.widgets.CoilImage +import com.meloda.fast.ui.widgets.TextFieldErrorText +import org.koin.androidx.viewmodel.ext.android.viewModel + +class CaptchaFragment : BaseFragment() { + + private val viewModel: CaptchaViewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier + .statusBarsPadding() + .navigationBarsPadding() + .imePadding() + ) { + val state by viewModel.screenState.collectAsStateWithLifecycle() + + CaptchaScreen( + onCancelButtonClicked = viewModel::onCancelButtonClicked, + onCodeInputChanged = viewModel::onCodeInputChanged, + onTextFieldDoneClicked = viewModel::onTextFieldDoneClicked, + onDoneButtonClicked = viewModel::onDoneButtonClicked, + state = state + ) + } + } + } + } + + @Preview + @Composable + fun CaptchaScreenPreview() { + AppTheme { + Surface(color = MaterialTheme.colorScheme.background) { + CaptchaScreen( + onCancelButtonClicked = {}, + onCodeInputChanged = {}, + onTextFieldDoneClicked = {}, + onDoneButtonClicked = {}, + state = CaptchaScreenState.EMPTY + ) + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun CaptchaScreen( + onCancelButtonClicked: () -> Unit, + onCodeInputChanged: (String) -> Unit, + onTextFieldDoneClicked: () -> Unit, + onDoneButtonClicked: () -> Unit, + state: CaptchaScreenState, + ) { + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxSize() + .padding(30.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + ExtendedFloatingActionButton( + onClick = onCancelButtonClicked, + text = { + Text( + text = "Cancel", + color = MaterialTheme.colorScheme.primary + ) + }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_round_close_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + ) + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Captcha", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(38.dp)) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "To proceed with your action, enter a code from the picture", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.weight(0.5f) + ) + Spacer(modifier = Modifier.width(24.dp)) + + CoilImage( + model = ImageRequest.Builder(LocalContext.current) + .data(state.captchaImage) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier + .border( + 2.dp, + MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(10.dp) + ) + .clip(RoundedCornerShape(10.dp)) + .height(48.dp) + .width(130.dp), + contentScale = ContentScale.FillBounds, + previewPainter = painterResource(id = R.drawable.test_captcha) + ) + } + + Spacer(modifier = Modifier.height(30.dp)) + + var code by remember { mutableStateOf(TextFieldValue(state.captchaCode)) } + val showError = state.codeError + + TextField( + value = code, + onValueChange = { newText -> + code = newText + onCodeInputChanged(newText.text) + }, + label = { Text(text = "Code") }, + placeholder = { Text(text = "Code") }, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)), + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.round_qr_code_24), + contentDescription = null, + tint = if (showError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + }, + shape = RoundedCornerShape(10.dp), + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + onTextFieldDoneClicked() + } + ), + isError = showError + ) + + AnimatedVisibility(visible = showError) { + TextFieldErrorText(text = "Field must not be empty") + } + } + + FloatingActionButton( + onClick = onDoneButtonClicked, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_round_done_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + activity?.onBackPressedDispatcher?.addCallback { + viewModel.onBackButtonClicked() + } + } + + companion object { + + fun newInstance(): CaptchaFragment { + return CaptchaFragment() + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaViewModel.kt new file mode 100644 index 00000000..f1da9c4e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/presentation/CaptchaViewModel.kt @@ -0,0 +1,79 @@ +package com.meloda.fast.screens.captcha.presentation + +import androidx.lifecycle.ViewModel +import com.meloda.fast.ext.updateValue +import com.meloda.fast.screens.captcha.model.CaptchaScreenState +import com.meloda.fast.screens.captcha.screen.CaptchaArguments +import com.meloda.fast.screens.captcha.screen.CaptchaResult +import com.meloda.fast.screens.captcha.validation.CaptchaValidator +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +interface CaptchaViewModel { + + val screenState: StateFlow + + fun onCodeInputChanged(newCode: String) + + fun onBackButtonClicked() + fun onCancelButtonClicked() + fun onTextFieldDoneClicked() + fun onDoneButtonClicked() +} + +class CaptchaViewModelImpl constructor( + private val coordinator: CaptchaCoordinator, + private val validator: CaptchaValidator, + arguments: CaptchaArguments +) : CaptchaViewModel, ViewModel() { + + override val screenState = MutableStateFlow(CaptchaScreenState.EMPTY) + + init { + screenState.updateValue( + screenState.value.copy( + captchaSid = arguments.captchaSid, + captchaImage = arguments.captchaImage + ) + ) + } + + override fun onCodeInputChanged(newCode: String) { + val newState = screenState.value.copy(captchaCode = newCode.trim()) + screenState.update { newState } + processValidation() + } + + override fun onBackButtonClicked() { + onCancelButtonClicked() + } + + override fun onCancelButtonClicked() { + coordinator.finishWithResult(CaptchaResult.Cancelled) + } + + override fun onTextFieldDoneClicked() { + onDoneButtonClicked() + } + + override fun onDoneButtonClicked() { + if (!processValidation()) return + + val captchaSid = screenState.value.captchaSid + val captchaCode = screenState.value.captchaCode + + coordinator.finishWithResult( + CaptchaResult.Success( + sid = captchaSid, + code = captchaCode + ) + ) + } + + private fun processValidation(): Boolean { + val isValid = validator.validate(screenState.value).isValid() + screenState.updateValue(screenState.value.copy(codeError = !isValid)) + return isValid + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaArguments.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaArguments.kt new file mode 100644 index 00000000..751f2223 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaArguments.kt @@ -0,0 +1,3 @@ +package com.meloda.fast.screens.captcha.screen + +data class CaptchaArguments(val captchaSid: String, val captchaImage: String) diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaResult.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaResult.kt new file mode 100644 index 00000000..1504a66e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaResult.kt @@ -0,0 +1,6 @@ +package com.meloda.fast.screens.captcha.screen + +sealed class CaptchaResult { + object Cancelled : CaptchaResult() + data class Success(val sid: String, val code: String) : CaptchaResult() +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaScreen.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaScreen.kt new file mode 100644 index 00000000..7aeca346 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/screen/CaptchaScreen.kt @@ -0,0 +1,21 @@ +package com.meloda.fast.screens.captcha.screen + +import com.github.terrakok.cicerone.Router +import com.meloda.fast.base.screen.AppScreen +import com.meloda.fast.base.screen.createResultFlow +import com.meloda.fast.screens.captcha.CaptchaScreens +import kotlin.properties.Delegates + +class CaptchaScreen : AppScreen { + + override val resultFlow = createResultFlow() + + override var args: CaptchaArguments by Delegates.notNull() + + override fun show(router: Router, args: CaptchaArguments) { + this.args = args + router.navigateTo(CaptchaScreens.captchaScreen()) + } +} + + diff --git a/app/src/main/kotlin/com/meloda/fast/screens/captcha/validation/CaptchaValidator.kt b/app/src/main/kotlin/com/meloda/fast/screens/captcha/validation/CaptchaValidator.kt new file mode 100644 index 00000000..d3e332a2 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/captcha/validation/CaptchaValidator.kt @@ -0,0 +1,14 @@ +package com.meloda.fast.screens.captcha.validation + +import com.meloda.fast.screens.captcha.model.CaptchaScreenState +import com.meloda.fast.screens.captcha.model.CaptchaValidationResult + +class CaptchaValidator { + + fun validate(screenState: CaptchaScreenState): CaptchaValidationResult { + return when { + screenState.captchaCode.isEmpty() -> CaptchaValidationResult.Empty + else -> CaptchaValidationResult.Valid + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoFragment.kt new file mode 100644 index 00000000..1131d301 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoFragment.kt @@ -0,0 +1,299 @@ +package com.meloda.fast.screens.chatinfo + +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import by.kirich1409.viewbindingdelegate.viewBinding +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.tabs.TabLayoutMediator +import com.meloda.fast.R +import com.meloda.fast.api.model.VkChat +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.api.model.domain.VkConversationDomain +import com.meloda.fast.base.viewmodel.BaseViewModelFragment +import com.meloda.fast.base.viewmodel.VkEvent +import com.meloda.fast.databinding.FragmentChatInfoBinding +import com.meloda.fast.ext.ImageLoader.loadWithGlide +import com.meloda.fast.ext.getParcelableCompat +import com.meloda.fast.ext.gone +import com.meloda.fast.ext.orDots +import com.meloda.fast.ext.visible +import com.meloda.fast.screens.messages.MessagesHistoryFragment +import dev.chrisbanes.insetter.applyInsetter +import org.koin.androidx.viewmodel.ext.android.viewModel +import java.text.SimpleDateFormat +import java.util.Locale + +class ChatInfoFragment : BaseViewModelFragment(R.layout.fragment_chat_info) { + + companion object { + const val KeyConfirmRemoveChatUser = "confirm_remove_chat_user" + const val KeyRemoveChatUser = "remove_chat_user" + const val ArgMemberId = "member_id" + + private const val ArgConversation = "conversation" + private const val ArgUser = "user" + private const val ArgGroup = "group" + + fun newInstance( + conversation: VkConversationDomain, + user: VkUser?, + group: VkGroup?, + ): ChatInfoFragment { + val fragment = ChatInfoFragment() + fragment.arguments = bundleOf( + ArgConversation to conversation, + ArgUser to user, + ArgGroup to group + ) + + return fragment + } + } + + override val viewModel: ChatInfoViewModel by viewModel() + + private val binding by viewBinding(FragmentChatInfoBinding::bind) + + private val user: VkUser? by lazy { + requireArguments().getParcelableCompat(MessagesHistoryFragment.ARG_USER, VkUser::class.java) + } + + private val group: VkGroup? by lazy { + requireArguments().getParcelableCompat( + MessagesHistoryFragment.ARG_GROUP, + VkGroup::class.java + ) + } + + private val conversation: VkConversationDomain by lazy { + requireNotNull( + requireArguments().getParcelableCompat( + MessagesHistoryFragment.ARG_CONVERSATION, + VkConversationDomain::class.java + ) + ) + } + + private val chatProfiles: MutableList = mutableListOf() + private val chatGroups: MutableList = mutableListOf() + private val chatMembers: MutableList = mutableListOf() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.getConversationMembers(conversation.id) + + val title = when { + conversation.isChat() -> conversation.conversationTitle + conversation.isUser() -> user?.toString() + conversation.isGroup() -> group?.name + else -> null + } + + + binding.toolbar.applyInsetter { + type(statusBars = true) { padding() } + } + binding.progresBar.applyInsetter { + type(navigationBars = true) { padding() } + } + binding.toolbar.title = title.orDots() + + updateStatus() + + val avatar = when { + conversation.isUser() -> user?.photo200 + conversation.isGroup() -> group?.photo200 + conversation.isChat() -> conversation.conversationPhoto + else -> null + } + + val avatarImageView = binding.toolbar.avatarImageView + avatarImageView.visible() + avatarImageView.loadWithGlide { + imageUrl = avatar + asCircle = true + crossFade = true + } + + binding.toolbar.avatarClickAction = { + showAvatarOptions() + } + binding.toolbar.startButtonClickAction = + { requireActivity().onBackPressedDispatcher.onBackPressed() } + + binding.viewPager.offscreenPageLimit = getTabsCount() - 1 + + childFragmentManager.setFragmentResultListener( + KeyConfirmRemoveChatUser, + this + ) { _, bundle -> + val memberId = bundle.getInt(ArgMemberId) + showConfirmRemoveMemberAlert(memberId) + } + } + + override fun onEvent(event: VkEvent) { + super.onEvent(event) + + when (event) { + is GetConversationMembersEvent -> { + fillChatInfo(event) + } + + is RemoveChatUserEvent -> { + val memberId = event.memberId + childFragmentManager.setFragmentResult( + KeyRemoveChatUser, bundleOf( + ArgMemberId to memberId + ) + ) + } + } + } + + // TODO: 17.04.2023, Danil Nikolaev: handle loading + private fun onProgressStart() { + binding.tabs.gone() + binding.viewPager.gone() + binding.progresBar.visible() + } + + private fun onProgressStop() { + binding.tabs.visible() + binding.viewPager.visible() + binding.progresBar.gone() + } + + private fun fillChatInfo(event: GetConversationMembersEvent) { + val onlineMembers = event.profiles.filter { it.online } + updateStatus(onlineMembers.size) + + val eventChatMembers = event.items.map { vkChatMember -> + val memberUser: VkUser? = if (vkChatMember.memberId < 0) null + else event.profiles.firstOrNull { it.id == vkChatMember.memberId } + + val memberGroup: VkGroup? = if (vkChatMember.memberId > 0) null + else event.groups.firstOrNull { it.id == vkChatMember.memberId } + + VkChat.ChatMember( + id = vkChatMember.memberId, + type = if (vkChatMember.memberId > 0) VkChat.ChatMember.ChatMemberType.Profile else VkChat.ChatMember.ChatMemberType.Group, + isOnline = memberUser?.online, + lastSeen = memberUser?.lastSeen, + name = memberGroup?.name, + firstName = memberUser?.firstName, + lastName = memberUser?.lastName, + invitedBy = vkChatMember.invitedBy, + photo50 = null, + photo100 = null, + photo200 = memberUser?.photo200 ?: memberGroup?.photo200, + isOwner = vkChatMember.isOwner, + isAdmin = vkChatMember.isAdmin, + canKick = vkChatMember.canKick + ) + } + + chatProfiles.addAll(event.profiles) + chatGroups.addAll(event.groups) + chatMembers.addAll(eventChatMembers) + prepareTabs() + } + + private fun updateStatus(onlineMembersCount: Int? = null) { + val status = when { + conversation.isChat() -> { + val membersCountText = "${conversation.membersCount} members" + if (onlineMembersCount == null) membersCountText + else { + "$membersCountText, $onlineMembersCount online" + } + } + + conversation.isUser() -> when { + // TODO: 9/15/2021 user normal time + user?.online == true -> "Online" + user?.lastSeen != null -> "Last seen at ${ + SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).format(user?.lastSeen!! * 1000L) + }" + + else -> if (user?.lastSeenStatus != null) "Last seen ${user?.lastSeenStatus!!}" else "Last seen recently" + } + + conversation.isGroup() -> if (group?.membersCount != null) "${group?.membersCount} members" else "Group" + else -> null + } + + binding.toolbar.subtitle = status.orDots() + + } + + fun getTabsCount(): Int { + return if (conversation.isChat()) 6 else 5 + } + + fun createTabFragment(position: Int): Fragment { + if (conversation.isChat() && position == 0) { + return ChatInfoMembersFragment.newInstance( + chatProfiles, + chatGroups, + chatMembers + ) + } + + return Fragment() + } + + private fun prepareTabs() { + val titles = mutableListOf("Members", "Photos", "Videos", "Audios", "Files", "Links") + + if (!conversation.isChat()) { + titles.removeAt(0) + } + + binding.viewPager.adapter = ChatInfoPagerAdapter(this) + + TabLayoutMediator(binding.tabs, binding.viewPager) { tab, position -> + tab.text = titles[position] + }.attach() + } + + private fun showConfirmRemoveMemberAlert(memberId: Int) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.warning) + .setMessage(R.string.confirm_remove_chat_user) + .setPositiveButton(R.string.yes) { _, _ -> + viewModel.removeChatUser(conversation.localId, memberId) + } + .setNegativeButton(R.string.no, null) + .show() + } + + private fun showAvatarOptions() { + val options = mutableListOf("Open") + + if (conversation.canChangeInfo) { + options += listOf("Edit", "Delete") + } + + MaterialAlertDialogBuilder(requireContext()) + .setItems(options.toTypedArray()) { _, which -> + when (options[which]) { + "Open" -> { + Toast.makeText(requireContext(), "Open photo", Toast.LENGTH_SHORT).show() + } + + else -> + Toast.makeText(requireContext(), "Change info", Toast.LENGTH_SHORT).show() + } + } + .show() + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoMembersAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoMembersAdapter.kt new file mode 100644 index 00000000..b9a45f04 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoMembersAdapter.kt @@ -0,0 +1,111 @@ +package com.meloda.fast.screens.chatinfo + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.model.VkChat +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.base.adapter.BaseAdapter +import com.meloda.fast.base.adapter.BaseHolder +import com.meloda.fast.databinding.ItemChatMemberBinding +import com.meloda.fast.ext.ImageLoader.loadWithGlide +import com.meloda.fast.ext.toggleVisibility +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.Objects + +class ChatInfoMembersAdapter( + context: Context, + preAddedValues: List, + private val profiles: List, + private val groups: List, + private val confirmRemoveMemberAction: ((memberId: Int) -> Unit)? = null, +) : BaseAdapter( + context, + comparator, + preAddedValues +) { + + companion object { + val comparator = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: VkChat.ChatMember, + newItem: VkChat.ChatMember, + ): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame( + oldItem: VkChat.ChatMember, + newItem: VkChat.ChatMember, + ): Boolean { + return Objects.deepEquals(oldItem, newItem) + } + + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { + return Holder(ItemChatMemberBinding.inflate(inflater, parent, false)) + } + + inner class Holder( + private val binding: ItemChatMemberBinding, + ) : BaseHolder(binding.root) { + + private val colorOnBackground = ContextCompat.getColor(context, R.color.colorOnBackground) + private val colorPrimary = ContextCompat.getColor(context, R.color.colorPrimary) + + override fun bind(position: Int) { + val chatMember = getItem(position) + + binding.avatar.loadWithGlide { + imageUrl = chatMember.photo200 + crossFade = true + placeholderColor = Color.GRAY + errorColor = Color.RED + } + + val title = chatMember.name ?: "${chatMember.firstName} ${chatMember.lastName}" + binding.title.text = title + + binding.online.toggleVisibility(chatMember.isProfile()) + binding.online.text = + if (chatMember.isOnline == true) "Online" + else if (chatMember.lastSeen != null) "Last seen at ${ + SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).format(chatMember.lastSeen * 1000L) + }" + else "Offline" + + binding.star.toggleVisibility(chatMember.isAdmin || chatMember.isOwner) + binding.star.imageTintList = + ColorStateList.valueOf( + if (chatMember.isOwner) colorPrimary + else colorOnBackground + ) + + binding.remove.toggleVisibility( + chatMember.canKick || chatMember.id == UserConfig.userId + ) + binding.remove.setOnClickListener { confirmRemoveMemberAction?.invoke(chatMember.id) } + } + } + + fun searchMemberIndex(memberId: Int): Int? { + for (i in indices) { + val member = getItem(i) + if (member.id == memberId) return i + } + + return null + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoMembersFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoMembersFragment.kt new file mode 100644 index 00000000..5c54c835 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoMembersFragment.kt @@ -0,0 +1,83 @@ +package com.meloda.fast.screens.chatinfo + +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.setFragmentResultListener +import by.kirich1409.viewbindingdelegate.viewBinding +import com.meloda.fast.R +import com.meloda.fast.api.model.VkChat +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.databinding.FragmentChatInfoMembersBinding +import com.meloda.fast.ext.dpToPx +import com.meloda.fast.view.SpaceItemDecoration +import dev.chrisbanes.insetter.applyInsetter + +class ChatInfoMembersFragment : BaseFragment(R.layout.fragment_chat_info_members) { + + companion object { + + private const val ArgProfiles = "profiles" + private const val ArgGroups = "groups" + private const val ArgMembers = "members" + + fun newInstance( + profiles: List, + groups: List, + members: List + ): ChatInfoMembersFragment { + val fragment = ChatInfoMembersFragment() + fragment.arguments = bundleOf( + ArgProfiles to profiles, + ArgGroups to groups, + ArgMembers to members + ) + + return fragment + } + } + + private val binding by viewBinding(FragmentChatInfoMembersBinding::bind) + + @Suppress("UNCHECKED_CAST") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.recyclerView.applyInsetter { + type(navigationBars = true) { padding() } + } + binding.recyclerView.addItemDecoration(SpaceItemDecoration(topMargin = 8.dpToPx())) + + // TODO: 17.04.2023, Danil Nikolaev: use single data class as parcelable + val profiles = requireArguments().getSerializable(ArgProfiles) as List + val groups = requireArguments().getSerializable(ArgGroups) as List + val members = requireArguments().getSerializable(ArgMembers) as List + + val adapter = + ChatInfoMembersAdapter( + requireContext(), + members, + profiles, + groups, + confirmRemoveMemberAction = { memberId -> + setFragmentResult( + ChatInfoFragment.KeyConfirmRemoveChatUser, + bundleOf(ChatInfoFragment.ArgMemberId to memberId) + ) + } + ) + binding.recyclerView.adapter = adapter + binding.recyclerView.itemAnimator = null + + setFragmentResultListener(ChatInfoFragment.KeyRemoveChatUser) { _, bundle -> + val memberId = bundle.getInt(ChatInfoFragment.ArgMemberId) + adapter.searchMemberIndex(memberId)?.let { index -> + adapter.removeAt(index) + } + } + } + +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoPagerAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoPagerAdapter.kt new file mode 100644 index 00000000..a93a7050 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoPagerAdapter.kt @@ -0,0 +1,18 @@ +package com.meloda.fast.screens.chatinfo + +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter + +class ChatInfoPagerAdapter( + private val fragment: ChatInfoFragment +) : FragmentStateAdapter(fragment) { + + override fun getItemCount(): Int { + return fragment.getTabsCount() + } + + override fun createFragment(position: Int): Fragment { + return fragment.createTabFragment(position) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoViewModel.kt new file mode 100644 index 00000000..3bf2e76c --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/ChatInfoViewModel.kt @@ -0,0 +1,58 @@ +package com.meloda.fast.screens.chatinfo + +import androidx.lifecycle.viewModelScope +import com.meloda.fast.api.VKConstants +import com.meloda.fast.api.model.VkChatMember +import com.meloda.fast.api.model.VkGroup +import com.meloda.fast.api.model.VkUser +import com.meloda.fast.base.viewmodel.DeprecatedBaseViewModel +import com.meloda.fast.base.viewmodel.VkEvent +import com.meloda.fast.data.messages.MessagesRepository +import kotlinx.coroutines.launch + +class ChatInfoViewModel constructor( + private val messagesRepository: MessagesRepository +) : DeprecatedBaseViewModel() { + + fun getConversationMembers(peerId: Int) = viewModelScope.launch { + makeJob( + { + messagesRepository.getConversationMembers( + peerId, + extended = true, + fields = VKConstants.ALL_FIELDS + ) + }, + onAnswer = { + val response = it.response ?: return@makeJob + + val items = response.items.map { member -> member.asVkChatMember() } + val profiles = response.profiles.orEmpty().map { profile -> profile.mapToDomain() } + val groups = response.groups.orEmpty().map { group -> group.mapToDomain() } + + sendEvent(GetConversationMembersEvent(response.count, items, profiles, groups)) + } + ) + } + + fun removeChatUser(chatId: Int, memberId: Int) = viewModelScope.launch { + makeJob( + { messagesRepository.removeChatUser(chatId, memberId) }, + onAnswer = { + sendEvent(RemoveChatUserEvent(chatId, memberId)) + } + ) + } + +} + +data class GetConversationMembersEvent( + val count: Int, + val items: List, + val profiles: List, + val groups: List +) : VkEvent() + +data class RemoveChatUserEvent( + val chatId: Int, val memberId: Int +) : VkEvent() diff --git a/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/di/ChatInfoDI.kt b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/di/ChatInfoDI.kt new file mode 100644 index 00000000..ac1f6db7 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/chatinfo/di/ChatInfoDI.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.screens.chatinfo.di + +import com.meloda.fast.screens.chatinfo.ChatInfoViewModel +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val chatInfoModule = module { + viewModelOf(::ChatInfoViewModel) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationCompose.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationCompose.kt new file mode 100644 index 00000000..1a094176 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationCompose.kt @@ -0,0 +1,256 @@ +package com.meloda.fast.screens.conversations + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +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.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.model.presentation.VkConversationUi +import com.meloda.fast.ext.combinedClickableSound +import com.meloda.fast.ext.getString +import com.meloda.fast.ext.orDots +import com.meloda.fast.model.base.getImage +import com.meloda.fast.ui.widgets.CoilImage + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Conversation( + onItemClick: (VkConversationUi) -> Unit, + onItemLongClick: (VkConversationUi) -> Unit, + conversation: VkConversationUi, + maxLines: Int +) { + Box( + modifier = Modifier + .fillMaxWidth() + .combinedClickableSound( + onClick = { onItemClick(conversation) }, + onLongClick = { onItemLongClick(conversation) } + ) + ) { + if (conversation.isUnread) { + Box( + modifier = Modifier + .matchParentSize() + .padding(start = 8.dp) + .clip( + RoundedCornerShape( + topStart = 34.dp, + bottomStart = 34.dp + ) + ) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)) + ) + } + + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.width(16.dp)) + Box(modifier = Modifier.size(56.dp)) { + + if (conversation.id == UserConfig.userId) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + ) { + Image( + modifier = Modifier + .align(Alignment.Center) + .size(32.dp), + painter = painterResource(id = R.drawable.ic_round_bookmark_border_24), + contentDescription = null + ) + } + } else { + Image( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + painter = painterResource(id = R.drawable.ic_account_circle_cut), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.outline) + ) + CoilImage( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + contentDescription = null, + model = conversation.avatar.getImage(), + previewPainter = painterResource(id = R.drawable.ic_account_circle_cut), + ) + } + + if (conversation.isPinned) { + Box( + modifier = Modifier + .clip(CircleShape) + .defaultMinSize(minWidth = 20.dp, minHeight = 20.dp) + .background(MaterialTheme.colorScheme.outline) + ) { + Image( + modifier = Modifier + .height(14.dp) + .align(Alignment.Center), + painter = painterResource(id = R.drawable.ic_round_push_pin_24), + contentDescription = null + ) + } + } + + if (conversation.isOnline) { + Box( + modifier = Modifier + .clip(CircleShape) + .size(18.dp) + .background( + if (conversation.isUnread) { + MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp) + } else { + MaterialTheme.colorScheme.background + } + ) + .padding(2.dp) + .align(Alignment.BottomEnd) + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .matchParentSize() + .background(MaterialTheme.colorScheme.primary) + ) + } + } + + if (conversation.isBirthday) { + Box( + modifier = Modifier + .clip(CircleShape) + .size(16.dp) + .background( + if (conversation.isUnread) { + MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp) + } else { + MaterialTheme.colorScheme.background + } + ) + .padding(2.dp) + .align(Alignment.TopEnd) + ) { + Box( + modifier = Modifier + .clip(CircleShape) + .matchParentSize() + .background(Color(0xFFB00B69)) + ) { + Image( + modifier = Modifier + .align(Alignment.Center) + .size(10.dp), + painter = painterResource(id = R.drawable.round_cake_24), + contentDescription = null + ) + } + } + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = conversation.title.getString().orDots(), + modifier = Modifier, + minLines = 1, + maxLines = maxLines, + style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp) + ) + + Row { + conversation.attachmentImage?.getResourceId()?.let { resId -> + Column { + Spacer(modifier = Modifier.height(4.dp)) + Image( + modifier = Modifier.size(14.dp), + painter = painterResource(id = resId), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer) + ) + } + + Spacer(modifier = Modifier.width(2.dp)) + } + + Text( + modifier = Modifier.weight(1f), + text = conversation.message, + minLines = 1, + maxLines = maxLines, + style = MaterialTheme.typography.bodyLarge, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.width(4.dp)) + Column { + Text(text = conversation.date) + + conversation.unreadCount?.let { count -> + Spacer(modifier = Modifier.height(6.dp)) + Box( + modifier = Modifier + .clip(CircleShape) + .defaultMinSize(minWidth = 20.dp, minHeight = 20.dp) + .background(MaterialTheme.colorScheme.primary) + .align(Alignment.CenterHorizontally) + ) { + Text( + modifier = Modifier + .padding(2.dp) + .align(Alignment.Center), + text = count, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + + Spacer(modifier = Modifier.width(24.dp)) + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt deleted file mode 100644 index 5ac71de2..00000000 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsAdapter.kt +++ /dev/null @@ -1,303 +0,0 @@ -package com.meloda.fast.screens.conversations - -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.text.SpannableString -import android.text.TextUtils -import android.text.style.ForegroundColorSpan -import android.view.ViewGroup -import androidx.core.util.ObjectsCompat -import androidx.core.view.isVisible -import androidx.core.view.setPadding -import androidx.recyclerview.widget.DiffUtil -import com.meloda.fast.R -import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.VkUtils -import com.meloda.fast.api.model.VkConversation -import com.meloda.fast.api.model.VkGroup -import com.meloda.fast.api.model.VkUser -import com.meloda.fast.base.adapter.BaseAdapter -import com.meloda.fast.base.adapter.BaseHolder -import com.meloda.fast.databinding.ItemConversationBinding -import com.meloda.fast.extensions.* -import com.meloda.fast.extensions.ImageLoader.clear -import com.meloda.fast.extensions.ImageLoader.loadWithGlide -import com.meloda.fast.util.TimeUtils - -class ConversationsAdapter constructor( - context: Context, - private val resourceManager: ConversationsResourceProvider, - var isMultilineEnabled: Boolean = true, - val profiles: HashMap = hashMapOf(), - val groups: HashMap = hashMapOf(), -) : BaseAdapter(context, comparator) { - - companion object { - private val comparator = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: VkConversation, - newItem: VkConversation - ): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame( - oldItem: VkConversation, - newItem: VkConversation - ): Boolean { - return ObjectsCompat.equals(oldItem, newItem) - } - } - } - - var pinnedCount = 0 - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemHolder { - return ItemHolder( - ItemConversationBinding.inflate(inflater, parent, false), - resourceManager - ) - } - - inner class ItemHolder( - private val binding: ItemConversationBinding, - private val resourceManager: ConversationsResourceProvider - ) : BaseHolder(binding.root) { - - override fun bind(position: Int) { - val conversation = getItem(position) - - if (conversation.isAccount()) { - binding.service.gone() - binding.callIcon.gone() - binding.phantomIcon.gone() - } else { - binding.service.toggleVisibility(conversation.isPhantom || conversation.callInProgress) - binding.callIcon.toggleVisibility(conversation.callInProgress) - binding.phantomIcon.toggleVisibility(conversation.isPhantom) - } - - val maxLines = if (isMultilineEnabled) 2 else 1 - - binding.title.maxLines = maxLines - binding.message.maxLines = maxLines - - val message = - if (conversation.lastMessage != null) requireNotNull(conversation.lastMessage) - else { - binding.title.text = conversation.title - val text = context.getString( - if (conversation.isPhantom) R.string.messages_self_destructed - else R.string.no_messages - ) - - val span = SpannableString(text) - span.setSpan( - ForegroundColorSpan(resourceManager.colorOutline), - 0, - text.length, - 0 - ) - - binding.message.text = span - return - } - - val conversationUserGroup = - VkUtils.getConversationUserGroup(conversation, profiles, groups) - val messageUserGroup = VkUtils.getMessageUserGroup(message, profiles, groups) - - val conversationUser = conversationUserGroup.first - val conversationGroup = conversationUserGroup.second - - val messageUser = messageUserGroup.first - val messageGroup = messageUserGroup.second - - val title = VkUtils.getConversationTitle( - context = context, - conversation = conversation, - defConversationUser = conversationUser, - defConversationGroup = conversationGroup - ) - - binding.title.text = title.orDots() - - binding.online.toggleVisibility( - !conversation.isAccount() && conversationUser?.online == true - ) - - binding.pin.toggleVisibility(conversation.isPinned()) - - val avatar = VkUtils.getConversationAvatar( - conversation = conversation, - conversationUser = conversationUser, - conversationGroup = conversationGroup - ) - - binding.avatar.toggleVisibility(avatar != null) - - if (avatar == null) { - binding.avatar.clear() - binding.avatarPlaceholder.visible() - - if (conversation.isAccount()) { - binding.placeholderBack.loadWithGlide( - drawable = ColorDrawable(resourceManager.icLauncherColor), - transformations = ImageLoader.userAvatarTransformations - ) - binding.placeholder.imageTintList = - ColorStateList.valueOf(resourceManager.colorOnPrimary) - binding.placeholder.setImageResource(R.drawable.ic_round_bookmark_border_24) - binding.placeholder.setPadding(36) - } else { - binding.placeholderBack.loadWithGlide( - drawable = ColorDrawable(resourceManager.colorOnUserAvatarAction), - transformations = ImageLoader.userAvatarTransformations - ) - binding.placeholder.imageTintList = - ColorStateList.valueOf(resourceManager.colorUserAvatarAction) - binding.placeholder.setImageResource(R.drawable.ic_account_circle_cut) - binding.placeholder.setPadding(0) - } - } else { - binding.avatar.loadWithGlide( - url = avatar, - crossFade = true, - onLoadedAction = { binding.avatarPlaceholder.gone() } - ) - } - - val actionMessage = VkUtils.getActionConversationText( - context = context, - message = message, - youPrefix = resourceManager.youPrefix, - profiles = profiles, - groups = groups, - messageUser = messageUser, - messageGroup = messageGroup - ) - - val attachmentIcon: Drawable? = when { - message.text == null -> null - !message.forwards.isNullOrEmpty() -> { - if (message.forwards?.size == 1) { - resourceManager.iconForwardedMessage - } else { - resourceManager.iconForwardedMessages - } - } - else -> VkUtils.getAttachmentConversationIcon(context, message) - } - - binding.textAttachment.toggleVisibility(attachmentIcon != null) - binding.textAttachment.setImageDrawable(attachmentIcon) - - val attachmentText = if (attachmentIcon == null) VkUtils.getAttachmentText( - context = context, - message = message - ) else null - - val forwardsMessage = if (message.text == null) VkUtils.getForwardsText( - context = context, - message = message - ) else null - - val messageText = (if ( - actionMessage != null || - forwardsMessage != null || - attachmentText != null - ) "" - else message.text ?: "").run { VkUtils.prepareMessageText(this) } - - val coloredMessage = actionMessage ?: attachmentText ?: forwardsMessage ?: "" - - var prefix = when { - actionMessage != null -> "" - message.isOut -> "${resourceManager.youPrefix}: " - else -> { - if (message.isUser() && messageUser != null && messageUser.firstName.isNotBlank()) "${messageUser.firstName}: " - else if (message.isGroup() && messageGroup != null && messageGroup.name.isNotBlank()) "${messageGroup.name}: " - else "" - } - } - - if ((!conversation.isChat() && !message.isOut) || conversation.id == UserConfig.userId) - prefix = "" - - val spanText = "$prefix$coloredMessage$messageText" - - val spanMessage = SpannableString(spanText) - spanMessage.setSpan( - ForegroundColorSpan(resourceManager.colorOutline), 0, - prefix.length + coloredMessage.length, - 0 - ) - - binding.message.text = spanMessage - - binding.date.text = TimeUtils.getLocalizedTime(context, message.date * 1000L) - - val showUnreadBackgroundCondition = - (message.isOut && conversation.isOutUnread()) || - (!message.isOut && conversation.isInUnread()) - - binding.container.background = - if (showUnreadBackgroundCondition) resourceManager.conversationUnreadBackground - else null - - binding.onlineBorder.setImageDrawable( - ColorDrawable( - if (showUnreadBackgroundCondition) resourceManager.colorBackgroundVariant - else resourceManager.colorBackground - ) - ) - - binding.counter.toggleVisibility( - !message.isOut && conversation.isInUnread() - ) - if (binding.counter.isVisible) { - if (conversation.unreadCount > 0) { - val count = - if (conversation.unreadCount > 999) "${conversation.unreadCount / 1000}K" - else conversation.unreadCount.toString() - binding.counter.text = count - } - } else { - binding.counter.text = "" - } - } - } - - fun removeConversation(conversationId: Int): Int? { - for (i in indices) { - val conversation = getItem(i) - if (conversation.id == conversationId) { - removeAt(i) - return i - } - } - - return null - } - - fun searchConversationIndex(conversationId: Int): Int? { - for (i in indices) { - val conversation = getItem(i) - if (conversation.id == conversationId) return i - } - - return null - } - - override fun onQueryItem(item: VkConversation, query: String): Boolean { - val userGroup = VkUtils.getConversationUserGroup(item, profiles, groups) - val title = VkUtils.getConversationTitle(context, item, userGroup.first, userGroup.second) - - return title.orEmpty().contains(query, ignoreCase = true) || - item.lastMessage?.text.orEmpty().contains(query, ignoreCase = true) - } - - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt index 6fbb34f4..60779e60 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsFragment.kt @@ -1,508 +1,342 @@ package com.meloda.fast.screens.conversations import android.os.Bundle -import android.view.Gravity -import android.view.MenuItem +import android.view.HapticFeedbackConstants +import android.view.LayoutInflater import android.view.View -import android.viewbinding.library.fragment.viewBinding -import android.widget.ImageView -import android.widget.TextView -import android.widget.Toast -import androidx.activity.addCallback -import androidx.appcompat.app.ActionBarDrawerToggle -import androidx.appcompat.widget.PopupMenu -import androidx.appcompat.widget.SearchView -import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import androidx.fragment.app.setFragmentResultListener -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.bumptech.glide.Glide -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import android.view.ViewGroup +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideIn +import androidx.compose.animation.slideOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.meloda.fast.R -import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.VkUtils -import com.meloda.fast.api.model.VkConversation -import com.meloda.fast.base.viewmodel.BaseViewModelFragment -import com.meloda.fast.base.viewmodel.VkEvent +import com.meloda.fast.api.model.presentation.VkConversationUi +import com.meloda.fast.base.BaseFragment import com.meloda.fast.common.AppGlobal -import com.meloda.fast.common.Screens -import com.meloda.fast.databinding.FragmentConversationsBinding -import com.meloda.fast.extensions.ImageLoader.loadWithGlide -import com.meloda.fast.extensions.addAvatarMenuItem -import com.meloda.fast.extensions.gone -import com.meloda.fast.extensions.tintMenuItemIcons -import com.meloda.fast.extensions.toggleVisibility -import com.meloda.fast.screens.main.MainActivity -import com.meloda.fast.screens.main.MainFragment -import com.meloda.fast.screens.settings.SettingsPrefsFragment -import com.meloda.fast.util.AndroidUtils -import com.meloda.fast.util.NotificationsUtils -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import com.meloda.fast.ext.asUiText +import com.meloda.fast.ext.listenValue +import com.meloda.fast.ext.showDialog +import com.meloda.fast.ext.string +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.settings.SettingsFragment +import com.meloda.fast.ui.AppTheme +import org.koin.androidx.viewmodel.ext.android.viewModel -@AndroidEntryPoint -class ConversationsFragment : - BaseViewModelFragment(R.layout.fragment_conversations) { +class ConversationsFragment : BaseFragment(R.layout.fragment_conversations) { - override val viewModel: ConversationsViewModel by viewModels() - private val binding: FragmentConversationsBinding by viewBinding() - - private val adapter: ConversationsAdapter by lazy { - ConversationsAdapter( - requireContext(), - ConversationsResourceProvider(requireContext()) - ).also { - it.itemClickListener = this::onItemClick - it.itemLongClickListener = this::onItemLongClick - } + private val viewModel: ConversationsViewModel by viewModel() + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) } - private val avatarPopupMenu: PopupMenu - get() = - PopupMenu( - requireContext(), - binding.toolbar, - Gravity.BOTTOM or Gravity.END - ).apply { - menu.add("Settings") - menu.add(getString(R.string.log_out)) - setOnMenuItemClickListener { item -> - return@setOnMenuItemClickListener when (item.title) { - getString(R.string.log_out) -> { - showLogOutDialog() - true - } - "Settings" -> { - requireActivityRouter().navigateTo(Screens.Settings()) - true - } - else -> false - } - } - } - - private var toggle: ActionBarDrawerToggle? = null - - private val useNavDrawer: Boolean get() = (requireActivity() as MainActivity).useNavDrawer - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + listenViewModel() - adapter.isMultilineEnabled = - AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefMultiline, true) + (view as? ComposeView)?.setContent { + ConversationsScreen() + } + } - prepareViews() + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun ConversationsScreen() { + val conversations by viewModel.conversationsList.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() - binding.recyclerView.adapter = adapter - - binding.createChat.setOnClickListener {} - - binding.toolbar.tintMenuItemIcons( - ContextCompat.getColor( - requireContext(), - R.color.colorPrimary - ) + val useLargeTopAppBar = AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_USE_LARGE_TOP_APP_BAR, + SettingsFragment.DEFAULT_VALUE_USE_LARGE_TOP_APP_BAR ) + val useMultiline = AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_APPEARANCE_MULTILINE, + SettingsFragment.DEFAULT_VALUE_MULTILINE + ) + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) - val searchMenuItem = binding.toolbar.menu.findItem(R.id.search) - val actionView = searchMenuItem.actionView as SearchView - - searchMenuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(p0: MenuItem?): Boolean { - if (!adapter.isSearching) - adapter.isSearching = true - return true - } - - override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean { - if (adapter.isSearching) - adapter.isSearching = false - return true - } - - }) - - actionView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - Toast.makeText(requireContext(), "API Search: $query", Toast.LENGTH_SHORT).show() - return false - } - - override fun onQueryTextChange(newText: String): Boolean { - adapter.filter.filter(newText) - return false - } - - }) - - requireActivity().onBackPressedDispatcher.addCallback(this) { - if (searchMenuItem.isActionViewExpanded) { - searchMenuItem.collapseActionView() - } else { - isEnabled = false - requireActivity().onBackPressed() - } + val scaffoldModifier = if (useLargeTopAppBar) { + Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + Modifier.fillMaxSize() } - val avatarMenuItem = binding.toolbar.addAvatarMenuItem() - syncAvatarMenuItem(avatarMenuItem) + val lazyListState = rememberLazyListState() - UserConfig.vkUser.observe(viewLifecycleOwner) { user -> - user?.run { - avatarMenuItem.actionView?.findViewById(R.id.avatar) - ?.loadWithGlide( - url = this.photo200, crossFade = true, asCircle = true + AppTheme { + Scaffold( + modifier = scaffoldModifier, + topBar = { + var dropDownMenuExpanded by remember { + mutableStateOf(false) + } + + val actions: @Composable RowScope.() -> Unit = @Composable { + IconButton( + onClick = { + dropDownMenuExpanded = true + } + ) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = "Options" + ) + } + + DropdownMenu( + expanded = dropDownMenuExpanded, + onDismissRequest = { + dropDownMenuExpanded = false + }, + offset = DpOffset(x = 0.dp, y = (-60).dp) + ) { + DropdownMenuItem( + onClick = { + viewModel.onToolbarMenuItemClicked(R.id.settings) + dropDownMenuExpanded = false + }, + text = { + Text(text = "Settings") + } + ) + DropdownMenuItem( + onClick = { + viewModel.onRefresh() + dropDownMenuExpanded = false + }, + text = { + Text(text = "Refresh") + } + ) + } + } + + val title = @Composable { + Text(text = if (isLoading) "Loading..." else "Conversations") + } + + if (useLargeTopAppBar) { + LargeTopAppBar( + title = title, + scrollBehavior = scrollBehavior, + actions = actions + ) + } else { + TopAppBar( + title = title, + actions = actions + ) + } + }, + floatingActionButton = { + if (!isLoading || conversations.isNotEmpty()) { + AnimatedVisibility( + visible = !lazyListState.isScrollInProgress || conversations.isNotEmpty(), + enter = slideIn(initialOffset = { IntOffset(x = 0, y = 300) }), + exit = slideOut(targetOffset = { IntOffset(x = 0, y = 300) }) + ) { + FloatingActionButton( + onClick = { + view?.performHapticFeedback(HapticFeedbackConstants.REJECT) + } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_baseline_create_24), + contentDescription = null + ) + } + } + } + } + ) { padding -> + if (isLoading && conversations.isEmpty()) { + Loader() + } else { + ConversationsList( + conversations = conversations, + padding = padding, + state = lazyListState, + useMultiline = useMultiline, ) - - val header = (requireActivity() as MainActivity).binding.drawer.getHeaderView(0) - header.findViewById(R.id.name).text = user.fullName - header.findViewById(R.id.avatar).loadWithGlide( - url = this.photo200, crossFade = true, asCircle = true - ) - } - } - - avatarMenuItem.actionView?.run { - setOnClickListener { avatarPopupMenu.show() } - } - - viewModel.loadProfileUser() - viewModel.loadConversations() - - syncToolbarToggle() - - binding.createChat.gone() - - setFragmentResultListener(SettingsPrefsFragment.KeyChangeMultiline) { _, bundle -> - val enabled = bundle.getBoolean(SettingsPrefsFragment.ArgEnabled) - - if (adapter.isMultilineEnabled != enabled) { - adapter.isMultilineEnabled = enabled - adapter.refreshList() - } - } - } - - private fun syncAvatarMenuItem(item: MenuItem) { - item.isVisible = !useNavDrawer - } - - private fun syncToolbarToggle() { - (requireActivity() as MainActivity).let { activity -> - if (useNavDrawer) { - toggle = ActionBarDrawerToggle( - activity, activity.binding.drawerLayout, - binding.toolbar, R.string.app_name, R.string.app_name - ).apply { - isDrawerSlideAnimationEnabled = false - activity.binding.drawerLayout.addDrawerListener(this) - syncState() - } - } else { - toggle?.let { toggle -> - activity.binding.drawerLayout.removeDrawerListener(toggle) } } } } - private fun showLogOutDialog() { - val isEasterEgg = UserConfig.userId == 37610580 + @Composable + fun Loader() { + AppTheme { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } - MaterialAlertDialogBuilder(requireContext()) - .setTitle( - if (isEasterEgg) "Выйти внаружу?" - else getString(R.string.sign_out_confirm_title) - ) - .setMessage(R.string.sign_out_confirm) - .setPositiveButton( - if (isEasterEgg) "Выйти внаружу" - else getString(R.string.action_sign_out) - ) { _, _ -> - lifecycleScope.launch(Dispatchers.Default) { - UserConfig.clear() - AppGlobal.appDatabase.clearAllTables() - setFragmentResult( - MainFragment.KeyStartServices, - bundleOf("enable" to false) - ) + @Composable + fun ConversationsList( + conversations: List, + padding: PaddingValues, + state: LazyListState, + useMultiline: Boolean, + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(padding), + state = state + ) { + items( + count = conversations.size, + key = { index -> + val item = conversations[index] + item.conversationId + } + ) { index -> + Conversation( + onItemClick = viewModel::onConversationItemClick, + onItemLongClick = viewModel::onConversationItemLongClick, + conversation = conversations[index], + maxLines = if (useMultiline) 2 else 1, + ) - viewModel.openRootScreen() + if (index < conversations.size - 1) { + Spacer(modifier = Modifier.height(8.dp)) } } - .setNegativeButton(R.string.no, null) - .show() - } - - override fun onEvent(event: VkEvent) { - super.onEvent(event) - when (event) { - is ConversationsLoadedEvent -> refreshConversations(event) - is ConversationsDeleteEvent -> deleteConversation(event.peerId) - - is ConversationsPinEvent -> { - adapter.pinnedCount++ - viewModel.loadConversations() - } - is ConversationsUnpinEvent -> { - adapter.pinnedCount-- - viewModel.loadConversations() - } - - is MessagesNewEvent -> onMessageNew(event) - is MessagesEditEvent -> onMessageEdit(event) - is MessagesReadEvent -> onMessageRead(event) } } - override fun toggleProgress(isProgressing: Boolean) { - view?.run { - findViewById(R.id.progress_bar).toggleVisibility( - if (isProgressing) adapter.isEmpty() else false - ) - findViewById(R.id.refresh_layout).isRefreshing = - if (isProgressing) adapter.isNotEmpty() else false - } + // TODO: 05.08.2023, Danil Nikolaev: remove and use compose dialogs + private fun listenViewModel() = with(viewModel) { + isNeedToShowOptionsDialog.listenValue(::showOptionsDialog) + isNeedToShowDeleteDialog.listenValue(::showDeleteConversationDialog) + isNeedToShowPinDialog.listenValue(::showPinConversationDialog) } - private fun prepareViews() { - prepareRecyclerView() - prepareRefreshLayout() - } - - private fun prepareRecyclerView() { - binding.recyclerView.itemAnimator = null - } - - private fun prepareRefreshLayout() { - with(binding.refreshLayout) { - setProgressViewOffset( - true, progressViewStartOffset, progressViewEndOffset - ) - setProgressBackgroundColorSchemeColor( - AndroidUtils.getThemeAttrColor( - requireContext(), - R.attr.colorSurface - ) - ) - setColorSchemeColors( - AndroidUtils.getThemeAttrColor( - requireContext(), - R.attr.colorPrimary - ) - ) - setOnRefreshListener { viewModel.loadConversations() } - } - } - - private fun refreshConversations(event: ConversationsLoadedEvent) { - adapter.profiles += event.profiles - adapter.groups += event.groups - - if (event.avatars != null) { - event.avatars.forEach { avatar -> - Glide.with(requireContext()) - .load(avatar) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .preload(200, 200) - } - } - - val pinnedConversations = event.conversations.filter { it.isPinned() } - adapter.pinnedCount = pinnedConversations.count() - - fillRecyclerView(event.conversations) - } - - private fun fillRecyclerView(values: List) { - adapter.submitList(values) - } - - private fun onItemClick(position: Int) { - val conversation = adapter[position] - - val user = - if (conversation.isUser()) adapter.profiles[conversation.id] - else null - - val group = - if (conversation.isGroup()) adapter.groups[conversation.id] - else null - - viewModel.openMessagesHistoryScreen(conversation, user, group) - } - - private fun onItemLongClick(position: Int): Boolean { - showOptionsDialog(position) - return true - } - - private fun showOptionsDialog(position: Int) { - val conversation = adapter[position] + // TODO: 06.04.2023, Danil Nikolaev: extract creating options to VM + private fun showOptionsDialog(conversation: VkConversationUi?) { + if (conversation == null) return var canPinOneMoreDialog = true - if (adapter.itemCount > 4) { - val pinnedConversations = adapter.cloneCurrentList().filter { it.majorId > 0 } - - if (pinnedConversations.size == 5 && position > 4) { + if (viewModel.conversationsList.value.size > 4) { + if (viewModel.pinnedConversationsCount.value == 5 && !conversation.isPinned) { canPinOneMoreDialog = false } } val read = "Mark as read" - val pin = getString( - if (conversation.isPinned()) R.string.conversation_context_action_unpin + val pin = string( + if (conversation.isPinned) R.string.conversation_context_action_unpin else R.string.conversation_context_action_pin ) - val delete = getString(R.string.conversation_context_action_delete) + val delete = string(R.string.conversation_context_action_delete) - val params = mutableListOf() + val params = mutableListOf>() conversation.lastMessage?.run { - if (!this.isRead(conversation) && !isOut) { - params += read + if (!conversation.isUnread && !this.isOut) { + params += "read" to read + } + + if (!this.text.isNullOrBlank()) { + params += "share" to "Share" } } - if (canPinOneMoreDialog) params += pin + if (canPinOneMoreDialog) params += "pin" to pin - params += delete + params += "delete" to delete - val arrayParams = params.toTypedArray() - - MaterialAlertDialogBuilder(requireContext()) - .setItems(arrayParams) { _, which -> - when (params[which]) { - read -> viewModel.readConversation(conversation) - pin -> showPinConversationDialog(conversation) - delete -> showDeleteConversationDialog(conversation.id) - } - }.show() + context?.showDialog( + items = params.map { param -> param.second.asUiText() }, + itemsClickAction = { index, _ -> + val key = params[index].first + viewModel.onOptionsDialogOptionClicked(conversation, key) + }, + onDismissAction = viewModel::onOptionsDialogDismissed + ) } - private fun showDeleteConversationDialog(conversationId: Int) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.confirm_delete_conversation) - .setPositiveButton(R.string.action_delete) { _, _ -> - viewModel.deleteConversation(conversationId) - } - .setNegativeButton(R.string.cancel, null) - .show() + private fun showDeleteConversationDialog(conversationId: Int?) { + if (conversationId == null) return + + context?.showDialog( + title = UiText.Resource(R.string.confirm_delete_conversation), + positiveText = UiText.Resource(R.string.action_delete), + positiveAction = { viewModel.onDeleteDialogPositiveClick(conversationId) }, + negativeText = UiText.Resource(R.string.cancel), + onDismissAction = viewModel::onDeleteDialogDismissed + ) } - private fun deleteConversation(conversationId: Int) { - adapter.removeConversation(conversationId) - } + private fun showPinConversationDialog(conversation: VkConversationUi?) { + if (conversation == null) return - private fun showPinConversationDialog(conversation: VkConversation) { - val isPinned = conversation.isPinned() - MaterialAlertDialogBuilder(requireContext()) - .setTitle( - if (isPinned) R.string.confirm_unpin_conversation + context?.showDialog( + title = UiText.Resource( + if (conversation.isPinned) R.string.confirm_unpin_conversation else R.string.confirm_pin_conversation - ) - .setPositiveButton( - if (isPinned) R.string.action_unpin + ), + positiveText = UiText.Resource( + if (conversation.isPinned) R.string.action_unpin else R.string.action_pin - ) { _, _ -> - viewModel.pinConversation( - peerId = conversation.id, - pin = !isPinned - ) - } - .setNegativeButton(R.string.cancel, null) - .show() + ), + positiveAction = { + viewModel.onPinDialogPositiveClick(conversation) + }, + negativeText = UiText.Resource(R.string.cancel), + onDismissAction = viewModel::onPinDialogDismissed + ) } - - private fun onMessageNew(event: MessagesNewEvent) { - adapter.profiles += event.profiles - adapter.groups += event.groups - - val message = event.message - - val conversationIndex = adapter.searchConversationIndex(message.peerId) - if (conversationIndex == null) { // диалога нет в списке - // pizdets - } else { - val conversation = adapter[conversationIndex] - val newConversation = conversation.copy( - lastMessage = message, - lastMessageId = message.id, - lastConversationMessageId = -1 - ) - if (!message.isOut) { - newConversation.unreadCount += 1 - } - -// if (!message.isOut) { -// NotificationsUtils.showSimpleNotification( -// requireContext(), -// VkUtils.getConversationTitle( -// requireContext(), conversation, profiles = event.profiles, -// groups = event.groups -// ) ?: "...", -// "${ -// VkUtils.getMessageTitle( -// message, -// profiles = event.profiles, -// groups = event.groups -// ) ?: "..." -// }: ${message.text}", -// customNotificationId = message.id, -// showWhen = true, -// timeStampWhen = message.date * 1000L -// ) -// } - - if (conversation.isPinned()) { - adapter[conversationIndex] = newConversation - return - } - - val newList = adapter.cloneCurrentList() - newList.removeAt(conversationIndex) - - val toPosition = adapter.pinnedCount - newList.add(toPosition, newConversation) - - adapter.submitList(newList) - } - } - - private fun onMessageEdit(event: MessagesEditEvent) { - val message = event.message - - val conversationIndex = adapter.searchConversationIndex(message.peerId) - if (conversationIndex == null) { // диалога нет в списке - - } else { - val conversation = adapter[conversationIndex] - adapter[conversationIndex] = conversation.copy( - lastMessage = message, - lastMessageId = message.id, - lastConversationMessageId = -1 - ) - } - } - - private fun onMessageRead(event: MessagesReadEvent) { - val conversationIndex = adapter.searchConversationIndex(event.peerId) ?: return - - val newConversation = adapter[conversationIndex].copy() - - if (event.isOut) { - newConversation.outRead = event.messageId - } else { - newConversation.inRead = event.messageId - } - - adapter[conversationIndex] = newConversation - } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt index 875f426a..5c618fff 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsResourceProvider.kt @@ -6,6 +6,7 @@ import com.meloda.fast.base.ResourceProvider class ConversationsResourceProvider(context: Context) : ResourceProvider(context) { + val colorPrimary = getColor(R.color.colorPrimary) val colorOutline = getColor(R.color.colorOutline) val colorOnPrimary = getColor(R.color.colorOnPrimary) val colorUserAvatarAction = getColor(R.color.colorUserAvatarAction) @@ -22,4 +23,4 @@ class ConversationsResourceProvider(context: Context) : ResourceProvider(context val iconForwardedMessages = getDrawable(R.drawable.ic_attachment_forwarded_messages) val iconForwardedMessage = getDrawable(R.drawable.ic_attachment_forwarded_message) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt index 733d61de..04604649 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/ConversationsViewModel.kt @@ -1,214 +1,573 @@ package com.meloda.fast.screens.conversations import androidx.lifecycle.viewModelScope +import coil.ImageLoader +import coil.request.ImageRequest import com.github.terrakok.cicerone.Router +import com.meloda.fast.R import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants import com.meloda.fast.api.VkUtils import com.meloda.fast.api.longpoll.LongPollEvent import com.meloda.fast.api.longpoll.LongPollUpdatesParser -import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup -import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser -import com.meloda.fast.api.network.conversations.* +import com.meloda.fast.api.model.base.BaseVkGroup +import com.meloda.fast.api.model.base.BaseVkUser +import com.meloda.fast.api.model.data.BaseVkConversation +import com.meloda.fast.api.model.domain.VkConversationDomain +import com.meloda.fast.api.model.presentation.VkConversationUi +import com.meloda.fast.api.network.conversations.ConversationsDeleteRequest +import com.meloda.fast.api.network.conversations.ConversationsGetRequest +import com.meloda.fast.api.network.conversations.ConversationsPinRequest +import com.meloda.fast.api.network.conversations.ConversationsUnpinRequest import com.meloda.fast.api.network.users.UsersGetRequest import com.meloda.fast.base.viewmodel.BaseViewModel -import com.meloda.fast.base.viewmodel.VkEvent +import com.meloda.fast.common.AppGlobal import com.meloda.fast.common.Screens import com.meloda.fast.data.conversations.ConversationsRepository import com.meloda.fast.data.messages.MessagesRepository import com.meloda.fast.data.users.UsersRepository -import dagger.hilt.android.lifecycle.HiltViewModel +import com.meloda.fast.ext.emitOnMainScope +import com.meloda.fast.ext.emitWithMain +import com.meloda.fast.ext.findIndex +import com.meloda.fast.ext.toMap import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject +import kotlinx.coroutines.withContext -@HiltViewModel -class ConversationsViewModel @Inject constructor( +// TODO: 04.08.2023, Danil Nikolaev: calculate time here and give ui ready to be shown date + +interface ConversationsViewModel { + + val pinnedConversationsCount: StateFlow + + val conversationsList: StateFlow> + val isLoading: StateFlow + + val isNeedToShowOptionsDialog: StateFlow + val isNeedToShowDeleteDialog: StateFlow + val isNeedToShowPinDialog: StateFlow + + val profiles: StateFlow> + val groups: StateFlow> + + fun onOptionsDialogDismissed() + + fun onOptionsDialogOptionClicked(conversation: VkConversationUi, key: String): Boolean + + fun onDeleteDialogDismissed() + + fun onDeleteDialogPositiveClick(conversationId: Int) + + fun onRefresh() + + fun onConversationItemClick(conversationUi: VkConversationUi) + fun onConversationItemLongClick(conversationUi: VkConversationUi): Boolean + + fun onPinDialogDismissed() + fun onPinDialogPositiveClick(conversation: VkConversationUi) + fun onToolbarMenuItemClicked(itemId: Int): Boolean +} + +class ConversationsViewModelImpl constructor( private val conversationsRepository: ConversationsRepository, private val usersRepository: UsersRepository, updatesParser: LongPollUpdatesParser, private val router: Router, - private val messagesRepository: MessagesRepository -) : BaseViewModel() { + private val messagesRepository: MessagesRepository, +) : ConversationsViewModel, BaseViewModel() { + + private val dataConversations: MutableStateFlow> = + MutableStateFlow(emptyList()) + + private val domainConversations: MutableStateFlow> = + MutableStateFlow(emptyList()) + + override val conversationsList: StateFlow> = + domainConversations.map { list -> + list.map(VkConversationDomain::mapToPresentation) + }.stateIn(viewModelScope, SharingStarted.Lazily, initialValue = emptyList()) + + override val isLoading = MutableStateFlow(false) + + override val isNeedToShowOptionsDialog = MutableStateFlow(null) + override val isNeedToShowDeleteDialog = MutableStateFlow(null) + override val isNeedToShowPinDialog = MutableStateFlow(null) + + override val profiles: MutableStateFlow> = MutableStateFlow(hashMapOf()) + override val groups: MutableStateFlow> = MutableStateFlow(hashMapOf()) + + override val pinnedConversationsCount = domainConversations.map { conversations -> + val pinnedConversations = conversations.filter { it.isPinned() } + pinnedConversations.size + }.stateIn(viewModelScope, SharingStarted.Eagerly, 0) + + private val imageLoader by lazy { + ImageLoader.Builder(AppGlobal.Instance) + .crossfade(true) + .build() + } + + override fun onOptionsDialogDismissed() { + isNeedToShowOptionsDialog.emitOnMainScope(null) + } + + override fun onOptionsDialogOptionClicked( + conversation: VkConversationUi, + key: String + ): Boolean { + return when (key) { + "read" -> { + readConversation( + conversationId = conversation.conversationId, + startMessageId = conversation.lastMessageId + ) + true + } + + "delete" -> { + isNeedToShowDeleteDialog.emitOnMainScope(conversation.id) + true + } + + "pin" -> { + isNeedToShowPinDialog.emitOnMainScope(conversation) + true + } + + else -> false + } + } + + override fun onDeleteDialogDismissed() { + isNeedToShowDeleteDialog.emitOnMainScope(null) + } + + override fun onDeleteDialogPositiveClick(conversationId: Int) { + deleteConversation(conversationId) + } + + override fun onRefresh() { + loadConversations() + } + + override fun onConversationItemClick(conversationUi: VkConversationUi) { + openMessagesHistoryScreen( + conversationUi, + conversationUi.conversationUser, + conversationUi.conversationGroup + ) + } + + override fun onConversationItemLongClick(conversationUi: VkConversationUi): Boolean { + val domainConversation = conversationsList.value.find { it.id == conversationUi.id } + isNeedToShowOptionsDialog.emitOnMainScope(domainConversation) + return true + } + + override fun onPinDialogDismissed() { + isNeedToShowPinDialog.emitOnMainScope(null) + } + + override fun onPinDialogPositiveClick(conversation: VkConversationUi) { + pinConversation(conversation.id, !conversation.isPinned) + } + + override fun onToolbarMenuItemClicked(itemId: Int): Boolean { + return when (itemId) { + R.id.settings -> { + router.navigateTo(Screens.Settings()) + true + } + + else -> false + } + } init { - updatesParser.onNewMessage { - viewModelScope.launch { handleNewMessage(it) } - } + updatesParser.onNewMessage(::handleNewMessage) + updatesParser.onMessageEdited(::handleEditedMessage) + updatesParser.onMessageIncomingRead(::handleReadIncomingMessage) + updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage) + updatesParser.onConversationPinStateChanged(::handlePinStateChanged) - updatesParser.onMessageEdited { - viewModelScope.launch { handleEditedMessage(it) } - } + loadProfileUser() + loadConversations() + } - updatesParser.onMessageIncomingRead { - viewModelScope.launch { handleReadIncomingMessage(it) } - } + private fun loadConversations(offset: Int? = null) { + viewModelScope.launch(Dispatchers.IO) { + isLoading.emitWithMain(true) + sendRequest( + onError = { + isLoading.emitWithMain(false) + false + } + ) { + conversationsRepository.get( + ConversationsGetRequest( + count = 30, + extended = true, + offset = offset, + fields = VKConstants.ALL_FIELDS + ) + ) + }?.response?.let { response -> + val dataConversationsMessages = response.items.map { item -> + item.conversation to item.lastMessage + } + val dataConversationsList = dataConversationsMessages.map { pair -> pair.first } + dataConversations.emit(dataConversationsList) - updatesParser.onMessageOutgoingRead { - viewModelScope.launch { handleReadOutgoingMessage(it) } + val newProfiles = response.profiles + ?.map(BaseVkUser::mapToDomain) + ?.toMap(hashMapOf(), VkUser::id) ?: hashMapOf() + profiles.emit(newProfiles) + + val newGroups = response.groups + ?.map(BaseVkGroup::mapToDomain) + ?.toMap(hashMapOf(), VkGroup::id) ?: hashMapOf() + groups.emit(newGroups) + + val messages = dataConversationsMessages + .map { pair -> pair.second } + .mapNotNull { message -> message?.asVkMessage() } + .map { message -> + message.apply { + message.user = newProfiles[message.fromId] + message.group = newGroups[message.fromId] + message.actionUser = newProfiles[message.actionMemberId] + message.actionGroup = newGroups[message.actionMemberId] + } + } + + messagesRepository.store(messages) + + val photos = newProfiles.mapNotNull { profile -> profile.value.photo200 } + + newGroups.mapNotNull { group -> group.value.photo200 } + + photos.forEach { url -> + ImageRequest.Builder(AppGlobal.Instance) + .data(url) + .build() + .let(imageLoader::enqueue) + } + + val domainConversationsList = + dataConversationsList.mapToDomain().map { conversation -> + conversation.apply { + conversation.conversationUser = newProfiles[conversation.id] + conversation.conversationGroup = newGroups[conversation.id] + } + + } + emitConversations(domainConversationsList) + + isLoading.emitWithMain(false) + } } } - fun loadConversations( - offset: Int? = null - ) = viewModelScope.launch(Dispatchers.Default) { - makeJob({ - conversationsRepository.get( - ConversationsGetRequest( - count = 100, - extended = true, - offset = offset, - fields = VKConstants.ALL_FIELDS - ) + private suspend fun emitConversations(conversations: List) = + withContext(Dispatchers.Default) { + domainConversations.emit(conversations) + } + + private suspend fun List.mapToDomain(): List = + this.map { baseConversation -> getFilledDomainVkConversation(baseConversation) } + + private suspend fun VkConversationDomain.fill(): VkConversationDomain { + val conversation = this + val messages = messagesRepository.getCached(conversation.id) + + val lastMessage = messages.find { it.id == conversation.lastMessageId } + + val userGroup = + VkUtils.getConversationUserGroup( + conversation, + profiles.value, + groups.value + ) + val actionUserGroup = + VkUtils.getMessageActionUserGroup( + lastMessage, + profiles.value, + groups.value + ) + val messageUserGroup = + VkUtils.getMessageUserGroup( + lastMessage, + profiles.value, + groups.value ) - }, - onAnswer = { - it.response?.let { response -> - val profiles = hashMapOf() - response.profiles?.forEach { baseUser -> - baseUser.asVkUser().let { user -> profiles[user.id] = user } - } - val groups = hashMapOf() - response.groups?.forEach { baseGroup -> - baseGroup.asVkGroup().let { group -> groups[group.id] = group } - } + conversation.conversationUser = userGroup.first + conversation.conversationGroup = userGroup.second - val conversations = response.items.map { items -> - items.conversation.asVkConversation( - items.lastMessage?.asVkMessage() - ) - } + val newMessage = lastMessage?.copy()?.apply { + this.user = messageUserGroup.first + this.group = messageUserGroup.second + this.actionUser = actionUserGroup.first + this.actionGroup = actionUserGroup.second + } - val avatars = conversations.mapNotNull { conversation -> - VkUtils.getConversationAvatar( - conversation, - if (conversation.isUser()) profiles[conversation.id] else null, - if (conversation.isGroup()) groups[conversation.id] else null - ) - } + conversation.lastMessage = newMessage - sendEvent( - ConversationsLoadedEvent( - count = response.count, - offset = offset, - unreadCount = response.unreadCount ?: 0, - conversations = conversations, - profiles = profiles, - groups = groups, - avatars = avatars - ) - ) + return conversation + } + + private suspend fun getFilledDomainVkConversation( + baseConversation: BaseVkConversation, + defDomainConversation: VkConversationDomain? = null, + ): VkConversationDomain { + val conversation = defDomainConversation ?: baseConversation.mapToDomain() + return conversation.fill() + } + + private fun loadProfileUser() { + viewModelScope.launch(Dispatchers.IO) { + sendRequest { + usersRepository.getById(UsersGetRequest(fields = VKConstants.USER_FIELDS)) + }?.response?.let { response -> + val users = response.map(BaseVkUser::mapToDomain) + usersRepository.storeUsers(users) + + UserConfig.vkUser.emit(users.first()) + } + } + } + + private fun deleteConversation(peerId: Int) { + viewModelScope.launch(Dispatchers.IO) { + sendRequest { + conversationsRepository.delete(ConversationsDeleteRequest(peerId)) + }?.let { + domainConversations.value.toMutableList().let { list -> + val index = list.indexOfFirst { conversation -> conversation.id == peerId } + if (index != -1) { + list.removeAt(index) + domainConversations.emit(list) + } } } - ) - } - - fun loadProfileUser() = viewModelScope.launch { - makeJob({ usersRepository.getById(UsersGetRequest(fields = VKConstants.USER_FIELDS)) }, - onAnswer = { - it.response?.let { r -> - val users = r.map { u -> u.asVkUser() } - this@ConversationsViewModel.usersRepository.storeUsers(users) - - UserConfig.vkUser.value = users[0] - } - }) - } - - fun deleteConversation(peerId: Int) = viewModelScope.launch { - makeJob({ - conversationsRepository.delete( - ConversationsDeleteRequest(peerId) - ) - }, onAnswer = { sendEvent(ConversationsDeleteEvent(peerId)) }) - } - - fun pinConversation( - peerId: Int, - pin: Boolean - ) = viewModelScope.launch { - if (pin) { - makeJob( - { conversationsRepository.pin(ConversationsPinRequest(peerId)) }, - onAnswer = { sendEvent(ConversationsPinEvent(peerId)) } - ) - } else { - makeJob( - { conversationsRepository.unpin(ConversationsUnpinRequest(peerId)) }, - onAnswer = { sendEvent(ConversationsUnpinEvent(peerId)) } - ) } } - private suspend fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) { - sendEvent( - MessagesNewEvent( - message = event.message, - profiles = event.profiles, - groups = event.groups - ) - ) + private fun pinConversation(peerId: Int, pin: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + if (pin) { + sendRequest { + conversationsRepository.pin(ConversationsPinRequest(peerId)) + }?.let { handleConversationPinStateUpdate(peerId, true) } + } else { + sendRequest { + conversationsRepository.unpin(ConversationsUnpinRequest(peerId)) + }?.let { + handleConversationPinStateUpdate(peerId, false) + } + } + } } - private suspend fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) { - sendEvent(MessagesEditEvent(event.message)) + // TODO: 07.01.2023, Danil Nikolaev: handle major AND minor id + private suspend fun handleConversationPinStateUpdate(peerId: Int, pin: Boolean) { + withContext(Dispatchers.IO) { + val conversationsList = domainConversations.value.toMutableList() + val conversationIndex = + conversationsList.findIndex { it.id == peerId } ?: return@withContext + + val conversation = conversationsList[conversationIndex].copy( + majorId = if (pin) (pinnedConversationsCount.value + 1) * 16 + else 0 + ).fill() + + conversationsList.removeAt(conversationIndex) + + if (pin) { + conversationsList.add(0, conversation) + } else { + conversationsList.add(pinnedConversationsCount.value - 1, conversation) + } + + emitConversations(conversationsList) + } } - private suspend fun handleReadIncomingMessage(event: LongPollEvent.VkMessageReadIncomingEvent) { - sendEvent(MessagesReadEvent(false, event.peerId, event.messageId)) + private fun handleNewMessage(event: LongPollEvent.VkMessageNewEvent) { + viewModelScope.launch(Dispatchers.IO) { + val message = event.message + + messagesRepository.store(message) + + val newProfiles: HashMap = + (profiles.value + event.profiles) as HashMap + profiles.update { newProfiles } + + val newGroups: HashMap = + (groups.value + event.groups) as HashMap + groups.update { newGroups } + + val dataConversationsList = domainConversations.value.toMutableList() + val dataConversationIndex = dataConversationsList.findIndex { it.id == message.peerId } + + if (dataConversationIndex == null) { // диалога нет в списке + // pizdets + } else { + val dataConversation = dataConversationsList[dataConversationIndex] + var newConversation = dataConversation.copy( + lastMessageId = message.id, + lastConversationMessageId = -1 + ).fill().also { + it.lastMessage = message + } + if (!message.isOut) { + newConversation = newConversation.copy( + unreadCount = newConversation.unreadCount + 1 + ).fill().also { + it.lastMessage = message + } + } + + if (dataConversation.isPinned()) { + dataConversationsList[dataConversationIndex] = newConversation + emitConversations(dataConversationsList) + return@launch + } + + dataConversationsList.removeAt(dataConversationIndex) + + val toPosition = pinnedConversationsCount.value + dataConversationsList.add(toPosition, newConversation) + + emitConversations(dataConversationsList) + } + } } - private suspend fun handleReadOutgoingMessage(event: LongPollEvent.VkMessageReadOutgoingEvent) { - sendEvent(MessagesReadEvent(true, event.peerId, event.messageId)) + private fun handleEditedMessage(event: LongPollEvent.VkMessageEditEvent) { + viewModelScope.launch(Dispatchers.IO) { + val message = event.message + + messagesRepository.store(message) + + val conversationsList = domainConversations.value.toMutableList() + + val conversationIndex = conversationsList.findIndex { it.id == message.peerId } + if (conversationIndex == null) { // диалога нет в списке + + } else { + val conversation = conversationsList[conversationIndex] + conversationsList[conversationIndex] = conversation.copy( + lastMessageId = message.id, + lastConversationMessageId = -1 + ).fill().also { + it.lastMessage = message + } + + emitConversations(conversationsList) + } + } } - fun openRootScreen() { - router.replaceScreen(Screens.Main()) + private fun handleReadIncomingMessage(event: LongPollEvent.VkMessageReadIncomingEvent) { + viewModelScope.launch(Dispatchers.IO) { + val conversationsList = domainConversations.value.toMutableList() + + val conversationIndex = + conversationsList.findIndex { it.id == event.peerId } ?: return@launch + + var conversation = conversationsList[conversationIndex] + conversation = conversation.copy( + inRead = event.messageId, + unreadCount = event.unreadCount + ).fill() + + conversationsList[conversationIndex] = conversation + + emitConversations(conversationsList) + } } - fun openMessagesHistoryScreen( - conversation: VkConversation, + private fun handleReadOutgoingMessage(event: LongPollEvent.VkMessageReadOutgoingEvent) = + viewModelScope.launch(Dispatchers.IO) { + val conversationsList = domainConversations.value.toMutableList() + + val conversationIndex = + conversationsList.findIndex { it.id == event.peerId } ?: return@launch + + var conversation = conversationsList[conversationIndex] + conversation = conversation.copy( + outRead = event.messageId, + unreadCount = event.unreadCount + ).fill() + + conversationsList[conversationIndex] = conversation + + emitConversations(conversationsList) + } + + // TODO: 07.01.2023, Danil Nikolaev: handle major AND minor id + private fun handlePinStateChanged(event: LongPollEvent.VkConversationPinStateChangedEvent) = + viewModelScope.launch(Dispatchers.IO) { + val conversationsList = domainConversations.value.toMutableList() + + val conversationIndex = + conversationsList.findIndex { it.id == event.peerId } ?: return@launch + + val pin = event.majorId > 0 + + var conversation = conversationsList[conversationIndex] + conversation = conversation.copy( + majorId = event.majorId + ).fill() + + conversationsList.removeAt(conversationIndex) + + if (pin) { + conversationsList.add(0, conversation) + } else { + conversationsList.add(pinnedConversationsCount.value - 1, conversation) + } + + emitConversations(conversationsList) + } + + + private fun openMessagesHistoryScreen( + conversationUi: VkConversationUi, user: VkUser?, - group: VkGroup? + group: VkGroup?, ) { + val conversation = domainConversations.value.find { domainConversation -> + domainConversation.id == conversationUi.id + } ?: return + router.navigateTo(Screens.MessagesHistory(conversation, user, group)) } - fun readConversation(conversation: VkConversation) { - makeJob( - { messagesRepository.markAsRead(conversation.id, startMessageId = conversation.lastMessageId) }, - onAnswer = { - sendEvent(MessagesReadEvent(false, conversation.id, conversation.lastMessageId)) + private fun readConversation(conversationId: Int, startMessageId: Int) { + viewModelScope.launch(Dispatchers.IO) { + sendRequest { + messagesRepository.markAsRead( + peerId = conversationId, + startMessageId = startMessageId + ) + }?.response?.let { messageId -> + domainConversations.value.toMutableList().let { list -> + val index = list.findIndex { it.id == conversationId } ?: return@launch + val newConversation = list[index].copy(inRead = messageId) + list[index] = newConversation + + domainConversations.emit(list) + } } - ) + } } } - -data class ConversationsLoadedEvent( - val count: Int, - val offset: Int?, - val unreadCount: Int?, - val conversations: List, - val profiles: HashMap, - val groups: HashMap, - val avatars: List? = null -) : VkEvent() - -data class ConversationsDeleteEvent(val peerId: Int) : VkEvent() - -data class ConversationsPinEvent(val peerId: Int) : VkEvent() - -data class ConversationsUnpinEvent(val peerId: Int) : VkEvent() - -data class MessagesNewEvent( - val message: VkMessage, - val profiles: HashMap, - val groups: HashMap -) : VkEvent() - -data class MessagesEditEvent(val message: VkMessage) : VkEvent() - -data class MessagesReadEvent(val isOut: Boolean, val peerId: Int, val messageId: Int) : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/adapter/ConversationsDelegates.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/adapter/ConversationsDelegates.kt new file mode 100644 index 00000000..793eb36b --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/adapter/ConversationsDelegates.kt @@ -0,0 +1,172 @@ +package com.meloda.fast.screens.conversations.adapter + +import android.graphics.drawable.Drawable +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import coil.ImageLoader +import coil.request.ImageRequest +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.VkUtils +import com.meloda.fast.api.model.ActionState +import com.meloda.fast.api.model.presentation.VkConversationUi +import com.meloda.fast.base.adapter.OnItemClickListener +import com.meloda.fast.base.adapter.OnItemLongClickListener +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.databinding.ItemConversationBinding +import com.meloda.fast.ext.gone +import com.meloda.fast.ext.isFalse +import com.meloda.fast.ext.isTrue +import com.meloda.fast.ext.toggleVisibility +import com.meloda.fast.ext.visible +import com.meloda.fast.model.base.AdapterDiffItem +import com.meloda.fast.model.base.UiImage +import com.meloda.fast.model.base.parseString +import com.meloda.fast.model.base.setImage +import com.meloda.fast.screens.conversations.ConversationsResourceProvider +import com.meloda.fast.screens.settings.SettingsFragment + +fun conversationDelegate( + onItemClickListener: OnItemClickListener, + onItemLongClickListener: OnItemLongClickListener, +) = adapterDelegateViewBinding( + viewBinding = { layoutInflater, parent -> + ItemConversationBinding.inflate(layoutInflater, parent, false) + } +) { + binding.root.setOnClickListener { onItemClickListener.onItemClick(item) } + binding.root.setOnLongClickListener { onItemLongClickListener.onLongItemClick(item) } + + val imageLoader = ImageLoader.Builder(context) + .crossfade(true) + .build() + + val resourceProvider = ConversationsResourceProvider(context) + + bind { + val isMultilineEnabled = + AppGlobal.preferences.getBoolean(SettingsFragment.KEY_APPEARANCE_MULTILINE, true) + val maxLines = if (isMultilineEnabled) 2 else 1 + + binding.title.maxLines = maxLines + binding.message.maxLines = maxLines + + binding.container.background = + if (item.isUnread) resourceProvider.conversationUnreadBackground else null + + binding.title.text = item.title.parseString(context) + + binding.date.text = item.date + + binding.service.toggleVisibility(item.actionState != ActionState.None) + binding.phantomIcon.toggleVisibility(item.actionState == ActionState.Phantom) + binding.callIcon.toggleVisibility(item.actionState == ActionState.CallInProgress) + + binding.counter.toggleVisibility(item.unreadCount != null) + binding.counter.text = item.unreadCount + + binding.textAttachment.toggleVisibility(item.attachmentImage != null) + + binding.pin.toggleVisibility(item.isPinned) + + binding.online.toggleVisibility(item.isOnline) + + binding.avatarPlaceholder.visible() + + (item.avatar as? UiImage.Url)?.let { image -> + ImageRequest.Builder(context) + .data(image.url) + .target( + onSuccess = { result -> + binding.avatar.setImageDrawable(result) + binding.avatarPlaceholder.gone() + } + ) + .build().let(imageLoader::enqueue) + } ?: { + binding.avatar.setImage(item.avatar) { + asCircle = true + crossFade = true + onLoadedAction = { binding.avatarPlaceholder.gone() } + } + } + + val actionMessage = VkUtils.getActionConversationText( + context = context, + message = item.lastMessage, + youPrefix = resourceProvider.youPrefix, + messageUser = item.lastMessage?.user, + messageGroup = item.lastMessage?.group, + action = item.lastMessage?.getPreparedAction(), + actionUser = item.lastMessage?.actionUser, + actionGroup = item.lastMessage?.actionGroup + ) + + val attachmentIcon: Drawable? = when { + item.lastMessage?.text == null -> null + !item.lastMessage?.forwards.isNullOrEmpty() -> { + if (item.lastMessage?.forwards?.size == 1) { + resourceProvider.iconForwardedMessage + } else { + resourceProvider.iconForwardedMessages + } + } + else -> VkUtils.getAttachmentConversationIcon(context, item.lastMessage) + } + + binding.textAttachment.toggleVisibility(attachmentIcon != null) + binding.textAttachment.setImageDrawable(attachmentIcon) + + val attachmentText = (if (attachmentIcon == null) VkUtils.getAttachmentText( + message = item.lastMessage + ) else null)?.parseString(context) + + val forwardsMessage = (if (item.lastMessage?.text == null) VkUtils.getForwardsText( + message = item.lastMessage + ) else null)?.parseString(context) + + val messageText = (if ( + actionMessage != null || + forwardsMessage != null || + attachmentText != null + ) "" + else item.lastMessage?.text ?: "").run { VkUtils.prepareMessageText(this) } + + val coloredMessage = actionMessage ?: attachmentText ?: forwardsMessage ?: "" + + var prefix = when { + actionMessage != null -> "" + item.lastMessage?.isOut.isTrue -> "${resourceProvider.youPrefix}: " + else -> + when { + item.lastMessage?.isUser().isTrue && item.lastMessage?.user != null && item.lastMessage?.user?.firstName?.isNotBlank().isTrue -> { + "${item.lastMessage?.user?.firstName}: " + } + item.lastMessage?.isGroup().isTrue && item.lastMessage?.group != null && item.lastMessage?.group?.name?.isNotBlank().isTrue -> { + "${item.lastMessage?.group?.name}: " + } + else -> "" + } + } + + if ((!item.peerType.isChat() && item.lastMessage?.isOut.isFalse) || item.conversationId == UserConfig.userId) + prefix = "" + + val spanText = "$prefix$coloredMessage$messageText" + + val visualizedMessageText = VkUtils.visualizeMentions( + messageText = spanText, + resourceProvider.colorPrimary + ) + + val length = prefix.length + coloredMessage.length + visualizedMessageText.setSpan( + ForegroundColorSpan(resourceProvider.colorOutline), + 0, + length, + if (length > 0) Spanned.SPAN_EXCLUSIVE_EXCLUSIVE else 0 + ) + + binding.message.text = visualizedMessageText + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/conversations/di/ConversationsModule.kt b/app/src/main/kotlin/com/meloda/fast/screens/conversations/di/ConversationsModule.kt new file mode 100644 index 00000000..aebacc6c --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/conversations/di/ConversationsModule.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.screens.conversations.di + +import com.meloda.fast.screens.conversations.ConversationsViewModelImpl +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val conversationsModule = module { + viewModelOf(::ConversationsViewModelImpl) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt index 0e42eb3c..5e74ac3e 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginFragment.kt @@ -1,475 +1,419 @@ package com.meloda.fast.screens.login -import android.annotation.SuppressLint -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.Typeface import android.os.Bundle -import android.util.Log -import android.view.KeyEvent +import android.view.LayoutInflater import android.view.View -import android.view.inputmethod.EditorInfo -import android.viewbinding.library.fragment.viewBinding -import android.webkit.CookieManager -import android.webkit.WebView -import android.webkit.WebViewClient -import android.widget.Toast -import androidx.activity.addCallback -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputLayout -import com.meloda.fast.BuildConfig +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.compose.animation.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.* +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.meloda.fast.R -import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.VKConstants -import com.meloda.fast.base.viewmodel.* -import com.meloda.fast.common.AppGlobal -import com.meloda.fast.databinding.DialogCaptchaBinding +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.base.viewmodel.ViewModelUtils +import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.databinding.DialogFastLoginBinding -import com.meloda.fast.databinding.DialogValidationBinding -import com.meloda.fast.databinding.FragmentLoginBinding -import com.meloda.fast.extensions.* -import com.meloda.fast.extensions.ImageLoader.loadWithGlide -import com.meloda.fast.screens.main.MainActivity -import com.meloda.fast.screens.settings.SettingsPrefsFragment -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.net.URLEncoder -import java.util.* -import java.util.regex.Pattern -import kotlin.concurrent.schedule +import com.meloda.fast.ext.* +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.login.model.LoginScreenState +import com.meloda.fast.ui.AppTheme +import com.meloda.fast.ui.widgets.TextFieldErrorText +import org.koin.androidx.viewmodel.ext.android.viewModel -@AndroidEntryPoint -class LoginFragment : BaseViewModelFragment(R.layout.fragment_login) { - companion object { - private const val ArgGetFastToken = "get_fast_token" +class LoginFragment : BaseFragment() { - fun newInstance(getFastToken: Boolean = false): LoginFragment { - val fragment = LoginFragment() - fragment.arguments = bundleOf( - ArgGetFastToken to getFastToken - ) + private val viewModel: LoginViewModel by viewModel() - return fragment + private val backPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + viewModel.onBackPressed() } } - override val viewModel: LoginViewModel by viewModels() - private val binding: FragmentLoginBinding by viewBinding() - - private var lastLogin: String = "" - private var lastPassword: String = "" - - private var errorTimer: Timer? = null - - private var captchaInputLayout: TextInputLayout? = null - private var validationInputLayout: TextInputLayout? = null - - private var isGetFastToken: Boolean = false - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.unknownErrorDefaultText = getString(R.string.unknown_error_occurred) - isGetFastToken = requireArguments().getBoolean(ArgGetFastToken, false) + activity?.onBackPressedDispatcher?.addCallback(backPressedCallback) + + viewModel.isNeedToShowLogo.listenValue { needToShow -> + backPressedCallback.isEnabled = !needToShow + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - prepareViews() + listenViewModel() - binding.loginInput.clearFocus() + (view as? ComposeView)?.apply { + setContent { + val showLogo by viewModel.isNeedToShowLogo.collectAsState() - binding.useCrashReporter.isChecked = - AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefEnableReporter, true) - binding.useCrashReporter.setOnCheckedChangeListener { _, isChecked -> - AppGlobal.preferences.edit { - putBoolean(SettingsPrefsFragment.PrefEnableReporter, isChecked) - requireActivity().finishAffinity() - startActivity(Intent(requireContext(), MainActivity::class.java)) - } - } + AppTheme { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + ) { + if (showLogo) { + LoginLogo() + } else { + val state by viewModel.screenState.collectAsStateWithLifecycle() - requireActivity().onBackPressedDispatcher.addCallback { - if (getView() == null) { - isEnabled = false - return@addCallback - } - - if (binding.webView.canGoBack()) { - binding.webView.goBack() - } else { - isEnabled = false - } - } - } - - override fun onEvent(event: VkEvent) { - super.onEvent(event) - - when (event) { - StartProgressEvent -> onProgressStarted() - StopProgressEvent -> onProgressStopped() - - is CaptchaRequiredEvent -> showCaptchaDialog(event.sid, event.image) - is ValidationRequiredEvent -> showValidationRequired(event.sid) - - LoginSuccessAuth -> { - viewModel.initUserConfig() - viewModel.openPrimaryScreen() - } - LoginCodeSent -> showValidationDialog() - } - } - - private fun onProgressStarted() { - binding.loginContainer.gone() - binding.passwordContainer.gone() - binding.auth.gone() - binding.progressBar.visible() - } - - private fun onProgressStopped() { - binding.loginContainer.visible() - binding.passwordContainer.visible() - binding.auth.visible() - binding.progressBar.gone() - } - - private fun prepareViews() { - prepareWebView() - prepareEmailEditText() - preparePasswordEditText() - prepareAuthButton() - } - - @SuppressLint("SetJavaScriptEnabled") - private fun prepareWebView() { - with(binding.webView) { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - - clearCache(true) - webViewClient = object : WebViewClient() { - override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { - if (getView() == null) return - binding.webViewProgressBar.visible() - binding.webView.gone() - - super.onPageStarted(view, url, favicon) - parseAuthUrl(url) - } - - override fun onPageFinished(view: WebView, url: String) { - if (getView() == null) return - binding.webViewProgressBar.gone() - binding.webView.visible() - - super.onPageFinished(view, url) + LoginSignIn( + onSignInClick = viewModel::onSignInButtonClicked, + onLoginInputChanged = viewModel::onLoginInputChanged, + onPasswordInputChanged = viewModel::onPasswordInputChanged, + onPasswordVisibilityButtonClicked = viewModel::onPasswordVisibilityButtonClicked, + state = state, + ) + } + } } } } + } - CookieManager.getInstance().apply { - removeAllCookies(null) - flush() - setAcceptCookie(true) + private fun listenViewModel() = with(viewModel) { + events.listenValue(::handleEvent) + isNeedToShowErrorDialog.listenValue(::handleErrorAlertShow) + isNeedToShowFastLoginDialog.listenValue(::handleFastLoginAlertShow) + } + + private fun handleEvent(event: VkEvent) { + ViewModelUtils.parseEvent(this, event) + } + + private fun handleErrorAlertShow(isNeedToShow: Boolean) { + if (isNeedToShow) { + showErrorDialog() } } - private fun launchWebView() { - binding.webViewContainer.visible() - - val urlToLoad = "https://oauth.vk.com/authorize?client_id=${UserConfig.FAST_APP_ID}&" + - "access_token=${UserConfig.accessToken}&" + - "sdk_package=${BuildConfig.sdkPackage}&" + - "sdk_fingerprint=${BuildConfig.sdkFingerprint}&" + - "display=page&" + - "revoke=1&" + - "scope=${VKConstants.Auth.SCOPE.replace("messages,", "")}&" + - "redirect_uri=${ - URLEncoder.encode( - "https://oauth.vk.com/blank.html", - "utf-8" - ) - }&" + - "response_type=token&" + - "v=${VKConstants.API_VERSION}" - - binding.webView.loadUrl(urlToLoad) - } - - private fun parseAuthUrl(url: String) { - if (url.isBlank()) return - - if (url.startsWith("https://oauth.vk.com/blank.html")) { - if (url.contains("error")) { - Log.e("Fast::Login", "errorUrl: $url") - return - } - - val authData = parseRedirectUrl(url) - if (authData == null) { - Log.e("Fast::Login", "errorUrl: $url") - return - } - - val fastToken = authData.first - - if (isGetFastToken) { - val userId = UserConfig.userId - val accessToken = UserConfig.accessToken - - UserConfig.fastToken = fastToken - - viewModel.saveAccount(userId, accessToken, fastToken) - } else { - val account = requireNotNull(viewModel.currentAccount) - viewModel.currentAccount = account.copy(fastToken = fastToken) - viewModel.initUserConfig() - } - - viewModel.openPrimaryScreen() + private fun handleFastLoginAlertShow(isNeedToShow: Boolean) { + if (isNeedToShow) { + showFastLoginDialog() } } - private fun parseRedirectUrl(url: String): Pair? { - val accessToken = extractPattern(url, "access_token=(.*?)&") ?: return null - val userId = extractPattern(url, "id=(\\d*)")?.toIntOrNull() ?: return null - - return accessToken to userId + private fun showErrorDialog() { + context?.showDialog( + title = UiText.Resource(R.string.title_error), + message = UiText.Simple(viewModel.screenState.value.error.orEmpty()), + positiveText = UiText.Resource(R.string.ok), + onDismissAction = viewModel::onErrorDialogDismissed + ) } - private fun extractPattern(string: String, pattern: String): String? { - val p = Pattern.compile(pattern) - val m = p.matcher(string) - return if (m.find()) { - m.group(1) - } else null - } - - private fun prepareEmailEditText() { - binding.loginInput.addTextChangedListener { - if (!binding.loginLayout.error.isNullOrBlank()) binding.loginLayout.error = "" - } - } - - private fun preparePasswordEditText() { - binding.passwordInput.typeface = Typeface.DEFAULT - binding.passwordLayout.endIconMode = TextInputLayout.END_ICON_NONE - - binding.passwordInput.addTextChangedListener { - if (!binding.passwordLayout.error.isNullOrBlank()) binding.passwordLayout.error = "" - } - - binding.passwordInput.setOnFocusChangeListener { _, hasFocus -> - binding.passwordLayout.endIconMode = - if (hasFocus) TextInputLayout.END_ICON_PASSWORD_TOGGLE - else TextInputLayout.END_ICON_NONE - } - - binding.passwordInput.setOnEditorActionListener edit@{ _, _, event -> - if (event == null) return@edit false - return@edit if (event.action == EditorInfo.IME_ACTION_GO || - (event.action == KeyEvent.ACTION_DOWN && (event.keyCode == KeyEvent.KEYCODE_ENTER || event.keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)) - ) { - binding.passwordInput.hideKeyboard() - binding.auth.performClick() - true - } else false - } - } - - private fun prepareAuthButton() { - binding.auth.setOnClickListener { validateDataAndAuth() } - binding.auth.setOnLongClickListener { - showFastLoginAlert() - true - } - } - - private fun showFastLoginAlert() { + private fun showFastLoginDialog() { val dialogFastLoginBinding = DialogFastLoginBinding.inflate(layoutInflater, null, false) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.fast_login_title) - .setView(dialogFastLoginBinding.root) - .setPositiveButton(R.string.ok) { _, _ -> + context?.showDialog( + title = UiText.Resource(R.string.fast_login_title), + view = dialogFastLoginBinding.root, + positiveText = UiText.Resource(R.string.ok), + positiveAction = { val text = dialogFastLoginBinding.fastLoginText.trimmedText - if (text.isEmpty()) return@setPositiveButton + if (text.isEmpty()) return@showDialog val split = text.split(";") try { val login = split[0] val password = split[1] - binding.loginInput.setText(login) - binding.loginInput.selectLast() + viewModel.onLoginInputChanged(login) + viewModel.onPasswordInputChanged(password) - binding.passwordInput.setText(password) - binding.passwordInput.selectLast() + viewModel.onFastLoginDialogOkButtonClicked() + } catch (e: Exception) { + e.printStackTrace() + } + }, + negativeText = UiText.Resource(R.string.cancel), + onDismissAction = viewModel::onFastLoginDialogDismissed + ) + } - validateDataAndAuth(login to password) - } catch (ignored: Exception) { + @OptIn(ExperimentalFoundationApi::class) + @Composable + fun LoginLogo() { + Box( + modifier = Modifier + .fillMaxSize() + .padding(30.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_logo_big), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), + modifier = Modifier.combinedClickableSound( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onLongClick = viewModel::onLogoLongClicked + ) + ) + Spacer(modifier = Modifier.height(46.dp)) + Text( + text = "Fast Messenger", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onBackground + ) + } + + FloatingActionButton( + onClick = viewModel::onLogoNextButtonClicked, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_end), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + + @Preview + @Composable + fun LoginSignInPreview() { + AppTheme( + useDarkTheme = false, + useDynamicColors = false + ) { + Surface(color = MaterialTheme.colorScheme.background) { + LoginSignIn( + state = LoginScreenState.EMPTY, + onSignInClick = { }, + onLoginInputChanged = {}, + onPasswordInputChanged = {}, + onPasswordVisibilityButtonClicked = {} + ) + } + } + } + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) + @Composable + fun LoginSignIn( + onSignInClick: () -> Unit, + onLoginInputChanged: (String) -> Unit, + onPasswordInputChanged: (String) -> Unit, + onPasswordVisibilityButtonClicked: () -> Unit, + state: LoginScreenState + ) { + val focusManager = LocalFocusManager.current + val (loginFocusable, passwordFocusable) = FocusRequester.createRefs() + val isLoading = state.isLoading + + val goButtonClickAction = { + if (!isLoading) { + focusManager.clearFocus() + onSignInClick.invoke() + } + } + val loginFieldTabClick = { + passwordFocusable.requestFocus() + true + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(30.dp) + .imePadding() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + ) { + Text( + text = "Sign in to VK", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.displayMedium + ) + + Spacer(modifier = Modifier.height(58.dp)) + + var loginText by remember { mutableStateOf(TextFieldValue(state.login)) } + val showLoginError = state.loginError + + TextField( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .handleEnterKey(loginFieldTabClick::invoke) + .handleTabKey(loginFieldTabClick::invoke) + .focusRequester(loginFocusable), + value = loginText, + onValueChange = { newText -> + loginText = newText + onLoginInputChanged.invoke(newText.text) + }, + label = { Text(text = "Login") }, + placeholder = { Text(text = "Login") }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_round_person_24), + contentDescription = null, + tint = if (showLoginError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + }, + shape = RoundedCornerShape(10.dp), + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Email + ), + keyboardActions = KeyboardActions(onNext = { passwordFocusable.requestFocus() }), + isError = showLoginError, + singleLine = true + ) + AnimatedVisibility(visible = showLoginError) { + TextFieldErrorText(text = "Field must not be empty") + } + + Spacer(modifier = Modifier.height(16.dp)) + + var passwordText by remember { mutableStateOf(TextFieldValue(state.password)) } + val showPasswordError = state.passwordError + var passwordVisible = state.passwordVisible + + TextField( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .handleEnterKey { + goButtonClickAction.invoke() + true + } + .focusRequester(passwordFocusable), + value = passwordText, + onValueChange = { newText -> + passwordText = newText + onPasswordInputChanged.invoke(newText.text) + }, + label = { Text(text = "Password") }, + placeholder = { Text(text = "Password") }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.round_vpn_key_24), + contentDescription = null, + tint = if (showPasswordError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + }, + trailingIcon = { + val imagePainter = painterResource( + id = if (passwordVisible) R.drawable.round_visibility_off_24 + else R.drawable.round_visibility_24 + ) + + IconButton( + onClick = { + onPasswordVisibilityButtonClicked.invoke() + passwordVisible = !passwordVisible + } + ) { + Icon(painter = imagePainter, contentDescription = null) + } + }, + shape = RoundedCornerShape(10.dp), + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Go, + keyboardType = KeyboardType.Password + ), + keyboardActions = KeyboardActions( + onGo = { goButtonClickAction.invoke() } + ), + isError = showPasswordError, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + singleLine = true + ) + AnimatedVisibility(visible = showPasswordError) { + TextFieldErrorText(text = "Field must not be empty") } } - .setNegativeButton(R.string.cancel, null) - .show() - } - private fun validateDataAndAuth(data: Pair? = null) { - if (binding.progressBar.isVisible) return - val loginString = data?.first ?: binding.loginInput.text.toString().trim() - val passwordString = data?.second ?: binding.passwordInput.text.toString().trim() + Box( + modifier = Modifier.align(Alignment.BottomCenter), + contentAlignment = Alignment.Center + ) { - if (!validateInputData(loginString, passwordString)) return - - lastLogin = loginString - lastPassword = passwordString - - requireView().findFocus()?.hideKeyboard() - - viewModel.login( - login = loginString, - password = passwordString - ) - } - - private fun validateInputData( - loginString: String?, - passwordString: String?, - captchaCode: String? = null, - validationCode: String? = null - ): Boolean { - var isValidated = true - - if (loginString?.isEmpty() == true) { - isValidated = false - setError(getString(R.string.input_login_hint), binding.loginLayout) - } - - if (passwordString?.isEmpty() == true) { - isValidated = false - setError(getString(R.string.input_password_hint), binding.passwordLayout) - } - - if (captchaCode?.isEmpty() == true && captchaInputLayout != null) { - isValidated = false - setError(getString(R.string.input_code_hint), captchaInputLayout!!) - } - - if (validationCode?.isEmpty() == true && validationInputLayout != null) { - isValidated = false - setError(getString(R.string.input_code_hint), validationInputLayout!!) - } - - return isValidated - } - - private fun setError(error: String, inputLayout: TextInputLayout) { - inputLayout.error = error - - if (errorTimer != null) { - errorTimer?.cancel() - errorTimer = null - } - - if (errorTimer == null) { - errorTimer = Timer() - } - - errorTimer?.schedule(2500) { - lifecycleScope.launch(Dispatchers.Main) { clearErrors() } + FloatingActionButton( + onClick = goButtonClickAction::invoke, + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_end), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut() + ) { + CircularProgressIndicator() + } + } } } - private fun clearErrors() { - binding.loginLayout.error = "" - binding.passwordLayout.error = "" + companion object { - captchaInputLayout?.error = "" - } - - private fun showCaptchaDialog(captchaSid: String, captchaImage: String) { - val captchaBinding = DialogCaptchaBinding.inflate(layoutInflater, null, false) - captchaInputLayout = captchaBinding.captchaLayout - - captchaBinding.image.loadWithGlide( - url = captchaImage, - crossFade = true - ) - captchaBinding.image.shapeAppearanceModel = - captchaBinding.image.shapeAppearanceModel.withCornerSize(16.dpToPx().toFloat()) - - val builder = MaterialAlertDialogBuilder(requireContext()) - .setView(captchaBinding.root) - .setCancelable(false) - .setTitle(R.string.input_captcha) - - val dialog = builder.show() - - captchaBinding.ok.setOnClickListener { - val captchaCode = captchaBinding.captchaInput.text.toString().trim() - - if (!validateInputData( - loginString = null, - passwordString = null, - captchaCode = captchaCode - ) - ) return@setOnClickListener - - dialog.dismiss() - - viewModel.login( - login = lastLogin, - password = lastPassword, - captcha = captchaSid to captchaCode - ) + fun newInstance(): LoginFragment { + return LoginFragment() } - captchaBinding.cancel.setOnClickListener { dialog.dismiss() } } - - private fun showValidationDialog() { - val validationBinding = DialogValidationBinding.inflate(layoutInflater, null, false) - validationInputLayout = validationBinding.codeLayout - - val builder = MaterialAlertDialogBuilder(requireContext()) - .setView(validationBinding.root) - .setCancelable(false) - .setTitle(R.string.input_validation_code) - - val dialog = builder.show() - - validationBinding.ok.setOnClickListener { - val validationCode = validationBinding.codeInput.trimmedText - - if (!validateInputData( - loginString = null, - passwordString = null, - validationCode = validationCode - ) - ) return@setOnClickListener - - dialog.dismiss() - - viewModel.login( - login = lastLogin, - password = lastPassword, - twoFaCode = validationCode - ) - } - validationBinding.cancel.setOnClickListener { dialog.dismiss() } - } - - private fun showValidationRequired(validationSid: String) { - Toast.makeText(requireContext(), R.string.validation_required, Toast.LENGTH_LONG).show() - viewModel.sendSms(validationSid) - } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginScreens.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginScreens.kt new file mode 100644 index 00000000..7b379af1 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginScreens.kt @@ -0,0 +1,10 @@ +package com.meloda.fast.screens.login + +import com.github.terrakok.cicerone.androidx.FragmentScreen + +object LoginScreens { + + fun login() = FragmentScreen { + LoginFragment.newInstance() + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt index c0ad4819..5527d5a9 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/LoginViewModel.kt @@ -4,93 +4,367 @@ import androidx.lifecycle.viewModelScope import com.github.terrakok.cicerone.Router import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VKConstants +import com.meloda.fast.api.network.WrongTwoFaCodeError +import com.meloda.fast.api.network.WrongTwoFaCodeFormatError import com.meloda.fast.api.network.auth.AuthDirectRequest -import com.meloda.fast.base.viewmodel.BaseViewModel -import com.meloda.fast.base.viewmodel.ErrorTextEvent +import com.meloda.fast.base.viewmodel.CaptchaRequiredEvent +import com.meloda.fast.base.viewmodel.DeprecatedBaseViewModel +import com.meloda.fast.base.viewmodel.UnknownErrorEvent +import com.meloda.fast.base.viewmodel.ValidationRequiredEvent import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.Screens import com.meloda.fast.data.account.AccountsDao import com.meloda.fast.data.auth.AuthRepository +import com.meloda.fast.ext.emitOnMainScope +import com.meloda.fast.ext.emitOnScope +import com.meloda.fast.ext.listenValue +import com.meloda.fast.ext.updateValue import com.meloda.fast.model.AppAccount -import dagger.hilt.android.lifecycle.HiltViewModel +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.captcha.screen.CaptchaArguments +import com.meloda.fast.screens.captcha.screen.CaptchaResult +import com.meloda.fast.screens.captcha.screen.CaptchaScreen +import com.meloda.fast.screens.login.model.LoginScreenState +import com.meloda.fast.screens.login.model.LoginValidationResult +import com.meloda.fast.screens.login.validation.LoginValidator +import com.meloda.fast.screens.twofa.model.TwoFaArguments +import com.meloda.fast.screens.twofa.model.TwoFaResult +import com.meloda.fast.screens.twofa.model.TwoFaValidationType +import com.meloda.fast.screens.twofa.screen.TwoFaScreen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class LoginViewModel @Inject constructor( +interface LoginViewModel { + val events: Flow + + val isNeedToShowLogo: StateFlow + + val screenState: StateFlow + + val isNeedToShowFastLoginDialog: Flow + val isNeedToShowErrorDialog: Flow + + fun onBackPressed() + + fun onPasswordVisibilityButtonClicked() + + fun onLogoNextButtonClicked() + + fun onLoginInputChanged(newLogin: String) + fun onPasswordInputChanged(newPassword: String) + + fun onSignInButtonClicked() + fun onSignInButtonLongClicked() + + fun onFastLoginDialogOkButtonClicked() + + fun onFastLoginDialogDismissed() + fun onErrorDialogDismissed() + fun onLogoLongClicked() +} + +class LoginViewModelImpl constructor( private val authRepository: AuthRepository, private val router: Router, - private val accounts: AccountsDao -) : BaseViewModel() { + private val accounts: AccountsDao, + private val loginValidator: LoginValidator, + private val captchaScreen: CaptchaScreen, + private val twoFaScreen: TwoFaScreen +) : DeprecatedBaseViewModel(), LoginViewModel { - var currentAccount: AppAccount? = null + override val isNeedToShowLogo = MutableStateFlow(true) - fun login( - login: String, - password: String, - twoFaCode: String? = null, - captcha: Pair? = null - ) = viewModelScope.launch { - makeJob( - { - authRepository.auth( - AuthDirectRequest( + override val screenState = MutableStateFlow(LoginScreenState.EMPTY) + + private val validationState: StateFlow> = + screenState.map(loginValidator::validate) + .stateIn(viewModelScope, SharingStarted.Eagerly, listOf(LoginValidationResult.Empty)) + + private val captchaResult = captchaScreen.resultFlow + private val twoFaResult = twoFaScreen.resultFlow + + override val events = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val isNeedToShowErrorDialog = MutableStateFlow(false) + override val isNeedToShowFastLoginDialog = MutableStateFlow(false) + + private var currentValidationEvent: ValidationRequiredEvent? = null + + init { + tasksEvent.listenValue(::handleEvent) + + captchaResult.listenValue { result -> + when (result) { + is CaptchaResult.Success -> { + val sid = result.sid + val code = result.code + val newState = screenState.value.copy( + captchaSid = sid, captchaCode = code + ) + screenState.updateValue(newState) + + login() + } + + else -> Unit + } + } + + twoFaResult.listenValue { result -> + when (result) { + is TwoFaResult.Success -> { + val sid = result.sid + val code = result.code + val newState = screenState.value.copy( + validationSid = sid, validationCode = code + ) + screenState.updateValue(newState) + + login() + } + + else -> Unit + } + } + } + + private fun handleEvent(event: VkEvent) { + when (event) { + is CaptchaRequiredEvent -> onCaptchaEventReceived(event) + is ValidationRequiredEvent -> onValidationEventReceived(event) + else -> events.emitOnScope(event) + } + } + + override fun onBackPressed() { + if (isNeedToShowLogo.value) { + router.exit() + } else { + isNeedToShowLogo.updateValue(true) + } + } + + override fun onPasswordVisibilityButtonClicked() { + val newState = screenState.value.copy( + passwordVisible = !screenState.value.passwordVisible + ) + screenState.updateValue(newState) + } + + override fun onLogoNextButtonClicked() { + isNeedToShowLogo.emitOnMainScope(false) + } + + override fun onLoginInputChanged(newLogin: String) { + val newState = screenState.value.copy( + login = newLogin.trim(), + loginError = false + ) + screenState.updateValue(newState) + } + + override fun onPasswordInputChanged(newPassword: String) { + val newState = screenState.value.copy( + password = newPassword.trim(), + passwordError = false + ) + screenState.updateValue(newState) + } + + private fun onCaptchaEventReceived(event: CaptchaRequiredEvent) { + val captchaSid = event.sid + val captchaImage = event.image + + val newState = screenState.value.copy( + captchaSid = captchaSid, + captchaImage = captchaImage + ) + screenState.update { newState } + + showCaptchaScreen( + CaptchaArguments( + captchaSid = captchaSid, + captchaImage = captchaImage + ) + ) + } + + private fun showCaptchaScreen(args: CaptchaArguments) { + captchaScreen.show(router, args) + } + + private fun onValidationEventReceived(event: ValidationRequiredEvent) { + currentValidationEvent = event + + val validationSid = event.sid + val newForm = screenState.value.copy( + validationSid = validationSid + ) + screenState.update { newForm } + + showValidationScreen( + TwoFaArguments( + validationSid = event.sid, + redirectUri = event.redirectUri, + phoneMask = event.phoneMask, + validationType = TwoFaValidationType.parse(event.validationType), + canResendSms = event.canResendSms, + wrongCodeError = event.codeError + ) + ) + } + + private fun showValidationScreen(args: TwoFaArguments) { + twoFaScreen.show(router, args) + } + + override fun onSignInButtonClicked() { + login() + } + + override fun onSignInButtonLongClicked() { + isNeedToShowFastLoginDialog.emitOnMainScope(true) + } + + override fun onFastLoginDialogOkButtonClicked() { + login() + } + + override fun onFastLoginDialogDismissed() { + isNeedToShowFastLoginDialog.emitOnMainScope(false) + } + + override fun onErrorDialogDismissed() { + isNeedToShowErrorDialog.emitOnMainScope(false) + } + + override fun onLogoLongClicked() { + router.navigateTo(Screens.Settings()) + } + + private fun login(forceSms: Boolean = false) { + currentValidationEvent?.let { event -> + if (!screenState.value.validationSid.isNullOrBlank() && screenState.value.validationCode == null) { + handleEvent(event) + return + } + } + + val state = screenState.value.copy() + + val clearedState = screenState.value.copy( + captchaSid = null, + captchaImage = null, + captchaCode = null, + validationSid = null, + validationCode = null + ) + + screenState.update { clearedState } + + processValidation() + if (!validationState.value.contains(LoginValidationResult.Valid)) return + + viewModelScope.launch(Dispatchers.IO) { + var newState = screenState.value.copy( + isLoading = true + ) + screenState.update { newState } + + sendRequest( + onError = { error -> + when (error) { + is WrongTwoFaCodeError, WrongTwoFaCodeFormatError -> { + currentValidationEvent?.let { event -> + val codeError = UiText.Simple( + if (error is WrongTwoFaCodeError) "Wrong code" + else "Wrong code format" + ) + handleEvent(event.copy(codeError = codeError)) + true + } ?: false + } + + else -> false + } + }, + request = { + val requestModel = AuthDirectRequest( grantType = VKConstants.Auth.GrantType.PASSWORD, clientId = VKConstants.VK_APP_ID, clientSecret = VKConstants.VK_SECRET, - username = login, - password = password, + username = state.login, + password = state.password, scope = VKConstants.Auth.SCOPE, - twoFaForceSms = true, - twoFaCode = twoFaCode, - captchaSid = captcha?.first, - captchaKey = captcha?.second + twoFaForceSms = forceSms, + twoFaCode = state.validationCode, + captchaSid = state.captchaSid, + captchaKey = state.captchaCode ) - ) - }, - onAnswer = { - if (it.userId == null || it.accessToken == null) { - sendEvent(ErrorTextEvent(unknownErrorDefaultText)) - return@makeJob + + authRepository.auth(requestModel) + } + )?.let { response -> + val userId = response.userId + val accessToken = response.accessToken + + if (userId == null || accessToken == null) { + sendEvent(UnknownErrorEvent) + return@let } - currentAccount = AppAccount( - userId = it.userId, - accessToken = it.accessToken, + if (currentValidationEvent != null) { + currentValidationEvent = null + } + + val currentAccount = AppAccount( + userId = userId, + accessToken = accessToken, fastToken = null ).also { account -> UserConfig.currentUserId = account.userId UserConfig.userId = account.userId UserConfig.accessToken = account.accessToken + UserConfig.fastToken = account.fastToken } - sendEvent(LoginSuccessAuth) + accounts.insert(listOf(currentAccount)) + + router.replaceScreen(Screens.Main()) } - ) + + newState = screenState.value.copy( + isLoading = false + ) + screenState.update { newState } + } } - fun sendSms(validationSid: String) = viewModelScope.launch { - makeJob({ authRepository.sendSms(validationSid) }, - onAnswer = { sendEvent(LoginCodeSent) } - ) - } + private fun processValidation() { + validationState.value.forEach { result -> + when (result) { + LoginValidationResult.LoginEmpty -> { + screenState.updateValue(screenState.value.copy(loginError = true)) + } - fun openPrimaryScreen() { - router.replaceScreen(Screens.Main()) - } + LoginValidationResult.PasswordEmpty -> { + screenState.updateValue(screenState.value.copy(passwordError = true)) + } - fun initUserConfig() = viewModelScope.launch { - val account = requireNotNull(currentAccount) - UserConfig.fastToken = account.fastToken - - accounts.insert(listOf(account)) - } - - fun saveAccount(userId: Int, accessToken: String, fastToken: String?) = viewModelScope.launch { - val account = AppAccount(userId, accessToken, fastToken) - accounts.insert(listOf(account)) + LoginValidationResult.Empty -> Unit + LoginValidationResult.Valid -> Unit + } + } } } - -object LoginCodeSent : VkEvent() -object LoginSuccessAuth : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/di/LoginModule.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/di/LoginModule.kt new file mode 100644 index 00000000..a7e4c4c0 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/di/LoginModule.kt @@ -0,0 +1,11 @@ +package com.meloda.fast.screens.login.di + +import com.meloda.fast.screens.login.LoginViewModelImpl +import com.meloda.fast.screens.login.validation.LoginValidator +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val loginModule = module { + single { LoginValidator() } + viewModelOf(::LoginViewModelImpl) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/model/LoginResult.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/model/LoginResult.kt new file mode 100644 index 00000000..03f3090d --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/model/LoginResult.kt @@ -0,0 +1,6 @@ +package com.meloda.fast.screens.login.model + +sealed class LoginResult { + object Authorized : LoginResult() + object Cancelled : LoginResult() +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/model/LoginScreenState.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/model/LoginScreenState.kt new file mode 100644 index 00000000..ea1aa70e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/model/LoginScreenState.kt @@ -0,0 +1,36 @@ +package com.meloda.fast.screens.login.model + +data class LoginScreenState( + val login: String, + val password: String, + val captchaSid: String?, + val captchaCode: String?, + val captchaImage: String?, + val validationSid: String?, + val validationCode: String?, + val error: String?, + val isLoading: Boolean, + val loginError: Boolean, + val passwordError: Boolean, + val passwordVisible: Boolean, + val copiedCode: String? +) { + + companion object { + val EMPTY = LoginScreenState( + login = "", + password = "", + captchaSid = null, + captchaCode = null, + captchaImage = null, + validationSid = null, + validationCode = null, + error = null, + isLoading = false, + loginError = false, + passwordError = false, + passwordVisible = false, + copiedCode = null + ) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/model/LoginValidationResult.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/model/LoginValidationResult.kt new file mode 100644 index 00000000..923ef6b7 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/model/LoginValidationResult.kt @@ -0,0 +1,12 @@ +package com.meloda.fast.screens.login.model + +sealed class LoginValidationResult { + + object LoginEmpty : LoginValidationResult() + + object PasswordEmpty : LoginValidationResult() + + object Empty : LoginValidationResult() + + object Valid : LoginValidationResult() +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/screen/LoginScreen.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/screen/LoginScreen.kt new file mode 100644 index 00000000..d49612d2 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/screen/LoginScreen.kt @@ -0,0 +1,16 @@ +package com.meloda.fast.screens.login.screen + +import com.github.terrakok.cicerone.Router +import com.meloda.fast.base.screen.AppScreen +import com.meloda.fast.base.screen.createResultFlow +import com.meloda.fast.screens.login.model.LoginResult + +class LoginScreen : AppScreen { + + override val resultFlow = createResultFlow() + override var args: Unit = Unit + + override fun show(router: Router, args: Unit) { + this.args = args + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/login/validation/LoginValidator.kt b/app/src/main/kotlin/com/meloda/fast/screens/login/validation/LoginValidator.kt new file mode 100644 index 00000000..4a74dccc --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/login/validation/LoginValidator.kt @@ -0,0 +1,27 @@ +package com.meloda.fast.screens.login.validation + +import com.meloda.fast.ext.addIf +import com.meloda.fast.screens.login.model.LoginScreenState +import com.meloda.fast.screens.login.model.LoginValidationResult + +class LoginValidator { + + fun validate(screenState: LoginScreenState): List { + val resultList = mutableListOf() + + resultList.addIf(LoginValidationResult.LoginEmpty) { + screenState.login.isBlank() + } + + resultList.addIf(LoginValidationResult.PasswordEmpty) { + screenState.password.isBlank() + } + + resultList.addIf(LoginValidationResult.Valid) { + resultList.isEmpty() + } + + return resultList + } + +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt deleted file mode 100644 index 381b6376..00000000 --- a/app/src/main/kotlin/com/meloda/fast/screens/main/MainActivity.kt +++ /dev/null @@ -1,350 +0,0 @@ -package com.meloda.fast.screens.main - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.ClipData -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.viewbinding.library.activity.viewBinding -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.core.view.size -import androidx.datastore.preferences.core.edit -import androidx.drawerlayout.widget.DrawerLayout -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentTransaction -import androidx.lifecycle.lifecycleScope -import com.github.terrakok.cicerone.NavigatorHolder -import com.github.terrakok.cicerone.Router -import com.github.terrakok.cicerone.androidx.AppNavigator -import com.github.terrakok.cicerone.androidx.FragmentScreen -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.meloda.fast.BuildConfig -import com.meloda.fast.R -import com.meloda.fast.api.UserConfig -import com.meloda.fast.api.longpoll.LongPollUpdatesParser -import com.meloda.fast.base.BaseActivity -import com.meloda.fast.common.* -import com.meloda.fast.data.account.AccountsDao -import com.meloda.fast.databinding.ActivityMainBinding -import com.meloda.fast.extensions.gone -import com.meloda.fast.extensions.toggleVisibility -import com.meloda.fast.screens.settings.SettingsPrefsFragment -import com.meloda.fast.service.LongPollService -import com.meloda.fast.service.OnlineService -import com.microsoft.appcenter.AppCenter -import com.microsoft.appcenter.analytics.Analytics -import com.microsoft.appcenter.crashes.Crashes -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import javax.inject.Inject -import kotlin.properties.Delegates - -@AndroidEntryPoint -class MainActivity : BaseActivity(R.layout.activity_main) { - - private val navigator = object : AppNavigator(this, R.id.root_fragment_container) { - override fun setupFragmentTransaction( - screen: FragmentScreen, - fragmentTransaction: FragmentTransaction, - currentFragment: Fragment?, - nextFragment: Fragment - ) { - } - } - - @Inject - lateinit var navigatorHolder: NavigatorHolder - - @Inject - lateinit var router: Router - - @Inject - lateinit var updateManager: UpdateManager - - @Inject - lateinit var accountsDao: AccountsDao - - @Inject - lateinit var updatesParser: LongPollUpdatesParser - - val binding: ActivityMainBinding by viewBinding() - - var useNavDrawer: Boolean by Delegates.observable(false) { _, _, _ -> - syncNavigationMode() - } - - override fun onResumeFragments() { - navigatorHolder.setNavigator(navigator) - super.onResumeFragments() - } - - override fun onPause() { - navigatorHolder.removeNavigator() - super.onPause() - } - - override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() - super.onCreate(savedInstanceState) - - createNotificationChannels() - - AppCenter.configure(application, BuildConfig.msAppCenterAppToken) - - if (!BuildConfig.DEBUG) { - AppCenter.start(Analytics::class.java) - } - - AppCenter.start(Crashes::class.java) - Crashes.setEnabled( - AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefEnableReporter, true) - ) - - binding.navigationBar.gone() - - lifecycleScope.launch { - dataStore.data.map { data -> - useNavDrawer = data[AppSettings.keyUseNavigationDrawer] ?: false - }.collect() - } - - if (UserConfig.currentUserId == -1) { - openMainScreen() - } else { - initUserConfig() - } - - updateManager.checkUpdates { item, _ -> - if (item != null) { - router.navigateTo(Screens.Updates(item)) - } - } - - binding.drawer.getHeaderView(0).setOnLongClickListener { - lifecycleScope.launch { - dataStore.edit { settings -> - val useNavDrawer = settings[AppSettings.keyUseNavigationDrawer] ?: false - settings[AppSettings.keyUseNavigationDrawer] = !useNavDrawer - - finish() - startActivity(Intent(this@MainActivity, MainActivity::class.java)) - } - } - true - } - - syncNavigationMode() - binding.navigationBar.selectedItemId = R.id.messages - - supportFragmentManager.setFragmentResultListener( - MainFragment.KeyStartServices, - this - ) { _, result -> - val enable = result.getBoolean("enable", true) - if (enable) { - startServices() - } else { - stopServices() - } - } - } - - private fun createNotificationChannels() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val dialogsName = "Dialogs" - val dialogsDescriptionText = "Channel for dialogs notifications" - val dialogsImportance = NotificationManager.IMPORTANCE_MAX - val dialogsChannel = NotificationChannel("simple_notifications", dialogsName, dialogsImportance).apply { - description = dialogsDescriptionText - } - - val longPollName = "Long Polling" - val longPollDescriptionText = "Channel for long polling service (temporary)" - val longPollImportance = NotificationManager.IMPORTANCE_NONE - val longPollChannel = NotificationChannel("long_polling", longPollName, longPollImportance).apply { - description = longPollDescriptionText - } - - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(dialogsChannel) - notificationManager.createNotificationChannel(longPollChannel) - } - } - - override fun onResume() { - super.onResume() - - Crashes.getLastSessionCrashReport().thenAccept { report -> - if (report != null) { - if (AppGlobal.preferences.getBoolean( - SettingsPrefsFragment.PrefShowCrashAlert, - true - ) - ) { - val stackTrace = report.stackTrace - - MaterialAlertDialogBuilder(this) - .setTitle(R.string.app_crash_occurred) - .setMessage("Stacktrace: $stackTrace") - .setPositiveButton(R.string.ok, null) - .setNegativeButton(R.string.copy) { _, _ -> - AppGlobal.clipboardManager.setPrimaryClip( - ClipData.newPlainText( - "Fast_Crash_Report", - stackTrace - ) - ) - Toast.makeText(this, "Copied", Toast.LENGTH_SHORT).show() - } - .setNeutralButton(R.string.share) { _, _ -> - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, stackTrace) - type = "text/plain" - } - - val shareIntent = Intent.createChooser(sendIntent, "Share stacktrace") - try { - startActivity(shareIntent) - } catch (e: Exception) { - e.printStackTrace() - - runOnUiThread { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.warning) - .setMessage("Can't share") - .setPositiveButton(R.string.ok, null) - .show() - } - } - } - .show() - } - } - } - - if (AppGlobal.preferences.getBoolean(LongPollService.KeyLongPollWasDestroyed, false)) { - AppGlobal.preferences.edit { - putBoolean(LongPollService.KeyLongPollWasDestroyed, false) - } - - if (AppGlobal.preferences.getBoolean( - SettingsPrefsFragment.PrefShowDestroyedLongPollAlert, - false - ) - ) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.warning) - .setMessage("Long poll was destroyed.") - .setPositiveButton("Restart this shit") { _, _ -> - startServices() - } - .setCancelable(false) - .show() - } else { - startServices() - } - } - } - - private fun startServices() { - ContextCompat.startForegroundService(this, Intent(this, LongPollService::class.java)) - startService(Intent(this, OnlineService::class.java)) - } - - private fun stopServices() { - stopService(Intent(this, LongPollService::class.java)) - stopService(Intent(this, OnlineService::class.java)) - } - - private fun addTestMenuItem() { - val test = binding.navigationBar.menu.add("Test") - test.setIcon(R.drawable.ic_round_settings_24) - test.setOnMenuItemClickListener { - if (binding.navigationBar.menu.size < 5) { - addClearMenuItem() - } else { - binding.navigationBar.menu.clear() - addTestMenuItem() - } - - true - } - } - - private fun addClearMenuItem() { - binding.navigationBar.menu.add("Test").run { - setIcon(R.drawable.ic_round_settings_24) - setOnMenuItemClickListener { - binding.navigationBar.menu.clear() - addTestMenuItem() - true - } - } - } - - private fun initUserConfig() { - if (UserConfig.currentUserId == -1) return - - lifecycleScope.launch { - val accounts = accountsDao.getAll() - - Log.d("MainActivity", "initUserConfig: accounts: $accounts") - if (accounts.isNotEmpty()) { - val currentAccount = accounts.find { it.userId == UserConfig.currentUserId } - if (currentAccount != null) { - UserConfig.parse(currentAccount) - } - - openMainScreen() - } else { - openMainScreen() - } - } - } - - private fun openMainScreen() { - router.newRootScreen(Screens.Main()) - } - - private fun syncNavigationMode() { -// binding.navigationBar.toggleVisibility(!useNavDrawer) - binding.drawerLayout.setDrawerLockMode( - if (useNavDrawer) DrawerLayout.LOCK_MODE_UNLOCKED - else DrawerLayout.LOCK_MODE_LOCKED_CLOSED - ) - } - - fun toggleNavBarVisibility(isVisible: Boolean, smooth: Boolean = false) { - if (true) { - binding.navigationBar.gone() - return - } - - if (useNavDrawer) { - binding.navigationBar.gone() - } else { - if (smooth) { - binding.navigationBar.toggleVisibility(isVisible) - } else { - binding.navigationBar.toggleVisibility(isVisible) - } - } - } - - override fun onDestroy() { - super.onDestroy() - stopServices() - updatesParser.clearListeners() - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt index e2dcec48..bf48d0d6 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/MainFragment.kt @@ -1,52 +1,59 @@ package com.meloda.fast.screens.main import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult -import androidx.fragment.app.viewModels -import com.meloda.fast.base.viewmodel.BaseViewModelFragment +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.base.viewmodel.ViewModelUtils import com.meloda.fast.base.viewmodel.VkEvent -import dagger.hilt.android.AndroidEntryPoint +import com.meloda.fast.ext.listenValue +import com.meloda.fast.screens.main.activity.ServicesState +import org.koin.androidx.viewmodel.ext.android.viewModel -@AndroidEntryPoint -class MainFragment : BaseViewModelFragment() { +class MainFragment : BaseFragment() { - companion object { - const val KeyStartServices = "start_services" + private val viewModel: MainViewModel by viewModel() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + Log.d("MainFragment", "onCreate: viewModel: $viewModel") } - override val viewModel: MainViewModel by viewModels() - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - return View(context) - } + ) = View(context) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - - viewModel.checkSession() + listenViewModel() } - override fun onEvent(event: VkEvent) { - super.onEvent(event) + private fun listenViewModel() { + viewModel.events.listenValue(::onEvent) - when (event) { - StartServicesEvent -> { - setFragmentResult(KeyStartServices, bundleOf("enable" to true)) - } - StopServicesEvent -> { - setFragmentResult(KeyStartServices, bundleOf("enable" to false)) - } - is SetNavBarVisibilityEvent -> { - (requireActivity() as MainActivity).toggleNavBarVisibility(event.isVisible) - } + viewModel.servicesState.listenValue { state -> + val enableServices = state == ServicesState.Started + setFragmentResult( + START_SERVICES_KEY, + bundleOf(START_SERVICES_ARG_ENABLE to enableServices) + ) } } -} \ No newline at end of file + + private fun onEvent(event: VkEvent) { + ViewModelUtils.parseEvent(this, event) + } + + companion object { + const val START_SERVICES_KEY = "start_services" + const val START_SERVICES_ARG_ENABLE = "enable" + + fun newInstance(): MainFragment = MainFragment() + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt index 05e62829..d34e1391 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/MainViewModel.kt @@ -5,42 +5,50 @@ import androidx.lifecycle.viewModelScope import com.github.terrakok.cicerone.Router import com.github.terrakok.cicerone.Screen import com.meloda.fast.api.UserConfig -import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.base.viewmodel.DeprecatedBaseViewModel import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.Screens -import dagger.hilt.android.lifecycle.HiltViewModel +import com.meloda.fast.screens.main.activity.ServicesState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class MainViewModel @Inject constructor(private val router: Router) : BaseViewModel() { +interface MainViewModel { + val events: Flow - fun checkSession() = viewModelScope.launch { - val currentUserId = UserConfig.currentUserId - val userId = UserConfig.userId - val accessToken = UserConfig.accessToken - val fastToken = UserConfig.fastToken + val servicesState: Flow +} +class MainViewModelImpl constructor( + private val router: Router +) : MainViewModel, DeprecatedBaseViewModel() { + + override val events = tasksEvent.map { it } + + override val servicesState = MutableStateFlow(ServicesState.Unknown) + + init { + checkSession() + } + + private fun checkSession() { viewModelScope.launch { - sendEvent(SetNavBarVisibilityEvent(UserConfig.isLoggedIn())) - } + val currentUserId = UserConfig.currentUserId + val userId = UserConfig.userId + val accessToken = UserConfig.accessToken + val fastToken = UserConfig.fastToken - Log.d( - "MainViewModel", - "checkSession: currentUserId: $currentUserId; userId: $userId; accessToken: $accessToken; fastToken: $fastToken" - ) + Log.d( + "MainViewModel", + "checkSession: currentUserId: $currentUserId; userId: $userId; accessToken: $accessToken; fastToken: $fastToken" + ) - when { -// fastToken == null -> { -// sendEvent(StopServicesEvent) -// openScreen(Screens.Login(true)) -// } - UserConfig.isLoggedIn() -> { - sendEvent(StartServicesEvent) + if (UserConfig.isLoggedIn()) { + servicesState.emit(ServicesState.Started) openScreen(Screens.Conversations()) - } - else -> { - sendEvent(StopServicesEvent) + } else { + servicesState.emit(ServicesState.Stopped) openScreen(Screens.Login()) } } @@ -49,11 +57,4 @@ class MainViewModel @Inject constructor(private val router: Router) : BaseViewMo private fun openScreen(screen: Screen) { router.replaceScreen(screen) } - } - -data class SetNavBarVisibilityEvent(val isVisible: Boolean) : VkEvent() - -object StartServicesEvent : VkEvent() - -object StopServicesEvent : VkEvent() \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/activity/LongPollState.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/activity/LongPollState.kt new file mode 100644 index 00000000..0e0ebf16 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/activity/LongPollState.kt @@ -0,0 +1,7 @@ +package com.meloda.fast.screens.main.activity + +sealed class LongPollState { + object ForegroundService : LongPollState() + object DefaultService : LongPollState() + object Stop : LongPollState() +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/activity/LongPollUtils.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/activity/LongPollUtils.kt new file mode 100644 index 00000000..d0078dcb --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/activity/LongPollUtils.kt @@ -0,0 +1,129 @@ +package com.meloda.fast.screens.main.activity + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import com.fondesa.kpermissions.coroutines.sendSuspend +import com.fondesa.kpermissions.extension.permissionsBuilder +import com.fondesa.kpermissions.isGranted +import com.fondesa.kpermissions.isPermanentlyDenied +import com.meloda.fast.R +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.ext.sdk33AndUp +import com.meloda.fast.ext.showDialog +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.settings.SettingsFragment +import kotlinx.coroutines.launch + +object LongPollUtils { + + fun requestNotificationsPermission( + fragmentActivity: FragmentActivity, + onStateChangedAction: (LongPollState) -> Unit, + fromSettings: Boolean = false, + ) { + val longPollInForegroundEnabled = + AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_FEATURES_LONG_POLL_IN_BACKGROUND, + SettingsFragment.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND + ) + + sdk33AndUp { + fragmentActivity.lifecycleScope.launch { + val result = + fragmentActivity.permissionsBuilder(Manifest.permission.POST_NOTIFICATIONS) + .build() + .sendSuspend() + .first() + + val resultToEmit: LongPollState = when { + longPollInForegroundEnabled && result.isGranted() -> LongPollState.ForegroundService + else -> LongPollState.DefaultService + } + + onStateChangedAction.invoke(resultToEmit) + + val isLongPollOnlyInsideApp = + AppGlobal.preferences.getBoolean("lp_inside_app", false) + + if (result.isGranted()) { + AppGlobal.preferences.edit { putBoolean("lp_inside_app", false) } + } + + if (longPollInForegroundEnabled && + !result.isGranted() && + (!isLongPollOnlyInsideApp || fromSettings) + ) { + showNotificationsPermissionAlert( + fragmentActivity, + onStateChangedAction, + result.isPermanentlyDenied(), + ) + } + } + } ?: run { + onStateChangedAction.invoke( + if (longPollInForegroundEnabled) LongPollState.ForegroundService + else LongPollState.DefaultService + ) + } + } + + private fun showNotificationsPermissionAlert( + fragmentActivity: FragmentActivity, + onStateChangedAction: (LongPollState) -> Unit, + permanentlyDenied: Boolean, + ) { + val positiveText = + UiText.Simple(if (permanentlyDenied) "Open settings" else "Grant") + val positiveAction = { + if (permanentlyDenied) { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${fragmentActivity.packageName}") + ) + intent.addCategory(Intent.CATEGORY_DEFAULT) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + try { + fragmentActivity.startActivity(intent) + } catch (e: Exception) { + e.printStackTrace() + } + } else { + requestNotificationsPermission(fragmentActivity, onStateChangedAction) + } + } + + val neutralText = + if (permanentlyDenied) UiText.Resource(R.string.ok) + else UiText.Simple("Dismiss") + val neutralAction = { + if (permanentlyDenied) { + AppGlobal.preferences.edit { + putBoolean("lp_inside_app", true) + putBoolean(SettingsFragment.KEY_FEATURES_LONG_POLL_IN_BACKGROUND, false) + } + } else Unit + } + + fragmentActivity.showDialog( + title = UiText.Resource(R.string.warning), + message = UiText.Simple( + "You denied notifications permission." + + "\nWithout notifications LongPoll service will work only inside app." + + "\nThis means that messages will only be updated while app is on the screen" + ), + positiveText = positiveText, + positiveAction = positiveAction, + neutralText = neutralText, + neutralAction = neutralAction, + isCancelable = false + ) + } + +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/activity/MainActivity.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/activity/MainActivity.kt new file mode 100644 index 00000000..2fcda73b --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/activity/MainActivity.kt @@ -0,0 +1,316 @@ +package com.meloda.fast.screens.main.activity + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.lifecycle.lifecycleScope +import com.github.terrakok.cicerone.NavigatorHolder +import com.github.terrakok.cicerone.Router +import com.github.terrakok.cicerone.androidx.AppNavigator +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.meloda.fast.BuildConfig +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.api.longpoll.LongPollUpdatesParser +import com.meloda.fast.base.BaseActivity +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.common.Screens +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.ext.edgeToEdge +import com.meloda.fast.ext.isSdkAtLeast +import com.meloda.fast.ext.listenValue +import com.meloda.fast.screens.main.MainFragment +import com.meloda.fast.screens.main.activity.LongPollUtils.requestNotificationsPermission +import com.meloda.fast.screens.settings.SettingsFragment +import com.meloda.fast.service.LongPollService +import com.meloda.fast.service.OnlineService +import com.meloda.fast.util.AndroidUtils +import com.microsoft.appcenter.AppCenter +import com.microsoft.appcenter.analytics.Analytics +import com.microsoft.appcenter.crashes.Crashes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject + +class MainActivity : BaseActivity(R.layout.activity_main) { + + private val navigator = object : AppNavigator(this, R.id.root_fragment_container) {} + + private val navigatorHolder: NavigatorHolder by inject() + + private val router: Router by inject() + + private val accountsDao: AccountsDao by inject() + + private val updatesParser: LongPollUpdatesParser by inject() + + private var isOnlineServiceWasLaunched: Boolean = false + + private var savedInstanceState: Bundle? = null + + override fun onResumeFragments() { + navigatorHolder.setNavigator(navigator) + super.onResumeFragments() + } + + override fun onPause() { + if (isOnlineServiceWasLaunched) { + toggleOnlineService(false) + } + navigatorHolder.removeNavigator() + super.onPause() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + this.savedInstanceState = savedInstanceState + edgeToEdge() + + createNotificationChannels() + + AppCenter.configure(application, BuildConfig.msAppCenterAppToken) + + if (!BuildConfig.DEBUG) { + AppCenter.start(Analytics::class.java) + } + + val enableCrashLogs = + AppGlobal.preferences.getBoolean(SettingsFragment.KEY_MS_APPCENTER_ENABLE, true) + || (BuildConfig.DEBUG && AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_MS_APPCENTER_ENABLE_ON_DEBUG, + false + )) + + if (enableCrashLogs) { + AppCenter.start(Crashes::class.java) + } + + if (UserConfig.currentUserId == -1) { + openMainScreen() + } else { + initUserConfig() + } + + // TODO: 09.04.2023, Danil Nikolaev: implement checking updates on startup + + // TODO: 09.04.2023, Danil Nikolaev: rewrite this + supportFragmentManager.setFragmentResultListener( + MainFragment.START_SERVICES_KEY, + this + ) { _, result -> + val enable = result.getBoolean(MainFragment.START_SERVICES_ARG_ENABLE, true) + if (enable) { + requestNotificationsPermission( + fragmentActivity = this, + onStateChangedAction = { state -> + lifecycleScope.launch { longPollState.emit(state) } + } + ) + + startServices() + } else { + stopServices() + } + } + + // TODO: 09.04.2023, Danil Nikolaev: rewrite this + longPollState.listenValue { state -> + stopLongPollService() + + when (state) { + LongPollState.DefaultService -> startLongPollService(false) + LongPollState.ForegroundService -> startLongPollService(true) + else -> Unit + } + } + } + + private fun createNotificationChannels() { + isSdkAtLeast(Build.VERSION_CODES.O) { + val dialogsName = "Dialogs" + val dialogsDescriptionText = "Channel for dialogs notifications" + val dialogsImportance = NotificationManager.IMPORTANCE_HIGH + val dialogsChannel = + NotificationChannel("simple_notifications", dialogsName, dialogsImportance).apply { + description = dialogsDescriptionText + } + + val longPollName = "Long Polling" + val longPollDescriptionText = "Channel for long polling service" + val longPollImportance = NotificationManager.IMPORTANCE_NONE + val longPollChannel = + NotificationChannel("long_polling", longPollName, longPollImportance).apply { + description = longPollDescriptionText + } + + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannels(listOf(dialogsChannel, longPollChannel)) + } + } + + override fun onResume() { + super.onResume() + + if (isOnlineServiceWasLaunched) { + toggleOnlineService(true) + } + + Crashes.getLastSessionCrashReport().thenAccept { report -> + if (report != null) { + if (AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_DEBUG_SHOW_CRASH_ALERT, + true + ) + ) { + val stackTrace = report.stackTrace + + MaterialAlertDialogBuilder(this) + .setTitle(R.string.app_crash_occurred) + .setMessage("Stacktrace: $stackTrace") + .setPositiveButton(R.string.ok, null) + .setNegativeButton(R.string.copy) { _, _ -> + AndroidUtils.copyText( + label = "Fast_Crash_Report", + text = stackTrace + ) + Toast.makeText(this, "Copied", Toast.LENGTH_SHORT).show() + } + .setNeutralButton(R.string.share) { _, _ -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, stackTrace) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, "Share stacktrace") + try { + startActivity(shareIntent) + } catch (e: Exception) { + e.printStackTrace() + + runOnUiThread { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.warning) + .setMessage("Can't share") + .setPositiveButton(R.string.ok, null) + .show() + } + } + } + .show() + } + } + } + + if (AppGlobal.preferences.getBoolean(LongPollService.KeyLongPollWasDestroyed, false)) { + AppGlobal.preferences.edit { + putBoolean(LongPollService.KeyLongPollWasDestroyed, false) + } + + startServices() + } + } + + private fun startServices() { + toggleOnlineService(true) + } + + private fun stopServices() { + toggleOnlineService(false) + } + + private fun createLongPollIntent(asForeground: Boolean? = null): Intent = + Intent(this, LongPollService::class.java).apply { + asForeground?.let { putExtra("foreground", it) } + } + + private fun startLongPollService(asForeground: Boolean) { + val longPollIntent = createLongPollIntent(asForeground) + + if (asForeground) { + ContextCompat.startForegroundService(this, longPollIntent) + } else { + startService(longPollIntent) + } + } + + private fun stopLongPollService() { + stopService(createLongPollIntent()) + } + + private fun toggleOnlineService(enable: Boolean) { + if (enable) { + isOnlineServiceWasLaunched = true + startService(Intent(this, OnlineService::class.java)) + } else { + stopService(Intent(this, OnlineService::class.java)) + } + } + + private fun initUserConfig() { + if (UserConfig.currentUserId == -1) return + + lifecycleScope.launch { + val accounts = accountsDao.getAll() + + Log.d("MainActivity", "initUserConfig: accounts: $accounts") + if (accounts.isNotEmpty()) { + val currentAccount = accounts.find { it.userId == UserConfig.currentUserId } + if (currentAccount != null) { + UserConfig.parse(currentAccount) + } + + openMainScreen() + } else { + openMainScreen() + } + } + } + + private fun openMainScreen() { + if (savedInstanceState != null) return + + var needToOpenSettings = false + + if (intent.dataString != null) { + intent.dataString?.let { data -> + if (data == "shortcut_settings") { + needToOpenSettings = true + } + } + } + + if (intent.hasExtra("data")) { + if (intent.getStringExtra("data") == "open_settings") { + needToOpenSettings = true + } + } + + if (needToOpenSettings) { + router.newRootScreen(Screens.Settings()) + } else { + router.newRootScreen(Screens.Main()) + } + } + + override fun onDestroy() { + super.onDestroy() + stopServices() + updatesParser.clearListeners() + isOnlineServiceWasLaunched = false + savedInstanceState = null + } + + companion object { + val longPollState = MutableStateFlow(LongPollState.Stop) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/activity/ServicesState.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/activity/ServicesState.kt new file mode 100644 index 00000000..8a16a8ae --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/activity/ServicesState.kt @@ -0,0 +1,7 @@ +package com.meloda.fast.screens.main.activity + +sealed class ServicesState { + object Started : ServicesState() + object Stopped : ServicesState() + object Unknown : ServicesState() +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/main/di/MainModule.kt b/app/src/main/kotlin/com/meloda/fast/screens/main/di/MainModule.kt new file mode 100644 index 00000000..1305bdb7 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/main/di/MainModule.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.screens.main.di + +import com.meloda.fast.screens.main.MainViewModelImpl +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val mainModule = module { + viewModelOf(::MainViewModelImpl) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt index 33e919a0..52b55b52 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentInflater.kt @@ -14,7 +14,11 @@ import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.LinearLayoutCompat import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat -import androidx.core.view.* +import androidx.core.view.isNotEmpty +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updateMarginsRelative +import androidx.core.view.updatePadding import com.bumptech.glide.Priority import com.bumptech.glide.load.engine.DiskCacheStrategy import com.meloda.fast.R @@ -23,15 +27,48 @@ import com.meloda.fast.api.VkUtils import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser -import com.meloda.fast.api.model.attachments.* +import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.api.model.attachments.VkAudio +import com.meloda.fast.api.model.attachments.VkCall +import com.meloda.fast.api.model.attachments.VkFile +import com.meloda.fast.api.model.attachments.VkGift +import com.meloda.fast.api.model.attachments.VkGraffiti +import com.meloda.fast.api.model.attachments.VkLink +import com.meloda.fast.api.model.attachments.VkPhoto +import com.meloda.fast.api.model.attachments.VkSticker +import com.meloda.fast.api.model.attachments.VkStory +import com.meloda.fast.api.model.attachments.VkVideo +import com.meloda.fast.api.model.attachments.VkVoiceMessage +import com.meloda.fast.api.model.attachments.VkWall import com.meloda.fast.api.model.base.BaseVkMessage -import com.meloda.fast.databinding.* -import com.meloda.fast.extensions.* -import com.meloda.fast.extensions.ImageLoader.clear -import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.databinding.ItemMessageAttachmentAudioBinding +import com.meloda.fast.databinding.ItemMessageAttachmentCallBinding +import com.meloda.fast.databinding.ItemMessageAttachmentFileBinding +import com.meloda.fast.databinding.ItemMessageAttachmentForwardsBinding +import com.meloda.fast.databinding.ItemMessageAttachmentGeoBinding +import com.meloda.fast.databinding.ItemMessageAttachmentGiftBinding +import com.meloda.fast.databinding.ItemMessageAttachmentGraffitiBinding +import com.meloda.fast.databinding.ItemMessageAttachmentLinkBinding +import com.meloda.fast.databinding.ItemMessageAttachmentPhotoBinding +import com.meloda.fast.databinding.ItemMessageAttachmentReplyBinding +import com.meloda.fast.databinding.ItemMessageAttachmentStickerBinding +import com.meloda.fast.databinding.ItemMessageAttachmentStoryBinding +import com.meloda.fast.databinding.ItemMessageAttachmentVideoBinding +import com.meloda.fast.databinding.ItemMessageAttachmentVoiceBinding +import com.meloda.fast.databinding.ItemMessageAttachmentWallPostBinding +import com.meloda.fast.ext.ImageLoader.clear +import com.meloda.fast.ext.ImageLoader.loadWithGlide +import com.meloda.fast.ext.TypeTransformations +import com.meloda.fast.ext.dpToPx +import com.meloda.fast.ext.gone +import com.meloda.fast.ext.orDots +import com.meloda.fast.ext.toggleVisibility +import com.meloda.fast.ext.toggleVisibilityIfHasContent +import com.meloda.fast.ext.visible +import com.meloda.fast.model.base.parseString import com.meloda.fast.util.AndroidUtils import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale import kotlin.math.roundToInt class AttachmentInflater constructor( @@ -41,12 +78,16 @@ class AttachmentInflater constructor( private val timeReadContainer: View, private val message: VkMessage, private val profiles: Map, - private val groups: Map + private val groups: Map, ) { private lateinit var attachments: List private val inflater = LayoutInflater.from(context) + private val colorPrimary = ContextCompat.getColor( + context, + R.color.colorPrimary + ) private val colorBackground = ContextCompat.getColor( context, R.color.colorBackground @@ -177,21 +218,22 @@ class AttachmentInflater constructor( val binding = ItemMessageAttachmentReplyBinding.inflate(inflater, replyContainer, true) binding.root.setOnClickListener { replyClickListener?.invoke(replyMessage) } - val attachmentText = VkUtils.getAttachmentText( - context = context, + val attachmentText = (VkUtils.getAttachmentText( message = replyMessage - ) + ))?.parseString(context) - val forwardsMessage = if (replyMessage.text == null) VkUtils.getForwardsText( - context = context, + val forwardsMessage = (if (replyMessage.text == null) VkUtils.getForwardsText( message = replyMessage - ) else null + ) else null)?.parseString(context) val messageText = attachmentText ?: forwardsMessage ?: (replyMessage.text.orDots()).run { VkUtils.prepareMessageText(this) } - binding.text.text = messageText + binding.text.text = VkUtils.visualizeMentions( + messageText = messageText, + mentionColor = colorPrimary + ) val replyUserGroup = VkUtils.getMessageUserGroup(replyMessage, profiles, groups) @@ -246,11 +288,11 @@ class AttachmentInflater constructor( width = (displayMetrics.widthPixels * widthMultiplier).roundToInt() dimensionRatio = ratio } - loadWithGlide( - drawable = ColorDrawable(colorSecondary), - priority = Priority.IMMEDIATE, + loadWithGlide { + imageDrawable = ColorDrawable(colorSecondary) + loadPriority = Priority.IMMEDIATE cacheStrategy = DiskCacheStrategy.NONE - ) + } } binding.image.run { @@ -260,12 +302,12 @@ class AttachmentInflater constructor( photo.getMaxSize()?.let { size -> photoClickListener?.invoke(size.url) } } - loadWithGlide( - url = size.url, - crossFade = true, - placeholderDrawable = ColorDrawable(colorBackground), - priority = Priority.LOW - ) + loadWithGlide { + imageUrl = size.url + crossFade = true + placeholderDrawable = ColorDrawable(colorBackground) + loadPriority = Priority.LOW + } } } @@ -298,22 +340,22 @@ class AttachmentInflater constructor( width = (displayMetrics.widthPixels * widthMultiplier).roundToInt() dimensionRatio = ratio } - loadWithGlide( - drawable = ColorDrawable(colorSecondary), - priority = Priority.IMMEDIATE, + loadWithGlide { + imageDrawable = ColorDrawable(colorSecondary) + loadPriority = Priority.IMMEDIATE cacheStrategy = DiskCacheStrategy.NONE - ) + } } binding.image.run { shapeAppearanceModel = shapeAppearanceModel.withCornerSize(cornersRadius * 0.8F) - loadWithGlide( - url = size.url, - crossFade = true, - placeholderDrawable = ColorDrawable(colorBackground), - priority = Priority.LOW - ) + loadWithGlide { + imageUrl = size.url + crossFade = true + placeholderDrawable = ColorDrawable(colorBackground) + loadPriority = Priority.LOW + } } } @@ -349,7 +391,10 @@ class AttachmentInflater constructor( binding.caption.toggleVisibility(!link.caption.isNullOrBlank()) link.photo?.getSizeOrSmaller('y')?.let { size -> - binding.preview.loadWithGlide(url = size.url, crossFade = true) + binding.preview.loadWithGlide { + imageUrl = size.url + crossFade = true + } binding.linkIcon.gone() return } @@ -375,7 +420,10 @@ class AttachmentInflater constructor( layoutParams = LinearLayoutCompat.LayoutParams(size, size) - loadWithGlide(url = url, crossFade = true) + loadWithGlide { + imageUrl = url + crossFade = true + } } } @@ -409,7 +457,10 @@ class AttachmentInflater constructor( binding.avatar.toggleVisibility(group != null || user != null) if (binding.avatar.isVisible) { - binding.avatar.loadWithGlide(url = avatar, crossFade = true) + binding.avatar.loadWithGlide { + imageUrl = avatar + crossFade = true + } } else { binding.avatar.clear() } @@ -491,7 +542,10 @@ class AttachmentInflater constructor( (graffiti.height / heightCoefficient).roundToInt() ) - loadWithGlide(url = url, crossFade = true) + loadWithGlide { + imageUrl = url + crossFade = true + } } } @@ -507,7 +561,10 @@ class AttachmentInflater constructor( layoutParams = LinearLayoutCompat.LayoutParams(size, size) - loadWithGlide(url = url, crossFade = true) + loadWithGlide { + imageUrl = url + crossFade = true + } } } @@ -531,21 +588,21 @@ class AttachmentInflater constructor( ) } - binding.dimmer.loadWithGlide( - drawable = dimmerDrawable, - transformations = listOf(TypeTransformations.RoundedCornerCrop(cornersRadius)), - priority = Priority.IMMEDIATE, + binding.dimmer.loadWithGlide { + imageDrawable = dimmerDrawable + transformations = listOf(TypeTransformations.RoundedCornerCrop(cornersRadius)) + loadPriority = Priority.IMMEDIATE cacheStrategy = DiskCacheStrategy.NONE - ) + } binding.image.run { shapeAppearanceModel = shapeAppearanceModel.withCornerSize(cornersRadius.toFloat()) - loadWithGlide( - url = photoUrl, - crossFade = true, + loadWithGlide { + imageUrl = photoUrl + crossFade = true placeholderDrawable = ColorDrawable(Color.GRAY) - ) + } } if (story.ownerId == UserConfig.userId) { @@ -568,4 +625,4 @@ class AttachmentInflater constructor( binding.dimmer.toggleVisibility(binding.caption.isVisible) } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt index c9ca0d64..97c15eb8 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/AttachmentsAdapter.kt @@ -7,19 +7,23 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DiffUtil import com.google.android.material.shape.ShapeAppearanceModel import com.meloda.fast.R -import com.meloda.fast.api.model.attachments.* +import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.api.model.attachments.VkAudio +import com.meloda.fast.api.model.attachments.VkFile +import com.meloda.fast.api.model.attachments.VkPhoto +import com.meloda.fast.api.model.attachments.VkVideo import com.meloda.fast.base.adapter.BaseAdapter import com.meloda.fast.base.adapter.BaseHolder import com.meloda.fast.databinding.ItemUploadedAttachmentAudioBinding import com.meloda.fast.databinding.ItemUploadedAttachmentFileBinding import com.meloda.fast.databinding.ItemUploadedAttachmentPhotoBinding import com.meloda.fast.databinding.ItemUploadedAttachmentVideoBinding -import com.meloda.fast.extensions.ImageLoader.clear -import com.meloda.fast.extensions.ImageLoader.loadWithGlide -import com.meloda.fast.extensions.dpToPx -import com.meloda.fast.extensions.gone -import com.meloda.fast.extensions.toggleVisibility -import com.meloda.fast.extensions.visible +import com.meloda.fast.ext.ImageLoader.clear +import com.meloda.fast.ext.ImageLoader.loadWithGlide +import com.meloda.fast.ext.dpToPx +import com.meloda.fast.ext.gone +import com.meloda.fast.ext.toggleVisibility +import com.meloda.fast.ext.visible class AttachmentsAdapter( context: Context, @@ -80,7 +84,7 @@ class AttachmentsAdapter( } inner class PhotoViewHolder( - private val binding: ItemUploadedAttachmentPhotoBinding + private val binding: ItemUploadedAttachmentPhotoBinding, ) : Holder(binding.root) { init { @@ -93,13 +97,15 @@ class AttachmentsAdapter( binding.progressBar.visible() - binding.image.loadWithGlide( - url = photo.getSizeOrSmaller(VkPhoto.SIZE_TYPE_807)?.url, - crossFade = true, - placeholderColor = colorPrimaryVariant, - onLoadedAction = { binding.progressBar.gone() }, - onFailedAction = { binding.progressBar.gone() } - ) + val onDoneAction = { binding.progressBar.gone() } + + binding.image.loadWithGlide { + imageUrl = photo.getSizeOrSmaller(VkPhoto.SIZE_TYPE_807)?.url + crossFade = true + placeholderColor = colorPrimaryVariant + onLoadedAction = onDoneAction + onFailedAction = onDoneAction + } binding.close.setOnClickListener { onRemoveClickedListener?.invoke(position) @@ -108,7 +114,7 @@ class AttachmentsAdapter( } inner class VideoViewHolder( - private val binding: ItemUploadedAttachmentVideoBinding + private val binding: ItemUploadedAttachmentVideoBinding, ) : Holder(binding.root) { init { val cornerSizedShapeAppearanceModel = ShapeAppearanceModel().withCornerSize( @@ -132,13 +138,13 @@ class AttachmentsAdapter( if (previewSrc != null) { binding.progressBar.visible() - binding.image.loadWithGlide( - url = previewSrc.url, - crossFade = true, - placeholderColor = colorPrimaryVariant, - onLoadedAction = { binding.progressBar.gone() }, + binding.image.loadWithGlide { + imageUrl = previewSrc.url + crossFade = true + placeholderColor = colorPrimaryVariant + onLoadedAction = { binding.progressBar.gone() } onFailedAction = { showPlaceholder() } - ) + } } else { binding.progressBar.gone() binding.image.clear() @@ -159,7 +165,7 @@ class AttachmentsAdapter( } inner class AudioViewHolder( - private val binding: ItemUploadedAttachmentAudioBinding + private val binding: ItemUploadedAttachmentAudioBinding, ) : Holder(binding.root) { init { binding.coloredBackground.shapeAppearanceModel = @@ -178,7 +184,7 @@ class AttachmentsAdapter( } inner class FileViewHolder( - private val binding: ItemUploadedAttachmentFileBinding + private val binding: ItemUploadedAttachmentFileBinding, ) : Holder(binding.root) { init { @@ -203,13 +209,13 @@ class AttachmentsAdapter( if (previewSrc != null) { binding.progressBar.visible() - binding.image.loadWithGlide( - url = previewSrc.src, - crossFade = true, - placeholderColor = colorPrimaryVariant, - onLoadedAction = { binding.progressBar.gone() }, + binding.image.loadWithGlide { + imageUrl = previewSrc.src + crossFade = true + placeholderColor = colorPrimaryVariant + onLoadedAction = { binding.progressBar.gone() } onFailedAction = { showPlaceholder() } - ) + } } else { binding.progressBar.gone() binding.image.clear() @@ -228,4 +234,4 @@ class AttachmentsAdapter( binding.progressBar.gone() } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt index 5362b612..8c68224a 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/ForwardedMessagesFragment.kt @@ -2,49 +2,33 @@ package com.meloda.fast.screens.messages import android.os.Bundle import android.view.View -import android.viewbinding.library.fragment.viewBinding import androidx.core.os.bundleOf +import by.kirich1409.viewbindingdelegate.viewBinding +import com.github.terrakok.cicerone.Router import com.meloda.fast.R -import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser +import com.meloda.fast.api.model.domain.VkConversationDomain import com.meloda.fast.base.BaseFragment import com.meloda.fast.common.Screens import com.meloda.fast.databinding.FragmentForwardedMessagesBinding +import com.meloda.fast.ext.getParcelableArrayListCompat +import com.meloda.fast.ext.getParcelableCompat +import com.meloda.fast.ext.getSerializableCompat +import dev.chrisbanes.insetter.applyInsetter +import org.koin.android.ext.android.inject class ForwardedMessagesFragment : BaseFragment(R.layout.fragment_forwarded_messages) { - companion object { - private const val ArgConversation = "conversation" - private const val ArgMessages = "messages" - private const val ArgProfiles = "profiles" - private const val ArgGroups = "groups" + private val router: Router by inject() - fun newInstance( - conversation: VkConversation, - messages: List, - profiles: HashMap = hashMapOf(), - groups: HashMap = hashMapOf() - ): ForwardedMessagesFragment { - val fragment = ForwardedMessagesFragment() - fragment.arguments = bundleOf( - ArgConversation to conversation, - ArgMessages to messages, - ArgProfiles to profiles, - ArgGroups to groups - ) + private val binding by viewBinding(FragmentForwardedMessagesBinding::bind) - return fragment - } - } - - private val binding: FragmentForwardedMessagesBinding by viewBinding() - - private var conversation: VkConversation? = null + private var conversation: VkConversationDomain? = null private var messages: List = emptyList() - private var profiles: HashMap = hashMapOf() - private var groups: HashMap = hashMapOf() + private var profiles = hashMapOf() + private var groups = hashMapOf() private val adapter: MessagesHistoryAdapter by lazy { MessagesHistoryAdapter( @@ -57,18 +41,31 @@ class ForwardedMessagesFragment : BaseFragment(R.layout.fragment_forwarded_messa super.onCreate(savedInstanceState) requireArguments().run { - conversation = getParcelable(ArgConversation) - messages = getParcelableArrayList(ArgMessages) ?: emptyList() + conversation = getParcelableCompat(ArgConversation, VkConversationDomain::class.java) - profiles = getSerializable(ArgProfiles) as? HashMap ?: hashMapOf() - groups = getSerializable(ArgGroups) as? HashMap ?: hashMapOf() + messages = getParcelableArrayListCompat(ArgMessages, VkMessage::class.java) + ?: emptyList() + + profiles = + getSerializableCompat(ArgProfiles, HashMap::class.java) as? HashMap + ?: hashMapOf() + groups = + getSerializableCompat(ArgGroups, HashMap::class.java) as? HashMap + ?: hashMapOf() } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + binding.recyclerView.applyInsetter { + type(navigationBars = true) { padding() } + } + + binding.toolbar.applyInsetter { + type(statusBars = true) { padding() } + } + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } fillRecyclerView() } @@ -85,14 +82,37 @@ class ForwardedMessagesFragment : BaseFragment(R.layout.fragment_forwarded_messa } fun openForwardsScreen( - conversation: VkConversation, + conversation: VkConversationDomain, messages: List, profiles: HashMap = hashMapOf(), groups: HashMap = hashMapOf() ) { - requireActivityRouter().navigateTo( + router.navigateTo( Screens.ForwardedMessages(conversation, messages, profiles, groups) ) } -} \ No newline at end of file + companion object { + private const val ArgConversation = "conversation" + private const val ArgMessages = "messages" + private const val ArgProfiles = "profiles" + private const val ArgGroups = "groups" + + fun newInstance( + conversation: VkConversationDomain, + messages: List, + profiles: HashMap = hashMapOf(), + groups: HashMap = hashMapOf() + ): ForwardedMessagesFragment { + val fragment = ForwardedMessagesFragment() + fragment.arguments = bundleOf( + ArgConversation to conversation, + ArgMessages to messages, + ArgProfiles to profiles, + ArgGroups to groups + ) + + return fragment + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt index 77f063f3..bbbb680d 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryAdapter.kt @@ -1,6 +1,5 @@ package com.meloda.fast.screens.messages -import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.Color @@ -15,33 +14,32 @@ import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import com.meloda.fast.R import com.meloda.fast.api.VkUtils -import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.VkPhoto +import com.meloda.fast.api.model.domain.VkConversationDomain import com.meloda.fast.base.adapter.BaseAdapter import com.meloda.fast.base.adapter.BaseHolder import com.meloda.fast.databinding.ItemMessageInBinding import com.meloda.fast.databinding.ItemMessageOutBinding import com.meloda.fast.databinding.ItemMessageServiceBinding -import com.meloda.fast.extensions.ImageLoader.loadWithGlide -import com.meloda.fast.extensions.dpToPx -import com.meloda.fast.model.DataItem +import com.meloda.fast.ext.ImageLoader.loadWithGlide +import com.meloda.fast.ext.dpToPx class MessagesHistoryAdapter constructor( context: Context, - val conversation: VkConversation, + val conversation: VkConversationDomain, val profiles: HashMap = hashMapOf(), val groups: HashMap = hashMapOf(), -) : BaseAdapter, MessagesHistoryAdapter.BasicHolder>( +) : BaseAdapter( context, Comparator ) { constructor( fragment: MessagesHistoryFragment, - conversation: VkConversation, + conversation: VkConversationDomain, profiles: HashMap = hashMapOf(), groups: HashMap = hashMapOf(), ) : this(fragment.requireContext(), conversation, profiles, groups) { @@ -50,9 +48,9 @@ class MessagesHistoryAdapter constructor( constructor( fragment: ForwardedMessagesFragment, - conversation: VkConversation, + conversation: VkConversationDomain, profiles: HashMap = hashMapOf(), - groups: HashMap = hashMapOf() + groups: HashMap = hashMapOf(), ) : this(fragment.requireContext(), conversation, profiles, groups) { this.isForwards = true this.forwardedMessagesFragment = fragment @@ -75,29 +73,12 @@ class MessagesHistoryAdapter constructor( else -> -1 } } - is DataItem.Header -> { - return TypeHeader - } - is DataItem.Footer -> { - return TypeFooter - } else -> -1 } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BasicHolder { return when (viewType) { - // magick numbers is great! - TypeHeader -> { - Header(createEmptyView(60)) - } - TypeFooter -> { - Footer( - createEmptyView( - context.resources.getDimensionPixelSize(R.dimen.messages_history_input_panel_height_with_margins) - ) - ) - } TypeService -> ServiceMessage( ItemMessageServiceBinding.inflate(inflater, parent, false) ) @@ -129,17 +110,6 @@ class MessagesHistoryAdapter constructor( holder.bind(position) } - private fun createEmptyView(size: Int) = View(context).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - size - ) - - isEnabled = false - isClickable = false - isFocusable = false - } - open inner class BasicHolder(v: View = View(context)) : BaseHolder(v) inner class Header(v: View) : BasicHolder(v) @@ -147,17 +117,19 @@ class MessagesHistoryAdapter constructor( inner class Footer(v: View) : BasicHolder(v) inner class IncomingMessage( - private val binding: ItemMessageInBinding + private val binding: ItemMessageInBinding, ) : BasicHolder(binding.root) { override fun bind(position: Int, payloads: MutableList?) { val message = getItem(position) as VkMessage - val prevMessage = getVkMessage(getOrNull(position - 1)) - val nextMessage = getVkMessage(getOrNull(position + 1)) + val prevMessage = getOrNull(position - 1) + val nextMessage = getOrNull(position + 1) MessagesPreparator( context = context, + position = position, + adapterClickListener = itemClickListener, payloads = payloads, root = binding.root, @@ -212,15 +184,17 @@ class MessagesHistoryAdapter constructor( } inner class OutgoingMessage( - private val binding: ItemMessageOutBinding + private val binding: ItemMessageOutBinding, ) : BasicHolder(binding.root) { override fun bind(position: Int, payloads: MutableList?) { - val message = getItem(position) as VkMessage - val prevMessage = getVkMessage(getOrNull(position - 1)) + val message = getItem(position) + val prevMessage = getOrNull(position - 1) MessagesPreparator( context = context, + position = position, + adapterClickListener = itemClickListener, payloads = payloads, root = binding.root, conversation = conversation, @@ -264,7 +238,7 @@ class MessagesHistoryAdapter constructor( } inner class ServiceMessage( - private val binding: ItemMessageServiceBinding + private val binding: ItemMessageServiceBinding, ) : BasicHolder(binding.root) { private val youPrefix = context.getString(R.string.you_message_prefix) @@ -291,10 +265,11 @@ class MessagesHistoryAdapter constructor( context = context, message = message, youPrefix = youPrefix, - profiles = profiles, - groups = groups, messageUser = messageUser, - messageGroup = messageGroup + messageGroup = messageGroup, + action = message.getPreparedAction(), + actionUser = null, + actionGroup = null, ) val attachments = message.attachments ?: return @@ -310,11 +285,11 @@ class MessagesHistoryAdapter constructor( size.height ) - binding.photo.loadWithGlide( - url = size.url, - crossFade = true, + binding.photo.loadWithGlide { + imageUrl = size.url + crossFade = true placeholderDrawable = ColorDrawable(Color.LTGRAY) - ) + } binding.photo.setOnClickListener { Intent(Intent.ACTION_VIEW, Uri.parse(size.url)).run { @@ -360,13 +335,6 @@ class MessagesHistoryAdapter constructor( return false } - fun getVkMessage(item: DataItem<*>?): VkMessage? { - if (item == null) return null - if (item is VkMessage) return item - - return null - } - fun searchMessageIndex(messageId: Int): Int? { for (i in indices) { val message = getItem(i) @@ -387,32 +355,23 @@ class MessagesHistoryAdapter constructor( companion object { private const val TypeService = 1 - private const val TypeHeader = 0 - private const val TypeFooter = 2 private const val TypeIncoming = 3 private const val TypeOutgoing = 4 - private val Comparator = object : DiffUtil.ItemCallback>() { + private val Comparator = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: DataItem, - newItem: DataItem + oldItem: VkMessage, + newItem: VkMessage, ): Boolean { - return if (oldItem is VkMessage && newItem is VkMessage) { - oldItem.id == newItem.id - } else { - oldItem is DataItem.Footer && newItem is DataItem.Footer || - oldItem is DataItem.Header && newItem is DataItem.Header || - ObjectsCompat.equals(oldItem, newItem) - } + return oldItem.id == newItem.id } override fun areContentsTheSame( - oldItem: DataItem, - newItem: DataItem + oldItem: VkMessage, + newItem: VkMessage, ): Boolean { - - return ObjectsCompat.equals(oldItem, newItem) && ((oldItem is VkMessage && newItem is VkMessage) && oldItem.state == newItem.state) + return ObjectsCompat.equals(oldItem, newItem) && (oldItem.state == newItem.state) } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt index e72cffe1..f02915f6 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryFragment.kt @@ -1,35 +1,43 @@ package com.meloda.fast.screens.messages +import android.animation.ValueAnimator +import android.content.Context import android.net.Uri import android.os.Bundle import android.os.Environment import android.provider.OpenableColumns import android.util.Log +import android.view.HapticFeedbackConstants import android.view.View -import android.viewbinding.library.fragment.viewBinding -import android.widget.ImageView +import android.view.animation.LinearInterpolator import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu +import androidx.core.animation.doOnEnd +import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.isVisible +import androidx.core.view.updatePaddingRelative import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.viewModels -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import by.kirich1409.viewbindingdelegate.viewBinding +import com.bumptech.glide.Glide +import com.github.terrakok.cicerone.Router import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.common.net.MediaType import com.meloda.fast.R import com.meloda.fast.api.UserConfig import com.meloda.fast.api.VkUtils -import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.VkAttachment +import com.meloda.fast.api.model.attachments.VkPhoto +import com.meloda.fast.api.model.domain.VkConversationDomain import com.meloda.fast.base.viewmodel.BaseViewModelFragment import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.common.AppGlobal @@ -37,55 +45,57 @@ import com.meloda.fast.common.Screens import com.meloda.fast.data.files.FilesRepository import com.meloda.fast.databinding.DialogMessageDeleteBinding import com.meloda.fast.databinding.FragmentMessagesHistoryBinding -import com.meloda.fast.extensions.* -import com.meloda.fast.extensions.ImageLoader.loadWithGlide -import com.meloda.fast.screens.conversations.MessagesNewEvent -import com.meloda.fast.screens.settings.SettingsPrefsFragment +import com.meloda.fast.ext.ImageLoader.loadWithGlide +import com.meloda.fast.ext.clear +import com.meloda.fast.ext.doOnApplyWindowInsets +import com.meloda.fast.ext.dpToPx +import com.meloda.fast.ext.getParcelableCompat +import com.meloda.fast.ext.gone +import com.meloda.fast.ext.hideKeyboard +import com.meloda.fast.ext.listenValue +import com.meloda.fast.ext.mimeType +import com.meloda.fast.ext.orDots +import com.meloda.fast.ext.sdk30AndUp +import com.meloda.fast.ext.selectLast +import com.meloda.fast.ext.showKeyboard +import com.meloda.fast.ext.trimmedText +import com.meloda.fast.ext.visible +import com.meloda.fast.model.base.parseString +import com.meloda.fast.screens.settings.SettingsFragment import com.meloda.fast.util.AndroidUtils +import com.meloda.fast.util.ColorUtils +import com.meloda.fast.util.ShareContent import com.meloda.fast.util.TimeUtils import com.meloda.fast.view.SpaceItemDecoration -import dagger.hilt.android.AndroidEntryPoint +import dev.chrisbanes.insetter.applyInsetter import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel import java.io.File import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale +import java.util.Timer import kotlin.concurrent.schedule import kotlin.math.abs +import kotlin.properties.Delegates import kotlin.random.Random -@AndroidEntryPoint class MessagesHistoryFragment : BaseViewModelFragment(R.layout.fragment_messages_history) { - companion object { - const val ARG_USER: String = "user" - const val ARG_GROUP: String = "group" - const val ARG_CONVERSATION: String = "conversation" + private val router: Router by inject() - private const val ATTACHMENT_PANEL_ANIMATION_DURATION = 150L - - fun newInstance( - conversation: VkConversation, - user: VkUser?, - group: VkGroup? - ): MessagesHistoryFragment { - val fragment = MessagesHistoryFragment() - fragment.arguments = bundleOf( - ARG_CONVERSATION to conversation, - ARG_USER to user, - ARG_GROUP to group - ) - - return fragment - } - } - - override val viewModel: MessagesHistoryViewModel by viewModels() - private val binding: FragmentMessagesHistoryBinding by viewBinding() + private val binding by viewBinding(FragmentMessagesHistoryBinding::bind) + override val viewModel: MessagesHistoryViewModel by viewModel() private var pickFile: Boolean = false @@ -119,23 +129,21 @@ class MessagesHistoryFragment : } - private val actionState = MutableLiveData() + private val actionState = MutableStateFlow(Action.RECORD) private enum class Action { RECORD, SEND, EDIT, DELETE } private val user: VkUser? by lazy { - requireArguments().getParcelable(ARG_USER) + requireArguments().getParcelableCompat(ARG_USER, VkUser::class.java) } private val group: VkGroup? by lazy { - requireArguments().getParcelable(ARG_GROUP) + requireArguments().getParcelableCompat(ARG_GROUP, VkGroup::class.java) } - private val conversation: VkConversation by lazy { - requireNotNull(requireArguments().getParcelable(ARG_CONVERSATION)) - } + private var conversation: VkConversationDomain by Delegates.notNull() private val adapter: MessagesHistoryAdapter by lazy { MessagesHistoryAdapter(this, conversation).also { @@ -158,25 +166,64 @@ class MessagesHistoryFragment : private lateinit var attachmentController: AttachmentPanelController - init { - shouldNavBarShown = false + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + conversation = requireNotNull( + requireArguments().getParcelableCompat( + ARG_CONVERSATION, + VkConversationDomain::class.java + ) + ) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } + val colorBackground = ContextCompat.getColor(requireContext(), R.color.colorBackground) + val alphaColorBackground = ColorUtils.alphaColor(colorBackground, 0.85F) + binding.bottomMessagePanel.setBackgroundColor(alphaColorBackground) - attachmentController = AttachmentPanelController().init() + binding.toolbar.startButtonClickAction = { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + attachmentController = AttachmentPanelController.init( + context = requireContext(), + adapter = adapter, + lifecycleOwner = viewLifecycleOwner, + binding = binding, + isAttachmentsEmpty = { attachmentsToLoad.isEmpty() } + ) val title = when { - conversation.isChat() -> conversation.title + conversation.isChat() -> conversation.conversationTitle conversation.isUser() -> user?.toString() conversation.isGroup() -> group?.name else -> null } +// listOf( +// binding.bottomAlpha, +// binding.bottomGradient +// ).forEach { v -> +// v.applyInsetter { +// type(navigationBars = true) { padding() } +// } +// } + binding.bottomMessagePanel.applyInsetter { + type(navigationBars = true, ime = true) { padding(animated = true) } + } +// binding.recyclerView.applyInsetter { +// type(navigationBars = true, ime = true) { padding(animated = true) } +// } + binding.toolbar.applyInsetter { + type(statusBars = true) { padding() } + } binding.toolbar.title = title.orDots() + binding.toolbar.setOnClickListener { + openChatInfoScreen(conversation, user, group) + } val status = when { conversation.isChat() -> "${conversation.membersCount} members" @@ -189,8 +236,10 @@ class MessagesHistoryFragment : Locale.getDefault() ).format(user?.lastSeen!! * 1000L) }" + else -> if (user?.lastSeenStatus != null) "Last seen ${user?.lastSeenStatus!!}" else "Last seen recently" } + conversation.isGroup() -> if (group?.membersCount != null) "${group?.membersCount} members" else "Group" else -> null } @@ -205,21 +254,15 @@ class MessagesHistoryFragment : viewModel.loadHistory(conversation.id) - binding.action.setOnClickListener { performAction() } - - binding.recyclerView.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom -> - if (bottom >= oldBottom) return@addOnLayoutChangeListener - val lastVisiblePosition = - (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() - - if (lastVisiblePosition <= adapter.lastPosition - 10) return@addOnLayoutChangeListener - - binding.recyclerView.postDelayed({ - if (getView() == null) return@postDelayed - binding.recyclerView.scrollToPosition(adapter.lastPosition) - }, 25) + binding.action.setOnClickListener { + performAction() } +// binding.recyclerView.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom -> +// if (bottom >= oldBottom) return@addOnLayoutChangeListener +// checkIfNeedToScrollToBottom() +// } + binding.unreadCounter.setOnClickListener { binding.recyclerView.scrollToPosition(adapter.lastPosition) } @@ -233,7 +276,7 @@ class MessagesHistoryFragment : val lastPosition = layoutManager.findLastCompletelyVisibleItemPosition() if (AppGlobal.preferences.getBoolean( - SettingsPrefsFragment.PrefHideKeyboardOnScroll, + SettingsFragment.KEY_FEATURES_HIDE_KEYBOARD_ON_SCROLL, true ) && dy < 0 ) { @@ -243,20 +286,21 @@ class MessagesHistoryFragment : setUnreadCounterVisibility(lastPosition, dy) adapter.getOrNull(firstPosition)?.let { - if (it !is VkMessage) return binding.timestamp.visible() + val showExactTime = AppGlobal.preferences.getBoolean(SettingsFragment.KEY_SHOW_EXACT_TIME_ON_TIME_STAMP, false) + + val exactTime = SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).format(it.date * 1000L) + val time = "${ TimeUtils.getLocalizedDate( requireContext(), it.date * 1000L ) - }, ${ - SimpleDateFormat( - "HH:mm", - Locale.getDefault() - ).format(it.date * 1000L) - }" + }${if (showExactTime) ", $exactTime" else ""}" binding.timestamp.text = time @@ -267,7 +311,10 @@ class MessagesHistoryFragment : timestampTimer = Timer() timestampTimer?.schedule(2500) { - recyclerView.post { binding.timestamp.gone() } + recyclerView.post { + if (getView() == null) return@post + binding.timestamp.gone() + } } } @@ -275,62 +322,66 @@ class MessagesHistoryFragment : } }) - binding.message.doAfterTextChanged { - val canSend = it.toString().isNotBlank() + binding.message.doAfterTextChanged { text -> + val canSend = text.toString().isNotBlank() || attachmentsToLoad.isNotEmpty() val newValue: Action = when { - attachmentController.isEditing -> if (it.isNullOrBlank()) Action.DELETE else Action.EDIT + attachmentController.isEditing -> + if (text.isNullOrBlank() && attachmentsToLoad.isEmpty()) { + Action.DELETE + } else { + Action.EDIT + } + canSend -> Action.SEND else -> { - if (attachmentsToLoad.isNotEmpty()) { - if (attachmentController.isEditing) { - Action.EDIT - } else { - Action.SEND - } - } else { - Action.RECORD - } + Action.RECORD } } - actionState.setIfNotEquals(newValue) + actionState.update { newValue } } - actionState.observe(viewLifecycleOwner) { - binding.action.animate() - .scaleX(1.25f) - .scaleY(1.25f) - .setDuration(100) - .withEndAction { - if (getView() == null) return@withEndAction + actionState + .asStateFlow() + .flowWithLifecycle(lifecycle) + .onEach { state -> + binding.action.animate() + .scaleX(1.25f) + .scaleY(1.25f) + .setDuration(100) + .withEndAction { + if (getView() == null) return@withEndAction - binding.action.animate() - .scaleX(1f) - .scaleY(1f) - .setDuration(100) - .start() - }.start() + binding.action.animate() + .scaleX(1f) + .scaleY(1f) + .setDuration(100) + .start() + }.start() - when (it) { - Action.RECORD -> { - binding.action.setImageResource(R.drawable.ic_round_mic_24) + when (state) { + Action.RECORD -> { + binding.action.setImageResource(R.drawable.ic_round_mic_24) + } + + Action.SEND -> { + binding.action.setImageResource(R.drawable.ic_round_send_24) + } + + Action.EDIT -> { + binding.action.setImageResource(R.drawable.ic_round_done_24) + } + + Action.DELETE -> { + binding.action.setImageResource(R.drawable.ic_trash_can_outline_24) + } } - Action.SEND -> { - binding.action.setImageResource(R.drawable.ic_round_send_24) - } - Action.EDIT -> { - binding.action.setImageResource(R.drawable.ic_round_done_24) - } - Action.DELETE -> { - binding.action.setImageResource(R.drawable.ic_trash_can_outline_24) - } - else -> return@observe } - } + .launchIn(lifecycleScope) - attachmentController.isPanelVisible.observe(viewLifecycleOwner) { isVisible -> + attachmentController.isPanelVisible.listenValue { isVisible -> if (isVisible) binding.message.setSelection(binding.message.text.toString().length) // val currentHeight = binding.listAnchor.height @@ -363,7 +414,7 @@ class MessagesHistoryFragment : binding.dismissReply.setOnClickListener { if (attachmentController.message.value != null) - attachmentController.message.value = null + attachmentController.message.update { null } } binding.attach.setOnClickListener { @@ -391,14 +442,18 @@ class MessagesHistoryFragment : } } - override fun toggleProgress(isProgressing: Boolean) { - view?.run { - findViewById(R.id.progress_bar).toggleVisibility( - if (isProgressing) adapter.isEmpty() else false - ) - findViewById(R.id.refresh_layout).isRefreshing = - if (isProgressing) adapter.isNotEmpty() else false - } + private fun checkIfNeedToScrollToBottom() { + if (adapter.isEmpty()) return + + val lastVisiblePosition = + (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() + + if (lastVisiblePosition <= adapter.lastPosition - 10) return + + binding.recyclerView.postDelayed({ + if (view == null) return@postDelayed + binding.recyclerView.smoothScrollToPosition(adapter.lastPosition) + }, 0) } private suspend fun processFileFromStorage(uri: Uri) { @@ -448,7 +503,6 @@ class MessagesHistoryFragment : if (file.exists()) file.delete() withContext(Dispatchers.IO) { - @Suppress("BlockingMethodInNonBlockingContext") val inputStream = requireActivity().contentResolver.openInputStream(uri) ?: return@withContext @@ -475,10 +529,12 @@ class MessagesHistoryFragment : val uploadedAttachment = viewModel.uploadPhoto(conversation.id, file, name) addAttachment(uploadedAttachment) } + MediaType.ANY_VIDEO_TYPE.type() -> { val uploadedAttachment = viewModel.uploadVideo(file, name) addAttachment(uploadedAttachment) } + MediaType.ANY_AUDIO_TYPE.type() -> { val uploadedAttachment = viewModel.uploadAudio(file, name) addAttachment(uploadedAttachment) @@ -504,22 +560,27 @@ class MessagesHistoryFragment : clearAttachments() true } + "Photo" -> { pickPhoto() true } + "Video" -> { pickVideo() true } + "Audio" -> { pickAudio() true } + "File" -> { pickFile() true } + else -> false } } @@ -536,10 +597,9 @@ class MessagesHistoryFragment : attachmentController.showPanel() - actionState.setIfNotEquals( + actionState.value = if (attachmentController.isEditing) Action.EDIT else Action.SEND - ) } private fun removeAttachment(attachment: VkAttachment) { @@ -596,20 +656,27 @@ class MessagesHistoryFragment : val avatar = when { conversation.isUser() -> user?.photo200 conversation.isGroup() -> group?.photo200 - conversation.isChat() -> conversation.photo200 + conversation.isChat() -> conversation.conversationPhoto else -> null } - val avatarMenuItem = binding.toolbar.addAvatarMenuItem() - val avatarImageView: ImageView? = avatarMenuItem.actionView?.findViewById(R.id.avatar) - - avatarImageView?.loadWithGlide(url = avatar, asCircle = true, crossFade = true) + val avatarImageView = binding.toolbar.avatarImageView + avatarImageView.visible() + avatarImageView.loadWithGlide { + imageUrl = avatar + asCircle = true + crossFade = true + } } private fun performAction() { when (actionState.value) { Action.RECORD -> { + sdk30AndUp { + binding.action.performHapticFeedback(HapticFeedbackConstants.REJECT) + } } + Action.SEND -> { val messageText = binding.message.trimmedText if (messageText.isBlank() && attachmentsToLoad.isEmpty()) { @@ -643,13 +710,17 @@ class MessagesHistoryFragment : Log.d("LongPollUpdatesParser", "newMessageRandomId: ${message.randomId}") - adapter.add(message, beforeFooter = true, commitCallback = { + adapter.add(message, commitCallback = { binding.recyclerView.scrollToPosition(adapter.lastPosition) binding.message.clear() }) val replyMessage = attachmentController.message.value - attachmentController.message.value = null + attachmentController.message.update { null } + + sdk30AndUp { + binding.action.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + } viewModel.sendMessage( peerId = conversation.id, @@ -657,7 +728,7 @@ class MessagesHistoryFragment : randomId = message.randomId, replyTo = replyMessage?.id, setId = { messageId -> - val messageToUpdate = adapter[messageIndex] as VkMessage + val messageToUpdate = adapter[messageIndex] messageToUpdate.id = messageId messageToUpdate.state = VkMessage.State.Sent adapter.notifyItemChanged(messageIndex, "kek") @@ -665,7 +736,7 @@ class MessagesHistoryFragment : attachmentsAdapter.clear() }, onError = { - val messageToUpdate = adapter[messageIndex] as VkMessage + val messageToUpdate = adapter[messageIndex] messageToUpdate.state = VkMessage.State.Error adapter.notifyItemChanged(messageIndex, "kek") // adapter[messageIndex] = messageToUpdate @@ -674,11 +745,12 @@ class MessagesHistoryFragment : attachments = attachments ) } + Action.EDIT -> { val message = attachmentController.message.value ?: return val messageText = binding.message.text.toString().trim() - attachmentController.message.value = null + attachmentController.message.update { null } viewModel.editMessage( originalMessage = message, @@ -688,50 +760,87 @@ class MessagesHistoryFragment : attachments = message.attachments ) } + Action.DELETE -> attachmentController.message.value?.let { showDeleteMessageDialog(it) } - else -> {} } } private fun prepareViews() { prepareRecyclerView() - prepareRefreshLayout() prepareEmojiButton() prepareAttachmentsList() } private fun prepareRecyclerView() { binding.recyclerView.itemAnimator = null - } - private fun prepareRefreshLayout() { - with(binding.refreshLayout) { - setProgressViewOffset( - true, progressViewStartOffset, progressViewEndOffset - ) - setProgressBackgroundColorSchemeColor( - AndroidUtils.getThemeAttrColor( - requireContext(), - R.attr.colorSurface - ) - ) - setColorSchemeColors( - AndroidUtils.getThemeAttrColor( - requireContext(), - R.attr.colorPrimary - ) - ) - setOnRefreshListener { viewModel.loadHistory(peerId = conversation.id) } + binding.toolbar.measure( + View.MeasureSpec.AT_MOST, + View.MeasureSpec.UNSPECIFIED + ) + + binding.bottomMessagePanel.measure( + View.MeasureSpec.AT_MOST, + View.MeasureSpec.UNSPECIFIED + ) + +// binding.recyclerView.updatePaddingRelative( +// top = binding.toolbar.measuredHeight, +// bottom = binding.bottomMessagePanel.measuredHeight +// ) + + val toolbarMeasuredHeight = binding.toolbar.measuredHeight + val bottomMessagePanelMeasuredHeight = binding.bottomMessagePanel.measuredHeight + + binding.recyclerView.doOnApplyWindowInsets { v, insets, _, _ -> + val statusBars = AndroidUtils.getStatusBarInsets(insets) + val ime = AndroidUtils.getImeInsets(insets) + val navBars = AndroidUtils.getNavBarInsets(insets) + + val topPadding = toolbarMeasuredHeight + statusBars.top + + val bottomPadding = bottomMessagePanelMeasuredHeight + + ime.bottom + (if (ime.bottom == 0) navBars.bottom else 0) + + val currentPadding = v.paddingBottom + + v.updatePaddingRelative(top = topPadding) + ValueAnimator.ofInt(currentPadding, bottomPadding).apply { + interpolator = LinearInterpolator() + duration = if (currentPadding > bottomPadding) 125 else 50 + + addUpdateListener { + if (view == null) return@addUpdateListener + val value = it.animatedValue as Int + v.updatePaddingRelative(bottom = value) + } + + doOnEnd { + if (view == null) return@doOnEnd + checkIfNeedToScrollToBottom() + } + }.start() + +// v.updatePaddingRelative(top = topPadding, bottom = bottomPadding) + + insets } } private fun prepareEmojiButton() { + binding.emoji.setOnClickListener { + sdk30AndUp { + binding.emoji.performHapticFeedback(HapticFeedbackConstants.REJECT) + } + } binding.emoji.setOnLongClickListener { - val text = binding.message.text.toString() + AppGlobal.preferences.getString( - SettingsPrefsFragment.PrefFastText, SettingsPrefsFragment.PrefFastTextDefaultValue - ) + val text = binding.message.text.toString() + + AppGlobal.preferences.getString( + SettingsFragment.KEY_FEATURES_FAST_TEXT, + SettingsFragment.DEFAULT_VALUE_FEATURES_FAST_TEXT + ) binding.message.setText(text) binding.message.selectLast() @@ -748,6 +857,8 @@ class MessagesHistoryFragment : .setDuration(100) .start() }.start() + + binding.emoji.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) true } } @@ -763,8 +874,7 @@ class MessagesHistoryFragment : val newList = adapter.cloneCurrentList() for (i in newList.indices) { - val item = newList[i] - val message: VkMessage = (if (item !is VkMessage) null else item) ?: continue + val message = newList[i] if (event.messagesIds.contains(message.id)) { newList[i] = message.copy(important = event.important) } @@ -785,8 +895,6 @@ class MessagesHistoryFragment : adapter.setItems( values.sortedBy { it.date }, - withHeader = true, - withFooter = true, commitCallback = { if (view == null) return@setItems if (smoothScroll) binding.recyclerView.smoothScrollToPosition(adapter.lastPosition) @@ -800,7 +908,7 @@ class MessagesHistoryFragment : } private fun onAvatarLongClickListener(position: Int) { - val message = adapter[position] as VkMessage + val message = adapter[position] val messageUser = VkUtils.getMessageUser(message, adapter.profiles) val messageGroup = VkUtils.getMessageGroup(message, adapter.groups) @@ -810,7 +918,7 @@ class MessagesHistoryFragment : } private fun showOptionsDialog(position: Int) { - val message = adapter[position] as VkMessage + val message = adapter[position] if (message.action != null) return val time = getString( @@ -839,6 +947,10 @@ class MessagesHistoryFragment : val edit = getString(R.string.message_context_action_edit) + val copy = "Copy" + + val share = "Share" + val delete = getString(R.string.message_context_action_delete) val params = mutableListOf() @@ -865,6 +977,18 @@ class MessagesHistoryFragment : onlySentParams += edit } + val notNullText = message.text.orEmpty() + val messageTextIsNotNull = !message.text.isNullOrBlank() + + val notNullAttachments = message.attachments.orEmpty() + val attachmentsIsOnePhoto = notNullAttachments.size == 1 && + notNullAttachments.first() is VkPhoto + + if (messageTextIsNotNull || attachmentsIsOnePhoto) { + params += copy + params += share + } + params += delete if (!message.isSent()) { @@ -879,27 +1003,122 @@ class MessagesHistoryFragment : when (params[which]) { reply -> { if (attachmentController.message.value != message) - attachmentController.message.value = message + attachmentController.message.update { message } } + pin -> showPinMessageDialog( peerId = conversation.id, messageId = message.id, pin = !isMessageAlreadyPinned ) + important -> viewModel.markAsImportant( messagesIds = listOf(message.id), important = !message.important ) + read -> viewModel.readMessage( conversation.id, message.id ) + edit -> { attachmentController.isEditing = true if (attachmentController.message.value != message) - attachmentController.message.value = message + attachmentController.message.update { message } } + + copy -> { + lifecycleScope.launch(Dispatchers.IO) { + when { + messageTextIsNotNull && !attachmentsIsOnePhoto -> { + withContext(Dispatchers.Main) { + AndroidUtils.copyText( + text = notNullText, + withToast = true + ) + } + } + + else -> { + val imageUrl = + ((notNullAttachments.first() as? VkPhoto)?.getMaxSize() + ?: return@launch).url + + val preloadedImageFileUri = Glide + .with(requireContext()) + .downloadOnly() + .load(imageUrl) + .submit() + .get().let { file -> + val newFile = + AndroidUtils.getImageToShare(requireContext(), file) + + newFile!! + } + + withContext(Dispatchers.Main) { + if (messageTextIsNotNull) { + AndroidUtils.copyText(text = notNullText) + AndroidUtils.copyImage( + label = "Image", + imageUri = preloadedImageFileUri, + withToast = true + ) + } else { + AndroidUtils.copyImage( + label = "Image", + imageUri = preloadedImageFileUri, + withToast = true + ) + } + } + } + } + } + } + + share -> { + lifecycleScope.launch(Dispatchers.IO) { + val content = when { + messageTextIsNotNull && !attachmentsIsOnePhoto -> { + ShareContent.Text(notNullText) + } + + else -> { + val imageUrl = + ((notNullAttachments.first() as? VkPhoto)?.getMaxSize() + ?: return@launch).url + + val preloadedImageFileUri = Glide + .with(requireContext()) + .downloadOnly() + .load(imageUrl) + .submit() + .get().let { file -> + val newFile = + AndroidUtils.getImageToShare(requireContext(), file) + + newFile!! + } + + if (messageTextIsNotNull) { + ShareContent.TextWithImage( + notNullText, preloadedImageFileUri + ) + } else { + ShareContent.Image(preloadedImageFileUri) + } + } + } + + withContext(Dispatchers.Main) { + AndroidUtils.showShareSheet(requireActivity(), content) + } + } + } + delete -> showDeleteMessageDialog(message) } }.show() @@ -908,7 +1127,7 @@ class MessagesHistoryFragment : private fun showPinMessageDialog( peerId: Int, messageId: Int?, - pin: Boolean + pin: Boolean, ) { MaterialAlertDialogBuilder(requireContext()) .setTitle( @@ -933,7 +1152,7 @@ class MessagesHistoryFragment : val binding = DialogMessageDeleteBinding.inflate(layoutInflater, null, false) binding.check.setText( - if (message.isOut) R.string.message_delete_for_all + if (message.isOut || conversation.canChangeInfo) R.string.message_delete_for_all else R.string.message_mark_as_spam ) @@ -948,7 +1167,7 @@ class MessagesHistoryFragment : .setTitle(R.string.confirm_delete_message) .setView(binding.root) .setPositiveButton(R.string.action_delete) { _, _ -> - attachmentController.message.value = null + attachmentController.message.update { null } if (message.isError()) { adapter.searchIndexOf(message)?.let { index -> @@ -990,17 +1209,15 @@ class MessagesHistoryFragment : val oldOutRead = conversation.outRead val oldInRead = conversation.inRead - if (event.isOut) { - conversation.outRead = event.messageId - } else { - conversation.inRead = event.messageId - } + conversation = conversation.copy( + outRead = if (event.isOut) event.messageId else conversation.outRead, + inRead = if (!event.isOut) event.messageId else conversation.inRead + ) val positionsToUpdate = mutableListOf() val newList = adapter.cloneCurrentList() for (i in newList.indices) { val message = newList[i] - if (message !is VkMessage) continue if ((message.isOut && conversation.outRead - oldOutRead > 0 && message.id > oldOutRead) || (!message.isOut && conversation.inRead - oldInRead > 0 && message.id > oldInRead) @@ -1021,10 +1238,9 @@ class MessagesHistoryFragment : } } - @Suppress("NAME_SHADOWING") private fun setUnreadCounterVisibility( lastCompletelyVisiblePosition: Int, - dy: Int? = null + dy: Int? = null, ) { if (lastCompletelyVisiblePosition >= adapter.lastPosition - 1) { setUnreadCounterVisibility(false) @@ -1057,7 +1273,7 @@ class MessagesHistoryFragment : val itemCount = adapter.itemCount - adapter.add(event.message, beforeFooter = true) { + adapter.add(event.message) { if (view == null) return@add val lastVisiblePosition = @@ -1085,23 +1301,49 @@ class MessagesHistoryFragment : } } - private inner class AttachmentPanelController { - val isPanelVisible = MutableLiveData(false) - val message = MutableLiveData() + private class AttachmentPanelController { + companion object { + fun init( + context: Context, + adapter: MessagesHistoryAdapter, + lifecycleOwner: LifecycleOwner, + binding: FragmentMessagesHistoryBinding, + isAttachmentsEmpty: () -> Boolean, + ): AttachmentPanelController { + val controller = AttachmentPanelController().apply { + this.context = context + this.binding = binding + this.adapter = adapter + this.isAttachmentsEmpty = isAttachmentsEmpty + this.message.listenValue( + lifecycleOwner.lifecycleScope, + this::onMessageValueChanged + ) + + this.message.update { null } + } + + + return controller + } + } + + val isPanelVisible = MutableStateFlow(false) + val message = MutableStateFlow(null) var isEditing = false - fun init(): AttachmentPanelController { - message.observe(viewLifecycleOwner) { value -> - if (value != null) { - applyMessage(value) - } else { - clearMessage() - } - } + var adapter: MessagesHistoryAdapter by Delegates.notNull() + var binding: FragmentMessagesHistoryBinding by Delegates.notNull() + var context: Context by Delegates.notNull() + var isAttachmentsEmpty: () -> Boolean by Delegates.notNull() - message.value = null - return this + fun onMessageValueChanged(value: VkMessage?) { + if (value != null) { + applyMessage(value) + } else { + clearMessage() + } } private fun applyMessage(message: VkMessage) { @@ -1115,15 +1357,13 @@ class MessagesHistoryFragment : message, messageUser, messageGroup ) - val attachmentText = if (message.text == null) VkUtils.getAttachmentText( - context = requireContext(), + val attachmentText = (if (message.text == null) VkUtils.getAttachmentText( message = message - ) else null + ) else null)?.parseString(context) - val forwardsMessage = if (message.text == null) VkUtils.getForwardsText( - context = requireContext(), + val forwardsMessage = (if (message.text == null) VkUtils.getForwardsText( message = message - ) else null + ) else null)?.parseString(context) val messageText = forwardsMessage ?: attachmentText ?: (message.text ?: "").run { VkUtils.prepareMessageText(this) } @@ -1144,7 +1384,7 @@ class MessagesHistoryFragment : } private fun clearMessage() { - if (attachmentsToLoad.isEmpty()) { + if (isAttachmentsEmpty()) { hidePanel() } @@ -1160,15 +1400,15 @@ class MessagesHistoryFragment : } fun showPanel() { - if (isPanelVisible.requireValue()) return + if (isPanelVisible.value) return binding.attachmentPanel.visible() // binding.attachmentPanel.measure( // View.MeasureSpec.AT_MOST, View.MeasureSpec.UNSPECIFIED // ) - if (!attachmentController.isPanelVisible.requireValue()) - attachmentController.isPanelVisible.value = true + if (!isPanelVisible.value) + isPanelVisible.update { true } // binding.attachmentPanel.visible() @@ -1203,13 +1443,13 @@ class MessagesHistoryFragment : } fun hidePanel() { - if (!isPanelVisible.requireValue() || - attachmentsToLoad.isNotEmpty() || + if (!isPanelVisible.value || + !isAttachmentsEmpty() || message.value != null ) return - if (attachmentController.isPanelVisible.requireValue()) - attachmentController.isPanelVisible.value = false + if (isPanelVisible.value) + isPanelVisible.update { false } binding.attachmentPanel.gone() @@ -1245,14 +1485,47 @@ class MessagesHistoryFragment : } fun openForwardsScreen( - conversation: VkConversation, + conversation: VkConversationDomain, messages: List, profiles: HashMap = hashMapOf(), - groups: HashMap = hashMapOf() + groups: HashMap = hashMapOf(), ) { - requireActivityRouter().navigateTo( + router.navigateTo( Screens.ForwardedMessages(conversation, messages, profiles, groups) ) } -} \ No newline at end of file + private fun openChatInfoScreen( + conversation: VkConversationDomain, + user: VkUser?, + group: VkGroup?, + ) { + router.navigateTo( + Screens.ChatInfo(conversation, user, group) + ) + } + + companion object { + const val ARG_USER: String = "user" + const val ARG_GROUP: String = "group" + const val ARG_CONVERSATION: String = "conversation" + + private const val ATTACHMENT_PANEL_ANIMATION_DURATION = 150L + + fun newInstance( + conversation: VkConversationDomain, + user: VkUser?, + group: VkGroup?, + ): MessagesHistoryFragment { + val fragment = MessagesHistoryFragment() + fragment.arguments = bundleOf( + ARG_CONVERSATION to conversation, + ARG_USER to user, + ARG_GROUP to group + ) + + return fragment + } + } + +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt index 1051fd44..ebc05fce 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesHistoryViewModel.kt @@ -4,45 +4,44 @@ import com.meloda.fast.api.VKConstants import com.meloda.fast.api.base.ApiError import com.meloda.fast.api.longpoll.LongPollEvent import com.meloda.fast.api.longpoll.LongPollUpdatesParser -import com.meloda.fast.api.model.VkConversation import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.api.model.attachments.VkAttachment import com.meloda.fast.api.model.attachments.VkVideo -import com.meloda.fast.api.network.ApiAnswer -import com.meloda.fast.api.network.messages.* +import com.meloda.fast.api.model.domain.VkConversationDomain +import com.meloda.fast.api.network.messages.MessagesDeleteRequest +import com.meloda.fast.api.network.messages.MessagesEditRequest +import com.meloda.fast.api.network.messages.MessagesGetHistoryRequest +import com.meloda.fast.api.network.messages.MessagesMarkAsImportantRequest +import com.meloda.fast.api.network.messages.MessagesPinMessageRequest +import com.meloda.fast.api.network.messages.MessagesSendRequest +import com.meloda.fast.api.network.messages.MessagesUnPinMessageRequest import com.meloda.fast.api.network.photos.PhotosSaveMessagePhotoRequest -import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.base.viewmodel.DeprecatedBaseViewModel import com.meloda.fast.base.viewmodel.VkEvent import com.meloda.fast.data.audios.AudiosRepository import com.meloda.fast.data.files.FilesRepository import com.meloda.fast.data.messages.MessagesRepository import com.meloda.fast.data.photos.PhotosRepository import com.meloda.fast.data.videos.VideosRepository -import com.meloda.fast.extensions.requireNotNull -import com.meloda.fast.screens.conversations.MessagesNewEvent -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay +import com.meloda.fast.ext.notNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import java.io.File -import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine - -@HiltViewModel -class MessagesHistoryViewModel @Inject constructor( +class MessagesHistoryViewModel constructor( private val messagesRepository: MessagesRepository, updatesParser: LongPollUpdatesParser, private val photosRepository: PhotosRepository, private val filesRepository: FilesRepository, private val audiosRepository: AudiosRepository, - private val videosRepository: VideosRepository -) : BaseViewModel() { + private val videosRepository: VideosRepository, +) : DeprecatedBaseViewModel() { init { updatesParser.onNewMessage { @@ -107,14 +106,14 @@ class MessagesHistoryViewModel @Inject constructor( val profiles = hashMapOf() response.profiles?.let { baseProfiles -> baseProfiles.forEach { baseProfile -> - baseProfile.asVkUser().let { profile -> profiles[profile.id] = profile } + baseProfile.mapToDomain().let { profile -> profiles[profile.id] = profile } } } val groups = hashMapOf() response.groups?.let { baseGroups -> baseGroups.forEach { baseGroup -> - baseGroup.asVkGroup().let { group -> groups[group.id] = group } + baseGroup.mapToDomain().let { group -> groups[group.id] = group } } } @@ -126,10 +125,10 @@ class MessagesHistoryViewModel @Inject constructor( messagesRepository.store(hashMessages.values.toList()) - val conversations = hashMapOf() + val conversations = hashMapOf() response.conversations?.let { baseConversations -> baseConversations.forEach { baseConversation -> - baseConversation.asVkConversation( + baseConversation.mapToDomain( hashMessages[baseConversation.last_message_id] ).let { conversation -> conversations[conversation.id] = conversation } } @@ -154,9 +153,8 @@ class MessagesHistoryViewModel @Inject constructor( replyTo: Int? = null, setId: ((messageId: Int) -> Unit)? = null, onError: ((error: Throwable) -> Unit)? = null, - attachments: List? = null + attachments: List? = null, ) = launch { - delay(2500) makeJob( { messagesRepository.send( @@ -180,7 +178,7 @@ class MessagesHistoryViewModel @Inject constructor( fun markAsImportant( messagesIds: List, - important: Boolean + important: Boolean, ) = launch { makeJob({ messagesRepository.markAsImportant( @@ -205,7 +203,7 @@ class MessagesHistoryViewModel @Inject constructor( peerId: Int, messageId: Int? = null, conversationMessageId: Int? = null, - pin: Boolean + pin: Boolean, ) = launch { if (pin) { makeJob({ @@ -237,7 +235,7 @@ class MessagesHistoryViewModel @Inject constructor( messagesIds: List? = null, conversationsMessagesIds: List? = null, isSpam: Boolean? = null, - deleteForAll: Boolean? = null + deleteForAll: Boolean? = null, ) = launch { makeJob( { @@ -266,7 +264,7 @@ class MessagesHistoryViewModel @Inject constructor( peerId: Int, messageId: Int, message: String? = null, - attachments: List? = null + attachments: List? = null, ) = launch { makeJob( { @@ -298,8 +296,8 @@ class MessagesHistoryViewModel @Inject constructor( suspend fun uploadPhoto( peerId: Int, photo: File, - name: String - ) = suspendCoroutine { + name: String, + ) = suspendCoroutine { launch { val uploadServerUrl = getPhotoMessageUploadServer(peerId) val uploadedFileInfo = uploadPhotoToServer(uploadServerUrl, photo, name) @@ -311,42 +309,41 @@ class MessagesHistoryViewModel @Inject constructor( ) it.resume(savedAttachment) - }.also { it.invokeOnCompletion { launch { onStop() } } } + } } - private suspend fun getPhotoMessageUploadServer(peerId: Int) = suspendCoroutine { - launch { - val uploadServerResponse = makeSuspendJob( - { photosRepository.getMessagesUploadServer(peerId) } - ) - if (!uploadServerResponse.isSuccessful()) { - throw requireNotNull(uploadServerResponse.error.throwable) - } else { - (uploadServerResponse as ApiAnswer.Success).run { - it.resume(requireNotNull(this.data.response?.uploadUrl)) + private suspend fun getPhotoMessageUploadServer(peerId: Int) = + suspendCoroutine { continuation -> + launch { + sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { photosRepository.getMessagesUploadServer(peerId) } + ).response?.let { response -> + continuation.resume(response.uploadUrl) } } } - } private suspend fun uploadPhotoToServer( uploadUrl: String, photo: File, - name: String - ) = suspendCoroutine> { + name: String, + ) = suspendCoroutine { continuation -> launch { val requestBody = photo.asRequestBody("image/*".toMediaType()) val body = MultipartBody.Part.createFormData("photo", name, requestBody) - val uploadFileResponse = makeSuspendJob( - { photosRepository.uploadPhoto(uploadUrl, body) } - ) - if (!uploadFileResponse.isSuccessful()) { - throw uploadFileResponse.error.throwable!! - } else { - (uploadFileResponse as ApiAnswer.Success).data.run { - it.resume(Triple(this.server, this.photo, this.hash)) - } + sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { photosRepository.uploadPhoto(uploadUrl, body) } + ).let { response -> + continuation.resume(Triple(response.server, response.photo, response.hash)) } } } @@ -354,34 +351,27 @@ class MessagesHistoryViewModel @Inject constructor( private suspend fun saveMessagePhoto( server: Int, photo: String, - hash: String - ) = suspendCoroutine { + hash: String, + ) = suspendCoroutine { continuation -> launch { - val saveResponse = makeSuspendJob( - { + sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { photosRepository.saveMessagePhoto( - PhotosSaveMessagePhotoRequest( - photo, - server, - hash - ) + PhotosSaveMessagePhotoRequest(photo, server, hash) ) } - ) - if (!saveResponse.isSuccessful()) { - throw saveResponse.error.throwable!! - } else { - (saveResponse as ApiAnswer.Success).data.response?.run { - it.resume(requireNotNull(first().asVkPhoto())) - } - } + ).response?.first()?.asVkPhoto()?.let(continuation::resume) } } suspend fun uploadVideo( file: File, - name: String - ) = suspendCoroutine { + name: String, + ) = suspendCoroutine { launch { val uploadInfo = getVideoMessageUploadServer() @@ -395,18 +385,15 @@ class MessagesHistoryViewModel @Inject constructor( } } - private suspend fun getVideoMessageUploadServer() = suspendCoroutine> { + private suspend fun getVideoMessageUploadServer() = suspendCoroutine { continuation -> launch { - val saveResponse = makeSuspendJob( - { videosRepository.save() } - ) - - if (!saveResponse.isSuccessful()) { - it.resumeWithException(saveResponse.error.throwable!!) - return@launch - } else { - val response = (saveResponse as ApiAnswer.Success).data.response ?: return@launch - + sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { videosRepository.save() } + ).response?.let { response -> val uploadUrl = response.uploadUrl val video = VkVideo( id = response.videoId, @@ -417,7 +404,7 @@ class MessagesHistoryViewModel @Inject constructor( title = response.title ) - it.resume(uploadUrl to video) + continuation.resume(uploadUrl to video) } } } @@ -425,23 +412,21 @@ class MessagesHistoryViewModel @Inject constructor( private suspend fun uploadVideoToServer( uploadUrl: String, file: File, - name: String + name: String, ) = launch { val requestBody = file.asRequestBody() val body = MultipartBody.Part.createFormData("video_file", name, requestBody) - val response = makeSuspendJob( - { videosRepository.upload(uploadUrl, body) } + sendRequest( + onError = { exception -> throw exception }, + request = { videosRepository.upload(uploadUrl, body) } ) - if (!response.isSuccessful()) { - throw response.error.throwable!! - } } suspend fun uploadAudio( file: File, - name: String - ) = suspendCoroutine { + name: String, + ) = suspendCoroutine { launch { val uploadUrl = getAudioUploadServer() val uploadInfo = uploadAudioToServer(uploadUrl, file, name) @@ -453,43 +438,39 @@ class MessagesHistoryViewModel @Inject constructor( } } - private suspend fun getAudioUploadServer() = suspendCoroutine { + private suspend fun getAudioUploadServer() = suspendCoroutine { continuation -> launch { - val uploadResponse = makeSuspendJob( - { audiosRepository.getUploadServer() } - ) - if (!uploadResponse.isSuccessful()) { - throw uploadResponse.error.throwable!! - } else { - (uploadResponse as ApiAnswer.Success).data.response.run { - it.resume(requireNotNull(this).uploadUrl) - } - } + sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { audiosRepository.getUploadServer() } + ).response?.uploadUrl?.let(continuation::resume) } } private suspend fun uploadAudioToServer( uploadUrl: String, file: File, - name: String - ) = suspendCoroutine> { + name: String, + ) = suspendCoroutine { continuation -> launch { val requestBody = file.asRequestBody() val body = MultipartBody.Part.createFormData("file", name, requestBody) - val uploadResponse = makeSuspendJob( - { audiosRepository.upload(uploadUrl, body) } - ) - if (!uploadResponse.isSuccessful()) { - throw uploadResponse.error.throwable!! - } else { - (uploadResponse as ApiAnswer.Success).data.run { - if (this.error != null) { - throw ApiError(error = error) - } else { - it.resume(Triple(this.server, requireNotNull(this.audio), this.hash)) - } - } + sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { audiosRepository.upload(uploadUrl, body) } + ).let { response -> + response.error?.let { error -> throw ApiError(error = error) } + + continuation.resume( + Triple(response.server, response.audio.notNull(), response.hash) + ) } } } @@ -497,19 +478,16 @@ class MessagesHistoryViewModel @Inject constructor( private suspend fun saveMessageAudio( server: Int, audio: String, - hash: String - ) = suspendCoroutine { + hash: String, + ) = suspendCoroutine { continuation -> launch { - val saveResponse = makeSuspendJob( - { audiosRepository.save(server, audio, hash) } - ) - if (!saveResponse.isSuccessful()) { - throw saveResponse.error.throwable!! - } else { - (saveResponse as ApiAnswer.Success).data.response.run { - it.resume(requireNotNull(this).asVkAudio()) - } - } + sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { audiosRepository.save(server, audio, hash) } + ).response?.asVkAudio()?.let(continuation::resume) } } @@ -517,79 +495,72 @@ class MessagesHistoryViewModel @Inject constructor( peerId: Int, file: File, name: String, - type: FilesRepository.FileType - ) = suspendCoroutine { + type: FilesRepository.FileType, + ) = suspendCoroutine { continuation -> launch { val uploadServerUrl = getFileMessageUploadServer(peerId, type) val uploadedFileInfo = uploadFileToServer(uploadServerUrl, file, name) val savedAttachmentPair = saveMessageFile(uploadedFileInfo) - it.resume(savedAttachmentPair.second) - }.also { it.invokeOnCompletion { launch { onStop() } } } + continuation.resume(savedAttachmentPair.second) + } } private suspend fun getFileMessageUploadServer( peerId: Int, - type: FilesRepository.FileType - ) = suspendCoroutine { + type: FilesRepository.FileType, + ) = suspendCoroutine { continuation -> launch { - val uploadServerResponse = makeSuspendJob( - { filesRepository.getMessagesUploadServer(peerId, type) } - ) - if (!uploadServerResponse.isSuccessful()) { - throw uploadServerResponse.error.throwable!! - } else { - (uploadServerResponse as ApiAnswer.Success).data.response.run { - it.resume(requireNotNull(this).uploadUrl) - } - } + val uploadServerResponse = sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { filesRepository.getMessagesUploadServer(peerId, type) } + ).response.notNull() + + continuation.resume(uploadServerResponse.uploadUrl) } } private suspend fun uploadFileToServer( uploadUrl: String, file: File, - name: String - ) = suspendCoroutine { + name: String, + ) = suspendCoroutine { continuation -> launch { val requestBody = file.asRequestBody() val body = MultipartBody.Part.createFormData("file", name, requestBody) - val uploadFileResponse = makeSuspendJob( - { filesRepository.uploadFile(uploadUrl, body) } - ) - if (!uploadFileResponse.isSuccessful()) { - throw uploadFileResponse.error.throwable!! - } else { - (uploadFileResponse as ApiAnswer.Success).data.run { - if (this.error != null) { - throw ApiError(error = this.error) - } else { - it.resume(this.file.requireNotNull()) - } - } + sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { filesRepository.uploadFile(uploadUrl, body) } + ).let { response -> + response.error?.let { error -> throw ApiError(error = error) } + + continuation.resume(response.file.notNull()) } } } private suspend fun saveMessageFile(file: String) = - suspendCoroutine> { + suspendCoroutine { continuation -> launch { - val saveResponse = makeSuspendJob( - { filesRepository.saveMessageFile(file) } - ) - if (!saveResponse.isSuccessful()) { - throw saveResponse.error.throwable!! - } else { - (saveResponse as ApiAnswer.Success).data.run { - val response = this.response.requireNotNull() - it.resume( - response.type to ( - response.file?.asVkFile() - ?: response.voiceMessage?.asVkVoiceMessage() - ).requireNotNull() - ) - } + sendRequestNotNull( + onError = { exception -> + continuation.resumeWithException(exception) + true + }, + request = { filesRepository.saveMessageFile(file) } + ).response?.let { response -> + val type = response.type + val attachmentFile = + response.file?.asVkFile() ?: response.voiceMessage?.asVkVoiceMessage() + + continuation.resume(type to attachmentFile.notNull()) } } } @@ -597,10 +568,10 @@ class MessagesHistoryViewModel @Inject constructor( data class MessagesLoadedEvent( val count: Int, - val conversations: HashMap, + val conversations: HashMap, val messages: List, val profiles: HashMap, - val groups: HashMap + val groups: HashMap, ) : VkEvent() data class MessagesMarkAsImportantEvent(val messagesIds: List, val important: Boolean) : @@ -617,5 +588,11 @@ data class MessagesEditEvent(val message: VkMessage) : VkEvent() data class MessagesReadEvent( val isOut: Boolean, val peerId: Int, - val messageId: Int -) : VkEvent() \ No newline at end of file + val messageId: Int, +) : VkEvent() + +data class MessagesNewEvent( + val message: VkMessage, + val profiles: HashMap, + val groups: HashMap, +) : VkEvent() diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt index 67153ea4..74bfb33e 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/MessagesPreparator.kt @@ -3,12 +3,10 @@ package com.meloda.fast.screens.messages import android.content.Context import android.content.res.ColorStateList import android.graphics.drawable.ColorDrawable +import android.text.method.LinkMovementMethod import android.util.Log import android.view.View -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.Space -import android.widget.TextView +import android.widget.* import androidx.appcompat.widget.LinearLayoutCompat import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat @@ -16,13 +14,18 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import com.meloda.fast.R import com.meloda.fast.api.VkUtils -import com.meloda.fast.api.model.VkConversation +import com.meloda.fast.api.model.domain.VkConversationDomain import com.meloda.fast.api.model.VkGroup import com.meloda.fast.api.model.VkMessage import com.meloda.fast.api.model.VkUser import com.meloda.fast.common.AppGlobal -import com.meloda.fast.extensions.* -import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.ext.clear +import com.meloda.fast.ext.dpToPx +import com.meloda.fast.ext.gone +import com.meloda.fast.ext.orDots +import com.meloda.fast.ext.toggleVisibility +import com.meloda.fast.ext.visible +import com.meloda.fast.ext.ImageLoader.loadWithGlide import java.text.SimpleDateFormat import java.util.* @@ -30,11 +33,15 @@ import java.util.* class MessagesPreparator constructor( private val context: Context, + private val position: Int, + + private val adapterClickListener: ((position: Int) -> Unit)? = null, + private val payloads: MutableList? = null, private val root: View? = null, - private val conversation: VkConversation, + private val conversation: VkConversationDomain, private val message: VkMessage, private val prevMessage: VkMessage? = null, private val nextMessage: VkMessage? = null, @@ -59,6 +66,9 @@ class MessagesPreparator constructor( private val rootHighlightedColor = ContextCompat.getColor(context, R.color.n2_100) + private val mentionColor = + ContextCompat.getColor(context, R.color.colorPrimary) + private var photoClickListener: ((url: String) -> Unit)? = null private var replyClickListener: ((replyMessage: VkMessage) -> Unit)? = null private var forwardsClickListener: ((forwards: List) -> Unit)? = null @@ -239,8 +249,10 @@ class MessagesPreparator constructor( private fun prepareText() { if (text != null) { + text.setOnClickListener { adapterClickListener?.invoke(position) } + text.movementMethod = LinkMovementMethod.getInstance() text.updateLayoutParams { - val topMargin = if (title != null && title.isVisible) 6 else 0.dpToPx() + val topMargin = (if (title != null && title.isVisible) 6 else 0).dpToPx() goneTopMargin = topMargin } @@ -261,7 +273,16 @@ class MessagesPreparator constructor( timeSpacer + (if (!message.isOut && message.isRead(conversation)) "" else messageStateSpacer) - text.text = preparedText + val visualizedText = + VkUtils.visualizeMentions( + preparedText, + mentionColor, + onMentionClick = { id -> + Toast.makeText(context, "id: $id", Toast.LENGTH_SHORT).show() + } + ) + + text.text = visualizedText } } } @@ -273,10 +294,10 @@ class MessagesPreparator constructor( if (avatar != null) { val avatarUrl = VkUtils.getMessageAvatar(message, messageUser, messageGroup) - avatar.loadWithGlide( - url = avatarUrl, + avatar.loadWithGlide { + imageUrl = avatarUrl crossFade = true - ) + } } } } diff --git a/app/src/main/kotlin/com/meloda/fast/screens/messages/di/MessagesHistoryModule.kt b/app/src/main/kotlin/com/meloda/fast/screens/messages/di/MessagesHistoryModule.kt new file mode 100644 index 00000000..17f1816a --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/messages/di/MessagesHistoryModule.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.screens.messages.di + +import com.meloda.fast.screens.messages.MessagesHistoryViewModel +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val messagesHistoryModule = module { + viewModelOf(::MessagesHistoryViewModel) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt index 3ff40d25..ca0f2051 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewFragment.kt @@ -5,14 +5,12 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView -import androidx.fragment.app.viewModels -import com.meloda.fast.base.viewmodel.BaseViewModelFragment -import dagger.hilt.android.AndroidEntryPoint +import com.meloda.fast.base.BaseFragment +import org.koin.androidx.viewmodel.ext.android.viewModel -@AndroidEntryPoint -class PhotoViewFragment : BaseViewModelFragment() { +class PhotoViewFragment : BaseFragment() { - override val viewModel: PhotoViewViewModel by viewModels() + private val viewModel: PhotoViewViewModel by viewModel() // private val photosList: MutableList = mutableListOf() @@ -44,5 +42,4 @@ class PhotoViewFragment : BaseViewModelFragment() { photoLink?.let { viewModel.loadImageFromUrl(it, requireView() as ImageView) } } - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt index 6b06b293..7eb6dc1b 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/photos/PhotoViewViewModel.kt @@ -1,18 +1,17 @@ package com.meloda.fast.screens.photos import android.widget.ImageView +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.meloda.fast.base.viewmodel.BaseViewModel -import com.meloda.fast.extensions.ImageLoader.loadWithGlide +import com.meloda.fast.ext.ImageLoader.loadWithGlide import kotlinx.coroutines.launch -class PhotoViewViewModel : BaseViewModel() { +class PhotoViewViewModel : ViewModel() { fun loadImageFromUrl( url: String, - imageView: ImageView + imageView: ImageView, ) = viewModelScope.launch { - imageView.loadWithGlide(url = url) + imageView.loadWithGlide { imageUrl = url } } - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/photos/di/PhotoViewDI.kt b/app/src/main/kotlin/com/meloda/fast/screens/photos/di/PhotoViewDI.kt new file mode 100644 index 00000000..320f75ba --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/photos/di/PhotoViewDI.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.screens.photos.di + +import com.meloda.fast.screens.photos.PhotoViewViewModel +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val photoViewModule = module { + viewModelOf(::PhotoViewViewModel) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsFragment.kt new file mode 100644 index 00000000..116e99c1 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsFragment.kt @@ -0,0 +1,338 @@ +package com.meloda.fast.screens.settings + +import android.annotation.SuppressLint +import android.app.StatusBarManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.compose.MaterialDialog +import com.meloda.fast.ext.* +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.main.MainFragment +import com.meloda.fast.screens.main.activity.LongPollUtils +import com.meloda.fast.screens.main.activity.MainActivity +import com.meloda.fast.screens.settings.items.* +import com.meloda.fast.screens.settings.model.OnSettingsChangeListener +import com.meloda.fast.screens.settings.model.OnSettingsClickListener +import com.meloda.fast.screens.settings.model.OnSettingsLongClickListener +import com.meloda.fast.screens.settings.model.SettingsItem +import com.meloda.fast.screens.testing.TestActivity +import com.meloda.fast.service.LongPollQSTileService +import com.meloda.fast.ui.AppTheme +import kotlinx.coroutines.flow.update +import org.koin.androidx.viewmodel.ext.android.viewModel + +class SettingsFragment : BaseFragment() { + + private val viewModel: SettingsViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listenViewModel() + + (view as? ComposeView)?.setContent { SettingsScreen() } + } + + private fun listenViewModel() { + viewModel.isLongPollBackgroundEnabled.listenValue(::handleLongPollEnabled) + viewModel.isNeedToOpenTestingActivity.listenValue(::handleOpenTestingActivity) + viewModel.isNeedToShowPerformCrashAlert.listenValue(::handlePerformCrashAlert) + viewModel.isNeedToShowAddQuickSettingsTileAlert.listenValue(::handleShowAddQuickSettingsTileAlert) + } + + private fun handleLongPollEnabled(newValue: Boolean?) { + if (newValue == null) return + + // TODO: 08.04.2023, Danil Nikolaev: rewrite this + LongPollUtils.requestNotificationsPermission( + fragmentActivity = requireActivity(), + onStateChangedAction = { newState -> MainActivity.longPollState.update { newState } }, + fromSettings = true + ) + } + + private fun handleOpenTestingActivity(newValue: Boolean) { + if (newValue) { + viewModel.onTestingActivityOpened() + context?.startActivity(Intent(context, TestActivity::class.java)) + } + } + + private fun handlePerformCrashAlert(newValue: Boolean) { + if (newValue) { + context?.showDialog( + title = UiText.Simple("Perform Crash"), + message = UiText.Simple("App will be crashed. Are you sure?"), + positiveText = UiText.Resource(R.string.yes), + positiveAction = viewModel::onPerformCrashPositiveButtonClicked, + negativeText = UiText.Resource(R.string.cancel), + onDismissAction = viewModel::onPerformCrashAlertDismissed + ) + } + } + + @SuppressLint("WrongConstant") + private fun handleShowAddQuickSettingsTileAlert(newValue: Boolean) { + if (newValue) { + viewModel.onAddQuickSettingsTileAlertShown() + + if (Build.VERSION.SDK_INT >= 33) { + val statusBarManager = + requireContext().getSystemService(Context.STATUS_BAR_SERVICE) as StatusBarManager + statusBarManager.requestAddTileService( + ComponentName( + requireActivity(), LongPollQSTileService::class.java + ), + "Open Settings", + android.graphics.drawable.Icon.createWithResource( + requireActivity(), + R.drawable.ic_round_settings_24 + ), + {}, + {} + ) + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun SettingsScreen() { + val view = LocalView.current + + val useDynamicColors by viewModel.useDynamicColors.collectAsStateWithLifecycle() + val useLargeTopAppBar by viewModel.useLargeTopAppBar.collectAsStateWithLifecycle() + val isMultilineEnabled by viewModel.isMultilineEnabled.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + + val isNeedToShowLogOutDialog by viewModel.isNeedToShowLogOutAlert.collectAsStateWithLifecycle() + + val useHaptics by viewModel.isNeedToUseHaptics.collectAsStateWithLifecycle() + val hapticType = useHaptics.getHaptic() + view.performHapticFeedback(hapticType) + + HandleDialogs( + isNeedToShowLogOutDialog = isNeedToShowLogOutDialog + ) + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState() + ) + val scaffoldModifier = if (useLargeTopAppBar) { + Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + } else { + Modifier.fillMaxSize() + } + + val clickListener = OnSettingsClickListener(viewModel::onSettingsItemClicked) + val longClickListener = OnSettingsLongClickListener(viewModel::onSettingsItemLongClicked) + val changeListener = OnSettingsChangeListener(viewModel::onSettingsItemChanged) + + // TODO: 17.04.2023, Danil Nikolaev: make it work + val systemUiController = rememberSystemUiController() + DisposableEffect(systemUiController) { + systemUiController.systemBarsDarkContentEnabled = !isSystemUsingDarkMode() + onDispose {} + } + + AppTheme(useDynamicColors = useDynamicColors) { + Scaffold( + modifier = scaffoldModifier, + topBar = { + val title = @Composable { Text(text = "Settings") } + val navigationIcon = @Composable { + IconButton(onClick = { activity?.onBackPressedDispatcher?.onBackPressed() }) { + Icon( + painter = painterResource(id = R.drawable.ic_round_arrow_back_24), + contentDescription = null + ) + } + } + if (useLargeTopAppBar) { + LargeTopAppBar( + title = title, + navigationIcon = navigationIcon, + scrollBehavior = scrollBehavior + ) + } else { + TopAppBar( + title = title, + navigationIcon = navigationIcon + ) + } + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + ) { + items( + count = settings.size, + key = { index -> + val item = settings[index] + (item.title ?: item.summary).notNull() + } + ) { index -> + when (val item = settings[index]) { + is SettingsItem.Title -> TitleSettingsItem( + item = item, + isMultiline = isMultilineEnabled + ) + + is SettingsItem.TitleSummary -> TitleSummarySettingsItem( + item = item, + isMultiline = isMultilineEnabled, + onSettingsClickListener = clickListener, + onSettingsLongClickListener = longClickListener + ) + + is SettingsItem.Switch -> SwitchSettingsItem( + item = item, + isMultiline = isMultilineEnabled, + onSettingsClickListener = clickListener, + onSettingsLongClickListener = longClickListener, + onSettingsChangeListener = changeListener + ) + + is SettingsItem.TextField -> EditTextSettingsItem( + item = item, + isMultiline = isMultilineEnabled, + onSettingsClickListener = clickListener, + onSettingsLongClickListener = longClickListener, + onSettingsChangeListener = changeListener + ) + + is SettingsItem.ListItem -> ListSettingsItem( + item = item, + isMultiline = isMultilineEnabled, + onSettingsClickListener = clickListener, + onSettingsLongClickListener = longClickListener, + onSettingsChangeListener = changeListener + ) + } + } + } + } + } + } + + @Composable + fun HandleDialogs( + isNeedToShowLogOutDialog: Boolean + ) { + if (isNeedToShowLogOutDialog) { + val isEasterEgg = UserConfig.userId == ID_DMITRY + + val title = UiText.Resource( + if (isEasterEgg) R.string.easter_egg_log_out_dmitry + else R.string.sign_out_confirm_title + ) + + val positiveText = UiText.Resource( + if (isEasterEgg) R.string.easter_egg_log_out_dmitry + else R.string.action_sign_out + ) + + MaterialDialog( + title = title, + message = UiText.Resource(R.string.sign_out_confirm), + positiveText = positiveText, + positiveAction = { + setFragmentResult( + MainFragment.START_SERVICES_KEY, + bundleOf(MainFragment.START_SERVICES_ARG_ENABLE to false) + ) + viewModel.onLogOutAlertPositiveClick() + }, + negativeText = UiText.Resource(R.string.cancel), + onDismissAction = viewModel::onLogOutAlertDismissed + ) + } + } + + companion object { + fun newInstance(): SettingsFragment = SettingsFragment() + + const val KEY_ACCOUNT = "account" + const val KEY_ACCOUNT_LOGOUT = "account_logout" + + const val KEY_APPEARANCE = "appearance" + const val KEY_APPEARANCE_MULTILINE = "appearance_multiline" + const val DEFAULT_VALUE_MULTILINE = true + + const val KEY_FEATURES_HIDE_KEYBOARD_ON_SCROLL = "features_hide_keyboard_on_scroll" + const val KEY_FEATURES_FAST_TEXT = "features_fast_text" + const val DEFAULT_VALUE_FEATURES_FAST_TEXT = "¯\\_(ツ)_/¯" + const val KEY_FEATURES_LONG_POLL_IN_BACKGROUND = "features_lp_background" + const val DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND = true + + const val KEY_VISIBILITY_SEND_ONLINE_STATUS = "visibility_send_online_status" + + const val KEY_UPDATES_CHECK_AT_STARTUP = "updates_check_at_startup" + const val KEY_UPDATES_CHECK_UPDATES = "updates_check_updates" + + const val KEY_MS_APPCENTER_ENABLE = "msappcenter.enable" + const val KEY_MS_APPCENTER_ENABLE_ON_DEBUG = "msappcenter.enable_on_debug" + + const val KEY_DEBUG_PERFORM_CRASH = "debug_perform_crash" + const val KEY_USE_DYNAMIC_COLORS = "debug_use_dynamic_colors" + const val DEFAULT_VALUE_USE_DYNAMIC_COLORS = false + const val KEY_DEBUG_SHOW_CRASH_ALERT = "debug_show_crash_alert" + const val KEY_APPEARANCE_DARK_THEME = "debug_appearance_dark_theme" + const val DEFAULT_VALUE_APPEARANCE_DARK_THEME = AppCompatDelegate.MODE_NIGHT_NO + const val KEY_USE_LARGE_TOP_APP_BAR = "debug_large_top_app_bar" + const val DEFAULT_VALUE_USE_LARGE_TOP_APP_BAR = true + const val KEY_OPEN_TESTING_ACTIVITY = "debug_open_testing_activity" + + const val KEY_DEBUG_HIDE_DEBUG_LIST = "debug_hide_debug_list" + + const val KEY_SHOW_DEBUG_CATEGORY = "show_debug_category" + + const val KEY_SHOW_EXACT_TIME_ON_TIME_STAMP = "show_exact_time_on_time_stamp" + + const val KEY_SHOW_ADD_QS_TILE_ALERT = "show_add_qs_tile_alert" + + + const val ID_DMITRY = 37610580 + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt deleted file mode 100644 index a5174f6b..00000000 --- a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsPrefsFragment.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.meloda.fast.screens.settings - -import android.os.Bundle -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import androidx.preference.EditTextPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import com.meloda.fast.BuildConfig -import com.meloda.fast.R -import com.meloda.fast.common.AppGlobal -import com.microsoft.appcenter.crashes.Crashes - -class SettingsPrefsFragment : PreferenceFragmentCompat(), - Preference.OnPreferenceClickListener, - Preference.OnPreferenceChangeListener { - - @Suppress("unused") - companion object { - const val KeyChangeMultiline = "change_multiline" - const val ArgEnabled = "enabled" - - const val CategoryAppearance = "appearance" - const val PrefMultiline = "multiline" - - const val CategoryFeatures = "features" - const val PrefHideKeyboardOnScroll = "hide_keyboard_on_scroll" - const val PrefFastText = "fast_text" - const val PrefFastTextDefaultValue = "¯\\_(ツ)_/¯" - - const val CategoryVisibility = "visibility" - const val PrefSendOnlineStatus = "send_online_status" - - const val CategoryUpdates = "updates" - const val PrefCheckUpdates = "check_updates" - - const val CategoryDebug = "debug" - const val PrefPerformCrash = "perform_crash" - const val PrefShowDestroyedLongPollAlert = "show_destroyed_long_poll_alert" - const val PrefShowCrashAlert = "show_crash_alert" - - const val CategoryAppCenter = "msappcenter" - const val PrefEnableReporter = "msappcenter.enable" - } - - private val prefs = AppGlobal.preferences - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.preferences, rootKey) - - getPreference(PrefMultiline)?.let { - it.onPreferenceChangeListener = this - } - - getPreference(PrefCheckUpdates)?.let { - val version = AppGlobal.versionName.split("_").getOrNull(1) - val summaryText = getString(R.string.pref_updates_check_update_summary, version) - it.summary = summaryText - - it.onPreferenceClickListener = this - } - - getPreference(CategoryDebug)?.let { - it.isVisible = BuildConfig.DEBUG - } - getPreference(PrefPerformCrash)?.let { - it.isVisible = BuildConfig.DEBUG - it.onPreferenceClickListener = this - } - - findPreference(PrefFastText)?.summaryProvider = fastTextSummaryProvider - } - - override fun onPreferenceClick(preference: Preference): Boolean { - return when (preference.key) { - PrefCheckUpdates -> { - rootFragment?.openUpdatesScreen() - true - } - PrefPerformCrash -> { - Crashes.generateTestCrash() - true - } - else -> false - } - } - - override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean { - return when (preference.key) { - PrefMultiline -> { - val enabled = newValue as Boolean - setFragmentResult(KeyChangeMultiline, bundleOf(ArgEnabled to enabled)) - true - } - else -> false - } - } - - private val fastTextSummaryProvider = Preference.SummaryProvider { - getString( - R.string.pref_message_fast_text_summary, - prefs.getString(PrefFastText, PrefFastTextDefaultValue) - ) - } - - private val rootFragment: SettingsRootFragment? get() = parentFragment as? SettingsRootFragment - - private fun getPreference(key: String) = findPreference(key) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt deleted file mode 100644 index 4c2272f9..00000000 --- a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsRootFragment.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.meloda.fast.screens.settings - -import android.os.Bundle -import android.view.View -import android.viewbinding.library.fragment.viewBinding -import androidx.fragment.app.commit -import androidx.fragment.app.setFragmentResultListener -import com.meloda.fast.R -import com.meloda.fast.base.BaseFragment -import com.meloda.fast.common.Screens -import com.meloda.fast.databinding.FragmentSettingsRootBinding - -class SettingsRootFragment : BaseFragment(R.layout.fragment_settings_root) { - - companion object { - const val KeyCheckUpdates = "check_updates" - } - - private val binding: FragmentSettingsRootBinding by viewBinding() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } - - setFragmentResultListener(KeyCheckUpdates) { _, _ -> - openUpdatesScreen() - } - - childFragmentManager.commit { - replace(R.id.settings_fragment_container, SettingsPrefsFragment()) - } - } - - fun openUpdatesScreen() { - requireActivityRouter().navigateTo(Screens.Updates()) - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsViewModel.kt new file mode 100644 index 00000000..4f6bb202 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/SettingsViewModel.kt @@ -0,0 +1,526 @@ +package com.meloda.fast.screens.settings + +import android.os.Build +import android.view.HapticFeedbackConstants +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.edit +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.terrakok.cicerone.Router +import com.meloda.fast.BuildConfig +import com.meloda.fast.R +import com.meloda.fast.api.UserConfig +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.common.Screens +import com.meloda.fast.data.account.AccountsDao +import com.meloda.fast.database.CacheDatabase +import com.meloda.fast.ext.emitOnMainScope +import com.meloda.fast.ext.ifEmpty +import com.meloda.fast.ext.isSdkAtLeast +import com.meloda.fast.ext.isTrue +import com.meloda.fast.model.base.UiText +import com.meloda.fast.model.base.parseString +import com.meloda.fast.screens.main.activity.LongPollState +import com.meloda.fast.screens.main.activity.MainActivity +import com.meloda.fast.screens.settings.model.SettingsItem +import com.microsoft.appcenter.crashes.model.TestCrashException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +typealias SettingsList = List> + +interface SettingsViewModel { + + val settings: StateFlow + val useDynamicColors: StateFlow + val useLargeTopAppBar: StateFlow + val isMultilineEnabled: StateFlow + val isLongPollBackgroundEnabled: StateFlow + + val isNeedToShowLogOutAlert: StateFlow + + val isNeedToOpenTestingActivity: StateFlow + + val isNeedToShowPerformCrashAlert: StateFlow + + val isNeedToShowAddQuickSettingsTileAlert: StateFlow + + val isNeedToUseHaptics: StateFlow + + fun onLogOutAlertDismissed() + + fun onPerformCrashAlertDismissed() + + fun onPerformCrashPositiveButtonClicked() + + fun onLogOutAlertPositiveClick() + + fun onSettingsItemClicked(key: String) + fun onSettingsItemLongClicked(key: String): Boolean + fun onSettingsItemChanged(key: String, newValue: Any?) + + fun onTestingActivityOpened() + + fun onAddQuickSettingsTileAlertShown() + + fun onHapticsUsed() +} + +class SettingsViewModelImpl constructor( + private val accountsDao: AccountsDao, + private val cacheDatabase: CacheDatabase, + private val router: Router +) : SettingsViewModel, ViewModel() { + + override val settings = MutableStateFlow(emptyList()) + override val useDynamicColors = MutableStateFlow( + AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_USE_DYNAMIC_COLORS, + SettingsFragment.DEFAULT_VALUE_USE_DYNAMIC_COLORS + ) + ) + override val useLargeTopAppBar = MutableStateFlow( + AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_USE_LARGE_TOP_APP_BAR, + SettingsFragment.DEFAULT_VALUE_USE_LARGE_TOP_APP_BAR + ) + ) + override val isMultilineEnabled = MutableStateFlow( + AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_APPEARANCE_MULTILINE, + SettingsFragment.DEFAULT_VALUE_MULTILINE + ) + ) + override val isLongPollBackgroundEnabled = MutableStateFlow(null) + + override val isNeedToShowLogOutAlert = MutableStateFlow(false) + + override val isNeedToOpenTestingActivity = MutableStateFlow(false) + + override val isNeedToShowPerformCrashAlert = MutableStateFlow(false) + + override val isNeedToShowAddQuickSettingsTileAlert = MutableStateFlow(false) + + override val isNeedToUseHaptics = MutableStateFlow(HapticType.None) + + init { + if (AppGlobal.preferences.getBoolean("first_open_settings", true)) { + AppGlobal.preferences.edit { + putBoolean("first_open_settings", false) + } + + isNeedToShowAddQuickSettingsTileAlert.emitOnMainScope(true) + } + + createSettings() + } + + private fun createSettings() { + viewModelScope.launch { + val accountVisible = UserConfig.isLoggedIn() + val accountTitle = SettingsItem.Title.build( + key = SettingsFragment.KEY_ACCOUNT, + title = UiText.Simple("Account") + ) { + isVisible = accountVisible + } + val accountLogOut = SettingsItem.TitleSummary.build( + key = SettingsFragment.KEY_ACCOUNT_LOGOUT, + title = UiText.Simple("Log out"), + summary = UiText.Simple("Log out from account and delete all local data related to this account") + ) { + isVisible = accountVisible + } + + val appearanceTitle = SettingsItem.Title.build( + key = SettingsFragment.KEY_APPEARANCE, + title = UiText.Simple("Appearance") + ) + val appearanceMultiline = SettingsItem.Switch.build( + key = SettingsFragment.KEY_APPEARANCE_MULTILINE, + defaultValue = SettingsFragment.DEFAULT_VALUE_MULTILINE, + title = UiText.Simple("Multiline titles and messages"), + summary = UiText.Simple("The title of the dialog and the text of the message can take up two lines") + ) + + val featuresTitle = SettingsItem.Title.build( + key = "features", + title = UiText.Simple("Features") + ) + val featuresHideKeyboardOnScroll = SettingsItem.Switch.build( + key = SettingsFragment.KEY_FEATURES_HIDE_KEYBOARD_ON_SCROLL, + defaultValue = true, + title = UiText.Simple("Hide keyboard on scroll"), + summary = UiText.Simple("Hides keyboard when you scrolling messages up in messages history screen") + ) + val featuresFastText = SettingsItem.TextField.build( + key = SettingsFragment.KEY_FEATURES_FAST_TEXT, + title = UiText.Simple("Fast text"), + defaultValue = "¯\\_(ツ)_/¯", + ).apply { + summaryProvider = SettingsItem.SummaryProvider { settingsItem -> + UiText.ResourceParams( + R.string.pref_message_fast_text_summary, + listOf(settingsItem.value.ifEmpty { null }) + ) + } + } + val featuresLongPollBackground = SettingsItem.Switch.build( + key = SettingsFragment.KEY_FEATURES_LONG_POLL_IN_BACKGROUND, + defaultValue = SettingsFragment.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND, + title = UiText.Simple("LongPoll in background"), + summary = UiText.Simple("Your messages will be updates even when app is not on the screen") + ) + + val visibilityTitle = SettingsItem.Title.build( + key = "visibility", + title = UiText.Simple("Visibility") + ) + val visibilitySendOnlineStatus = SettingsItem.Switch.build( + key = SettingsFragment.KEY_VISIBILITY_SEND_ONLINE_STATUS, + defaultValue = false, + title = UiText.Simple("Send online status"), + summary = UiText.Simple("Online status will be sent every five minutes") + ) + + val updatesTitle = SettingsItem.Title.build( + key = "updates", + title = UiText.Simple("Updates") + ) + val updatesCheckAtStartup = SettingsItem.Switch.build( + key = SettingsFragment.KEY_UPDATES_CHECK_AT_STARTUP, + title = UiText.Simple("Check at startup"), + summary = UiText.Simple("Check updates at app startup"), + defaultValue = true + ) + val updatesCheckUpdates = SettingsItem.TitleSummary.build( + key = SettingsFragment.KEY_UPDATES_CHECK_UPDATES, + title = UiText.Simple("Check updates") + ) + + val msAppCenterTitle = SettingsItem.Title.build( + key = "msappcenter", + title = UiText.Simple("MS AppCenter Crash Reporter") + ) + val msAppCenterEnable = SettingsItem.Switch.build( + key = SettingsFragment.KEY_MS_APPCENTER_ENABLE, + defaultValue = true, + title = UiText.Simple("Enable Crash Reporter") + ) + val msAppCenterEnableOnDebug = SettingsItem.Switch.build( + key = SettingsFragment.KEY_MS_APPCENTER_ENABLE_ON_DEBUG, + defaultValue = false, + title = UiText.Simple("Enable Crash Reporter on debug builds"), + summary = UiText.Simple("Requires application restart") + ) + + val debugTitle = SettingsItem.Title.build( + key = "debug", + title = UiText.Simple("Debug") + ) + val debugPerformCrash = SettingsItem.TitleSummary.build( + key = SettingsFragment.KEY_DEBUG_PERFORM_CRASH, + title = UiText.Simple("Perform crash"), + summary = UiText.Simple("App will be crashed. Obviously") + ) + val debugShowCrashAlert = SettingsItem.Switch.build( + key = SettingsFragment.KEY_DEBUG_SHOW_CRASH_ALERT, + defaultValue = true, + title = UiText.Simple("Show alert after crash"), + summary = UiText.Simple("Shows alert dialog with stacktrace after app crashed\n(it will be not shown if you perform crash manually)") + ) + val debugUseDynamicColors = SettingsItem.Switch.build( + key = SettingsFragment.KEY_USE_DYNAMIC_COLORS, + title = UiText.Simple("[WIP] Use dynamic colors"), + isEnabled = isSdkAtLeast(Build.VERSION_CODES.S), + summary = UiText.Simple("Requires Android 12 or higher;\nUnstable - you may need to manually kill app via it's info screen in order for changes to applied"), + defaultValue = false + ) + + val darkThemeValues = listOf( + AppCompatDelegate.MODE_NIGHT_YES, + AppCompatDelegate.MODE_NIGHT_NO, + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY + ) + val darkThemeTitles = listOf( + UiText.Simple("Enabled"), + UiText.Simple("Disabled"), + UiText.Simple("Follow system"), + UiText.Simple("Battery saver") + ) + val darkThemeValuesMap = List(darkThemeValues.size) { index -> + darkThemeValues[index] to darkThemeTitles[index].parseString(AppGlobal.Instance) + }.toMap() + + val debugDarkTheme = SettingsItem.ListItem.build( + key = SettingsFragment.KEY_APPEARANCE_DARK_THEME, + title = UiText.Simple("[WIP] Dark theme"), + values = darkThemeValues, + valueTitles = darkThemeTitles, + defaultValue = AppCompatDelegate.MODE_NIGHT_NO + ) { + summaryProvider = SettingsItem.SummaryProvider { item -> + UiText.Simple( + "Current value: ${ + darkThemeValuesMap.getOrElse(item.value ?: -1) { + "Unknown" + } + }" + ) + } + } + val debugUseLargeTopAppBar = SettingsItem.Switch.build( + key = SettingsFragment.KEY_USE_LARGE_TOP_APP_BAR, + title = UiText.Simple("[WIP] Use LargeTopAppBar"), + summary = UiText.Simple("Using large top appbar instead of default toolbar everywhere in app"), + defaultValue = SettingsFragment.DEFAULT_VALUE_USE_LARGE_TOP_APP_BAR + ) + val debugOpenTestingActivity = SettingsItem.TitleSummary.build( + key = SettingsFragment.KEY_OPEN_TESTING_ACTIVITY, + title = UiText.Simple("Open testing activity") + ) + val debugShowExactTimeOnTimeStamp = SettingsItem.Switch.build( + key = SettingsFragment.KEY_SHOW_EXACT_TIME_ON_TIME_STAMP, + title = UiText.Simple("Show exact time on time stamp"), + summary = UiText.Simple("Shows hours and minutes on time stamp in messages history"), + defaultValue = false + ) + val debugShowAddQuickSettingsTileAlert = SettingsItem.TitleSummary.build( + key = SettingsFragment.KEY_SHOW_ADD_QS_TILE_ALERT, + title = UiText.Simple("Add QuickSettings Tile") + ) + + val debugHideDebugList = SettingsItem.TitleSummary.build( + key = SettingsFragment.KEY_DEBUG_HIDE_DEBUG_LIST, + title = UiText.Simple("Hide debug list") + ) + + val accountList = listOf( + accountTitle, + accountLogOut + ) + val appearanceList = listOf( + appearanceTitle, + appearanceMultiline + ) + val featuresList = listOf( + featuresTitle, + featuresHideKeyboardOnScroll, + featuresFastText, + featuresLongPollBackground + ) + val visibilityList = listOf( + visibilityTitle, + visibilitySendOnlineStatus, + ) + val updatesList = listOf( + updatesTitle, + updatesCheckAtStartup, + updatesCheckUpdates, + ) + val msAppCenterList = mutableListOf( + msAppCenterTitle, + msAppCenterEnable, + ).apply { + if (BuildConfig.DEBUG) { + this += msAppCenterEnableOnDebug + } + } + val debugList = mutableListOf>() + listOf( + debugTitle, + debugPerformCrash, + debugShowCrashAlert, + debugUseDynamicColors, + debugDarkTheme, + debugUseLargeTopAppBar, + debugOpenTestingActivity, + debugShowExactTimeOnTimeStamp, + debugShowAddQuickSettingsTileAlert + ).forEach(debugList::add) + + debugList += debugHideDebugList + + val settingsList = mutableListOf>() + listOf( + accountList, + appearanceList, + featuresList, + visibilityList, + updatesList, + msAppCenterList, + debugList, + ).forEach(settingsList::addAll) + + if (!AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_SHOW_DEBUG_CATEGORY, + false + ) + ) { + settingsList.removeAll(debugList) + } + + settings.emit(settingsList) + } + } + + override fun onLogOutAlertDismissed() { + viewModelScope.launch(Dispatchers.Main) { + isNeedToShowLogOutAlert.emit(false) + } + } + + override fun onPerformCrashAlertDismissed() { + isNeedToShowPerformCrashAlert.emitOnMainScope(false) + } + + override fun onPerformCrashPositiveButtonClicked() { + isNeedToShowPerformCrashAlert.emitOnMainScope(false) + throw TestCrashException() + } + + override fun onLogOutAlertPositiveClick() { + viewModelScope.launch(Dispatchers.IO) { + accountsDao.deleteById(UserConfig.userId) + cacheDatabase.clearAllTables() + + MainActivity.longPollState.emit(LongPollState.Stop) + + UserConfig.clear() + + withContext(Dispatchers.Main) { + router.newRootScreen(Screens.Main()) + } + } + } + + override fun onSettingsItemClicked(key: String) { + when (key) { + SettingsFragment.KEY_ACCOUNT_LOGOUT -> { + viewModelScope.launch(Dispatchers.Main) { + isNeedToShowLogOutAlert.emit(true) + } + } + + SettingsFragment.KEY_UPDATES_CHECK_UPDATES -> { + openUpdatesScreen() + } + + SettingsFragment.KEY_DEBUG_PERFORM_CRASH -> { + isNeedToShowPerformCrashAlert.emitOnMainScope(true) + } + + SettingsFragment.KEY_DEBUG_HIDE_DEBUG_LIST -> { + val showDebugCategory = + AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_SHOW_DEBUG_CATEGORY, + false + ) + if (!showDebugCategory) return + + AppGlobal.preferences.edit { + putBoolean(SettingsFragment.KEY_SHOW_DEBUG_CATEGORY, false) + } + + createSettings() + + isNeedToUseHaptics.emitOnMainScope(HapticType.HideDebugMenu) + } + + SettingsFragment.KEY_OPEN_TESTING_ACTIVITY -> { + isNeedToOpenTestingActivity.emitOnMainScope(true) + } + + SettingsFragment.KEY_SHOW_ADD_QS_TILE_ALERT -> { + isNeedToShowAddQuickSettingsTileAlert.emitOnMainScope(true) + } + } + } + + override fun onSettingsItemLongClicked(key: String): Boolean { + return when (key) { + SettingsFragment.KEY_UPDATES_CHECK_UPDATES -> { + val showDebugCategory = + AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_SHOW_DEBUG_CATEGORY, + false + ) + if (showDebugCategory) return false + + AppGlobal.preferences.edit { + putBoolean(SettingsFragment.KEY_SHOW_DEBUG_CATEGORY, true) + } + createSettings() + + isNeedToUseHaptics.emitOnMainScope(HapticType.ShowDebugMenu) + true + } + + else -> false + } + } + + override fun onSettingsItemChanged(key: String, newValue: Any?) { + when (key) { + SettingsFragment.KEY_APPEARANCE_DARK_THEME -> { + val newMode = newValue as? Int ?: return + AppCompatDelegate.setDefaultNightMode(newMode) + } + + SettingsFragment.KEY_APPEARANCE_MULTILINE -> { + val isEnabled = (newValue as? Boolean).isTrue + isMultilineEnabled.update { isEnabled } + } + + SettingsFragment.KEY_USE_DYNAMIC_COLORS -> { + val isEnabled = (newValue as? Boolean).isTrue + useDynamicColors.update { isEnabled } + } + + SettingsFragment.KEY_USE_LARGE_TOP_APP_BAR -> { + val isEnabled = (newValue as? Boolean).isTrue + useLargeTopAppBar.update { isEnabled } + } + + SettingsFragment.KEY_FEATURES_LONG_POLL_IN_BACKGROUND -> { + val isEnabled = (newValue as? Boolean).isTrue + isLongPollBackgroundEnabled.update { isEnabled } + } + } + } + + override fun onTestingActivityOpened() { + isNeedToOpenTestingActivity.emitOnMainScope(false) + } + + override fun onAddQuickSettingsTileAlertShown() { + isNeedToShowAddQuickSettingsTileAlert.emitOnMainScope(false) + } + + override fun onHapticsUsed() { + isNeedToUseHaptics.emitOnMainScope(HapticType.None) + } + + private fun openUpdatesScreen() { + router.navigateTo(Screens.Updates()) + } +} + +sealed interface HapticType { + object ShowDebugMenu : HapticType + object HideDebugMenu : HapticType + object None : HapticType + + fun getHaptic(): Int { + return when (this) { + ShowDebugMenu -> HapticFeedbackConstants.LONG_PRESS + HideDebugMenu -> HapticFeedbackConstants.REJECT + None -> -1 + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/di/SettingsModule.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/di/SettingsModule.kt new file mode 100644 index 00000000..f9dd86c6 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/di/SettingsModule.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.screens.settings.di + +import com.meloda.fast.screens.settings.SettingsViewModelImpl +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val settingsModule = module { + viewModelOf(::SettingsViewModelImpl) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/items/ListSettingsItem.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/ListSettingsItem.kt new file mode 100644 index 00000000..c742e7c7 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/ListSettingsItem.kt @@ -0,0 +1,122 @@ +package com.meloda.fast.screens.settings.items + +import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.meloda.fast.R +import com.meloda.fast.ext.ItemsChoiceType +import com.meloda.fast.ext.getString +import com.meloda.fast.ext.showDialog +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.settings.model.OnSettingsChangeListener +import com.meloda.fast.screens.settings.model.OnSettingsClickListener +import com.meloda.fast.screens.settings.model.OnSettingsLongClickListener +import com.meloda.fast.screens.settings.model.SettingsItem + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ListSettingsItem( + item: SettingsItem.ListItem, + isMultiline: Boolean, + onSettingsClickListener: OnSettingsClickListener, + onSettingsLongClickListener: OnSettingsLongClickListener, + onSettingsChangeListener: OnSettingsChangeListener +) { + val context = LocalContext.current + + var title by remember { mutableStateOf(item.title) } + item.onTitleChanged = { newTitle -> title = newTitle } + + var summary by remember { mutableStateOf(item.summary) } + item.onSummaryChanged = { newSummary -> summary = newSummary } + + var isEnabled by remember { mutableStateOf(item.isEnabled) } + item.onEnabledStateChanged = { newEnabled -> isEnabled = newEnabled } + + var isVisible by remember { mutableStateOf(item.isVisible) } + item.onVisibleStateChanged = { newVisible -> isVisible = newVisible } + + if (!isVisible) return + Row( + modifier = Modifier + .heightIn(min = 56.dp) + .fillMaxWidth() + .combinedClickable( + enabled = isEnabled, + onClick = { + onSettingsClickListener.onClick(item.key) + showListAlertDialog( + context = context, + item = item, + onSettingsChangeListener = { key, newValue -> + summary = item.summaryProvider?.provideSummary(item) + onSettingsChangeListener.onChange(key, newValue) + } + ) + }, + onLongClick = { onSettingsLongClickListener.onLongClick(item.key) }, + ) + ) { + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start + ) { + Spacer(modifier = Modifier.height(14.dp)) + title?.getString()?.let { title -> + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + maxLines = if (isMultiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + summary?.getString()?.let { summary -> + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (isMultiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.height(14.dp)) + } + Spacer(modifier = Modifier.width(16.dp)) + } +} + +private fun showListAlertDialog( + context: Context, + item: SettingsItem.ListItem, + onSettingsChangeListener: OnSettingsChangeListener +) { + var selectedOption = item.value + val checkedItem = item.values.indexOf(selectedOption) + + context.showDialog( + title = item.title, + items = item.valueTitles, + checkedItems = listOf(checkedItem), + itemsChoiceType = ItemsChoiceType.SingleChoice, + itemsClickAction = { index, _ -> + selectedOption = item.values[index] + }, + positiveText = UiText.Resource(R.string.ok), + positiveAction = { + if (item.value != selectedOption) { + item.value = selectedOption + onSettingsChangeListener.onChange(item.key, selectedOption) + } + } + ) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/items/SwitchSettingsItem.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/SwitchSettingsItem.kt new file mode 100644 index 00000000..c603e8af --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/SwitchSettingsItem.kt @@ -0,0 +1,111 @@ +package com.meloda.fast.screens.settings.items + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.meloda.fast.ext.getString +import com.meloda.fast.ext.isTrue +import com.meloda.fast.screens.settings.model.OnSettingsChangeListener +import com.meloda.fast.screens.settings.model.OnSettingsClickListener +import com.meloda.fast.screens.settings.model.OnSettingsLongClickListener +import com.meloda.fast.screens.settings.model.SettingsItem + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SwitchSettingsItem( + item: SettingsItem.Switch, + isMultiline: Boolean, + onSettingsClickListener: OnSettingsClickListener, + onSettingsLongClickListener: OnSettingsLongClickListener, + onSettingsChangeListener: OnSettingsChangeListener +) { + var isChecked by remember { + mutableStateOf(item.value.isTrue) + } + + val onCheckedChange = { newValue: Boolean -> + isChecked = newValue + + if (item.value != isChecked) { + item.value = isChecked + onSettingsChangeListener.onChange(item.key, isChecked) + } + } + + var title by remember { mutableStateOf(item.title) } + item.onTitleChanged = { newTitle -> title = newTitle } + + var summary by remember { mutableStateOf(item.summary) } + item.onSummaryChanged = { newSummary -> summary = newSummary } + + var value by remember { mutableStateOf(item.value) } + item.onValueChanged = { newValue -> + value = newValue + isChecked = newValue.isTrue + } + + // TODO: 07.04.2023, Danil Nikolaev: handle isEnabled + var isEnabled by remember { mutableStateOf(item.isEnabled) } + item.onEnabledStateChanged = { newEnabled -> isEnabled = newEnabled } + + var isVisible by remember { mutableStateOf(item.isVisible) } + item.onVisibleStateChanged = { newVisible -> isVisible = newVisible } + + if (!isVisible) return + Row( + modifier = Modifier + .fillMaxSize() + .heightIn(min = 56.dp) + .combinedClickable( + enabled = isEnabled, + onClick = { + onSettingsClickListener.onClick(item.key) + onCheckedChange.invoke(!isChecked) + }, + onLongClick = { onSettingsLongClickListener.onLongClick(item.key) }, + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Spacer(modifier = Modifier.height(14.dp)) + title?.getString()?.let { title -> + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + maxLines = if (isMultiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + summary?.getString()?.let { summary -> + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (isMultiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.height(14.dp)) + } + Row { + Spacer(modifier = Modifier.width(16.dp)) + Switch( + enabled = isEnabled, + checked = isChecked, + onCheckedChange = onCheckedChange + ) + } + Spacer(modifier = Modifier.width(16.dp)) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/items/TextFieldSettingsItem.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/TextFieldSettingsItem.kt new file mode 100644 index 00000000..1639e0e9 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/TextFieldSettingsItem.kt @@ -0,0 +1,187 @@ +package com.meloda.fast.screens.settings.items + +import android.content.Context +import android.view.LayoutInflater +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.meloda.fast.R +import com.meloda.fast.compose.MaterialDialog +import com.meloda.fast.databinding.ItemSettingsEditTextAlertBinding +import com.meloda.fast.ext.getString +import com.meloda.fast.ext.showDialog +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.settings.model.OnSettingsChangeListener +import com.meloda.fast.screens.settings.model.OnSettingsClickListener +import com.meloda.fast.screens.settings.model.OnSettingsLongClickListener +import com.meloda.fast.screens.settings.model.SettingsItem + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun EditTextSettingsItem( + item: SettingsItem.TextField, + isMultiline: Boolean, + onSettingsClickListener: OnSettingsClickListener, + onSettingsLongClickListener: OnSettingsLongClickListener, + onSettingsChangeListener: OnSettingsChangeListener +) { + val context = LocalContext.current + + var title by remember { mutableStateOf(item.title) } + item.onTitleChanged = { newTitle -> title = newTitle } + + var summary by remember { mutableStateOf(item.summary) } + item.onSummaryChanged = { newSummary -> summary = newSummary } + + // TODO: 07.04.2023, Danil Nikolaev: handle isEnabled + var isEnabled by remember { mutableStateOf(item.isEnabled) } + item.onEnabledStateChanged = { newEnabled -> isEnabled = newEnabled } + + var isVisible by remember { mutableStateOf(item.isVisible) } + item.onVisibleStateChanged = { newVisible -> isVisible = newVisible } + + var showDialog by remember { + mutableStateOf(false) + } + + if (showDialog) { + ShowEditTextAlert( + item = item, + onSettingsChangeListener = { key, newValue -> + summary = item.summaryProvider?.provideSummary(item) + onSettingsChangeListener.onChange(key, newValue) + }, + onDismiss = { showDialog = false } + ) + } + + if (!isVisible) return + Row( + modifier = Modifier + .heightIn(min = 56.dp) + .fillMaxWidth() + .combinedClickable( + enabled = isEnabled, + onClick = { + onSettingsClickListener.onClick(item.key) + showDialog = true + }, + onLongClick = { onSettingsLongClickListener.onLongClick(item.key) }, + ) + ) { + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start + ) { + Spacer(modifier = Modifier.height(14.dp)) + title?.getString()?.let { title -> + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + maxLines = if (isMultiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + summary?.getString()?.let { summary -> + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (isMultiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.height(14.dp)) + } + Spacer(modifier = Modifier.width(16.dp)) + } +} + +private fun showEditTextAlert( + context: Context, + item: SettingsItem.TextField, + onSettingsChangeListener: OnSettingsChangeListener +) { + val binding = ItemSettingsEditTextAlertBinding.inflate( + LayoutInflater.from(context), null, false + ) + + binding.editText.setText(item.value) + + context.showDialog( + title = item.title, + view = binding.root, + positiveText = UiText.Resource(R.string.ok), + positiveAction = { + val newValue = binding.editText.text.toString() + + if (item.value != newValue) { + item.value = newValue + onSettingsChangeListener.onChange(item.key, newValue) + } + }, + negativeText = UiText.Resource(R.string.cancel) + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun ShowEditTextAlert( + item: SettingsItem.TextField, + onSettingsChangeListener: OnSettingsChangeListener, + onDismiss: () -> Unit +) { + val (textFieldFocusable) = FocusRequester.createRefs() + + var textFieldValue by remember { + mutableStateOf(TextFieldValue(item.value.orEmpty())) + } + + MaterialDialog( + title = item.title, + positiveText = UiText.Resource(R.string.ok), + positiveAction = { + val newValue = textFieldValue.text.trim() + + if (item.value != newValue) { + item.value = newValue + onSettingsChangeListener.onChange(item.key, newValue) + } + }, + negativeText = UiText.Resource(R.string.cancel), + onDismissAction = onDismiss + ) { + TextField( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .focusRequester(textFieldFocusable), + value = textFieldValue, + onValueChange = { newText -> + textFieldValue = newText + }, + label = { Text(text = "Value") }, + placeholder = { Text(text = "Value") }, + shape = RoundedCornerShape(10.dp), + ) + } + + LaunchedEffect(Unit) { + textFieldFocusable.requestFocus() + textFieldValue = textFieldValue.copy(selection = TextRange(textFieldValue.text.length)) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/items/TitleSettingsItem.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/TitleSettingsItem.kt new file mode 100644 index 00000000..932e9ac0 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/TitleSettingsItem.kt @@ -0,0 +1,47 @@ +package com.meloda.fast.screens.settings.items + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.meloda.fast.ext.getString +import com.meloda.fast.screens.settings.model.SettingsItem + +@Composable +fun TitleSettingsItem( + item: SettingsItem.Title, + isMultiline: Boolean +) { + var title by remember { mutableStateOf(item.title) } + item.onTitleChanged = { newTitle -> title = newTitle } + + // TODO: 07.04.2023, Danil Nikolaev: handle isEnabled + var isEnabled by remember { mutableStateOf(item.isEnabled) } + item.onEnabledStateChanged = { newEnabled -> isEnabled = newEnabled } + + var isVisible by remember { mutableStateOf(item.isVisible) } + item.onVisibleStateChanged = { newVisible -> isVisible = newVisible } + + if (!isVisible) return + + Text( + text = title.getString().orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding( + top = 14.dp, + end = 16.dp, + start = 16.dp, + bottom = 4.dp + ), + maxLines = if (isMultiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/items/TitleSummarySettingsItem.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/TitleSummarySettingsItem.kt new file mode 100644 index 00000000..89642d38 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/items/TitleSummarySettingsItem.kt @@ -0,0 +1,77 @@ +package com.meloda.fast.screens.settings.items + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.meloda.fast.ext.getString +import com.meloda.fast.screens.settings.model.OnSettingsClickListener +import com.meloda.fast.screens.settings.model.OnSettingsLongClickListener +import com.meloda.fast.screens.settings.model.SettingsItem + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TitleSummarySettingsItem( + item: SettingsItem.TitleSummary, + isMultiline: Boolean, + onSettingsClickListener: OnSettingsClickListener, + onSettingsLongClickListener: OnSettingsLongClickListener +) { + var title by remember { mutableStateOf(item.title) } + item.onTitleChanged = { newTitle -> title = newTitle } + + var summary by remember { mutableStateOf(item.summary) } + item.onSummaryChanged = { newSummary -> summary = newSummary } + + // TODO: 08.04.2023, Danil Nikolaev: handle isEnabled state + var isEnabled by remember { mutableStateOf(item.isEnabled) } + item.onEnabledStateChanged = { newEnabled -> isEnabled = newEnabled } + + var isVisible by remember { mutableStateOf(item.isVisible) } + item.onVisibleStateChanged = { newVisible -> isVisible = newVisible } + + if (!isVisible) return + Row( + modifier = Modifier + .heightIn(min = 56.dp) + .fillMaxWidth() + .combinedClickable( + enabled = isEnabled, + onClick = { onSettingsClickListener.onClick(item.key) }, + onLongClick = { onSettingsLongClickListener.onLongClick(item.key) }, + ) + ) { + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start + ) { + Spacer(modifier = Modifier.height(14.dp)) + title?.getString()?.let { title -> + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + maxLines = if (isMultiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + summary?.getString()?.let { summary -> + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (isMultiline) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.height(14.dp)) + } + Spacer(modifier = Modifier.width(16.dp)) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/model/SettingsItem.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/model/SettingsItem.kt new file mode 100644 index 00000000..d836be57 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/model/SettingsItem.kt @@ -0,0 +1,234 @@ +package com.meloda.fast.screens.settings.model + +import androidx.core.content.edit +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.model.base.AdapterDiffItem +import com.meloda.fast.model.base.UiText +import kotlin.properties.Delegates + +sealed class SettingsItem( + open val key: String, +) : AdapterDiffItem { + + var onTitleChanged: ((newTitle: UiText?) -> Unit)? = null + + var title: UiText? by Delegates.observable(null) { _, _, newValue -> + onTitleChanged?.invoke(newValue) + } + + var onSummaryChanged: ((newSummary: UiText?) -> Unit)? = null + + var summary: UiText? by Delegates.observable(null) { _, _, newValue -> + onSummaryChanged?.invoke(newValue) + } + + var onEnabledStateChanged: ((newEnabled: Boolean) -> Unit)? = null + + var isEnabled: Boolean by Delegates.observable(true) { _, _, newValue -> + onEnabledStateChanged?.invoke(newValue) + } + + var onVisibleStateChanged: ((newVisible: Boolean) -> Unit)? = null + + var isVisible: Boolean by Delegates.observable(true) { _, _, newValue -> + onVisibleStateChanged?.invoke(newValue) + } + + var onValueChanged: ((newValue: Value?) -> Unit)? = null + + var value: Value? by Delegates.observable(null) { _, oldValue, newValue -> + if (key.trim().isEmpty() || oldValue == newValue) return@observable + + onValueChanged?.invoke(newValue) + + saveValueToPreferences(key, value) + } + + private fun saveValueToPreferences(key: String, value: Any?) { + AppGlobal.preferences.edit { + when (value) { + is String -> putString(key, value) + is Boolean -> putBoolean(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Float -> putFloat(key, value) + else -> throw IllegalArgumentException("unknown class \"${value?.javaClass}\" with value \"$value\"") + } + } + } + + @Suppress("UNCHECKED_CAST") + protected fun getValueFromPreferences( + key: String, + classToGet: Class, + defaultValue: Any? + ): T? { + val preferences = AppGlobal.preferences + + return when (classToGet) { + String::class.java -> preferences.getString(key, defaultValue as? String) + Boolean::class.java -> preferences.getBoolean(key, defaultValue as? Boolean == true) + Int::class.java -> preferences.getInt(key, defaultValue as? Int ?: -1) + Long::class.java -> preferences.getLong(key, defaultValue as? Long ?: -1) + Float::class.java -> preferences.getFloat(key, defaultValue as? Float ?: -1f) + else -> null + }.let { value -> value as? T } + } + + var defaultValue: Value? = null + + var titleProvider: TitleProvider>? by Delegates.observable(null) { _, _, _ -> + updateTitle() + } + + var summaryProvider: SummaryProvider>? by Delegates.observable(null) { _, _, _ -> + updateSummary() + } + + fun updateTitle() { + titleProvider?.provideTitle(this)?.let { newTitle -> title = newTitle } + } + + fun updateSummary() { + summaryProvider?.provideSummary(this)?.let { newSummary -> summary = newSummary } + } + + fun requireValue() = requireNotNull(value) + + override fun areItemsTheSame(newItem: AdapterDiffItem): Boolean { + return newItem is SettingsItem<*> && newItem.key == this.key + } + + fun interface TitleProvider> { + fun provideTitle(settingsItem: Item): UiText? + } + + fun interface SummaryProvider> { + fun provideSummary(settingsItem: Item): UiText? + } + + data class Title(override val key: String) : SettingsItem(key) { + + override val id: Int = -1 + + companion object { + fun build( + key: String, + title: UiText, + isEnabled: Boolean = true, + builder: Title.() -> Unit = {} + ): Title { + return Title(key).apply { + this.title = title + this.isEnabled = isEnabled + }.apply(builder) + } + } + } + + data class TitleSummary(override val key: String) : SettingsItem(key) { + + override val id: Int = -1 + + companion object { + fun build( + key: String, + title: UiText? = null, + summary: UiText? = null, + isEnabled: Boolean = true, + builder: TitleSummary.() -> Unit = {} + ): TitleSummary { + return TitleSummary(key).apply { + this.title = title + this.summary = summary + this.isEnabled = isEnabled + }.apply(builder) + } + } + } + + data class TextField(override val key: String) : SettingsItem(key) { + + override val id: Int = -1 + + companion object { + fun build( + key: String, + title: UiText? = null, + summary: UiText? = null, + defaultValue: String? = null, + isEnabled: Boolean = true, + builder: TextField.() -> Unit = {} + ): TextField { + return TextField(key).apply { + this.title = title + this.summary = summary + this.defaultValue = defaultValue + this.isEnabled = isEnabled + this.value = AppGlobal.preferences.getString(key, defaultValue) + }.apply(builder) + } + } + } + + data class Switch(override val key: String) : SettingsItem(key) { + + override val id: Int = -1 + + companion object { + + fun build( + key: String, + title: UiText? = null, + summary: UiText? = null, + isEnabled: Boolean = true, + isChecked: Boolean? = null, + defaultValue: Boolean? = null, + builder: Switch.() -> Unit = {} + ): Switch { + return Switch(key).apply { + this.title = title + this.summary = summary + this.isEnabled = isEnabled + this.defaultValue = defaultValue + this.value = defaultValue + ?.let { value -> AppGlobal.preferences.getBoolean(key, value) } + ?: isChecked + }.apply(builder) + } + } + } + + data class ListItem(override val key: String) : SettingsItem(key) { + override val id: Int = -1 + + var values: List = emptyList() + var valueTitles: List = emptyList() + + companion object { + fun build( + key: String, + title: UiText? = null, + summary: UiText? = null, + isEnabled: Boolean = true, + values: List, + valueTitles: List, + defaultValue: Int? = null, + selectedIndex: Int? = null, + builder: ListItem.() -> Unit = {} + ): ListItem { + return ListItem(key).apply { + this.title = title + this.summary = summary + this.isEnabled = isEnabled + this.values = values + this.valueTitles = valueTitles + + this.value = defaultValue + ?.let { value -> getValueFromPreferences(key, Int::class.java, value) } + ?: selectedIndex?.let { values[it] } + }.apply(builder) + } + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/settings/model/SettingsListeners.kt b/app/src/main/kotlin/com/meloda/fast/screens/settings/model/SettingsListeners.kt new file mode 100644 index 00000000..70681654 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/settings/model/SettingsListeners.kt @@ -0,0 +1,13 @@ +package com.meloda.fast.screens.settings.model + +fun interface OnSettingsClickListener { + fun onClick(key: String) +} + +fun interface OnSettingsLongClickListener { + fun onLongClick(key: String): Boolean +} + +fun interface OnSettingsChangeListener { + fun onChange(key: String, newValue: Any?) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/testing/TestActivity.kt b/app/src/main/kotlin/com/meloda/fast/screens/testing/TestActivity.kt new file mode 100644 index 00000000..147fabd2 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/testing/TestActivity.kt @@ -0,0 +1,61 @@ +package com.meloda.fast.screens.testing + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.meloda.fast.common.AppGlobal +import com.meloda.fast.ext.edgeToEdge +import com.meloda.fast.screens.settings.SettingsFragment +import com.meloda.fast.ui.AppTheme + +class TestActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + edgeToEdge() + + setContent { + TestingScreen() + } + } + + @Preview + @Composable + fun TestingScreenPreview() { + TestingScreen() + } + + @Composable + fun TestingScreen() { + val useDynamicColors = AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_USE_DYNAMIC_COLORS, + SettingsFragment.DEFAULT_VALUE_USE_DYNAMIC_COLORS + ) + AppTheme(useDynamicColors = useDynamicColors) { + Scaffold(modifier = Modifier.fillMaxSize()) { padding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Column { + Button(onClick = {}) { + Text(text = "Button") + } + Text(text = "Testing text") + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/TwoFaScreens.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/TwoFaScreens.kt new file mode 100644 index 00000000..242c1b77 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/TwoFaScreens.kt @@ -0,0 +1,12 @@ +package com.meloda.fast.screens.twofa + +import com.github.terrakok.cicerone.androidx.FragmentScreen +import com.meloda.fast.screens.twofa.presentation.TwoFaFragment + +object TwoFaScreens { + + fun twoFaScreen() = FragmentScreen(key = "TwoFaScreen") { + TwoFaFragment.newInstance() + } + +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/di/TwoFaDI.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/di/TwoFaDI.kt new file mode 100644 index 00000000..62eec829 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/di/TwoFaDI.kt @@ -0,0 +1,35 @@ +package com.meloda.fast.screens.twofa.di + +import com.meloda.fast.di.navigationModule +import com.meloda.fast.screens.twofa.presentation.TwoFaViewModelImpl +import com.meloda.fast.screens.twofa.screen.TwoFaCoordinator +import com.meloda.fast.screens.twofa.screen.TwoFaCoordinatorImpl +import com.meloda.fast.screens.twofa.screen.TwoFaScreen +import com.meloda.fast.screens.twofa.validation.TwoFaValidator +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.core.module.dsl.singleOf +import org.koin.core.qualifier.named +import org.koin.core.scope.Scope +import org.koin.dsl.bind +import org.koin.dsl.module + +val twoFaModule = module { + val moduleQualifier = named("twoFa") + + includes(navigationModule) + + single(moduleQualifier) { screen().resultFlow } + single { screen().getArguments() } + + single { + TwoFaCoordinatorImpl( + resultFlow = get(moduleQualifier), + router = get() + ) + } bind TwoFaCoordinator::class + + singleOf(::TwoFaValidator) + viewModelOf(::TwoFaViewModelImpl) +} + +private fun Scope.screen(): TwoFaScreen = get() diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaArguments.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaArguments.kt new file mode 100644 index 00000000..f0b71e42 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaArguments.kt @@ -0,0 +1,12 @@ +package com.meloda.fast.screens.twofa.model + +import com.meloda.fast.model.base.UiText + +data class TwoFaArguments( + val validationSid: String, + val redirectUri: String, + val phoneMask: String, + val validationType: TwoFaValidationType, + val canResendSms: Boolean, + val wrongCodeError: UiText?, +) diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaResult.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaResult.kt new file mode 100644 index 00000000..2e7d7c3e --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaResult.kt @@ -0,0 +1,6 @@ +package com.meloda.fast.screens.twofa.model + +sealed class TwoFaResult { + object Cancelled : TwoFaResult() + data class Success(val sid: String, val code: String) : TwoFaResult() +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaScreenState.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaScreenState.kt new file mode 100644 index 00000000..fe9c518c --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaScreenState.kt @@ -0,0 +1,24 @@ +package com.meloda.fast.screens.twofa.model + +import com.meloda.fast.model.base.UiText + +data class TwoFaScreenState( + val twoFaSid: String, + val twoFaCode: String, + val twoFaText: UiText, + val canResendSms: Boolean, + val codeError: UiText?, + val delayTime: Int +) { + + companion object { + val EMPTY = TwoFaScreenState( + twoFaSid = "", + twoFaCode = "", + twoFaText = UiText.Simple(""), + canResendSms = false, + codeError = null, + delayTime = 0 + ) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaValidationResult.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaValidationResult.kt new file mode 100644 index 00000000..188dbc99 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaValidationResult.kt @@ -0,0 +1,8 @@ +package com.meloda.fast.screens.twofa.model + +sealed class TwoFaValidationResult { + object Empty : TwoFaValidationResult() + object Valid : TwoFaValidationResult() + + fun isValid() = this == Valid +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaValidationType.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaValidationType.kt new file mode 100644 index 00000000..0e696efa --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/model/TwoFaValidationType.kt @@ -0,0 +1,24 @@ +package com.meloda.fast.screens.twofa.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +sealed class TwoFaValidationType : Parcelable { + object Sms : TwoFaValidationType() + object TwoFaApp : TwoFaValidationType() + data class Another(val type: String) : TwoFaValidationType() + + companion object { + private const val TYPE_SMS = "sms" + private const val TYPE_TWO_FA_APP = "2fa_app" + + fun parse(validationType: String): TwoFaValidationType { + return when (validationType) { + TYPE_SMS -> Sms + TYPE_TWO_FA_APP -> TwoFaApp + else -> Another(validationType) + } + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/presentation/TwoFaFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/presentation/TwoFaFragment.kt new file mode 100644 index 00000000..8f46f963 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/presentation/TwoFaFragment.kt @@ -0,0 +1,262 @@ +package com.meloda.fast.screens.twofa.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.meloda.fast.R +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.ext.getString +import com.meloda.fast.screens.twofa.model.TwoFaScreenState +import com.meloda.fast.ui.AppTheme +import com.meloda.fast.ui.widgets.TextFieldErrorText +import org.koin.androidx.viewmodel.ext.android.viewModel + +class TwoFaFragment : BaseFragment() { + + private val viewModel: TwoFaViewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + activity?.onBackPressedDispatcher?.addCallback { + viewModel.onBackButtonClicked() + } + + (view as? ComposeView)?.apply { + setContent { + AppTheme { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier + .statusBarsPadding() + .navigationBarsPadding() + .imePadding() + ) { + val state by viewModel.screenState.collectAsStateWithLifecycle() + + TwoFaScreen( + onCodeInputChanged = viewModel::onCodeInputChanged, + onTextFieldDoneClicked = viewModel::onTextFieldDoneClicked, + onRequestSmsButtonClicked = viewModel::onRequestSmsButtonClicked, + onDoneButtonClicked = viewModel::onDoneButtonClicked, + state = state + ) + } + } + } + } + } + + @Preview + @Composable + fun TwoFaScreenPreview() { + AppTheme( + useDarkTheme = false, + useDynamicColors = false + ) { + Surface(color = MaterialTheme.colorScheme.background) { + TwoFaScreen( + onCodeInputChanged = {}, + onTextFieldDoneClicked = {}, + onRequestSmsButtonClicked = {}, + onDoneButtonClicked = {}, + state = TwoFaScreenState.EMPTY + ) + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun TwoFaScreen( + onCodeInputChanged: (String) -> Unit, + onTextFieldDoneClicked: () -> Unit, + onRequestSmsButtonClicked: () -> Unit, + onDoneButtonClicked: () -> Unit, + state: TwoFaScreenState, + ) { + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxSize() + .padding(30.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + ExtendedFloatingActionButton( + onClick = { + activity?.onBackPressedDispatcher?.onBackPressed() + }, + text = { + Text( + text = "Cancel", + color = MaterialTheme.colorScheme.primary + ) + }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_round_close_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + ) + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + + Text( + text = "Two-Factor\nAuthentication", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(38.dp)) + Text( + text = state.twoFaText.getString().orEmpty(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(30.dp)) + + val delayRemainedTime = state.delayTime + AnimatedVisibility(visible = delayRemainedTime > 0) { + Text(text = "Can resend after $delayRemainedTime seconds") + } + + var code by remember { mutableStateOf(TextFieldValue(state.twoFaCode)) } + val codeError = state.codeError + + TextField( + value = code, + onValueChange = { newText -> + code = newText + onCodeInputChanged.invoke(newText.text) + }, + label = { Text(text = "Code") }, + placeholder = { Text(text = "Code") }, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)), + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.round_qr_code_24), + contentDescription = null, + tint = if (codeError != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + }, + shape = RoundedCornerShape(10.dp), + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Number + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + onTextFieldDoneClicked.invoke() + } + ), + isError = codeError != null + ) + + AnimatedVisibility(visible = codeError != null) { + TextFieldErrorText(text = codeError.getString().orEmpty()) + } + } + + // TODO: 09.04.2023, Danil Nikolaev: проверить работоспособность 2фа + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + val canResendSms = state.canResendSms + + AnimatedVisibility( + visible = canResendSms, + ) { + ExtendedFloatingActionButton( + onClick = onRequestSmsButtonClicked, + text = { + Text( + text = "Request SMS", + color = MaterialTheme.colorScheme.onPrimary + ) + }, + icon = { + Icon( + painter = painterResource(id = R.drawable.round_sms_24), + tint = MaterialTheme.colorScheme.onPrimary, + contentDescription = null + ) + }, + containerColor = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + FloatingActionButton( + onClick = onDoneButtonClicked, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_round_done_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + + AnimatedVisibility( + visible = !canResendSms, + exit = shrinkHorizontally() + ) { + Spacer(modifier = Modifier.width(16.dp)) + } + } + } + } + + companion object { + + fun newInstance(): TwoFaFragment { + return TwoFaFragment() + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/presentation/TwoFaViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/presentation/TwoFaViewModel.kt new file mode 100644 index 00000000..eddb0ca5 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/presentation/TwoFaViewModel.kt @@ -0,0 +1,172 @@ +package com.meloda.fast.screens.twofa.presentation + +import androidx.lifecycle.viewModelScope +import com.meloda.fast.base.viewmodel.BaseViewModel +import com.meloda.fast.data.auth.AuthRepository +import com.meloda.fast.ext.createTimerFlow +import com.meloda.fast.ext.isTrue +import com.meloda.fast.ext.updateValue +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.twofa.model.TwoFaArguments +import com.meloda.fast.screens.twofa.model.TwoFaResult +import com.meloda.fast.screens.twofa.model.TwoFaScreenState +import com.meloda.fast.screens.twofa.model.TwoFaValidationType +import com.meloda.fast.screens.twofa.screen.TwoFaCoordinator +import com.meloda.fast.screens.twofa.validation.TwoFaValidator +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch + + +interface TwoFaViewModel { + + val screenState: StateFlow + + fun onCodeInputChanged(newCode: String) + + fun onBackButtonClicked() + fun onCancelButtonClicked() + fun onRequestSmsButtonClicked() + fun onTextFieldDoneClicked() + fun onDoneButtonClicked() +} + +class TwoFaViewModelImpl constructor( + private val coordinator: TwoFaCoordinator, + private val validator: TwoFaValidator, + private val authRepository: AuthRepository, + arguments: TwoFaArguments, +) : TwoFaViewModel, BaseViewModel() { + + override val screenState = MutableStateFlow(TwoFaScreenState.EMPTY) + + private var delayJob: Job? = null + + init { + if (arguments.wrongCodeError != null) { + screenState.updateValue( + screenState.value.copy(codeError = arguments.wrongCodeError) + ) + } + + screenState.updateValue( + screenState.value.copy( + twoFaSid = arguments.validationSid, + twoFaText = getTwoFaText(arguments.validationType), + canResendSms = arguments.canResendSms + ) + ) + } + + override fun onCodeInputChanged(newCode: String) { + screenState.updateValue( + screenState.value.copy( + twoFaCode = newCode.trim(), + codeError = null + ) + ) + + if (newCode.length == 6) { + onDoneButtonClicked() + } + } + + override fun onBackButtonClicked() { + onCancelButtonClicked() + } + + override fun onCancelButtonClicked() { + coordinator.finishWithResult(TwoFaResult.Cancelled) + } + + override fun onRequestSmsButtonClicked() { + sendValidationCode() + } + + override fun onTextFieldDoneClicked() { + onDoneButtonClicked() + } + + override fun onDoneButtonClicked() { + if (!processValidation()) return + + val twoFaSid = screenState.value.twoFaSid + val twoFaCode = screenState.value.twoFaCode + + coordinator.finishWithResult(TwoFaResult.Success(sid = twoFaSid, code = twoFaCode)) + } + + private fun processValidation(): Boolean { + val isValid = validator.validate(screenState.value).isValid() + + screenState.updateValue( + screenState.value.copy( + codeError = if (isValid) null + else UiText.Simple("Field must not be empty") + ) + ) + + return isValid + } + + private fun sendValidationCode() { + val validationSid = screenState.value.twoFaSid + + viewModelScope.launch { + sendRequest { + authRepository.sendSms(validationSid) + }?.let { response -> + val newValidationType = response.validationType + val newCanResendSms = response.validationResend == "sms" + + screenState.updateValue( + screenState.value.copy( + canResendSms = newCanResendSms, + twoFaText = getTwoFaText( + TwoFaValidationType.parse( + newValidationType ?: "null" + ) + ) + ) + ) + + startTickTimer(response.delay) + } + } + } + + private fun startTickTimer(delay: Int?) { + if (delay == null || delayJob?.isActive.isTrue) return + + delayJob = createTimerFlow( + time = delay, + onStartAction = { + screenState.updateValue( + screenState.value.copy(canResendSms = false) + ) + }, + onTickAction = { remainedTime -> + screenState.updateValue( + screenState.value.copy(delayTime = remainedTime) + ) + }, + onTimeoutAction = { + screenState.updateValue( + screenState.value.copy( + canResendSms = true + ) + ) + }, + ).launchIn(viewModelScope) + } + + private fun getTwoFaText(validationType: TwoFaValidationType): UiText { + return when (validationType) { + TwoFaValidationType.Sms -> UiText.Simple("sms") + TwoFaValidationType.TwoFaApp -> UiText.Simple("2fa app") + is TwoFaValidationType.Another -> UiText.Simple(validationType.type) + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/screen/TwoFaCoordinator.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/screen/TwoFaCoordinator.kt new file mode 100644 index 00000000..4cbd4e9f --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/screen/TwoFaCoordinator.kt @@ -0,0 +1,21 @@ +package com.meloda.fast.screens.twofa.screen + +import com.github.terrakok.cicerone.Router +import com.meloda.fast.screens.twofa.model.TwoFaResult +import kotlinx.coroutines.flow.MutableSharedFlow + +interface TwoFaCoordinator { + + fun finishWithResult(result: TwoFaResult) +} + +class TwoFaCoordinatorImpl( + private val resultFlow: MutableSharedFlow, + private val router: Router +) : TwoFaCoordinator { + + override fun finishWithResult(result: TwoFaResult) { + resultFlow.tryEmit(result) + router.exit() + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/screen/TwoFaScreen.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/screen/TwoFaScreen.kt new file mode 100644 index 00000000..8437f6ff --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/screen/TwoFaScreen.kt @@ -0,0 +1,21 @@ +package com.meloda.fast.screens.twofa.screen + +import com.github.terrakok.cicerone.Router +import com.meloda.fast.base.screen.AppScreen +import com.meloda.fast.base.screen.createResultFlow +import com.meloda.fast.screens.twofa.TwoFaScreens +import com.meloda.fast.screens.twofa.model.TwoFaArguments +import com.meloda.fast.screens.twofa.model.TwoFaResult +import kotlin.properties.Delegates + +class TwoFaScreen : AppScreen { + + override val resultFlow = createResultFlow() + + override var args: TwoFaArguments by Delegates.notNull() + + override fun show(router: Router, args: TwoFaArguments) { + this.args = args + router.navigateTo(TwoFaScreens.twoFaScreen()) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/twofa/validation/TwoFaValidator.kt b/app/src/main/kotlin/com/meloda/fast/screens/twofa/validation/TwoFaValidator.kt new file mode 100644 index 00000000..d3b54e28 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/twofa/validation/TwoFaValidator.kt @@ -0,0 +1,14 @@ +package com.meloda.fast.screens.twofa.validation + +import com.meloda.fast.screens.twofa.model.TwoFaScreenState +import com.meloda.fast.screens.twofa.model.TwoFaValidationResult + +class TwoFaValidator { + + fun validate(screenState: TwoFaScreenState): TwoFaValidationResult { + return when { + screenState.twoFaCode.isEmpty() -> TwoFaValidationResult.Empty + else -> TwoFaValidationResult.Valid + } + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt deleted file mode 100644 index 24b7ef02..00000000 --- a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdateState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.meloda.fast.screens.updates - -enum class UpdateState { - NewUpdate, NoUpdates, Loading, Error, Downloading -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt index bda360af..bacb2186 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesFragment.kt @@ -1,45 +1,381 @@ package com.meloda.fast.screens.updates -import android.animation.ObjectAnimator -import android.app.DownloadManager -import android.content.IntentFilter -import android.net.Uri import android.os.Bundle -import android.os.Environment -import android.util.Log +import android.view.LayoutInflater import android.view.View -import android.view.animation.DecelerateInterpolator -import android.viewbinding.library.fragment.viewBinding +import android.view.ViewGroup +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.meloda.fast.R -import com.meloda.fast.base.viewmodel.BaseViewModelFragment -import com.meloda.fast.common.AppConstants -import com.meloda.fast.common.AppGlobal -import com.meloda.fast.common.UpdateManager -import com.meloda.fast.databinding.FragmentUpdatesBinding -import com.meloda.fast.extensions.clear -import com.meloda.fast.extensions.setIfNotEquals -import com.meloda.fast.extensions.toggleVisibility +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.ext.getParcelableCompat +import com.meloda.fast.ext.listenValue +import com.meloda.fast.ext.showDialog +import com.meloda.fast.ext.string import com.meloda.fast.model.UpdateItem -import com.meloda.fast.receiver.DownloadManagerReceiver +import com.meloda.fast.model.base.UiText +import com.meloda.fast.screens.updates.model.UpdateState +import com.meloda.fast.ui.AppTheme import com.meloda.fast.util.AndroidUtils -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import okhttp3.ResponseBody -import java.io.* -import java.util.* +import org.koin.androidx.viewmodel.ext.android.viewModel -@AndroidEntryPoint -class UpdatesFragment : BaseViewModelFragment(R.layout.fragment_updates) { + +class UpdatesFragment : BaseFragment(R.layout.fragment_updates) { + + private val viewModel: UpdatesViewModel by viewModel() + + private val changelogPlaceholder by lazy { + string(R.string.fragment_updates_changelog_none) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + UpdatesScreen() + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun UpdatesScreen() { + AppTheme { + val state by viewModel.screenState.collectAsState() + val updateState = state.updateState + val downloadProgress by viewModel.currentDownloadProgress.collectAsState() + val animatedProgress by animateFloatAsState( + targetValue = downloadProgress / 100f, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec + ) + + Scaffold(topBar = { Toolbar() }) { paddingValues -> + Surface( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + when { + updateState.isLoading() -> CircularProgressIndicator() + updateState.isDownloading() -> { + Text( + text = getString(R.string.fragment_updates_downloading_update), + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(12.dp)) + if (animatedProgress > 0) { + LinearProgressIndicator(progress = animatedProgress) + } else { + LinearProgressIndicator() + } + Spacer(modifier = Modifier.height(12.dp)) + FilledTonalButton(onClick = viewModel::onCancelDownloadButtonClicked) { + Text(text = getString(R.string.action_stop)) + } + } + + else -> { + getTitle(updateState)?.let { title -> + Text( + text = title, + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + getSubtitle(updateState)?.let { subtitle -> + Text( + text = subtitle, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(6.dp)) + } + + state.updateItem?.changelog?.let { + Text( + text = getString(R.string.fragment_updates_changelog), + style = TextStyle(textDecoration = TextDecoration.Underline), + modifier = Modifier.clickable(onClick = viewModel::onChangelogButtonClicked) + ) + } + + getActionButtonText(updateState)?.let { buttonText -> + Spacer(modifier = Modifier.height(24.dp)) + ExtendedFloatingActionButton( + onClick = viewModel::onActionButtonClicked, + modifier = Modifier, + text = { Text(text = buttonText) }, + icon = { + getActionButtonIcon(state = updateState)?.let { painter -> + Spacer(modifier = Modifier.width(4.dp)) + Icon(painter = painter, contentDescription = null) + } + } + ) + } + + if (updateState.isDownloaded()) { + Spacer(modifier = Modifier.height(48.dp)) + Text( + text = getString(R.string.fragment_updates_issues_installing), + style = TextStyle(textDecoration = TextDecoration.Underline), + modifier = Modifier.clickable(onClick = viewModel::onIssuesButtonClicked), + ) + } + } + } + } + } + } + } + } + + private fun getTitle(state: UpdateState): String? { + return when (state) { + UpdateState.Error -> R.string.error_occurred + UpdateState.NewUpdate -> R.string.fragment_updates_new_version + UpdateState.NoUpdates -> R.string.fragment_updates_no_updates + UpdateState.Downloaded -> R.string.fragment_updates_downloaded + else -> null + }?.let(requireContext()::getString) + } + + private fun getSubtitle(state: UpdateState): String? { + return when (state) { + UpdateState.Error -> { + viewModel.screenState.value.error?.let { error -> + if (error.contains("cannot be converted", ignoreCase = true) + || error.contains("begin_object", ignoreCase = true) + ) { + "OTA Server is unavailable" + } else { + string(R.string.error_occurred_description, error) + } + } + } + + UpdateState.NewUpdate, UpdateState.Downloaded -> { + viewModel.screenState.value.updateItem?.let { item -> + string( + R.string.fragment_updates_new_version_description, item.versionName + ) + } + } + + UpdateState.NoUpdates -> string(R.string.fragment_updates_no_updates_description) + else -> null + } + } + + private fun getActionButtonText(state: UpdateState): String? { + return when (state) { + UpdateState.Error -> R.string.fragment_updates_try_again + UpdateState.NewUpdate -> R.string.fragment_updates_download_update + UpdateState.NoUpdates -> R.string.fragment_updates_check_updates + UpdateState.Downloaded -> R.string.fragment_updates_install + else -> null + }?.let(requireContext()::getString) + } + + @Composable + private fun getActionButtonIcon(state: UpdateState): Painter? { + return when (state) { + UpdateState.Error -> R.drawable.round_restart_alt_24 + UpdateState.NewUpdate -> R.drawable.round_file_download_24 + UpdateState.Downloaded -> R.drawable.round_install_mobile_24 + else -> null + }?.let { painterResource(id = it) } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun Toolbar() { + TopAppBar( + title = { Text(text = "Application updates") }, + navigationIcon = { + IconButton( + onClick = { requireActivity().onBackPressedDispatcher.onBackPressed() } + ) { + Icon( + imageVector = Icons.Rounded.ArrowBack, + contentDescription = null, + ) + } + } + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + listenViewModel() + + if (requireArguments().containsKey(ARG_UPDATE_ITEM)) { + val updateItem: UpdateItem = + requireArguments().getParcelableCompat(ARG_UPDATE_ITEM, UpdateItem::class.java) + ?: return + + viewModel.onUpdateItemExists(updateItem) + } else { + viewModel.checkUpdates() + } + } + + private fun listenViewModel() = with(viewModel) { + isNeedToShowChangelogAlert.listenValue(::handleNeedToShowChangelogAlert) + isNeedToShowUnknownSourcesAlert.listenValue(::handleNeedToShowUnknownSourcesAlert) + isNeedToShowIssuesAlert.listenValue(::handleNeedToShowIssuesAlert) + isNeedToShowFileNotFoundAlert.listenValue(::handleNeedToShowFileNotFoundAlert) + } + + private fun handleNeedToShowChangelogAlert(isNeedToShow: Boolean) { + if (isNeedToShow) { + showChangelogAlert() + } + } + + private fun handleNeedToShowUnknownSourcesAlert(isNeedToShow: Boolean) { + if (isNeedToShow) { + showUnknownSourcesAlert() + } + } + + private fun handleNeedToShowIssuesAlert(isNeedToShow: Boolean) { + if (isNeedToShow) { + showIssuesAlert() + } + } + + private fun handleNeedToShowFileNotFoundAlert(isNeedToShow: Boolean) { + if (isNeedToShow) { + showFileNotFoundAlert() + } + } + + private fun showUnknownSourcesAlert() { + context?.showDialog( + title = UiText.Resource(R.string.warning), + message = UiText.Resource(R.string.fragment_updates_unknown_sources_disabled_message), + positiveText = UiText.Resource(R.string.yes), + positiveAction = { AndroidUtils.openInstallUnknownAppsScreen(requireContext()) }, + negativeText = UiText.Resource(R.string.cancel), + onDismissAction = viewModel::onUnknownSourcesAlertDismissed, + isCancelable = false + ) + } + + private fun showChangelogAlert() { + val messageText = + viewModel.screenState.value.updateItem?.changelog?.ifBlank { + changelogPlaceholder + } ?: changelogPlaceholder + + context?.showDialog( + title = UiText.Resource(R.string.fragment_updates_changelog), + message = UiText.Simple(messageText), + positiveText = UiText.Resource(R.string.ok), + onDismissAction = viewModel::onChangelogAlertDismissed + ) + } + + private fun showIssuesAlert() { + context?.showDialog( + message = UiText.Resource(R.string.fragment_updates_issues_description), + positiveText = UiText.Resource(R.string.action_delete), + positiveAction = viewModel::onIssuesAlertPositiveButtonClicked, + negativeText = UiText.Resource(R.string.cancel), + onDismissAction = viewModel::onIssuesAlertDismissed + ) + } + + private fun showFileNotFoundAlert() { + context?.showDialog( + title = UiText.Resource(R.string.warning), + message = UiText.Resource(R.string.fragment_updates_file_not_found_description), + positiveText = UiText.Resource(R.string.ok), + onDismissAction = viewModel::onFileNotFoundAlertDismissed, + isCancelable = false + ) + } + + private fun writeFileToStorage(responseBody: ResponseBody?) { +// if (responseBody == null) return +// +// val updateItem = requireNotNull(viewModel.currentItem.value) +// +// try { +// val destination = requireContext() +// .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + +// "${File.separator}${updateItem.fileName}.${updateItem.extension}" +// +// val file = File(destination) +// if (file.exists()) file.delete() +// +// var inputStream: InputStream? = null +// var outputStream: OutputStream? = null +// try { +// val fileReader = ByteArray(4096) +// val fileSize: Long = responseBody.contentLength() +// +// requireActivity().runOnUiThread { +// binding.loadingProgress.max = fileSize.toInt() +// binding.loadingProgress.progress = 0 +// } +// +// var fileSizeDownloaded: Long = 0 +// inputStream = responseBody.byteStream() +// outputStream = FileOutputStream(file) +// while (true) { +// val read: Int = inputStream.read(fileReader) +// if (read == -1) { +// break +// } +// outputStream.write(fileReader, 0, read) +// fileSizeDownloaded += read.toLong() +// } +// outputStream.flush() +// +// requireActivity().runOnUiThread { +// installUpdate(file) +// } +// } catch (e: IOException) { +// +// } finally { +// inputStream?.close() +// outputStream?.close() +// } +// } catch (e: IOException) { +// +// } + } companion object { private const val ARG_UPDATE_ITEM = "arg_update_item" private const val ARG_FILE_BASE_PATH = "file://" - private const val ARG_PROVIDER_PATH = ".provider" fun newInstance(updateItem: UpdateItem? = null): UpdatesFragment { val fragment = UpdatesFragment() @@ -52,311 +388,4 @@ class UpdatesFragment : BaseViewModelFragment(R.layout.fragmen return fragment } } - - override val viewModel: UpdatesViewModel by viewModels() - - private val binding: FragmentUpdatesBinding by viewBinding() - - private var downloadId: Long? = null - - private var timer: Timer? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - UpdateManager.newUpdate.observe(viewLifecycleOwner) { item -> - viewModel.currentItem.setIfNotEquals(item) - } - - viewModel.updateState.observe(viewLifecycleOwner) { state -> - state?.run { refreshState(this) } - } - - if (requireArguments().containsKey(ARG_UPDATE_ITEM)) { - val updateItem: UpdateItem = requireArguments().getParcelable(ARG_UPDATE_ITEM) ?: return - viewModel.currentItem.setIfNotEquals(updateItem) - viewModel.updateState.setIfNotEquals(UpdateState.NewUpdate) - } else { - viewModel.checkUpdates() - } - - binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() } - - binding.changelog.setOnClickListener { - showChangelogAlert() - } - } - - private fun refreshState(state: UpdateState) { - binding.actionButton.toggleVisibility( - !listOf( - UpdateState.Downloading, - UpdateState.Loading - ).contains(viewModel.updateState.value) - ) - binding.flow.toggleVisibility( - !listOf( - UpdateState.Downloading, - UpdateState.Loading - ).contains(viewModel.updateState.value) - ) - binding.progress.toggleVisibility( - viewModel.updateState.value == UpdateState.Loading - ) - binding.changelog.toggleVisibility( - viewModel.updateState.value == UpdateState.NewUpdate - ) - binding.loadingProgress.toggleVisibility( - viewModel.updateState.value == UpdateState.Downloading - ) - - if (state != UpdateState.Downloading) { - timer?.cancel() - downloadId?.run { AppGlobal.downloadManager.remove(this) } - } - - when (state) { - UpdateState.NewUpdate -> { - val item = viewModel.currentItem.value ?: return - binding.title.setText(R.string.fragment_updates_new_version) - - binding.description.text = getString( - R.string.fragment_updates_new_version_description, - item.versionName - ) - - binding.actionButton.setText(R.string.fragment_updates_download_update) - binding.actionButton.setOnClickListener { checkIsInstallingAllowed(item) } - } - UpdateState.NoUpdates -> { - binding.title.setText(R.string.fragment_updates_no_updates) - binding.description.setText(R.string.fragment_updates_no_updates_description) - - binding.actionButton.setText(R.string.fragment_updates_check_updates) - binding.actionButton.setOnClickListener { viewModel.checkUpdates() } - } - UpdateState.Loading -> { - binding.title.clear() - binding.description.clear() - binding.actionButton.clear() - } - UpdateState.Error -> { - val error = viewModel.currentError.value ?: return - - binding.title.setText(R.string.fragment_updates_error_occurred) - - val errorText = - if (error.contains("cannot be converted", ignoreCase = true) - || error.contains("begin_object", ignoreCase = true) - ) { - "OTA Server is unavailable" - } else { - getString(R.string.fragment_updates_error_occurred_description, error) - } - - binding.description.text = errorText - - binding.actionButton.setText(R.string.fragment_updates_try_again) - binding.actionButton.setOnClickListener { viewModel.checkUpdates() } - } - UpdateState.Downloading -> { - binding.loadingProgress.run { - max = 0 - progress = 0 - isIndeterminate = true - } - } - } - } - - private fun checkIsInstallingAllowed(item: UpdateItem) { - if (!AndroidUtils.isCanInstallUnknownApps(requireContext())) { - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setTitle(R.string.warning) - builder.setMessage(R.string.fragment_updates_unknown_sources_disabled_message) - builder.setPositiveButton(R.string.yes) { _, _ -> - AndroidUtils.openInstallUnknownAppsScreen(requireContext()) - } - builder.setNegativeButton(R.string.no, null) - builder.show() - } else { - downloadUpdate(item) - } - } - - private fun downloadUpdate(newUpdate: UpdateItem) { - viewModel.updateState.setIfNotEquals(UpdateState.Loading) - - timer = Timer() - - val apkName = newUpdate.versionName - - val destination = requireContext() - .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + "/$apkName.apk" - - val file = File(destination) - if (file.exists()) file.delete() - - val request = DownloadManager.Request(Uri.parse(newUpdate.downloadLink)).apply { - setTitle("${getString(R.string.app_name)} ${apkName}.apk") - setMimeType(AppConstants.INSTALL_APP_MIME_TYPE) - setDestinationInExternalFilesDir( - requireContext(), - Environment.DIRECTORY_DOWNLOADS, - "$apkName.apk" - ) - setAllowedNetworkTypes( - DownloadManager.Request.NETWORK_WIFI or - DownloadManager.Request.NETWORK_MOBILE - ) - setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - } - - val receiver = DownloadManagerReceiver() - receiver.onReceiveAction = { - timer?.cancel() - downloadId = null - - installUpdate(file) - - requireContext().unregisterReceiver(receiver) - - viewModel.updateState.setIfNotEquals(UpdateState.NewUpdate) - } - - requireContext().registerReceiver( - receiver, - IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) - ) - - downloadId = AppGlobal.downloadManager.enqueue(request) - - viewModel.updateState.setIfNotEquals(UpdateState.Downloading) - - if (binding.loadingProgress.max != 100 * 100) { - binding.loadingProgress.max = 100 * 100 - } - - timer?.schedule(object : TimerTask() { // for progress - override fun run() { - val query = DownloadManager.Query() - query.setFilterById(downloadId ?: -1) - - val cursor = AppGlobal.downloadManager.query(query) - if (cursor.moveToFirst()) { - val sizeIndex = - cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) - val downloadedIndex = - cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) - val size = cursor.getInt(sizeIndex) - val downloaded = cursor.getInt(downloadedIndex) - - val progress = - if (size != -1) (downloaded * 100.0F / size) else 0.0F - - if (progress.toInt() >= 1) { - lifecycleScope.launch(Dispatchers.Main) { - if (view == null) { - downloadId?.run { AppGlobal.downloadManager.remove(this) } - timer?.cancel() - return@launch - } - binding.loadingProgress.isIndeterminate = false - - if (binding.loadingProgress.progress != progress.toInt()) { - ObjectAnimator.ofInt( - binding.loadingProgress, - "progress", - binding.loadingProgress.progress, - progress.toInt() * 100 - ).apply { - duration = 250 - setAutoCancel(true) - interpolator = DecelerateInterpolator() - }.start() - } - } - } - - Log.d("Downloading update", "progress $progress%") - } - } - - }, 0, 250) - } - - private fun writeFileToStorage(responseBody: ResponseBody?) { - if (responseBody == null) return - - val updateItem = requireNotNull(viewModel.currentItem.value) - - try { - val destination = requireContext() - .getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + - "${File.separator}${updateItem.fileName}.${updateItem.extension}" - - val file = File(destination) - if (file.exists()) file.delete() - - var inputStream: InputStream? = null - var outputStream: OutputStream? = null - try { - val fileReader = ByteArray(4096) - val fileSize: Long = responseBody.contentLength() - - requireActivity().runOnUiThread { - binding.loadingProgress.max = fileSize.toInt() - binding.loadingProgress.progress = 0 - } - - var fileSizeDownloaded: Long = 0 - inputStream = responseBody.byteStream() - outputStream = FileOutputStream(file) - while (true) { - val read: Int = inputStream.read(fileReader) - if (read == -1) { - break - } - outputStream.write(fileReader, 0, read) - fileSizeDownloaded += read.toLong() - } - outputStream.flush() - - requireActivity().runOnUiThread { - installUpdate(file) - } - } catch (e: IOException) { - - } finally { - inputStream?.close() - outputStream?.close() - } - } catch (e: IOException) { - - } - } - - private fun installUpdate(file: File) { - val installIntent = AndroidUtils.getInstallPackageIntent( - requireContext(), - ARG_PROVIDER_PATH, - file - ) - - requireContext().startActivity(installIntent) - } - - private fun showChangelogAlert() { - val changelog = viewModel.currentItem.value?.changelog - - val messageText = - if (changelog.isNullOrBlank()) getString(R.string.fragment_updates_changelog_none) - else changelog - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.fragment_updates_changelog) - .setMessage(messageText) - .setPositiveButton(R.string.ok, null) - .show() - } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt deleted file mode 100644 index 416fa2b3..00000000 --- a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesResourceProvider.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.meloda.fast.screens.updates - -import android.content.Context -import com.meloda.fast.base.ResourceProvider - -class UpdatesResourceProvider(context: Context) : ResourceProvider(context) { - - - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt index 950c48ba..f45e3ba6 100644 --- a/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/UpdatesViewModel.kt @@ -1,50 +1,375 @@ package com.meloda.fast.screens.updates -import androidx.lifecycle.MutableLiveData -import com.meloda.fast.data.ota.OtaApi -import com.meloda.fast.base.viewmodel.BaseViewModel +import android.app.DownloadManager +import android.content.Context +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewModelScope +import com.meloda.fast.R +import com.meloda.fast.base.viewmodel.DeprecatedBaseViewModel +import com.meloda.fast.common.AppConstants +import com.meloda.fast.common.AppGlobal import com.meloda.fast.common.UpdateManager -import com.meloda.fast.extensions.setIfNotEquals +import com.meloda.fast.common.UpdateManagerState +import com.meloda.fast.ext.createTimerFlow +import com.meloda.fast.ext.isSdkAtLeast +import com.meloda.fast.ext.listenValue import com.meloda.fast.model.UpdateItem -import dagger.hilt.android.lifecycle.HiltViewModel +import com.meloda.fast.receiver.DownloadManagerReceiver +import com.meloda.fast.screens.updates.model.UpdateState +import com.meloda.fast.screens.updates.model.UpdatesScreenState +import com.meloda.fast.util.AndroidUtils import kotlinx.coroutines.Job -import javax.inject.Inject +import kotlinx.coroutines.flow.* +import java.io.File +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +interface UpdatesViewModel { + val screenState: MutableStateFlow + val currentDownloadProgress: StateFlow -@HiltViewModel -class UpdatesViewModel @Inject constructor( + val isNeedToShowChangelogAlert: Flow + val isNeedToShowUnknownSourcesAlert: Flow + val isNeedToShowIssuesAlert: Flow + val isNeedToShowFileNotFoundAlert: Flow + + fun onUpdateItemExists(updateItem: UpdateItem) + + fun checkUpdates() + + fun onChangelogButtonClicked() + fun onActionButtonClicked() + fun onCancelDownloadButtonClicked() + fun onIssuesButtonClicked() + + fun onChangelogAlertDismissed() + fun onUnknownSourcesAlertDismissed() + fun onIssuesAlertDismissed() + fun onIssuesAlertPositiveButtonClicked() + fun onFileNotFoundAlertDismissed() +} + +class UpdatesViewModelImpl constructor( private val updateManager: UpdateManager, - private val otaApi: OtaApi -) : BaseViewModel() { +) : DeprecatedBaseViewModel(), UpdatesViewModel { - val updateState = MutableLiveData(UpdateState.Loading) - val currentError = MutableLiveData(null) - val currentItem = MutableLiveData(null) + override val screenState = MutableStateFlow(UpdatesScreenState.EMPTY) + override val currentDownloadProgress = MutableStateFlow(0) + + override val isNeedToShowChangelogAlert = MutableStateFlow(false) + override val isNeedToShowUnknownSourcesAlert = MutableStateFlow(false) + override val isNeedToShowIssuesAlert = MutableStateFlow(false) + override val isNeedToShowFileNotFoundAlert = MutableStateFlow(false) private var currentJob: Job? = null - fun checkUpdates() { + private val downloadManager by lazy { + AppGlobal.Instance.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + } + + init { + updateManager.stateFlow.listenValue(::updateState) + } + + override fun onUpdateItemExists(updateItem: UpdateItem) { + val newForm = screenState.value.copy( + updateItem = updateItem, + updateState = UpdateState.NewUpdate, + error = null + ) + screenState.update { newForm } + } + + override fun checkUpdates() { if (currentJob != null) { currentJob?.cancel() currentJob = null } - updateState.setIfNotEquals(UpdateState.Loading) - currentJob = updateManager.checkUpdates { item, error -> - when { - item != null -> { - currentError.setIfNotEquals(null) - updateState.setIfNotEquals(UpdateState.NewUpdate) - } - error != null -> { - currentError.setIfNotEquals(error.message ?: "") - updateState.setIfNotEquals(UpdateState.Error) - } - else -> { - currentError.setIfNotEquals(null) - updateState.setIfNotEquals(UpdateState.NoUpdates) + updateUpdateState(UpdateState.Loading) + + currentJob = updateManager.checkUpdates().apply { + invokeOnCompletion { currentJob = null } + } + } + + override fun onChangelogButtonClicked() { + isNeedToShowChangelogAlert.tryEmit(true) + } + + override fun onActionButtonClicked() { + val state = screenState.value.updateState + + if (!state.isDownloaded()) { + downloadUpdate() + return + } + + when (state) { + UpdateState.NewUpdate -> checkIsInstallingAllowed() + UpdateState.NoUpdates, UpdateState.Error -> checkUpdates() + UpdateState.Downloaded -> installUpdate() + else -> Unit + } + } + + override fun onCancelDownloadButtonClicked() { + when (screenState.value.updateState) { + UpdateState.Downloading -> cancelCurrentDownload() + else -> Unit + } + } + + override fun onIssuesButtonClicked() { + isNeedToShowIssuesAlert.tryEmit(true) + } + + override fun onChangelogAlertDismissed() { + isNeedToShowChangelogAlert.tryEmit(false) + } + + override fun onUnknownSourcesAlertDismissed() { + isNeedToShowUnknownSourcesAlert.tryEmit(false) + } + + override fun onIssuesAlertDismissed() { + isNeedToShowIssuesAlert.tryEmit(false) + } + + override fun onIssuesAlertPositiveButtonClicked() { + deleteInstalledFile() + checkUpdates() + } + + override fun onFileNotFoundAlertDismissed() { + isNeedToShowFileNotFoundAlert.tryEmit(false) + checkUpdates() + } + + private fun deleteInstalledFile() { + // TODO: 26.03.2023, Danil Nikolaev: use updateItem + val apkName = "bruhLol" + + val apkFileName = "$apkName.apk" + + val destination = "%s/$apkFileName".format( + AppGlobal.Instance.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + ) + + val file = File(destination) + if (!file.exists()) return + file.delete() + } + + private fun updateState(updateManagerState: UpdateManagerState) { + val item = UpdateItem.EMPTY +// updateManagerState.updateItem + val error = updateManagerState.throwable + + var fileExists = false + + if (item != null) { + // TODO: 26.03.2023, Danil Nikolaev: use updateItem + val apkName = "bruhLol" + + val apkFileName = "$apkName.apk" + + val destination = "%s/$apkFileName".format( + AppGlobal.Instance.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + ) + + val file = File(destination) + fileExists = file.exists() + } + + val newUpdateState = when { + item != null -> { + if (fileExists) { + UpdateState.Downloaded + } else { + UpdateState.NewUpdate } } - }.apply { invokeOnCompletion { currentJob = null } } + error != null -> UpdateState.Error + else -> UpdateState.NoUpdates + } + updateUpdateState(newUpdateState) + + val newError = error?.message + + val newState = screenState.value.copy( + updateItem = item, + error = newError + ) + screenState.update { newState } } -} \ No newline at end of file + + private fun checkIsInstallingAllowed() { + if (!isSdkAtLeast(Build.VERSION_CODES.O) && !AndroidUtils.isCanInstallUnknownApps()) { + isNeedToShowUnknownSourcesAlert.update { true } + } else { + downloadUpdate() + } + } + + private var downloadId: Long? = null + + private fun downloadUpdate() { + val context = AppGlobal.Instance + + updateUpdateState(UpdateState.Loading) +// val newUpdate = screenState.value.updateItem ?: return + + // TODO: 26.03.2023, Danil Nikolaev: use updateItem + val apkName = "bruhLol" + + val apkFileName = "$apkName.apk" + + val destination = "%s/$apkFileName".format( + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + ) + + val file = File(destination) + if (file.exists()) { + updateUpdateState(UpdateState.Downloaded) + return + } + + val downloadUri = try { + Uri.parse( + "https://vk.com/doc157582555_635903147?hash=gTEOVno21WCtxX9GclYo8Liloat5V4xt4WB6nSuOMl8&dl=PQvcF2f7jyJDhJzMFOfRzCZXMx0MztmnwzhQYe4Ycdz" + ) + } catch (e: Exception) { + e.printStackTrace() + Uri.EMPTY + } + + val request = DownloadManager.Request(downloadUri).apply { + setTitle("${context.getString(R.string.app_name)} $apkFileName") + setMimeType(AppConstants.INSTALL_APP_MIME_TYPE) + setDestinationInExternalFilesDir( + context, + Environment.DIRECTORY_DOWNLOADS, + apkFileName + ) + setAllowedNetworkTypes( + DownloadManager.Request.NETWORK_WIFI or + DownloadManager.Request.NETWORK_MOBILE + ) + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + } + + val receiver = DownloadManagerReceiver() + receiver.onReceiveAction = { + downloadId = null + + installUpdate(file) + + context.unregisterReceiver(receiver) + } + + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + + downloadId = downloadManager.enqueue(request) + + updateUpdateState(UpdateState.Downloading) + + var isDownloaded = false + + createTimerFlow( + isNeedToEndCondition = { isDownloaded }, + onStartAction = { + Log.d("Downloading update", "downloadUpdate: onStart") + }, + onTickAction = { + val query = DownloadManager.Query() + query.setFilterById(downloadId ?: -1) + + val cursor = downloadManager.query(query) + if (cursor.moveToFirst()) { + val sizeIndex = + cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val downloadedIndex = + cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val size = cursor.getInt(sizeIndex) + val downloaded = cursor.getInt(downloadedIndex) + val progress = if (size != -1) { + downloaded * 100.0F / size + } else { + 0.0F + } + + val intProgress = progress.roundToInt() + if (intProgress >= 1) { + currentDownloadProgress.emit(intProgress) + } + + Log.d("Downloading update", "progress: $progress%") + + if (intProgress >= 100) { + isDownloaded = true + currentDownloadProgress.emit(0) + updateUpdateState(UpdateState.Downloaded) + } + } + }, + onEndAction = {}, + interval = 250.milliseconds + ).launchIn(viewModelScope) + } + + private fun checkDownloadedFileExists(): File? { + // TODO: 26.03.2023, Danil Nikolaev: use updateItem + val apkName = "bruhLol" + + val apkFileName = "$apkName.apk" + + val destination = "%s/$apkFileName".format( + AppGlobal.Instance.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + ) + + val file = File(destination) + return if (file.exists()) file else null + } + + private fun installUpdate(file: File? = null) { + val context = AppGlobal.Instance + val destinationFile = file ?: checkDownloadedFileExists() ?: run { + isNeedToShowFileNotFoundAlert.tryEmit(true) + return + } + + val installIntent = AndroidUtils.getInstallPackageIntent( + context, + ARG_PROVIDER_PATH, + destinationFile + ) + + context.startActivity(installIntent) + } + + private fun updateUpdateState(newState: UpdateState) { + val newForm = screenState.value.copy(updateState = newState) + screenState.update { newForm } + } + + private fun cancelCurrentDownload() { + currentDownloadProgress.tryEmit(0) + downloadId?.run { downloadManager.remove(this) } + checkUpdates() + } + + companion object { + private const val ARG_PROVIDER_PATH = ".provider" + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/di/UpdatesDI.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/di/UpdatesDI.kt new file mode 100644 index 00000000..60634afb --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/di/UpdatesDI.kt @@ -0,0 +1,9 @@ +package com.meloda.fast.screens.updates.di + +import com.meloda.fast.screens.updates.UpdatesViewModelImpl +import org.koin.androidx.viewmodel.dsl.viewModelOf +import org.koin.dsl.module + +val updatesModule = module { + viewModelOf(::UpdatesViewModelImpl) +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/model/UpdateState.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/model/UpdateState.kt new file mode 100644 index 00000000..8ea8b863 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/model/UpdateState.kt @@ -0,0 +1,15 @@ +package com.meloda.fast.screens.updates.model + +sealed class UpdateState { + object NewUpdate : UpdateState() + object NoUpdates : UpdateState() + object Loading : UpdateState() + object Error : UpdateState() + object Downloading : UpdateState() + object Downloaded : UpdateState() + + fun isNewUpdate() = this == NewUpdate + fun isLoading() = this == Loading + fun isDownloading() = this == Downloading + fun isDownloaded() = this == Downloaded +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/updates/model/UpdatesScreenState.kt b/app/src/main/kotlin/com/meloda/fast/screens/updates/model/UpdatesScreenState.kt new file mode 100644 index 00000000..c99b0dcb --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/updates/model/UpdatesScreenState.kt @@ -0,0 +1,22 @@ +package com.meloda.fast.screens.updates.model + +import com.meloda.fast.model.UpdateItem + +data class UpdatesScreenState( + val updateItem: UpdateItem?, + val updateState: UpdateState, + val error: String?, + val currentProgress: Float?, + val isProgressIntermediate: Boolean, +) { + + companion object { + val EMPTY = UpdatesScreenState( + updateItem = null, + updateState = UpdateState.NoUpdates, + error = null, + currentProgress = null, + isProgressIntermediate = true + ) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/screens/userbanned/UserBannedFragment.kt b/app/src/main/kotlin/com/meloda/fast/screens/userbanned/UserBannedFragment.kt new file mode 100644 index 00000000..49930a03 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/screens/userbanned/UserBannedFragment.kt @@ -0,0 +1,55 @@ +package com.meloda.fast.screens.userbanned + +import android.os.Bundle +import android.view.View +import androidx.core.os.bundleOf +import by.kirich1409.viewbindingdelegate.viewBinding +import com.meloda.fast.R +import com.meloda.fast.base.BaseFragment +import com.meloda.fast.databinding.FragmentUserBannedBinding +import dev.chrisbanes.insetter.applyInsetter + +class UserBannedFragment : BaseFragment(R.layout.fragment_user_banned) { + + companion object { + + private const val ArgMemberName = "member_name" + private const val ArgMessage = "message" + private const val ArgRestoreUrl = "restore_url" + private const val ArgAccessToken = "access_token" + + fun newInstance( + memberName: String, + message: String, + restoreUrl: String, + accessToken: String + ): UserBannedFragment { + val fragment = UserBannedFragment() + fragment.arguments = bundleOf( + ArgMemberName to memberName, + ArgMessage to message, + ArgRestoreUrl to restoreUrl, + ArgAccessToken to accessToken + ) + return fragment + } + } + + private val binding by viewBinding(FragmentUserBannedBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.root.applyInsetter { + type(navigationBars = true) { padding() } + } + + binding.toolbar.applyInsetter { + type(statusBars = true) { padding() } + } + binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } + + binding.name.text = requireArguments().getString(ArgMemberName) + binding.reason.text = requireArguments().getString(ArgMessage) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/service/LongPollQSTileService.kt b/app/src/main/kotlin/com/meloda/fast/service/LongPollQSTileService.kt new file mode 100644 index 00000000..bb033821 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/service/LongPollQSTileService.kt @@ -0,0 +1,43 @@ +package com.meloda.fast.service + +import android.content.Intent +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import android.util.Log +import com.meloda.fast.screens.main.activity.MainActivity + +class LongPollQSTileService : TileService() { + + override fun onTileAdded() { + Log.d("LongPollQSTileService", "onTileAdded") + super.onTileAdded() + } + + override fun onStartListening() { + qsTile.state = Tile.STATE_ACTIVE + qsTile.updateTile() + Log.d("LongPollQSTileService", "onStartListening") + super.onStartListening() + } + + + override fun onStopListening() { + Log.d("LongPollQSTileService", "onStopListening") + super.onStopListening() + } + + override fun onClick() { + Log.d("LongPollQSTileService", "onClick") + + startActivityAndCollapse(Intent(this, MainActivity::class.java).apply { + putExtra("data", "open_settings") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + super.onClick() + } + + override fun onTileRemoved() { + Log.d("LongPollQSTileService", "onTileRemoved") + super.onTileRemoved() + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt b/app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt index d38c230a..26fb7c0b 100644 --- a/app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt +++ b/app/src/main/kotlin/com/meloda/fast/service/LongPollService.kt @@ -1,13 +1,17 @@ package com.meloda.fast.service +import android.app.Notification +import android.app.PendingIntent import android.app.Service import android.content.Intent import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.content.edit import com.google.gson.JsonArray import com.google.gson.JsonObject +import com.meloda.fast.R import com.meloda.fast.api.VKConstants import com.meloda.fast.api.base.ApiError import com.meloda.fast.api.longpoll.LongPollUpdatesParser @@ -16,21 +20,28 @@ import com.meloda.fast.api.network.ApiAnswer import com.meloda.fast.api.network.longpoll.LongPollGetUpdatesRequest import com.meloda.fast.api.network.messages.MessagesGetLongPollServerRequest import com.meloda.fast.common.AppGlobal -import com.meloda.fast.data.longpoll.LongPollApi import com.meloda.fast.data.messages.MessagesRepository +import com.meloda.fast.ext.isTrue +import com.meloda.fast.receiver.StopLongPollServiceReceiver +import com.meloda.fast.screens.settings.SettingsFragment import com.meloda.fast.util.NotificationsUtils -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.* -import javax.inject.Inject +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import kotlin.coroutines.CoroutineContext -@AndroidEntryPoint -class LongPollService : Service(), CoroutineScope { +class LongPollService : Service() { companion object { const val TAG = "LongPollTask" const val KeyLongPollWasDestroyed = "long_poll_was_destroyed" + + private const val NOTIFICATION_ID = 1001 } private val job = SupervisorJob() @@ -40,41 +51,97 @@ class LongPollService : Service(), CoroutineScope { throwable.printStackTrace() } - override val coroutineContext: CoroutineContext - get() = Dispatchers.Default + job + exceptionHandler + private val coroutineContext: CoroutineContext + get() = Dispatchers.IO + job + exceptionHandler - @Inject - lateinit var repository: MessagesRepository + private val coroutineScope = CoroutineScope(coroutineContext) - @Inject - lateinit var longPollApi: LongPollApi + private val repository: MessagesRepository by inject() - @Inject - lateinit var updatesParser: LongPollUpdatesParser + private val updatesParser: LongPollUpdatesParser by inject() + + private var asForeground = true + private var foregroundNotification: Notification? = null + + override fun onCreate() { + super.onCreate() + + if (AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_FEATURES_LONG_POLL_IN_BACKGROUND, + SettingsFragment.DEFAULT_VALUE_FEATURES_LONG_POLL_IN_BACKGROUND + ) + ) { + val notificationBuilder = + NotificationsUtils.createNotification( + context = this, + title = "LongPoll", + contentText = "обновление ваших сообщений в фоне", + notRemovable = false, + channelId = "long_polling", + priority = NotificationsUtils.NotificationPriority.Low, + category = NotificationCompat.CATEGORY_SERVICE, + customNotificationId = NOTIFICATION_ID + ) + + foregroundNotification = notificationBuilder.build() + startForeground(NOTIFICATION_ID, foregroundNotification) + } + } override fun onBind(p0: Intent?): IBinder? { return null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d("LongPollService", "onStartCommand: flags: $flags; startId: $startId") - launch { startPolling().join() } + asForeground = intent?.getBooleanExtra("foreground", false).isTrue - val notificationBuilder = - NotificationsUtils.createNotification( - context = this, - title = "Сервис анального зондирования", - contentText = "ищем нюдесы в ваших сообщениях", - notRemovable = true, - channelId = "long_polling", - priority = NotificationsUtils.NotificationPriority.Min, - category = NotificationCompat.CATEGORY_SERVICE - ) - - startForeground( - startId, - notificationBuilder.build() + Log.d( + "LongPollService", + "onStartCommand: asForeground: $asForeground; flags: $flags; startId: $startId" ) + + coroutineScope.launch { startPolling().join() } + + val stopIntent = Intent(this, StopLongPollServiceReceiver::class.java).apply { + action = StopLongPollServiceReceiver.ACTION_STOP + putExtra(StopLongPollServiceReceiver.NOTIFICATION_ID, startId) + } + val stopPendingIntent = PendingIntent.getBroadcast( + this, + 1, + stopIntent, + PendingIntent.FLAG_IMMUTABLE + ) + + val action = NotificationCompat.Action( + R.drawable.ic_round_close_24, + getString(R.string.action_stop), + stopPendingIntent + ) + + if (asForeground) { + val notificationBuilder = + NotificationsUtils.createNotification( + context = this, + title = "LongPoll", + contentText = "обновление ваших сообщений в фоне", + notRemovable = false, + channelId = "long_polling", + priority = NotificationsUtils.NotificationPriority.Low, + category = NotificationCompat.CATEGORY_SERVICE, + actions = listOf(action), + customNotificationId = NOTIFICATION_ID + ) + + foregroundNotification = notificationBuilder.build() + + startForeground(NOTIFICATION_ID, foregroundNotification) + } else { + if (foregroundNotification != null) { + NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID) + foregroundNotification = null + } + } return START_STICKY } @@ -86,7 +153,7 @@ class LongPollService : Service(), CoroutineScope { Log.d("LongPollService", "job started") - return launch { + return coroutineScope.launch { var serverInfo = getServerInfo() ?: throw ApiError(errorMessage = "bad VK response (server info)") @@ -114,6 +181,7 @@ class LongPollService : Service(), CoroutineScope { lastUpdatesResponse = getUpdatesResponse(serverInfo.copy(ts = newTs)) } + 2, 3 -> { serverInfo = getServerInfo() ?: throw ApiError( @@ -121,6 +189,7 @@ class LongPollService : Service(), CoroutineScope { ) lastUpdatesResponse = getUpdatesResponse(serverInfo) } + else -> { val newTs = lastUpdatesResponse["ts"]?.asInt @@ -181,8 +250,6 @@ class LongPollService : Service(), CoroutineScope { println("$TAG: lastUpdateResponse: $response") - if (response is ApiAnswer.Error) return null - if (response is ApiAnswer.Success) { return response.data } @@ -211,4 +278,4 @@ class LongPollService : Service(), CoroutineScope { Log.d("LongPollService", "onLowMemory") super.onLowMemory() } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/service/MyCustomControlService.kt b/app/src/main/kotlin/com/meloda/fast/service/MyCustomControlService.kt new file mode 100644 index 00000000..9e20b4e0 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/service/MyCustomControlService.kt @@ -0,0 +1,59 @@ +@file:RequiresApi(Build.VERSION_CODES.R) + +package com.meloda.fast.service + +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.provider.AlarmClock.EXTRA_MESSAGE +import android.service.controls.Control +import android.service.controls.ControlsProviderService +import android.service.controls.DeviceTypes +import android.service.controls.actions.ControlAction +import androidx.annotation.RequiresApi +import com.meloda.fast.screens.main.activity.MainActivity +import kotlinx.coroutines.jdk9.flowPublish +import java.util.concurrent.Flow +import java.util.function.Consumer + +private const val LIGHT_ID = 1234 +private const val LIGHT_TITLE = "Enable Long Polling" +private const val LIGHT_TYPE = DeviceTypes.TYPE_DOOR + + +class MyCustomControlService : ControlsProviderService() { + + override fun createPublisherForAllAvailable() = + flowPublish { + send(createStatelessControl(LIGHT_ID, LIGHT_TITLE, LIGHT_TYPE)) + } + + private fun createStatelessControl(id: Int, title: String, type: Int): Control { + val intent = Intent(this, MainActivity::class.java) + .putExtra(EXTRA_MESSAGE, title) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val action = PendingIntent.getActivity( + this, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return Control.StatelessBuilder(id.toString(), action) + .setTitle(title) + .setDeviceType(type) + .build() + } + + override fun createPublisherFor(controlIds: MutableList): Flow.Publisher { + TODO("Not yet implemented") + } + + override fun performControlAction( + controlId: String, + action: ControlAction, + consumer: Consumer + ) { + + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt b/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt index a0ada619..29a2580e 100644 --- a/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt +++ b/app/src/main/kotlin/com/meloda/fast/service/OnlineService.kt @@ -9,15 +9,19 @@ import com.meloda.fast.api.network.account.AccountSetOfflineRequest import com.meloda.fast.api.network.account.AccountSetOnlineRequest import com.meloda.fast.common.AppGlobal import com.meloda.fast.data.account.AccountsRepository -import com.meloda.fast.screens.settings.SettingsPrefsFragment -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.* -import java.util.* -import javax.inject.Inject +import com.meloda.fast.screens.settings.SettingsFragment +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import java.util.Timer import kotlin.concurrent.schedule import kotlin.coroutines.CoroutineContext -@AndroidEntryPoint class OnlineService : Service(), CoroutineScope { private val job = SupervisorJob() @@ -30,58 +34,57 @@ class OnlineService : Service(), CoroutineScope { override val coroutineContext: CoroutineContext get() = Dispatchers.Default + job + exceptionHandler - @Inject - lateinit var repository: AccountsRepository + private val repository: AccountsRepository by inject() private var timer: Timer? = null + private var currentJob: Job? = null + override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d("OnlineService", "onStartCommand: flags: $flags; startId: $startId") - createTimer() + + if (AppGlobal.preferences.getBoolean( + SettingsFragment.KEY_VISIBILITY_SEND_ONLINE_STATUS, true + ) + ) { + createTimer() + } return START_STICKY_COMPATIBILITY } private fun createTimer() { timer = Timer().apply { - schedule(delay = 0, period = 60 * 1000L) { - launch { performJob() } + schedule(delay = 0, period = 300 * 1000L) { + setOnline() } } } - private suspend fun performJob() { - if (!AppGlobal.preferences.getBoolean(SettingsPrefsFragment.PrefSendOnlineStatus, true)) { - return - } + private fun setOnline() { + if (currentJob != null) return - setOffline() - delay(5000) - setOnline() - } + currentJob = launch { + Log.d("OnlineService", "setOnline()") - private suspend fun setOnline() { - Log.d("OnlineService", "setOnline()") + val token = UserConfig.fastToken ?: UserConfig.accessToken - val fastToken = UserConfig.fastToken - - val token = - if (fastToken == null) { - Log.d("OnlineService", "setOnline: Fast token is null. Using VK token") - UserConfig.accessToken - } else { - fastToken + if (token.isBlank()) { + Log.d("OnlineService", "setOnline: token is empty") + return@launch } - val response = repository.setOnline( - AccountSetOnlineRequest( - voip = false, - accessToken = token + val response = repository.setOnline( + AccountSetOnlineRequest( + voip = false, + accessToken = token + ) ) - ) - Log.d("OnlineService", "setOnline: response: $response") + Log.d("OnlineService", "setOnline: response: $response") + currentJob = null + } } private suspend fun setOffline() { @@ -96,7 +99,8 @@ class OnlineService : Service(), CoroutineScope { override fun onDestroy() { super.onDestroy() + timer?.cancel() + currentJob?.cancel("OnlineService destroyed") Log.d("OnlineService", "onDestroy") } - -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/ui/AppTheme.kt b/app/src/main/kotlin/com/meloda/fast/ui/AppTheme.kt new file mode 100644 index 00000000..a9f2282d --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ui/AppTheme.kt @@ -0,0 +1,93 @@ +package com.meloda.fast.ui + +import android.os.Build +import androidx.compose.material3.ColorScheme +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.ui.platform.LocalContext +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 com.meloda.fast.R +import com.meloda.fast.ext.isSystemUsingDarkMode +import com.meloda.fast.ext.isUsingDarkTheme +import com.meloda.fast.ext.isUsingDynamicColors + + +val StandardColorScheme + get() = if (isSystemUsingDarkMode()) DarkColorScheme + else LightColorScheme + +private val LightColorScheme = lightColorScheme() +private val DarkColorScheme = darkColorScheme() + +@Composable +fun dynamicColorScheme(): ColorScheme { + val context = LocalContext.current + return if (isSystemUsingDarkMode()) dynamicDarkColorScheme(context) + else dynamicLightColorScheme(context) +} + +private val googleSansFonts = FontFamily( + Font(R.font.google_sans_regular), + Font(R.font.google_sans_italic, style = FontStyle.Italic), + Font(R.font.google_sans_medium, weight = FontWeight.Medium), + Font( + R.font.google_sans_medium_italic, + weight = FontWeight.Medium, + style = FontStyle.Italic + ), + Font(R.font.google_sans_bold, weight = FontWeight.Bold), + Font( + R.font.google_sans_bold_italic, + weight = FontWeight.Bold, + style = FontStyle.Italic + ), +) + +private val robotoFonts = FontFamily( + Font(R.font.roboto_regular), + // TODO: 27.03.2023, Danil Nikolaev: add all roboto fonts +) + +@Composable +fun AppTheme( + predefinedColorScheme: ColorScheme? = null, + useDarkTheme: Boolean = isUsingDarkTheme(), + useDynamicColors: Boolean = isUsingDynamicColors(), + content: @Composable () -> Unit +) { + val colorScheme: ColorScheme = when { + useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (useDarkTheme) dynamicDarkColorScheme(context) + else dynamicLightColorScheme(context) + } + + useDarkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val typography = MaterialTheme.typography.copy( + 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), + bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = robotoFonts), + bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = robotoFonts), + bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = robotoFonts) + ) + + MaterialTheme( + colorScheme = predefinedColorScheme ?: colorScheme, + typography = typography, + content = content + ) +} diff --git a/app/src/main/kotlin/com/meloda/fast/ui/BlueColorScheme.kt b/app/src/main/kotlin/com/meloda/fast/ui/BlueColorScheme.kt new file mode 100644 index 00000000..12f27494 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ui/BlueColorScheme.kt @@ -0,0 +1,74 @@ +package com.meloda.fast.ui + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import com.meloda.fast.ext.isSystemUsingDarkMode +import com.meloda.fast.ui.colors.Blue + +val BlueColorScheme + get() = if (isSystemUsingDarkMode()) BlueDarkColorScheme + else BlueLightColorScheme + +val BlueLightColorScheme = lightColorScheme( + primary = Blue.md_theme_light_primary, + onPrimary = Blue.md_theme_light_onPrimary, + primaryContainer = Blue.md_theme_light_primaryContainer, + onPrimaryContainer = Blue.md_theme_light_onPrimaryContainer, + secondary = Blue.md_theme_light_secondary, + onSecondary = Blue.md_theme_light_onSecondary, + secondaryContainer = Blue.md_theme_light_secondaryContainer, + onSecondaryContainer = Blue.md_theme_light_onSecondaryContainer, + tertiary = Blue.md_theme_light_tertiary, + onTertiary = Blue.md_theme_light_onTertiary, + tertiaryContainer = Blue.md_theme_light_tertiaryContainer, + onTertiaryContainer = Blue.md_theme_light_onTertiaryContainer, + error = Blue.md_theme_light_error, + errorContainer = Blue.md_theme_light_errorContainer, + onError = Blue.md_theme_light_onError, + onErrorContainer = Blue.md_theme_light_onErrorContainer, + background = Blue.md_theme_light_background, + onBackground = Blue.md_theme_light_onBackground, + surface = Blue.md_theme_light_surface, + onSurface = Blue.md_theme_light_onSurface, + surfaceVariant = Blue.md_theme_light_surfaceVariant, + onSurfaceVariant = Blue.md_theme_light_onSurfaceVariant, + outline = Blue.md_theme_light_outline, + inverseOnSurface = Blue.md_theme_light_inverseOnSurface, + inverseSurface = Blue.md_theme_light_inverseSurface, + inversePrimary = Blue.md_theme_light_inversePrimary, + surfaceTint = Blue.md_theme_light_surfaceTint, + outlineVariant = Blue.md_theme_light_outlineVariant, + scrim = Blue.md_theme_light_scrim, +) + +val BlueDarkColorScheme = darkColorScheme( + primary = Blue.md_theme_dark_primary, + onPrimary = Blue.md_theme_dark_onPrimary, + primaryContainer = Blue.md_theme_dark_primaryContainer, + onPrimaryContainer = Blue.md_theme_dark_onPrimaryContainer, + secondary = Blue.md_theme_dark_secondary, + onSecondary = Blue.md_theme_dark_onSecondary, + secondaryContainer = Blue.md_theme_dark_secondaryContainer, + onSecondaryContainer = Blue.md_theme_dark_onSecondaryContainer, + tertiary = Blue.md_theme_dark_tertiary, + onTertiary = Blue.md_theme_dark_onTertiary, + tertiaryContainer = Blue.md_theme_dark_tertiaryContainer, + onTertiaryContainer = Blue.md_theme_dark_onTertiaryContainer, + error = Blue.md_theme_dark_error, + errorContainer = Blue.md_theme_dark_errorContainer, + onError = Blue.md_theme_dark_onError, + onErrorContainer = Blue.md_theme_dark_onErrorContainer, + background = Blue.md_theme_dark_background, + onBackground = Blue.md_theme_dark_onBackground, + surface = Blue.md_theme_dark_surface, + onSurface = Blue.md_theme_dark_onSurface, + surfaceVariant = Blue.md_theme_dark_surfaceVariant, + onSurfaceVariant = Blue.md_theme_dark_onSurfaceVariant, + outline = Blue.md_theme_dark_outline, + inverseOnSurface = Blue.md_theme_dark_inverseOnSurface, + inverseSurface = Blue.md_theme_dark_inverseSurface, + inversePrimary = Blue.md_theme_dark_inversePrimary, + surfaceTint = Blue.md_theme_dark_surfaceTint, + outlineVariant = Blue.md_theme_dark_outlineVariant, + scrim = Blue.md_theme_dark_scrim, +) diff --git a/app/src/main/kotlin/com/meloda/fast/ui/GreenColorScheme.kt b/app/src/main/kotlin/com/meloda/fast/ui/GreenColorScheme.kt new file mode 100644 index 00000000..79e9dc77 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ui/GreenColorScheme.kt @@ -0,0 +1,74 @@ +package com.meloda.fast.ui + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import com.meloda.fast.ext.isSystemUsingDarkMode +import com.meloda.fast.ui.colors.Green + +val GreenColorScheme + get() = if (isSystemUsingDarkMode()) GreenDarkColorScheme + else GreenLightColorScheme + +val GreenLightColorScheme = lightColorScheme( + primary = Green.md_theme_light_primary, + onPrimary = Green.md_theme_light_onPrimary, + primaryContainer = Green.md_theme_light_primaryContainer, + onPrimaryContainer = Green.md_theme_light_onPrimaryContainer, + secondary = Green.md_theme_light_secondary, + onSecondary = Green.md_theme_light_onSecondary, + secondaryContainer = Green.md_theme_light_secondaryContainer, + onSecondaryContainer = Green.md_theme_light_onSecondaryContainer, + tertiary = Green.md_theme_light_tertiary, + onTertiary = Green.md_theme_light_onTertiary, + tertiaryContainer = Green.md_theme_light_tertiaryContainer, + onTertiaryContainer = Green.md_theme_light_onTertiaryContainer, + error = Green.md_theme_light_error, + errorContainer = Green.md_theme_light_errorContainer, + onError = Green.md_theme_light_onError, + onErrorContainer = Green.md_theme_light_onErrorContainer, + background = Green.md_theme_light_background, + onBackground = Green.md_theme_light_onBackground, + surface = Green.md_theme_light_surface, + onSurface = Green.md_theme_light_onSurface, + surfaceVariant = Green.md_theme_light_surfaceVariant, + onSurfaceVariant = Green.md_theme_light_onSurfaceVariant, + outline = Green.md_theme_light_outline, + inverseOnSurface = Green.md_theme_light_inverseOnSurface, + inverseSurface = Green.md_theme_light_inverseSurface, + inversePrimary = Green.md_theme_light_inversePrimary, + surfaceTint = Green.md_theme_light_surfaceTint, + outlineVariant = Green.md_theme_light_outlineVariant, + scrim = Green.md_theme_light_scrim, +) + +val GreenDarkColorScheme = darkColorScheme( + primary = Green.md_theme_dark_primary, + onPrimary = Green.md_theme_dark_onPrimary, + primaryContainer = Green.md_theme_dark_primaryContainer, + onPrimaryContainer = Green.md_theme_dark_onPrimaryContainer, + secondary = Green.md_theme_dark_secondary, + onSecondary = Green.md_theme_dark_onSecondary, + secondaryContainer = Green.md_theme_dark_secondaryContainer, + onSecondaryContainer = Green.md_theme_dark_onSecondaryContainer, + tertiary = Green.md_theme_dark_tertiary, + onTertiary = Green.md_theme_dark_onTertiary, + tertiaryContainer = Green.md_theme_dark_tertiaryContainer, + onTertiaryContainer = Green.md_theme_dark_onTertiaryContainer, + error = Green.md_theme_dark_error, + errorContainer = Green.md_theme_dark_errorContainer, + onError = Green.md_theme_dark_onError, + onErrorContainer = Green.md_theme_dark_onErrorContainer, + background = Green.md_theme_dark_background, + onBackground = Green.md_theme_dark_onBackground, + surface = Green.md_theme_dark_surface, + onSurface = Green.md_theme_dark_onSurface, + surfaceVariant = Green.md_theme_dark_surfaceVariant, + onSurfaceVariant = Green.md_theme_dark_onSurfaceVariant, + outline = Green.md_theme_dark_outline, + inverseOnSurface = Green.md_theme_dark_inverseOnSurface, + inverseSurface = Green.md_theme_dark_inverseSurface, + inversePrimary = Green.md_theme_dark_inversePrimary, + surfaceTint = Green.md_theme_dark_surfaceTint, + outlineVariant = Green.md_theme_dark_outlineVariant, + scrim = Green.md_theme_dark_scrim, +) diff --git a/app/src/main/kotlin/com/meloda/fast/ui/RedColorScheme.kt b/app/src/main/kotlin/com/meloda/fast/ui/RedColorScheme.kt new file mode 100644 index 00000000..f270a59f --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ui/RedColorScheme.kt @@ -0,0 +1,74 @@ +package com.meloda.fast.ui + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import com.meloda.fast.ext.isSystemUsingDarkMode +import com.meloda.fast.ui.colors.Red + +val RedColorScheme + get() = if (isSystemUsingDarkMode()) RedDarkColorScheme + else RedLightColorScheme + +val RedLightColorScheme = lightColorScheme( + primary = Red.md_theme_light_primary, + onPrimary = Red.md_theme_light_onPrimary, + primaryContainer = Red.md_theme_light_primaryContainer, + onPrimaryContainer = Red.md_theme_light_onPrimaryContainer, + secondary = Red.md_theme_light_secondary, + onSecondary = Red.md_theme_light_onSecondary, + secondaryContainer = Red.md_theme_light_secondaryContainer, + onSecondaryContainer = Red.md_theme_light_onSecondaryContainer, + tertiary = Red.md_theme_light_tertiary, + onTertiary = Red.md_theme_light_onTertiary, + tertiaryContainer = Red.md_theme_light_tertiaryContainer, + onTertiaryContainer = Red.md_theme_light_onTertiaryContainer, + error = Red.md_theme_light_error, + errorContainer = Red.md_theme_light_errorContainer, + onError = Red.md_theme_light_onError, + onErrorContainer = Red.md_theme_light_onErrorContainer, + background = Red.md_theme_light_background, + onBackground = Red.md_theme_light_onBackground, + surface = Red.md_theme_light_surface, + onSurface = Red.md_theme_light_onSurface, + surfaceVariant = Red.md_theme_light_surfaceVariant, + onSurfaceVariant = Red.md_theme_light_onSurfaceVariant, + outline = Red.md_theme_light_outline, + inverseOnSurface = Red.md_theme_light_inverseOnSurface, + inverseSurface = Red.md_theme_light_inverseSurface, + inversePrimary = Red.md_theme_light_inversePrimary, + surfaceTint = Red.md_theme_light_surfaceTint, + outlineVariant = Red.md_theme_light_outlineVariant, + scrim = Red.md_theme_light_scrim, +) + +val RedDarkColorScheme = darkColorScheme( + primary = Red.md_theme_dark_primary, + onPrimary = Red.md_theme_dark_onPrimary, + primaryContainer = Red.md_theme_dark_primaryContainer, + onPrimaryContainer = Red.md_theme_dark_onPrimaryContainer, + secondary = Red.md_theme_dark_secondary, + onSecondary = Red.md_theme_dark_onSecondary, + secondaryContainer = Red.md_theme_dark_secondaryContainer, + onSecondaryContainer = Red.md_theme_dark_onSecondaryContainer, + tertiary = Red.md_theme_dark_tertiary, + onTertiary = Red.md_theme_dark_onTertiary, + tertiaryContainer = Red.md_theme_dark_tertiaryContainer, + onTertiaryContainer = Red.md_theme_dark_onTertiaryContainer, + error = Red.md_theme_dark_error, + errorContainer = Red.md_theme_dark_errorContainer, + onError = Red.md_theme_dark_onError, + onErrorContainer = Red.md_theme_dark_onErrorContainer, + background = Red.md_theme_dark_background, + onBackground = Red.md_theme_dark_onBackground, + surface = Red.md_theme_dark_surface, + onSurface = Red.md_theme_dark_onSurface, + surfaceVariant = Red.md_theme_dark_surfaceVariant, + onSurfaceVariant = Red.md_theme_dark_onSurfaceVariant, + outline = Red.md_theme_dark_outline, + inverseOnSurface = Red.md_theme_dark_inverseOnSurface, + inverseSurface = Red.md_theme_dark_inverseSurface, + inversePrimary = Red.md_theme_dark_inversePrimary, + surfaceTint = Red.md_theme_dark_surfaceTint, + outlineVariant = Red.md_theme_dark_outlineVariant, + scrim = Red.md_theme_dark_scrim, +) diff --git a/app/src/main/kotlin/com/meloda/fast/ui/colors/Blue.kt b/app/src/main/kotlin/com/meloda/fast/ui/colors/Blue.kt new file mode 100644 index 00000000..6d37c3e8 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ui/colors/Blue.kt @@ -0,0 +1,71 @@ +package com.meloda.fast.ui.colors + +import androidx.compose.ui.graphics.Color + +object Blue { + val md_theme_light_primary = Color(0xFF1059C6) + val md_theme_light_onPrimary = Color(0xFFFFFFFF) + val md_theme_light_primaryContainer = Color(0xFFD9E2FF) + val md_theme_light_onPrimaryContainer = Color(0xFF001945) + val md_theme_light_secondary = Color(0xFF6F4DA0) + val md_theme_light_onSecondary = Color(0xFFFFFFFF) + val md_theme_light_secondaryContainer = Color(0xFFEDDCFF) + val md_theme_light_onSecondaryContainer = Color(0xFF280056) + val md_theme_light_tertiary = Color(0xFF725572) + val md_theme_light_onTertiary = Color(0xFFFFFFFF) + val md_theme_light_tertiaryContainer = Color(0xFFFDD7FA) + val md_theme_light_onTertiaryContainer = Color(0xFF2A132C) + val md_theme_light_error = Color(0xFFBA1A1A) + val md_theme_light_errorContainer = Color(0xFFFFDAD6) + val md_theme_light_onError = Color(0xFFFFFFFF) + val md_theme_light_onErrorContainer = Color(0xFF410002) + val md_theme_light_background = Color(0xFFFEFBFF) + val md_theme_light_onBackground = Color(0xFF1B1B1F) + val md_theme_light_surface = Color(0xFFFEFBFF) + val md_theme_light_onSurface = Color(0xFF1B1B1F) + val md_theme_light_surfaceVariant = Color(0xFFE1E2EC) + val md_theme_light_onSurfaceVariant = Color(0xFF44464F) + val md_theme_light_outline = Color(0xFF757780) + val md_theme_light_inverseOnSurface = Color(0xFFF2F0F4) + val md_theme_light_inverseSurface = Color(0xFF303034) + val md_theme_light_inversePrimary = Color(0xFFB0C6FF) + val md_theme_light_shadow = Color(0xFF000000) + val md_theme_light_surfaceTint = Color(0xFF1059C6) + val md_theme_light_outlineVariant = Color(0xFFC5C6D0) + val md_theme_light_scrim = Color(0xFF000000) + + val md_theme_dark_primary = Color(0xFFB0C6FF) + val md_theme_dark_onPrimary = Color(0xFF002D6E) + val md_theme_dark_primaryContainer = Color(0xFF00429B) + val md_theme_dark_onPrimaryContainer = Color(0xFFD9E2FF) + val md_theme_dark_secondary = Color(0xFFD7BAFF) + val md_theme_dark_onSecondary = Color(0xFF3F1C6E) + val md_theme_dark_secondaryContainer = Color(0xFF563587) + val md_theme_dark_onSecondaryContainer = Color(0xFFEDDCFF) + val md_theme_dark_tertiary = Color(0xFFE0BBDE) + val md_theme_dark_onTertiary = Color(0xFF412742) + val md_theme_dark_tertiaryContainer = Color(0xFF593D5A) + val md_theme_dark_onTertiaryContainer = Color(0xFFFDD7FA) + val md_theme_dark_error = Color(0xFFFFB4AB) + val md_theme_dark_errorContainer = Color(0xFF93000A) + val md_theme_dark_onError = Color(0xFF690005) + val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) + val md_theme_dark_background = Color(0xFF1B1B1F) + val md_theme_dark_onBackground = Color(0xFFE3E2E6) + val md_theme_dark_surface = Color(0xFF1B1B1F) + val md_theme_dark_onSurface = Color(0xFFE3E2E6) + val md_theme_dark_surfaceVariant = Color(0xFF44464F) + val md_theme_dark_onSurfaceVariant = Color(0xFFC5C6D0) + val md_theme_dark_outline = Color(0xFF8F9099) + val md_theme_dark_inverseOnSurface = Color(0xFF1B1B1F) + val md_theme_dark_inverseSurface = Color(0xFFE3E2E6) + val md_theme_dark_inversePrimary = Color(0xFF1059C6) + val md_theme_dark_shadow = Color(0xFF000000) + val md_theme_dark_surfaceTint = Color(0xFFB0C6FF) + val md_theme_dark_outlineVariant = Color(0xFF44464F) + val md_theme_dark_scrim = Color(0xFF000000) + + + val seed = Color(0xFF3771DF) + +} diff --git a/app/src/main/kotlin/com/meloda/fast/ui/colors/Green.kt b/app/src/main/kotlin/com/meloda/fast/ui/colors/Green.kt new file mode 100644 index 00000000..f21e7c4d --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ui/colors/Green.kt @@ -0,0 +1,71 @@ +package com.meloda.fast.ui.colors + +import androidx.compose.ui.graphics.Color + +object Green { + val md_theme_light_primary = Color(0xFF006E29) + val md_theme_light_onPrimary = Color(0xFFFFFFFF) + val md_theme_light_primaryContainer = Color(0xFF94F99D) + val md_theme_light_onPrimaryContainer = Color(0xFF002107) + val md_theme_light_secondary = Color(0xFF516350) + val md_theme_light_onSecondary = Color(0xFFFFFFFF) + val md_theme_light_secondaryContainer = Color(0xFFD4E8D0) + val md_theme_light_onSecondaryContainer = Color(0xFF0F1F10) + val md_theme_light_tertiary = Color(0xFF39656C) + val md_theme_light_onTertiary = Color(0xFFFFFFFF) + val md_theme_light_tertiaryContainer = Color(0xFFBCEAF2) + val md_theme_light_onTertiaryContainer = Color(0xFF001F24) + val md_theme_light_error = Color(0xFFBA1A1A) + val md_theme_light_errorContainer = Color(0xFFFFDAD6) + val md_theme_light_onError = Color(0xFFFFFFFF) + val md_theme_light_onErrorContainer = Color(0xFF410002) + val md_theme_light_background = Color(0xFFFCFDF7) + val md_theme_light_onBackground = Color(0xFF1A1C19) + val md_theme_light_surface = Color(0xFFFCFDF7) + val md_theme_light_onSurface = Color(0xFF1A1C19) + val md_theme_light_surfaceVariant = Color(0xFFDEE5D9) + val md_theme_light_onSurfaceVariant = Color(0xFF424940) + val md_theme_light_outline = Color(0xFF727970) + val md_theme_light_inverseOnSurface = Color(0xFFF0F1EB) + val md_theme_light_inverseSurface = Color(0xFF2F312D) + val md_theme_light_inversePrimary = Color(0xFF79DC84) + val md_theme_light_shadow = Color(0xFF000000) + val md_theme_light_surfaceTint = Color(0xFF006E29) + val md_theme_light_outlineVariant = Color(0xFFC2C9BE) + val md_theme_light_scrim = Color(0xFF000000) + + val md_theme_dark_primary = Color(0xFF79DC84) + val md_theme_dark_onPrimary = Color(0xFF003911) + val md_theme_dark_primaryContainer = Color(0xFF00531D) + val md_theme_dark_onPrimaryContainer = Color(0xFF94F99D) + val md_theme_dark_secondary = Color(0xFFB8CCB5) + val md_theme_dark_onSecondary = Color(0xFF243424) + val md_theme_dark_secondaryContainer = Color(0xFF3A4B3A) + val md_theme_dark_onSecondaryContainer = Color(0xFFD4E8D0) + val md_theme_dark_tertiary = Color(0xFFA1CED6) + val md_theme_dark_onTertiary = Color(0xFF00363D) + val md_theme_dark_tertiaryContainer = Color(0xFF1F4D54) + val md_theme_dark_onTertiaryContainer = Color(0xFFBCEAF2) + val md_theme_dark_error = Color(0xFFFFB4AB) + val md_theme_dark_errorContainer = Color(0xFF93000A) + val md_theme_dark_onError = Color(0xFF690005) + val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) + val md_theme_dark_background = Color(0xFF1A1C19) + val md_theme_dark_onBackground = Color(0xFFE2E3DD) + val md_theme_dark_surface = Color(0xFF1A1C19) + val md_theme_dark_onSurface = Color(0xFFE2E3DD) + val md_theme_dark_surfaceVariant = Color(0xFF424940) + val md_theme_dark_onSurfaceVariant = Color(0xFFC2C9BE) + val md_theme_dark_outline = Color(0xFF8C9389) + val md_theme_dark_inverseOnSurface = Color(0xFF1A1C19) + val md_theme_dark_inverseSurface = Color(0xFFE2E3DD) + val md_theme_dark_inversePrimary = Color(0xFF006E29) + val md_theme_dark_shadow = Color(0xFF000000) + val md_theme_dark_surfaceTint = Color(0xFF79DC84) + val md_theme_dark_outlineVariant = Color(0xFF424940) + val md_theme_dark_scrim = Color(0xFF000000) + + + val seed = Color(0xFF22893C) + +} diff --git a/app/src/main/kotlin/com/meloda/fast/ui/colors/Red.kt b/app/src/main/kotlin/com/meloda/fast/ui/colors/Red.kt new file mode 100644 index 00000000..4f0aa966 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ui/colors/Red.kt @@ -0,0 +1,70 @@ +package com.meloda.fast.ui.colors + +import androidx.compose.ui.graphics.Color + +object Red { + val md_theme_light_primary = Color(0xFFA43A3A) + val md_theme_light_onPrimary = Color(0xFFFFFFFF) + val md_theme_light_primaryContainer = Color(0xFFFFDAD8) + val md_theme_light_onPrimaryContainer = Color(0xFF410006) + val md_theme_light_secondary = Color(0xFF775654) + val md_theme_light_onSecondary = Color(0xFFFFFFFF) + val md_theme_light_secondaryContainer = Color(0xFFFFDAD8) + val md_theme_light_onSecondaryContainer = Color(0xFF2C1514) + val md_theme_light_tertiary = Color(0xFF735A2E) + val md_theme_light_onTertiary = Color(0xFFFFFFFF) + val md_theme_light_tertiaryContainer = Color(0xFFFFDEA9) + val md_theme_light_onTertiaryContainer = Color(0xFF271900) + val md_theme_light_error = Color(0xFFBA1A1A) + val md_theme_light_errorContainer = Color(0xFFFFDAD6) + val md_theme_light_onError = Color(0xFFFFFFFF) + val md_theme_light_onErrorContainer = Color(0xFF410002) + val md_theme_light_background = Color(0xFFFFFBFF) + val md_theme_light_onBackground = Color(0xFF201A1A) + val md_theme_light_surface = Color(0xFFFFFBFF) + val md_theme_light_onSurface = Color(0xFF201A1A) + val md_theme_light_surfaceVariant = Color(0xFFF4DDDC) + val md_theme_light_onSurfaceVariant = Color(0xFF534342) + val md_theme_light_outline = Color(0xFF857372) + val md_theme_light_inverseOnSurface = Color(0xFFFBEEEC) + val md_theme_light_inverseSurface = Color(0xFF362F2E) + val md_theme_light_inversePrimary = Color(0xFFFFB3AF) + val md_theme_light_shadow = Color(0xFF000000) + val md_theme_light_surfaceTint = Color(0xFFA43A3A) + val md_theme_light_outlineVariant = Color(0xFFD7C1C0) + val md_theme_light_scrim = Color(0xFF000000) + + val md_theme_dark_primary = Color(0xFFFFB3AF) + val md_theme_dark_onPrimary = Color(0xFF650912) + val md_theme_dark_primaryContainer = Color(0xFF842225) + val md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD8) + val md_theme_dark_secondary = Color(0xFFE7BDBA) + val md_theme_dark_onSecondary = Color(0xFF442928) + val md_theme_dark_secondaryContainer = Color(0xFF5D3F3E) + val md_theme_dark_onSecondaryContainer = Color(0xFFFFDAD8) + val md_theme_dark_tertiary = Color(0xFFE3C28C) + val md_theme_dark_onTertiary = Color(0xFF412D05) + val md_theme_dark_tertiaryContainer = Color(0xFF594319) + val md_theme_dark_onTertiaryContainer = Color(0xFFFFDEA9) + val md_theme_dark_error = Color(0xFFFFB4AB) + val md_theme_dark_errorContainer = Color(0xFF93000A) + val md_theme_dark_onError = Color(0xFF690005) + val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) + val md_theme_dark_background = Color(0xFF201A1A) + val md_theme_dark_onBackground = Color(0xFFEDE0DE) + val md_theme_dark_surface = Color(0xFF201A1A) + val md_theme_dark_onSurface = Color(0xFFEDE0DE) + val md_theme_dark_surfaceVariant = Color(0xFF534342) + val md_theme_dark_onSurfaceVariant = Color(0xFFD7C1C0) + val md_theme_dark_outline = Color(0xFFA08C8B) + val md_theme_dark_inverseOnSurface = Color(0xFF201A1A) + val md_theme_dark_inverseSurface = Color(0xFFEDE0DE) + val md_theme_dark_inversePrimary = Color(0xFFA43A3A) + val md_theme_dark_shadow = Color(0xFF000000) + val md_theme_dark_surfaceTint = Color(0xFFFFB3AF) + val md_theme_dark_outlineVariant = Color(0xFF534342) + val md_theme_dark_scrim = Color(0xFF000000) + + + val seed = Color(0xFFC55251) +} diff --git a/app/src/main/kotlin/com/meloda/fast/ui/widgets/AsyncImage.kt b/app/src/main/kotlin/com/meloda/fast/ui/widgets/AsyncImage.kt new file mode 100644 index 00000000..68c5fc89 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ui/widgets/AsyncImage.kt @@ -0,0 +1,48 @@ +package com.meloda.fast.ui.widgets + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalView +import coil.compose.AsyncImage +import coil.request.ImageRequest + + +/** + * Simple wrapper for coil's AsyncImage for showing preview + * @param contentDescription text used by accessibility services to describe what this image + * represents. This should always be provided unless this image is used for decorative purposes, + * and does not represent a meaningful action that a user can take. This text should be + * localized, such as by using [androidx.compose.ui.res.stringResource] or similar + * @param modifier Modifier used to adjust the layout algorithm or draw decoration content (ex. + * background) + * @param model Either an [ImageRequest] or the [ImageRequest.data] value. + * @param contentScale Optional scale parameter used to determine the aspect ratio scaling to be used + * @param previewPainter Optional painter for preview + */ + +@Composable +fun CoilImage( + contentDescription: String?, + modifier: Modifier, + model: Any?, + contentScale: ContentScale = ContentScale.Fit, + previewPainter: Painter? +) { + if (previewPainter != null && LocalView.current.isInEditMode) { + Image( + painter = previewPainter, + contentDescription = contentDescription, + modifier = modifier + ) + } else { + AsyncImage( + model = model, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier + ) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/ui/widgets/TextFieldErrorText.kt b/app/src/main/kotlin/com/meloda/fast/ui/widgets/TextFieldErrorText.kt new file mode 100644 index 00000000..d130759f --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/ui/widgets/TextFieldErrorText.kt @@ -0,0 +1,31 @@ +package com.meloda.fast.ui.widgets + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Preview +@Composable +fun TextFieldErrorText( + modifier: Modifier = Modifier, + text: String = "Field must not be empty", + withSpacer: Boolean = true +) { + Row { + if (withSpacer) { + Spacer(modifier = Modifier.width(16.dp)) + } + Text( + text = text, + modifier = modifier, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } +} diff --git a/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt index ece66913..83c34eee 100644 --- a/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/util/AndroidUtils.kt @@ -1,46 +1,30 @@ package com.meloda.fast.util import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.content.res.Configuration import android.content.res.Resources -import android.net.NetworkCapabilities +import android.graphics.Bitmap import android.net.Uri import android.os.Build +import android.os.PowerManager import android.provider.Settings import android.util.TypedValue +import android.widget.Toast import androidx.annotation.AttrRes import androidx.core.content.FileProvider +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import com.meloda.fast.BuildConfig -import com.meloda.fast.common.AppConstants import com.meloda.fast.common.AppGlobal +import com.meloda.fast.ext.isTrue import java.io.File +import java.io.FileOutputStream object AndroidUtils { - fun isDarkTheme(): Boolean { - return when (AppGlobal.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { - Configuration.UI_MODE_NIGHT_YES -> true - else -> false - } - } - - fun hasConnection(): Boolean { - val network = AppGlobal.connectivityManager.activeNetwork ?: return false - val activeNetwork = - AppGlobal.connectivityManager.getNetworkCapabilities(network) ?: return false - - return when { - activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true - activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true - activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true - activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> true - else -> false - } - } - fun getDisplayWidth(): Int { return Resources.getSystem().displayMetrics.widthPixels } @@ -49,8 +33,34 @@ object AndroidUtils { return Resources.getSystem().displayMetrics.heightPixels } - fun copyText(label: String? = "", text: String) { - AppGlobal.clipboardManager.setPrimaryClip(ClipData.newPlainText(label, text)) + fun copyText( + label: String? = "", + text: String, + withToast: Boolean = false + ) { + val clipboardManager = + AppGlobal.Instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + clipboardManager.setPrimaryClip(ClipData.newPlainText(label, text)) + + if (withToast && Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + Toast.makeText(AppGlobal.Instance, "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + } + + fun copyImage( + label: String? = "", + imageUri: Uri, + withToast: Boolean = false + ) { + val clipboardManager = + AppGlobal.Instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + clipboardManager.setPrimaryClip(ClipData.newRawUri(label, imageUri)) + + if (withToast && Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + Toast.makeText(AppGlobal.Instance, "Copied to clipboard", Toast.LENGTH_SHORT).show() + } } fun getThemeAttrColor(context: Context, @AttrRes resId: Int): Int { @@ -61,7 +71,7 @@ object AndroidUtils { try { color = context.resources.getColor(colorRes, context.theme) } catch (e: Exception) { - + e.printStackTrace() } return color @@ -74,19 +84,19 @@ object AndroidUtils { fun bytesToHumanReadableSize(bytes: Double): String = when { bytes >= 1 shl 30 -> "%.1f GB".format(bytes / (1 shl 30)) bytes >= 1 shl 20 -> "%.1f MB".format(bytes / (1 shl 20)) - bytes >= 1 shl 10 -> "%.0f KB".format(bytes / (1 shl 10)) + bytes >= 1 shl 10 -> "%.1f KB".format(bytes / (1 shl 10)) else -> "$bytes B" } @Suppress("DEPRECATION") - fun isCanInstallUnknownApps(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - AppGlobal.packageManager.canRequestPackageInstalls() - } else { + fun isCanInstallUnknownApps(): Boolean { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { Settings.Secure.getInt( - context.contentResolver, + AppGlobal.Instance.contentResolver, Settings.Secure.INSTALL_NON_MARKET_APPS ) == 1 + } else { + AppGlobal.packageManager.canRequestPackageInstalls() } } @@ -104,24 +114,115 @@ object AndroidUtils { fun getInstallPackageIntent( context: Context, providerPath: String, - fileToRead: File + fileToRead: File, ): Intent { val intent = Intent(Intent.ACTION_VIEW) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) - intent.data = FileProvider.getUriForFile( - context, - BuildConfig.APPLICATION_ID + providerPath, - fileToRead - ) - } else { - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP - intent.setDataAndType(Uri.fromFile(fileToRead), AppConstants.INSTALL_APP_MIME_TYPE) - } + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + intent.data = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + providerPath, + fileToRead + ) return intent } -} \ No newline at end of file + + fun getStatusBarInsets(insets: WindowInsetsCompat): Insets { + return insets.getInsets(WindowInsetsCompat.Type.statusBars()) + } + + fun getNavBarInsets(insets: WindowInsetsCompat): Insets { + return insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + } + + fun getImeInsets(insets: WindowInsetsCompat): Insets { + return insets.getInsets(WindowInsetsCompat.Type.ime()) + } + + fun isBatterySaverOn(): Boolean { + return (AppGlobal.Instance.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isPowerSaveMode.isTrue + } + + fun getImageToShare(context: Context, existingFile: File): Uri? { + val imageFolder = File(context.cacheDir, "images") + + return try { + imageFolder.mkdirs() + + val copyToFile = File(imageFolder, "shared_image.png") + if (copyToFile.exists()) { + copyToFile.delete() + } + + val file = existingFile.copyTo(copyToFile) + FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", file) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun getImageToShare(context: Context, bitmap: Bitmap): Uri? { + val imageFolder = File(context.cacheDir, "images") + + return try { + imageFolder.mkdirs() + + val file = File(imageFolder, "shared_image.png") + val outputStream = FileOutputStream(file) + bitmap.compress(Bitmap.CompressFormat.PNG, 90, outputStream) + outputStream.flush() + outputStream.close() + FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.fileprovider", file) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun showShareSheet(context: Context, content: ShareContent) { + val intent = Intent(Intent.ACTION_SEND).apply { + + type = when (content) { + is ShareContent.Text -> { + putExtra(Intent.EXTRA_TEXT, content.text) + "text/plain" + } + + is ShareContent.Image -> { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + putExtra(Intent.EXTRA_STREAM, content.uri) + "image/png" + } + + is ShareContent.TextWithImage -> { + putExtra(Intent.EXTRA_TEXT, content.text) + putExtra(Intent.EXTRA_STREAM, content.imageUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + "image/png" + } + } + } + + val contentType = when (content) { + is ShareContent.Text -> "Text" + is ShareContent.Image -> "Image" + is ShareContent.TextWithImage -> "Text with image" + } + val chooserIntent = Intent.createChooser(intent, "Share $contentType") + + + context.startActivity(chooserIntent) + } +} + +sealed class ShareContent { + data class Text(val text: String) : ShareContent() + + data class Image(val uri: Uri) : ShareContent() + + data class TextWithImage(val text: String, val imageUri: Uri) : ShareContent() +} diff --git a/app/src/main/kotlin/com/meloda/fast/util/ColorUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/ColorUtils.kt new file mode 100644 index 00000000..db82c31c --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/util/ColorUtils.kt @@ -0,0 +1,18 @@ +package com.meloda.fast.util + +import android.graphics.Color + +object ColorUtils { + + + fun alphaColor(color: Int, alphaFactor: Float): Int { + val alpha = Color.alpha(color) + + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + + return Color.argb((alpha * alphaFactor).toInt(), red, green, blue) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt index e3833efc..efb8294f 100644 --- a/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt +++ b/app/src/main/kotlin/com/meloda/fast/util/NotificationsUtils.kt @@ -1,5 +1,6 @@ package com.meloda.fast.util +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import androidx.core.app.NotificationCompat @@ -8,6 +9,7 @@ import com.meloda.fast.R object NotificationsUtils { + @SuppressLint("MissingPermission") fun createNotification( context: Context, title: String? = null, @@ -21,9 +23,10 @@ object NotificationsUtils { channelId: String = "simple_notifications", priority: NotificationPriority = NotificationPriority.Default, contentIntent: PendingIntent? = null, - category: String? = null + category: String? = null, + actions: List = emptyList(), ): NotificationCompat.Builder { - var builder = NotificationCompat.Builder(context, channelId) + val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.ic_fast_logo) .setContentTitle(title) .setPriority(priority.value) @@ -33,24 +36,30 @@ object NotificationsUtils { .setOngoing(notRemovable) if (category != null) { - builder = builder.setCategory(category) + builder.setCategory(category) } if (contentText != null) { - builder = builder.setContentText(contentText) + builder.setContentText(contentText) } if (bigText != null) { - builder = builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) } if (timeStampWhen != null) { - builder = builder.setWhen(timeStampWhen) + builder.setWhen(timeStampWhen) } + actions.forEach(builder::addAction) + if (notify) { - with(NotificationManagerCompat.from(context)) { - notify(customNotificationId ?: -1, builder.build()) + try { + with(NotificationManagerCompat.from(context)) { + notify(customNotificationId ?: -1, builder.build()) + } + } catch (e: Exception) { + e.printStackTrace() } } @@ -61,4 +70,4 @@ object NotificationsUtils { Default(0), Low(-1), Min(-2), High(1), Max(2) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt b/app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt deleted file mode 100644 index 872d7dbb..00000000 --- a/app/src/main/kotlin/com/meloda/fast/util/ViewUtils.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.meloda.fast.util - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.meloda.fast.R - -object ViewUtils { - - fun Context.showErrorDialog( - message: String, - showErrorPrefix: Boolean = true, - isCancelable: Boolean? = null, - positiveText: Int? = null, - positiveAction: (() -> Unit)? = null, - negativeText: Int? = null, - negativeAction: (() -> Unit)? = null, - ): AlertDialog { - val builder = MaterialAlertDialogBuilder(this) - .setTitle(R.string.warning) - .setMessage( - if (showErrorPrefix) getString(R.string.error, message) - else message - ) - .setPositiveButton(positiveText ?: R.string.ok) { _, _ -> - positiveAction?.invoke() - } - - negativeAction?.run { - builder.setNegativeButton( - negativeText ?: R.string.cancel - ) { _, _ -> this.invoke() } - } - - isCancelable?.run { builder.setCancelable(this) } - - return builder.show() - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/meloda/fast/view/DialogToolbar.kt b/app/src/main/kotlin/com/meloda/fast/view/DialogToolbar.kt new file mode 100644 index 00000000..0508a9f9 --- /dev/null +++ b/app/src/main/kotlin/com/meloda/fast/view/DialogToolbar.kt @@ -0,0 +1,119 @@ +package com.meloda.fast.view + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.content.withStyledAttributes +import androidx.core.view.updatePaddingRelative +import com.meloda.fast.R +import com.meloda.fast.databinding.ViewDialogToolbarBinding +import com.meloda.fast.ext.dpToPx +import com.meloda.fast.ext.toggleVisibility +import com.meloda.fast.ext.toggleVisibilityIfHasContent +import com.meloda.fast.util.ColorUtils +import kotlin.properties.Delegates + +class DialogToolbar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binding = + ViewDialogToolbarBinding.inflate(LayoutInflater.from(context), this) + + + var title: String? by Delegates.observable(null) { _, _, _ -> + applyTitle(title) + } + + var subtitle: String? by Delegates.observable(null) { _, _, _ -> + applySubtitle(subtitle) + } + + var avatarDrawable: Drawable? by Delegates.observable(null) { _, _, _ -> + applyAvatarDrawable(avatarDrawable) + } + + var avatarClickAction: ((avatar: View) -> Unit)? by Delegates.observable(null) { _, _, _ -> + applyAvatarClickAction(avatarClickAction) + } + + var startIconDrawable: Drawable? by Delegates.observable(null) { _, _, _ -> + applyStartIconDrawable(startIconDrawable) + } + + var startButtonClickAction: (() -> Unit)? = null + + private val defaultBackgroundColor = ContextCompat.getColor( + context, + R.color.colorBackground + ) + + init { + isSaveEnabled = false + + val padding = 4.dpToPx() + updatePaddingRelative(top = padding, bottom = padding) + + context.withStyledAttributes(attrs, R.styleable.DialogToolbar) { + title = getText(R.styleable.DialogToolbar_title)?.toString() + subtitle = getText(R.styleable.DialogToolbar_subtitle)?.toString() + avatarDrawable = getDrawable(R.styleable.DialogToolbar_avatar) + startIconDrawable = getDrawable(R.styleable.DialogToolbar_startIcon) + + val attrBackgroundColor = + getColor(R.styleable.DialogToolbar_backgroundColor, defaultBackgroundColor) + + val useTranslucentBackgroundColor = + getBoolean(R.styleable.DialogToolbar_useTranslucentBackgroundColor, false) + + val backgroundColor = + if (useTranslucentBackgroundColor) ColorUtils.alphaColor(attrBackgroundColor, 0.9F) + else attrBackgroundColor + + setBackgroundColor(backgroundColor) + } + + binding.startIconContainer.setOnClickListener { startButtonClickAction?.invoke() } + } + + private fun syncView() { + applyTitle(title) + applySubtitle(subtitle) + applyAvatarDrawable(avatarDrawable) + applyStartIconDrawable(startIconDrawable) + } + + private fun applyTitle(title: String?) { + binding.title.text = title + binding.title.toggleVisibilityIfHasContent() + } + + private fun applySubtitle(subtitle: String?) { + binding.subtitle.text = subtitle + binding.subtitle.toggleVisibilityIfHasContent() + } + + private fun applyAvatarDrawable(drawable: Drawable?) { + binding.avatar.setImageDrawable(drawable) + binding.avatar.toggleVisibilityIfHasContent() + } + + private fun applyAvatarClickAction(action: ((avatar: View) -> Unit)?) { + binding.avatar.setOnClickListener(action) + } + + private fun applyStartIconDrawable(drawable: Drawable?) { + binding.startIcon.setImageDrawable(drawable) + + binding.startIconContainer.toggleVisibility(drawable != null) + } + + val avatarImageView get() = binding.avatar + +} \ No newline at end of file diff --git a/app/src/main/res/drawable-v21/ic_arrow_end.xml b/app/src/main/res/drawable/ic_arrow_end.xml similarity index 100% rename from app/src/main/res/drawable-v21/ic_arrow_end.xml rename to app/src/main/res/drawable/ic_arrow_end.xml diff --git a/app/src/main/res/drawable-v21/ic_baseline_account_circle_24.xml b/app/src/main/res/drawable/ic_baseline_account_circle_24.xml similarity index 100% rename from app/src/main/res/drawable-v21/ic_baseline_account_circle_24.xml rename to app/src/main/res/drawable/ic_baseline_account_circle_24.xml diff --git a/app/src/main/res/drawable-v21/ic_key.xml b/app/src/main/res/drawable/ic_key.xml similarity index 100% rename from app/src/main/res/drawable-v21/ic_key.xml rename to app/src/main/res/drawable/ic_key.xml diff --git a/app/src/main/res/drawable/ic_launcher_foreground_splash.xml b/app/src/main/res/drawable/ic_launcher_foreground_splash.xml deleted file mode 100644 index 0bf84b05..00000000 --- a/app/src/main/res/drawable/ic_launcher_foreground_splash.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_logo_big.xml b/app/src/main/res/drawable/ic_logo_big.xml new file mode 100644 index 00000000..e71f3c1d --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_big.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable-anydpi-v24/ic_notification_new_message.xml b/app/src/main/res/drawable/ic_notification_new_message.xml similarity index 100% rename from app/src/main/res/drawable-anydpi-v24/ic_notification_new_message.xml rename to app/src/main/res/drawable/ic_notification_new_message.xml diff --git a/app/src/main/res/drawable-v21/ic_people_outline.xml b/app/src/main/res/drawable/ic_people_outline.xml similarity index 100% rename from app/src/main/res/drawable-v21/ic_people_outline.xml rename to app/src/main/res/drawable/ic_people_outline.xml diff --git a/app/src/main/res/drawable/ic_round_mic_none_24.xml b/app/src/main/res/drawable/ic_round_mic_none_24.xml index a18159f6..212a723d 100644 --- a/app/src/main/res/drawable/ic_round_mic_none_24.xml +++ b/app/src/main/res/drawable/ic_round_mic_none_24.xml @@ -1,5 +1,9 @@ - - + + diff --git a/app/src/main/res/drawable/ic_round_person_24.xml b/app/src/main/res/drawable/ic_round_person_24.xml new file mode 100644 index 00000000..ea106803 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_person_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_round_settings_primary.xml b/app/src/main/res/drawable/ic_round_settings_primary.xml new file mode 100644 index 00000000..1d4f7900 --- /dev/null +++ b/app/src/main/res/drawable/ic_round_settings_primary.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable-v21/ic_search.xml b/app/src/main/res/drawable/ic_search.xml similarity index 100% rename from app/src/main/res/drawable-v21/ic_search.xml rename to app/src/main/res/drawable/ic_search.xml diff --git a/app/src/main/res/drawable-v21/ic_security.xml b/app/src/main/res/drawable/ic_security.xml similarity index 100% rename from app/src/main/res/drawable-v21/ic_security.xml rename to app/src/main/res/drawable/ic_security.xml diff --git a/app/src/main/res/drawable-v21/ic_star_border.xml b/app/src/main/res/drawable/ic_star_border.xml similarity index 100% rename from app/src/main/res/drawable-v21/ic_star_border.xml rename to app/src/main/res/drawable/ic_star_border.xml diff --git a/app/src/main/res/drawable/round_cake_24.xml b/app/src/main/res/drawable/round_cake_24.xml new file mode 100644 index 00000000..95926f48 --- /dev/null +++ b/app/src/main/res/drawable/round_cake_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/round_file_download_24.xml b/app/src/main/res/drawable/round_file_download_24.xml new file mode 100644 index 00000000..58ee4a26 --- /dev/null +++ b/app/src/main/res/drawable/round_file_download_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/round_install_mobile_24.xml b/app/src/main/res/drawable/round_install_mobile_24.xml new file mode 100644 index 00000000..7181f910 --- /dev/null +++ b/app/src/main/res/drawable/round_install_mobile_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/round_more_vert_24.xml b/app/src/main/res/drawable/round_more_vert_24.xml new file mode 100644 index 00000000..b29b2aef --- /dev/null +++ b/app/src/main/res/drawable/round_more_vert_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/round_qr_code_24.xml b/app/src/main/res/drawable/round_qr_code_24.xml new file mode 100644 index 00000000..581feda6 --- /dev/null +++ b/app/src/main/res/drawable/round_qr_code_24.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/round_restart_alt_24.xml b/app/src/main/res/drawable/round_restart_alt_24.xml new file mode 100644 index 00000000..5da3d873 --- /dev/null +++ b/app/src/main/res/drawable/round_restart_alt_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/round_sms_24.xml b/app/src/main/res/drawable/round_sms_24.xml new file mode 100644 index 00000000..aac4d3c3 --- /dev/null +++ b/app/src/main/res/drawable/round_sms_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/round_visibility_24.xml b/app/src/main/res/drawable/round_visibility_24.xml new file mode 100644 index 00000000..11319c64 --- /dev/null +++ b/app/src/main/res/drawable/round_visibility_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/round_visibility_off_24.xml b/app/src/main/res/drawable/round_visibility_off_24.xml new file mode 100644 index 00000000..89ab0f68 --- /dev/null +++ b/app/src/main/res/drawable/round_visibility_off_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/round_vpn_key_24.xml b/app/src/main/res/drawable/round_vpn_key_24.xml new file mode 100644 index 00000000..03873dea --- /dev/null +++ b/app/src/main/res/drawable/round_vpn_key_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/test_captcha.webp b/app/src/main/res/drawable/test_captcha.webp new file mode 100644 index 00000000..5c636e22 Binary files /dev/null and b/app/src/main/res/drawable/test_captcha.webp differ diff --git a/app/src/main/res/font/google_sans_bold_italic.ttf b/app/src/main/res/font/google_sans_bold_italic.ttf new file mode 100644 index 00000000..5684264d Binary files /dev/null and b/app/src/main/res/font/google_sans_bold_italic.ttf differ diff --git a/app/src/main/res/font/google_sans_medium_italic.ttf b/app/src/main/res/font/google_sans_medium_italic.ttf new file mode 100644 index 00000000..dd1d2d94 Binary files /dev/null and b/app/src/main/res/font/google_sans_medium_italic.ttf differ diff --git a/app/src/main/res/font/roboto_black.ttf b/app/src/main/res/font/roboto_black.ttf new file mode 100644 index 00000000..0112e7da Binary files /dev/null and b/app/src/main/res/font/roboto_black.ttf differ diff --git a/app/src/main/res/font/roboto_black_italic.ttf b/app/src/main/res/font/roboto_black_italic.ttf new file mode 100644 index 00000000..b2c6aca5 Binary files /dev/null and b/app/src/main/res/font/roboto_black_italic.ttf differ diff --git a/app/src/main/res/font/roboto_bold.ttf b/app/src/main/res/font/roboto_bold.ttf index 50fe0db7..43da14d8 100644 Binary files a/app/src/main/res/font/roboto_bold.ttf and b/app/src/main/res/font/roboto_bold.ttf differ diff --git a/app/src/main/res/font/roboto_bold_italic.ttf b/app/src/main/res/font/roboto_bold_italic.ttf new file mode 100644 index 00000000..bcfdab43 Binary files /dev/null and b/app/src/main/res/font/roboto_bold_italic.ttf differ diff --git a/app/src/main/res/font/roboto_italic.ttf b/app/src/main/res/font/roboto_italic.ttf new file mode 100644 index 00000000..1b5eaa36 Binary files /dev/null and b/app/src/main/res/font/roboto_italic.ttf differ diff --git a/app/src/main/res/font/roboto_light.ttf b/app/src/main/res/font/roboto_light.ttf index 04cc9550..e7307e72 100644 Binary files a/app/src/main/res/font/roboto_light.ttf and b/app/src/main/res/font/roboto_light.ttf differ diff --git a/app/src/main/res/font/roboto_light_italic.ttf b/app/src/main/res/font/roboto_light_italic.ttf new file mode 100644 index 00000000..2d277afb Binary files /dev/null and b/app/src/main/res/font/roboto_light_italic.ttf differ diff --git a/app/src/main/res/font/roboto_medium.ttf b/app/src/main/res/font/roboto_medium.ttf index 0c1a577b..ac0f908b 100644 Binary files a/app/src/main/res/font/roboto_medium.ttf and b/app/src/main/res/font/roboto_medium.ttf differ diff --git a/app/src/main/res/font/roboto_medium_italic.ttf b/app/src/main/res/font/roboto_medium_italic.ttf new file mode 100644 index 00000000..fc36a478 Binary files /dev/null and b/app/src/main/res/font/roboto_medium_italic.ttf differ diff --git a/app/src/main/res/font/roboto_regular.ttf b/app/src/main/res/font/roboto_regular.ttf index f16ad7af..ddf4bfac 100644 Binary files a/app/src/main/res/font/roboto_regular.ttf and b/app/src/main/res/font/roboto_regular.ttf differ diff --git a/app/src/main/res/font/roboto_thin.ttf b/app/src/main/res/font/roboto_thin.ttf index d728fa9b..2e0dee6a 100644 Binary files a/app/src/main/res/font/roboto_thin.ttf and b/app/src/main/res/font/roboto_thin.ttf differ diff --git a/app/src/main/res/font/roboto_thin_italic.ttf b/app/src/main/res/font/roboto_thin_italic.ttf new file mode 100644 index 00000000..084f9c0f Binary files /dev/null and b/app/src/main/res/font/roboto_thin_italic.ttf differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index d634b0e7..09341829 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,42 +1,5 @@ - - - - - - - - - - - - - \ No newline at end of file + android:layout_height="match_parent" /> diff --git a/app/src/main/res/layout/fragment_chat_info.xml b/app/src/main/res/layout/fragment_chat_info.xml new file mode 100644 index 00000000..963d3e44 --- /dev/null +++ b/app/src/main/res/layout/fragment_chat_info.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_chat_info_members.xml b/app/src/main/res/layout/fragment_chat_info_members.xml new file mode 100644 index 00000000..ac3270d3 --- /dev/null +++ b/app/src/main/res/layout/fragment_chat_info_members.xml @@ -0,0 +1,25 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_conversations.xml b/app/src/main/res/layout/fragment_conversations.xml index d0d5c48c..1499f534 100644 --- a/app/src/main/res/layout/fragment_conversations.xml +++ b/app/src/main/res/layout/fragment_conversations.xml @@ -8,19 +8,16 @@ + android:layout_height="wrap_content"> + app:title="@string/title_messages" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_forwarded_messages.xml b/app/src/main/res/layout/fragment_forwarded_messages.xml index 462866c5..828374c7 100644 --- a/app/src/main/res/layout/fragment_forwarded_messages.xml +++ b/app/src/main/res/layout/fragment_forwarded_messages.xml @@ -18,6 +18,7 @@ android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipToPadding="false" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:listitem="@layout/item_message_in" /> diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml deleted file mode 100644 index a80a4e37..00000000 --- a/app/src/main/res/layout/fragment_login.xml +++ /dev/null @@ -1,168 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_messages_history.xml b/app/src/main/res/layout/fragment_messages_history.xml index 4967b7e1..32435b4d 100644 --- a/app/src/main/res/layout/fragment_messages_history.xml +++ b/app/src/main/res/layout/fragment_messages_history.xml @@ -5,46 +5,33 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + app:layout_constraintTop_toTopOf="parent" + tools:itemCount="100" + tools:listitem="@layout/item_message_out" + tools:paddingBottom="72dp" + tools:paddingTop="56dp" /> - - - - - - - - - + app:layout_constraintTop_toTopOf="parent" + app:startIcon="@drawable/ic_round_arrow_back_24" + app:useTranslucentBackgroundColor="true" + tools:avatar="#ff0000" + tools:subtitle="Last seen at 05.26.21, 17:55" + tools:title="@tools:sample/full_names" /> @@ -166,12 +154,14 @@ + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="@id/bottom_message_panel" /> + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_settings_root.xml b/app/src/main/res/layout/fragment_settings_root.xml deleted file mode 100644 index 72ed28c1..00000000 --- a/app/src/main/res/layout/fragment_settings_root.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_updates.xml b/app/src/main/res/layout/fragment_updates.xml index 7dea74e9..20cfcb9e 100644 --- a/app/src/main/res/layout/fragment_updates.xml +++ b/app/src/main/res/layout/fragment_updates.xml @@ -1,95 +1,105 @@ - + android:layout_height="match_parent"> - + - + - + - + - + - + - + - + - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_user_banned.xml b/app/src/main/res/layout/fragment_user_banned.xml new file mode 100644 index 00000000..c0b90365 --- /dev/null +++ b/app/src/main/res/layout/fragment_user_banned.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_chat_member.xml b/app/src/main/res/layout/item_chat_member.xml new file mode 100644 index 00000000..4b6d30ff --- /dev/null +++ b/app/src/main/res/layout/item_chat_member.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_settings_checkbox.xml b/app/src/main/res/layout/item_settings_checkbox.xml new file mode 100644 index 00000000..d3959648 --- /dev/null +++ b/app/src/main/res/layout/item_settings_checkbox.xml @@ -0,0 +1,50 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_settings_edit_text.xml b/app/src/main/res/layout/item_settings_edit_text.xml new file mode 100644 index 00000000..06331356 --- /dev/null +++ b/app/src/main/res/layout/item_settings_edit_text.xml @@ -0,0 +1,40 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_settings_edit_text_alert.xml b/app/src/main/res/layout/item_settings_edit_text_alert.xml new file mode 100644 index 00000000..6a23eae5 --- /dev/null +++ b/app/src/main/res/layout/item_settings_edit_text_alert.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/item_settings_list.xml b/app/src/main/res/layout/item_settings_list.xml new file mode 100644 index 00000000..9106ecae --- /dev/null +++ b/app/src/main/res/layout/item_settings_list.xml @@ -0,0 +1,40 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_settings_switch.xml b/app/src/main/res/layout/item_settings_switch.xml new file mode 100644 index 00000000..2328e3db --- /dev/null +++ b/app/src/main/res/layout/item_settings_switch.xml @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_settings_title.xml b/app/src/main/res/layout/item_settings_title.xml new file mode 100644 index 00000000..54a00174 --- /dev/null +++ b/app/src/main/res/layout/item_settings_title.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_settings_title_summary.xml b/app/src/main/res/layout/item_settings_title_summary.xml new file mode 100644 index 00000000..06331356 --- /dev/null +++ b/app/src/main/res/layout/item_settings_title_summary.xml @@ -0,0 +1,40 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_dialog_toolbar.xml b/app/src/main/res/layout/view_dialog_toolbar.xml new file mode 100644 index 00000000..a99cca08 --- /dev/null +++ b/app/src/main/res/layout/view_dialog_toolbar.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/fragment_conversations.xml b/app/src/main/res/menu/fragment_conversations.xml index 07727f64..167638f2 100644 --- a/app/src/main/res/menu/fragment_conversations.xml +++ b/app/src/main/res/menu/fragment_conversations.xml @@ -5,8 +5,13 @@ - \ No newline at end of file + + + diff --git a/app/src/main/res/menu/fragment_conversations_popup.xml b/app/src/main/res/menu/fragment_conversations_popup.xml new file mode 100644 index 00000000..b05248ac --- /dev/null +++ b/app/src/main/res/menu/fragment_conversations_popup.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml deleted file mode 100644 index 90a3711b..00000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml deleted file mode 100644 index 2f2de293..00000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_monet_splash.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index ef49c991..00000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..9cefb9c4 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..35bfb635 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..19dec746 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..8838d363 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..b3f5917d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 6fa63cdb..6f14ead9 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -1,48 +1,5 @@ - @color/a1_200 - @color/a1_800 - @color/a1_700 - @color/a1_100 - @color/a1_600 - @color/a3_100 - @color/a2_200 - @color/a2_800 - @color/a2_700 - @color/a2_100 - - @color/a3_200 - @color/a3_800 - @color/a3_700 - @color/a3_100 - - #f2b8b5 - #601410 - #8c1d18 - #f2b8b5 - - @color/n2_400 - - @color/n1_900 - @color/n1_100 - @color/n1_100_50 - @color/n2_800 - @color/a1_200 - @color/a1_900 - - @color/n1_900 - @color/n1_100 - @color/n2_700 - @color/n2_200 - @color/n1_100 - @color/n1_800 - - @color/n2_500 - @color/n2_10 - - @color/n2_100 - @color/n2_400 - - @color/a1_1000 - \ No newline at end of file + #40000000 + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 00000000..d102c852 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,6 @@ + + + Вложения + Настроечбки + Настроечбки + diff --git a/app/src/main/res/values-v27/themes.xml b/app/src/main/res/values-v27/themes.xml index ab0e9e61..6646f962 100644 --- a/app/src/main/res/values-v27/themes.xml +++ b/app/src/main/res/values-v27/themes.xml @@ -2,7 +2,7 @@ diff --git a/app/src/main/res/values-v31/monet_colors.xml b/app/src/main/res/values-v31/monet_colors.xml index 36b888d6..d8219651 100644 --- a/app/src/main/res/values-v31/monet_colors.xml +++ b/app/src/main/res/values-v31/monet_colors.xml @@ -1,51 +1,51 @@ - @android:color/system_accent1_0 - @android:color/system_accent1_100 - @android:color/system_accent1_200 - @android:color/system_accent1_400 - @android:color/system_accent1_500 - @android:color/system_accent1_600 - @android:color/system_accent1_700 - @android:color/system_accent1_800 - @android:color/system_accent1_900 - @android:color/system_accent1_1000 + @android:color/system_accent1_0 + @android:color/system_accent1_100 + @android:color/system_accent1_200 + @android:color/system_accent1_400 + @android:color/system_accent1_500 + @android:color/system_accent1_600 + @android:color/system_accent1_700 + @android:color/system_accent1_800 + @android:color/system_accent1_900 + @android:color/system_accent1_1000 - @android:color/system_accent2_0 - @android:color/system_accent2_100 - @android:color/system_accent2_200 - @android:color/system_accent2_300 - @android:color/system_accent2_600 - @android:color/system_accent2_700 - @android:color/system_accent2_800 - @android:color/system_accent2_900 + @android:color/system_accent2_0 + @android:color/system_accent2_100 + @android:color/system_accent2_200 + @android:color/system_accent2_300 + @android:color/system_accent2_600 + @android:color/system_accent2_700 + @android:color/system_accent2_800 + @android:color/system_accent2_900 - @android:color/system_accent3_0 - @android:color/system_accent3_100 - @android:color/system_accent3_200 - @android:color/system_accent3_600 - @android:color/system_accent3_700 - @android:color/system_accent3_800 - @android:color/system_accent3_900 + @android:color/system_accent3_0 + @android:color/system_accent3_100 + @android:color/system_accent3_200 + @android:color/system_accent3_600 + @android:color/system_accent3_700 + @android:color/system_accent3_800 + @android:color/system_accent3_900 - @android:color/system_neutral1_0 - @android:color/system_neutral1_50 - @android:color/system_neutral1_100 - @android:color/system_neutral1_200 - @android:color/system_neutral1_400 - @android:color/system_neutral1_700 - @android:color/system_neutral1_800 - @android:color/system_neutral1_900 + @android:color/system_neutral1_0 + @android:color/system_neutral1_50 + @android:color/system_neutral1_100 + @android:color/system_neutral1_200 + @android:color/system_neutral1_400 + @android:color/system_neutral1_700 + @android:color/system_neutral1_800 + @android:color/system_neutral1_900 - #80E2E1E5 - #801B1B1D + #80E2E1E5 + #801B1B1D - @android:color/system_neutral2_0 - @android:color/system_neutral2_10 - @android:color/system_neutral2_100 - @android:color/system_neutral2_500 - @android:color/system_neutral2_600 - @android:color/system_neutral2_800 + @android:color/system_neutral2_0 + @android:color/system_neutral2_10 + @android:color/system_neutral2_100 + @android:color/system_neutral2_500 + @android:color/system_neutral2_600 + @android:color/system_neutral2_800 - \ No newline at end of file + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index f98ec6d3..a0f15fe1 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -36,4 +36,13 @@ - \ No newline at end of file + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index a5122111..a8820d05 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,5 +1,9 @@ + #ff0000 + #00ff00 + #0000ff + @color/a1_600 @color/a1_0 @color/a1_100 @@ -45,4 +49,9 @@ @color/a2_300 @color/a1_0 + + @color/n1_50 + @color/n1_10 + + #33000000 diff --git a/app/src/main/res/values/monet_colors.xml b/app/src/main/res/values/monet_colors.xml index 5ce1bc65..af7f8a00 100644 --- a/app/src/main/res/values/monet_colors.xml +++ b/app/src/main/res/values/monet_colors.xml @@ -1,50 +1,50 @@ - #FFFFFF - #D8E1FC - #B1C6FA - #598DF7 - #3771DF - #2559BC - #194290 - #0F2D67 - #061A41 - #000000 + #FFFFFF + #D8E1FC + #B1C6FA + #598DF7 + #3771DF + #2559BC + #194290 + #0F2D67 + #061A41 + #000000 - #FEFEFE - #DCE1F7 - #C0C6DA - #A4ABBF - #585E6F - #414757 - #2A3040 - #151C2B + #FEFEFE + #DCE1F7 + #C0C6DA + #A4ABBF + #585E6F + #414757 + #2A3040 + #151C2B - #FFFFFF - #F8D6FC - #DEBAE5 - #715379 - #583C61 - #40254A - #2A0F33 + #FFFFFF + #F8D6FC + #DEBAE5 + #715379 + #583C61 + #40254A + #2A0F33 - #FBF9FC - #F1F1F1 - #E2E1E5 + #FBF9FC + #F1F1F1 + #E2E1E5 #80E2E1E5 - #C7C6C9 - #919094 - #46464A - #303033 - #1B1B1D + #C7C6C9 + #919094 + #46464A + #303033 + #1B1B1D #801B1B1D - #FFFFFF - #FDFBFE - #E0E2EB - #74767D - #5C5E65 - #2F3037 + #FFFFFF + #FDFBFE + #E0E2EB + #74767D + #5C5E65 + #2F3037 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81b0199a..34a0556d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,8 +8,12 @@ Profile Favorites Settings + Static Settings - Error: %s + Error occurred + Error: %s + + Error Password @@ -54,6 +58,7 @@ Validation required Unknown error occurred Authorization failed + Access token expired %s created «%s» @@ -140,7 +145,7 @@ Delete Delete the conversation? Sign out - Sign out + Sign out? Unpin Pin Unpin the conversation? @@ -166,17 +171,21 @@ Current version: %s Changelog is missing - Changelog + Show changelog Check updates + Install Download Try again New version available! + Update downloaded! v. %s No updates + Issues with installing? Try again later - Error occurred - Error: %s Unknown sources installing is disabled in the settings. Open settings? + Downloading update… + In case of any kind of problem related to installing new version you can delete and then re-download executable *.apk file. Proceed? + Installed executable *.apk file not found. Try to re-download it Warning @@ -196,5 +205,18 @@ Fast login $login;$password + Are you sure you want to remove this member? + User banned + Blocking reason + Account temporarily blocked + User name + Sign in to VK + + + Выйти внаружу? + Search + Stop + + Value diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 0da3dc0a..ca2a5379 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -17,4 +17,25 @@ 15dp - \ No newline at end of file + + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index b570edc2..9516afa1 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,61 +2,31 @@ - \ No newline at end of file + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 00000000..d10e89d9 --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index a7fa4c3b..c160fcbf 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -12,4 +12,7 @@ - \ No newline at end of file + + diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 00000000..381211ff --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index a725f7ee..58a69141 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,13 +5,16 @@ buildscript { mavenCentral() } dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10") - classpath("com.android.tools.build:gradle:7.2.2") - - classpath("com.google.dagger:hilt-android-gradle-plugin:2.40.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10") + classpath("com.android.tools.build:gradle:8.1.0") } } +plugins { + id("org.jetbrains.kotlin.android") version "1.8.20" apply false + id("com.google.devtools.ksp") version "1.8.20-1.0.11" apply false +} + allprojects { repositories { google() @@ -22,4 +25,4 @@ allprojects { tasks.register("clean", Delete::class) { delete(rootProject.buildDir) -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index e5edfa7b..9f19457d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,10 @@ -org.gradle.jvmargs=-Xmx4096M -XX:MaxPermSize=4096m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.daemon=true org.gradle.parallel=true org.gradle.configureondemand=false android.useAndroidX=true -android.enableJetifier=true -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +org.gradle.unsafe.configuration-cache=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aa991fce..7d347fcf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Tue Feb 14 16:22:40 MSK 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/ota_alpha.json b/ota_alpha.json deleted file mode 100644 index 07c89ef7..00000000 --- a/ota_alpha.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "1.4.8", - "link": "https://github.com/melod1n/fast-messenger/releases/download/1.4.8/app-alpha.apk", - "changelogs": { - "1.4.3": "* forwards attachment\n* fix empty screen after clear database", - "1.4.4": "* hotfix crash on conversations list", - "1.4.5": "* ability to see changelog on update screen\n* settings screen\n* ACRA Crash Reporter", - "1.4.6": "* hotfix light status bar on android < 8", - "1.4.7": "* multiline dialogs in settings\n* fix crash on 2FA sign in", - "1.4.8": "* Microsoft AppCenter integration with crashes and distribution" - } -} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 1e8d82b3..55ba3730 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,116 +1,2 @@ rootProject.name = "fast-messenger" include(":app") - -enableFeaturePreview("VERSION_CATALOGS") - -dependencyResolutionManagement { - versionCatalogs { - create("libs") { - // androidx - Core - library("androidx-core", "androidx.core:core-ktx:1.8.0") - - // androidx - Lifecycle - version("androidx-lifecycle", "2.5.1") - library("androidx-lifecycle-viewmodel", "androidx.lifecycle", "lifecycle-viewmodel-ktx").versionRef("androidx-lifecycle") - library("androidx-lifecycle-livedata", "androidx.lifecycle", "lifecycle-livedata-ktx").versionRef("androidx-lifecycle") - library("androidx-lifecycle-runtime", "androidx.lifecycle", "lifecycle-runtime-ktx").versionRef("androidx-lifecycle") - library("androidx-lifecycle-viewmodel-savedstate", "androidx.lifecycle", "lifecycle-viewmodel-savedstate").versionRef("androidx-lifecycle") - library("androidx-lifecycle-common-java8", "androidx.lifecycle", "lifecycle-common-java8").versionRef("androidx-lifecycle") - - // androidx - SplashScreen - library("androidx-splashScreen", "androidx.core:core-splashscreen:1.0.0") - - // androidx - DataStore - library("androidx-dataStore", "androidx.datastore:datastore-preferences:1.0.0") - - // androidx - AppCompat - library("androidx-appCompat", "androidx.appcompat:appcompat:1.5.0") - - // androidx - Activity - library("androidx-activity", "androidx.activity:activity-ktx:1.5.1") - - // androidx - Fragment - library("androidx-fragment", "androidx.fragment:fragment-ktx:1.5.2") - - // androidx - Preference - library("androidx-preference", "androidx.preference:preference-ktx:1.2.0") - - // androidx - SwipeRefreshLayout - library("androidx-swipeRefreshLayout", "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - - // androidx - RecyclerView - library("androidx-recyclerView", "androidx.recyclerview:recyclerview:1.2.1") - - // androidx - CardView - library("androidx-cardView", "androidx.cardview:cardview:1.0.0") - - // androidx - ConstraintLayout - library("androidx-constraintLayout", "androidx.constraintlayout:constraintlayout:2.1.4") - - // androidx - Room - version("room", "2.4.3") - library("androidx-room", "androidx.room", "room-ktx").versionRef("room") - library("androidx-room-runtime", "androidx.room", "room-runtime").versionRef("room") - library("androidx-room-compiler", "androidx.room", "room-compiler").versionRef("room") - - // Cicerone - library("cicerone", "com.github.terrakok:cicerone:7.1") - - // WaveformSeekBar - library("waveformSeekBar", "com.github.massoudss:waveformSeekBar:5.0.0") - - // Glide - version("glide", "4.13.0") - library("glide", "com.github.bumptech.glide", "glide").versionRef("glide") - library("glide-compiler", "com.github.bumptech.glide", "compiler").versionRef("glide") - - // KPermissions - version("kPermissions", "3.3.0") - library("kPermissions", "com.github.fondesa", "kpermissions").versionRef("kPermissions") - library("kPermissions-coroutines", "com.github.fondesa", "kpermissions-coroutines").versionRef("kPermissions") - - // Microsoft AppCenter - version("appCenterSdk", "4.3.1") - library("appCenter-analytics", "com.microsoft.appcenter", "appcenter-analytics").versionRef("appCenterSdk") - library("appCenter-crashes", "com.microsoft.appcenter", "appcenter-crashes").versionRef("appCenterSdk") - - // Hilt - version("hilt", "2.39.1") - library("hilt", "com.google.dagger", "hilt-android").versionRef("hilt") - library("hilt-compiler", "com.google.dagger", "hilt-android-compiler").versionRef("hilt") - - // Retrofit - version("retrofit", "2.9.0") - library("retrofit", "com.squareup.retrofit2", "retrofit").versionRef("retrofit") - library("retrofit-gson-converter", "com.squareup.retrofit2", "converter-gson").versionRef("retrofit") - - // OkHttp3 - version("okhttp3", "5.0.0-alpha.2") - library("okhttp3", "com.squareup.okhttp3", "okhttp").versionRef("okhttp3") - library("okhttp3-interceptor", "com.squareup.okhttp3", "logging-interceptor").versionRef("okhttp3") - - // Coroutines - version("coroutines", "1.6.1") - library("coroutines-core", "org.jetbrains.kotlinx", "kotlinx-coroutines-core").versionRef("coroutines") - library("coroutines-android", "org.jetbrains.kotlinx", "kotlinx-coroutines-android").versionRef("coroutines") - - // ViewBinding Delegate - library("viewBindingDelegate", "com.github.yogacp:android-viewbinding:1.0.4") - - // Google - Gson - library("google-gson", "com.google.code.gson:gson:2.8.9") - - // Google - Guava - library("google-guava", "com.google.guava:guava:31.1-android") - - // Google - Material - library("google-material", "com.google.android.material:material:1.6.1") - - // Jsoup - library("jsoup", "org.jsoup:jsoup:1.15.1") - - // Chucker - library("chucker", "com.github.chuckerteam.chucker:library:3.5.2") - } - } -} \ No newline at end of file