diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..015d3bc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,34 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +tab_width = 4 +no-unused-imports = true +no-wildcard-import = true +max_line_length = 100 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{kt,kts}] +ktlint_code_style = ktlint_official +ktlint_function_naming_ignore_when_annotated_with=Composable +ktlint_standard_annotation = disabled +ktlint_ignore_back_ticked_identifier = true +ktlint_standard = enabled +ktlint_standard_multiline-if-else = disabled + +# Don't allow any wildcard imports +ij_kotlin_packages_to_use_import_on_demand = unset + +# Prevent wildcard imports +ij_kotlin_name_count_to_use_star_import = 99 +ij_kotlin_name_count_to_use_star_import_for_members = 99 + +[*.md] +trim_trailing_whitespace = false + +[**/test/**.kt] +max_line_length=off diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6da73a..10d3d33 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,83 +1,100 @@ name: Build the app on: - push: - branches: [ main ] - pull_request: - branches: [ main ] + push: + branches: [ main ] + pull_request: + branches: [ main ] env: - BRANCH_NAME: ${{ github.ref_name }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH_NAME: ${{ github.ref_name }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: - check: - if: ${{ startsWith(github.actor, 'dependabot') }} - environment: Development - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'adopt' - cache: gradle - - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@v3 - - - name: Build debug APK - run: ./gradlew assembleDebug - - build: - environment: Development - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'adopt' - cache: gradle - - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@v3 - - - name: Decrypt the keystore for signing - run: | - echo "${{ secrets.KEYSTORE_ENCRYPTED }}" > keystore.asc - gpg -d --passphrase "${{ secrets.KEYSTORE_PASSWORD }}" --batch keystore.asc > keystore.jks - - - name: Build release APK - run: ./gradlew assembleRelease - - - name: Upload APK - uses: actions/upload-artifact@v4 - with: - name: ark-drop-release - path: ./app/build/outputs/apk/release/ark-drop-release.apk - lint: - needs: build - environment: Development - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'adopt' - - - name: Run linter - run: ./gradlew lint - - - uses: actions/upload-artifact@v4 - with: - name: lint-results - path: ./app/build/reports/*.html + check: + needs: [lint, ktlint] + if: ${{ startsWith(github.actor, 'dependabot') }} + environment: Development + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + cache: gradle + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v3 + + - name: Build debug APK + run: ./gradlew assembleDebug + + build: + needs: [lint, ktlint] + environment: Development + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + cache: gradle + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v3 + + - name: Decrypt the keystore for signing + run: | + echo "${{ secrets.KEYSTORE_ENCRYPTED }}" > keystore.asc + gpg -d --passphrase "${{ secrets.KEYSTORE_PASSWORD }}" --batch keystore.asc > keystore.jks + + - name: Build release APK + run: ./gradlew assembleRelease + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: ark-drop-release + path: ./app/build/outputs/apk/release/ark-drop-release.apk + + lint: + environment: Development + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + + - name: Run linter + run: ./gradlew lint + + - uses: actions/upload-artifact@v4 + with: + name: lint-results + path: ./app/build/reports/*.html + + ktlint: + environment: Development + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'adopt' + + - name: Run KtLint + run: ./gradlew ktlintCheck diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dbc414b..15edf56 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,11 +1,19 @@ +import com.android.build.gradle.internal.tasks.factory.dependsOn + plugins { - kotlin("kapt") version "2.2.0" - kotlin("plugin.serialization") version "1.9.23" alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - id("com.google.dagger.hilt.android") version "2.56.2" - id("com.github.triplet.play") version "3.10.1" + alias(libs.plugins.triplet.play) + alias(libs.plugins.ksp) + alias(libs.plugins.ktlint.gradle) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + compilerOptions { + jvmToolchain(11) + } } android { @@ -23,17 +31,20 @@ android { defaultConfig { applicationId = "dev.arkbuilders.drop.app" - minSdk = 29 + minSdk = 26 targetSdk = 36 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - setProperty("archivesBaseName", "ark-drop") } + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + } + buildTypes { debug { applicationIdSuffix = ".debug" @@ -48,7 +59,7 @@ android { signingConfig = signingConfigs.getByName("testRelease") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" + "proguard-rules.pro", ) // Enable R8 full mode @@ -63,10 +74,6 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } - buildFeatures { compose = true buildConfig = true @@ -75,17 +82,19 @@ android { packaging { jniLibs.excludes.add("META-INF/AL2.0") jniLibs.excludes.add("META-INF/LGPL2.1") - resources.excludes.addAll(listOf( - "META-INF/DEPENDENCIES", - "META-INF/LICENSE", - "META-INF/LICENSE.txt", - "META-INF/license.txt", - "META-INF/NOTICE", - "META-INF/NOTICE.txt", - "META-INF/notice.txt", - "META-INF/ASL2.0", - "META-INF/*.kotlin_module" - )) + resources.excludes.addAll( + listOf( + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/license.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/notice.txt", + "META-INF/ASL2.0", + "META-INF/*.kotlin_module", + ), + ) } bundle { @@ -122,7 +131,7 @@ dependencies { } } //noinspection Aligned16KB - implementation("dev.arkbuilders:drop:17348879247") { + implementation(libs.arkbuilders.drop) { artifact { extension = "aar" type = "aar" @@ -141,22 +150,19 @@ dependencies { implementation(libs.mlkit.barcode.scanning) implementation(libs.accompanist.permissions) - // DAGGER SETUP - implementation("com.google.dagger:hilt-android:2.56.2") - kapt("com.google.dagger:hilt-compiler:2.56.2") + implementation(libs.timber) - // For instrumentation tests - androidTestImplementation("com.google.dagger:hilt-android-testing:2.56.2") - kaptAndroidTest("com.google.dagger:hilt-compiler:2.56.2") + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) - // For local unit tests - testImplementation("com.google.dagger:hilt-android-testing:2.56.2") - kaptTest("com.google.dagger:hilt-compiler:2.56.2") + implementation(libs.orbit.compose) + implementation(libs.orbit.viewmodel) // EXTRA ICONS - implementation("br.com.devsrsouza.compose.icons:simple-icons:1.1.0") - implementation("br.com.devsrsouza.compose.icons:font-awesome:1.1.0") - implementation("br.com.devsrsouza.compose.icons:tabler-icons:1.1.0") + implementation(libs.simple.icons) + implementation(libs.font.awesome) + implementation(libs.tabler.icons) // DEVELOPMENT SETUP testImplementation(libs.junit) @@ -168,16 +174,22 @@ dependencies { debugImplementation(libs.androidx.ui.test.manifest) // File-system profile manager - implementation("io.coil-kt:coil-compose:2.5.0") - implementation("androidx.compose.foundation:foundation:1.4.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") -} + implementation(libs.io.coil) + implementation(libs.androidx.compose.foundation) + implementation(libs.kotlinx.serialization) -kapt { - correctErrorTypes = true + implementation(libs.ark.about) + + // Koin Dependency Injection + implementation(libs.io.koin.core) + implementation(libs.io.koin.android) + implementation(libs.io.koin.compose) + implementation(libs.io.koin.test) } +tasks.preBuild.dependsOn(tasks.ktlintCheck) +tasks.ktlintCheck.dependsOn(tasks.ktlintFormat) + tasks.named("clean") { delete(fileTree("$projectDir/src/main/jniLibs")) } - diff --git a/app/schemas/dev.arkbuilders.drop.app.data.db.Database/1.json b/app/schemas/dev.arkbuilders.drop.app.data.db.Database/1.json new file mode 100644 index 0000000..f9a8d14 --- /dev/null +++ b/app/schemas/dev.arkbuilders.drop.app.data.db.Database/1.json @@ -0,0 +1,66 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "be40b7222d453a16e4b006fe48729c8e", + "entities": [ + { + "tableName": "RoomTransferSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `files` TEXT NOT NULL, `type` TEXT NOT NULL, `timestamp` TEXT NOT NULL, `status` TEXT NOT NULL, `peerName` TEXT NOT NULL, `peerAvatar` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "files", + "columnName": "files", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerName", + "columnName": "peerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerAvatar", + "columnName": "peerAvatar", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, 'be40b7222d453a16e4b006fe48729c8e')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/arkbuilders/drop/app/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/arkbuilders/drop/app/ExampleInstrumentedTest.kt index 12b1c32..2d5d626 100644 --- a/app/src/androidTest/java/dev/arkbuilders/drop/app/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/dev/arkbuilders/drop/app/ExampleInstrumentedTest.kt @@ -19,4 +19,4 @@ class ExampleInstrumentedTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext Assert.assertEquals("dev.arkbuilders.drop", appContext.packageName) } -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 15f87b0..ebfa873 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,7 @@ diff --git a/app/src/main/java/dev/arkbuilders/drop/app/DropApplication.kt b/app/src/main/java/dev/arkbuilders/drop/app/DropApplication.kt deleted file mode 100644 index 0ed5b59..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/DropApplication.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.arkbuilders.drop.app - -import android.app.Application -import dagger.hilt.android.HiltAndroidApp - -@HiltAndroidApp -class DropApplication : Application() diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ProfileManager.kt b/app/src/main/java/dev/arkbuilders/drop/app/ProfileManager.kt deleted file mode 100644 index b8bdea9..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ProfileManager.kt +++ /dev/null @@ -1,98 +0,0 @@ -package dev.arkbuilders.drop.app - -import android.content.Context -import android.content.SharedPreferences -import dagger.hilt.android.qualifiers.ApplicationContext -import dev.arkbuilders.drop.app.ui.profile.AvatarUtils -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import javax.inject.Inject -import javax.inject.Singleton - -@Serializable -data class UserProfile( - val name: String = "", - val avatarB64: String = "", - val avatarId: String = "avatar_00" -) - -@Singleton -class ProfileManager @Inject constructor( - @ApplicationContext private val context: Context -) { - companion object { - private const val PREFS_NAME = "drop_profile" - private const val KEY_PROFILE = "user_profile" - } - - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true } - - private val _profile = MutableStateFlow(loadProfile()) - val profile: StateFlow = _profile.asStateFlow() - - private fun loadProfile(): UserProfile { - val profileJson = prefs.getString(KEY_PROFILE, null) - return if (profileJson != null) { - try { - json.decodeFromString(profileJson) - } catch (e: Exception) { - createDefaultProfile() - } - } else { - createDefaultProfile() - } - } - - private fun createDefaultProfile(): UserProfile { - val defaultProfile = UserProfile( - name = "Anonymous", - avatarB64 = AvatarUtils.getDefaultAvatarBase64(context, "avatar_00"), - avatarId = "avatar_00" - ) - saveProfile(defaultProfile) - return defaultProfile - } - - fun updateProfile(profile: UserProfile) { - _profile.value = profile - saveProfile(profile) - } - - fun updateName(name: String) { - val updatedProfile = _profile.value.copy(name = name) - updateProfile(updatedProfile) - } - - fun updateAvatar(avatarId: String) { - val avatarB64 = AvatarUtils.getDefaultAvatarBase64(context, avatarId) - val updatedProfile = _profile.value.copy( - avatarId = avatarId, - avatarB64 = avatarB64 - ) - updateProfile(updatedProfile) - } - - fun updateCustomAvatar(base64: String) { - val updatedProfile = _profile.value.copy( - avatarB64 = base64, - avatarId = "custom" - ) - updateProfile(updatedProfile) - } - - private fun saveProfile(profile: UserProfile) { - try { - val profileJson = json.encodeToString(profile) - prefs.edit().putString(KEY_PROFILE, profileJson).apply() - } catch (e: Exception) { - // Handle serialization error - } - } - - fun getCurrentProfile(): UserProfile = _profile.value -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/TransferManager.kt b/app/src/main/java/dev/arkbuilders/drop/app/TransferManager.kt deleted file mode 100644 index 28756c9..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/TransferManager.kt +++ /dev/null @@ -1,476 +0,0 @@ -package dev.arkbuilders.drop.app - -import android.content.ContentValues -import android.content.Context -import android.net.Uri -import android.os.Environment -import android.provider.MediaStore -import android.provider.OpenableColumns -import android.util.Log -import dagger.hilt.android.qualifiers.ApplicationContext -import dev.arkbuilders.drop.* -import dev.arkbuilders.drop.app.data.HistoryRepository -import dev.arkbuilders.drop.app.data.ReceiveFilesSubscriberImpl -import dev.arkbuilders.drop.app.data.SendFilesSubscriberImpl -import dev.arkbuilders.drop.app.data.SenderFileDataImpl -import dev.arkbuilders.drop.app.data.TransferStatus -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileOutputStream -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class TransferManager @Inject constructor( - @ApplicationContext private val context: Context, - private val profileManager: ProfileManager, - private val historyRepository: HistoryRepository -) { - companion object { - private const val TAG = "TransferManager" - } - - private var currentSendBubble: SendFilesBubble? = null - private var currentReceiveBubble: ReceiveFilesBubble? = null - private var sendSubscriber: SendFilesSubscriberImpl? = null - private var receiveSubscriber: ReceiveFilesSubscriberImpl? = null - - val sendProgress: StateFlow? - get() = sendSubscriber?.progress - - val receiveProgress: StateFlow? - get() = receiveSubscriber?.progress - - suspend fun sendFiles(fileUris: List): SendFilesBubble? = withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Starting file send for ${fileUris.size} files") - - val profile = profileManager.getCurrentProfile() - val senderProfile = SenderProfile( - name = profile.name.ifEmpty { "Anonymous" }, - avatarB64 = profile.avatarB64.takeIf { it.isNotEmpty() } - ) - - val senderFiles = fileUris.mapNotNull { uri -> - val fileName = getFileName(uri) - if (fileName != null) { - val fileData = SenderFileDataImpl(context, uri) - SenderFile( - name = fileName, - data = fileData - ) - } else { - Log.w(TAG, "Could not get filename for URI: $uri") - null - } - } - - if (senderFiles.isEmpty()) { - Log.e(TAG, "No valid files to send") - return@withContext null - } - - val request = SendFilesRequest( - profile = senderProfile, - files = senderFiles, - config = SenderConfig( - chunkSize = 1024u * 512u, - parallelStreams = 4u, - ), - ) - - // Create and subscribe to bubble - val bubble = sendFiles(request) - currentSendBubble = bubble - - // Set up subscriber - sendSubscriber = SendFilesSubscriberImpl().also { subscriber -> - bubble.subscribe(subscriber) - } - - Log.d(TAG, "Send bubble created with ticket and confirmation: ${bubble.getTicket()} ${bubble.getConfirmation()}") - bubble - - } catch (e: Exception) { - Log.e(TAG, "Error starting file send", e) - null - } - } - - suspend fun receiveFiles(ticket: String, confirmation: UByte): ReceiveFilesBubble? = - withContext(Dispatchers.IO) { - try { - Log.d(TAG, "Starting file receive with ticket: $ticket") - - val profile = profileManager.getCurrentProfile() - val receiverProfile = ReceiverProfile( - name = profile.name.ifEmpty { "Anonymous" }, - avatarB64 = profile.avatarB64.takeIf { it.isNotEmpty() } - ) - - val request = ReceiveFilesRequest( - ticket = ticket, - confirmation = confirmation, - profile = receiverProfile, - config = ReceiverConfig( - chunkSize = 1024u * 512u, - parallelStreams = 4u, - ) - ) - - // Create and subscribe to bubble - val bubble = receiveFiles(request) - currentReceiveBubble = bubble - - // Set up subscriber - receiveSubscriber = ReceiveFilesSubscriberImpl().also { subscriber -> - bubble.subscribe(subscriber) - } - - // Start receiving - bubble.start() - - Log.d(TAG, "Receive bubble created and started") - bubble - - } catch (e: Exception) { - Log.e(TAG, "Error starting file receive", e) - null - } - } - - suspend fun saveReceivedFiles(): List = withContext(Dispatchers.IO) { - val subscriber = receiveSubscriber ?: return@withContext emptyList() - val completeFiles = subscriber.getCompleteFiles() - val savedFiles = mutableListOf() - - try { - completeFiles.forEach { (fileInfo, data) -> - val savedFile = saveFileToDownloads(fileInfo.name, data) - if (savedFile != null) { - savedFiles.add(savedFile) - Log.d(TAG, "Saved file: ${savedFile.absolutePath}") - } else { - Log.e(TAG, "Failed to save file: ${fileInfo.name}") - } - } - - // Add to history if files were saved successfully - if (savedFiles.isNotEmpty()) { - val progress = receiveSubscriber?.progress?.value - val senderName = progress?.senderName ?: "Unknown" - val senderAvatar = progress?.senderAvatar - - val totalSize = completeFiles.sumOf { it.second.size.toLong() } - val firstFileName = savedFiles.firstOrNull()?.name ?: "Unknown" - - historyRepository.addReceivedTransfer( - fileName = firstFileName, - fileSize = totalSize, - peerName = senderName, - peerAvatar = senderAvatar, - fileCount = savedFiles.size, - status = TransferStatus.COMPLETED - ) - } - - } catch (e: Exception) { - Log.e(TAG, "Error saving received files", e) - - // Add failed transfer to history - val progress = receiveSubscriber?.progress?.value - val senderName = progress?.senderName ?: "Unknown" - val senderAvatar = progress?.senderAvatar - - historyRepository.addReceivedTransfer( - fileName = "Transfer failed", - fileSize = 0L, - peerName = senderName, - peerAvatar = senderAvatar, - fileCount = completeFiles.size, - status = TransferStatus.FAILED - ) - } - - savedFiles - } - - fun recordSendCompletion(fileUris: List) { - try { - val progress = sendSubscriber?.progress?.value - val receiverName = progress?.receiverName ?: "Unknown" - val receiverAvatar = progress?.receiverAvatar - - val totalSize = fileUris.sumOf { uri -> - try { - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) - if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0L - } else 0L - } ?: 0L - } catch (_: Exception) { - 0L - } - } - - val firstFileName = getFileName(fileUris.firstOrNull()) ?: "Unknown" - - historyRepository.addSentTransfer( - fileName = firstFileName, - fileSize = totalSize, - peerName = receiverName, - peerAvatar = receiverAvatar, - fileCount = fileUris.size, - status = TransferStatus.COMPLETED - ) - } catch (e: Exception) { - Log.e(TAG, "Error recording send completion", e) - } - } - - private suspend fun saveFileToDownloads(fileName: String, data: ByteArray): File? = - withContext(Dispatchers.IO) { - try { - // Use MediaStore for Android 10+ (Scoped Storage) - return@withContext saveFileUsingMediaStore(fileName, data) - } catch (e: Exception) { - Log.e(TAG, "Error saving file: $fileName", e) - return@withContext null - } - } - - private fun saveFileUsingMediaStore(fileName: String, data: ByteArray): File? { - try { - val resolver = context.contentResolver - - // Generate unique filename to avoid conflicts - val uniqueFileName = generateUniqueFileName(fileName) - - // Create content values for the file - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, uniqueFileName) - put(MediaStore.MediaColumns.MIME_TYPE, getMimeType(uniqueFileName)) - put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) - } - - // Insert the file into MediaStore - val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) - - if (uri != null) { - // Write the file data - resolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(data) - outputStream.flush() - } - - // Get the actual file path for return - val actualFile = getFileFromMediaStoreUri(uri, uniqueFileName) - Log.d(TAG, "File saved using MediaStore: $uniqueFileName") - return actualFile - } else { - Log.e(TAG, "Failed to create MediaStore entry for: $uniqueFileName") - return null - } - } catch (e: Exception) { - Log.e(TAG, "Error saving file using MediaStore: $fileName", e) - return null - } - } - - private fun saveFileUsingLegacyStorage(fileName: String, data: ByteArray): File? { - try { - val downloadsDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - if (!downloadsDir.exists()) { - downloadsDir.mkdirs() - } - - // Generate unique filename to avoid conflicts - val uniqueFileName = generateUniqueFileNameForDirectory(downloadsDir, fileName) - val file = File(downloadsDir, uniqueFileName) - - FileOutputStream(file).use { outputStream -> - outputStream.write(data) - outputStream.flush() - } - - Log.d(TAG, "File saved using legacy storage: ${file.absolutePath}") - return file - } catch (e: Exception) { - Log.e(TAG, "Error saving file using legacy storage: $fileName", e) - return null - } - } - - private fun generateUniqueFileName(originalFileName: String): String { - val downloadsDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - return generateUniqueFileNameForDirectory(downloadsDir, originalFileName) - } - - private fun generateUniqueFileNameForDirectory( - directory: File, - originalFileName: String - ): String { - val nameWithoutExt = originalFileName.substringBeforeLast(".", originalFileName) - val extension = originalFileName.substringAfterLast(".", "") - - var counter = 1 - var candidateFileName = originalFileName - var candidateFile = File(directory, candidateFileName) - - // Keep incrementing counter until we find a unique filename - while (candidateFile.exists() || isFileNameInMediaStore(candidateFileName)) { - candidateFileName = if (extension.isNotEmpty()) { - "${nameWithoutExt}($counter).$extension" - } else { - "${nameWithoutExt}($counter)" - } - candidateFile = File(directory, candidateFileName) - counter++ - - // Safety check to prevent infinite loop - if (counter > 1000) { - Log.w(TAG, "Too many duplicate files, using timestamp suffix") - val timestamp = System.currentTimeMillis() - candidateFileName = if (extension.isNotEmpty()) { - "${nameWithoutExt}_$timestamp.$extension" - } else { - "${nameWithoutExt}_$timestamp" - } - break - } - } - - Log.d(TAG, "Generated unique filename: $candidateFileName (original: $originalFileName)") - return candidateFileName - } - - private fun isFileNameInMediaStore(fileName: String): Boolean { - return try { - val resolver = context.contentResolver - val projection = arrayOf(MediaStore.MediaColumns.DISPLAY_NAME) - val selection = - "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} = ?" - val selectionArgs = arrayOf(fileName, "${Environment.DIRECTORY_DOWNLOADS}/") - - resolver.query( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, - projection, - selection, - selectionArgs, - null - )?.use { cursor -> - cursor.count > 0 - } ?: false - } catch (e: Exception) { - Log.w(TAG, "Error checking MediaStore for filename: $fileName", e) - false - } - } - - private fun getFileFromMediaStoreUri(uri: Uri, fileName: String): File { - // For MediaStore files, we create a reference file object - // The actual file is managed by the system - val downloadsDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - return File(downloadsDir, fileName) - } - - private fun getMimeType(fileName: String): String { - val extension = fileName.substringAfterLast(".", "").lowercase() - return when (extension) { - "jpg", "jpeg" -> "image/jpeg" - "png" -> "image/png" - "gif" -> "image/gif" - "webp" -> "image/webp" - "pdf" -> "application/pdf" - "txt" -> "text/plain" - "doc" -> "application/msword" - "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - "xls" -> "application/vnd.ms-excel" - "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - "ppt" -> "application/vnd.ms-powerpoint" - "pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation" - "zip" -> "application/zip" - "rar" -> "application/x-rar-compressed" - "7z" -> "application/x-7z-compressed" - "mp3" -> "audio/mpeg" - "wav" -> "audio/wav" - "flac" -> "audio/flac" - "mp4" -> "video/mp4" - "avi" -> "video/x-msvideo" - "mkv" -> "video/x-matroska" - "mov" -> "video/quicktime" - else -> "application/octet-stream" - } - } - - fun cancelSend() { - try { - currentSendBubble?.let { bubble -> - sendSubscriber?.let { subscriber -> - bubble.unsubscribe(subscriber) - } - // Note: cancel() is async in the UDL, but we'll call it anyway - // bubble.cancel() // Commented out as it's async and we can't await here - } - } catch (e: Exception) { - Log.e(TAG, "Error cancelling send", e) - } finally { - cleanup() - } - } - - fun cancelReceive() { - try { - currentReceiveBubble?.let { bubble -> - receiveSubscriber?.let { subscriber -> - bubble.unsubscribe(subscriber) - } - bubble.cancel() - } - } catch (e: Exception) { - Log.e(TAG, "Error cancelling receive", e) - } finally { - cleanup() - } - } - - fun getCurrentSendTicket(): String? = currentSendBubble?.getTicket() - - fun getCurrentSendConfirmation(): UByte? = currentSendBubble?.getConfirmation() - - fun isSendFinished(): Boolean = currentSendBubble?.isFinished() ?: true - - fun isReceiveFinished(): Boolean = currentReceiveBubble?.isFinished() ?: true - - fun isSendConnected(): Boolean = currentSendBubble?.isConnected() ?: false - - private fun cleanup() { - sendSubscriber?.reset() - receiveSubscriber?.reset() - sendSubscriber = null - receiveSubscriber = null - currentSendBubble = null - currentReceiveBubble = null - } - - private fun getFileName(uri: Uri?): String? { - if (uri == null) return null - return try { - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0) cursor.getString(nameIndex) else null - } else null - } - } catch (e: Exception) { - Log.e(TAG, "Error getting filename for URI: $uri", e) - null - } - } -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/HistoryRepository.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/HistoryRepository.kt deleted file mode 100644 index 9ce02e6..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/data/HistoryRepository.kt +++ /dev/null @@ -1,148 +0,0 @@ -package dev.arkbuilders.drop.app.data - -import android.content.Context -import android.content.SharedPreferences -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import javax.inject.Inject -import javax.inject.Singleton - -@Serializable -data class TransferHistoryItem( - val id: String, - val fileName: String, - val fileSize: Long, - val type: TransferType, - val timestamp: Long, - val status: TransferStatus, - val peerName: String = "Unknown", - val peerAvatar: String? = null, - val fileCount: Int = 1 -) - -@Serializable -enum class TransferType { - SENT, RECEIVED -} - -@Serializable -enum class TransferStatus { - COMPLETED, FAILED, CANCELLED -} - -@Singleton -class HistoryRepository @Inject constructor( - @ApplicationContext private val context: Context -) { - companion object { - private const val PREFS_NAME = "drop_history" - private const val KEY_HISTORY = "transfer_history" - private const val MAX_HISTORY_ITEMS = 100 - } - - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val json = Json { ignoreUnknownKeys = true } - - private val _historyItems = MutableStateFlow(loadHistory()) - val historyItems: StateFlow> = _historyItems.asStateFlow() - - private fun loadHistory(): List { - return try { - val historyJson = prefs.getString(KEY_HISTORY, null) - if (historyJson != null) { - json.decodeFromString>(historyJson) - } else { - emptyList() - } - } catch (e: Exception) { - emptyList() - } - } - - private fun saveHistory(items: List) { - try { - val historyJson = json.encodeToString(items) - prefs.edit().putString(KEY_HISTORY, historyJson).apply() - _historyItems.value = items - } catch (e: Exception) { - // Handle serialization error - } - } - - fun addSentTransfer( - fileName: String, - fileSize: Long, - peerName: String, - peerAvatar: String?, - fileCount: Int = 1, - status: TransferStatus = TransferStatus.COMPLETED - ) { - val newItem = TransferHistoryItem( - id = generateId(), - fileName = if (fileCount > 1) "$fileName and ${fileCount - 1} more" else fileName, - fileSize = fileSize, - type = TransferType.SENT, - timestamp = System.currentTimeMillis(), - status = status, - peerName = peerName, - peerAvatar = peerAvatar, - fileCount = fileCount - ) - - addHistoryItem(newItem) - } - - fun addReceivedTransfer( - fileName: String, - fileSize: Long, - peerName: String, - peerAvatar: String?, - fileCount: Int = 1, - status: TransferStatus = TransferStatus.COMPLETED - ) { - val newItem = TransferHistoryItem( - id = generateId(), - fileName = if (fileCount > 1) "$fileName and ${fileCount - 1} more" else fileName, - fileSize = fileSize, - type = TransferType.RECEIVED, - timestamp = System.currentTimeMillis(), - status = status, - peerName = peerName, - peerAvatar = peerAvatar, - fileCount = fileCount - ) - - addHistoryItem(newItem) - } - - private fun addHistoryItem(item: TransferHistoryItem) { - val currentItems = _historyItems.value.toMutableList() - currentItems.add(0, item) // Add to beginning (most recent first) - - // Keep only the most recent items - if (currentItems.size > MAX_HISTORY_ITEMS) { - currentItems.removeAt(currentItems.size - 1) - } - - saveHistory(currentItems) - } - - fun deleteHistoryItem(itemId: String) { - val currentItems = _historyItems.value.toMutableList() - currentItems.removeAll { it.id == itemId } - saveHistory(currentItems) - } - - fun clearHistory() { - saveHistory(emptyList()) - } - - private fun generateId(): String { - return "${System.currentTimeMillis()}_${(0..999).random()}" - } -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/ReceiveFilesSubscriberImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/ReceiveFilesSubscriberImpl.kt index 3f9a885..575a9eb 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/data/ReceiveFilesSubscriberImpl.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/ReceiveFilesSubscriberImpl.kt @@ -1,12 +1,12 @@ package dev.arkbuilders.drop.app.data -import android.util.Log import dev.arkbuilders.drop.ReceiveFilesConnectingEvent import dev.arkbuilders.drop.ReceiveFilesReceivingEvent import dev.arkbuilders.drop.ReceiveFilesSubscriber import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import timber.log.Timber import java.io.ByteArrayOutputStream import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -16,22 +16,21 @@ data class ReceivingProgress( val senderName: String = "", val senderAvatar: String? = null, val files: List = emptyList(), - val fileProgress: Map = emptyMap() + val fileProgress: Map = emptyMap(), ) data class ReceiveFileInfo( - val id: String, - val name: String, - val size: ULong + val id: String, + val name: String, + val size: ULong, ) data class FileProgressInfo( val receivedBytes: Long = 0L, - val isComplete: Boolean = false + val isComplete: Boolean = false, ) class ReceiveFilesSubscriberImpl : ReceiveFilesSubscriber { - companion object { private const val TAG = "ReceiveFilesSubscriber" } @@ -40,68 +39,74 @@ class ReceiveFilesSubscriberImpl : ReceiveFilesSubscriber { // Thread-safe storage for received data using ByteArrayOutputStream for efficient appending private val receivedDataStreams = ConcurrentHashMap() - + private val _progress = MutableStateFlow(ReceivingProgress()) val progress: StateFlow = _progress.asStateFlow() override fun getId(): String = id override fun log(message: String) { - Log.d(TAG, message) + Timber.tag(TAG).d(message) } override fun notifyReceiving(event: ReceiveFilesReceivingEvent) { - Log.d(TAG, "Receiving data for file: ${event.id}, data size: ${event.data.size}") + Timber.tag(TAG).d("Receiving data for file: ${event.id}, data size: ${event.data.size}") // Get or create ByteArrayOutputStream for this file val stream = receivedDataStreams.getOrPut(event.id) { ByteArrayOutputStream() } - + // Efficiently append data to the stream synchronized(stream) { stream.write(event.data) } - + // Find the file info to get expected size val currentProgress = _progress.value val fileInfo = currentProgress.files.find { it.id == event.id } - + if (fileInfo != null) { val receivedBytes = stream.size().toLong() val isComplete = receivedBytes.toULong() >= fileInfo.size - + // Update progress with new file progress info val updatedFileProgress = currentProgress.fileProgress.toMutableMap() - updatedFileProgress[event.id] = FileProgressInfo( - receivedBytes = receivedBytes, - isComplete = isComplete - ) - + updatedFileProgress[event.id] = + FileProgressInfo( + receivedBytes = receivedBytes, + isComplete = isComplete, + ) + // Emit new state - _progress.value = currentProgress.copy( - fileProgress = updatedFileProgress.toMap() - ) - + _progress.value = + currentProgress.copy( + fileProgress = updatedFileProgress.toMap(), + ) + if (isComplete) { - Log.d(TAG, "File ${fileInfo.name} completed: $receivedBytes bytes") + Timber.tag(TAG).d("File ${fileInfo.name} completed: $receivedBytes bytes") } } } override fun notifyConnecting(event: ReceiveFilesConnectingEvent) { - Log.d(TAG, "Connected to sender: ${event.sender.name}, files: ${event.files.size}") + Timber.tag(TAG).d("Connected to sender: ${event.sender.name}, files: ${event.files.size}") + + val fileInfos = + event.files.map { file -> + ReceiveFileInfo( + id = file.id, + name = file.name, + size = file.len, + ) + } - val fileInfos = event.files.map { file -> - ReceiveFileInfo( - id = file.id, name = file.name, size = file.len + _progress.value = + _progress.value.copy( + isConnected = true, + senderName = event.sender.name, + senderAvatar = event.sender.avatarB64, + files = fileInfos, ) - } - - _progress.value = _progress.value.copy( - isConnected = true, - senderName = event.sender.name, - senderAvatar = event.sender.avatarB64, - files = fileInfos - ) } fun reset() { @@ -132,7 +137,7 @@ class ReceiveFilesSubscriberImpl : ReceiveFilesSubscriber { } } } - + /** * Get progress for a specific file (0.0 to 1.0) */ @@ -140,29 +145,29 @@ class ReceiveFilesSubscriberImpl : ReceiveFilesSubscriber { val currentProgress = _progress.value val fileInfo = currentProgress.files.find { it.id == fileId } val progressInfo = currentProgress.fileProgress[fileId] - + return if (fileInfo != null && progressInfo != null && fileInfo.size > 0UL) { (progressInfo.receivedBytes.toFloat() / fileInfo.size.toFloat()).coerceIn(0f, 1f) } else { 0f } } - + /** * Get received bytes for a specific file */ fun getReceivedBytes(fileId: String): Long { return _progress.value.fileProgress[fileId]?.receivedBytes ?: 0L } - + /** * Check if all files are complete */ - public fun areAllFilesComplete(): Boolean { + fun areAllFilesComplete(): Boolean { val currentProgress = _progress.value - return currentProgress.files.isNotEmpty() && - currentProgress.files.all { file -> - currentProgress.fileProgress[file.id]?.isComplete == true - } + return currentProgress.files.isNotEmpty() && + currentProgress.files.all { file -> + currentProgress.fileProgress[file.id]?.isComplete == true + } } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/SendFilesSubscriberImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/SendFilesSubscriberImpl.kt index 4705990..bbc79b4 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/data/SendFilesSubscriberImpl.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/SendFilesSubscriberImpl.kt @@ -1,12 +1,12 @@ package dev.arkbuilders.drop.app.data -import android.util.Log import dev.arkbuilders.drop.SendFilesConnectingEvent import dev.arkbuilders.drop.SendFilesSendingEvent import dev.arkbuilders.drop.SendFilesSubscriber import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import timber.log.Timber import java.util.UUID data class SendingProgress( @@ -15,46 +15,47 @@ data class SendingProgress( val remaining: ULong = 0UL, val isConnected: Boolean = false, val receiverName: String = "", - val receiverAvatar: String? = null + val receiverAvatar: String? = null, ) class SendFilesSubscriberImpl : SendFilesSubscriber { - companion object { private const val TAG = "SendFilesSubscriber" } - + private val id = UUID.randomUUID().toString() - + private val _progress = MutableStateFlow(SendingProgress()) val progress: StateFlow = _progress.asStateFlow() - + override fun getId(): String = id override fun log(message: String) { - Log.d(TAG, message) + Timber.tag(TAG).d(message) } override fun notifySending(event: SendFilesSendingEvent) { - Log.d(TAG, "Sending progress: ${event.name} - sent: ${event.sent}, remaining: ${event.remaining}") - - _progress.value = _progress.value.copy( - fileName = event.name, - sent = event.sent, - remaining = event.remaining - ) + log("Sending progress: ${event.name} - sent: ${event.sent}, remaining: ${event.remaining}") + + _progress.value = + _progress.value.copy( + fileName = event.name, + sent = event.sent, + remaining = event.remaining, + ) } - + override fun notifyConnecting(event: SendFilesConnectingEvent) { - Log.d(TAG, "Connected to receiver: ${event.receiver.name}") - - _progress.value = _progress.value.copy( - isConnected = true, - receiverName = event.receiver.name, - receiverAvatar = event.receiver.avatarB64 - ) + log("Connected to receiver: ${event.receiver.name}") + + _progress.value = + _progress.value.copy( + isConnected = true, + receiverName = event.receiver.name, + receiverAvatar = event.receiver.avatarB64, + ) } - + fun reset() { _progress.value = SendingProgress() } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/SenderFileDataImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/SenderFileDataImpl.kt index 9379dcd..c933b80 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/data/SenderFileDataImpl.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/SenderFileDataImpl.kt @@ -2,14 +2,14 @@ package dev.arkbuilders.drop.app.data import android.content.Context import android.net.Uri -import android.util.Log import dev.arkbuilders.drop.SenderFileData +import timber.log.Timber import java.io.InputStream class SenderFileDataImpl( - private val context: Context, private val uri: Uri + private val context: Context, + private val uri: Uri, ) : SenderFileData { - companion object { private const val TAG = "SenderFileDataImpl" } @@ -36,9 +36,9 @@ class SenderFileDataImpl( inputStream = context.contentResolver.openInputStream(uri) isInitialized = true - Log.d(TAG, "Initialized SenderFileData for URI: $uri, size: $totalLength") + Timber.tag(TAG).d("Initialized SenderFileData for URI: $uri, size: $totalLength") } catch (e: Exception) { - Log.e(TAG, "Failed to initialize SenderFileData", e) + Timber.tag(TAG).e(e, "Failed to initialize SenderFileData") } } @@ -58,7 +58,7 @@ class SenderFileDataImpl( byte?.toUByte() } } catch (e: Exception) { - Log.e(TAG, "Error reading byte", e) + Timber.tag(TAG).e(e, "Error reading byte") null } } @@ -80,7 +80,7 @@ class SenderFileDataImpl( inputStream?.read(bytes) ?: 0 bytes } catch (e: Exception) { - Log.e(TAG, "Error reading chunk of size $size", e) + Timber.tag(TAG).e(e, "Error reading chunk of size $size") ByteArray(0) } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/datasource/ProfileLocalDataSource.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/datasource/ProfileLocalDataSource.kt new file mode 100644 index 0000000..f0962da --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/datasource/ProfileLocalDataSource.kt @@ -0,0 +1,84 @@ +package dev.arkbuilders.drop.app.data.datasource + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import dev.arkbuilders.drop.app.data.model.UserAvatarDto +import dev.arkbuilders.drop.app.data.model.UserProfileDto +import dev.arkbuilders.drop.app.domain.AvatarHelper +import dev.arkbuilders.drop.app.domain.model.UserAvatar +import dev.arkbuilders.drop.app.domain.model.UserProfile +import kotlinx.serialization.json.Json + +class ProfileLocalDataSource( + context: Context, + private val avatarHelper: AvatarHelper, +) { + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val json = Json { ignoreUnknownKeys = true } + + fun loadProfile(): UserProfile { + val profileJson = prefs.getString(KEY_PROFILE, null) ?: return createDefaultProfile() + + return runCatching { + json + .decodeFromString(profileJson) + .toDomain() + }.getOrElse { + createDefaultProfile() + } + } + + private fun createDefaultProfile(): UserProfile { + val defaultAvatarId = "avatar_00" + val default = + UserProfile( + name = "Anonymous", + avatar = + UserAvatar( + base64 = avatarHelper.getDefaultAvatarBase64(defaultAvatarId), + predefinedId = defaultAvatarId, + ), + ) + saveProfile(default) + return default + } + + fun saveProfile(profile: UserProfile) { + runCatching { + val profileJson = json.encodeToString(profile.toDto()) + prefs.edit { putString(KEY_PROFILE, profileJson) } + } + } + + companion object { + private const val PREFS_NAME = "drop_profile" + private const val KEY_PROFILE = "user_profile" + } +} + +private fun UserProfileDto.toDomain() = + UserProfile( + name = name, + avatar = avatar.toDomain(), + ) + +private fun UserProfile.toDto() = + UserProfileDto( + name = name, + avatar = avatar.toDto(), + ) + +private fun UserAvatar.toDto() = + UserAvatarDto( + base64 = base64, + predefinedId = predefinedId, + ) + +private fun UserAvatarDto.toDomain() = + UserAvatar( + base64 = base64, + predefinedId = predefinedId, + ) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/datasource/TransferSessionLocalDataSource.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/datasource/TransferSessionLocalDataSource.kt new file mode 100644 index 0000000..751d244 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/datasource/TransferSessionLocalDataSource.kt @@ -0,0 +1,44 @@ +package dev.arkbuilders.drop.app.data.datasource + +import dev.arkbuilders.drop.app.data.db.dao.TransferSessionDao +import dev.arkbuilders.drop.app.data.db.entity.RoomTransferSession +import dev.arkbuilders.drop.app.domain.model.TransferSession +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class TransferSessionLocalDataSource( + private val dao: TransferSessionDao, +) { + fun flow(): Flow> = + dao.getAll().map { list -> list.map { it.toDomain() } } + + suspend fun add(item: TransferSession): Long = dao.insert(item.toEntity()) + + suspend fun addAll(items: List) = dao.insertAll(items.map { it.toEntity() }) + + suspend fun delete(itemId: Long) = dao.deleteById(itemId) + + suspend fun clear() = dao.clear() +} + +fun RoomTransferSession.toDomain() = + TransferSession( + id = id, + files = files, + type = type, + timestamp = timestamp, + status = status, + peerName = peerName, + peerAvatar = peerAvatar, + ) + +fun TransferSession.toEntity() = + RoomTransferSession( + id = id, + files = files, + type = type, + timestamp = timestamp, + status = status, + peerName = peerName, + peerAvatar = peerAvatar, + ) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/db/Database.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/db/Database.kt new file mode 100644 index 0000000..4b87b2c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/db/Database.kt @@ -0,0 +1,33 @@ +package dev.arkbuilders.drop.app.data.db + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import dev.arkbuilders.drop.app.data.db.dao.TransferSessionDao +import dev.arkbuilders.drop.app.data.db.entity.RoomTransferSession +import dev.arkbuilders.drop.app.data.db.typeconverters.DropFileListConverter +import dev.arkbuilders.drop.app.data.db.typeconverters.OffsetDateTimeTypeConverter + +@androidx.room.Database( + entities = [ + RoomTransferSession::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters( + OffsetDateTimeTypeConverter::class, + DropFileListConverter::class, +) +abstract class Database : RoomDatabase() { + abstract fun transferHistoryDao(): TransferSessionDao + + companion object { + const val DB_NAME = "arkdrop.db" + + fun build(ctx: Context) = + Room.databaseBuilder(ctx, Database::class.java, DB_NAME) + .build() + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/db/dao/TransferSessionDao.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/db/dao/TransferSessionDao.kt new file mode 100644 index 0000000..61a4fd8 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/db/dao/TransferSessionDao.kt @@ -0,0 +1,26 @@ +package dev.arkbuilders.drop.app.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import dev.arkbuilders.drop.app.data.db.entity.RoomTransferSession +import kotlinx.coroutines.flow.Flow + +@Dao +interface TransferSessionDao { + @Query("SELECT * FROM RoomTransferSession ORDER BY timestamp DESC") + fun getAll(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(item: RoomTransferSession): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(items: List) + + @Query("DELETE FROM RoomTransferSession WHERE id = :itemId") + suspend fun deleteById(itemId: Long) + + @Query("DELETE FROM RoomTransferSession") + suspend fun clear() +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/db/entity/RoomTransferSession.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/db/entity/RoomTransferSession.kt new file mode 100644 index 0000000..3485112 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/db/entity/RoomTransferSession.kt @@ -0,0 +1,20 @@ +package dev.arkbuilders.drop.app.data.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import dev.arkbuilders.drop.app.domain.model.DropFileInfo +import dev.arkbuilders.drop.app.domain.model.TransferStatus +import dev.arkbuilders.drop.app.domain.model.TransferType +import java.time.OffsetDateTime + +@Entity +data class RoomTransferSession( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val files: List, + val type: TransferType, + val timestamp: OffsetDateTime, + val status: TransferStatus, + val peerName: String, + val peerAvatar: String?, +) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/db/typeconverters/DropFileListConverter.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/db/typeconverters/DropFileListConverter.kt new file mode 100644 index 0000000..fc6d961 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/db/typeconverters/DropFileListConverter.kt @@ -0,0 +1,17 @@ +package dev.arkbuilders.drop.app.data.db.typeconverters + +import androidx.room.TypeConverter +import dev.arkbuilders.drop.app.domain.model.DropFileInfo +import kotlinx.serialization.json.Json + +object DropFileListConverter { + @TypeConverter + fun fromList(list: List): String { + return Json.encodeToString(list) + } + + @TypeConverter + fun toList(data: String): List { + return Json.decodeFromString(data) + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/db/typeconverters/OffsetDateTimeTypeConverter.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/db/typeconverters/OffsetDateTimeTypeConverter.kt new file mode 100644 index 0000000..b91bab6 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/db/typeconverters/OffsetDateTimeTypeConverter.kt @@ -0,0 +1,16 @@ +package dev.arkbuilders.drop.app.data.db.typeconverters + +import androidx.room.TypeConverter +import java.time.OffsetDateTime + +object OffsetDateTimeTypeConverter { + @TypeConverter + fun fromOffsetDateTime(date: OffsetDateTime): String = date.toString() + + @TypeConverter + fun toOffsetDateTime(dateString: String): OffsetDateTime { + val date = OffsetDateTime.parse(dateString) + val offset = OffsetDateTime.now().offset + return date.withOffsetSameInstant(offset) + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/helper/AvatarHelperImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/helper/AvatarHelperImpl.kt new file mode 100644 index 0000000..8b6b3c5 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/helper/AvatarHelperImpl.kt @@ -0,0 +1,102 @@ +package dev.arkbuilders.drop.app.data.helper + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.util.Base64 +import androidx.core.graphics.scale +import androidx.core.net.toUri +import dev.arkbuilders.drop.app.domain.AvatarHelper +import dev.arkbuilders.drop.app.domain.AvatarHelper.Companion.JPEG_QUALITY +import dev.arkbuilders.drop.app.domain.AvatarHelper.Companion.MAX_FILE_SIZE +import dev.arkbuilders.drop.app.domain.AvatarHelper.Companion.MAX_IMAGE_SIZE +import java.io.ByteArrayOutputStream + +class AvatarHelperImpl( + private val context: Context, +) : AvatarHelper { + override fun uriToBase64(uri: String): String? { + return try { + val bitmap = loadBitmapFromUri(uri.toUri()) ?: return null + val optimizedBitmap = optimizeBitmap(bitmap) + bitmapToBase64(optimizedBitmap) + } catch (_: Exception) { + null + } + } + + private fun loadBitmapFromUri(uri: Uri): Bitmap? { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val source = ImageDecoder.createSource(context.contentResolver, uri) + ImageDecoder.decodeBitmap(source) + } else { + context.contentResolver.openInputStream(uri)?.use { input -> + BitmapFactory.decodeStream(input) + } + } + } catch (_: Throwable) { + null + } + } + + private fun optimizeBitmap(bitmap: Bitmap): Bitmap { + val width = bitmap.width + val height = bitmap.height + + // Calculate scaling factor + val scaleFactor = + if (width > height) { + MAX_IMAGE_SIZE.toFloat() / width + } else { + MAX_IMAGE_SIZE.toFloat() / height + } + + return if (scaleFactor < 1f) { + val newWidth = (width * scaleFactor).toInt() + val newHeight = (height * scaleFactor).toInt() + bitmap.scale(newWidth, newHeight) + } else { + bitmap + } + } + + private fun bitmapToBase64(bitmap: Bitmap): String? { + return try { + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, outputStream) + val byteArray = outputStream.toByteArray() + + // Check file size + if (byteArray.size > MAX_FILE_SIZE) { + return null + } + + Base64.encodeToString(byteArray, Base64.DEFAULT) + } catch (_: Exception) { + null + } + } + + override fun getDefaultAvatarBase64(avatarId: String): String { + return try { + val resourceId = + context.resources.getIdentifier( + avatarId, + "drawable", + context.packageName, + ) + if (resourceId != 0) { + val bitmap = BitmapFactory.decodeResource(context.resources, resourceId) + bitmapToBase64(bitmap) ?: "" + } else { + "" + } + } catch (_: Exception) { + "" + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/helper/PermissionsHelperImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/helper/PermissionsHelperImpl.kt new file mode 100644 index 0000000..e3869b7 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/helper/PermissionsHelperImpl.kt @@ -0,0 +1,30 @@ +package dev.arkbuilders.drop.app.data.helper + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import dev.arkbuilders.drop.app.domain.PermissionsHelper + +class PermissionsHelperImpl( + private val ctx: Context, +) : PermissionsHelper { + override fun isCameraGranted(): Boolean { + return ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + } + + override fun isWritePermissionGranted(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + true + } else { + ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ) == PackageManager.PERMISSION_GRANTED + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/helper/ResourcesHelperImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/helper/ResourcesHelperImpl.kt new file mode 100644 index 0000000..3475e1a --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/helper/ResourcesHelperImpl.kt @@ -0,0 +1,228 @@ +package dev.arkbuilders.drop.app.data.helper + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.annotation.RequiresApi +import androidx.core.net.toUri +import dev.arkbuilders.drop.app.domain.ResourcesHelper +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.net.URLConnection + +class ResourcesHelperImpl( + private val context: Context, +) : ResourcesHelper { + override fun getFileName(uri: String): String? { + return try { + context + .contentResolver + .query(uri.toUri(), null, null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) cursor.getString(nameIndex) else null + } else { + null + } + } + } catch (e: Exception) { + Timber.e(e, "Error getting filename for URI: $uri") + null + } + } + + override fun validateUris(uris: List): Pair, Int> { + val validFiles = mutableListOf() + var skippedCount = 0 + + uris.forEach { uri -> + try { + val size = getFileSize(uri) + if (size in 1..2_000_000_000L) { // 2GB limit + validFiles.add(uri) + } else { + skippedCount++ + } + } catch (_: Exception) { + skippedCount++ + } + } + + return validFiles to skippedCount + } + + override fun getFileSize(uri: String): Long { + return try { + context.contentResolver.query(uri.toUri(), null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0L + } else { + 0L + } + } ?: 0L + } catch (_: Exception) { + 0L + } + } + + override fun saveFileToDownloads( + fileName: String, + data: ByteArray, + ): String? { + val uniqueName = getUniqueFileName(fileName) + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveModern(uniqueName, data) + } else { + saveLegacy(uniqueName, data) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveModern( + fileName: String, + data: ByteArray, + ): String? { + val resolver = context.contentResolver + val mime = getMimeType(fileName) + val relativePath = Environment.DIRECTORY_DOWNLOADS + "/" + + val values = + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, mime) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + val uri = + resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: return null + + try { + resolver.openOutputStream(uri)?.use { it.write(data) } + } catch (e: Exception) { + resolver.delete(uri, null, null) + return null + } + + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + + return fileName + } + + private fun saveLegacy( + fileName: String, + data: ByteArray, + ): String { + val downloads = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!downloads.exists()) downloads.mkdirs() + + val file = File(downloads, fileName) + + FileOutputStream(file).use { it.write(data) } + + return fileName + } + + private fun getUniqueFileName(originalName: String): String { + val baseName = originalName.substringBeforeLast(".") + val ext = originalName.substringAfterLast(".", "") + var candidateName = originalName + var attempt = 1 + + while (true) { + if (doesFileExistInDownloads(candidateName).not()) { + return candidateName + } + + candidateName = + if (ext.isNotEmpty()) { + "$baseName($attempt).$ext" + } else { + "$baseName($attempt)" + } + + attempt++ + + if (attempt > 1000) { + val ts = System.currentTimeMillis() + return if (ext.isNotEmpty()) { + "${baseName}_$ts.$ext" + } else { + "${baseName}_$ts" + } + } + } + } + + private fun doesFileExistInDownloads(name: String): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + doesFileExistModern(name) + } else { + doesFileExistLegacy(name) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun doesFileExistModern(name: String): Boolean { + val resolver = context.contentResolver + + val projection = arrayOf(MediaStore.MediaColumns.DISPLAY_NAME) + + val selection = + "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND " + + "${MediaStore.MediaColumns.RELATIVE_PATH} = ?" + + val selectionArgs = + arrayOf( + name, + Environment.DIRECTORY_DOWNLOADS + "/", + ) + + resolver.query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + null, + )?.use { cursor -> + return cursor.moveToFirst() + } + + return false + } + + private fun doesFileExistLegacy(name: String): Boolean { + val downloads = + Environment + .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + + val file = File(downloads, name) + return file.exists() + } + + private fun getMimeType(fileName: String): String { + val ext = fileName.substringAfterLast(".", "").lowercase() + + MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.let { + return it + } + + URLConnection.guessContentTypeFromName(fileName)?.let { + return it + } + + return "application/octet-stream" + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/model/UserAvatarDto.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/model/UserAvatarDto.kt new file mode 100644 index 0000000..cdafc8e --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/model/UserAvatarDto.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.drop.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UserAvatarDto( + val base64: String, + val predefinedId: String?, +) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/model/UserProfileDto.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/model/UserProfileDto.kt new file mode 100644 index 0000000..5688139 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/model/UserProfileDto.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.drop.app.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UserProfileDto( + val name: String, + val avatar: UserAvatarDto, +) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/repository/NetworkStatusImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/NetworkStatusImpl.kt new file mode 100644 index 0000000..e0321f9 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/NetworkStatusImpl.kt @@ -0,0 +1,63 @@ +package dev.arkbuilders.drop.app.data.repository + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import dev.arkbuilders.drop.app.domain.repository.NetworkStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class NetworkStatusImpl( + context: Context, +) : NetworkStatus { + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private val _onlineStatus = MutableStateFlow(checkIsOnline()) + override val onlineStatus: StateFlow = _onlineStatus + + init { + val networkRequest = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) + } + } + .build() + + connectivityManager.registerNetworkCallback( + networkRequest, + object : ConnectivityManager.NetworkCallback() { + override fun onLost(network: Network) { + _onlineStatus.tryEmit(false) + } + + override fun onAvailable(network: Network) { + _onlineStatus.tryEmit(true) + } + }, + ) + } + + private fun checkIsOnline(): Boolean { + val network: Network = connectivityManager.activeNetwork ?: return false + val networkCapabilities: NetworkCapabilities = + connectivityManager.getNetworkCapabilities(network) ?: return false + + var isOnline = + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + isOnline = isOnline && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) + } + return isOnline + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/repository/ProfileRepoImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/ProfileRepoImpl.kt new file mode 100644 index 0000000..165c968 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/ProfileRepoImpl.kt @@ -0,0 +1,29 @@ +package dev.arkbuilders.drop.app.data.repository + +import dev.arkbuilders.drop.app.data.datasource.ProfileLocalDataSource +import dev.arkbuilders.drop.app.domain.model.UserAvatar +import dev.arkbuilders.drop.app.domain.model.UserProfile +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ProfileRepoImpl( + private val localDataSource: ProfileLocalDataSource, +) : ProfileRepo { + private val _profile = MutableStateFlow(localDataSource.loadProfile()) + override val profile: StateFlow = _profile.asStateFlow() + + override fun updateProfile(profile: UserProfile) { + _profile.value = profile + localDataSource.saveProfile(profile) + } + + override fun updateName(name: String) { + updateProfile(_profile.value.copy(name = name)) + } + + override fun updateAvatar(avatar: UserAvatar) { + updateProfile(_profile.value.copy(avatar = avatar)) + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/repository/ReceiveSessionRepo.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/ReceiveSessionRepo.kt new file mode 100644 index 0000000..cacc4f2 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/ReceiveSessionRepo.kt @@ -0,0 +1,119 @@ +package dev.arkbuilders.drop.app.data.repository + +import dev.arkbuilders.drop.app.data.ReceiveFilesSubscriberImpl +import dev.arkbuilders.drop.app.domain.ResourcesHelper +import dev.arkbuilders.drop.app.domain.model.DropFileInfo +import dev.arkbuilders.drop.app.domain.model.ReceiveSession +import dev.arkbuilders.drop.app.domain.model.TransferStatus +import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.app.domain.usecase.ReceiveFilesUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber + +class ReceiveSessionRepo( + private val receiveFilesUseCase: ReceiveFilesUseCase, + private val transferHistoryRepository: TransferSessionRepo, + private val resourcesHelper: ResourcesHelper, +) { + // Keep references to active sessions here so file transfers continue even if the ViewModel dies + private val activeSessions = mutableListOf() + private val activeSessionsMutex = Mutex() + private val cancelScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + suspend fun receiveFiles( + ticket: String, + confirmation: UByte, + ): ReceiveSession? = + withContext(Dispatchers.IO) { + receiveFilesUseCase.invoke(ticket, confirmation).fold( + onSuccess = { bubble -> + val subscriber = + ReceiveFilesSubscriberImpl().also { subscriber -> + bubble.subscribe(subscriber) + } + + val session = + ReceiveSession( + bubble, + subscriber, + ) + activeSessionsMutex.withLock { + activeSessions.add(session) + } + + bubble.start() + return@withContext session + }, + onFailure = { + return@withContext null + }, + ) + } + + suspend fun saveReceivedFiles(session: ReceiveSession): List = + withContext(Dispatchers.IO) { + val subscriber = session.subscriber + val completeFiles = subscriber.getCompleteFiles() + val savedFiles = mutableListOf() + + try { + completeFiles.forEach { (fileInfo, data) -> + val savedFile = resourcesHelper.saveFileToDownloads(fileInfo.name, data) + if (savedFile != null) { + savedFiles.add(DropFileInfo(savedFile, fileInfo.size.toLong())) + Timber.i("Saved file name: $savedFile") + } else { + Timber.e("Failed to save file: ${fileInfo.name}") + } + } + + if (savedFiles.isNotEmpty()) { + val progress = subscriber.progress.value + val senderName = progress.senderName + val senderAvatar = progress.senderAvatar + + transferHistoryRepository.addReceivedTransfer( + files = savedFiles, + peerName = senderName, + peerAvatar = senderAvatar, + status = TransferStatus.COMPLETED, + ) + } + } catch (e: Exception) { + Timber.e("Error saving received files ${e.message}") + + val progress = subscriber.progress.value + val senderName = progress.senderName + val senderAvatar = progress.senderAvatar + + transferHistoryRepository.addReceivedTransfer( + files = emptyList(), + peerName = senderName, + peerAvatar = senderAvatar, + status = TransferStatus.FAILED, + ) + } + + return@withContext savedFiles.map { it.name } + } + + fun cancelReceive(session: ReceiveSession) { + cancelScope.launch { + try { + activeSessionsMutex.withLock { + activeSessions.remove(session) + } + session.bubble.unsubscribe(session.subscriber) + session.bubble.cancel() + } catch (e: Throwable) { + Timber.e("Error cancelling receive ${e.message}") + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/repository/SendSessionRepo.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/SendSessionRepo.kt new file mode 100644 index 0000000..07beed7 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/SendSessionRepo.kt @@ -0,0 +1,100 @@ +package dev.arkbuilders.drop.app.data.repository + +import android.net.Uri +import dev.arkbuilders.drop.app.data.SendFilesSubscriberImpl +import dev.arkbuilders.drop.app.domain.ResourcesHelper +import dev.arkbuilders.drop.app.domain.model.DropFileInfo +import dev.arkbuilders.drop.app.domain.model.SendSession +import dev.arkbuilders.drop.app.domain.model.TransferStatus +import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.app.domain.usecase.SendFilesUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber + +class SendSessionRepo( + private val sendUseCase: SendFilesUseCase, + private val resourcesHelper: ResourcesHelper, + private val transferSessionRepository: TransferSessionRepo, +) { + // Keep references to active sessions here so file transfers continue even if the ViewModel dies + private val activeSessions = mutableListOf() + private val activeSessionsMutex = Mutex() + private val cancelScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + suspend fun sendFiles(fileUris: List): SendSession? = + withContext(Dispatchers.IO) { + cleanupFinishedSessions() + + sendUseCase.invoke(fileUris).fold( + onSuccess = { bubble -> + val subscriber = + SendFilesSubscriberImpl().also { subscriber -> + bubble.subscribe(subscriber) + } + + val session = SendSession(bubble, subscriber) + activeSessionsMutex.withLock { + activeSessions.add(session) + } + return@withContext session + }, + onFailure = { + return@withContext null + }, + ) + } + + suspend fun recordSendCompletion( + fileUris: List, + session: SendSession, + ) { + try { + cleanupFinishedSessions() + val progress = session.subscriber.progress.value + val receiverName = progress.receiverName + val receiverAvatar = progress.receiverAvatar + + val filesInfo = + fileUris.map { + DropFileInfo( + name = resourcesHelper.getFileName(it.toString()) ?: "", + size = resourcesHelper.getFileSize(it.toString()), + ) + } + + transferSessionRepository.addSentTransfer( + files = filesInfo, + peerName = receiverName, + peerAvatar = receiverAvatar, + status = TransferStatus.COMPLETED, + ) + } catch (e: Exception) { + Timber.e("Error recording send completion ${e.message}") + } + } + + fun cancelSend(session: SendSession) { + cancelScope.launch { + try { + activeSessionsMutex.withLock { + activeSessions.remove(session) + } + session.bubble.unsubscribe(session.subscriber) + session.bubble.cancel() + } catch (e: Throwable) { + Timber.e("Error cancelling send ${e.message}") + } + } + } + + private suspend fun cleanupFinishedSessions() = + activeSessionsMutex.withLock { + activeSessions.removeAll { it.bubble.isFinished() } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/data/repository/TransferSessionRepoImpl.kt b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/TransferSessionRepoImpl.kt new file mode 100644 index 0000000..a74867e --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/repository/TransferSessionRepoImpl.kt @@ -0,0 +1,60 @@ +package dev.arkbuilders.drop.app.data.repository + +import dev.arkbuilders.drop.app.data.datasource.TransferSessionLocalDataSource +import dev.arkbuilders.drop.app.domain.model.DropFileInfo +import dev.arkbuilders.drop.app.domain.model.TransferSession +import dev.arkbuilders.drop.app.domain.model.TransferStatus +import dev.arkbuilders.drop.app.domain.model.TransferType +import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import kotlinx.coroutines.flow.Flow +import java.time.OffsetDateTime + +class TransferSessionRepoImpl( + private val localSource: TransferSessionLocalDataSource, +) : TransferSessionRepo { + override val historyItems: Flow> = localSource.flow() + + override suspend fun addSentTransfer( + files: List, + peerName: String, + peerAvatar: String?, + status: TransferStatus, + ) { + val newItem = + TransferSession( + files = files, + type = TransferType.SENT, + timestamp = OffsetDateTime.now(), + status = status, + peerName = peerName, + peerAvatar = peerAvatar, + ) + localSource.add(newItem) + } + + override suspend fun addReceivedTransfer( + files: List, + peerName: String, + peerAvatar: String?, + status: TransferStatus, + ) { + val newItem = + TransferSession( + files = files, + type = TransferType.RECEIVED, + timestamp = OffsetDateTime.now(), + status = status, + peerName = peerName, + peerAvatar = peerAvatar, + ) + localSource.add(newItem) + } + + override suspend fun deleteSession(itemId: Long) { + localSource.delete(itemId) + } + + override suspend fun clearHistory() { + localSource.clear() + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/di/AppModule.kt b/app/src/main/java/dev/arkbuilders/drop/app/di/AppModule.kt index 9315d85..e5b71bc 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/di/AppModule.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/di/AppModule.kt @@ -1,33 +1,44 @@ package dev.arkbuilders.drop.app.di -import android.content.Context -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import dev.arkbuilders.drop.app.TransferManager -import dev.arkbuilders.drop.app.ProfileManager -import dev.arkbuilders.drop.app.data.HistoryRepository -import javax.inject.Singleton +import dev.arkbuilders.drop.app.data.datasource.ProfileLocalDataSource +import dev.arkbuilders.drop.app.data.datasource.TransferSessionLocalDataSource +import dev.arkbuilders.drop.app.data.db.Database +import dev.arkbuilders.drop.app.data.db.dao.TransferSessionDao +import dev.arkbuilders.drop.app.data.helper.AvatarHelperImpl +import dev.arkbuilders.drop.app.data.helper.PermissionsHelperImpl +import dev.arkbuilders.drop.app.data.helper.ResourcesHelperImpl +import dev.arkbuilders.drop.app.data.repository.NetworkStatusImpl +import dev.arkbuilders.drop.app.data.repository.ProfileRepoImpl +import dev.arkbuilders.drop.app.data.repository.ReceiveSessionRepo +import dev.arkbuilders.drop.app.data.repository.SendSessionRepo +import dev.arkbuilders.drop.app.data.repository.TransferSessionRepoImpl +import dev.arkbuilders.drop.app.domain.AvatarHelper +import dev.arkbuilders.drop.app.domain.PermissionsHelper +import dev.arkbuilders.drop.app.domain.ResourcesHelper +import dev.arkbuilders.drop.app.domain.repository.NetworkStatus +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.app.domain.usecase.ReceiveFilesUseCase +import dev.arkbuilders.drop.app.domain.usecase.SendFilesUseCase +import org.koin.dsl.module -@Module -@InstallIn(SingletonComponent::class) -object AppModule { - - @Provides - @Singleton - fun provideProfileManager(@ApplicationContext context: Context): ProfileManager { - return ProfileManager(context) - } - - @Provides - @Singleton - fun provideTransferManager( - @ApplicationContext context: Context, - profileManager: ProfileManager, - historyRepository: HistoryRepository - ): TransferManager { - return TransferManager(context, profileManager, historyRepository) +val appModule = + module { + single { ProfileRepoImpl(get()) } + single { ResourcesHelperImpl(get()) } + single { Database.build(get()) } + single { TransferSessionRepoImpl(get()) } + single { PermissionsHelperImpl(get()) } + single { NetworkStatusImpl(get()) } + single { AvatarHelperImpl(get()) } + single { ProfileLocalDataSource(get(), get()) } + single { TransferSessionLocalDataSource(get()) } + single { SendSessionRepo(get(), get(), get()) } + single { ReceiveSessionRepo(get(), get(), get()) } + factory { + val db: Database = get() + db.transferHistoryDao() + } + factory { SendFilesUseCase(get(), get(), get()) } + factory { ReceiveFilesUseCase(get()) } } -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/di/ViewModelsModule.kt b/app/src/main/java/dev/arkbuilders/drop/app/di/ViewModelsModule.kt new file mode 100644 index 0000000..ab8f0e8 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/di/ViewModelsModule.kt @@ -0,0 +1,18 @@ +package dev.arkbuilders.drop.app.di + +import dev.arkbuilders.drop.app.presentation.history.HistoryViewModel +import dev.arkbuilders.drop.app.presentation.home.HomeViewModel +import dev.arkbuilders.drop.app.presentation.profile.EditProfileViewModel +import dev.arkbuilders.drop.app.presentation.receive.ReceiveViewModel +import dev.arkbuilders.drop.app.presentation.send.SendViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val viewModelsModule = + module { + viewModel { HistoryViewModel(get()) } + viewModel { HomeViewModel(get(), get(), get()) } + viewModel { EditProfileViewModel(get(), get()) } + viewModel { ReceiveViewModel(get(), get()) } + viewModel { SendViewModel(get(), get(), get()) } + } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/AvatarHelper.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/AvatarHelper.kt new file mode 100644 index 0000000..91ec9f5 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/AvatarHelper.kt @@ -0,0 +1,13 @@ +package dev.arkbuilders.drop.app.domain + +interface AvatarHelper { + fun uriToBase64(uri: String): String? + + fun getDefaultAvatarBase64(avatarId: String): String + + companion object { + const val MAX_IMAGE_SIZE = 512 // Maximum width/height in pixels + const val JPEG_QUALITY = 85 // JPEG compression quality + const val MAX_FILE_SIZE = 500 * 1024 // 500KB max file size + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/PermissionsHelper.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/PermissionsHelper.kt new file mode 100644 index 0000000..79ff861 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/PermissionsHelper.kt @@ -0,0 +1,7 @@ +package dev.arkbuilders.drop.app.domain + +interface PermissionsHelper { + fun isCameraGranted(): Boolean + + fun isWritePermissionGranted(): Boolean +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/ResourcesHelper.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/ResourcesHelper.kt new file mode 100644 index 0000000..d730dd7 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/ResourcesHelper.kt @@ -0,0 +1,14 @@ +package dev.arkbuilders.drop.app.domain + +interface ResourcesHelper { + fun getFileName(uri: String): String? + + fun validateUris(uris: List): Pair, Int> + + fun getFileSize(uri: String): Long + + fun saveFileToDownloads( + fileName: String, + data: ByteArray, + ): String? +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/BuildConfigFields.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/BuildConfigFields.kt new file mode 100644 index 0000000..1e139a1 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/BuildConfigFields.kt @@ -0,0 +1,6 @@ +package dev.arkbuilders.drop.app.domain.model + +class BuildConfigFields( + val versionCode: Int, + val versionName: String, +) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/DropFileInfo.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/DropFileInfo.kt new file mode 100644 index 0000000..c1f6708 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/DropFileInfo.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.drop.app.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class DropFileInfo( + val name: String, + val size: Long, +) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/ReceiveSession.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/ReceiveSession.kt new file mode 100644 index 0000000..4f8e7b8 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/ReceiveSession.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.drop.app.domain.model + +import dev.arkbuilders.drop.ReceiveFilesBubble +import dev.arkbuilders.drop.app.data.ReceiveFilesSubscriberImpl + +class ReceiveSession( + val bubble: ReceiveFilesBubble, + val subscriber: ReceiveFilesSubscriberImpl, +) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendSession.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendSession.kt new file mode 100644 index 0000000..25208bb --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/SendSession.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.drop.app.domain.model + +import dev.arkbuilders.drop.SendFilesBubble +import dev.arkbuilders.drop.app.data.SendFilesSubscriberImpl + +class SendSession( + val bubble: SendFilesBubble, + val subscriber: SendFilesSubscriberImpl, +) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/TransferSession.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/TransferSession.kt new file mode 100644 index 0000000..24a855a --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/TransferSession.kt @@ -0,0 +1,13 @@ +package dev.arkbuilders.drop.app.domain.model + +import java.time.OffsetDateTime + +data class TransferSession( + val id: Long = 0, + val files: List, + val type: TransferType, + val timestamp: OffsetDateTime, + val status: TransferStatus, + val peerName: String, + val peerAvatar: String?, +) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/TransferStatus.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/TransferStatus.kt new file mode 100644 index 0000000..c2d4762 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/TransferStatus.kt @@ -0,0 +1,12 @@ +package dev.arkbuilders.drop.app.domain.model + +enum class TransferStatus { + COMPLETED, + FAILED, + CANCELLED, +} + +enum class TransferType { + SENT, + RECEIVED, +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/UserAvatar.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/UserAvatar.kt new file mode 100644 index 0000000..d131ec2 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/UserAvatar.kt @@ -0,0 +1,14 @@ +package dev.arkbuilders.drop.app.domain.model + +data class UserAvatar( + val base64: String, + val predefinedId: String?, +) { + companion object { + val predefinedIds = + listOf( + "avatar_00", "avatar_01", "avatar_02", "avatar_03", + "avatar_04", "avatar_05", "avatar_06", "avatar_07", "avatar_08", + ) + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/model/UserProfile.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/UserProfile.kt new file mode 100644 index 0000000..f0ed2bb --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/model/UserProfile.kt @@ -0,0 +1,10 @@ +package dev.arkbuilders.drop.app.domain.model + +data class UserProfile( + val name: String, + val avatar: UserAvatar, +) { + companion object { + fun empty() = UserProfile("", UserAvatar("", null)) + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/repository/NetworkStatus.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/repository/NetworkStatus.kt new file mode 100644 index 0000000..804c89b --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/repository/NetworkStatus.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.drop.app.domain.repository + +import kotlinx.coroutines.flow.StateFlow + +interface NetworkStatus { + fun isOnline() = onlineStatus.value + + val onlineStatus: StateFlow +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/repository/ProfileRepo.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/repository/ProfileRepo.kt new file mode 100644 index 0000000..960dddf --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/repository/ProfileRepo.kt @@ -0,0 +1,17 @@ +package dev.arkbuilders.drop.app.domain.repository + +import dev.arkbuilders.drop.app.domain.model.UserAvatar +import dev.arkbuilders.drop.app.domain.model.UserProfile +import kotlinx.coroutines.flow.StateFlow + +interface ProfileRepo { + val profile: StateFlow + + fun getCurrentProfile() = profile.value + + fun updateProfile(profile: UserProfile) + + fun updateName(name: String) + + fun updateAvatar(avatar: UserAvatar) +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/repository/TransferSessionRepo.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/repository/TransferSessionRepo.kt new file mode 100644 index 0000000..604bf0f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/repository/TransferSessionRepo.kt @@ -0,0 +1,28 @@ +package dev.arkbuilders.drop.app.domain.repository + +import dev.arkbuilders.drop.app.domain.model.DropFileInfo +import dev.arkbuilders.drop.app.domain.model.TransferSession +import dev.arkbuilders.drop.app.domain.model.TransferStatus +import kotlinx.coroutines.flow.Flow + +interface TransferSessionRepo { + val historyItems: Flow> + + suspend fun addSentTransfer( + files: List, + peerName: String, + peerAvatar: String?, + status: TransferStatus = TransferStatus.COMPLETED, + ) + + suspend fun addReceivedTransfer( + files: List, + peerName: String, + peerAvatar: String?, + status: TransferStatus = TransferStatus.COMPLETED, + ) + + suspend fun deleteSession(itemId: Long) + + suspend fun clearHistory() +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/ReceiveFilesUseCase.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/ReceiveFilesUseCase.kt new file mode 100644 index 0000000..732d522 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/ReceiveFilesUseCase.kt @@ -0,0 +1,51 @@ +package dev.arkbuilders.drop.app.domain.usecase + +import dev.arkbuilders.drop.ReceiveFilesBubble +import dev.arkbuilders.drop.ReceiveFilesRequest +import dev.arkbuilders.drop.ReceiverConfig +import dev.arkbuilders.drop.ReceiverProfile +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import dev.arkbuilders.drop.receiveFiles +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber + +class ReceiveFilesUseCase( + private val profileRepo: ProfileRepo, +) { + suspend operator fun invoke( + ticket: String, + confirmation: UByte, + ): Result = + withContext(Dispatchers.IO) { + runCatching { + Timber.d("Starting file receive with ticket: $ticket") + + val profile = profileRepo.getCurrentProfile() + val receiverProfile = + ReceiverProfile( + name = profile.name.ifEmpty { "Anonymous" }, + avatarB64 = profile.avatar.base64.takeIf { it.isNotEmpty() }, + ) + + val request = + ReceiveFilesRequest( + ticket = ticket, + confirmation = confirmation, + profile = receiverProfile, + config = + ReceiverConfig( + chunkSize = 1024u * 512u, + parallelStreams = 4u, + ), + ) + + val bubble = receiveFiles(request) + + Timber.d("Receive bubble created and started") + bubble + }.onFailure { + Timber.e("Error starting file receive ${it.message}") + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/SendFilesUseCase.kt b/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/SendFilesUseCase.kt new file mode 100644 index 0000000..3cb1638 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/domain/usecase/SendFilesUseCase.kt @@ -0,0 +1,78 @@ +package dev.arkbuilders.drop.app.domain.usecase + +import android.content.Context +import android.net.Uri +import dev.arkbuilders.drop.SendFilesBubble +import dev.arkbuilders.drop.SendFilesRequest +import dev.arkbuilders.drop.SenderConfig +import dev.arkbuilders.drop.SenderFile +import dev.arkbuilders.drop.SenderProfile +import dev.arkbuilders.drop.app.data.SenderFileDataImpl +import dev.arkbuilders.drop.app.domain.ResourcesHelper +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import dev.arkbuilders.drop.sendFiles +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber + +class SendFilesUseCase( + private val context: Context, + private val profileRepo: ProfileRepo, + private val resourcesHelper: ResourcesHelper, +) { + suspend operator fun invoke(fileUris: List): Result = + withContext(Dispatchers.IO) { + runCatching { + Timber.d("Starting file send for ${fileUris.size} files") + + val profile = profileRepo.getCurrentProfile() + val senderProfile = + SenderProfile( + name = profile.name.ifEmpty { "Anonymous" }, + avatarB64 = profile.avatar.base64.takeIf { it.isNotEmpty() }, + ) + + val senderFiles = + fileUris.mapNotNull { uri -> + val fileName = resourcesHelper.getFileName(uri.toString()) + if (fileName != null) { + val fileData = SenderFileDataImpl(context, uri) + SenderFile( + name = fileName, + data = fileData, + ) + } else { + Timber.w("Could not get filename for URI: $uri") + null + } + } + + if (senderFiles.isEmpty()) { + Timber.e("No valid files to send") + error("No valid files to send") + } + + val request = + SendFilesRequest( + profile = senderProfile, + files = senderFiles, + config = + SenderConfig( + chunkSize = 1024u * 512u, + parallelStreams = 4u, + ), + ) + + val bubble = sendFiles(request) + + Timber.d( + "Send bubble created with ticket and confirmation: ${ + bubble.getTicket() + } ${bubble.getConfirmation()}", + ) + bubble + }.onFailure { + Timber.e("Error starting file send ${it.message}") + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/DisplayUtils.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/DisplayUtils.kt new file mode 100644 index 0000000..6fef4bf --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/DisplayUtils.kt @@ -0,0 +1,25 @@ +package dev.arkbuilders.drop.app.presentation + +object DisplayUtils { + fun formatBytes(bytes: Long): String { + if (bytes < 0) return "0 B" + if (bytes < 1024) return "$bytes B" + + val kb = bytes / 1024.0 + if (kb < 1024) return "%.1f KB".format(kb) + + val mb = kb / 1024.0 + if (mb < 1024) return "%.1f MB".format(mb) + + val gb = mb / 1024.0 + return "%.1f GB".format(gb) + } + + fun formatDuration(seconds: Long): String { + return when { + seconds < 60 -> "${seconds}s" + seconds < 3600 -> "${seconds / 60}m ${seconds % 60}s" + else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m" + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/DropApplication.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/DropApplication.kt new file mode 100644 index 0000000..bee919f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/DropApplication.kt @@ -0,0 +1,32 @@ +package dev.arkbuilders.drop.app.presentation + +import android.app.Application +import dev.arkbuilders.drop.app.BuildConfig +import dev.arkbuilders.drop.app.di.appModule +import dev.arkbuilders.drop.app.di.viewModelsModule +import dev.arkbuilders.drop.app.domain.model.BuildConfigFields +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin +import org.koin.dsl.module +import timber.log.Timber + +class DropApplication : Application() { + override fun onCreate() { + super.onCreate() + Timber.plant(Timber.DebugTree()) + + val buildConfigFieldsModule = + module { + single { + BuildConfigFields( + BuildConfig.VERSION_CODE, + BuildConfig.VERSION_NAME, + ) + } + } + startKoin { + androidContext(this@DropApplication) + modules(appModule, viewModelsModule, buildConfigFieldsModule) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/MainActivity.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/MainActivity.kt similarity index 51% rename from app/src/main/java/dev/arkbuilders/drop/app/MainActivity.kt rename to app/src/main/java/dev/arkbuilders/drop/app/presentation/MainActivity.kt index 37c4d36..5ccdf87 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/MainActivity.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/MainActivity.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.drop.app +package dev.arkbuilders.drop.app.presentation import android.os.Bundle import androidx.activity.ComponentActivity @@ -6,6 +6,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -14,28 +15,22 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navDeepLink -import dagger.hilt.android.AndroidEntryPoint -import dev.arkbuilders.drop.app.data.HistoryRepository -import dev.arkbuilders.drop.app.navigation.DropDestination -import dev.arkbuilders.drop.app.ui.history.History -import dev.arkbuilders.drop.app.ui.Home.Home -import dev.arkbuilders.drop.app.ui.profile.EditProfileEnhanced -import dev.arkbuilders.drop.app.ui.receive.Receive -import dev.arkbuilders.drop.app.ui.send.Send -import dev.arkbuilders.drop.app.ui.theme.DropTheme -import javax.inject.Inject +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.app.presentation.history.History +import dev.arkbuilders.drop.app.presentation.home.Home +import dev.arkbuilders.drop.app.presentation.navigation.DropDestination +import dev.arkbuilders.drop.app.presentation.profile.AboutScreen +import dev.arkbuilders.drop.app.presentation.profile.EditProfileEnhanced +import dev.arkbuilders.drop.app.presentation.receive.Receive +import dev.arkbuilders.drop.app.presentation.send.Send +import dev.arkbuilders.drop.app.presentation.theme.DropTheme +import org.koin.android.ext.android.get -@AndroidEntryPoint class MainActivity : ComponentActivity() { + private val profileRepo: ProfileRepo = get() - @Inject - lateinit var transferManager: TransferManager - - @Inject - lateinit var profileManager: ProfileManager - - @Inject - lateinit var historyRepository: HistoryRepository + private val transferSessionRepo: TransferSessionRepo = get() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -43,15 +38,17 @@ class MainActivity : ComponentActivity() { setContent { DropTheme { Scaffold( - modifier = Modifier - .fillMaxSize() + modifier = + Modifier + .fillMaxSize() + .safeDrawingPadding(), ) { innerPadding -> DropNavigation( - modifier = Modifier - .padding(innerPadding), - transferManager = transferManager, - profileManager = profileManager, - historyRepository = historyRepository + modifier = + Modifier + .padding(innerPadding), + profileRepo = profileRepo, + transferSessionRepo = transferSessionRepo, ) } } @@ -63,51 +60,53 @@ class MainActivity : ComponentActivity() { fun DropNavigation( modifier: Modifier = Modifier, navController: NavHostController = rememberNavController(), - transferManager: TransferManager, - profileManager: ProfileManager, - historyRepository: HistoryRepository + profileRepo: ProfileRepo, + transferSessionRepo: TransferSessionRepo, ) { NavHost( navController = navController, startDestination = DropDestination.Home.route, - modifier = modifier + modifier = modifier, ) { composable(DropDestination.Home.route) { Home( navController = navController, - profileManager = profileManager, - historyRepository = historyRepository + profileRepo = profileRepo, + transferSessionRepo = transferSessionRepo, ) } composable(DropDestination.Send.route) { Send( navController = navController, - transferManager = transferManager ) } composable( DropDestination.Receive.route, - deepLinks = listOf( - navDeepLink { - uriPattern = DropDestination.Receive.DEEP_LINK_PATTERN - } - ) + deepLinks = + listOf( + navDeepLink { + uriPattern = DropDestination.Receive.DEEP_LINK_PATTERN + }, + ), ) { Receive( navController = navController, - transferManager = transferManager ) } composable(DropDestination.History.route) { History( navController = navController, - historyRepository = historyRepository + transferSessionRepo = transferSessionRepo, ) } composable(DropDestination.EditProfile.route) { EditProfileEnhanced( navController = navController, - profileManager = profileManager + ) + } + composable(DropDestination.About.route) { + AboutScreen( + navController = navController, ) } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/AvatarImage.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/AvatarImage.kt new file mode 100644 index 0000000..d48820a --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/AvatarImage.kt @@ -0,0 +1,109 @@ +package dev.arkbuilders.drop.app.presentation.components + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun AvatarImage( + avatarB64: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + val imageBytes = Base64.decode(avatarB64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + + if (bitmap != null) { + Image( + painter = BitmapPainter(bitmap.asImageBitmap()), + contentDescription = contentDescription, + modifier = + modifier + .clip(CircleShape) + .semantics { + this.contentDescription = contentDescription ?: "Profile avatar" + }, + contentScale = ContentScale.Crop, + ) + } else { + AvatarFallback(modifier = modifier, contentDescription = contentDescription) + } +} + +@Composable +fun AvatarImageWithFallback( + avatarB64: String?, + fallbackText: String = "", + size: Dp = 48.dp, + contentDescription: String? = null, +) { + if (avatarB64 != null && avatarB64.isNotEmpty()) { + AvatarImage( + avatarB64 = avatarB64, + modifier = Modifier.size(size), + contentDescription = contentDescription, + ) + } else { + AvatarFallback( + modifier = Modifier.size(size), + fallbackText = fallbackText, + contentDescription = contentDescription, + ) + } +} + +@Composable +private fun AvatarFallback( + modifier: Modifier = Modifier, + fallbackText: String = "", + contentDescription: String? = null, +) { + Box( + modifier = + modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .semantics { + this.contentDescription = contentDescription ?: "Default profile avatar" + }, + contentAlignment = Alignment.Center, + ) { + if (fallbackText.isNotEmpty()) { + Text( + text = fallbackText.take(2).uppercase(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.fillMaxSize(0.6f), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropButton.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropButton.kt new file mode 100644 index 0000000..3b21539 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropButton.kt @@ -0,0 +1,235 @@ +package dev.arkbuilders.drop.app.presentation.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +enum class DropButtonSize { + Small, + Medium, + Large, +} + +enum class DropButtonVariant { + Primary, + Secondary, + Tertiary, + Destructive, +} + +@Composable +fun DropButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + variant: DropButtonVariant = DropButtonVariant.Primary, + size: DropButtonSize = DropButtonSize.Medium, + enabled: Boolean = true, + loading: Boolean = false, + contentDescription: String? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable RowScope.() -> Unit, +) { + val haptic = LocalHapticFeedback.current + val isPressed by interactionSource.collectIsPressedAsState() + + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.95f else 1f, + label = "buttonScale", + ) + + val buttonHeight = + when (size) { + DropButtonSize.Small -> 40.dp + DropButtonSize.Medium -> DesignTokens.TouchTarget.minimum + DropButtonSize.Large -> DesignTokens.TouchTarget.large + } + + val contentPadding = + when (size) { + DropButtonSize.Small -> + PaddingValues( + horizontal = DesignTokens.Spacing.md, + vertical = DesignTokens.Spacing.xs, + ) + DropButtonSize.Medium -> + PaddingValues( + horizontal = DesignTokens.Spacing.lg, + vertical = DesignTokens.Spacing.sm, + ) + DropButtonSize.Large -> + PaddingValues( + horizontal = DesignTokens.Spacing.xl, + vertical = DesignTokens.Spacing.md, + ) + } + + val colors = + when (variant) { + DropButtonVariant.Primary -> + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + DropButtonVariant.Secondary -> + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary, + contentColor = MaterialTheme.colorScheme.onSecondary, + disabledContainerColor = + MaterialTheme.colorScheme.secondary.copy( + alpha = 0.12f, + ), + disabledContentColor = + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.38f, + ), + ) + DropButtonVariant.Tertiary -> + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary, + contentColor = MaterialTheme.colorScheme.onTertiary, + disabledContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.12f), + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + DropButtonVariant.Destructive -> + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + disabledContainerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.12f), + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + } + + Button( + onClick = { + if (!loading) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } + }, + modifier = + modifier + .scale(scale) + .defaultMinSize(minHeight = buttonHeight) + .semantics { + role = Role.Button + contentDescription?.let { this.contentDescription = it } + }, + enabled = enabled && !loading, + colors = colors, + contentPadding = contentPadding, + interactionSource = interactionSource, + shape = RoundedCornerShape(DesignTokens.CornerRadius.md), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + content() + } + } +} + +@Composable +fun DropOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: DropButtonSize = DropButtonSize.Medium, + enabled: Boolean = true, + loading: Boolean = false, + contentDescription: String? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable RowScope.() -> Unit, +) { + val haptic = LocalHapticFeedback.current + val isPressed by interactionSource.collectIsPressedAsState() + + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.95f else 1f, + label = "buttonScale", + ) + + val buttonHeight = + when (size) { + DropButtonSize.Small -> 40.dp + DropButtonSize.Medium -> DesignTokens.TouchTarget.minimum + DropButtonSize.Large -> DesignTokens.TouchTarget.large + } + + val contentPadding = + when (size) { + DropButtonSize.Small -> + PaddingValues( + horizontal = DesignTokens.Spacing.md, + vertical = DesignTokens.Spacing.xs, + ) + DropButtonSize.Medium -> + PaddingValues( + horizontal = DesignTokens.Spacing.lg, + vertical = DesignTokens.Spacing.sm, + ) + DropButtonSize.Large -> + PaddingValues( + horizontal = DesignTokens.Spacing.xl, + vertical = DesignTokens.Spacing.md, + ) + } + + OutlinedButton( + onClick = { + if (!loading) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } + }, + modifier = + modifier + .scale(scale) + .defaultMinSize(minHeight = buttonHeight) + .semantics { + role = Role.Button + contentDescription?.let { this.contentDescription = it } + }, + enabled = enabled && !loading, + contentPadding = contentPadding, + interactionSource = interactionSource, + shape = RoundedCornerShape(DesignTokens.CornerRadius.md), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.dp, + ) + } else { + content() + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropCard.kt new file mode 100644 index 0000000..e5fbd12 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropCard.kt @@ -0,0 +1,153 @@ +package dev.arkbuilders.drop.app.presentation.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +enum class DropCardVariant { + Filled, + Elevated, + Outlined, +} + +enum class DropCardSize { + Small, + Medium, + Large, +} + +@Composable +fun DropCard( + modifier: Modifier = Modifier, + variant: DropCardVariant = DropCardVariant.Filled, + size: DropCardSize = DropCardSize.Medium, + onClick: (() -> Unit)? = null, + contentDescription: String? = null, + shape: Shape = + RoundedCornerShape( + when (size) { + DropCardSize.Small -> DesignTokens.CornerRadius.sm + DropCardSize.Medium -> DesignTokens.CornerRadius.md + DropCardSize.Large -> DesignTokens.CornerRadius.lg + }, + ), + colors: CardColors = + when (variant) { + DropCardVariant.Filled -> + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) + DropCardVariant.Elevated -> + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) + DropCardVariant.Outlined -> + CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) + }, + content: @Composable ColumnScope.() -> Unit, +) { + val cardModifier = + modifier + .fillMaxWidth() + .semantics { + contentDescription?.let { this.contentDescription = it } + } + + val cardPadding = + when (size) { + DropCardSize.Small -> DesignTokens.Spacing.md + DropCardSize.Medium -> DesignTokens.Spacing.lg + DropCardSize.Large -> DesignTokens.Spacing.xl + } + + val elevation = + when (variant) { + DropCardVariant.Filled -> + CardDefaults.cardElevation( + defaultElevation = DesignTokens.Elevation.none, + ) + DropCardVariant.Elevated -> + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.md, + ) + DropCardVariant.Outlined -> + CardDefaults.outlinedCardElevation( + defaultElevation = DesignTokens.Elevation.none, + ) + } + + when (variant) { + DropCardVariant.Filled -> { + Card( + modifier = cardModifier, + onClick = onClick ?: { }, + shape = shape, + colors = colors, + elevation = elevation, + ) { + content() + } + } + DropCardVariant.Elevated -> { + ElevatedCard( + modifier = cardModifier, + onClick = onClick ?: { }, + shape = shape, + colors = colors, + elevation = elevation, + ) { + content() + } + } + DropCardVariant.Outlined -> { + OutlinedCard( + modifier = cardModifier, + onClick = onClick ?: { }, + shape = shape, + colors = colors, + elevation = elevation, + ) { + content() + } + } + } +} + +@Composable +fun DropCardContent( + modifier: Modifier = Modifier, + size: DropCardSize = DropCardSize.Medium, + content: @Composable ColumnScope.() -> Unit, +) { + val padding = + when (size) { + DropCardSize.Small -> DesignTokens.Spacing.md + DropCardSize.Medium -> DesignTokens.Spacing.lg + DropCardSize.Large -> DesignTokens.Spacing.xl + } + + Column( + modifier = modifier.padding(padding), + ) { + content() + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropErrorCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropErrorCard.kt new file mode 100644 index 0000000..963254f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropErrorCard.kt @@ -0,0 +1,121 @@ +package dev.arkbuilders.drop.app.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +@Composable +fun DropErrorCard( + message: String, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + ElevatedCard( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.lg, + ), + ) { + Column( + modifier = Modifier.Companion.padding(DesignTokens.Spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(80.dp) + .background( + color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.2, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md), + ) { + TextButton( + onClick = onDismiss, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.md), + ) { + Text("Cancel", fontWeight = FontWeight.Medium) + } + + Button( + onClick = onRetry, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.md), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text("Retry", fontWeight = FontWeight.SemiBold) + } + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropInstructionsCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropInstructionsCard.kt new file mode 100644 index 0000000..e938e7f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropInstructionsCard.kt @@ -0,0 +1,70 @@ +package dev.arkbuilders.drop.app.presentation.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +@Composable +fun DropInstructionsCard( + modifier: Modifier = Modifier, + title: String, + steps: List, +) { + Card( + modifier = modifier, + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.5f, + ), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + ) { + Column( + modifier = Modifier.Companion.padding(DesignTokens.Spacing.lg), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + steps.forEachIndexed { index, step -> + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(vertical = 2.dp), + ) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.sm)) + Text( + text = step, + style = MaterialTheme.typography.bodyMedium, + lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2, + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropTopBar.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropTopBar.kt new file mode 100644 index 0000000..dd336a0 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/DropTopBar.kt @@ -0,0 +1,80 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package dev.arkbuilders.drop.app.presentation.components + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import dev.arkbuilders.drop.app.R + +@Composable +fun DropTopBar( + title: String, + leadingIcon: Painter? = null, + onLeadingIconClick: (() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + containerColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = MaterialTheme.colorScheme.onBackground, +) { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { + leadingIcon?.let { + IconButton( + onClick = { + onLeadingIconClick?.invoke() + }, + ) { + Icon( + painter = leadingIcon, + contentDescription = null, + ) + } + } + }, + actions = { + trailingContent?.let { + it() + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = containerColor, + titleContentColor = contentColor, + actionIconContentColor = contentColor, + ), + ) +} + +@Composable +fun DropTopBarBack( + title: String, + onBackClick: (() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + containerColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = MaterialTheme.colorScheme.onBackground, +) { + DropTopBar( + title = title, + leadingIcon = painterResource(R.drawable.ic_back), + onLeadingIconClick = onBackClick, + trailingContent = trailingContent, + containerColor = containerColor, + contentColor = contentColor, + ) +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/ErrorStates.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/ErrorStates.kt new file mode 100644 index 0000000..9126023 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/ErrorStates.kt @@ -0,0 +1,179 @@ +package dev.arkbuilders.drop.app.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import compose.icons.TablerIcons +import compose.icons.tablericons.AlertCircle +import compose.icons.tablericons.CloudOff +import compose.icons.tablericons.FileX +import compose.icons.tablericons.WifiOff +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +enum class ErrorType { + Network, + FileTransfer, + Permission, + Generic, + Offline, +} + +data class ErrorState( + val type: ErrorType, + val title: String, + val message: String, + val actionLabel: String? = null, + val onAction: (() -> Unit)? = null, +) + +@Composable +fun ErrorStateDisplay( + errorState: ErrorState, + modifier: Modifier = Modifier, +) { + val icon = + when (errorState.type) { + ErrorType.Network -> TablerIcons.WifiOff + ErrorType.FileTransfer -> TablerIcons.FileX + ErrorType.Permission -> TablerIcons.AlertCircle + ErrorType.Offline -> TablerIcons.CloudOff + ErrorType.Generic -> Icons.Default.Warning + } + + val iconColor = + when (errorState.type) { + ErrorType.Network, ErrorType.Offline -> MaterialTheme.colorScheme.error + ErrorType.FileTransfer -> MaterialTheme.colorScheme.error + ErrorType.Permission -> MaterialTheme.colorScheme.error + ErrorType.Generic -> MaterialTheme.colorScheme.error + } + + DropCard( + modifier = modifier, + variant = DropCardVariant.Outlined, + size = DropCardSize.Large, + colors = + CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.1f), + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + DropCardContent(size = DropCardSize.Large) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = iconColor, + ) + + Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) + + Text( + text = errorState.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(DesignTokens.Spacing.sm)) + + Text( + text = errorState.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + errorState.actionLabel?.let { label -> + Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) + + DropButton( + onClick = { errorState.onAction?.invoke() }, + variant = DropButtonVariant.Primary, + size = DropButtonSize.Medium, + contentDescription = "Retry action", + ) { + Text(text = label) + } + } + } + } + } +} + +// Predefined error states for common scenarios +object CommonErrors { + fun networkError(onRetry: () -> Unit) = + ErrorState( + type = ErrorType.Network, + title = "Connection Problem", + message = + "Unable to connect to the network." + + " Please check your internet connection and try again.", + actionLabel = "Retry", + onAction = onRetry, + ) + + fun fileTransferError(onRetry: () -> Unit) = + ErrorState( + type = ErrorType.FileTransfer, + title = "Transfer Failed", + message = + "The file transfer was interrupted." + + " This might be due to network issues or insufficient storage space.", + actionLabel = "Try Again", + onAction = onRetry, + ) + + fun permissionError(onRequestPermission: () -> Unit) = + ErrorState( + type = ErrorType.Permission, + title = "Permission Required", + message = + "This feature requires additional permissions to work properly." + + " Please grant the necessary permissions.", + actionLabel = "Grant Permission", + onAction = onRequestPermission, + ) + + fun offlineError() = + ErrorState( + type = ErrorType.Offline, + title = "You're Offline", + message = + "This feature requires an internet connection." + + " Please check your network settings and try again.", + actionLabel = null, + onAction = null, + ) + + fun genericError(onRetry: () -> Unit) = + ErrorState( + type = ErrorType.Generic, + title = "Something Went Wrong", + message = + "An unexpected error occurred." + + " Please try again or contact support if the problem persists.", + actionLabel = "Retry", + onAction = onRetry, + ) +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/components/LoadingStates.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/LoadingStates.kt similarity index 62% rename from app/src/main/java/dev/arkbuilders/drop/app/ui/components/LoadingStates.kt rename to app/src/main/java/dev/arkbuilders/drop/app/presentation/components/LoadingStates.kt index 883bcbd..035c750 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/components/LoadingStates.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/components/LoadingStates.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.drop.app.ui.components +package dev.arkbuilders.drop.app.presentation.components import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -30,47 +30,44 @@ import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import dev.arkbuilders.drop.app.ui.theme.DesignTokens +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens @Composable fun LoadingIndicator( modifier: Modifier = Modifier, - message: String? = null + message: String? = null, ) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { CircularProgressIndicator( modifier = Modifier.size(48.dp), color = MaterialTheme.colorScheme.primary, - strokeWidth = 4.dp + strokeWidth = 4.dp, ) - + message?.let { Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) Text( text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) } } } @Composable -fun SkeletonLoader( - modifier: Modifier = Modifier -) { +fun SkeletonLoader(modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) + verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md), ) { repeat(3) { SkeletonCard() @@ -82,41 +79,44 @@ fun SkeletonLoader( private fun SkeletonCard() { DropCard( variant = DropCardVariant.Elevated, - size = DropCardSize.Medium + size = DropCardSize.Medium, ) { DropCardContent(size = DropCardSize.Medium) { Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Avatar skeleton Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .shimmerEffect() + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .shimmerEffect(), ) - + Spacer(modifier = Modifier.width(DesignTokens.Spacing.lg)) - + Column(modifier = Modifier.weight(1f)) { // Title skeleton Box( - modifier = Modifier - .fillMaxWidth(0.7f) - .height(16.dp) - .clip(RoundedCornerShape(DesignTokens.CornerRadius.xs)) - .shimmerEffect() + modifier = + Modifier + .fillMaxWidth(0.7f) + .height(16.dp) + .clip(RoundedCornerShape(DesignTokens.CornerRadius.xs)) + .shimmerEffect(), ) - + Spacer(modifier = Modifier.height(DesignTokens.Spacing.xs)) - + // Subtitle skeleton Box( - modifier = Modifier - .fillMaxWidth(0.5f) - .height(12.dp) - .clip(RoundedCornerShape(DesignTokens.CornerRadius.xs)) - .shimmerEffect() + modifier = + Modifier + .fillMaxWidth(0.5f) + .height(12.dp) + .clip(RoundedCornerShape(DesignTokens.CornerRadius.xs)) + .shimmerEffect(), ) } } @@ -124,59 +124,63 @@ private fun SkeletonCard() { } } -fun Modifier.shimmerEffect(): Modifier = composed { - val transition = rememberInfiniteTransition(label = "shimmer") - val alpha by transition.animateFloat( - initialValue = 0.2f, - targetValue = 0.9f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "shimmerAlpha" - ) - - background( - brush = Brush.linearGradient( - colors = listOf( - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha), - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha * 0.5f) - ), - start = Offset.Zero, - end = Offset.Infinite +fun Modifier.shimmerEffect(): Modifier = + composed { + val transition = rememberInfiniteTransition(label = "shimmer") + val alpha by transition.animateFloat( + initialValue = 0.2f, + targetValue = 0.9f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "shimmerAlpha", ) - ) -} + + background( + brush = + Brush.linearGradient( + colors = + listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha * 0.5f), + ), + start = Offset.Zero, + end = Offset.Infinite, + ), + ) + } @Composable fun EmptyState( title: String, description: String, modifier: Modifier = Modifier, - action: (@Composable () -> Unit)? = null + action: (@Composable () -> Unit)? = null, ) { Column( modifier = modifier.padding(DesignTokens.Spacing.xl), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { Text( text = title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) - + Spacer(modifier = Modifier.height(DesignTokens.Spacing.sm)) - + Text( text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, ) - + action?.let { Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) it() diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/history/History.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/history/History.kt similarity index 53% rename from app/src/main/java/dev/arkbuilders/drop/app/ui/history/History.kt rename to app/src/main/java/dev/arkbuilders/drop/app/presentation/history/History.kt index 614e40f..df756d2 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/history/History.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/history/History.kt @@ -1,7 +1,8 @@ -package dev.arkbuilders.drop.app.ui.history +package dev.arkbuilders.drop.app.presentation.history import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -14,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -29,11 +29,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -45,175 +42,175 @@ import compose.icons.tablericons.ClearAll import compose.icons.tablericons.FileDownload import compose.icons.tablericons.FileUpload import compose.icons.tablericons.History -import dev.arkbuilders.drop.app.data.HistoryRepository -import dev.arkbuilders.drop.app.data.TransferHistoryItem -import dev.arkbuilders.drop.app.data.TransferStatus -import dev.arkbuilders.drop.app.data.TransferType -import dev.arkbuilders.drop.app.ui.profile.AvatarUtils -import java.text.SimpleDateFormat -import java.util.Date +import dev.arkbuilders.drop.app.domain.model.TransferSession +import dev.arkbuilders.drop.app.domain.model.TransferStatus +import dev.arkbuilders.drop.app.domain.model.TransferType +import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.app.presentation.components.AvatarImageWithFallback +import dev.arkbuilders.drop.app.presentation.components.DropTopBarBack +import org.koin.compose.koinInject +import org.orbitmvi.orbit.compose.collectAsState +import java.time.Duration +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun History( navController: NavController, - historyRepository: HistoryRepository + transferSessionRepo: TransferSessionRepo, ) { - val historyItems by historyRepository.historyItems.collectAsState() - var showClearDialog by remember { mutableStateOf(false) } + val viewModel: HistoryViewModel = koinInject() + + val scope = rememberCoroutineScope() + val state by viewModel.collectAsState() Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) + modifier = + Modifier + .fillMaxSize(), ) { - // Top bar - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { navController.navigateUp() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - Text( - text = "Transfer History", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - - // Clear all button - if (historyItems.isNotEmpty()) { - IconButton(onClick = { showClearDialog = true }) { + DropTopBarBack( + title = "Transfer history", + onBackClick = { navController.navigateUp() }, + trailingContent = { + IconButton(onClick = { viewModel.onShowClearDialog() }) { Icon( TablerIcons.ClearAll, contentDescription = "Clear All", - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } - } - - Spacer(modifier = Modifier.height(16.dp)) + }, + ) - if (historyItems.isEmpty()) { - // Empty state + if (state.historyItems.isEmpty()) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) { Column( modifier = Modifier.padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( TablerIcons.History, contentDescription = null, modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(20.dp)) Text( text = "No Transfer History", style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Your sent and received files will appear here with details about each transfer.", + text = + "Your sent and received files will appear" + + " here with details about each transfer.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3 + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3, ) } } } else { - // History list LazyColumn( - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(16.dp), ) { - items(historyItems) { item -> + items(state.historyItems) { item -> HistoryItemCard( + state = state, item = item, + onShowDeleteDialog = viewModel::onShowDeleteDialog, + onDismissDeleteDialog = viewModel::onDismissDeleteDialog, onDelete = { - historyRepository.deleteHistoryItem(item.id) - } + viewModel.onDelete(item.id) + }, ) } } } } - // Clear all confirmation dialog - if (showClearDialog) { + if (state.showClearDialog) { AlertDialog( - onDismissRequest = { showClearDialog = false }, + onDismissRequest = { viewModel.onDismissClearDialog() }, title = { Text( "Clear All History", style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) }, text = { Text( - "Are you sure you want to clear all transfer history? This action cannot be undone.", - style = MaterialTheme.typography.bodyLarge + "Are you sure you want to clear all transfer history?" + + " This action cannot be undone.", + style = MaterialTheme.typography.bodyLarge, ) }, confirmButton = { Button( onClick = { - historyRepository.clearHistory() - showClearDialog = false + viewModel.onClear() }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), ) { Text("Clear All", fontWeight = FontWeight.Medium) } }, dismissButton = { - TextButton(onClick = { showClearDialog = false }) { + TextButton(onClick = { viewModel.onDismissClearDialog() }) { Text("Cancel", fontWeight = FontWeight.Medium) } }, - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) } } @Composable private fun HistoryItemCard( - item: TransferHistoryItem, - onDelete: () -> Unit + state: HistoryScreenState, + item: TransferSession, + onShowDeleteDialog: () -> Unit, + onDismissDeleteDialog: () -> Unit, + onDelete: () -> Unit, ) { - var showDeleteDialog by remember { mutableStateOf(false) } - ElevatedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = 4.dp) + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 4.dp), ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, ) { // Peer avatar - AvatarUtils.AvatarImageWithFallback( - base64String = item.peerAvatar, + AvatarImageWithFallback( + avatarB64 = item.peerAvatar, fallbackText = item.peerName, - size = 48.dp + size = 48.dp, ) Spacer(modifier = Modifier.width(16.dp)) @@ -222,151 +219,135 @@ private fun HistoryItemCard( Column(modifier = Modifier.weight(1f)) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { // Transfer type icon Icon( - imageVector = when (item.type) { - TransferType.SENT -> TablerIcons.FileUpload - TransferType.RECEIVED -> TablerIcons.FileDownload - }, + imageVector = + when (item.type) { + TransferType.SENT -> TablerIcons.FileUpload + TransferType.RECEIVED -> TablerIcons.FileDownload + }, contentDescription = null, modifier = Modifier.size(20.dp), - tint = when (item.status) { - TransferStatus.COMPLETED -> MaterialTheme.colorScheme.primary - TransferStatus.FAILED -> MaterialTheme.colorScheme.error - TransferStatus.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant - } + tint = + when (item.status) { + TransferStatus.COMPLETED -> MaterialTheme.colorScheme.primary + TransferStatus.FAILED -> MaterialTheme.colorScheme.error + TransferStatus.CANCELLED -> + MaterialTheme.colorScheme.onSurfaceVariant + }, ) Text( - text = when (item.type) { - TransferType.SENT -> "Sent to ${item.peerName}" - TransferType.RECEIVED -> "Received from ${item.peerName}" - }, + text = + when (item.type) { + TransferType.SENT -> "Sent to ${item.peerName}" + TransferType.RECEIVED -> "Received from ${item.peerName}" + }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } Spacer(modifier = Modifier.height(4.dp)) Text( - text = item.fileName, + text = item.files.firstOrNull()?.name ?: "", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = formatFileSize(item.fileSize), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = "•", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = formatTimestamp(item.timestamp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + val size = formatFileSize(item.files.sumOf { it.size }) + val timestamp = formatTimestamp(item.timestamp) + val filesCount = + if (item.files.size > 1) + " • ${item.files.size} files" + else + "" - if (item.fileCount > 1) { - Text( - text = "•", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + val info = "$size • $timestamp$filesCount" - Text( - text = "${item.fileCount} files", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium - ) - } - } + Text( + text = info, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(8.dp)) // Status Text( - text = when (item.status) { - TransferStatus.COMPLETED -> "Completed" - TransferStatus.FAILED -> "Failed" - TransferStatus.CANCELLED -> "Cancelled" - }, + text = + when (item.status) { + TransferStatus.COMPLETED -> "Completed" + TransferStatus.FAILED -> "Failed" + TransferStatus.CANCELLED -> "Cancelled" + }, style = MaterialTheme.typography.bodySmall, - color = when (item.status) { - TransferStatus.COMPLETED -> MaterialTheme.colorScheme.primary - TransferStatus.FAILED -> MaterialTheme.colorScheme.error - TransferStatus.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant - }, - fontWeight = FontWeight.Medium + color = + when (item.status) { + TransferStatus.COMPLETED -> MaterialTheme.colorScheme.primary + TransferStatus.FAILED -> MaterialTheme.colorScheme.error + TransferStatus.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = FontWeight.Medium, ) } // Delete button - IconButton(onClick = { showDeleteDialog = true }) { + IconButton(onClick = { onShowDeleteDialog() }) { Icon( Icons.Default.Delete, contentDescription = "Delete", - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } // Delete confirmation dialog - if (showDeleteDialog) { + if (state.showDeleteDialog) { AlertDialog( - onDismissRequest = { showDeleteDialog = false }, + onDismissRequest = { onDismissDeleteDialog() }, title = { Text( "Delete History Item", style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, ) }, text = { Text( "Are you sure you want to delete this transfer from history?", - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, ) }, confirmButton = { Button( onClick = { onDelete() - showDeleteDialog = false }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), ) { Text("Delete", fontWeight = FontWeight.Medium) } }, dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { + TextButton(onClick = { onDismissDeleteDialog() }) { Text("Cancel", fontWeight = FontWeight.Medium) } }, - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) } } @@ -381,15 +362,18 @@ private fun formatFileSize(bytes: Long): String { return "%.1f GB".format(gb) } -private fun formatTimestamp(timestamp: Long): String { - val now = System.currentTimeMillis() - val diff = now - timestamp +private fun formatTimestamp(timestamp: OffsetDateTime): String { + val now = OffsetDateTime.now() + val diff = Duration.between(timestamp, now).toMillis() return when { diff < 60000 -> "Just now" diff < 3600000 -> "${diff / 60000}m ago" diff < 86400000 -> "${diff / 3600000}h ago" diff < 604800000 -> "${diff / 86400000}d ago" - else -> SimpleDateFormat("MMM dd", Locale.getDefault()).format(Date(timestamp)) + else -> + timestamp.format( + DateTimeFormatter.ofPattern("MMM dd", Locale.getDefault()), + ) } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/history/HistoryViewModel.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/history/HistoryViewModel.kt new file mode 100644 index 0000000..b9b6a70 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/history/HistoryViewModel.kt @@ -0,0 +1,86 @@ +package dev.arkbuilders.drop.app.presentation.history + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.arkbuilders.drop.app.domain.model.TransferSession +import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +data class HistoryScreenState( + val historyItems: List, + val showClearDialog: Boolean, + val showDeleteDialog: Boolean, +) + +sealed class HistoryScreenEffect + +class HistoryViewModel( + private val historyItemRepository: TransferSessionRepo, +) : ViewModel(), ContainerHost { + override val container: Container = + container( + HistoryScreenState( + historyItems = emptyList(), + showClearDialog = false, + showDeleteDialog = false, + ), + ) + + init { + historyItemRepository.historyItems.onEach { items -> + intent { + reduce { + state.copy(historyItems = items) + } + } + }.launchIn(viewModelScope) + } + + fun onShowClearDialog() = + intent { + reduce { + state.copy(showClearDialog = true) + } + } + + fun onClear() = + intent { + historyItemRepository.clearHistory() + reduce { + state.copy(showClearDialog = false) + } + } + + fun onDismissClearDialog() = + intent { + reduce { + state.copy(showClearDialog = false) + } + } + + fun onShowDeleteDialog() = + intent { + reduce { + state.copy(showDeleteDialog = true) + } + } + + fun onDelete(id: Long) = + intent { + historyItemRepository.deleteSession(id) + reduce { + state.copy(showDeleteDialog = false) + } + } + + fun onDismissDeleteDialog() = + intent { + reduce { + state.copy(showDeleteDialog = false) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/Home/Home.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/home/Home.kt similarity index 53% rename from app/src/main/java/dev/arkbuilders/drop/app/ui/Home/Home.kt rename to app/src/main/java/dev/arkbuilders/drop/app/presentation/home/Home.kt index 75df214..e3da778 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/Home/Home.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/home/Home.kt @@ -1,5 +1,9 @@ -package dev.arkbuilders.drop.app.ui.Home +package dev.arkbuilders.drop.app.presentation.home +import android.Manifest +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring @@ -21,7 +25,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -30,6 +33,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -43,37 +47,43 @@ import compose.icons.tablericons.ArrowUpCircle import compose.icons.tablericons.CloudDownload import compose.icons.tablericons.CloudUpload import compose.icons.tablericons.History -import dev.arkbuilders.drop.app.ProfileManager import dev.arkbuilders.drop.app.R -import dev.arkbuilders.drop.app.UserProfile -import dev.arkbuilders.drop.app.data.HistoryRepository -import dev.arkbuilders.drop.app.data.TransferHistoryItem -import dev.arkbuilders.drop.app.data.TransferType -import dev.arkbuilders.drop.app.navigation.DropDestination -import dev.arkbuilders.drop.app.ui.components.DropButton -import dev.arkbuilders.drop.app.ui.components.DropButtonSize -import dev.arkbuilders.drop.app.ui.components.DropButtonVariant -import dev.arkbuilders.drop.app.ui.components.DropCard -import dev.arkbuilders.drop.app.ui.components.DropCardContent -import dev.arkbuilders.drop.app.ui.components.DropCardSize -import dev.arkbuilders.drop.app.ui.components.DropCardVariant -import dev.arkbuilders.drop.app.ui.components.DropOutlinedButton -import dev.arkbuilders.drop.app.ui.components.EmptyState -import dev.arkbuilders.drop.app.ui.profile.AvatarUtils -import dev.arkbuilders.drop.app.ui.theme.DesignTokens +import dev.arkbuilders.drop.app.domain.model.TransferSession +import dev.arkbuilders.drop.app.domain.model.TransferType +import dev.arkbuilders.drop.app.domain.model.UserProfile +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import dev.arkbuilders.drop.app.presentation.components.AvatarImage +import dev.arkbuilders.drop.app.presentation.components.AvatarImageWithFallback +import dev.arkbuilders.drop.app.presentation.components.DropButton +import dev.arkbuilders.drop.app.presentation.components.DropButtonSize +import dev.arkbuilders.drop.app.presentation.components.DropButtonVariant +import dev.arkbuilders.drop.app.presentation.components.DropCard +import dev.arkbuilders.drop.app.presentation.components.DropCardContent +import dev.arkbuilders.drop.app.presentation.components.DropCardSize +import dev.arkbuilders.drop.app.presentation.components.DropCardVariant +import dev.arkbuilders.drop.app.presentation.components.DropOutlinedButton +import dev.arkbuilders.drop.app.presentation.components.EmptyState +import dev.arkbuilders.drop.app.presentation.navigation.DropDestination +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens import kotlinx.coroutines.delay -import java.text.SimpleDateFormat -import java.util.Date +import org.koin.compose.koinInject +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect +import java.time.Duration +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter import java.util.Locale @Composable fun Home( navController: NavController, - profileManager: ProfileManager, - historyRepository: HistoryRepository, + profileRepo: ProfileRepo, + transferSessionRepo: TransferSessionRepo, ) { - val profile = remember { profileManager.getCurrentProfile() } - val historyItems by historyRepository.historyItems.collectAsState() + val viewModel: HomeViewModel = koinInject() + val state by viewModel.collectAsState() + val context = LocalContext.current var logoScale by remember { mutableStateOf(0f) } @@ -85,25 +95,51 @@ fun Home( val animatedLogoScale by animateFloatAsState( targetValue = logoScale, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "logoScale" + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + label = "logoScale", ) + val requestWritePermissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + navController.navigate(DropDestination.Receive.route) + } else { + Toast + .makeText(context, "Write permission not granted", Toast.LENGTH_SHORT) + .show() + } + } + + viewModel.collectSideEffect { effect -> + when (effect) { + HomeScreenEffect.AskWritePermission -> { + requestWritePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + HomeScreenEffect.NavigateToReceiveScreen -> { + navController.navigate(DropDestination.Receive.route) + } + } + } + LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(DesignTokens.Spacing.lg), - verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.xl) + modifier = + Modifier + .fillMaxSize() + .padding(DesignTokens.Spacing.lg), + verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.xl), ) { // Header Section item { HeaderSection( logoScale = animatedLogoScale, onProfileClick = { navController.navigate(DropDestination.EditProfile.route) }, - profile = profile + profile = state.profile, ) } @@ -111,17 +147,17 @@ fun Home( item { QuickActionsSection( onSendClick = { navController.navigate(DropDestination.Send.route) }, - onReceiveClick = { navController.navigate(DropDestination.Receive.route) } + onReceiveClick = { viewModel.onReceiveClick() }, ) } // Recent Transfers Section item { - if (historyItems.isNotEmpty()) { + if (state.historyItems.isNotEmpty()) { RecentTransfersSection( - historyItems = historyItems.take(5), + historyItems = state.historyItems.take(5), onViewAllClick = { navController.navigate(DropDestination.History.route) }, - showViewAll = historyItems.isNotEmpty() + showViewAll = state.historyItems.isNotEmpty(), ) } else { EmptyTransfersSection() @@ -134,22 +170,23 @@ fun Home( private fun HeaderSection( logoScale: Float, onProfileClick: () -> Unit, - profile: UserProfile + profile: UserProfile, ) { Row( - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = "App header with logo and profile access" }, + modifier = + Modifier + .fillMaxWidth() + .semantics { contentDescription = "App header with logo and profile access" }, horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // App branding Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.lg) + horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.lg), ) { Box( - modifier = Modifier.scale(logoScale) + modifier = Modifier.scale(logoScale), ) { Icon( modifier = Modifier.size(56.dp), @@ -164,24 +201,24 @@ private fun HeaderSection( text = "Drop", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) Text( text = "Share files instantly", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - // Profile access IconButton( onClick = onProfileClick, - modifier = Modifier.semantics { - contentDescription = "Open profile settings" - } + modifier = + Modifier.semantics { + contentDescription = "Open profile settings" + }, ) { - AvatarUtils.AvatarImageWithFallback(profile.avatarB64) + AvatarImage(avatarB64 = profile.avatar.base64) } } } @@ -189,12 +226,12 @@ private fun HeaderSection( @Composable private fun QuickActionsSection( onSendClick: () -> Unit, - onReceiveClick: () -> Unit + onReceiveClick: () -> Unit, ) { DropCard( variant = DropCardVariant.Elevated, size = DropCardSize.Large, - contentDescription = "Quick actions for sending and receiving files" + contentDescription = "Quick actions for sending and receiving files", ) { DropCardContent(size = DropCardSize.Large) { Text( @@ -203,10 +240,10 @@ private fun QuickActionsSection( fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) // Send button DropButton( @@ -214,40 +251,40 @@ private fun QuickActionsSection( variant = DropButtonVariant.Primary, size = DropButtonSize.Large, modifier = Modifier.fillMaxWidth(), - contentDescription = "Send files to another device" + contentDescription = "Send files to another device", ) { Icon( TablerIcons.ArrowUpCircle, contentDescription = null, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.md)) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.md)) Text( "Send Files", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) } - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) // Receive button DropOutlinedButton( onClick = onReceiveClick, size = DropButtonSize.Large, modifier = Modifier.fillMaxWidth(), - contentDescription = "Receive files from another device" + contentDescription = "Receive files from another device", ) { Icon( TablerIcons.ArrowDownCircle, contentDescription = null, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(24.dp), ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.md)) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.md)) Text( "Receive Files", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ) } } @@ -256,44 +293,44 @@ private fun QuickActionsSection( @Composable private fun RecentTransfersSection( - historyItems: List, + historyItems: List, onViewAllClick: () -> Unit, - showViewAll: Boolean + showViewAll: Boolean, ) { Column { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "Recent Transfers", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) if (showViewAll) { DropOutlinedButton( onClick = onViewAllClick, size = DropButtonSize.Small, - contentDescription = "View all transfer history" + contentDescription = "View all transfer history", ) { Icon( TablerIcons.History, contentDescription = null, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.xs)) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.xs)) Text("View All") } } } - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) Column( - verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) + verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md), ) { historyItems.forEach { item -> EnhancedTransferHistoryCard(item = item) @@ -307,78 +344,97 @@ private fun EmptyTransfersSection() { DropCard( variant = DropCardVariant.Outlined, size = DropCardSize.Large, - contentDescription = "No transfers yet - empty state" + contentDescription = "No transfers yet - empty state", ) { EmptyState( title = "No transfers yet", - description = "Start by sending or receiving files to see your transfer history here. Your recent activity will appear in this section." + description = + "Start by sending or receiving files to see your transfer history here." + + " Your recent activity will appear in this section.", ) } } @Composable -private fun EnhancedTransferHistoryCard(item: TransferHistoryItem) { +private fun EnhancedTransferHistoryCard(item: TransferSession) { DropCard( variant = DropCardVariant.Elevated, size = DropCardSize.Medium, - contentDescription = "Transfer: ${if (item.type == TransferType.SENT) "Sent to" else "Received from"} ${item.peerName}" + contentDescription = "Transfer: ${ + if (item.type == TransferType.SENT) "Sent to" else "Received from" + } ${item.peerName}", ) { DropCardContent(size = DropCardSize.Medium) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Transfer type icon with semantic color Icon( - imageVector = if (item.type == TransferType.SENT) TablerIcons.CloudUpload else TablerIcons.CloudDownload, + imageVector = + if (item.type == TransferType.SENT) + TablerIcons.CloudUpload + else + TablerIcons.CloudDownload, contentDescription = if (item.type == TransferType.SENT) "Sent" else "Received", modifier = Modifier.size(24.dp), - tint = if (item.type == TransferType.SENT) - MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.secondary + tint = + if (item.type == TransferType.SENT) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.secondary + }, ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.lg)) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.lg)) Column(modifier = Modifier.weight(1f)) { Text( - text = if (item.type == TransferType.SENT) - "Sent to ${item.peerName}" - else - "Received from ${item.peerName}", + text = + if (item.type == TransferType.SENT) { + "Sent to ${item.peerName}" + } else { + "Received from ${item.peerName}" + }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xs)) + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xs)) Text( - text = "${item.fileCount} file${if (item.fileCount != 1) "s" else ""} • ${formatTimestamp(item.timestamp)}", + text = "${item.files.size} file${ + if (item.files.size != 1) "s" else "" + } • ${formatTimestamp( + item.timestamp, + )}", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } // Peer avatar - AvatarUtils.AvatarImageWithFallback( - base64String = item.peerAvatar, + AvatarImageWithFallback( + avatarB64 = item.peerAvatar, fallbackText = item.peerName, - size = 40.dp + size = 40.dp, ) } } } } -private fun formatTimestamp(timestamp: Long): String { - val now = System.currentTimeMillis() - val diff = now - timestamp +private fun formatTimestamp(timestamp: OffsetDateTime): String { + val now = OffsetDateTime.now() + val diff = Duration.between(timestamp, now) return when { - diff < 60000 -> "Just now" - diff < 3600000 -> "${diff / 60000}m ago" - diff < 86400000 -> "${diff / 3600000}h ago" - diff < 604800000 -> "${diff / 86400000}d ago" - else -> SimpleDateFormat("MMM dd", Locale.getDefault()).format(Date(timestamp)) + diff.toMinutes() < 1 -> "Just now" + diff.toHours() < 1 -> "${diff.toMinutes()}m ago" + diff.toDays() < 1 -> "${diff.toHours()}h ago" + diff.toDays() < 7 -> "${diff.toDays()}d ago" + else -> + timestamp.format( + DateTimeFormatter.ofPattern("MMM dd", Locale.getDefault()), + ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/home/HomeViewModel.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/home/HomeViewModel.kt new file mode 100644 index 0000000..95ba5fe --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/home/HomeViewModel.kt @@ -0,0 +1,65 @@ +package dev.arkbuilders.drop.app.presentation.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.arkbuilders.drop.app.domain.PermissionsHelper +import dev.arkbuilders.drop.app.domain.model.TransferSession +import dev.arkbuilders.drop.app.domain.model.UserProfile +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import dev.arkbuilders.drop.app.domain.repository.TransferSessionRepo +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +data class HomeScreenState( + val historyItems: List, + val profile: UserProfile, +) + +sealed class HomeScreenEffect { + object AskWritePermission : HomeScreenEffect() + + object NavigateToReceiveScreen : HomeScreenEffect() +} + +class HomeViewModel( + private val historyItemRepository: TransferSessionRepo, + private val profileRepo: ProfileRepo, + private val permissionsHelper: PermissionsHelper, +) : ViewModel(), ContainerHost { + override val container: Container = + container(HomeScreenState(emptyList(), UserProfile.empty())) + + init { + historyItemRepository.historyItems.onEach { + intent { + reduce { + state.copy(historyItems = it) + } + } + }.launchIn(viewModelScope) + + intent { + val items = historyItemRepository.historyItems.first() + val profile = profileRepo.getCurrentProfile() + reduce { + state.copy( + historyItems = items, + profile = profile, + ) + } + } + } + + fun onReceiveClick() = + intent { + if (permissionsHelper.isWritePermissionGranted()) { + postSideEffect(HomeScreenEffect.NavigateToReceiveScreen) + } else { + postSideEffect(HomeScreenEffect.AskWritePermission) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/navigation/DropDestination.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/navigation/DropDestination.kt similarity index 70% rename from app/src/main/java/dev/arkbuilders/drop/app/navigation/DropDestination.kt rename to app/src/main/java/dev/arkbuilders/drop/app/presentation/navigation/DropDestination.kt index 86f6c82..a7b0a86 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/navigation/DropDestination.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/navigation/DropDestination.kt @@ -1,14 +1,23 @@ -package dev.arkbuilders.drop.app.navigation +package dev.arkbuilders.drop.app.presentation.navigation sealed class DropDestination(val route: String) { object Home : DropDestination("home") + object Send : DropDestination("send") + object History : DropDestination("history") + object EditProfile : DropDestination("edit_profile") + + object About : DropDestination("about") + object Receive : DropDestination("receive?ticket={ticket}&confirmation={confirmation}") { const val DEEP_LINK_PATTERN = "drop://receive?ticket={ticket}&confirmation={confirmation}" - - fun createRoute(ticket: String = "", confirmation: UByte): String { + + fun createRoute( + ticket: String = "", + confirmation: UByte, + ): String { return "receive?ticket=$ticket&confirmation=$confirmation" } } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/profile/AboutScreen.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/profile/AboutScreen.kt new file mode 100644 index 0000000..41ea41c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/profile/AboutScreen.kt @@ -0,0 +1,35 @@ +package dev.arkbuilders.drop.app.presentation.profile + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import dev.arkbuilders.components.about.presentation.ArkAbout +import dev.arkbuilders.drop.app.R +import dev.arkbuilders.drop.app.domain.model.BuildConfigFields +import dev.arkbuilders.drop.app.presentation.components.DropTopBarBack +import org.koin.compose.koinInject + +@Composable +fun AboutScreen(navController: NavController) { + val fields = koinInject() + + Scaffold( + topBar = { + DropTopBarBack( + title = "About", + onBackClick = { navController.popBackStack() }, + ) + }, + ) { + ArkAbout( + modifier = Modifier.padding(it), + appName = stringResource(id = R.string.app_name), + appLogoResId = R.drawable.ic_logo, + versionName = fields.versionName, + privacyPolicyUrl = "", + ) + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/profile/EditProfileScreen.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/profile/EditProfileScreen.kt new file mode 100644 index 0000000..6f03098 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/profile/EditProfileScreen.kt @@ -0,0 +1,716 @@ +package dev.arkbuilders.drop.app.presentation.profile + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import compose.icons.TablerIcons +import compose.icons.tablericons.Camera +import dev.arkbuilders.drop.app.domain.AvatarHelper +import dev.arkbuilders.drop.app.domain.model.UserAvatar +import dev.arkbuilders.drop.app.presentation.components.AvatarImage +import dev.arkbuilders.drop.app.presentation.components.DropButton +import dev.arkbuilders.drop.app.presentation.components.DropButtonSize +import dev.arkbuilders.drop.app.presentation.components.DropButtonVariant +import dev.arkbuilders.drop.app.presentation.components.DropCard +import dev.arkbuilders.drop.app.presentation.components.DropCardContent +import dev.arkbuilders.drop.app.presentation.components.DropCardSize +import dev.arkbuilders.drop.app.presentation.components.DropCardVariant +import dev.arkbuilders.drop.app.presentation.components.DropTopBarBack +import dev.arkbuilders.drop.app.presentation.components.ErrorState +import dev.arkbuilders.drop.app.presentation.components.ErrorStateDisplay +import dev.arkbuilders.drop.app.presentation.components.ErrorType +import dev.arkbuilders.drop.app.presentation.navigation.DropDestination +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens +import kotlinx.coroutines.delay +import org.koin.compose.koinInject +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditProfileEnhanced(navController: NavController) { + val viewModel: EditProfileViewModel = koinInject() + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val nameFocusRequester = remember { FocusRequester() } + + val state by viewModel.collectAsState() + + val imagePickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + if (uri != null) { + viewModel.onImagePicked(uri.toString()) + } + } + + viewModel.collectSideEffect { effect -> + when (effect) { + EditProfileScreenEffect.LaunchImagePicker -> { + imagePickerLauncher.launch("image/*") + } + + EditProfileScreenEffect.NavigateBack -> { + navController.popBackStack() + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.ime) + .verticalScroll(rememberScrollState()), + ) { + DropTopBarBack( + title = "Edit Profile", + onBackClick = { navController.navigateUp() }, + trailingContent = { + AnimatedVisibility( + modifier = Modifier.padding(end = DesignTokens.Spacing.lg), + visible = state.hasChanges, + enter = scaleIn(spring(stiffness = Spring.StiffnessHigh)) + fadeIn(), + exit = scaleOut(spring(stiffness = Spring.StiffnessHigh)) + fadeOut(), + ) { + DropButton( + onClick = { viewModel.onSave() }, + variant = DropButtonVariant.Primary, + size = DropButtonSize.Medium, + contentDescription = "Save profile changes", + ) { + Text( + text = "Save", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } + }, + ) + + AnimatedVisibility( + visible = state.avatarImageLoadingFailed, + enter = + slideInVertically( + initialOffsetY = { -it }, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + ) + fadeIn(), + exit = + slideOutVertically( + targetOffsetY = { -it }, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + ) + fadeOut(), + ) { + ErrorStateDisplay( + errorState = + ErrorState( + type = ErrorType.Generic, + title = "Profile Update Failed", + message = + "Failed to load image." + + " Please check your storage permissions and try again.", + actionLabel = "Dismiss", + onAction = { + viewModel.clearAvatarLoadingError() + }, + ), + modifier = Modifier.Companion.padding(DesignTokens.Spacing.lg), + ) + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(DesignTokens.Spacing.lg), + verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.xl), + ) { + ProfilePreviewSection( + name = state.name, + avatar = state.avatar, + onNameChange = { newName -> + viewModel.onNameChanged(newName) + }, + nameError = state.nameError, + nameFocusRequester = nameFocusRequester, + ) + + CustomAvatarSection( + onUploadClick = { + viewModel.onPickImage() + }, + hasError = state.avatarImageLoadingFailed, + ) + + AvatarSelectionSection( + availableAvatars = UserAvatar.predefinedIds, + avatar = state.avatar, + onAvatarSelected = { avatarId -> + viewModel.onAvatarSelected(avatarId) + }, + ) + + About(navController = navController) + + PrivacyNoticeSection() + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xxxl)) + } + } +} + +@Composable +private fun ProfilePreviewSection( + name: String, + avatar: UserAvatar, + onNameChange: (String) -> Unit, + nameError: EditProfileNameError?, + nameFocusRequester: FocusRequester, +) { + DropCard( + variant = DropCardVariant.Elevated, + size = DropCardSize.Large, + contentDescription = "Profile preview and name editing", + ) { + DropCardContent(size = DropCardSize.Large) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + var avatarScale by remember { mutableStateOf(0.8f) } + val animatedAvatarScale by animateFloatAsState( + targetValue = avatarScale, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "avatarScale", + ) + + LaunchedEffect(avatar) { + avatarScale = 0.8f + delay(100) + avatarScale = 1f + } + + Box( + modifier = + Modifier + .size(120.dp) + .scale(animatedAvatarScale), + contentAlignment = Alignment.Center, + ) { + AvatarImage( + modifier = + Modifier + .size(120.dp) + .semantics { + contentDescription = "Current profile avatar" + }, + avatarB64 = avatar.base64, + ) + + Surface( + modifier = + Modifier + .align(Alignment.BottomEnd) + .size(32.dp), + shape = CircleShape, + color = colorScheme.primary, + shadowElevation = DesignTokens.Elevation.sm, + ) { + Box( + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit avatar", + modifier = Modifier.size(16.dp), + tint = colorScheme.onPrimary, + ) + } + } + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) + + // Enhanced Name Input + OutlinedTextField( + value = name, + onValueChange = onNameChange, + label = { + Text( + "Display Name", + style = MaterialTheme.typography.bodyMedium, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(nameFocusRequester) + .semantics { + contentDescription = "Enter your display name" + }, + singleLine = true, + isError = nameError != null, + supportingText = { + AnimatedVisibility( + visible = nameError != null, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + nameError?.let { + Text( + text = it.toString(), + color = colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + trailingIcon = { + if (name.isNotEmpty()) { + IconButton( + onClick = { onNameChange("") }, + modifier = + Modifier.semantics { + contentDescription = "Clear name field" + }, + ) { + Icon( + Icons.Default.Clear, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = colorScheme.onSurfaceVariant, + ) + } + } + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done, + ), + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = colorScheme.primary, + unfocusedBorderColor = colorScheme.outline, + errorBorderColor = colorScheme.error, + ), + shape = RoundedCornerShape(DesignTokens.CornerRadius.md), + ) + + // Character count + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = DesignTokens.Spacing.xs), + horizontalArrangement = Arrangement.End, + ) { + Text( + text = "${name.length}/50", + style = MaterialTheme.typography.bodySmall, + color = + if (name.length > 45) { + colorScheme.error + } else { + colorScheme.onSurfaceVariant + }, + ) + } + } + } + } +} + +@Composable +private fun CustomAvatarSection( + onUploadClick: () -> Unit, + hasError: Boolean, +) { + DropCard( + variant = DropCardVariant.Outlined, + size = DropCardSize.Medium, + contentDescription = "Upload custom avatar option", + ) { + DropCardContent(size = DropCardSize.Medium) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Custom Avatar", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface, + ) + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xs)) + Text( + text = "Upload your own profile picture", + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.lg)) + + DropButton( + onClick = onUploadClick, + variant = + if (hasError) + DropButtonVariant.Destructive + else + DropButtonVariant.Secondary, + size = DropButtonSize.Medium, + contentDescription = "Upload custom avatar image", + ) { + Icon( + TablerIcons.Camera, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.sm)) + Text( + "Upload", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + ) + } + } + } + } +} + +@Composable +private fun AvatarSelectionSection( + availableAvatars: List, + avatar: UserAvatar, + onAvatarSelected: (String) -> Unit, +) { + val columns = 3 + + Column { + Text( + text = "Choose Default Avatar", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = colorScheme.onSurface, + modifier = + Modifier.semantics { + contentDescription = "Avatar selection section" + }, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + + Column( + verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.lg), + ) { + availableAvatars.chunked(columns).forEach { rowAvatars -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.lg), + ) { + rowAvatars.forEach { avatarId -> + EnhancedAvatarOption( + modifier = + Modifier + .aspectRatio(1f) + .weight(1f), + avatarId = avatarId, + isSelected = avatar.predefinedId == avatarId, + onClick = { onAvatarSelected(avatarId) }, + ) + } + + if (rowAvatars.size < columns) { + repeat(columns - rowAvatars.size) { + Spacer( + modifier = + Modifier + .weight(1f) + .aspectRatio(1f), + ) + } + } + } + } + } + } +} + +@Composable +private fun EnhancedAvatarOption( + modifier: Modifier, + avatarId: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + val avatarHelper: AvatarHelper = koinInject() + + var scale by remember { mutableStateOf(1f) } + val animatedScale by animateFloatAsState( + targetValue = scale, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh, + ), + label = "avatarScale", + ) + + Card( + modifier = + modifier + .scale(animatedScale), + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + scale = 0.95f + onClick() + }, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + colorScheme.primaryContainer + } else { + colorScheme.surface + }, + ), + border = + if (isSelected) { + CardDefaults.outlinedCardBorder().copy( + width = 3.dp, + brush = SolidColor(colorScheme.primary), + ) + } else { + CardDefaults.outlinedCardBorder().copy( + width = 1.dp, + brush = SolidColor(colorScheme.outline), + ) + }, + elevation = + CardDefaults.cardElevation( + defaultElevation = + if (isSelected) + DesignTokens.Elevation.md + else + DesignTokens.Elevation.xs, + ), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + ) { + Box { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + AvatarImage( + avatarB64 = avatarHelper.getDefaultAvatarBase64(avatarId), + modifier = Modifier.size(56.dp), + ) + } + this@Card.AnimatedVisibility( + visible = isSelected, + enter = scaleIn(spring(stiffness = Spring.StiffnessHigh)) + fadeIn(), + exit = scaleOut(spring(stiffness = Spring.StiffnessHigh)) + fadeOut(), + modifier = Modifier.align(Alignment.TopEnd), + ) { + Surface( + modifier = + Modifier.Companion + .padding(DesignTokens.Spacing.sm) + .size(20.dp), + shape = CircleShape, + color = colorScheme.primary, + shadowElevation = DesignTokens.Elevation.sm, + ) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + modifier = + Modifier + .fillMaxSize() + .padding(DesignTokens.Spacing.xs), + tint = colorScheme.onPrimary, + ) + } + } + } + } + + // Reset scale after animation + LaunchedEffect(isSelected) { + if (scale != 1f) { + delay(150) + scale = 1f + } + } +} + +@Composable +private fun PrivacyNoticeSection() { + DropCard( + variant = DropCardVariant.Filled, + size = DropCardSize.Medium, + colors = + CardDefaults.cardColors( + containerColor = colorScheme.surfaceVariant.copy(alpha = 0.5f), + contentColor = colorScheme.onSurfaceVariant, + ), + contentDescription = "Privacy information about profile data", + ) { + DropCardContent(size = DropCardSize.Medium) { + Row( + verticalAlignment = Alignment.Top, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = + Modifier + .size(20.dp) + .padding(top = 2.dp), + tint = colorScheme.primary, + ) + + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.md)) + + Column { + Text( + text = "Privacy & Security", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xs)) + + Text( + text = + "Your profile information is only shared during file transfers and" + + " is stored locally on your device." + + " Custom avatars are processed and" + + " stored securely without being uploaded to any server.", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.2, + ) + } + } + } + } +} + +@Composable +fun About(navController: NavController) { + DropCard( + variant = DropCardVariant.Filled, + size = DropCardSize.Medium, + colors = + CardDefaults.cardColors( + containerColor = colorScheme.surfaceVariant.copy(alpha = 0.5f), + contentColor = colorScheme.onSurfaceVariant, + ), + onClick = { + navController.navigate(DropDestination.About.route) + }, + ) { + Row(Modifier.padding(DesignTokens.Spacing.lg)) { + Icon( + Icons.Default.Info, + contentDescription = null, + modifier = + Modifier + .size(20.dp) + .padding(top = 2.dp), + tint = colorScheme.primary, + ) + + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.md)) + + Text( + text = "About", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurface, + ) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/profile/EditProfileViewModel.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/profile/EditProfileViewModel.kt new file mode 100644 index 0000000..80a9442 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/profile/EditProfileViewModel.kt @@ -0,0 +1,119 @@ +package dev.arkbuilders.drop.app.presentation.profile + +import androidx.lifecycle.ViewModel +import dev.arkbuilders.drop.app.domain.AvatarHelper +import dev.arkbuilders.drop.app.domain.model.UserAvatar +import dev.arkbuilders.drop.app.domain.model.UserProfile +import dev.arkbuilders.drop.app.domain.repository.ProfileRepo +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container + +data class EditProfileScreenState( + val currentProfile: UserProfile, + val name: String, + val nameError: EditProfileNameError?, + val avatar: UserAvatar, + val avatarImageLoadingFailed: Boolean = false, + val hasChanges: Boolean = false, +) + +enum class EditProfileNameError { + EMPTY, + TOO_SHORT, + TOO_LONG, +} + +sealed class EditProfileScreenEffect { + data object LaunchImagePicker : EditProfileScreenEffect() + + data object NavigateBack : EditProfileScreenEffect() +} + +class EditProfileViewModel( + private val profileRepo: ProfileRepo, + private val avatarHelper: AvatarHelper, +) : ViewModel(), ContainerHost { + override val container: Container = + container( + EditProfileScreenState( + currentProfile = UserProfile.empty(), + name = "", + nameError = null, + avatar = UserAvatar("", null), + ), + ) + + init { + val profile = profileRepo.getCurrentProfile() + intent { + reduce { + state.copy(currentProfile = profile, name = profile.name, avatar = profile.avatar) + } + } + } + + fun onNameChanged(newName: String) = + blockingIntent { + val nameError = + when { + newName.isBlank() -> EditProfileNameError.EMPTY + newName.trim().length < 2 -> EditProfileNameError.TOO_SHORT + newName.length > 50 -> EditProfileNameError.TOO_LONG + else -> null + } + reduce { + state.copy( + name = newName, + nameError = nameError, + hasChanges = state.currentProfile.name != newName, + ) + } + } + + fun onPickImage() = + intent { + postSideEffect(EditProfileScreenEffect.LaunchImagePicker) + } + + fun onImagePicked(uri: String) = + intent { + val base64 = avatarHelper.uriToBase64(uri) + base64?.let { + reduce { + state.copy( + avatar = UserAvatar(base64, predefinedId = null), + hasChanges = state.avatar != state.currentProfile.avatar, + ) + } + } ?: let { + state.copy( + avatarImageLoadingFailed = true, + ) + } + } + + fun onAvatarSelected(id: String) = + intent { + val base64 = avatarHelper.getDefaultAvatarBase64(id) + reduce { + state.copy( + avatar = UserAvatar(base64, predefinedId = id), + hasChanges = state.avatar != state.currentProfile.avatar, + ) + } + } + + fun onSave() = + intent { + profileRepo.updateProfile(UserProfile(state.name, state.avatar)) + postSideEffect(EditProfileScreenEffect.NavigateBack) + } + + fun clearAvatarLoadingError() = + intent { + reduce { + state.copy(avatarImageLoadingFailed = false) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/Receive.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/Receive.kt new file mode 100644 index 0000000..318d041 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/Receive.kt @@ -0,0 +1,198 @@ +package dev.arkbuilders.drop.app.presentation.receive + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.navigation.NavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import dev.arkbuilders.drop.app.presentation.components.DropErrorCard +import dev.arkbuilders.drop.app.presentation.components.DropTopBarBack +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveCompleteCard +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveLoadingCard +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveManualInputCard +import dev.arkbuilders.drop.app.presentation.receive.components.ReceivePermissionRequestCard +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveProgressCard +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveQRCodeScannedCard +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveReadyToScanCard +import dev.arkbuilders.drop.app.presentation.receive.components.ReceiveScanningCard +import org.koin.compose.koinInject +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@Composable +fun Receive(navController: NavController) { + val viewModel: ReceiveViewModel = koinInject() + val clipboardManager = LocalClipboardManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + val requestPermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted -> + viewModel.onCameraPermissionGranted(isGranted) + } + + val state by viewModel.collectAsState() + viewModel.collectSideEffect { effect -> + when (effect) { + ReceiveScreenEffect.HideKeyboard -> { + keyboardController?.hide() + } + + ReceiveScreenEffect.NavigateBack -> { + navController.navigateUp() + } + + ReceiveScreenEffect.RequestCameraPermission -> { + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + DropTopBarBack( + title = "Receive files", + onBackClick = { navController.navigateUp() }, + ) + + val receiveScreenState = state + when (receiveScreenState) { + is ReceiveScreenState.Initial -> { + if (receiveScreenState.cameraPermissionGranted) { + ReceiveReadyToScanCard( + onStartScanning = { viewModel.onStartScanning() }, + onEnterManually = { viewModel.onEnterManually() }, + ) + } else { + ReceivePermissionRequestCard( + onRequestPermission = { + viewModel.onRequestCameraPermission() + }, + onEnterManually = { + viewModel.onEnterManually() + }, + ) + } + } + + ReceiveScreenState.RequestingPermission -> { + ReceiveLoadingCard(message = "Requesting camera permission...") + } + + ReceiveScreenState.Scanning -> { + ReceiveScanningCard( + onQRCodeScanned = { ticket, confirmation -> + viewModel.onQrCodeScanned(ticket, confirmation) + }, + onError = { error -> + viewModel.onError(error) + }, + onStopScanning = { viewModel.onStopScanning() }, + onEnterManually = { viewModel.onEnterManually() }, + ) + } + + is ReceiveScreenState.ManualInput -> { + ReceiveManualInputCard( + inputText = receiveScreenState.inputText, + onInputChange = { + viewModel.onManualInputChanged(it) + }, + inputError = receiveScreenState.inputError, + onPasteFromClipboard = { + viewModel.onPasteFromClipboard( + clipboardManager.getText()?.text, + ) + }, + onSubmit = { viewModel.handleManualInputSubmit() }, + onCancel = { + viewModel.onCancelManualInput() + }, + ) + } + + is ReceiveScreenState.QRCodeScanned -> { + ReceiveQRCodeScannedCard( + onAccept = { + viewModel.onAccept() + }, + onScanAgain = { + viewModel.onScanAgain() + }, + ) + } + + ReceiveScreenState.Connecting -> { + ReceiveLoadingCard(message = "Connecting to sender...") + } + + is ReceiveScreenState.Receiving -> { + ReceiveProgressCard( + progress = receiveScreenState.progress, + onCancel = { + viewModel.onCancelReceiving() + }, + ) + } + + is ReceiveScreenState.Success -> { + ReceiveCompleteCard( + receivedFiles = receiveScreenState.receivedFiles, + onReceiveMore = { + viewModel.onReceiveMore() + }, + onDone = { + viewModel.onDone() + }, + ) + } + + is ReceiveScreenState.Error -> { + DropErrorCard( + message = receiveScreenState.error.toMessage(), + onRetry = { + viewModel.onErrorRetry() + }, + onDismiss = { + viewModel.onErrorDismiss() + }, + ) + } + } + } +} + +private fun ReceiveError.toMessage() = + when (this) { + ReceiveError.CameraInitializationFailed -> "Unable to initialize camera. Please try again." + ReceiveError.CameraPermissionDenied -> "Camera permission is required to scan QR codes" + ReceiveError.ConnectionFailed -> "Unable to connect to sender." + ReceiveError.InvalidManualInput -> "Invalid format. Please enter: ticket confirmation" + ReceiveError.InvalidQRCode -> + "This QR code is not from Drop. Please scan a valid Drop QR code." + + ReceiveError.NetworkError -> + "Network connection lost. Please check your connection and try again." + + ReceiveError.NoFilesReceived -> "No files were received from the sender." + ReceiveError.StorageError -> "Unable to save files. Please check your storage permissions." + ReceiveError.TransferInterrupted -> "File transfer was interrupted. Please try again." + ReceiveError.UnknownError -> "An unexpected error occurred. Please try again." + } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/ReceiveScreenState.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/ReceiveScreenState.kt new file mode 100644 index 0000000..999c3f5 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/ReceiveScreenState.kt @@ -0,0 +1,54 @@ +package dev.arkbuilders.drop.app.presentation.receive + +import dev.arkbuilders.drop.app.data.ReceivingProgress +import dev.arkbuilders.drop.app.domain.model.ReceiveSession + +sealed class ReceiveScreenState { + data class Initial(val cameraPermissionGranted: Boolean) : ReceiveScreenState() + + data object RequestingPermission : ReceiveScreenState() + + data object Scanning : ReceiveScreenState() + + data class ManualInput(val inputText: String, val inputError: String?) : ReceiveScreenState() + + data class QRCodeScanned(val ticket: String, val confirmation: UByte) : ReceiveScreenState() + + data object Connecting : ReceiveScreenState() + + data class Receiving( + val session: ReceiveSession, + val progress: ReceivingProgress, + ) : ReceiveScreenState() + + data class Success( + val session: ReceiveSession, + val receivedFiles: List, + ) : ReceiveScreenState() + + data class Error( + val session: ReceiveSession? = null, + val error: ReceiveError, + ) : ReceiveScreenState() +} + +sealed class ReceiveScreenEffect { + data object HideKeyboard : ReceiveScreenEffect() + + data object NavigateBack : ReceiveScreenEffect() + + data object RequestCameraPermission : ReceiveScreenEffect() +} + +enum class ReceiveError { + CameraPermissionDenied, + CameraInitializationFailed, + InvalidQRCode, + InvalidManualInput, + ConnectionFailed, + TransferInterrupted, + NoFilesReceived, + StorageError, + NetworkError, + UnknownError, +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/ReceiveViewModel.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/ReceiveViewModel.kt new file mode 100644 index 0000000..74cff33 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/ReceiveViewModel.kt @@ -0,0 +1,347 @@ +package dev.arkbuilders.drop.app.presentation.receive + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.arkbuilders.drop.app.data.repository.ReceiveSessionRepo +import dev.arkbuilders.drop.app.domain.PermissionsHelper +import dev.arkbuilders.drop.app.domain.model.ReceiveSession +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import timber.log.Timber + +class ReceiveViewModel( + private val receiveSessionRepo: ReceiveSessionRepo, + private val permissionsHelper: PermissionsHelper, +) : ViewModel(), ContainerHost { + override val container: Container = + container(ReceiveScreenState.Initial(false)) + + init { + intent { + reduce { + ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) + } + } + } + + fun onRequestCameraPermission() = + intent { + reduce { + ReceiveScreenState.RequestingPermission + } + postSideEffect(ReceiveScreenEffect.RequestCameraPermission) + } + + fun onEnterManually() = + intent { + reduce { + ReceiveScreenState.ManualInput(inputText = "", inputError = null) + } + } + + fun onStartScanning() = + intent { + reduce { + ReceiveScreenState.Scanning + } + } + + fun onStopScanning() = + intent { + reduce { + ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) + } + } + + fun onError(error: ReceiveError) = + intent { + reduce { + ReceiveScreenState.Error(error = error) + } + } + + fun onAccept() = + intent { + try { + val s = state + if (s !is ReceiveScreenState.QRCodeScanned) { + return@intent + } + val ticket = s.ticket + val confirmation = s.confirmation + + reduce { + ReceiveScreenState.Connecting + } + + val session = + receiveSessionRepo.receiveFiles(ticket, confirmation) + if (session != null) { + reduce { + ReceiveScreenState.Receiving( + session, + session.subscriber.progress.value, + ) + } + listenToProgress(session) + } else { + reduce { + ReceiveScreenState.Error(error = ReceiveError.ConnectionFailed) + } + } + } catch (e: Exception) { + val error = + when { + e.message?.contains( + "network", + ignoreCase = true, + ) == true -> ReceiveError.NetworkError + + else -> ReceiveError.ConnectionFailed + } + + reduce { + ReceiveScreenState.Error(error = error) + } + } + } + + fun onCameraPermissionGranted(isGranted: Boolean) = + intent { + val state = + if (isGranted) { + ReceiveScreenState.Scanning + } else { + ReceiveScreenState.Error(error = ReceiveError.CameraPermissionDenied) + } + reduce { + state + } + } + + fun onScanAgain() = + intent { + val state = + if (permissionsHelper.isCameraGranted()) { + ReceiveScreenState.Scanning + } else { + ReceiveScreenState.ManualInput(inputText = "", inputError = null) + } + reduce { + state + } + } + + fun onReceiveMore() = + intent { + val s = state + if (s is ReceiveScreenState.Success) { + receiveSessionRepo.cancelReceive(s.session) + } + reduce { + ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) + } + } + + fun onDone() = + intent { + val s = state + if (s is ReceiveScreenState.Success) { + receiveSessionRepo.cancelReceive(s.session) + } + postSideEffect(ReceiveScreenEffect.NavigateBack) + } + + fun onPasteFromClipboard(clipText: String?) = + intent { + val s = state + if (s !is ReceiveScreenState.ManualInput) + return@intent + + if (!clipText.isNullOrEmpty()) { + reduce { + s.copy( + inputText = clipText, + inputError = null, + ) + } + } + } + + fun onErrorRetry() = + intent { + reduce { + ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) + } + } + + fun onErrorDismiss() = + intent { + val s = state + if (s is ReceiveScreenState.Error) { + s.session?.let { + receiveSessionRepo.cancelReceive(it) + } + } + postSideEffect(ReceiveScreenEffect.NavigateBack) + } + + fun onQrCodeScanned( + ticket: String, + confirmation: UByte, + ) = intent { + reduce { + ReceiveScreenState.QRCodeScanned(ticket, confirmation) + } + } + + fun onManualInputChanged(input: String) = + blockingIntent { + val s = state + if (s !is ReceiveScreenState.ManualInput) + return@blockingIntent + + reduce { + s.copy( + inputText = input, + inputError = null, + ) + } + } + + fun onCancelReceiving() = + intent { + val s = state + if (s is ReceiveScreenState.Receiving) { + receiveSessionRepo.cancelReceive(s.session) + } + + reduce { + ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) + } + } + + fun onCancelManualInput() = + intent { + reduce { + ReceiveScreenState.Initial(permissionsHelper.isCameraGranted()) + } + postSideEffect(ReceiveScreenEffect.HideKeyboard) + } + + fun handleManualInputSubmit() = + intent { + val s = state + if (s !is ReceiveScreenState.ManualInput) + return@intent + + val parsed = parseManualInput(s.inputText) + if (parsed != null) { + reduce { + ReceiveScreenState.QRCodeScanned( + ticket = parsed.first, + confirmation = parsed.second, + ) + } + postSideEffect(ReceiveScreenEffect.HideKeyboard) + } else { + reduce { + s.copy( + inputError = "Invalid format. Please enter: ticket confirmation", + ) + } + } + } + + private fun listenToProgress(session: ReceiveSession) { + session.subscriber.progress.onEach { progress -> + intent { + val s = state + if (s !is ReceiveScreenState.Receiving) + return@intent + + reduce { + s.copy( + progress = progress, + ) + } + + if (progress.isConnected && progress.files.isNotEmpty()) { + // Check if all files are complete + val allFilesComplete = + progress.files.all { file -> + val fileProgress = progress.fileProgress[file.id] + fileProgress?.isComplete == true + } + + if (allFilesComplete) { + // Small delay to ensure UI updates are visible + delay(1000) + try { + val savedFiles = receiveSessionRepo.saveReceivedFiles(session) + if (savedFiles.isNotEmpty()) { + reduce { + ReceiveScreenState.Success( + session = session, + receivedFiles = savedFiles, + ) + } + } else { + reduce { + ReceiveScreenState.Error( + session = session, + error = ReceiveError.NoFilesReceived, + ) + } + } + } catch (e: Exception) { + Timber.w("Save failed: ${e::class.simpleName} ${e.message}") + val error = + when { + e.message?.contains("storage", ignoreCase = true) == true -> + ReceiveError.StorageError + + e.message?.contains("network", ignoreCase = true) == true -> + ReceiveError.NetworkError + + else -> ReceiveError.UnknownError + } + reduce { + ReceiveScreenState.Error( + session = session, + error = error, + ) + } + } + } + } + } + }.launchIn(viewModelScope) + } + + private fun parseManualInput(input: String): Pair? { + return try { + val trimmed = input.trim() + val parts = trimmed.split(" ") + + if (parts.size == 2) { + val ticket = parts[0].trim() + val confirmation = parts[1].trim().toUByte() + + if (ticket.isNotEmpty()) { + Pair(ticket, confirmation) + } else { + null + } + } else { + null + } + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveCompleteCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveCompleteCard.kt new file mode 100644 index 0000000..008e54f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveCompleteCard.kt @@ -0,0 +1,176 @@ +package dev.arkbuilders.drop.app.presentation.receive.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +@Composable +fun ReceiveCompleteCard( + receivedFiles: List, + onReceiveMore: () -> Unit, + onDone: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.lg, + ), + ) { + Column( + modifier = Modifier.Companion.padding(DesignTokens.Spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(80.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Complete", + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + + Text( + text = "Files Received Successfully!", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.sm)) + + Text( + text = "${receivedFiles.size} file${ + if (receivedFiles.size != 1) "s" else "" + } saved to Downloads", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + ) + + if (receivedFiles.isNotEmpty()) { + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + Card( + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + shape = RoundedCornerShape(DesignTokens.CornerRadius.md), + ) { + Column( + modifier = Modifier.Companion.padding(DesignTokens.Spacing.lg), + ) { + Text( + text = "Received Files:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.sm)) + + // Show first 3 files, then "... and X more" if needed + receivedFiles.take(3).forEach { fileName -> + Text( + text = "• $fileName", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (receivedFiles.size > 3) { + Text( + text = "• ... and ${receivedFiles.size - 3} more", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + fontWeight = FontWeight.Medium, + ) + } + } + } + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md), + ) { + OutlinedButton( + onClick = onReceiveMore, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.md), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.sm)) + Text("Receive More", fontWeight = FontWeight.Medium) + } + + Button( + onClick = onDone, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.md), + ) { + Text("Done", fontWeight = FontWeight.Medium) + } + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveLoadingCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveLoadingCard.kt new file mode 100644 index 0000000..88e4647 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveLoadingCard.kt @@ -0,0 +1,59 @@ +package dev.arkbuilders.drop.app.presentation.receive.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +@Composable +fun ReceiveLoadingCard(message: String) { + ElevatedCard( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.md, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(DesignTokens.Spacing.xl), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.lg)) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveManualInputCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveManualInputCard.kt new file mode 100644 index 0000000..56acdb6 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveManualInputCard.kt @@ -0,0 +1,177 @@ +package dev.arkbuilders.drop.app.presentation.receive.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import compose.icons.TablerIcons +import compose.icons.tablericons.ArrowForward +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +@Composable +fun ReceiveManualInputCard( + inputText: String, + onInputChange: (String) -> Unit, + inputError: String?, + onPasteFromClipboard: () -> Unit, + onSubmit: () -> Unit, + onCancel: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.lg, + ), + ) { + Column( + modifier = Modifier.Companion.padding(DesignTokens.Spacing.xxl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(80.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + TablerIcons.ArrowForward, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + + Text( + text = "Enter Transfer Code", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + Text( + text = + "Paste or type the transfer code from the sender in the format:" + + " ticket confirmation", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) + + OutlinedTextField( + value = inputText, + onValueChange = onInputChange, + label = { Text("Transfer Code") }, + placeholder = { Text("ticket confirmation") }, + modifier = Modifier.fillMaxWidth(), + isError = inputError != null, + supportingText = + inputError?.let { error -> + { Text(error, color = MaterialTheme.colorScheme.error) } + }, + trailingIcon = { + IconButton(onClick = onPasteFromClipboard) { + Icon( + TablerIcons.ArrowForward, + contentDescription = "Paste", + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { onSubmit() }, + ), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md), + ) { + OutlinedButton( + onClick = onCancel, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + ) { + Text( + "Cancel", + fontWeight = FontWeight.Medium, + ) + } + + Button( + onClick = onSubmit, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + enabled = inputText.trim().isNotEmpty(), + ) { + Text( + "Connect", + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceivePermissionRequestCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceivePermissionRequestCard.kt new file mode 100644 index 0000000..b939654 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceivePermissionRequestCard.kt @@ -0,0 +1,160 @@ +package dev.arkbuilders.drop.app.presentation.receive.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import compose.icons.TablerIcons +import compose.icons.tablericons.ArrowForward +import compose.icons.tablericons.Camera +import dev.arkbuilders.drop.app.presentation.components.DropInstructionsCard +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +@Composable +fun ReceivePermissionRequestCard( + onRequestPermission: () -> Unit, + onEnterManually: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.lg, + ), + ) { + Column( + modifier = Modifier.Companion.padding(DesignTokens.Spacing.xxl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(80.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + TablerIcons.Camera, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + + Text( + text = "Camera Permission Required", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + Text( + text = + "We need camera access to scan QR codes for receiving files." + + " Your privacy is protected - we only use the camera for QR code scanning.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) + + Button( + onClick = onRequestPermission, + modifier = + Modifier + .fillMaxWidth() + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + "Grant Permission", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + Text( + text = "Or", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + OutlinedButton( + onClick = onEnterManually, + modifier = + Modifier + .fillMaxWidth() + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + ) { + Icon( + TablerIcons.ArrowForward, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.sm)) + Text( + "Enter Code Manually", + fontWeight = FontWeight.Medium, + ) + } + } + } + + DropInstructionsCard( + modifier = Modifier.fillMaxWidth().padding(16.dp), + title = "How to receive files:", + steps = + listOf( + "Ask the sender to start a transfer", + "Scan QR code OR enter transfer code manually", + "Accept the transfer", + "Files will be saved to your Downloads folder", + ), + ) +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveProgressCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveProgressCard.kt new file mode 100644 index 0000000..8f87916 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveProgressCard.kt @@ -0,0 +1,264 @@ +package dev.arkbuilders.drop.app.presentation.receive.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.data.ReceiveFileInfo +import dev.arkbuilders.drop.app.data.ReceivingProgress +import dev.arkbuilders.drop.app.presentation.components.AvatarImageWithFallback +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +@Composable +fun ReceiveProgressCard( + progress: ReceivingProgress, + onCancel: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.lg, + ), + ) { + Column( + modifier = Modifier.Companion.padding(DesignTokens.Spacing.lg), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (progress.isConnected) "Receiving Files..." else "Connecting...", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Surface( + onClick = onCancel, + shape = CircleShape, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + modifier = Modifier.size(40.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Cancel", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp), + ) + } + } + } + + if (progress.isConnected) { + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md), + ) { + AvatarImageWithFallback( + avatarB64 = progress.senderAvatar, + fallbackText = progress.senderName, + size = 36.dp, + ) + + Column { + Text( + text = "From:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), + ) + Text( + text = progress.senderName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + + Text( + text = "Files (${progress.files.size}):", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + LazyColumn( + modifier = Modifier.heightIn(max = 280.dp), + verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.sm), + ) { + items(progress.files) { file -> + val fileProgress = progress.fileProgress[file.id] + ReceivingFileItem( + file = file, + progress = + if (file.size > 0UL && fileProgress != null) { + (fileProgress.receivedBytes.toFloat() / file.size.toFloat()) + .coerceIn( + 0f, + 1f, + ) + } else { + 0f + }, + receivedBytes = fileProgress?.receivedBytes ?: 0L, + isComplete = fileProgress?.isComplete ?: false, + ) + } + } + } else { + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + strokeWidth = 4.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +@Composable +private fun ReceivingFileItem( + file: ReceiveFileInfo, + progress: Float, + receivedBytes: Long, + isComplete: Boolean, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + shape = RoundedCornerShape(DesignTokens.CornerRadius.md), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.xs, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(DesignTokens.Spacing.lg), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.sm)) + + LinearProgressIndicator( + progress = { progress }, + modifier = + Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = "${formatBytes(receivedBytes)} / ${formatBytes(file.size.toLong())}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (isComplete) { + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.md)) + Box( + modifier = + Modifier + .size(32.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Complete", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + } + } +} + +private fun formatBytes(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val kb = bytes / 1024.0 + if (kb < 1024) return "%.1f KB".format(kb) + val mb = kb / 1024.0 + if (mb < 1024) return "%.1f MB".format(mb) + val gb = mb / 1024.0 + return "%.1f GB".format(gb) +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveQRCodeScannedCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveQRCodeScannedCard.kt new file mode 100644 index 0000000..66e6793 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveQRCodeScannedCard.kt @@ -0,0 +1,132 @@ +package dev.arkbuilders.drop.app.presentation.receive.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +@Composable +fun ReceiveQRCodeScannedCard( + onAccept: () -> Unit, + onScanAgain: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.lg, + ), + ) { + Column( + modifier = Modifier.Companion.padding(DesignTokens.Spacing.xxl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(80.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + + Text( + text = "Code Received!", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + Text( + text = "Ready to receive files from sender. Tap Accept to start the transfer.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.2, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md), + ) { + OutlinedButton( + onClick = onScanAgain, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + ) { + Text( + "Try Again", + fontWeight = FontWeight.Medium, + ) + } + + Button( + onClick = onAccept, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + "Accept", + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveReadyToScanCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveReadyToScanCard.kt new file mode 100644 index 0000000..75e8284 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveReadyToScanCard.kt @@ -0,0 +1,174 @@ +package dev.arkbuilders.drop.app.presentation.receive.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import compose.icons.TablerIcons +import compose.icons.tablericons.ArrowForward +import compose.icons.tablericons.Camera +import compose.icons.tablericons.Qrcode +import dev.arkbuilders.drop.app.presentation.components.DropInstructionsCard +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens + +@Composable +fun ReceiveReadyToScanCard( + onStartScanning: () -> Unit, + onEnterManually: () -> Unit, +) { + ElevatedCard( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), + colors = + CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.lg, + ), + ) { + Column( + modifier = Modifier.Companion.padding(DesignTokens.Spacing.xxl), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(80.dp) + .background( + brush = + Brush.radialGradient( + colors = + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + ), + ), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + TablerIcons.Qrcode, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + + Text( + text = "Ready to Receive", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + Text( + text = + "Scan the QR code from the sender's device or enter the transfer" + + " code manually to start receiving files securely.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) + + Button( + onClick = onStartScanning, + modifier = + Modifier + .fillMaxWidth() + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Icon( + TablerIcons.Camera, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.sm)) + Text( + "Start Scanning", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + Text( + text = "Or", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.md)) + + OutlinedButton( + onClick = onEnterManually, + modifier = + Modifier + .fillMaxWidth() + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + ) { + Icon( + TablerIcons.ArrowForward, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.sm)) + Text( + "Enter Code Manually", + fontWeight = FontWeight.Medium, + ) + } + } + } + + DropInstructionsCard( + modifier = Modifier.fillMaxWidth().padding(16.dp), + title = "How to receive files:", + steps = + listOf( + "Ask the sender to start a transfer", + "Scan QR code OR enter transfer code manually", + "Accept the transfer", + "Files will be saved to your Downloads folder", + ), + ) +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveScanningCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveScanningCard.kt new file mode 100644 index 0000000..107bbc1 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/receive/components/ReceiveScanningCard.kt @@ -0,0 +1,259 @@ +package dev.arkbuilders.drop.app.presentation.receive.components + +import androidx.annotation.OptIn +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import compose.icons.TablerIcons +import compose.icons.tablericons.ArrowForward +import compose.icons.tablericons.CameraOff +import dev.arkbuilders.drop.app.presentation.receive.ReceiveError +import dev.arkbuilders.drop.app.presentation.theme.DesignTokens +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.text.toUByte + +@Composable +fun ReceiveScanningCard( + onQRCodeScanned: (String, UByte) -> Unit, + onError: (ReceiveError) -> Unit, + onStopScanning: () -> Unit, + onEnterManually: () -> Unit, +) { + Column(modifier = Modifier.padding(16.dp)) { + ElevatedCard( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f), + shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), + elevation = + CardDefaults.elevatedCardElevation( + defaultElevation = DesignTokens.Elevation.lg, + ), + ) { + QRCodeScanner( + onQRCodeScanned = onQRCodeScanned, + onError = onError, + ) + } + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.xl)) + + Text( + text = "Point your camera at the QR code", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.Companion.height(DesignTokens.Spacing.lg)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md), + ) { + OutlinedButton( + onClick = onStopScanning, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + ) { + Icon( + TablerIcons.CameraOff, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.sm)) + Text( + "Stop Scanning", + fontWeight = FontWeight.Medium, + ) + } + + Button( + onClick = onEnterManually, + modifier = + Modifier + .weight(1f) + .height(DesignTokens.TouchTarget.comfortable), + shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), + ) { + Icon( + TablerIcons.ArrowForward, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.Companion.width(DesignTokens.Spacing.sm)) + Text( + "Enter Code", + fontWeight = FontWeight.Medium, + ) + } + } + } +} + +@OptIn(ExperimentalGetImage::class) +@Composable +private fun QRCodeScanner( + onQRCodeScanned: (String, UByte) -> Unit, + onError: (ReceiveError) -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } + + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx) + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + + cameraProviderFuture.addListener({ + try { + val cameraProvider = cameraProviderFuture.get() + + val preview = + Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val imageAnalyzer = + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer(cameraExecutor) { imageProxy -> + processImageProxy(imageProxy, onQRCodeScanned, onError) + } + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageAnalyzer, + ) + } catch (exc: Exception) { + onError(ReceiveError.CameraInitializationFailed) + } + }, ContextCompat.getMainExecutor(ctx)) + + previewView + }, + modifier = Modifier.fillMaxSize(), + ) + + DisposableEffect(Unit) { + onDispose { + cameraExecutor.shutdown() + } + } +} + +@ExperimentalGetImage +private fun processImageProxy( + imageProxy: ImageProxy, + onQRCodeScanned: (String, UByte) -> Unit, + onError: (ReceiveError) -> Unit, +) { + val mediaImage = imageProxy.image + if (mediaImage != null) { + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + val scanner = BarcodeScanning.getClient() + + scanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + when (barcode.valueType) { + Barcode.TYPE_TEXT, Barcode.TYPE_URL -> { + barcode.rawValue?.let { value -> + // Parse Drop QR code format: drop://receive?ticket=...&confirmation=... + if (value.startsWith("drop://receive?")) { + try { + val uri = value.toUri() + val ticket = uri.getQueryParameter("ticket") + val confirmationStr = uri.getQueryParameter("confirmation") + + if (ticket != null && confirmationStr != null) { + val confirmation = confirmationStr.toUByte() + onQRCodeScanned(ticket, confirmation) + return@addOnSuccessListener + } + } catch (_: Exception) { + onError(ReceiveError.InvalidQRCode) + return@addOnSuccessListener + } + } else { + onError(ReceiveError.InvalidQRCode) + return@addOnSuccessListener + } + } + } + } + } + } + .addOnFailureListener { exception -> + onError( + when { + exception.message?.contains( + "camera", + ignoreCase = true, + ) == true -> ReceiveError.CameraInitializationFailed + + else -> ReceiveError.UnknownError + }, + ) + } + .addOnCompleteListener { + imageProxy.close() + } + } else { + imageProxy.close() + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/Send.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/Send.kt new file mode 100644 index 0000000..76575ce --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/Send.kt @@ -0,0 +1,133 @@ +package dev.arkbuilders.drop.app.presentation.send + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import dev.arkbuilders.drop.app.presentation.components.DropErrorCard +import dev.arkbuilders.drop.app.presentation.components.DropTopBarBack +import dev.arkbuilders.drop.app.presentation.send.components.phase.FileSelectionPhase +import dev.arkbuilders.drop.app.presentation.send.components.phase.GeneratingQRPhase +import dev.arkbuilders.drop.app.presentation.send.components.phase.TransferCompletePhase +import dev.arkbuilders.drop.app.presentation.send.components.phase.TransferringPhase +import dev.arkbuilders.drop.app.presentation.send.components.phase.WaitingForReceiverPhase +import org.koin.compose.koinInject +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Send(navController: NavController) { + val viewModel: SendViewModel = koinInject() + + val state by viewModel.collectAsState() + + val filePickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents(), + ) { uris -> + viewModel.onFilesAdded(uris.map { it.toString() }) + } + + viewModel.collectSideEffect { effect -> + when (effect) { + SendScreenEffect.LaunchFilePicker -> { + filePickerLauncher.launch("*/*") + } + + SendScreenEffect.NavigateBack -> { + navController.popBackStack() + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + DropTopBarBack( + title = "Send files", + onBackClick = { navController.navigateUp() }, + ) + + val sendScreenState = state + when (sendScreenState) { + is SendScreenState.FileSelection -> { + FileSelectionPhase( + selectedFiles = sendScreenState.files, + totalFileSize = sendScreenState.size, + onAddFiles = { + viewModel.onAddFiles() + }, + onRemoveFile = { uri -> + viewModel.onFileRemove(uri) + }, + onStartTransfer = { + viewModel.onStartTransfer() + }, + canStartTransfer = sendScreenState.canStartTransfer, + ) + } + + is SendScreenState.GeneratingQR -> { + GeneratingQRPhase(onCancel = { viewModel.onCancelQrGeneration() }) + } + + is SendScreenState.WaitingForReceiver -> { + WaitingForReceiverPhase( + qrBitmap = sendScreenState.qrBitmap, + copyString = sendScreenState.copyString, + fileCount = sendScreenState.files.size, + onCancel = { viewModel.onCancelTransfer() }, + ) + } + + is SendScreenState.Transfer -> { + TransferringPhase( + progress = sendScreenState, + onCancel = { viewModel.onCancelTransfer() }, + ) + } + + is SendScreenState.Complete -> { + TransferCompletePhase( + fileCount = sendScreenState.files.size, + onSendMore = { + viewModel.onSendMore() + }, + onDone = { + viewModel.onDone() + }, + ) + } + + is SendScreenState.Error -> { + DropErrorCard( + message = sendScreenState.error.toMessage(), + onRetry = { + viewModel.onErrorRetry() + }, + onDismiss = { + viewModel.onErrorDismiss() + }, + ) + } + } + } +} + +private fun SendException.toMessage() = + when (this) { + SendException.TransferInitializationFailed -> "Transfer initialization failed" + SendException.QRGenerationFailed -> "QR generation failed" + SendException.TransferInterrupted -> "Transfer interrupted" + } diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendScreenState.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendScreenState.kt new file mode 100644 index 0000000..0193c5f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendScreenState.kt @@ -0,0 +1,61 @@ +package dev.arkbuilders.drop.app.presentation.send + +import android.graphics.Bitmap +import dev.arkbuilders.drop.app.domain.model.SendSession + +sealed class SendScreenState { + data class FileSelection( + val files: List = emptyList(), + val size: Long = 0L, + val canStartTransfer: Boolean = false, + ) : SendScreenState() + + data class GeneratingQR( + val files: List, + ) : SendScreenState() + + data class WaitingForReceiver( + val session: SendSession, + val files: List, + val qrBitmap: Bitmap, + val copyString: String, + ) : SendScreenState() + + data class Transfer( + val session: SendSession, + val files: List, + val isConnected: Boolean = false, + val receiverName: String = "", + val receiverAvatar: String? = null, + val currentFileName: String = "", + val filesCompleted: Int = 0, + val totalFiles: Int = 0, + val bytesTransferred: Long = 0L, + val totalBytes: Long = 0L, + val transferSpeedBps: Long = 0L, + val estimatedTimeRemaining: Long = 0L, + ) : SendScreenState() + + data class Complete( + val session: SendSession, + val files: List, + ) : SendScreenState() + + data class Error( + val session: SendSession? = null, + val files: List? = null, + val error: SendException, + ) : SendScreenState() +} + +sealed class SendScreenEffect { + data object LaunchFilePicker : SendScreenEffect() + + data object NavigateBack : SendScreenEffect() +} + +enum class SendException { + TransferInitializationFailed, + QRGenerationFailed, + TransferInterrupted, +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendViewModel.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendViewModel.kt new file mode 100644 index 0000000..b34813c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/SendViewModel.kt @@ -0,0 +1,320 @@ +package dev.arkbuilders.drop.app.presentation.send + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import dev.arkbuilders.drop.app.data.repository.SendSessionRepo +import dev.arkbuilders.drop.app.domain.ResourcesHelper +import dev.arkbuilders.drop.app.domain.model.SendSession +import dev.arkbuilders.drop.app.domain.repository.NetworkStatus +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import timber.log.Timber +import kotlin.collections.isNotEmpty +import kotlin.collections.plus +import kotlin.collections.sumOf + +class SendViewModel( + private val resourcesHelper: ResourcesHelper, + private val networkStatus: NetworkStatus, + private val sendSessionRepo: SendSessionRepo, +) : ViewModel(), ContainerHost { + override val container: Container = + container(SendScreenState.FileSelection()) + + fun onAddFiles() = + intent { + postSideEffect(SendScreenEffect.LaunchFilePicker) + } + + fun onFilesAdded(newFiles: List) = + intent { + val s = state + if (s is SendScreenState.FileSelection) { + val validated = resourcesHelper.validateUris(newFiles) + val allFiles = s.files + validated.first + val canStartTransfer = allFiles.isNotEmpty() && networkStatus.isOnline() + + val size = allFiles.sumOf { resourcesHelper.getFileSize(it) } + + reduce { + s.copy(files = allFiles, size = size, canStartTransfer = canStartTransfer) + } + } + } + + fun onFileRemove(file: String) = + intent { + val s = state + if (s is SendScreenState.FileSelection) { + val newFiles = s.files - file + val size = newFiles.sumOf { resourcesHelper.getFileSize(it) } + reduce { + s.copy(files = newFiles, size = size) + } + } + } + + fun onStartTransfer() = + intent { + val s = state + if (s !is SendScreenState.FileSelection) { + return@intent + } + reduce { + SendScreenState.GeneratingQR(s.files) + } + + val session = sendSessionRepo.sendFiles(s.files.map { it.toUri() }) + if (session == null) { + reduce { + SendScreenState.Error( + files = s.files, + error = SendException.TransferInitializationFailed, + ) + } + return@intent + } + val ticket = session.bubble.getTicket() + val confirmation = session.bubble.getConfirmation() + + if (ticket.isEmpty()) { + reduce { + SendScreenState.Error( + session = session, + files = s.files, + error = SendException.TransferInitializationFailed, + ) + } + return@intent + } + val copyString = "${session.bubble.getTicket()} ${session.bubble.getConfirmation()}" + + val qrBitmap = generateQRCodeSafely(ticket, confirmation) + if (qrBitmap == null) { + reduce { + SendScreenState.Error( + session = session, + files = s.files, + error = SendException.QRGenerationFailed, + ) + } + return@intent + } + listenToSendProgress(session) + monitorTransferCompletion(session) + reduce { + SendScreenState.WaitingForReceiver( + session = session, + files = s.files, + qrBitmap = qrBitmap, + copyString = copyString, + ) + } + } + + fun onCancelTransfer() = + intent { + val s = state + val session = + when (s) { + is SendScreenState.WaitingForReceiver -> s.session + is SendScreenState.Transfer -> s.session + else -> null + } + session?.let { sendSessionRepo.cancelSend(it) } + postSideEffect(SendScreenEffect.NavigateBack) + } + + fun onCancelQrGeneration() = + intent { + val s = state + val files = + if (s is SendScreenState.GeneratingQR) { + s.files + } else { + emptyList() + } + + reduce { + SendScreenState.FileSelection(files) + } + } + + fun onComplete() = + intent { + val s = state + if (s is SendScreenState.Transfer) { + sendSessionRepo.recordSendCompletion( + s.files.map { it.toUri() }, + s.session, + ) + reduce { + SendScreenState.Complete(session = s.session, files = s.files) + } + } + } + + fun onDone() = + intent { + val s = state + if (s is SendScreenState.Complete) { + sendSessionRepo.cancelSend(s.session) + } + postSideEffect(SendScreenEffect.NavigateBack) + } + + fun onSendMore() = + intent { + val s = state + if (s is SendScreenState.Complete) { + sendSessionRepo.cancelSend(s.session) + } + reduce { + SendScreenState.FileSelection() + } + } + + fun onErrorRetry() = + intent { + val s = state + if (s is SendScreenState.Error) { + s.session?.let { + sendSessionRepo.cancelSend(it) + } + } + val files = + when (s) { + is SendScreenState.Error -> s.files + else -> emptyList() + } ?: emptyList() + + val validated = resourcesHelper.validateUris(files).first + val canStartTransfer = validated.isNotEmpty() && networkStatus.isOnline() + val size = validated.sumOf { resourcesHelper.getFileSize(it) } + + reduce { + SendScreenState.FileSelection( + files = validated, + size = size, + canStartTransfer = canStartTransfer, + ) + } + } + + fun onErrorDismiss() = + intent { + val s = state + if (s is SendScreenState.Error) { + s.session?.let { + sendSessionRepo.cancelSend(it) + } + } + postSideEffect(SendScreenEffect.NavigateBack) + } + + private fun listenToSendProgress(session: SendSession) { + session.subscriber.progress.onEach { progress -> + intent { + val s = state + val files = + when (s) { + is SendScreenState.Transfer -> s.files + is SendScreenState.WaitingForReceiver -> s.files + else -> return@intent + } + + try { + if (progress.isConnected.not()) + return@intent + + val transfer = + SendScreenState.Transfer( + session = session, + files = files, + isConnected = progress.isConnected, + receiverName = progress.receiverName, + receiverAvatar = progress.receiverAvatar, + currentFileName = progress.fileName, + bytesTransferred = progress.sent.toLong(), + totalBytes = (progress.sent + progress.remaining).toLong(), + transferSpeedBps = 0L, + estimatedTimeRemaining = 0L, + ) + + reduce { + transfer + } + } catch (e: Throwable) { + Timber.e("Transfer interrupted: ${e::class.simpleName} ${e.message}") + reduce { + SendScreenState.Error( + session = session, + files = files, + error = SendException.TransferInterrupted, + ) + } + } + } + }.launchIn(viewModelScope) + } + + private fun monitorTransferCompletion(session: SendSession) { + viewModelScope.launch { + while (coroutineContext.isActive) { + val isFinished = session.bubble.isFinished() + if (isFinished) { + onComplete() + break + } + delay(500) + } + } + } + + private fun generateQRCodeSafely( + ticket: String, + confirmation: UByte, + ): Bitmap? { + val writer = QRCodeWriter() + try { + if (ticket.isEmpty()) { + throw IllegalArgumentException("Ticket cannot be empty") + } + + val qrData = "drop://receive?ticket=$ticket&confirmation=$confirmation" + val bitMatrix: BitMatrix = writer.encode(qrData, BarcodeFormat.QR_CODE, 512, 512) + val width = bitMatrix.width + val height = bitMatrix.height + val bitmap = createBitmap(width, height, Bitmap.Config.RGB_565) + + for (x in 0 until width) { + for (y in 0 until height) { + bitmap[x, y] = + if (bitMatrix[x, y]) { + Color.BLACK + } else { + Color.WHITE + } + } + } + return bitmap + } catch (e: Throwable) { + Timber.e("Unexpected error during QR code generation: ${e.message}") + return null + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendButton.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendButton.kt new file mode 100644 index 0000000..186186c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendButton.kt @@ -0,0 +1,100 @@ +package dev.arkbuilders.drop.app.presentation.send.components + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +enum class ButtonVariant { Primary, Secondary } + +enum class ButtonSize { Medium, Large } + +@Composable +fun SendButton( + onClick: () -> Unit, + variant: ButtonVariant, + size: ButtonSize, + modifier: Modifier = Modifier, + enabled: Boolean = true, + loading: Boolean = false, + content: @Composable () -> Unit, +) { + val height = + when (size) { + ButtonSize.Medium -> 44.dp + ButtonSize.Large -> 56.dp + } + + when (variant) { + ButtonVariant.Primary -> { + Button( + onClick = onClick, + modifier = modifier.height(height), + enabled = enabled && !loading, + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = + MaterialTheme.colorScheme.primary.copy( + alpha = 0.3f, + ), + disabledContentColor = + MaterialTheme.colorScheme.onPrimary.copy( + alpha = 0.5f, + ), + ), + elevation = + ButtonDefaults.buttonElevation( + defaultElevation = 2.dp, + pressedElevation = 4.dp, + disabledElevation = 0.dp, + ), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(12.dp)) + } + content() + } + } + + ButtonVariant.Secondary -> { + FilledTonalButton( + onClick = onClick, + modifier = modifier.height(height), + enabled = enabled && !loading, + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer, + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(12.dp)) + } + content() + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendCard.kt new file mode 100644 index 0000000..4218433 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendCard.kt @@ -0,0 +1,33 @@ +package dev.arkbuilders.drop.app.presentation.send.components + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun SendCard( + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.surface, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = backgroundColor, + ), + elevation = + CardDefaults.cardElevation( + defaultElevation = 1.dp, + pressedElevation = 2.dp, + ), + ) { + content() + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendEmptyState.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendEmptyState.kt new file mode 100644 index 0000000..ea27222 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendEmptyState.kt @@ -0,0 +1,66 @@ +package dev.arkbuilders.drop.app.presentation.send.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun SendEmptyState( + title: String, + description: String, + icon: ImageVector, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = title, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendFileItem.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendFileItem.kt new file mode 100644 index 0000000..5c7b30b --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendFileItem.kt @@ -0,0 +1,129 @@ +package dev.arkbuilders.drop.app.presentation.send.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import compose.icons.TablerIcons +import compose.icons.tablericons.FileText +import dev.arkbuilders.drop.app.domain.ResourcesHelper +import dev.arkbuilders.drop.app.presentation.DisplayUtils.formatBytes +import org.koin.java.KoinJavaComponent.inject + +@Composable +fun SendFileItem( + uri: String, + onRemove: () -> Unit, +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + var fileName by remember { mutableStateOf("Loading...") } + var fileSize by remember { mutableStateOf(0L) } + + LaunchedEffect(uri) { + try { + val resourcesHelper: ResourcesHelper = + inject( + ResourcesHelper::class.java, + ).value + + fileName = resourcesHelper.getFileName(uri) ?: "Unknown file" + fileSize = resourcesHelper.getFileSize(uri) + } catch (e: Exception) { + fileName = "Unknown file" + fileSize = 0L + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + tonalElevation = 1.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + TablerIcons.FileText, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = fileName, + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (fileSize > 0) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = formatBytes(fileSize), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onRemove() + }, + modifier = + Modifier + .size(36.dp) + .clip(CircleShape), + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove file", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendLoadingIndicator.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendLoadingIndicator.kt new file mode 100644 index 0000000..db21573 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendLoadingIndicator.kt @@ -0,0 +1,41 @@ +package dev.arkbuilders.drop.app.presentation.send.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun SendLoadingIndicator(message: String? = null) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp, + ) + + message?.let { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = it, + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendProgressBar.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendProgressBar.kt new file mode 100644 index 0000000..7781649 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/SendProgressBar.kt @@ -0,0 +1,26 @@ +package dev.arkbuilders.drop.app.presentation.send.components + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +@Composable +fun SendProgressBar( + progress: Float, + modifier: Modifier = Modifier, +) { + LinearProgressIndicator( + progress = { progress }, + modifier = + modifier + .height(6.dp) + .clip(RoundedCornerShape(3.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + ) +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/FileSelectionPhase.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/FileSelectionPhase.kt new file mode 100644 index 0000000..ce5d626 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/FileSelectionPhase.kt @@ -0,0 +1,166 @@ +package dev.arkbuilders.drop.app.presentation.send.components.phase + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import compose.icons.TablerIcons +import compose.icons.tablericons.FileText +import compose.icons.tablericons.Plus +import dev.arkbuilders.drop.app.presentation.DisplayUtils.formatBytes +import dev.arkbuilders.drop.app.presentation.components.DropInstructionsCard +import dev.arkbuilders.drop.app.presentation.send.components.ButtonSize +import dev.arkbuilders.drop.app.presentation.send.components.ButtonVariant +import dev.arkbuilders.drop.app.presentation.send.components.SendButton +import dev.arkbuilders.drop.app.presentation.send.components.SendCard +import dev.arkbuilders.drop.app.presentation.send.components.SendEmptyState +import dev.arkbuilders.drop.app.presentation.send.components.SendFileItem + +@Composable +fun FileSelectionPhase( + selectedFiles: List, + totalFileSize: Long, + onAddFiles: () -> Unit, + onRemoveFile: (String) -> Unit, + onStartTransfer: () -> Unit, + canStartTransfer: Boolean, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + SendCard { + Column( + modifier = Modifier.padding(20.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "Selected Files", + style = + MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + + if (selectedFiles.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${selectedFiles.size} file${ + if (selectedFiles.size != 1) "s" else "" + } • ${ + formatBytes( + totalFileSize, + ) + }", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + SendButton( + onClick = onAddFiles, + variant = ButtonVariant.Secondary, + size = ButtonSize.Medium, + ) { + Icon( + TablerIcons.Plus, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Add Files", + style = + MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + ), + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + if (selectedFiles.isEmpty()) { + SendEmptyState( + title = "No Files Selected", + description = "Tap 'Add Files' to choose files you want to send.", + icon = TablerIcons.FileText, + ) + } else { + LazyColumn( + modifier = Modifier.heightIn(max = 300.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(selectedFiles) { uri -> + SendFileItem( + uri = uri, + onRemove = { onRemoveFile(uri) }, + ) + } + } + } + } + } + + SendButton( + onClick = onStartTransfer, + variant = ButtonVariant.Primary, + size = ButtonSize.Large, + enabled = canStartTransfer, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + ) { + Text( + text = + when { + selectedFiles.isEmpty() -> "Select Files First" + else -> + "Send ${selectedFiles.size} File${ + if (selectedFiles.size != 1) "s" else "" + }" + }, + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + ) + } + + DropInstructionsCard( + modifier = Modifier.fillMaxWidth(), + title = "How to send files:", + steps = + listOf( + "Select files you want to send", + "Tap 'Send Files' to generate QR code", + "Let the receiver scan the QR code", + "Files transfer automatically", + ), + ) + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/GeneratingQRPhase.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/GeneratingQRPhase.kt new file mode 100644 index 0000000..6ec188a --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/GeneratingQRPhase.kt @@ -0,0 +1,47 @@ +package dev.arkbuilders.drop.app.presentation.send.components.phase + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.presentation.send.components.ButtonSize +import dev.arkbuilders.drop.app.presentation.send.components.ButtonVariant +import dev.arkbuilders.drop.app.presentation.send.components.SendButton +import dev.arkbuilders.drop.app.presentation.send.components.SendCard +import dev.arkbuilders.drop.app.presentation.send.components.SendLoadingIndicator + +@Composable +fun GeneratingQRPhase(onCancel: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + SendCard { + Column( + modifier = Modifier.padding(40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SendLoadingIndicator( + message = "Generating QR Code...", + ) + + Spacer(modifier = Modifier.height(32.dp)) + + SendButton( + onClick = onCancel, + variant = ButtonVariant.Secondary, + size = ButtonSize.Medium, + ) { + Text("Cancel") + } + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/TransferCompletePhase.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/TransferCompletePhase.kt new file mode 100644 index 0000000..912d0f5 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/TransferCompletePhase.kt @@ -0,0 +1,126 @@ +package dev.arkbuilders.drop.app.presentation.send.components.phase + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.presentation.send.components.ButtonSize +import dev.arkbuilders.drop.app.presentation.send.components.ButtonVariant +import dev.arkbuilders.drop.app.presentation.send.components.SendButton +import dev.arkbuilders.drop.app.presentation.send.components.SendCard + +@Composable +fun TransferCompletePhase( + fileCount: Int, + onSendMore: () -> Unit, + onDone: () -> Unit, +) { + val haptic = LocalHapticFeedback.current + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + SendCard( + backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Complete", + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Files Sent Successfully!", + style = + MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + ), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "$fileCount file${ + if (fileCount != 1) "s" else "" + } transferred successfully", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + SendButton( + onClick = onSendMore, + variant = ButtonVariant.Secondary, + size = ButtonSize.Large, + modifier = Modifier.weight(1f), + ) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Send More", + fontWeight = FontWeight.Medium, + ) + } + + SendButton( + onClick = onDone, + variant = ButtonVariant.Primary, + size = ButtonSize.Large, + modifier = Modifier.weight(1f), + ) { + Text( + "Done", + fontWeight = FontWeight.Medium, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/TransferringPhase.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/TransferringPhase.kt new file mode 100644 index 0000000..47668d4 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/TransferringPhase.kt @@ -0,0 +1,186 @@ +package dev.arkbuilders.drop.app.presentation.send.components.phase + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.arkbuilders.drop.app.presentation.DisplayUtils.formatBytes +import dev.arkbuilders.drop.app.presentation.DisplayUtils.formatDuration +import dev.arkbuilders.drop.app.presentation.components.AvatarImageWithFallback +import dev.arkbuilders.drop.app.presentation.send.SendScreenState +import dev.arkbuilders.drop.app.presentation.send.components.SendCard +import dev.arkbuilders.drop.app.presentation.send.components.SendProgressBar + +@Composable +fun TransferringPhase( + progress: SendScreenState.Transfer, + onCancel: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + SendCard( + backgroundColor = MaterialTheme.colorScheme.primaryContainer, + ) { + Column( + modifier = Modifier.padding(24.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Sending Files", + style = + MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + IconButton( + onClick = onCancel, + modifier = + Modifier + .size(32.dp) + .clip(CircleShape), + ) { + Icon( + Icons.Default.Close, + contentDescription = "Cancel transfer", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + if (progress.receiverName.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AvatarImageWithFallback( + avatarB64 = progress.receiverAvatar, + fallbackText = progress.receiverName, + size = 32.dp, + ) + + Text( + text = "Connected to: ${progress.receiverName}", + style = MaterialTheme.typography.bodyLarge, + color = + MaterialTheme.colorScheme.onPrimaryContainer.copy( + alpha = 0.8f, + ), + ) + } + } + + if (progress.currentFileName.isNotEmpty()) { + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Sending: ${progress.currentFileName}", + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onPrimaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + val progressValue = + if (progress.totalBytes > 0) { + (progress.bytesTransferred.toFloat() / progress.totalBytes.toFloat()) + .coerceIn( + 0f, + 1f, + ) + } else { + 0f + } + + Spacer(modifier = Modifier.height(16.dp)) + + SendProgressBar( + progress = progressValue, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${ + formatBytes( + progress.bytesTransferred, + ) + } / ${formatBytes(progress.totalBytes)}", + style = MaterialTheme.typography.bodyMedium, + color = + MaterialTheme.colorScheme.onPrimaryContainer.copy( + alpha = 0.7f, + ), + ) + + if (progress.transferSpeedBps > 0) { + Text( + text = "${formatBytes(progress.transferSpeedBps)}/s", + style = MaterialTheme.typography.bodyMedium, + color = + MaterialTheme.colorScheme.onPrimaryContainer.copy( + alpha = 0.7f, + ), + ) + } + } + + if (progress.estimatedTimeRemaining > 0) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Time remaining: ${ + formatDuration( + progress.estimatedTimeRemaining, + ) + }", + style = MaterialTheme.typography.bodySmall, + color = + MaterialTheme.colorScheme.onPrimaryContainer.copy( + alpha = 0.6f, + ), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/WaitingForReceiverPhase.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/WaitingForReceiverPhase.kt new file mode 100644 index 0000000..4e440ce --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/send/components/phase/WaitingForReceiverPhase.kt @@ -0,0 +1,152 @@ +package dev.arkbuilders.drop.app.presentation.send.components.phase + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Bitmap +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import compose.icons.TablerIcons +import compose.icons.tablericons.Copy +import dev.arkbuilders.drop.app.presentation.send.components.ButtonSize +import dev.arkbuilders.drop.app.presentation.send.components.ButtonVariant +import dev.arkbuilders.drop.app.presentation.send.components.SendButton +import dev.arkbuilders.drop.app.presentation.send.components.SendLoadingIndicator + +@Composable +fun WaitingForReceiverPhase( + qrBitmap: Bitmap, + fileCount: Int, + copyString: String, + onCancel: () -> Unit, +) { + val context = LocalContext.current + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = Color.White, + shadowElevation = 4.dp, + ) { + Image( + bitmap = qrBitmap.asImageBitmap(), + contentDescription = "QR code for file transfer", + modifier = + Modifier + .size(220.dp) + .padding(16.dp), + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Show this QR code to the receiver", + style = + MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "$fileCount file${if (fileCount != 1) "s" else ""} ready to transfer", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SendButton( + onClick = { + copyToClipboard(context, copyString) + Toast.makeText(context, "Code copied!", Toast.LENGTH_SHORT).show() + }, + variant = ButtonVariant.Secondary, + size = ButtonSize.Medium, + ) { + Icon( + TablerIcons.Copy, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Copy code", + fontWeight = FontWeight.Medium, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SendLoadingIndicator() + Text( + text = "Waiting for receiver to scan...", + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + ), + color = MaterialTheme.colorScheme.primary, + ) + } + + TextButton( + onClick = onCancel, + shape = RoundedCornerShape(8.dp), + ) { + Text( + "Cancel Transfer", + fontWeight = FontWeight.Medium, + ) + } + } +} + +private fun copyToClipboard( + context: Context, + text: String, +) { + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText(null, text) + clipboardManager.setPrimaryClip(clipData) +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Color.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/Color.kt similarity index 95% rename from app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Color.kt rename to app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/Color.kt index 09f14d8..c1c6bc5 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Color.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/Color.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.drop.app.ui.theme +package dev.arkbuilders.drop.app.presentation.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/DesignTokens.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/DesignTokens.kt new file mode 100644 index 0000000..9812f9d --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/DesignTokens.kt @@ -0,0 +1,65 @@ +package dev.arkbuilders.drop.app.presentation.theme + +import androidx.compose.ui.unit.dp + +/** + * Design System Tokens for Drop + * Following Material Design 3 and Apple HIG principles + */ +object DesignTokens { + // Spacing Scale - 8pt grid system + object Spacing { + val xs = 4.dp // Micro spacing + val sm = 8.dp // Small spacing + val md = 12.dp // Medium spacing + val lg = 16.dp // Large spacing + val xl = 24.dp // Extra large spacing + val xxl = 32.dp // Double extra large spacing + val xxxl = 48.dp // Triple extra large spacing + val huge = 64.dp // Huge spacing + } + + // Elevation Scale + object Elevation { + val none = 0.dp + val xs = 1.dp // Subtle elevation + val sm = 3.dp // Small elevation + val md = 6.dp // Medium elevation + val lg = 8.dp // Large elevation + val xl = 12.dp // Extra large elevation + val xxl = 16.dp // Maximum elevation + } + + // Corner Radius Scale + object CornerRadius { + val xs = 4.dp // Small corners + val sm = 8.dp // Medium corners + val md = 12.dp // Default corners + val lg = 16.dp // Large corners + val xl = 20.dp // Extra large corners + val xxl = 24.dp // Maximum corners + val round = 50.dp // Fully rounded (pills) + } + + // Touch Targets + object TouchTarget { + val minimum = 48.dp // Minimum touch target size + val comfortable = 56.dp // Comfortable touch target size + val large = 64.dp // Large touch target size + } + + // Animation Durations + object Animation { + const val FAST = 150 + const val NORMAL = 300 + const val SLOW = 500 + const val EXTRA_SLOW = 800 + } + + // Content Width Constraints + object Layout { + val maxContentWidth = 600.dp + val minTouchTarget = 48.dp + val cardMaxWidth = 400.dp + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/Theme.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/Theme.kt new file mode 100644 index 0000000..a11559c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/Theme.kt @@ -0,0 +1,131 @@ +package dev.arkbuilders.drop.app.presentation.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = + darkColorScheme( + primary = Blue80, + onPrimary = Grey10, + primaryContainer = Blue40, + onPrimaryContainer = Grey10, + secondary = Teal80, + onSecondary = Grey10, + secondaryContainer = Teal40, + onSecondaryContainer = Grey10, + tertiary = BlueGrey80, + onTertiary = Grey10, + tertiaryContainer = BlueGrey40, + onTertiaryContainer = Grey10, + error = Red80, + onError = Grey10, + errorContainer = Red40, + onErrorContainer = Grey10, + background = Grey95, + onBackground = Grey10, + surface = SurfaceDark, + onSurface = Grey10, + surfaceVariant = SurfaceVariantDark, + onSurfaceVariant = Grey30, + outline = Grey60, + outlineVariant = Grey70, + scrim = Color.Black, + inverseSurface = Grey10, + inverseOnSurface = Grey90, + inversePrimary = Blue40, + surfaceDim = Grey80, + surfaceBright = Grey70, + surfaceContainerLowest = Grey95, + surfaceContainerLow = Grey90, + surfaceContainer = Grey80, + surfaceContainerHigh = Grey70, + surfaceContainerHighest = Grey60, + ) + +private val LightColorScheme = + lightColorScheme( + primary = Blue40, + onPrimary = Color.White, + primaryContainer = Blue80, + onPrimaryContainer = Grey90, + secondary = Teal40, + onSecondary = Color.White, + secondaryContainer = Teal80, + onSecondaryContainer = Grey90, + tertiary = BlueGrey40, + onTertiary = Color.White, + tertiaryContainer = BlueGrey80, + onTertiaryContainer = Grey90, + error = Red40, + onError = Color.White, + errorContainer = Red80, + onErrorContainer = Grey90, + background = Grey10, + onBackground = Grey90, + surface = SurfaceLight, + onSurface = Grey90, + surfaceVariant = SurfaceVariantLight, + onSurfaceVariant = Grey70, + outline = Grey60, + outlineVariant = Grey40, + scrim = Color.Black, + inverseSurface = Grey90, + inverseOnSurface = Grey10, + inversePrimary = Blue80, + surfaceDim = Grey30, + surfaceBright = Color.White, + surfaceContainerLowest = Color.White, + surfaceContainerLow = Grey20, + surfaceContainer = Grey30, + surfaceContainerHigh = Grey40, + surfaceContainerHighest = Grey50, + ) + +@Composable +fun DropTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Disabled for consistent branding + dynamicColor: Boolean = false, + content: @Composable () -> Unit, +) { + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + window.navigationBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + WindowCompat.getInsetsController(window, view) + .isAppearanceLightNavigationBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/TopBar.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/TopBar.kt similarity index 79% rename from app/src/main/java/dev/arkbuilders/drop/app/ui/theme/TopBar.kt rename to app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/TopBar.kt index 0690a17..796c36b 100644 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/TopBar.kt +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/TopBar.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.drop.app.ui.theme +package dev.arkbuilders.drop.app.presentation.theme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -40,12 +40,16 @@ fun TopBar(modifier: Modifier = Modifier) { ) } IconButton( - modifier = innerModifier, onClick = {}, enabled = false, colors = IconButtonColors( - containerColor = Color.LightGray, - disabledContainerColor = Color.LightGray, - contentColor = Color.Gray, - disabledContentColor = Color.Gray, - ) + modifier = innerModifier, + onClick = {}, + enabled = false, + colors = + IconButtonColors( + containerColor = Color.LightGray, + disabledContainerColor = Color.LightGray, + contentColor = Color.Gray, + disabledContentColor = Color.Gray, + ), ) { Icon(imageVector = Icons.Rounded.Person, contentDescription = null) } @@ -58,4 +62,4 @@ fun TopBar(modifier: Modifier = Modifier) { @Composable fun AppBarPreview() { TopBar() -} \ No newline at end of file +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/Type.kt b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/Type.kt new file mode 100644 index 0000000..e27f006 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/presentation/theme/Type.kt @@ -0,0 +1,136 @@ +package dev.arkbuilders.drop.app.presentation.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = + Typography( + // Display styles + displayLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + // Headline styles + headlineLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + // Title styles + titleLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + // Body styles + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + // Label styles + labelLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + ) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropButton.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropButton.kt deleted file mode 100644 index 8854788..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropButton.kt +++ /dev/null @@ -1,191 +0,0 @@ -package dev.arkbuilders.drop.app.ui.components - -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import dev.arkbuilders.drop.app.ui.theme.DesignTokens - -enum class DropButtonSize { - Small, Medium, Large -} - -enum class DropButtonVariant { - Primary, Secondary, Tertiary, Destructive -} - -@Composable -fun DropButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - variant: DropButtonVariant = DropButtonVariant.Primary, - size: DropButtonSize = DropButtonSize.Medium, - enabled: Boolean = true, - loading: Boolean = false, - contentDescription: String? = null, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - content: @Composable RowScope.() -> Unit -) { - val haptic = LocalHapticFeedback.current - val isPressed by interactionSource.collectIsPressedAsState() - - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.95f else 1f, - label = "buttonScale" - ) - - val buttonHeight = when (size) { - DropButtonSize.Small -> 40.dp - DropButtonSize.Medium -> DesignTokens.TouchTarget.minimum - DropButtonSize.Large -> DesignTokens.TouchTarget.large - } - - val contentPadding = when (size) { - DropButtonSize.Small -> PaddingValues(horizontal = DesignTokens.Spacing.md, vertical = DesignTokens.Spacing.xs) - DropButtonSize.Medium -> PaddingValues(horizontal = DesignTokens.Spacing.lg, vertical = DesignTokens.Spacing.sm) - DropButtonSize.Large -> PaddingValues(horizontal = DesignTokens.Spacing.xl, vertical = DesignTokens.Spacing.md) - } - - val colors = when (variant) { - DropButtonVariant.Primary -> ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - DropButtonVariant.Secondary -> ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary, - contentColor = MaterialTheme.colorScheme.onSecondary, - disabledContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - DropButtonVariant.Tertiary -> ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary, - disabledContainerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.12f), - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - DropButtonVariant.Destructive -> ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - disabledContainerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.12f), - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - - Button( - onClick = { - if (!loading) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onClick() - } - }, - modifier = modifier - .scale(scale) - .defaultMinSize(minHeight = buttonHeight) - .semantics { - role = Role.Button - contentDescription?.let { this.contentDescription = it } - }, - enabled = enabled && !loading, - colors = colors, - contentPadding = contentPadding, - interactionSource = interactionSource, - shape = RoundedCornerShape(DesignTokens.CornerRadius.md) - ) { - if (loading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp - ) - } else { - content() - } - } -} - -@Composable -fun DropOutlinedButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - size: DropButtonSize = DropButtonSize.Medium, - enabled: Boolean = true, - loading: Boolean = false, - contentDescription: String? = null, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - content: @Composable RowScope.() -> Unit -) { - val haptic = LocalHapticFeedback.current - val isPressed by interactionSource.collectIsPressedAsState() - - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.95f else 1f, - label = "buttonScale" - ) - - val buttonHeight = when (size) { - DropButtonSize.Small -> 40.dp - DropButtonSize.Medium -> DesignTokens.TouchTarget.minimum - DropButtonSize.Large -> DesignTokens.TouchTarget.large - } - - val contentPadding = when (size) { - DropButtonSize.Small -> PaddingValues(horizontal = DesignTokens.Spacing.md, vertical = DesignTokens.Spacing.xs) - DropButtonSize.Medium -> PaddingValues(horizontal = DesignTokens.Spacing.lg, vertical = DesignTokens.Spacing.sm) - DropButtonSize.Large -> PaddingValues(horizontal = DesignTokens.Spacing.xl, vertical = DesignTokens.Spacing.md) - } - - OutlinedButton( - onClick = { - if (!loading) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onClick() - } - }, - modifier = modifier - .scale(scale) - .defaultMinSize(minHeight = buttonHeight) - .semantics { - role = Role.Button - contentDescription?.let { this.contentDescription = it } - }, - enabled = enabled && !loading, - contentPadding = contentPadding, - interactionSource = interactionSource, - shape = RoundedCornerShape(DesignTokens.CornerRadius.md) - ) { - if (loading) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.primary, - strokeWidth = 2.dp - ) - } else { - content() - } - } -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropCard.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropCard.kt deleted file mode 100644 index a903d2b..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropCard.kt +++ /dev/null @@ -1,130 +0,0 @@ -package dev.arkbuilders.drop.app.ui.components - -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardColors -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import dev.arkbuilders.drop.app.ui.theme.DesignTokens - -enum class DropCardVariant { - Filled, Elevated, Outlined -} - -enum class DropCardSize { - Small, Medium, Large -} - -@Composable -fun DropCard( - modifier: Modifier = Modifier, - variant: DropCardVariant = DropCardVariant.Filled, - size: DropCardSize = DropCardSize.Medium, - onClick: (() -> Unit)? = null, - contentDescription: String? = null, - shape: Shape = RoundedCornerShape( - when (size) { - DropCardSize.Small -> DesignTokens.CornerRadius.sm - DropCardSize.Medium -> DesignTokens.CornerRadius.md - DropCardSize.Large -> DesignTokens.CornerRadius.lg - } - ), - colors: CardColors = when (variant) { - DropCardVariant.Filled -> CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ) - DropCardVariant.Elevated -> CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ) - DropCardVariant.Outlined -> CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ) - }, - content: @Composable ColumnScope.() -> Unit -) { - val cardModifier = modifier - .fillMaxWidth() - .semantics { - contentDescription?.let { this.contentDescription = it } - } - - val cardPadding = when (size) { - DropCardSize.Small -> DesignTokens.Spacing.md - DropCardSize.Medium -> DesignTokens.Spacing.lg - DropCardSize.Large -> DesignTokens.Spacing.xl - } - - val elevation = when (variant) { - DropCardVariant.Filled -> CardDefaults.cardElevation(defaultElevation = DesignTokens.Elevation.none) - DropCardVariant.Elevated -> CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.md) - DropCardVariant.Outlined -> CardDefaults.outlinedCardElevation(defaultElevation = DesignTokens.Elevation.none) - } - - when (variant) { - DropCardVariant.Filled -> { - Card( - modifier = cardModifier, - onClick = onClick ?: { }, - shape = shape, - colors = colors, - elevation = elevation - ) { - content() - } - } - DropCardVariant.Elevated -> { - ElevatedCard( - modifier = cardModifier, - onClick = onClick ?: { }, - shape = shape, - colors = colors, - elevation = elevation - ) { - content() - } - } - DropCardVariant.Outlined -> { - OutlinedCard( - modifier = cardModifier, - onClick = onClick ?: { }, - shape = shape, - colors = colors, - elevation = elevation - ) { - content() - } - } - } -} - -@Composable -fun DropCardContent( - modifier: Modifier = Modifier, - size: DropCardSize = DropCardSize.Medium, - content: @Composable ColumnScope.() -> Unit -) { - val padding = when (size) { - DropCardSize.Small -> DesignTokens.Spacing.md - DropCardSize.Medium -> DesignTokens.Spacing.lg - DropCardSize.Large -> DesignTokens.Spacing.xl - } - - androidx.compose.foundation.layout.Column( - modifier = modifier.padding(padding) - ) { - content() - } -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/components/ErrorStates.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/ErrorStates.kt deleted file mode 100644 index 8287c39..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/components/ErrorStates.kt +++ /dev/null @@ -1,159 +0,0 @@ -package dev.arkbuilders.drop.app.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -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.material.icons.Icons -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import compose.icons.TablerIcons -import compose.icons.tablericons.AlertCircle -import compose.icons.tablericons.CloudOff -import compose.icons.tablericons.FileX -import compose.icons.tablericons.WifiOff -import dev.arkbuilders.drop.app.ui.theme.DesignTokens - -enum class ErrorType { - Network, FileTransfer, Permission, Generic, Offline -} - -data class ErrorState( - val type: ErrorType, - val title: String, - val message: String, - val actionLabel: String? = null, - val onAction: (() -> Unit)? = null -) - -@Composable -fun ErrorStateDisplay( - errorState: ErrorState, - modifier: Modifier = Modifier -) { - val icon = when (errorState.type) { - ErrorType.Network -> TablerIcons.WifiOff - ErrorType.FileTransfer -> TablerIcons.FileX - ErrorType.Permission -> TablerIcons.AlertCircle - ErrorType.Offline -> TablerIcons.CloudOff - ErrorType.Generic -> Icons.Default.Warning - } - - val iconColor = when (errorState.type) { - ErrorType.Network, ErrorType.Offline -> MaterialTheme.colorScheme.error - ErrorType.FileTransfer -> MaterialTheme.colorScheme.error - ErrorType.Permission -> MaterialTheme.colorScheme.error - ErrorType.Generic -> MaterialTheme.colorScheme.error - } - - DropCard( - modifier = modifier, - variant = DropCardVariant.Outlined, - size = DropCardSize.Large, - colors = androidx.compose.material3.CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.1f), - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - DropCardContent(size = DropCardSize.Large) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = iconColor - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Text( - text = errorState.title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.sm)) - - Text( - text = errorState.message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - errorState.actionLabel?.let { label -> - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - DropButton( - onClick = { errorState.onAction?.invoke() }, - variant = DropButtonVariant.Primary, - size = DropButtonSize.Medium, - contentDescription = "Retry action" - ) { - Text(text = label) - } - } - } - } - } -} - -// Predefined error states for common scenarios -object CommonErrors { - fun networkError(onRetry: () -> Unit) = ErrorState( - type = ErrorType.Network, - title = "Connection Problem", - message = "Unable to connect to the network. Please check your internet connection and try again.", - actionLabel = "Retry", - onAction = onRetry - ) - - fun fileTransferError(onRetry: () -> Unit) = ErrorState( - type = ErrorType.FileTransfer, - title = "Transfer Failed", - message = "The file transfer was interrupted. This might be due to network issues or insufficient storage space.", - actionLabel = "Try Again", - onAction = onRetry - ) - - fun permissionError(onRequestPermission: () -> Unit) = ErrorState( - type = ErrorType.Permission, - title = "Permission Required", - message = "This feature requires additional permissions to work properly. Please grant the necessary permissions.", - actionLabel = "Grant Permission", - onAction = onRequestPermission - ) - - fun offlineError() = ErrorState( - type = ErrorType.Offline, - title = "You're Offline", - message = "This feature requires an internet connection. Please check your network settings and try again.", - actionLabel = null, - onAction = null - ) - - fun genericError(onRetry: () -> Unit) = ErrorState( - type = ErrorType.Generic, - title = "Something Went Wrong", - message = "An unexpected error occurred. Please try again or contact support if the problem persists.", - actionLabel = "Retry", - onAction = onRetry - ) -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/AvatarUtils.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/AvatarUtils.kt deleted file mode 100644 index ca1b5cc..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/AvatarUtils.kt +++ /dev/null @@ -1,209 +0,0 @@ -//package dev.arkbuilders.drop.app.ui.profile -// -//import android.content.Context -//import android.graphics.Bitmap -//import android.graphics.BitmapFactory -//import android.graphics.Canvas -//import android.graphics.Paint -//import android.graphics.PorterDuff -//import android.graphics.PorterDuffXfermode -//import android.graphics.Rect -//import android.graphics.RectF -//import android.net.Uri -//import android.util.Base64 -//import androidx.compose.foundation.Image -//import androidx.compose.foundation.background -//import androidx.compose.foundation.layout.Box -//import androidx.compose.foundation.layout.size -//import androidx.compose.foundation.shape.CircleShape -//import androidx.compose.material3.MaterialTheme -//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.asImageBitmap -//import androidx.compose.ui.layout.ContentScale -//import androidx.compose.ui.unit.Dp -//import androidx.compose.ui.unit.dp -//import java.io.ByteArrayOutputStream -//import java.io.InputStream -// -//object AvatarUtils { -// private const val AVATAR_SIZE = 512 -// private const val COMPRESSION_QUALITY = 85 -// -// fun getDefaultAvatarBase64(context: Context, avatarId: String): String { -// return try { -// val resourceId = context.resources.getIdentifier( -// avatarId, -// "drawable", -// context.packageName -// ) -// -// if (resourceId != 0) { -// val inputStream = context.resources.openRawResource(resourceId) -// val bitmap = BitmapFactory.decodeStream(inputStream) -// inputStream.close() -// -// val resizedBitmap = resizeBitmap(bitmap, AVATAR_SIZE, AVATAR_SIZE) -// val circularBitmap = getCircularBitmap(resizedBitmap) -// bitmapToBase64(circularBitmap) -// } else { -// // Fallback to generated avatar -// generateDefaultAvatar(avatarId) -// } -// } catch (e: Exception) { -// generateDefaultAvatar(avatarId) -// } -// } -// -// fun uriToBase64(context: Context, uri: Uri): String? { -// return try { -// val inputStream: InputStream? = context.contentResolver.openInputStream(uri) -// inputStream?.use { stream -> -// val bitmap = BitmapFactory.decodeStream(stream) -// val resizedBitmap = resizeBitmap(bitmap, AVATAR_SIZE, AVATAR_SIZE) -// val circularBitmap = getCircularBitmap(resizedBitmap) -// bitmapToBase64(circularBitmap) -// } -// } catch (e: Exception) { -// null -// } -// } -// -// private fun resizeBitmap(bitmap: Bitmap, width: Int, height: Int): Bitmap { -// return Bitmap.createScaledBitmap(bitmap, width, height, true) -// } -// -// private fun getCircularBitmap(bitmap: Bitmap): Bitmap { -// val size = minOf(bitmap.width, bitmap.height) -// val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) -// -// val canvas = Canvas(output) -// val paint = Paint().apply { -// isAntiAlias = true -// } -// -// val rect = Rect(0, 0, size, size) -// val rectF = RectF(rect) -// -// canvas.drawOval(rectF, paint) -// -// paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) -// -// val sourceRect = Rect( -// (bitmap.width - size) / 2, -// (bitmap.height - size) / 2, -// (bitmap.width + size) / 2, -// (bitmap.height + size) / 2 -// ) -// -// canvas.drawBitmap(bitmap, sourceRect, rect, paint) -// -// return output -// } -// -// private fun bitmapToBase64(bitmap: Bitmap): String { -// val byteArrayOutputStream = ByteArrayOutputStream() -// bitmap.compress(Bitmap.CompressFormat.PNG, COMPRESSION_QUALITY, byteArrayOutputStream) -// val byteArray = byteArrayOutputStream.toByteArray() -// return Base64.encodeToString(byteArray, Base64.DEFAULT) -// } -// -// private fun base64ToBitmap(base64String: String): Bitmap? { -// return try { -// val decodedBytes = Base64.decode(base64String, Base64.DEFAULT) -// BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) -// } catch (e: Exception) { -// null -// } -// } -// -// private fun generateDefaultAvatar(avatarId: String): String { -// // Generate a simple colored circle as fallback -// val bitmap = Bitmap.createBitmap(AVATAR_SIZE, AVATAR_SIZE, Bitmap.Config.ARGB_8888) -// val canvas = Canvas(bitmap) -// val paint = Paint().apply { -// isAntiAlias = true -// color = when (avatarId.hashCode() % 6) { -// 0 -> android.graphics.Color.parseColor("#FF6B6B") -// 1 -> android.graphics.Color.parseColor("#4ECDC4") -// 2 -> android.graphics.Color.parseColor("#45B7D1") -// 3 -> android.graphics.Color.parseColor("#96CEB4") -// 4 -> android.graphics.Color.parseColor("#FFEAA7") -// else -> android.graphics.Color.parseColor("#DDA0DD") -// } -// } -// -// canvas.drawCircle( -// AVATAR_SIZE / 2f, -// AVATAR_SIZE / 2f, -// AVATAR_SIZE / 2f, -// paint -// ) -// -// return bitmapToBase64(bitmap) -// } -// -// @Composable -// fun AvatarImage( -// base64String: String, -// modifier: Modifier = Modifier, -// size: Dp = 48.dp -// ) { -// val bitmap = base64ToBitmap(base64String) -// -// if (bitmap != null) { -// Image( -// bitmap = bitmap.asImageBitmap(), -// contentDescription = "Avatar", -// modifier = modifier -// .size(size) -// .clip(CircleShape), -// contentScale = ContentScale.Crop -// ) -// } else { -// // Fallback to colored circle -// Box( -// modifier = modifier -// .size(size) -// .clip(CircleShape) -// .background(MaterialTheme.colorScheme.primary), -// contentAlignment = Alignment.Center -// ) { -// // Empty fallback -// } -// } -// } -// -// @Composable -// fun AvatarImageWithFallback( -// base64String: String?, -// fallbackText: String = "?", -// modifier: Modifier = Modifier, -// size: Dp = 48.dp -// ) { -// if (!base64String.isNullOrEmpty()) { -// AvatarImage( -// base64String = base64String, -// modifier = modifier, -// size = size -// ) -// } else { -// // Text-based fallback -// Box( -// modifier = modifier -// .size(size) -// .clip(CircleShape) -// .background(MaterialTheme.colorScheme.primary), -// contentAlignment = Alignment.Center -// ) { -// androidx.compose.material3.Text( -// text = fallbackText.take(1).uppercase(), -// color = MaterialTheme.colorScheme.onPrimary, -// style = MaterialTheme.typography.titleMedium -// ) -// } -// } -// } -//} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/AvatarUtilsEnhanced.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/AvatarUtilsEnhanced.kt deleted file mode 100644 index 46dfdf9..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/AvatarUtilsEnhanced.kt +++ /dev/null @@ -1,252 +0,0 @@ -package dev.arkbuilders.drop.app.ui.profile - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.ImageDecoder -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import android.util.Base64 -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -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.asImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import java.io.ByteArrayOutputStream -import java.io.IOException -import androidx.core.graphics.scale - -object AvatarUtils { - - private const val MAX_IMAGE_SIZE = 512 // Maximum width/height in pixels - private const val JPEG_QUALITY = 85 // JPEG compression quality - private const val MAX_FILE_SIZE = 500 * 1024 // 500KB max file size - - /** - * Convert URI to Base64 with comprehensive error handling and optimization - */ - fun uriToBase64(context: Context, uri: Uri): String? { - return try { - val bitmap = loadBitmapFromUri(context, uri) ?: return null - val optimizedBitmap = optimizeBitmap(bitmap) - bitmapToBase64(optimizedBitmap) - } catch (e: Exception) { - null - } - } - - /** - * Load bitmap from URI with proper error handling - */ - private fun loadBitmapFromUri(context: Context, uri: Uri): Bitmap? { - return try { - val source = ImageDecoder.createSource(context.contentResolver, uri) - ImageDecoder.decodeBitmap(source) - } catch (e: IOException) { - null - } catch (e: SecurityException) { - null - } - } - - /** - * Optimize bitmap for storage and performance - */ - private fun optimizeBitmap(bitmap: Bitmap): Bitmap { - val width = bitmap.width - val height = bitmap.height - - // Calculate scaling factor - val scaleFactor = if (width > height) { - MAX_IMAGE_SIZE.toFloat() / width - } else { - MAX_IMAGE_SIZE.toFloat() / height - } - - return if (scaleFactor < 1f) { - val newWidth = (width * scaleFactor).toInt() - val newHeight = (height * scaleFactor).toInt() - bitmap.scale(newWidth, newHeight) - } else { - bitmap - } - } - - /** - * Convert bitmap to Base64 with size validation - */ - private fun bitmapToBase64(bitmap: Bitmap): String? { - return try { - val outputStream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, outputStream) - val byteArray = outputStream.toByteArray() - - // Check file size - if (byteArray.size > MAX_FILE_SIZE) { - return null - } - - Base64.encodeToString(byteArray, Base64.DEFAULT) - } catch (e: Exception) { - null - } - } - - /** - * Get default avatar Base64 string - */ - @SuppressLint("DiscouragedApi") - fun getDefaultAvatarBase64(context: Context, avatarId: String): String { - return try { - val resourceId = context.resources.getIdentifier( - avatarId, "drawable", context.packageName - ) - if (resourceId != 0) { - val bitmap = BitmapFactory.decodeResource(context.resources, resourceId) - bitmapToBase64(bitmap) ?: "" - } else { - "" - } - } catch (e: Exception) { - "" - } - } - - /** - * Enhanced avatar image composable with error handling - */ - @Composable - fun AvatarImage( - base64String: String, - modifier: Modifier = Modifier, - contentDescription: String? = null - ) { - if (base64String.isNotEmpty()) { - val imageBytes = Base64.decode(base64String, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) - - if (bitmap != null) { - Image( - painter = BitmapPainter(bitmap.asImageBitmap()), - contentDescription = contentDescription, - modifier = modifier - .clip(CircleShape) - .semantics { - this.contentDescription = contentDescription ?: "Profile avatar" - }, - contentScale = ContentScale.Crop - ) - } else { - AvatarFallback(modifier = modifier, contentDescription = contentDescription) - } - } else { - AvatarFallback(modifier = modifier, contentDescription = contentDescription) - } - } - - /** - * Avatar with fallback for better UX - */ - @Composable - fun AvatarImageWithFallback( - base64String: String?, - fallbackText: String = "", - size: Dp = 48.dp, - contentDescription: String? = null - ) { - if (base64String != null && base64String.isNotEmpty()) { - AvatarImage( - base64String = base64String, - modifier = Modifier.size(size), - contentDescription = contentDescription - ) - } else { - AvatarFallback( - modifier = Modifier.size(size), - fallbackText = fallbackText, - contentDescription = contentDescription - ) - } - } - - /** - * Fallback avatar component - */ - @Composable - private fun AvatarFallback( - modifier: Modifier = Modifier, - fallbackText: String = "", - contentDescription: String? = null - ) { - Box( - modifier = modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer) - .semantics { - this.contentDescription = contentDescription ?: "Default profile avatar" - }, - contentAlignment = Alignment.Center - ) { - if (fallbackText.isNotEmpty()) { - androidx.compose.material3.Text( - text = fallbackText.take(2).uppercase(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } else { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.fillMaxSize(0.6f), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - } - - /** - * Validate image format and size - */ - fun validateImage(context: Context, uri: Uri): ValidationResult { - return try { - val bitmap = loadBitmapFromUri(context, uri) - when { - bitmap == null -> ValidationResult.InvalidFormat - bitmap.width < 64 || bitmap.height < 64 -> ValidationResult.TooSmall - bitmap.width > 2048 || bitmap.height > 2048 -> ValidationResult.TooLarge - else -> ValidationResult.Valid - } - } catch (e: Exception) { - ValidationResult.Error - } - } - - enum class ValidationResult { - Valid, - InvalidFormat, - TooSmall, - TooLarge, - Error - } -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/EditProfile.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/EditProfile.kt deleted file mode 100644 index 6542d27..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/EditProfile.kt +++ /dev/null @@ -1,287 +0,0 @@ -package dev.arkbuilders.drop.app.ui.profile - -import android.net.Uri -import android.widget.ScrollView -import android.widget.Scroller -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -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.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import compose.icons.TablerIcons -import compose.icons.tablericons.Camera -import dev.arkbuilders.drop.app.ProfileManager - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditProfile( - navController: NavController, - profileManager: ProfileManager -) { - val context = LocalContext.current - val profile by profileManager.profile.collectAsState() - var name by remember { mutableStateOf(profile.name) } - var selectedAvatarId by remember { mutableStateOf(profile.avatarId) } - var customAvatarBase64 by remember { mutableStateOf(null) } - - val availableAvatars = listOf( - "avatar_00", "avatar_01", "avatar_02", "avatar_03", - "avatar_04", "avatar_05", "avatar_06", "avatar_07", "avatar_08" - ) - - // Image picker launcher - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - uri?.let { - val base64 = AvatarUtils.uriToBase64(context, it) - if (base64 != null) { - customAvatarBase64 = base64 - selectedAvatarId = "custom" - } - } - } - - Column ( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - ) { - // Top bar - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { navController.navigateUp() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - Text( - text = "Edit Profile", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - - // Save button - FilledTonalButton( - onClick = { - profileManager.updateName(name) - if (selectedAvatarId == "custom" && customAvatarBase64 != null) { - profileManager.updateCustomAvatar(customAvatarBase64!!) - } else { - profileManager.updateAvatar(selectedAvatarId) - } - navController.navigateUp() - } - ) { - Icon(Icons.Default.Check, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text("Save") - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Current avatar preview - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val displayAvatarBase64 = when { - selectedAvatarId == "custom" && customAvatarBase64 != null -> customAvatarBase64!! - selectedAvatarId == "custom" && profile.avatarId == "custom" -> profile.avatarB64 - else -> AvatarUtils.getDefaultAvatarBase64(context, selectedAvatarId) - } - - AvatarUtils.AvatarImage( - base64String = displayAvatarBase64, - modifier = Modifier.size(80.dp) - ) - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Display Name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Upload custom avatar button - OutlinedButton( - onClick = { imagePickerLauncher.launch("image/*") }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - TablerIcons.Camera, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Upload Custom Avatar", fontWeight = FontWeight.Medium) - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Avatar selection - Text( - text = "Choose Default Avatar", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(16.dp)) - - LazyVerticalGrid( - columns = GridCells.Fixed(3), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(availableAvatars) { avatarId -> - AvatarOption( - avatarId = avatarId, - isSelected = selectedAvatarId == avatarId, - onClick = { - selectedAvatarId = avatarId - customAvatarBase64 = null - } - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - // Instructions - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Text( - text = "Your profile information is only shared during file transfers and is not stored on any server. Custom avatars are stored locally on your device.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp) - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AvatarOption( - avatarId: String, - isSelected: Boolean, - onClick: () -> Unit -) { - val context = LocalContext.current - - Card( - modifier = Modifier - .aspectRatio(1f) - .clip(CircleShape), - onClick = onClick, - colors = CardDefaults.cardColors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - } - ), - border = if (isSelected) { - CardDefaults.outlinedCardBorder().copy( - width = 2.dp, - brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary) - ) - } else null - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - AvatarUtils.AvatarImage( - base64String = AvatarUtils.getDefaultAvatarBase64(context, avatarId), - modifier = Modifier.size(48.dp) - ) - - if (isSelected) { - Box( - modifier = Modifier - .fillMaxSize() - .clip(CircleShape), - contentAlignment = Alignment.BottomEnd - ) { - Surface( - modifier = Modifier.size(20.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.primary - ) { - Icon( - Icons.Default.Check, - contentDescription = "Selected", - modifier = Modifier - .fillMaxSize() - .padding(2.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - } - } -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/EditProfileEnhanced.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/EditProfileEnhanced.kt deleted file mode 100644 index baf5fc0..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/EditProfileEnhanced.kt +++ /dev/null @@ -1,817 +0,0 @@ -package dev.arkbuilders.drop.app.ui.profile - -import android.net.Uri -import android.text.Layout -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Person -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.LineHeightStyle -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import compose.icons.TablerIcons -import compose.icons.tablericons.Camera -import compose.icons.tablericons.Check -import dev.arkbuilders.drop.app.ProfileManager -import dev.arkbuilders.drop.app.ui.components.DropButton -import dev.arkbuilders.drop.app.ui.components.DropButtonSize -import dev.arkbuilders.drop.app.ui.components.DropButtonVariant -import dev.arkbuilders.drop.app.ui.components.DropCard -import dev.arkbuilders.drop.app.ui.components.DropCardContent -import dev.arkbuilders.drop.app.ui.components.DropCardSize -import dev.arkbuilders.drop.app.ui.components.DropCardVariant -import dev.arkbuilders.drop.app.ui.components.ErrorStateDisplay -import dev.arkbuilders.drop.app.ui.components.ErrorType -import dev.arkbuilders.drop.app.ui.components.LoadingIndicator -import dev.arkbuilders.drop.app.ui.theme.DesignTokens -import kotlinx.coroutines.delay - -// UI State Management -data class EditProfileUiState( - val isLoading: Boolean = false, - val isSaving: Boolean = false, - val error: String? = null, - val showSuccess: Boolean = false, - val nameError: String? = null, - val avatarError: String? = null -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditProfileEnhanced( - navController: NavController, - profileManager: ProfileManager -) { - val context = LocalContext.current - val haptic = LocalHapticFeedback.current - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - - // State management - val profile by profileManager.profile.collectAsState() - var uiState by remember { mutableStateOf(EditProfileUiState()) } - var name by rememberSaveable { mutableStateOf(profile.name) } - var selectedAvatarId by rememberSaveable { mutableStateOf(profile.avatarId) } - var customAvatarBase64 by remember { mutableStateOf(null) } - - // Focus management - val nameFocusRequester = remember { FocusRequester() } - - // Validation - val isNameValid by remember { - derivedStateOf { - name.isNotBlank() && name.length <= 50 && name.trim().length >= 2 - } - } - - val hasChanges by remember { - derivedStateOf { - name != profile.name || - selectedAvatarId != profile.avatarId || - customAvatarBase64 != null - } - } - - val canSave by remember { - derivedStateOf { - isNameValid && hasChanges && !uiState.isSaving - } - } - - // Available avatars - val availableAvatars = remember { - listOf( - "avatar_00", "avatar_01", "avatar_02", "avatar_03", - "avatar_04", "avatar_05", "avatar_06", "avatar_07", "avatar_08" - ) - } - - // Image picker launcher with error handling - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - if (uri != null) { - try { - uiState = uiState.copy(isLoading = true, error = null) - val base64 = AvatarUtils.uriToBase64(context, uri) - if (base64 != null) { - customAvatarBase64 = base64 - selectedAvatarId = "custom" - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } else { - uiState = uiState.copy( - error = "Unable to process the selected image. Please try a different image.", - avatarError = "Invalid image format" - ) - } - } catch (e: Exception) { - uiState = uiState.copy( - error = "Failed to load image. Please check your storage permissions and try again.", - avatarError = "Image loading failed" - ) - } finally { - uiState = uiState.copy(isLoading = false) - } - } - } - - // Save profile function - val saveProfile = { - if (canSave) { - uiState = uiState.copy(isSaving = true, error = null) - profileManager.updateName(name.trim()) - if (selectedAvatarId == "custom" && customAvatarBase64 != null) { - profileManager.updateCustomAvatar(customAvatarBase64!!) - } else { - profileManager.updateAvatar(selectedAvatarId) - } - - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - uiState = uiState.copy(isSaving = false, showSuccess = true) - } - } - - LaunchedEffect(uiState.showSuccess) { - if (uiState.showSuccess) { - delay(1500) - navController.navigateUp() - } - } - - - // Real-time name validation - LaunchedEffect(name) { - uiState = uiState.copy( - nameError = when { - name.isBlank() -> "Name cannot be empty" - name.trim().length < 2 -> "Name must be at least 2 characters" - name.length > 50 -> "Name cannot exceed 50 characters" - else -> null - } - ) - } - - // Clear success state when user makes changes - LaunchedEffect(name, selectedAvatarId, customAvatarBase64) { - if (uiState.showSuccess) { - uiState = uiState.copy(showSuccess = false) - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.ime) - ) { - // Enhanced Top App Bar - TopAppBar( - title = { - Text( - text = "Edit Profile", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold - ) - }, - navigationIcon = { - IconButton( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - navController.navigateUp() - }, - modifier = Modifier.semantics { - contentDescription = "Go back to previous screen" - } - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - tint = colorScheme.onSurface - ) - } - }, - actions = { - AnimatedVisibility( - visible = canSave, - enter = scaleIn(spring(stiffness = Spring.StiffnessHigh)) + fadeIn(), - exit = scaleOut(spring(stiffness = Spring.StiffnessHigh)) + fadeOut() - ) { - DropButton( - onClick = saveProfile, - variant = DropButtonVariant.Primary, - size = DropButtonSize.Medium, - loading = uiState.isSaving, - contentDescription = "Save profile changes" - ) { - if (!uiState.isSaving) { - Icon( - if (uiState.showSuccess) TablerIcons.Check else Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.xs)) - } - Text( - text = when { - uiState.showSuccess -> "Saved!" - uiState.isSaving -> "Saving..." - else -> "Save" - }, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - } - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = colorScheme.surface, - titleContentColor = colorScheme.onSurface - ) - ) - - // Error Display - AnimatedVisibility( - visible = uiState.error != null, - enter = slideInVertically( - initialOffsetY = { -it }, - animationSpec = spring(stiffness = Spring.StiffnessMedium) - ) + fadeIn(), - exit = slideOutVertically( - targetOffsetY = { -it }, - animationSpec = spring(stiffness = Spring.StiffnessMedium) - ) + fadeOut() - ) { - uiState.error?.let { error -> - ErrorStateDisplay( - errorState = dev.arkbuilders.drop.app.ui.components.ErrorState( - type = ErrorType.Generic, - title = "Profile Update Failed", - message = error, - actionLabel = "Dismiss", - onAction = { uiState = uiState.copy(error = null) } - ), - modifier = Modifier.padding(DesignTokens.Spacing.lg) - ) - } - } - - // Loading Overlay - if (uiState.isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(DesignTokens.Spacing.lg), - contentAlignment = Alignment.Center - ) { - LoadingIndicator(message = "Processing image...") - } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(DesignTokens.Spacing.lg), - verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.xl) - ) { - // Profile Preview Section - item { - ProfilePreviewSection( - name = name, - selectedAvatarId = selectedAvatarId, - customAvatarBase64 = customAvatarBase64, - profile = profile, - onNameChange = { newName -> - name = newName - uiState = uiState.copy(error = null) - }, - nameError = uiState.nameError, - nameFocusRequester = nameFocusRequester, - onDone = { - keyboardController?.hide() - focusManager.clearFocus() - } - ) - } - - // Custom Avatar Upload Section - item { - CustomAvatarSection( - onUploadClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - imagePickerLauncher.launch("image/*") - }, - hasError = uiState.avatarError != null - ) - } - - // Avatar Selection Section - item { - AvatarSelectionSection( - availableAvatars = availableAvatars, - selectedAvatarId = selectedAvatarId, - onAvatarSelected = { avatarId -> - selectedAvatarId = avatarId - customAvatarBase64 = null - uiState = uiState.copy(error = null, avatarError = null) - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - ) - } - - // Privacy Notice Section - item { - PrivacyNoticeSection() - } - - // Bottom spacing for better UX - item { - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xxxl)) - } - } - } - } -} - -@Composable -private fun ProfilePreviewSection( - name: String, - selectedAvatarId: String, - customAvatarBase64: String?, - profile: dev.arkbuilders.drop.app.UserProfile, - onNameChange: (String) -> Unit, - nameError: String?, - nameFocusRequester: FocusRequester, - onDone: () -> Unit -) { - val context = LocalContext.current - - DropCard( - variant = DropCardVariant.Elevated, - size = DropCardSize.Large, - contentDescription = "Profile preview and name editing" - ) { - DropCardContent(size = DropCardSize.Large) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Avatar Preview with Animation - val displayAvatarBase64 = when { - selectedAvatarId == "custom" && customAvatarBase64 != null -> customAvatarBase64!! - selectedAvatarId == "custom" && profile.avatarId == "custom" -> profile.avatarB64 - else -> AvatarUtils.getDefaultAvatarBase64(context, selectedAvatarId) - } - - var avatarScale by remember { mutableStateOf(0.8f) } - val animatedAvatarScale by animateFloatAsState( - targetValue = avatarScale, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "avatarScale" - ) - - LaunchedEffect(selectedAvatarId, customAvatarBase64) { - avatarScale = 0.8f - delay(100) - avatarScale = 1f - } - - Box( - modifier = Modifier - .size(120.dp) - .scale(animatedAvatarScale), - contentAlignment = Alignment.Center - ) { - AvatarUtils.AvatarImage( - base64String = displayAvatarBase64, - modifier = Modifier - .size(120.dp) - .semantics { - contentDescription = "Current profile avatar" - } - ) - - // Edit indicator - Surface( - modifier = Modifier - .align(Alignment.BottomEnd) - .size(32.dp), - shape = CircleShape, - color = colorScheme.primary, - shadowElevation = DesignTokens.Elevation.sm - ) { - Box( - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Edit, - contentDescription = "Edit avatar", - modifier = Modifier.size(16.dp), - tint = colorScheme.onPrimary - ) - } - } - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - // Enhanced Name Input - OutlinedTextField( - value = name, - onValueChange = onNameChange, - label = { - Text( - "Display Name", - style = MaterialTheme.typography.bodyMedium - ) - }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(nameFocusRequester) - .semantics { - contentDescription = "Enter your display name" - }, - singleLine = true, - isError = nameError != null, - supportingText = { - AnimatedVisibility( - visible = nameError != null, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut() - ) { - nameError?.let { - Text( - text = it, - color = colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } - }, - trailingIcon = { - if (name.isNotEmpty()) { - IconButton( - onClick = { onNameChange("") }, - modifier = Modifier.semantics { - contentDescription = "Clear name field" - } - ) { - Icon( - Icons.Default.Clear, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = colorScheme.onSurfaceVariant - ) - } - } - }, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Words, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { onDone() } - ), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = colorScheme.primary, - unfocusedBorderColor = colorScheme.outline, - errorBorderColor = colorScheme.error - ), - shape = RoundedCornerShape(DesignTokens.CornerRadius.md) - ) - - // Character count - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = DesignTokens.Spacing.xs), - horizontalArrangement = Arrangement.End - ) { - Text( - text = "${name.length}/50", - style = MaterialTheme.typography.bodySmall, - color = if (name.length > 45) { - colorScheme.error - } else { - colorScheme.onSurfaceVariant - } - ) - } - } - } - } -} - -@Composable -private fun CustomAvatarSection( - onUploadClick: () -> Unit, - hasError: Boolean -) { - DropCard( - variant = DropCardVariant.Outlined, - size = DropCardSize.Medium, - contentDescription = "Upload custom avatar option" - ) { - DropCardContent(size = DropCardSize.Medium) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Custom Avatar", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xs)) - Text( - text = "Upload your own profile picture", - style = MaterialTheme.typography.bodyMedium, - color = colorScheme.onSurfaceVariant - ) - } - - Spacer(modifier = Modifier.width(DesignTokens.Spacing.lg)) - - DropButton( - onClick = onUploadClick, - variant = if (hasError) DropButtonVariant.Destructive else DropButtonVariant.Secondary, - size = DropButtonSize.Medium, - contentDescription = "Upload custom avatar image" - ) { - Icon( - TablerIcons.Camera, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.sm)) - Text( - "Upload", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium - ) - } - } - } - } -} - -@Composable -private fun AvatarSelectionSection( - availableAvatars: List, - selectedAvatarId: String, - onAvatarSelected: (String) -> Unit -) { - Column { - Text( - text = "Choose Default Avatar", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = colorScheme.onSurface, - modifier = Modifier.semantics { - contentDescription = "Avatar selection section" - } - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - LazyVerticalGrid( - columns = GridCells.Fixed(3), - horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.lg), - verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.lg), - modifier = Modifier.height(300.dp) // Fixed height to prevent layout issues - ) { - items(availableAvatars) { avatarId -> - EnhancedAvatarOption( - avatarId = avatarId, - isSelected = selectedAvatarId == avatarId, - onClick = { onAvatarSelected(avatarId) } - ) - } - } - } -} - -@Composable -private fun EnhancedAvatarOption( - avatarId: String, - isSelected: Boolean, - onClick: () -> Unit -) { - val context = LocalContext.current - val haptic = LocalHapticFeedback.current - - var scale by remember { mutableStateOf(1f) } - val animatedScale by animateFloatAsState( - targetValue = scale, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessHigh - ), - label = "avatarScale" - ) - - Card( - modifier = Modifier - .aspectRatio(1f) - .scale(animatedScale) - .semantics { - contentDescription = "Avatar option ${avatarId.replace("avatar_", "")}" - }, - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - scale = 0.95f - onClick() - }, - colors = CardDefaults.cardColors( - containerColor = if (isSelected) { - colorScheme.primaryContainer - } else { - colorScheme.surface - } - ), - border = if (isSelected) { - CardDefaults.outlinedCardBorder().copy( - width = 3.dp, - brush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary) - ) - } else { - CardDefaults.outlinedCardBorder().copy( - width = 1.dp, - brush = androidx.compose.ui.graphics.SolidColor(colorScheme.outline) - ) - }, - elevation = CardDefaults.cardElevation( - defaultElevation = if (isSelected) DesignTokens.Elevation.md else DesignTokens.Elevation.xs - ), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg) - ) { - // Selection indicator - AnimatedVisibility( - visible = isSelected, - enter = scaleIn(spring(stiffness = Spring.StiffnessHigh)) + fadeIn(), - exit = scaleOut(spring(stiffness = Spring.StiffnessHigh)) + fadeOut(), - modifier = Modifier.align(Alignment.End) - ) { - Surface( - modifier = Modifier - .padding(DesignTokens.Spacing.sm) - .size(24.dp), - shape = CircleShape, - color = colorScheme.primary, - shadowElevation = DesignTokens.Elevation.sm - ) { - Icon( - Icons.Default.Check, - contentDescription = "Selected", - modifier = Modifier - .fillMaxSize() - .padding(DesignTokens.Spacing.xs), - tint = colorScheme.onPrimary - ) - } - } - - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - AvatarUtils.AvatarImage( - base64String = AvatarUtils.getDefaultAvatarBase64(context, avatarId), - modifier = Modifier.size(56.dp) - ) - } - } - - // Reset scale after animation - LaunchedEffect(isSelected) { - if (scale != 1f) { - delay(150) - scale = 1f - } - } -} - -@Composable -private fun PrivacyNoticeSection() { - DropCard( - variant = DropCardVariant.Filled, - size = DropCardSize.Medium, - colors = CardDefaults.cardColors( - containerColor = colorScheme.surfaceVariant.copy(alpha = 0.5f), - contentColor = colorScheme.onSurfaceVariant - ), - contentDescription = "Privacy information about profile data" - ) { - DropCardContent(size = DropCardSize.Medium) { - Row( - verticalAlignment = Alignment.Top - ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier - .size(20.dp) - .padding(top = 2.dp), - tint = colorScheme.primary - ) - - Spacer(modifier = Modifier.width(DesignTokens.Spacing.md)) - - Column { - Text( - text = "Privacy & Security", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xs)) - - Text( - text = "Your profile information is only shared during file transfers and is stored locally on your device. Custom avatars are processed and stored securely without being uploaded to any server.", - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurfaceVariant, - lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.2 - ) - } - } - } - } -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/receive/Receive.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/receive/Receive.kt deleted file mode 100644 index 91504a0..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/receive/Receive.kt +++ /dev/null @@ -1,1762 +0,0 @@ -package dev.arkbuilders.drop.app.ui.receive - -import android.Manifest -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.CameraSelector -import androidx.camera.core.ExperimentalGetImage -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageProxy -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import androidx.navigation.NavController -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage -import compose.icons.TablerIcons -import compose.icons.tablericons.AlertCircle -import compose.icons.tablericons.ArrowForward -import compose.icons.tablericons.Camera -import compose.icons.tablericons.CameraOff -import compose.icons.tablericons.Qrcode -import dev.arkbuilders.drop.app.R -import dev.arkbuilders.drop.app.TransferManager -import dev.arkbuilders.drop.app.data.ReceiveFileInfo -import dev.arkbuilders.drop.app.data.ReceivingProgress -import dev.arkbuilders.drop.app.ui.profile.AvatarUtils -import dev.arkbuilders.drop.app.ui.theme.DesignTokens -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -sealed class ReceiveError(val message: String, val isRecoverable: Boolean = true) { - object CameraPermissionDenied : - ReceiveError("Camera permission is required to scan QR codes", true) - - object CameraInitializationFailed : - ReceiveError("Unable to initialize camera. Please try again.", true) - - object InvalidQRCode : - ReceiveError("This QR code is not from Drop. Please scan a valid Drop QR code.", true) - - object InvalidManualInput : - ReceiveError("Invalid format. Please enter: ticket confirmation", true) - - object ConnectionFailed : - ReceiveError("Unable to connect to sender. Please ensure you're on the same network.", true) - - object TransferInterrupted : - ReceiveError("File transfer was interrupted. Please try again.", true) - - object NoFilesReceived : ReceiveError("No files were received from the sender.", true) - object StorageError : - ReceiveError("Unable to save files. Please check your storage permissions.", true) - - object NetworkError : - ReceiveError("Network connection lost. Please check your connection and try again.", true) - - object UnknownError : ReceiveError("An unexpected error occurred. Please try again.", true) -} - -sealed class ReceiveWorkflowState { - object Initial : ReceiveWorkflowState() - object RequestingPermission : ReceiveWorkflowState() - object Scanning : ReceiveWorkflowState() - object ManualInput : ReceiveWorkflowState() - object QRCodeScanned : ReceiveWorkflowState() - object Connecting : ReceiveWorkflowState() - object Receiving : ReceiveWorkflowState() - object Success : ReceiveWorkflowState() - data class Error(val error: ReceiveError) : ReceiveWorkflowState() -} - -@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) -@Composable -fun Receive( - navController: NavController, - transferManager: TransferManager -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val clipboardManager = LocalClipboardManager.current - val keyboardController = LocalSoftwareKeyboardController.current - - var workflowState by remember { - val receiveProgress = transferManager.receiveProgress?.value - if (receiveProgress != null && receiveProgress.isConnected) { - mutableStateOf(ReceiveWorkflowState.Receiving) - } else { - mutableStateOf(ReceiveWorkflowState.Initial) - } - } - var scannedTicket by remember { mutableStateOf(null) } - var scannedConfirmation by remember { mutableStateOf(null) } - var manualInputText by remember { mutableStateOf("") } - var manualInputError by remember { mutableStateOf(null) } - var receivedFiles by remember { mutableStateOf>(emptyList()) } - var showSuccessAnimation by remember { mutableStateOf(false) } - - val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) - - val receiveProgress by (transferManager.receiveProgress?.collectAsState() - ?: remember { mutableStateOf(null) }) - - val requestPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted -> - if (isGranted) { - workflowState = ReceiveWorkflowState.Scanning - } else { - workflowState = ReceiveWorkflowState.Error(ReceiveError.CameraPermissionDenied) - } - } - - // Function to parse manual input - fun parseManualInput(input: String): Pair? { - return try { - val trimmed = input.trim() - val parts = trimmed.split(" ") - - if (parts.size == 2) { - val ticket = parts[0].trim() - val confirmation = parts[1].trim().toUByte() - - if (ticket.isNotEmpty()) { - Pair(ticket, confirmation) - } else { - null - } - } else { - null - } - } catch (e: Exception) { - null - } - } - - // Function to handle manual input submission - fun handleManualInputSubmit() { - val parsed = parseManualInput(manualInputText) - if (parsed != null) { - scannedTicket = parsed.first - scannedConfirmation = parsed.second - workflowState = ReceiveWorkflowState.QRCodeScanned - manualInputError = null - keyboardController?.hide() - } else { - manualInputError = "Invalid format. Please enter: ticket confirmation" - } - } - - // Function to paste from clipboard - fun pasteFromClipboard() { - val clipText = clipboardManager.getText()?.text - if (!clipText.isNullOrEmpty()) { - manualInputText = clipText - manualInputError = null - } - } - - // Monitor receive progress and handle completion - LaunchedEffect(receiveProgress) { - receiveProgress?.let { progress -> - // Check if we're connected and have files - if (progress.isConnected && progress.files.isNotEmpty()) { - // Check if all files are complete - val allFilesComplete = progress.files.all { file -> - val fileProgress = progress.fileProgress[file.id] - fileProgress?.isComplete == true - } - - if (allFilesComplete) { - // Small delay to ensure UI updates are visible - delay(1000) - try { - val savedFiles = transferManager.saveReceivedFiles() - if (savedFiles.isNotEmpty()) { - receivedFiles = savedFiles.map { it.name } - workflowState = ReceiveWorkflowState.Success - showSuccessAnimation = true - } else { - workflowState = ReceiveWorkflowState.Error(ReceiveError.NoFilesReceived) - } - } catch (e: Exception) { - workflowState = ReceiveWorkflowState.Error( - when { - e.message?.contains("storage", ignoreCase = true) == true -> - ReceiveError.StorageError - - e.message?.contains("network", ignoreCase = true) == true -> - ReceiveError.NetworkError - - else -> ReceiveError.UnknownError - } - ) - } - } - } - } - } - - LaunchedEffect(showSuccessAnimation) { - if (showSuccessAnimation) { - delay(3000) - showSuccessAnimation = false - } - } - - val successScale by animateFloatAsState( - targetValue = if (showSuccessAnimation) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "successScale" - ) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(DesignTokens.Spacing.lg) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Surface( - onClick = { navController.navigateUp() }, - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.size(DesignTokens.TouchTarget.minimum) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - } - } - - Spacer(modifier = Modifier.width(DesignTokens.Spacing.md)) - - Icon( - modifier = Modifier.size(32.dp), - painter = painterResource(R.drawable.ic_logo), - contentDescription = null, - tint = Color.Unspecified, - ) - - Spacer(modifier = Modifier.width(DesignTokens.Spacing.md)) - - Text( - text = "Receive Files", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.onSurface - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - AnimatedVisibility( - visible = showSuccessAnimation, - enter = scaleIn( - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ) - ) + fadeIn(), - exit = scaleOut( - animationSpec = tween(DesignTokens.Animation.normal) - ) + fadeOut() - ) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .scale(successScale), - shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.xl) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.xxl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(80.dp) - .background( - brush = Brush.radialGradient( - colors = listOf( - MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - Color.Transparent - ) - ), - shape = CircleShape - ) - .clip(CircleShape), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Success", - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Text( - text = "Files Received!", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.sm)) - - Text( - text = "All files have been successfully saved to your Downloads folder.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.2 - ) - } - } - } - } - - AnimatedContent( - targetState = workflowState, - transitionSpec = { - slideInVertically( - initialOffsetY = { it / 3 }, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) - ) + fadeIn() togetherWith - slideOutVertically( - targetOffsetY = { -it / 3 }, - animationSpec = tween(DesignTokens.Animation.fast) - ) + fadeOut() - }, - label = "workflowStateTransition" - ) { state -> - when (state) { - is ReceiveWorkflowState.Initial -> { - if (!cameraPermissionState.status.isGranted) { - PermissionRequestCard( - onRequestPermission = { - workflowState = ReceiveWorkflowState.RequestingPermission - requestPermissionLauncher.launch(Manifest.permission.CAMERA) - }, - onEnterManually = { - workflowState = ReceiveWorkflowState.ManualInput - } - ) - } else { - ReadyToScanCard( - onStartScanning = { workflowState = ReceiveWorkflowState.Scanning }, - onEnterManually = { workflowState = ReceiveWorkflowState.ManualInput } - ) - } - } - - is ReceiveWorkflowState.RequestingPermission -> { - LoadingCard(message = "Requesting camera permission...") - } - - is ReceiveWorkflowState.Scanning -> { - ScanningCard( - onQRCodeScanned = { ticket, confirmation -> - scannedTicket = ticket - scannedConfirmation = confirmation - workflowState = ReceiveWorkflowState.QRCodeScanned - }, - onError = { error -> - workflowState = ReceiveWorkflowState.Error(error) - }, - onStopScanning = { workflowState = ReceiveWorkflowState.Initial }, - onEnterManually = { workflowState = ReceiveWorkflowState.ManualInput } - ) - } - - is ReceiveWorkflowState.ManualInput -> { - ManualInputCard( - inputText = manualInputText, - onInputChange = { - manualInputText = it - manualInputError = null - }, - inputError = manualInputError, - onPasteFromClipboard = { pasteFromClipboard() }, - onSubmit = { handleManualInputSubmit() }, - onCancel = { - workflowState = ReceiveWorkflowState.Initial - manualInputText = "" - manualInputError = null - keyboardController?.hide() - } - ) - } - - is ReceiveWorkflowState.QRCodeScanned -> { - QRCodeScannedCard( - onAccept = { - scope.launch { - try { - workflowState = ReceiveWorkflowState.Connecting - val ticket = scannedTicket!! - val confirmation = scannedConfirmation!! - - val bubble = transferManager.receiveFiles(ticket, confirmation) - if (bubble != null) { - workflowState = ReceiveWorkflowState.Receiving - } else { - workflowState = - ReceiveWorkflowState.Error(ReceiveError.ConnectionFailed) - } - } catch (e: Exception) { - workflowState = ReceiveWorkflowState.Error( - when { - e.message?.contains( - "network", - ignoreCase = true - ) == true -> ReceiveError.NetworkError - - else -> ReceiveError.ConnectionFailed - } - ) - } - } - }, - onScanAgain = { - scannedTicket = null - scannedConfirmation = null - manualInputText = "" - manualInputError = null - if (cameraPermissionState.status.isGranted) { - workflowState = ReceiveWorkflowState.Scanning - } else { - workflowState = ReceiveWorkflowState.ManualInput - } - } - ) - } - - is ReceiveWorkflowState.Connecting -> { - LoadingCard(message = "Connecting to sender...") - } - - is ReceiveWorkflowState.Receiving -> { - receiveProgress?.let { progress -> - ReceivingCard( - progress = progress, - onCancel = { - transferManager.cancelReceive() - workflowState = ReceiveWorkflowState.Initial - scannedTicket = null - scannedConfirmation = null - manualInputText = "" - manualInputError = null - } - ) - } - } - - is ReceiveWorkflowState.Success -> { - if (!showSuccessAnimation) { - TransferCompleteCard( - receivedFiles = receivedFiles, - onReceiveMore = { - receivedFiles = emptyList() - workflowState = ReceiveWorkflowState.Initial - scannedTicket = null - scannedConfirmation = null - manualInputText = "" - manualInputError = null - transferManager.cancelReceive() - }, - onDone = { - transferManager.cancelReceive() - navController.navigateUp() - } - ) - } - } - - is ReceiveWorkflowState.Error -> { - ErrorCard( - error = state.error, - onRetry = { - workflowState = ReceiveWorkflowState.Initial - scannedTicket = null - scannedConfirmation = null - manualInputText = "" - manualInputError = null - }, - onDismiss = { - transferManager.cancelReceive() - navController.navigateUp() - - } - ) - } - } - } - - if (workflowState !is ReceiveWorkflowState.Success && workflowState !is ReceiveWorkflowState.Error) { - Spacer(modifier = Modifier.weight(1f)) - - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.lg) - ) { - Text( - text = "How to receive files:", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - val steps = listOf( - "Ask the sender to start a transfer", - "Scan QR code OR enter transfer code manually", - "Accept the transfer", - "Files will be saved to your Downloads folder" - ) - - steps.forEachIndexed { index, step -> - Row( - verticalAlignment = Alignment.Top, - modifier = Modifier.padding(vertical = 2.dp) - ) { - Text( - text = "${index + 1}.", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.sm)) - Text( - text = step, - style = MaterialTheme.typography.bodyMedium, - lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2 - ) - } - } - } - } - } - } -} - -@Composable -private fun PermissionRequestCard( - onRequestPermission: () -> Unit, - onEnterManually: () -> Unit -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.lg) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.xxl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(80.dp) - .background( - color = MaterialTheme.colorScheme.primaryContainer, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - TablerIcons.Camera, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Text( - text = "Camera Permission Required", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - Text( - text = "We need camera access to scan QR codes for receiving files. Your privacy is protected - we only use the camera for QR code scanning.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3 - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - Button( - onClick = onRequestPermission, - modifier = Modifier - .fillMaxWidth() - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text( - "Grant Permission", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - Text( - text = "Or", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - OutlinedButton( - onClick = onEnterManually, - modifier = Modifier - .fillMaxWidth() - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg) - ) { - Icon( - TablerIcons.ArrowForward, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.sm)) - Text( - "Enter Code Manually", - fontWeight = FontWeight.Medium - ) - } - } - } -} - -@Composable -private fun ReadyToScanCard( - onStartScanning: () -> Unit, - onEnterManually: () -> Unit -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.lg) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.xxl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(80.dp) - .background( - brush = Brush.radialGradient( - colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) - ) - ), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - TablerIcons.Qrcode, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Text( - text = "Ready to Receive", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - Text( - text = "Scan the QR code from the sender's device or enter the transfer code manually to start receiving files securely.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3 - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - Button( - onClick = onStartScanning, - modifier = Modifier - .fillMaxWidth() - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon( - TablerIcons.Camera, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.sm)) - Text( - "Start Scanning", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - Text( - text = "Or", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - OutlinedButton( - onClick = onEnterManually, - modifier = Modifier - .fillMaxWidth() - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg) - ) { - Icon( - TablerIcons.ArrowForward, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.sm)) - Text( - "Enter Code Manually", - fontWeight = FontWeight.Medium - ) - } - } - } -} - -@Composable -private fun LoadingCard( - message: String -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.md) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(DesignTokens.Spacing.xl), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 3.dp, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.lg)) - Text( - text = message, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium - ) - } - } -} - -@Composable -private fun ScanningCard( - onQRCodeScanned: (String, UByte) -> Unit, - onError: (ReceiveError) -> Unit, - onStopScanning: () -> Unit, - onEnterManually: () -> Unit -) { - Column { - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.lg) - ) { - QRCodeScanner( - onQRCodeScanned = onQRCodeScanned, - onError = onError - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - Text( - text = "Point your camera at the QR code", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) - ) { - OutlinedButton( - onClick = onStopScanning, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg) - ) { - Icon( - TablerIcons.CameraOff, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.sm)) - Text( - "Stop Scanning", - fontWeight = FontWeight.Medium - ) - } - - Button( - onClick = onEnterManually, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg) - ) { - Icon( - TablerIcons.ArrowForward, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.sm)) - Text( - "Enter Code", - fontWeight = FontWeight.Medium - ) - } - } - } -} - -@Composable -private fun ManualInputCard( - inputText: String, - onInputChange: (String) -> Unit, - inputError: String?, - onPasteFromClipboard: () -> Unit, - onSubmit: () -> Unit, - onCancel: () -> Unit -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.lg) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.xxl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(80.dp) - .background( - color = MaterialTheme.colorScheme.primaryContainer, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - TablerIcons.ArrowForward, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Text( - text = "Enter Transfer Code", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - Text( - text = "Paste or type the transfer code from the sender in the format: ticket confirmation", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3 - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - OutlinedTextField( - value = inputText, - onValueChange = onInputChange, - label = { Text("Transfer Code") }, - placeholder = { Text("ticket confirmation") }, - modifier = Modifier.fillMaxWidth(), - isError = inputError != null, - supportingText = inputError?.let { error -> - { Text(error, color = MaterialTheme.colorScheme.error) } - }, - trailingIcon = { - IconButton(onClick = onPasteFromClipboard) { - Icon( - TablerIcons.ArrowForward, - contentDescription = "Paste", - tint = MaterialTheme.colorScheme.primary - ) - } - }, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { onSubmit() } - ), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg) - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) - ) { - OutlinedButton( - onClick = onCancel, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg) - ) { - Text( - "Cancel", - fontWeight = FontWeight.Medium - ) - } - - Button( - onClick = onSubmit, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - enabled = inputText.trim().isNotEmpty() - ) { - Text( - "Connect", - fontWeight = FontWeight.SemiBold - ) - } - } - } - } -} - -@Composable -private fun QRCodeScannedCard( - onAccept: () -> Unit, - onScanAgain: () -> Unit -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(DesignTokens.CornerRadius.xl), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.lg) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.xxl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(80.dp) - .background( - color = MaterialTheme.colorScheme.primaryContainer, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Text( - text = "Code Received!", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - Text( - text = "Ready to receive files from sender. Tap Accept to start the transfer.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.2 - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) - ) { - OutlinedButton( - onClick = onScanAgain, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg) - ) { - Text( - "Try Again", - fontWeight = FontWeight.Medium - ) - } - - Button( - onClick = onAccept, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text( - "Accept", - fontWeight = FontWeight.SemiBold - ) - } - } - } - } -} - -@Composable -private fun ReceivingCard( - progress: ReceivingProgress, - onCancel: () -> Unit -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.lg) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.lg) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (progress.isConnected) "Receiving Files..." else "Connecting...", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Surface( - onClick = onCancel, - shape = CircleShape, - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - modifier = Modifier.size(40.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - Icon( - Icons.Default.Close, - contentDescription = "Cancel", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp) - ) - } - } - } - - if (progress.isConnected) { - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) - ) { - AvatarUtils.AvatarImageWithFallback( - base64String = progress.senderAvatar, - fallbackText = progress.senderName, - size = 36.dp - ) - - Column { - Text( - text = "From:", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) - ) - Text( - text = progress.senderName, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Text( - text = "Files (${progress.files.size}):", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.md)) - - LazyColumn( - modifier = Modifier.heightIn(max = 280.dp), - verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.sm) - ) { - items(progress.files) { file -> - val fileProgress = progress.fileProgress[file.id] - ReceivingFileItem( - file = file, - progress = if (file.size > 0UL && fileProgress != null) { - (fileProgress.receivedBytes.toFloat() / file.size.toFloat()).coerceIn(0f, 1f) - } else 0f, - receivedBytes = fileProgress?.receivedBytes ?: 0L, - isComplete = fileProgress?.isComplete ?: false - ) - } - } - } else { - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(48.dp), - strokeWidth = 4.dp, - color = MaterialTheme.colorScheme.primary - ) - } - } - } - } -} - -@Composable -private fun TransferCompleteCard( - receivedFiles: List, - onReceiveMore: () -> Unit, - onDone: () -> Unit -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.lg) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.xl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(80.dp) - .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Complete", - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Text( - text = "Files Received Successfully!", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.sm)) - - Text( - text = "${receivedFiles.size} file${if (receivedFiles.size != 1) "s" else ""} saved to Downloads", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f) - ) - - if (receivedFiles.isNotEmpty()) { - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ), - shape = RoundedCornerShape(DesignTokens.CornerRadius.md) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.lg) - ) { - Text( - text = "Received Files:", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(DesignTokens.Spacing.sm)) - - // Show first 3 files, then "... and X more" if needed - receivedFiles.take(3).forEach { fileName -> - Text( - text = "• $fileName", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - if (receivedFiles.size > 3) { - Text( - text = "• ... and ${receivedFiles.size - 3} more", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - fontWeight = FontWeight.Medium - ) - } - } - } - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) - ) { - OutlinedButton( - onClick = onReceiveMore, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.md) - ) { - Icon( - Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(DesignTokens.Spacing.sm)) - Text("Receive More", fontWeight = FontWeight.Medium) - } - - Button( - onClick = onDone, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.md) - ) { - Text("Done", fontWeight = FontWeight.Medium) - } - } - } - } -} - -@Composable -private fun ErrorCard( - error: ReceiveError, - onRetry: () -> Unit, - onDismiss: () -> Unit -) { - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(DesignTokens.CornerRadius.lg), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.lg) - ) { - Column( - modifier = Modifier.padding(DesignTokens.Spacing.xl), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(80.dp) - .background( - color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - if (error.isRecoverable) Icons.Default.Warning else TablerIcons.AlertCircle, - contentDescription = null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.error - ) - } - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) - - Text( - text = if (error.isRecoverable) "Something went wrong" else "Error occurred", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.sm)) - - Text( - text = error.message, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.2 - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) - ) { - OutlinedButton( - onClick = onDismiss, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.md) - ) { - Text("Cancel", fontWeight = FontWeight.Medium) - } - - if (error.isRecoverable) { - Button( - onClick = onRetry, - modifier = Modifier - .weight(1f) - .height(DesignTokens.TouchTarget.comfortable), - shape = RoundedCornerShape(DesignTokens.CornerRadius.md), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text("Try Again", fontWeight = FontWeight.SemiBold) - } - } - } - } - } -} - -@Composable -private fun ReceivingFileItem( - file: ReceiveFileInfo, - progress: Float, - receivedBytes: Long, - isComplete: Boolean -) { - - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - ), - shape = RoundedCornerShape(DesignTokens.CornerRadius.md), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = DesignTokens.Elevation.xs) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(DesignTokens.Spacing.lg), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = file.name, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Spacer(modifier = Modifier.height(DesignTokens.Spacing.sm)) - - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .clip(RoundedCornerShape(3.dp)), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - ) - - Spacer(modifier = Modifier.height(6.dp)) - - Text( - text = "${formatBytes(receivedBytes)} / ${formatBytes(file.size.toLong())}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (isComplete) { - Spacer(modifier = Modifier.width(DesignTokens.Spacing.md)) - Box( - modifier = Modifier - .size(32.dp) - .background( - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Complete", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - } - } - } - } -} - -@androidx.annotation.OptIn(ExperimentalGetImage::class) -@Composable -private fun QRCodeScanner( - onQRCodeScanned: (String, UByte) -> Unit, - onError: (ReceiveError) -> Unit -) { - val context = LocalContext.current - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() } - - AndroidView( - factory = { ctx -> - val previewView = PreviewView(ctx) - val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) - - cameraProviderFuture.addListener({ - try { - val cameraProvider = cameraProviderFuture.get() - - val preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - - val imageAnalyzer = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { - it.setAnalyzer(cameraExecutor) { imageProxy -> - processImageProxy(imageProxy, onQRCodeScanned, onError) - } - } - - val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - preview, - imageAnalyzer - ) - } catch (exc: Exception) { - onError(ReceiveError.CameraInitializationFailed) - } - }, ContextCompat.getMainExecutor(ctx)) - - previewView - }, - modifier = Modifier.fillMaxSize() - ) - - DisposableEffect(Unit) { - onDispose { - cameraExecutor.shutdown() - } - } -} - -@ExperimentalGetImage -private fun processImageProxy( - imageProxy: ImageProxy, - onQRCodeScanned: (String, UByte) -> Unit, - onError: (ReceiveError) -> Unit -) { - val mediaImage = imageProxy.image - if (mediaImage != null) { - val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - val scanner = BarcodeScanning.getClient() - - scanner.process(image) - .addOnSuccessListener { barcodes -> - for (barcode in barcodes) { - when (barcode.valueType) { - Barcode.TYPE_TEXT, Barcode.TYPE_URL -> { - barcode.rawValue?.let { value -> - // Parse Drop QR code format: drop://receive?ticket=...&confirmation=... - if (value.startsWith("drop://receive?")) { - try { - val uri = value.toUri() - val ticket = uri.getQueryParameter("ticket") - val confirmationStr = uri.getQueryParameter("confirmation") - - if (ticket != null && confirmationStr != null) { - val confirmation = confirmationStr.toUByte() - onQRCodeScanned(ticket, confirmation) - return@addOnSuccessListener - } - } catch (_: Exception) { - onError(ReceiveError.InvalidQRCode) - return@addOnSuccessListener - } - } else { - onError(ReceiveError.InvalidQRCode) - return@addOnSuccessListener - } - } - } - } - } - } - .addOnFailureListener { exception -> - onError( - when { - exception.message?.contains( - "camera", - ignoreCase = true - ) == true -> ReceiveError.CameraInitializationFailed - - else -> ReceiveError.UnknownError - } - ) - } - .addOnCompleteListener { - imageProxy.close() - } - } else { - imageProxy.close() - } -} - -private fun formatBytes(bytes: Long): String { - if (bytes < 1024) return "$bytes B" - val kb = bytes / 1024.0 - if (kb < 1024) return "%.1f KB".format(kb) - val mb = kb / 1024.0 - if (mb < 1024) return "%.1f MB".format(mb) - val gb = mb / 1024.0 - return "%.1f GB".format(gb) -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/send/Send.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/send/Send.kt deleted file mode 100644 index 14de362..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/send/Send.kt +++ /dev/null @@ -1,1935 +0,0 @@ -package dev.arkbuilders.drop.app.ui.send - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.graphics.createBitmap -import androidx.core.graphics.set -import androidx.navigation.NavController -import com.google.zxing.BarcodeFormat -import com.google.zxing.WriterException -import com.google.zxing.common.BitMatrix -import com.google.zxing.qrcode.QRCodeWriter -import compose.icons.TablerIcons -import compose.icons.tablericons.AlertCircle -import compose.icons.tablericons.CloudUpload -import compose.icons.tablericons.Copy -import compose.icons.tablericons.FileText -import compose.icons.tablericons.Plus -import compose.icons.tablericons.Qrcode -import dev.arkbuilders.drop.app.R -import dev.arkbuilders.drop.app.TransferManager -import dev.arkbuilders.drop.app.ui.profile.AvatarUtils -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -private enum class SendPhase { - FileSelection, - GeneratingQR, - WaitingForReceiver, - Transferring, - Complete, - Error -} - -private data class SendState( - val phase: SendPhase = SendPhase.FileSelection, - val isLoading: Boolean = false, - val error: SendException? = null, - val transferProgress: TransferProgressState? = null, - val qrBitmap: Bitmap? = null, - val showQRDialog: Boolean = false, - val showSuccessAnimation: Boolean = false, - val successCountdown: Int = 0, - val networkConnected: Boolean = true, - val copyString: String? = null -) - -// Comprehensive exception handling -sealed class SendException( - val title: String, - val message: String, - val icon: ImageVector, - val isRecoverable: Boolean = true, - val actionLabel: String? = null -) { - object NetworkUnavailable : SendException( - title = "No Network Connection", - message = "Please check your Wi-Fi or mobile data connection and try again.", - icon = Icons.Default.Warning, - actionLabel = "Retry" - ) - - object FileTooLarge : SendException( - title = "File Too Large", - message = "Some files exceed the 2GB limit and were skipped. You can send the remaining files.", - icon = Icons.Default.Warning, - actionLabel = "Continue" - ) - - object NoFilesSelected : SendException( - title = "No Files Selected", - message = "Please select at least one file to send.", - icon = Icons.Default.Warning, - isRecoverable = false - ) - - object TransferInitializationFailed : SendException( - title = "Transfer Setup Failed", - message = "Unable to prepare files for transfer. Please try again.", - icon = TablerIcons.AlertCircle, - actionLabel = "Retry" - ) - - object QRGenerationFailed : SendException( - title = "QR Code Generation Failed", - message = "Unable to create QR code. Please restart the transfer.", - icon = TablerIcons.AlertCircle, - actionLabel = "Retry" - ) - - object TransferInterrupted : SendException( - title = "Transfer Interrupted", - message = "The connection was lost during transfer. You can try sending again.", - icon = TablerIcons.AlertCircle, - actionLabel = "Retry" - ) - - object ReceiverDisconnected : SendException( - title = "Receiver Disconnected", - message = "The receiving device disconnected. Please try again.", - icon = Icons.Default.Warning, - actionLabel = "Retry" - ) - - class UnknownError(details: String) : SendException( - title = "Something Went Wrong", - message = "An unexpected error occurred: $details", - icon = TablerIcons.AlertCircle, - actionLabel = "Retry" - ) -} - -data class TransferProgressState( - val isConnected: Boolean = false, - val receiverName: String = "", - val receiverAvatar: String? = null, - val currentFileName: String = "", - val filesCompleted: Int = 0, - val totalFiles: Int = 0, - val bytesTransferred: Long = 0L, - val totalBytes: Long = 0L, - val transferSpeedBps: Long = 0L, - val estimatedTimeRemaining: Long = 0L -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun Send( - navController: NavController, - transferManager: TransferManager -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val haptic = LocalHapticFeedback.current - val listState = rememberLazyListState() - - // State management - var sendState by remember { mutableStateOf(SendState()) } - var selectedFiles by rememberSaveable { mutableStateOf>(emptyList()) } - - // Observe transfer progress with error handling - val sendProgress by (transferManager.sendProgress?.collectAsState() - ?: remember { mutableStateOf(null) }) - - // Derived states - val totalFileSize by remember { - derivedStateOf { - selectedFiles.sumOf { uri -> - getFileSize(context, uri) - } - } - } - - val canStartTransfer by remember { - derivedStateOf { - selectedFiles.isNotEmpty() && - sendState.phase == SendPhase.FileSelection && - !sendState.isLoading && - sendState.networkConnected - } - } - - // File picker with comprehensive validation - val filePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetMultipleContents() - ) { uris -> - if (uris.isNotEmpty()) { - try { - val validatedFiles = validateAndFilterFiles(context, uris) - - if (validatedFiles.skippedCount > 0) { - sendState = sendState.copy( - error = SendException.FileTooLarge - ) - } - - selectedFiles = selectedFiles + validatedFiles.validFiles - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - - } catch (e: Exception) { - sendState = sendState.copy( - error = SendException.UnknownError("File validation failed: ${e.message}") - ) - } - } - } - - val transferStatus by produceState( - initialValue = false, - key1 = sendState.phase - ) { - if (sendState.phase == SendPhase.Transferring) { - while (currentCoroutineContext().isActive) { - delay(700) - val isFinished = transferManager.isSendFinished() - value = isFinished - if (isFinished) { - break - } - } - } - } - - // Handle transfer completion - LaunchedEffect(transferStatus) { - if (transferStatus && sendState.phase == SendPhase.Transferring) { - sendState = sendState.copy( - phase = SendPhase.Complete, - showSuccessAnimation = true, - successCountdown = 3000, - error = null - ) - try { - transferManager.recordSendCompletion(selectedFiles) - } catch (e: Exception) { - println("Failed to record completion: ${e.message}") - } - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - } - - // Network monitoring - LaunchedEffect(Unit) { - while (true) { - val isConnected = checkNetworkConnection(context) - if (sendState.networkConnected != isConnected) { - sendState = sendState.copy(networkConnected = isConnected) - - if (!isConnected && sendState.phase in listOf( - SendPhase.WaitingForReceiver, SendPhase.Transferring - ) - ) { - sendState = sendState.copy( - phase = SendPhase.Error, error = SendException.NetworkUnavailable - ) - } - } - delay(2000) - } - } - - // Transfer progress monitoring with error handling - LaunchedEffect(sendProgress) { - sendProgress?.let { progress -> - try { - val progressState = TransferProgressState( - isConnected = progress.isConnected, - receiverName = progress.receiverName, - receiverAvatar = progress.receiverAvatar, - currentFileName = progress.fileName, - bytesTransferred = progress.sent.toLong(), - totalBytes = (progress.sent + progress.remaining).toLong(), - transferSpeedBps = calculateTransferSpeed(progress.sent.toLong()), - estimatedTimeRemaining = calculateETA( - progress.sent.toLong(), progress.remaining.toLong() - ) - ) - when { - progress.isConnected && sendState.phase == SendPhase.WaitingForReceiver -> { - sendState = sendState.copy( - phase = SendPhase.Transferring, - transferProgress = progressState, - showQRDialog = false, - error = null - ) - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - - else -> { - sendState = sendState.copy(transferProgress = progressState) - } - } - } catch (e: Exception) { - val stacktrace = e.stackTraceToString() - println("DEBUG: $stacktrace") - if (!stacktrace.startsWith("androidx.compose.runtime.LeftCompositionCancellationException:")) { - sendState = sendState.copy( - phase = SendPhase.Error, - error = SendException.UnknownError("Progress monitoring failed: ${e.message}") - ) - } - } - } - } - -// Success animation countdown - LaunchedEffect(sendState.successCountdown) { - if (sendState.successCountdown > 0) { - delay(1000) - sendState = sendState.copy(successCountdown = sendState.successCountdown - 1000) - } - } - -// Core functions - val startTransfer = { - if (canStartTransfer) { - scope.launch { - try { - sendState = sendState.copy( - phase = SendPhase.GeneratingQR, isLoading = true, error = null - ) - - val bubble = transferManager.sendFiles(selectedFiles) - if (bubble != null) { - val ticket = transferManager.getCurrentSendTicket() ?: "" - val confirmation = transferManager.getCurrentSendConfirmation() ?: 0u - - if (ticket.isEmpty()) { - throw Exception("Invalid transfer ticket") - } - - val copyString = "${bubble.getTicket()} ${bubble.getConfirmation()}" - - val qrBitmap = generateQRCodeSafely(ticket, confirmation) - sendState = sendState.copy( - phase = SendPhase.WaitingForReceiver, - isLoading = false, - qrBitmap = qrBitmap, - showQRDialog = true, - copyString = copyString - ) - - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } else { - throw Exception("Transfer initialization returned null") - } - } catch (e: Exception) { - sendState = sendState.copy( - phase = SendPhase.Error, isLoading = false, error = when { - e.message?.contains("QR") == true -> SendException.QRGenerationFailed - e.message?.contains("network") == true -> SendException.NetworkUnavailable - else -> SendException.TransferInitializationFailed - } - ) - } - } - } else if (selectedFiles.isEmpty()) { - sendState = sendState.copy(error = SendException.NoFilesSelected) - } else if (!sendState.networkConnected) { - sendState = sendState.copy(error = SendException.NetworkUnavailable) - } - Unit - } - - val cancelTransfer = { - try { - transferManager.cancelSend() - sendState = SendState(networkConnected = sendState.networkConnected) - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } catch (e: Exception) { - // Silent fail for cancel operation - } - } - - val resetForNewTransfer = { - selectedFiles = emptyList() - sendState = SendState(networkConnected = sendState.networkConnected) - transferManager.cancelSend() - } - - val handleError = { action: String -> - when (action) { - "Retry" -> { - sendState = sendState.copy(error = null, phase = SendPhase.FileSelection) - } - - "Continue" -> { - sendState = sendState.copy(error = null) - } - } - } - - Scaffold( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.statusBars) - .windowInsetsPadding(WindowInsets.navigationBars) - .windowInsetsPadding(WindowInsets.ime), - topBar = { - SendTopBar( - onBackClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - navController.navigateUp() - }, networkConnected = sendState.networkConnected - ) - }, - containerColor = MaterialTheme.colorScheme.background - ) { paddingValues -> - - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - // Main content with phase-based transitions - AnimatedContent( - targetState = sendState.phase, transitionSpec = { - slideInVertically( - initialOffsetY = { it / 3 }, animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ) - ) + fadeIn(animationSpec = tween(300)) togetherWith slideOutVertically( - targetOffsetY = { -it / 3 }, animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ) - ) + fadeOut(animationSpec = tween(200)) - }, label = "phaseTransition" - ) { phase -> - when (phase) { - SendPhase.FileSelection -> { - FileSelectionPhase( - selectedFiles = selectedFiles, - totalFileSize = totalFileSize, - onAddFiles = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - filePickerLauncher.launch("*/*") - }, - onRemoveFile = { uri -> - selectedFiles = selectedFiles - uri - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - }, - onStartTransfer = startTransfer, - canStartTransfer = canStartTransfer, - isLoading = sendState.isLoading, - networkConnected = sendState.networkConnected, - listState = listState - ) - } - - SendPhase.GeneratingQR -> { - GeneratingQRPhase(onCancel = cancelTransfer) - } - - SendPhase.WaitingForReceiver -> { - WaitingForReceiverPhase( - fileCount = selectedFiles.size, onCancel = cancelTransfer - ) - } - - SendPhase.Transferring -> { - TransferringPhase( - progress = sendState.transferProgress, onCancel = cancelTransfer - ) - } - - SendPhase.Complete -> { - TransferCompletePhase( - fileCount = selectedFiles.size, - onSendMore = resetForNewTransfer, - onDone = { - transferManager.cancelSend() - navController.navigateUp() - }, - showSuccessAnimation = sendState.showSuccessAnimation, - successCountdown = sendState.successCountdown - ) - } - - SendPhase.Error -> { - ErrorPhase( - error = sendState.error, - onRetry = { handleError("Retry") }, - onCancel = { - transferManager.cancelSend() - navController.navigateUp() - }) - } - } - } - - sendState.error?.let { error -> - if (sendState.phase != SendPhase.Error) { - SendErrorOverlay( - error = error, - onDismiss = { sendState = sendState.copy(error = null) }, - onAction = { action -> handleError(action) }) - } - } - - // QR Code Dialog - if (sendState.showQRDialog && sendState.qrBitmap != null) { - SendQRDialog( - qrBitmap = sendState.qrBitmap!!, - fileCount = selectedFiles.size, - copyString = sendState.copyString, - onDismiss = { sendState = sendState.copy(showQRDialog = false) }, - onCancel = cancelTransfer - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SendTopBar( - onBackClick: () -> Unit, networkConnected: Boolean -) { - TopAppBar( - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(R.drawable.ic_logo), - contentDescription = null, - tint = Color.Unspecified, - ) - Text( - text = "Send Files", style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.SemiBold, fontSize = 20.sp - ), color = MaterialTheme.colorScheme.onSurface - ) - } - }, navigationIcon = { - IconButton( - onClick = onBackClick, - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .semantics { contentDescription = "Go back" }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp) - ) - } - }, actions = { - // Network status indicator - NetworkStatusIndicator( - connected = networkConnected, modifier = Modifier.padding(end = 8.dp) - ) - }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), - titleContentColor = MaterialTheme.colorScheme.onSurface - ) - ) -} - -@Composable -private fun NetworkStatusIndicator( - connected: Boolean, modifier: Modifier = Modifier -) { - val alpha by animateFloatAsState( - targetValue = if (connected) 0.7f else 1f, - animationSpec = tween(300), - label = "networkAlpha" - ) - - Icon( - imageVector = if (connected) TablerIcons.CloudUpload else Icons.Default.Warning, - contentDescription = if (connected) "Network connected" else "Network disconnected", - modifier = modifier - .size(20.dp) - .alpha(alpha), - tint = if (connected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.error - ) -} - -@Composable -private fun FileSelectionPhase( - selectedFiles: List, - totalFileSize: Long, - onAddFiles: () -> Unit, - onRemoveFile: (Uri) -> Unit, - onStartTransfer: () -> Unit, - canStartTransfer: Boolean, - isLoading: Boolean, - networkConnected: Boolean, - listState: androidx.compose.foundation.lazy.LazyListState -) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(20.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - // File selection section - item { - SendCard { - Column( - modifier = Modifier.padding(20.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - text = "Selected Files", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.SemiBold - ), - color = MaterialTheme.colorScheme.onSurface - ) - - if (selectedFiles.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "${selectedFiles.size} file${if (selectedFiles.size != 1) "s" else ""} • ${ - formatBytes( - totalFileSize - ) - }", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - SendButton( - onClick = onAddFiles, - variant = ButtonVariant.Secondary, - size = ButtonSize.Medium - ) { - Icon( - TablerIcons.Plus, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "Add Files", style = MaterialTheme.typography.labelLarge.copy( - fontWeight = FontWeight.Medium - ) - ) - } - } - - Spacer(modifier = Modifier.height(20.dp)) - - if (selectedFiles.isEmpty()) { - SendEmptyState( - title = "No Files Selected", - description = "Tap 'Add Files' to choose files you want to send.", - icon = TablerIcons.FileText - ) - } else { - LazyColumn( - modifier = Modifier.heightIn(max = 300.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(selectedFiles) { uri -> - SendFileItem( - uri = uri, onRemove = { onRemoveFile(uri) }) - } - } - } - } - } - } - - // Transfer button - item { - SendButton( - onClick = onStartTransfer, - variant = ButtonVariant.Primary, - size = ButtonSize.Large, - enabled = canStartTransfer && networkConnected, - loading = isLoading, - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - ) { - if (!isLoading) { - Icon( - TablerIcons.CloudUpload, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - } - Text( - text = when { - isLoading -> "Starting Transfer..." - !networkConnected -> "No Network Connection" - selectedFiles.isEmpty() -> "Select Files First" - else -> "Send ${selectedFiles.size} File${if (selectedFiles.size != 1) "s" else ""}" - }, style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold - ) - ) - } - } - - // Instructions - item { - SendInstructionsCard() - } - } -} - -@Composable -private fun GeneratingQRPhase( - onCancel: () -> Unit -) { - Box( - modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center - ) { - SendCard { - Column( - modifier = Modifier.padding(40.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - SendLoadingIndicator( - message = "Generating QR Code..." - ) - - Spacer(modifier = Modifier.height(32.dp)) - - SendButton( - onClick = onCancel, variant = ButtonVariant.Secondary, size = ButtonSize.Medium - ) { - Text("Cancel") - } - } - } - } -} - -@Composable -private fun WaitingForReceiverPhase( - fileCount: Int, onCancel: () -> Unit -) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - item { - SendCard { - Column( - modifier = Modifier.padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Ready to Send", style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold - ), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "$fileCount file${if (fileCount != 1) "s" else ""} ready for transfer", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - SendLoadingIndicator() - Text( - text = "Waiting for receiver to scan...", - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Medium - ), - color = MaterialTheme.colorScheme.primary - ) - } - } - } - } - - item { - SendButton( - onClick = onCancel, - variant = ButtonVariant.Secondary, - size = ButtonSize.Large, - modifier = Modifier.fillMaxWidth() - ) { - Text( - "Cancel Transfer", style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Medium - ) - ) - } - } - } -} - -@Composable -private fun TransferringPhase( - progress: TransferProgressState?, onCancel: () -> Unit -) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(20.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - item { - progress?.let { p -> - SendCard( - backgroundColor = MaterialTheme.colorScheme.primaryContainer - ) { - Column( - modifier = Modifier.padding(24.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Sending Files", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold - ), - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - - IconButton( - onClick = onCancel, - modifier = Modifier - .size(32.dp) - .clip(CircleShape) - ) { - Icon( - Icons.Default.Close, - contentDescription = "Cancel transfer", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - - if (p.receiverName.isNotEmpty()) { - Spacer(modifier = Modifier.height(16.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - AvatarUtils.AvatarImageWithFallback( - base64String = p.receiverAvatar ?: "", - fallbackText = p.receiverName, - size = 32.dp - ) - - Text( - text = "Connected to: ${p.receiverName}", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) - ) - } - } - - if (p.currentFileName.isNotEmpty()) { - Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = "Sending: ${p.currentFileName}", - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.Medium - ), - color = MaterialTheme.colorScheme.onPrimaryContainer, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - val progressValue = if (p.totalBytes > 0) { - (p.bytesTransferred.toFloat() / p.totalBytes.toFloat()).coerceIn( - 0f, 1f - ) - } else 0f - - Spacer(modifier = Modifier.height(16.dp)) - - SendProgressBar( - progress = progressValue, modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "${formatBytes(p.bytesTransferred)} / ${formatBytes(p.totalBytes)}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) - ) - - if (p.transferSpeedBps > 0) { - Text( - text = "${formatBytes(p.transferSpeedBps)}/s", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy( - alpha = 0.7f - ) - ) - } - } - - if (p.estimatedTimeRemaining > 0) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Time remaining: ${formatDuration(p.estimatedTimeRemaining)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) - ) - } - } - } - } - } - } - } -} - -@Composable -private fun TransferCompletePhase( - fileCount: Int, - onSendMore: () -> Unit, - onDone: () -> Unit, - showSuccessAnimation: Boolean, - successCountdown: Int -) { - val haptic = LocalHapticFeedback.current - val successScale = remember { Animatable(0f) } - - LaunchedEffect(showSuccessAnimation) { - if (showSuccessAnimation) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - successScale.animateTo( - targetValue = 1f, animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow - ) - ) - } - } - - Box( - modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - // Success animation - AnimatedVisibility( - visible = showSuccessAnimation && successCountdown > 0, enter = scaleIn( - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ) - ) + fadeIn(), exit = scaleOut() + fadeOut() - ) { - SendCard( - backgroundColor = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.scale(successScale.value) - ) { - Column( - modifier = Modifier.padding(40.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Success", - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = "Transfer Complete!", - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold - ), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "$fileCount file${if (fileCount != 1) "s" else ""} sent successfully", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) - ) - } - } - } - - // Action buttons - AnimatedVisibility( - visible = !showSuccessAnimation || successCountdown <= 0, enter = slideInVertically( - initialOffsetY = { it }, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) - ) + fadeIn(), exit = slideOutVertically() + fadeOut() - ) { - SendCard( - backgroundColor = MaterialTheme.colorScheme.tertiaryContainer - ) { - Column( - modifier = Modifier.padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Default.CheckCircle, - contentDescription = "Complete", - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = "Files Sent Successfully!", - style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold - ), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onTertiaryContainer - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "$fileCount file${if (fileCount != 1) "s" else ""} transferred successfully", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f) - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - SendButton( - onClick = onSendMore, - variant = ButtonVariant.Secondary, - size = ButtonSize.Large, - modifier = Modifier.weight(1f) - ) { - Icon( - Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "Send More", fontWeight = FontWeight.Medium - ) - } - - SendButton( - onClick = onDone, - variant = ButtonVariant.Primary, - size = ButtonSize.Large, - modifier = Modifier.weight(1f) - ) { - Text( - "Done", fontWeight = FontWeight.Medium - ) - } - } - } - } - } - } - } -} - -@Composable -private fun ErrorPhase( - error: SendException?, onRetry: () -> Unit, onCancel: () -> Unit -) { - Box( - modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - error?.let { err -> - SendErrorCard( - error = err, onAction = if (err.isRecoverable) onRetry else null - ) - } - - SendButton( - onClick = onCancel, - variant = ButtonVariant.Secondary, - size = ButtonSize.Large, - modifier = Modifier.fillMaxWidth() - ) { - Text( - "Cancel", style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Medium - ) - ) - } - } - } -} - -@Composable -private fun SendCard( - modifier: Modifier = Modifier, - backgroundColor: Color = MaterialTheme.colorScheme.surface, - content: @Composable () -> Unit -) { - Card( - modifier = modifier, shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = backgroundColor - ), elevation = CardDefaults.cardElevation( - defaultElevation = 1.dp, pressedElevation = 2.dp - ) - ) { - content() - } -} - -enum class ButtonVariant { Primary, Secondary } -enum class ButtonSize { Medium, Large } - -@Composable -private fun SendButton( - onClick: () -> Unit, - variant: ButtonVariant, - size: ButtonSize, - modifier: Modifier = Modifier, - enabled: Boolean = true, - loading: Boolean = false, - content: @Composable () -> Unit -) { - val height = when (size) { - ButtonSize.Medium -> 44.dp - ButtonSize.Large -> 56.dp - } - - when (variant) { - ButtonVariant.Primary -> { - Button( - onClick = onClick, - modifier = modifier.height(height), - enabled = enabled && !loading, - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), - disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f) - ), - elevation = ButtonDefaults.buttonElevation( - defaultElevation = 2.dp, pressedElevation = 4.dp, disabledElevation = 0.dp - ) - ) { - if (loading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(12.dp)) - } - content() - } - } - - ButtonVariant.Secondary -> { - FilledTonalButton( - onClick = onClick, - modifier = modifier.height(height), - enabled = enabled && !loading, - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) - ) { - if (loading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onSecondaryContainer, - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(12.dp)) - } - content() - } - } - } -} - -@Composable -private fun SendFileItem( - uri: Uri, onRemove: () -> Unit -) { - val context = LocalContext.current - val haptic = LocalHapticFeedback.current - var fileName by remember { mutableStateOf("Loading...") } - var fileSize by remember { mutableStateOf(0L) } - - LaunchedEffect(uri) { - try { - val fileInfo = getFileInfo(context, uri) - fileName = fileInfo.first - fileSize = fileInfo.second - } catch (e: Exception) { - fileName = "Unknown file" - fileSize = 0L - } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - tonalElevation = 1.dp - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - TablerIcons.FileText, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = fileName, - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.Medium - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - if (fileSize > 0) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = formatBytes(fileSize), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - } - - IconButton( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onRemove() - }, modifier = Modifier - .size(36.dp) - .clip(CircleShape) - ) { - Icon( - Icons.Default.Delete, - contentDescription = "Remove file", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.error - ) - } - } - } -} - -@Composable -private fun SendEmptyState( - title: String, description: String, icon: ImageVector -) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) { - Column( - modifier = Modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = title, style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold - ), color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - textAlign = TextAlign.Center - ) - } - } -} - -@Composable -private fun SendInstructionsCard() { - SendCard( - backgroundColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ) { - Column( - modifier = Modifier.padding(20.dp) - ) { - Text( - text = "How to Send Files", style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold - ), color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(16.dp)) - - val instructions = listOf( - "Select files you want to send", - "Tap 'Send Files' to generate QR code", - "Let the receiver scan the QR code", - "Files transfer automatically" - ) - - instructions.forEachIndexed { index, instruction -> - Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.size(20.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = "${index + 1}", - style = MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold - ), - color = MaterialTheme.colorScheme.primary - ) - } - } - - Text( - text = instruction, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp) - ) - } - - if (index < instructions.size - 1) { - Spacer(modifier = Modifier.height(12.dp)) - } - } - } - } -} - -@Composable -private fun SendLoadingIndicator( - message: String? = null -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = MaterialTheme.colorScheme.primary, - strokeWidth = 3.dp - ) - - message?.let { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = it, style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Medium - ), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center - ) - } - } -} - -@Composable -private fun SendProgressBar( - progress: Float, modifier: Modifier = Modifier -) { - LinearProgressIndicator( - progress = { progress }, - modifier = modifier - .height(6.dp) - .clip(RoundedCornerShape(3.dp)), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) - ) -} - -@Composable -private fun SendErrorCard( - error: SendException, onAction: (() -> Unit)? = null -) { - SendCard( - backgroundColor = MaterialTheme.colorScheme.errorContainer - ) { - Column( - modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = error.icon, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.error - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = error.title, style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold - ), color = MaterialTheme.colorScheme.onErrorContainer, textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = error.message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), - textAlign = TextAlign.Center - ) - - if (onAction != null && error.actionLabel != null) { - Spacer(modifier = Modifier.height(20.dp)) - - SendButton( - onClick = onAction, variant = ButtonVariant.Primary, size = ButtonSize.Medium - ) { - Text( - error.actionLabel, fontWeight = FontWeight.Medium - ) - } - } - } - } -} - -@Composable -private fun SendErrorOverlay( - error: SendException, onDismiss: () -> Unit, onAction: (String) -> Unit -) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, indication = null - ) { onDismiss() }, contentAlignment = Alignment.Center - ) { - SendCard( - modifier = Modifier - .padding(20.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, indication = null - ) { /* Prevent dismiss on card click */ }) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = error.icon, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.error - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = error.title, style = MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Bold - ), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = error.message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(20.dp)) - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (error.isRecoverable && error.actionLabel != null) { - SendButton( - onClick = { onAction(error.actionLabel) }, - variant = ButtonVariant.Primary, - size = ButtonSize.Medium - ) { - Text( - error.actionLabel, fontWeight = FontWeight.Medium - ) - } - } - - SendButton( - onClick = onDismiss, - variant = ButtonVariant.Secondary, - size = ButtonSize.Medium - ) { - Text( - "Dismiss", fontWeight = FontWeight.Medium - ) - } - } - } - } - } -} - -private fun copyToClipboard(context: Context, text: String, label: String = "Transfer Info") { - val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipData = ClipData.newPlainText(label, text) - clipboardManager.setPrimaryClip(clipData) -} - -@Composable -private fun SendQRDialog( - qrBitmap: Bitmap, - fileCount: Int, - copyString: String?, - onDismiss: () -> Unit, - onCancel: () -> Unit -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var showCopySuccess by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - TablerIcons.Qrcode, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - "QR Code for Transfer", - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold - ) - ) - } - }, - text = { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Surface( - shape = RoundedCornerShape(16.dp), - color = Color.White, - shadowElevation = 4.dp - ) { - Image( - bitmap = qrBitmap.asImageBitmap(), - contentDescription = "QR code for file transfer", - modifier = Modifier - .size(220.dp) - .padding(16.dp) - ) - } - - Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = "Show this QR code to the receiver", - style = MaterialTheme.typography.bodyLarge.copy( - fontWeight = FontWeight.Medium - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "$fileCount file${if (fileCount != 1) "s" else ""} ready to transfer", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - textAlign = TextAlign.Center - ) - - // Copy functionality - if (!copyString.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(16.dp)) - - SendButton( - onClick = { - copyToClipboard(context, copyString, "Transfer Code") - showCopySuccess = true - scope.launch { - delay(2000) - showCopySuccess = false - } - }, - variant = ButtonVariant.Secondary, - size = ButtonSize.Medium - ) { - Icon( - TablerIcons.Copy, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - if (showCopySuccess) "Copied!" else "Copy Code", - fontWeight = FontWeight.Medium - ) - } - - if (showCopySuccess) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Transfer code copied to clipboard", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - SendLoadingIndicator() - Text( - text = "Waiting for receiver to scan...", - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Medium - ), - color = MaterialTheme.colorScheme.primary - ) - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton( - onClick = onCancel, - shape = RoundedCornerShape(8.dp) - ) { - Text( - "Cancel Transfer", - fontWeight = FontWeight.Medium - ) - } - }, - shape = RoundedCornerShape(20.dp) - ) -} -// Utility Functions with Error Handling - -data class FileValidationResult( - val validFiles: List, val skippedCount: Int -) - -private fun validateAndFilterFiles( - context: android.content.Context, uris: List -): FileValidationResult { - val validFiles = mutableListOf() - var skippedCount = 0 - - uris.forEach { uri -> - try { - val size = getFileSize(context, uri) - if (size > 0 && size <= 2_000_000_000L) { // 2GB limit - validFiles.add(uri) - } else { - skippedCount++ - } - } catch (e: Exception) { - skippedCount++ - } - } - - return FileValidationResult(validFiles, skippedCount) -} - -private fun generateQRCodeSafely(ticket: String, confirmation: UByte): Bitmap { - val writer = QRCodeWriter() - try { - if (ticket.isEmpty()) { - throw IllegalArgumentException("Ticket cannot be empty") - } - - val qrData = "drop://receive?ticket=$ticket&confirmation=$confirmation" - val bitMatrix: BitMatrix = writer.encode(qrData, BarcodeFormat.QR_CODE, 512, 512) - val width = bitMatrix.width - val height = bitMatrix.height - val bitmap = createBitmap(width, height, Bitmap.Config.RGB_565) - - for (x in 0 until width) { - for (y in 0 until height) { - bitmap[x, y] = if (bitMatrix[x, y]) { - android.graphics.Color.BLACK - } else { - android.graphics.Color.WHITE - } - } - } - return bitmap - } catch (e: WriterException) { - throw RuntimeException("QR code generation failed: ${e.message}", e) - } catch (e: Exception) { - throw RuntimeException("Unexpected error during QR code generation: ${e.message}", e) - } -} - -private fun formatBytes(bytes: Long): String { - if (bytes < 0) return "0 B" - if (bytes < 1024) return "$bytes B" - - val kb = bytes / 1024.0 - if (kb < 1024) return "%.1f KB".format(kb) - - val mb = kb / 1024.0 - if (mb < 1024) return "%.1f MB".format(mb) - - val gb = mb / 1024.0 - return "%.1f GB".format(gb) -} - -private fun formatDuration(seconds: Long): String { - return when { - seconds < 60 -> "${seconds}s" - seconds < 3600 -> "${seconds / 60}m ${seconds % 60}s" - else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m" - } -} - -private fun getFileSize(context: android.content.Context, uri: Uri): Long { - return try { - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) - if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0L - } else 0L - } ?: 0L - } catch (e: Exception) { - 0L - } -} - -private fun getFileInfo(context: android.content.Context, uri: Uri): Pair { - return try { - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) - val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) - - val name = - if (nameIndex >= 0) cursor.getString(nameIndex) ?: "Unknown" else "Unknown" - val size = if (sizeIndex >= 0) cursor.getLong(sizeIndex) else 0L - - Pair(name, size) - } else Pair("Unknown", 0L) - } ?: Pair("Unknown", 0L) - } catch (e: Exception) { - Pair("Unknown", 0L) - } -} - -private fun checkNetworkConnection(context: android.content.Context): Boolean { - return try { - val connectivityManager = - context.getSystemService(android.content.Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager - val activeNetwork = connectivityManager?.activeNetworkInfo - activeNetwork?.isConnected == true - } catch (e: Exception) { - true // Assume connected if we can't check - } -} - -private fun calculateTransferSpeed(bytesTransferred: Long): Long { - // This would be implemented with actual timing data - // For now, return 0 to indicate no speed calculation - return 0L -} - -private fun calculateETA(bytesTransferred: Long, bytesRemaining: Long): Long { - // This would be implemented with actual transfer speed data - // For now, return 0 to indicate no ETA calculation - return 0L -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/DesignTokens.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/DesignTokens.kt deleted file mode 100644 index 0d48435..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/DesignTokens.kt +++ /dev/null @@ -1,66 +0,0 @@ -package dev.arkbuilders.drop.app.ui.theme - -import androidx.compose.ui.unit.dp - -/** - * Design System Tokens for Drop - * Following Material Design 3 and Apple HIG principles - */ -object DesignTokens { - - // Spacing Scale - 8pt grid system - object Spacing { - val xs = 4.dp // Micro spacing - val sm = 8.dp // Small spacing - val md = 12.dp // Medium spacing - val lg = 16.dp // Large spacing - val xl = 24.dp // Extra large spacing - val xxl = 32.dp // Double extra large spacing - val xxxl = 48.dp // Triple extra large spacing - val huge = 64.dp // Huge spacing - } - - // Elevation Scale - object Elevation { - val none = 0.dp - val xs = 1.dp // Subtle elevation - val sm = 3.dp // Small elevation - val md = 6.dp // Medium elevation - val lg = 8.dp // Large elevation - val xl = 12.dp // Extra large elevation - val xxl = 16.dp // Maximum elevation - } - - // Corner Radius Scale - object CornerRadius { - val xs = 4.dp // Small corners - val sm = 8.dp // Medium corners - val md = 12.dp // Default corners - val lg = 16.dp // Large corners - val xl = 20.dp // Extra large corners - val xxl = 24.dp // Maximum corners - val round = 50.dp // Fully rounded (pills) - } - - // Touch Targets - object TouchTarget { - val minimum = 48.dp // Minimum touch target size - val comfortable = 56.dp // Comfortable touch target size - val large = 64.dp // Large touch target size - } - - // Animation Durations - object Animation { - const val fast = 150 - const val normal = 300 - const val slow = 500 - const val extraSlow = 800 - } - - // Content Width Constraints - object Layout { - val maxContentWidth = 600.dp - val minTouchTarget = 48.dp - val cardMaxWidth = 400.dp - } -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Theme.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Theme.kt deleted file mode 100644 index d66aa91..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Theme.kt +++ /dev/null @@ -1,134 +0,0 @@ -package dev.arkbuilders.drop.app.ui.theme - -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat - -private val DarkColorScheme = darkColorScheme( - primary = Blue80, - onPrimary = Grey10, - primaryContainer = Blue40, - onPrimaryContainer = Grey10, - - secondary = Teal80, - onSecondary = Grey10, - secondaryContainer = Teal40, - onSecondaryContainer = Grey10, - - tertiary = BlueGrey80, - onTertiary = Grey10, - tertiaryContainer = BlueGrey40, - onTertiaryContainer = Grey10, - - error = Red80, - onError = Grey10, - errorContainer = Red40, - onErrorContainer = Grey10, - - background = Grey95, - onBackground = Grey10, - surface = SurfaceDark, - onSurface = Grey10, - surfaceVariant = SurfaceVariantDark, - onSurfaceVariant = Grey30, - - outline = Grey60, - outlineVariant = Grey70, - scrim = Color.Black, - inverseSurface = Grey10, - inverseOnSurface = Grey90, - inversePrimary = Blue40, - surfaceDim = Grey80, - surfaceBright = Grey70, - surfaceContainerLowest = Grey95, - surfaceContainerLow = Grey90, - surfaceContainer = Grey80, - surfaceContainerHigh = Grey70, - surfaceContainerHighest = Grey60 -) - -private val LightColorScheme = lightColorScheme( - primary = Blue40, - onPrimary = Color.White, - primaryContainer = Blue80, - onPrimaryContainer = Grey90, - - secondary = Teal40, - onSecondary = Color.White, - secondaryContainer = Teal80, - onSecondaryContainer = Grey90, - - tertiary = BlueGrey40, - onTertiary = Color.White, - tertiaryContainer = BlueGrey80, - onTertiaryContainer = Grey90, - - error = Red40, - onError = Color.White, - errorContainer = Red80, - onErrorContainer = Grey90, - - background = Grey10, - onBackground = Grey90, - surface = SurfaceLight, - onSurface = Grey90, - surfaceVariant = SurfaceVariantLight, - onSurfaceVariant = Grey70, - - outline = Grey60, - outlineVariant = Grey40, - scrim = Color.Black, - inverseSurface = Grey90, - inverseOnSurface = Grey10, - inversePrimary = Blue80, - surfaceDim = Grey30, - surfaceBright = Color.White, - surfaceContainerLowest = Color.White, - surfaceContainerLow = Grey20, - surfaceContainer = Grey30, - surfaceContainerHigh = Grey40, - surfaceContainerHighest = Grey50 -) - -@Composable -fun DropTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = false, // Disabled for consistent branding - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.surface.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme - } - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Type.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Type.kt deleted file mode 100644 index 8256ae0..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Type.kt +++ /dev/null @@ -1,124 +0,0 @@ -package dev.arkbuilders.drop.app.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -val Typography = Typography( - // Display styles - displayLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 57.sp, - lineHeight = 64.sp, - letterSpacing = (-0.25).sp - ), - displayMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 45.sp, - lineHeight = 52.sp, - letterSpacing = 0.sp - ), - displaySmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 36.sp, - lineHeight = 44.sp, - letterSpacing = 0.sp - ), - - // Headline styles - headlineLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 32.sp, - lineHeight = 40.sp, - letterSpacing = 0.sp - ), - headlineMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 28.sp, - lineHeight = 36.sp, - letterSpacing = 0.sp - ), - headlineSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 24.sp, - lineHeight = 32.sp, - letterSpacing = 0.sp - ), - - // Title styles - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - titleMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.15.sp - ), - titleSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp - ), - - // Body styles - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ), - bodyMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp - ), - bodySmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.4.sp - ), - - // Label styles - labelLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp - ), - labelMedium = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) -) diff --git a/app/src/main/java/dev/arkbuilders/drop/app/utils/RandomNameGenerator.kt b/app/src/main/java/dev/arkbuilders/drop/app/utils/RandomNameGenerator.kt deleted file mode 100644 index 70d6b8b..0000000 --- a/app/src/main/java/dev/arkbuilders/drop/app/utils/RandomNameGenerator.kt +++ /dev/null @@ -1,181 +0,0 @@ -package dev.arkbuilders.drop.app.utils - -import kotlin.random.Random - -object RandomNameGenerator { - - private val adjectives = listOf( - // Positive traits - "clever", "bright", "swift", "gentle", "brave", "wise", "kind", "bold", - "calm", "cool", "warm", "fierce", "quick", "smart", "sharp", "keen", - "pure", "free", "wild", "true", "fresh", "alive", "awake", "aware", - - // Nature-inspired - "misty", "sunny", "cloudy", "stormy", "windy", "frosty", "snowy", "rainy", - "golden", "silver", "crystal", "pearl", "amber", "jade", "ruby", "emerald", - - // Movement/Energy - "dancing", "flying", "soaring", "flowing", "gliding", "drifting", "rushing", - "floating", "spinning", "jumping", "leaping", "racing", "wandering", - - // Emotions/Feelings - "happy", "joyful", "cheerful", "peaceful", "serene", "content", "blissful", - "hopeful", "dreamy", "curious", "playful", "merry", "lively", "vibrant", - - // Colors - "crimson", "azure", "violet", "indigo", "scarlet", "turquoise", "lavender", - "magenta", "coral", "teal", "lime", "maroon", "navy", "olive", "pink", - - // Size/Intensity - "tiny", "small", "little", "mini", "micro", "giant", "huge", "mega", - "super", "ultra", "grand", "mighty", "vast", "epic", "colossal", - - // Technology/Modern - "digital", "cyber", "virtual", "quantum", "nano", "tech", "smart", "auto", - "electric", "magnetic", "sonic", "laser", "plasma", "neon", "pixel", - - // Mystical/Fantasy - "mystic", "magic", "cosmic", "stellar", "lunar", "solar", "astral", - "ethereal", "divine", "enchanted", "mysterious", "legendary", "mythical" - ) - - private val nouns = listOf( - // Animals - "fox", "wolf", "bear", "lion", "tiger", "eagle", "hawk", "owl", "raven", - "dove", "swan", "crane", "heron", "falcon", "shark", "whale", "dolphin", - "turtle", "rabbit", "deer", "horse", "unicorn", "dragon", "phoenix", - "butterfly", "bee", "ant", "spider", "cat", "dog", "mouse", "elephant", - - // Nature Elements - "river", "ocean", "mountain", "forest", "desert", "valley", "cliff", - "waterfall", "lake", "pond", "stream", "meadow", "grove", "hill", - "stone", "rock", "crystal", "gem", "pearl", "shell", "leaf", "flower", - "tree", "branch", "root", "seed", "flame", "spark", "ember", "ash", - - // Celestial Bodies - "star", "moon", "sun", "comet", "meteor", "galaxy", "nebula", "planet", - "cosmos", "orbit", "asteroid", "constellation", "aurora", "eclipse", - - // Weather/Elements - "cloud", "rain", "snow", "mist", "fog", "wind", "storm", "thunder", - "lightning", "rainbow", "frost", "ice", "fire", "earth", "water", "air", - - // Objects/Tools - "arrow", "blade", "shield", "hammer", "key", "lock", "bridge", "tower", - "castle", "gate", "door", "window", "mirror", "lamp", "candle", "torch", - "compass", "map", "book", "scroll", "pen", "brush", "canvas", "lens", - - // Abstract Concepts - "dream", "hope", "wish", "joy", "peace", "love", "trust", "faith", - "courage", "wisdom", "truth", "freedom", "unity", "harmony", "balance", - "energy", "power", "force", "spirit", "soul", "heart", "mind", "will", - - // Professions/Characters - "builder", "maker", "creator", "artist", "writer", "poet", "singer", - "dancer", "runner", "climber", "sailor", "pilot", "explorer", "seeker", - "hunter", "guardian", "keeper", "watcher", "guide", "teacher", "student", - - // Technology/Future - "robot", "droid", "cyber", "pixel", "byte", "code", "data", "signal", - "wave", "pulse", "beam", "ray", "core", "chip", "circuit", "matrix", - "nexus", "node", "hub", "link", "network", "system", "protocol", "cipher" - ) - - /** - * Generates a random name in the format "adjective_noun" - * Similar to Docker's naming convention - */ - fun generateName(): String { - val adjective = adjectives.random() - val noun = nouns.random() - return "${adjective}_${noun}" - } - - /** - * Generates a random name with a specific format - */ - fun generateName(separator: String = "_", capitalize: Boolean = false): String { - val adjective = adjectives.random() - val noun = nouns.random() - - return if (capitalize) { - "${adjective.replaceFirstChar { it.uppercaseChar() }}${separator}${noun.replaceFirstChar { it.uppercaseChar() }}" - } else { - "$adjective$separator$noun" - } - } - - /** - * Generates a random name with additional entropy (number suffix) - */ - fun generateNameWithNumber(maxNumber: Int = 999): String { - val baseName = generateName() - val number = Random.Default.nextInt(0, maxNumber + 1) - return "${baseName}_$number" - } - - /** - * Generates multiple unique names - */ - fun generateUniqueNames(count: Int): List { - val names = mutableSetOf() - var attempts = 0 - val maxAttempts = count * 10 // Avoid infinite loops - - while (names.size < count && attempts < maxAttempts) { - names.add(generateName()) - attempts++ - } - - // If we couldn't generate enough unique names, add numbers - while (names.size < count) { - names.add(generateNameWithNumber()) - } - - return names.toList() - } - - /** - * Get a random adjective - */ - fun getRandomAdjective(): String = adjectives.random() - - /** - * Get a random noun - */ - fun getRandomNoun(): String = nouns.random() - - /** - * Check if a name follows the expected format - */ - fun isValidGeneratedName(name: String): Boolean { - val parts = name.split("_") - if (parts.size < 2) return false - - val adjective = parts[0] - val noun = parts[1] - - return adjectives.contains(adjective) && nouns.contains(noun) - } - - /** - * Generate a name that's guaranteed to be different from the provided name - */ - fun generateDifferentName(excludeName: String): String { - var newName: String - var attempts = 0 - val maxAttempts = 50 - - do { - newName = generateName() - attempts++ - } while (newName == excludeName && attempts < maxAttempts) - - // If we still got the same name after many attempts, add a number - if (newName == excludeName) { - newName = generateNameWithNumber() - } - - return newName - } -} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..075e95d --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/dev/arkbuilders/drop/app/ExampleUnitTest.kt b/app/src/test/java/dev/arkbuilders/drop/app/ExampleUnitTest.kt index b5c2b78..b20fbf3 100644 --- a/app/src/test/java/dev/arkbuilders/drop/app/ExampleUnitTest.kt +++ b/app/src/test/java/dev/arkbuilders/drop/app/ExampleUnitTest.kt @@ -13,4 +13,4 @@ class ExampleUnitTest { fun addition_isCorrect() { Assert.assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/app/src/test/java/dev/arkbuilders/drop/app/utils/RandomNameGeneratorTest.kt b/app/src/test/java/dev/arkbuilders/drop/app/utils/RandomNameGeneratorTest.kt deleted file mode 100644 index f80132a..0000000 --- a/app/src/test/java/dev/arkbuilders/drop/app/utils/RandomNameGeneratorTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -package dev.arkbuilders.drop.app.utils - -import org.junit.Test -import org.junit.Assert.* - -class RandomNameGeneratorTest { - - @Test - fun testGenerateName() { - val name = RandomNameGenerator.generateName() - - // Check format (should contain underscore) - assertTrue("Name should contain underscore", name.contains("_")) - - // Check parts - val parts = name.split("_") - assertEquals("Name should have exactly 2 parts", 2, parts.size) - - // Check if it's a valid generated name - assertTrue("Should be a valid generated name", - RandomNameGenerator.isValidGeneratedName(name)) - - println("Generated name: $name") - } - - @Test - fun testGenerateNameWithCustomSeparator() { - val name = RandomNameGenerator.generateName("-", true) - - assertTrue("Name should contain hyphen", name.contains("-")) - - val parts = name.split("-") - assertEquals("Name should have exactly 2 parts", 2, parts.size) - - // Check capitalization - assertTrue("First part should be capitalized", - parts[0].first().isUpperCase()) - assertTrue("Second part should be capitalized", - parts[1].first().isUpperCase()) - - println("Generated name with hyphen and capitalization: $name") - } - - @Test - fun testGenerateNameWithNumber() { - val name = RandomNameGenerator.generateNameWithNumber(100) - - val parts = name.split("_") - assertEquals("Name should have exactly 3 parts", 3, parts.size) - - // Last part should be a number - val number = parts[2].toIntOrNull() - assertNotNull("Last part should be a number", number) - assertTrue("Number should be between 0 and 100", - number!! in 0..100) - - println("Generated name with number: $name") - } - - @Test - fun testGenerateUniqueNames() { - val names = RandomNameGenerator.generateUniqueNames(10) - - assertEquals("Should generate 10 names", 10, names.size) - - // Check uniqueness - val uniqueNames = names.toSet() - assertEquals("All names should be unique", names.size, uniqueNames.size) - - println("Generated unique names:") - names.forEach { println(" $it") } - } - - @Test - fun testGenerateDifferentName() { - val originalName = "clever_fox" - val newName = RandomNameGenerator.generateDifferentName(originalName) - - assertNotEquals("New name should be different from original", - originalName, newName) - - println("Original: $originalName") - println("New: $newName") - } - - @Test - fun testRandomComponents() { - val adjective = RandomNameGenerator.getRandomAdjective() - val noun = RandomNameGenerator.getRandomNoun() - - assertNotNull("Adjective should not be null", adjective) - assertNotNull("Noun should not be null", noun) - assertTrue("Adjective should not be empty", adjective.isNotEmpty()) - assertTrue("Noun should not be empty", noun.isNotEmpty()) - - println("Random adjective: $adjective") - println("Random noun: $noun") - } - - @Test - fun testMultipleGenerations() { - println("Sample generated names:") - repeat(20) { - val name = RandomNameGenerator.generateName() - println(" $name") - } - } - - @Test - fun testVariousFormats() { - println("Different formats:") - - // Standard format - println("Standard: ${RandomNameGenerator.generateName()}") - - // With hyphen - println("Hyphenated: ${RandomNameGenerator.generateName("-")}") - - // Capitalized with space - println("Capitalized: ${RandomNameGenerator.generateName(" ", true)}") - - // With number - println("With number: ${RandomNameGenerator.generateNameWithNumber()}") - - // CamelCase style - val camelCase = RandomNameGenerator.generateName("", true) - println("CamelCase: $camelCase") - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e2bd9f7..07b8f85 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,9 @@ plugins { - kotlin("kapt") version "2.2.0" apply false - kotlin("plugin.serialization") version "1.9.23" apply false + alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false - id("com.google.dagger.hilt.android") version "2.56.2" apply false - id("com.github.triplet.play") version "3.10.1" apply false + alias(libs.plugins.ksp) + alias(libs.plugins.triplet.play) apply false + alias(libs.plugins.ktlint.gradle) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8a602a..0a4a836 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,10 @@ [versions] agp = "8.12.1" kotlin = "2.2.0" +ksp = "2.2.0-2.0.2" coreKtx = "1.15.0" + +arkAbout = "0.2.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" @@ -18,6 +21,16 @@ cameraLifecycle = "1.4.0" cameraView = "1.4.0" mlkitBarcodeScanning = "17.3.0" accompanistPermissions = "0.36.0" +timber = "5.0.1" +room = "2.8.1" +orbitMVI = "9.0.0" +kotlinSerialization = "1.9.0" +tripletPlay = "3.10.1" +drop = "17348879247" +devsrsouzaIcons = "1.1.0" +coil = "2.5.0" +koin = "4.1.1" +ktlintGradlePlugin = "12.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -26,6 +39,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } @@ -34,18 +48,51 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } + +ark-about = { module = "dev.arkbuilders.components:about", version.ref = "arkAbout" } + androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } google-zxing-core = { group = "com.google.zxing", name = "core", version.ref = "googleZxingCore" } github-yuriy-budiyev-code-scanner = { group = "com.github.yuriy-budiyev", name = "code-scanner", version.ref = "githubYuriyBudiyevCodeScanner" } + androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraCore" } androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraCamera2" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraLifecycle" } androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraView" } + mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcodeScanning" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } +room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +room-testing = { module = "androidx.room:room-testing", version.ref = "room" } + +orbit-compose = { module = "org.orbit-mvi:orbit-compose", version.ref = "orbitMVI" } +orbit-viewmodel = { module = "org.orbit-mvi:orbit-viewmodel", version.ref = "orbitMVI" } + +arkbuilders-drop = { module = "dev.arkbuilders:drop", version.ref = "drop" } + +simple-icons = { module = "br.com.devsrsouza.compose.icons:simple-icons", version.ref = "devsrsouzaIcons" } +font-awesome = { module = "br.com.devsrsouza.compose.icons:font-awesome", version.ref = "devsrsouzaIcons" } +tabler-icons = { module = "br.com.devsrsouza.compose.icons:tabler-icons", version.ref = "devsrsouzaIcons" } + +io-coil = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" } + +io-koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +io-koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } +io-koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +io-koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp"} +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +triplet-play = { id = "com.github.triplet.play", version.ref = "tripletPlay" } +ktlint-gradle = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradlePlugin" }