diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3328b6a..f6da73a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,61 +6,70 @@ on: pull_request: branches: [ main ] +env: + 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@v3 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' cache: gradle - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@v1.0.5 + uses: gradle/actions/wrapper-validation@v3 - name: Build debug APK run: ./gradlew assembleDebug build: - if: ${{ ! startsWith(github.actor, 'dependabot') }} environment: Development runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' cache: gradle - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@v1.0.5 + uses: gradle/actions/wrapper-validation@v3 - - name: Build debug APK - run: ./gradlew assembleDebug + - 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: Upload debug arm64-v8a APK - uses: actions/upload-artifact@v3 - with: - name: ark-drop-debug-apk - path: ./app/build/outputs/apk/debug/app-debug.apk + - 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@v3 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' @@ -68,7 +77,7 @@ jobs: - name: Run linter run: ./gradlew lint - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: lint-results - path: ./app/build/reports/lint-results-debug.html \ No newline at end of file + path: ./app/build/reports/*.html diff --git a/.gitignore b/.gitignore index d4ce834..259e370 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,14 @@ -*.iml -.gradle -/local.properties -.DS_Store +# JNILibs +/app/src/main/jniLibs + +# Development Setup /build +/.idea +/.kotlin +/.gradle /captures -.externalNativeBuild -.cxx local.properties -/.idea/ \ No newline at end of file +.cxx +.DS_Store +.externalNativeBuild + diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 5d67f89..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 ARK Builders - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..e02a3f7 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,64 @@ +# Privacy Policy for Drop + +**Last updated: [Current Date]** + +## Overview + +Drop ("we", "our", or "us") is committed to protecting your privacy. This Privacy Policy explains how we handle information when you use our mobile application. + +## Information We Don't Collect + +Drop is designed with privacy in mind: + +- **No Personal Data Collection**: We do not collect, store, or transmit any personal information +- **No Analytics**: We do not use analytics services or tracking tools +- **No Advertising**: We do not display ads or work with advertising networks +- **No Cloud Storage**: Files are never uploaded to our servers or any cloud service + +## How Drop Works + +- **Direct Transfer**: Files are transferred directly between devices over the internet +- **Local Storage**: All app data (profiles, history) is stored locally on your device +- **Encryption**: File transfers are encrypted during transmission +- **Internet Connection**: The app requires internet connection for peer-to-peer transfers + +## Data Stored Locally + +The following data is stored only on your device: + +- **Profile Information**: Your chosen display name and avatar +- **Transfer History**: Records of files you've sent or received +- **App Preferences**: Your app settings and configurations + +## Permissions + +Drop requests the following permissions: + +- **Camera**: To scan QR codes for device pairing +- **Storage**: To access files you want to share and save received files +- **Network**: To communicate with other devices over the internet + +## Data Security + +- All file transfers use end-to-end encryption +- No data is transmitted through our servers +- Your files remain under your complete control +- App data is protected by Android's security model + +## Third-Party Services + +Drop does not integrate with any third-party services that collect data. + +## Changes to This Policy + +We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy in the app and updating the "Last updated" date. + +## Contact Us + +If you have any questions about this Privacy Policy, please contact us at: +- Email: privacy@arkbuilders.dev +- GitHub: [Create an issue](https://github.com/your-username/drop-android/issues) + +## Your Rights + +Since we don't collect any personal data, there is no personal data to access, modify, or delete. All your data remains on your device under your control. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2dab0f --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# Drop - Secure File Sharing + +Drop is a secure, peer-to-peer file sharing application for Android that allows you to transfer files between devices over the internet. + +## Features + +- πŸ”’ **Secure & Private**: End-to-end encryption for all transfers +- πŸ“± **Easy to Use**: Simple interface with QR code and link sharing +- ⚑ **Fast & Reliable**: High-speed internet transfers +- 🎨 **Customizable**: Profile management with custom avatars +- πŸ“Š **Transfer History**: Track all your file transfers +- 🌐 **Internet-based**: Works anywhere with internet connection + +## Getting Started + +### Prerequisites + +- Android Studio Arctic Fox or later +- JDK 11 or later +- Android SDK API 29 or later + +### Building the App + +1. Clone the repository: +```bash +git clone https://github.com/your-username/drop-android.git +cd drop-android +``` + +2. Open the project in Android Studio + +3. Build and run: +```bash +./gradlew assembleDebug +``` + +### Release Build + +To create a release build: + +```bash +./gradlew assembleRelease +``` + +## Google Play Store Release + +### Setup + +1. **Create a release keystore** (if you don't have one): +```bash +keytool -genkeypair -alias drop-key -keyalg RSA -keysize 2048 -validity 10000 -keystore release-keystore.jks +``` + +2. **Set up GitHub Secrets**: + - `KEYSTORE_BASE64`: Base64 encoded keystore file + - `KEY_ALIAS`, `KEY_PASSWORD`, `KEYSTORE_PASSWORD`: Keystore credentials + - `PLAY_STORE_CREDENTIALS`: Google Play Console service account JSON + +3. **Google Play Console Setup**: + - Create a new app in Google Play Console + - Set up app signing + - Create a service account for API access + - Download the service account JSON file + +### Release Process + +#### Automated Release (Recommended) + +1. **Create a release tag**: +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +2. **Manual workflow dispatch**: + - Go to GitHub Actions + - Select "Release to Google Play" + - Choose the release track (internal/alpha/beta/production) + - Run workflow + +#### Manual Release + +1. **Build release bundle**: +```bash +./gradlew bundleRelease +``` + +2. **Upload to Play Console**: +```bash +./gradlew publishBundle +``` + +### Release Tracks + +- **Internal**: For internal testing (up to 100 testers) +- **Alpha**: For alpha testing (open or closed) +- **Beta**: For beta testing (open or closed) +- **Production**: For public release + +### Version Management + +Versions are automatically managed: +- **Version Code**: GitHub run number +- **Version Name**: Git tag (for releases) or dev build number + +## How Drop Works + +Drop uses peer-to-peer technology to transfer files directly between devices over the internet: + +1. **Sender** selects files and starts transfer +2. **System** generates a secure transfer link and QR code +3. **Sender** shares the link or shows QR code to receiver +4. **Receiver** opens link or scans QR code to connect +5. **Files** are transferred directly between devices with encryption + +## Sharing Options + +Drop provides multiple ways to share transfers: + +- **Deep Links**: Share via messaging apps, email, or any text-based communication +- **QR Codes**: Perfect for in-person sharing or when devices are nearby +- **Copy Link**: Quick clipboard copying for easy sharing + +## Project Structure + +``` +app/ +β”œβ”€β”€ src/main/ +β”‚ β”œβ”€β”€ java/dev/arkbuilders/drop/app/ +β”‚ β”‚ β”œβ”€β”€ ui/ # Compose UI components +β”‚ β”‚ β”œβ”€β”€ data/ # Data layer +β”‚ β”‚ β”œβ”€β”€ domain/ # Business logic +β”‚ β”‚ └── di/ # Dependency injection +β”‚ β”œβ”€β”€ res/ # Resources +β”‚ └── AndroidManifest.xml +β”œβ”€β”€ build.gradle.kts # App build configuration +└── proguard-rules.pro # ProGuard rules + +fastlane/ +└── metadata/android/en-US/ # Play Store metadata + β”œβ”€β”€ title.txt + β”œβ”€β”€ short_description.txt + β”œβ”€β”€ full_description.txt + └── changelogs/ + +.github/workflows/ +β”œβ”€β”€ build_apk.yml # CI build workflow +└── release.yml # Release workflow +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Support + +For support and questions: +- Create an issue on GitHub +- Contact: support@arkbuilders.dev + +## Privacy Policy + +Drop respects your privacy: +- No data is collected or stored on external servers +- All transfers are direct device-to-device over internet +- Files are encrypted during transfer +- No analytics or tracking + +For more details, see our [Privacy Policy](PRIVACY.md). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7fc253..dbc414b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,56 +1,107 @@ plugins { - alias(libs.plugins.androidApplication) - alias(libs.plugins.jetbrainsKotlinAndroid) + 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" } android { - namespace = "dev.arkbuilders.arkdrop" - compileSdk = 34 + namespace = "dev.arkbuilders.drop.app" + compileSdk = 36 + + signingConfigs { + create("testRelease") { + storeFile = project.rootProject.file("keystore.jks") + storePassword = "sw0rdf1sh" + keyAlias = "ark-builders-test" + keyPassword = "rybamech" + } + } defaultConfig { - applicationId = "dev.arkbuilders.arkdrop" - minSdk = 31 - targetSdk = 34 + applicationId = "dev.arkbuilders.drop.app" + minSdk = 29 + targetSdk = 36 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } + + + setProperty("archivesBaseName", "ark-drop") } buildTypes { - release { + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + isDebuggable = true isMinifyEnabled = false + } + + release { + isMinifyEnabled = true + isShrinkResources = true + signingConfig = signingConfigs.getByName("testRelease") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + + // Enable R8 full mode + isDebuggable = false + isJniDebuggable = false + isPseudoLocalesEnabled = false } } + compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } + kotlinOptions { - jvmTarget = "17" + jvmTarget = "11" } + buildFeatures { compose = true + buildConfig = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.3" - } + packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" + 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" + )) + } + + bundle { + language { + enableSplit = false + } + density { + enableSplit = true + } + abi { + enableSplit = true } } } dependencies { - implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -59,27 +110,74 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + + // NAVIGATION implementation(libs.androidx.navigation.compose) - implementation(libs.androidx.material.icons.extended) - implementation(libs.androidx.appcompat) - implementation(libs.material) - implementation(libs.androidx.activity) - implementation(libs.androidx.constraintlayout) + // Bindings setup + implementation(libs.jna) { + artifact { + extension = "aar" + type = "aar" + } + } + //noinspection Aligned16KB + implementation("dev.arkbuilders:drop:17348879247") { + artifact { + extension = "aar" + type = "aar" + } + } + + // QR CODE create setup + implementation(libs.google.zxing.core) + implementation(libs.github.yuriy.budiyev.code.scanner) + + // QR CODE SCAN implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.view) - implementation (libs.androidx.camera.mlkit.vision) - implementation(libs.barcode.scanning) + implementation(libs.mlkit.barcode.scanning) + implementation(libs.accompanist.permissions) - testImplementation(libs.junit) + // DAGGER SETUP + implementation("com.google.dagger:hilt-android:2.56.2") + kapt("com.google.dagger:hilt-compiler:2.56.2") + + // For instrumentation tests + androidTestImplementation("com.google.dagger:hilt-android-testing:2.56.2") + kaptAndroidTest("com.google.dagger:hilt-compiler:2.56.2") + // For local unit tests + testImplementation("com.google.dagger:hilt-android-testing:2.56.2") + kaptTest("com.google.dagger:hilt-compiler:2.56.2") + + // 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") + + // DEVELOPMENT SETUP + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) 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") } + +kapt { + correctErrorTypes = true +} + +tasks.named("clean") { + delete(fileTree("$projectDir/src/main/jniLibs")) +} + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..5b8e65c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -14,8 +14,122 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +-renamesourcefileattribute SourceFile + +# Keep Hilt classes +-keep class dagger.hilt.** { *; } +-keep class javax.inject.** { *; } +-keep class * extends dagger.hilt.android.HiltAndroidApp +-keepclasseswithmembers class * { + @dagger.hilt.android.AndroidEntryPoint ; +} + +# Keep Compose classes +-keep class androidx.compose.** { *; } +-keep class kotlin.Metadata { *; } + +# Keep serialization classes +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-keepclassmembers class kotlinx.serialization.json.** { + *** Companion; +} +-keepclasseswithmembers class kotlinx.serialization.json.** { + kotlinx.serialization.KSerializer serializer(...); +} +-keep,includedescriptorclasses class dev.arkbuilders.drop.app.**$$serializer { *; } +-keepclassmembers class dev.arkbuilders.drop.app.** { + *** Companion; +} +-keepclasseswithmembers class dev.arkbuilders.drop.app.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep JNA classes +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } + +# Keep ZXing classes but exclude desktop GUI components +-keep class com.google.zxing.** { *; } +-dontwarn com.google.zxing.client.j2se.** +-dontwarn java.awt.** +-dontwarn javax.swing.** +-dontwarn javax.imageio.** +-dontwarn org.w3c.dom.bootstrap.** + +# Exclude ZXing desktop GUI classes completely +-dontnote com.google.zxing.client.j2se.** + +# Keep CameraX classes +-keep class androidx.camera.** { *; } + +# Keep ML Kit classes +-keep class com.google.mlkit.** { *; } + +# Keep file provider classes +-keep class androidx.core.content.FileProvider { *; } + +# Remove logging in release +-assumenosideeffects class android.util.Log { + public static boolean isLoggable(java.lang.String, int); + public static int v(...); + public static int i(...); + public static int w(...); + public static int d(...); + public static int e(...); +} + +# Fix for missing javax.imageio classes from ZXing +-dontwarn javax.imageio.spi.ImageInputStreamSpi +-dontwarn javax.imageio.spi.ImageOutputStreamSpi +-dontwarn javax.imageio.spi.ImageReaderSpi +-dontwarn javax.imageio.spi.ImageWriterSpi +-dontwarn com.github.jaiimageio.impl.** + +# Fix for missing AWT classes from ZXing desktop components +-dontwarn java.awt.Component +-dontwarn java.awt.Container +-dontwarn java.awt.Dimension +-dontwarn java.awt.FlowLayout +-dontwarn java.awt.Graphics2D +-dontwarn java.awt.GraphicsEnvironment +-dontwarn java.awt.HeadlessException +-dontwarn java.awt.Image +-dontwarn java.awt.LayoutManager +-dontwarn java.awt.Window +-dontwarn java.awt.geom.AffineTransform +-dontwarn java.awt.image.BufferedImage +-dontwarn java.awt.image.ImageObserver +-dontwarn java.awt.image.RenderedImage +-dontwarn java.awt.image.WritableRaster + +# Fix for missing Swing classes from ZXing desktop components +-dontwarn javax.swing.Icon +-dontwarn javax.swing.ImageIcon +-dontwarn javax.swing.JFileChooser +-dontwarn javax.swing.JFrame +-dontwarn javax.swing.JLabel +-dontwarn javax.swing.JPanel +-dontwarn javax.swing.JTextArea +-dontwarn javax.swing.SwingUtilities +-dontwarn javax.swing.text.JTextComponent + +# Suppress warnings for ZXing desktop classes that we don't use on Android +-dontwarn com.google.zxing.client.j2se.GUIRunner +-dontwarn com.google.zxing.client.j2se.BufferedImageLuminanceSource +-dontwarn com.google.zxing.client.j2se.DecodeWorker +-dontwarn com.google.zxing.client.j2se.HtmlAssetTranslator + +# Keep only the ZXing classes we actually use for Android +-keep class com.google.zxing.BarcodeFormat { *; } +-keep class com.google.zxing.WriterException { *; } +-keep class com.google.zxing.common.BitMatrix { *; } +-keep class com.google.zxing.qrcode.QRCodeWriter { *; } + +# Additional R8 optimizations +-allowaccessmodification +-repackageclasses '' diff --git a/app/src/androidTest/java/dev/arkbuilders/drop/app/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/arkbuilders/drop/app/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..12b1c32 --- /dev/null +++ b/app/src/androidTest/java/dev/arkbuilders/drop/app/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package dev.arkbuilders.drop.app + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + 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 5f49a69..15f87b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,18 +1,24 @@ - + - + + + + + - - + - + android:theme="@style/Theme.Drop" + android:largeHeap="true" + tools:targetApi="31"> + + android:launchMode="singleTop" + android:theme="@style/Theme.Drop"> - + + + + + + + - - \ No newline at end of file + + + + + diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/MainActivity.kt b/app/src/main/java/dev/arkbuilders/arkdrop/MainActivity.kt deleted file mode 100644 index 22b47ff..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/MainActivity.kt +++ /dev/null @@ -1,79 +0,0 @@ -package dev.arkbuilders.arkdrop - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController -import dev.arkbuilders.arkdrop.presentation.navigation.BottomTab -import dev.arkbuilders.arkdrop.presentation.navigation.TransfersDestination -import dev.arkbuilders.arkdrop.presentation.navigation.navRegistration -import dev.arkbuilders.arkdrop.presentation.permission.PermissionManager -import dev.arkbuilders.arkdrop.ui.theme.ARKDropTheme - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - // Register ActivityResult handler - val requestPermissions = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results -> - // Handle permission requests results - // See the permission example in the Android platform samples: https://github.com/android/platform-samples - } - - PermissionManager.initialize(requestPermissions) - PermissionManager.requestPermission(baseContext) - setContent { - ARKDropTheme { - // A surface container using the 'background' color from the theme - val navController = rememberNavController() - val currentBackStack by navController.currentBackStackEntryAsState() - val currentDestination = currentBackStack?.destination - val scope = rememberCoroutineScope() - - Scaffold( - bottomBar = { - BottomTab( - navController = navController, - currentDestination = currentDestination - ) - }) { innerPadding -> - NavHost( - navController, - startDestination = TransfersDestination.route, - Modifier.padding(innerPadding) - ) { - navRegistration(navController) - } - } - - } - } - } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - ARKDropTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/editprofile/EditProfileScreen.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/editprofile/EditProfileScreen.kt deleted file mode 100644 index 19f65a2..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/editprofile/EditProfileScreen.kt +++ /dev/null @@ -1,128 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.editprofile - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -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.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos -import androidx.compose.material.icons.filled.ArrowBackIosNew -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import dev.arkbuilders.arkdrop.R -import dev.arkbuilders.arkdrop.ui.theme.BlueDark600 - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditProfileScreen(modifier: Modifier = Modifier) { - Scaffold( - topBar = { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.White - ), - title = { Text(stringResource(R.string.edit_profile_title)) }, - navigationIcon = { - IconButton( - onClick = { - }, - ) { - Icon( - imageVector = Icons.Filled.ArrowBackIosNew, - contentDescription = null - ) - Spacer(modifier = modifier.width(ButtonDefaults.IconSpacing)) - } - }) - } - ) { padding -> - Column( - modifier = modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = modifier.height(24.dp)) - - Image( - painter = painterResource(id = R.drawable.avatar_mock), - contentDescription = null, - modifier = modifier - .size(128.dp) - .clip(CircleShape) - ) - Spacer(modifier = modifier.height(12.dp)) - TextButton( - onClick = { - // Open gallery picker - }, - colors = ButtonDefaults.textButtonColors( - contentColor = Color.Black - ) - ) { - Text("Change Avatar") - Spacer(modifier = modifier.width(ButtonDefaults.IconSpacing)) - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, - contentDescription = null - ) - } - Spacer(modifier = modifier.height(24.dp)) - OutlinedTextField( - modifier = modifier - .fillMaxWidth(), - value = "", onValueChange = {}, - trailingIcon = { - Icon(imageVector = Icons.Filled.Close, contentDescription = null) - }, - shape = RoundedCornerShape(24.dp), - colors = TextFieldDefaults.colors() - ) - Spacer(modifier = modifier.height(24.dp)) - Button( - modifier = modifier - .fillMaxWidth(), - onClick = { - }, - colors = ButtonDefaults.buttonColors( - containerColor = BlueDark600, - ), - ) { - Text("Save") - } - } - } -} - -@Preview -@Composable -fun PreviewEditProfileScreen() { - EditProfileScreen() -} diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/filestransfers/FilesTransferScreen.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/filestransfers/FilesTransferScreen.kt deleted file mode 100644 index 76d1a30..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/filestransfers/FilesTransferScreen.kt +++ /dev/null @@ -1,147 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.filestransfers - -import android.content.Intent -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowCircleDown -import androidx.compose.material.icons.filled.ArrowCircleUp -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController -import dev.arkbuilders.arkdrop.R -import dev.arkbuilders.arkdrop.presentation.navigation.TransferConfirmationDestination -import dev.arkbuilders.arkdrop.presentation.feature.filestransfers.composables.UserWelcomeHeader -import dev.arkbuilders.arkdrop.presentation.feature.qrcodescanner.QRCodeScannerActivity -import dev.arkbuilders.arkdrop.presentation.permission.PermissionManager -import dev.arkbuilders.arkdrop.ui.theme.Background -import dev.arkbuilders.arkdrop.ui.theme.BlueDark600 - -@Composable -fun FilesTransferScreen( - modifier: Modifier = Modifier, - navController: NavController -) { - val result = remember { mutableStateOf(null) } - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { -// result.value = it - navController.navigate(TransferConfirmationDestination.route) - } - Column( - modifier = modifier - .fillMaxSize() - .background(Background) - ) { - UserWelcomeHeader(modifier = modifier) - HorizontalDivider( - color = Color.LightGray, - modifier = modifier - .height(1.dp) - .fillMaxWidth() - ) - Spacer(modifier = modifier.height(64.dp)) - Image( - modifier = modifier - .fillMaxWidth() - .height(256.dp), - painter = painterResource(id = R.drawable.transfer_background), - contentDescription = null, - ) - Text( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - text = stringResource(R.string.files_transfer_seamless_to_transfer_your_files), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - ) - Spacer(modifier = modifier.height(8.dp)) - Text( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - textAlign = TextAlign.Center, - text = stringResource(R.string.files_transfer_simple_fast_and_limitless_start_sharing_your_files_now), - ) - Row( - modifier = modifier - .fillMaxWidth() - .align(Alignment.CenterHorizontally) - .padding(20.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Button( - modifier = modifier - .weight(1.0f) - .padding(8.dp), - onClick = { - launcher.launch("*/*") - }, - colors = ButtonDefaults.buttonColors( - containerColor = BlueDark600, - ), - ) { - Icon(imageVector = Icons.Filled.ArrowCircleUp, contentDescription = null) - Spacer(modifier = modifier.width(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.files_transfer_send)) - } - val context = LocalContext.current - Button( - modifier = modifier - .weight(1.0f) - .padding(8.dp), - onClick = { - if (PermissionManager.isCameraPermissionGranted(context)) { - Intent(context, QRCodeScannerActivity::class.java).run { - context.startActivity(this) - } - } else { - PermissionManager.requestCameraPermission() - } - }, - colors = ButtonDefaults.buttonColors( - containerColor = BlueDark600, - ), - ) { - Icon(imageVector = Icons.Filled.ArrowCircleDown, contentDescription = null) - Spacer(modifier = modifier.width(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.files_transfer_receive)) - } - } - } -} - -@Preview -@Composable -fun PreviewFilesTransferScreen() { - FilesTransferScreen(navController = rememberNavController()) -} diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/filestransfers/composables/UserWelcomeHeader.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/filestransfers/composables/UserWelcomeHeader.kt deleted file mode 100644 index 5b3199d..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/filestransfers/composables/UserWelcomeHeader.kt +++ /dev/null @@ -1,53 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.filestransfers.composables - -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -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.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import dev.arkbuilders.arkdrop.R - -@Composable -fun UserWelcomeHeader(modifier: Modifier = Modifier) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(20.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text(stringResource(R.string.files_transfer_hi_user)) - Text( - stringResource(R.string.files_transfer_welcome_back), - style = MaterialTheme.typography.titleMedium - ) - } - Image( - painter = painterResource(id = R.drawable.avatar_mock), - contentDescription = null, - modifier = modifier - .size(64.dp) - .clip(CircleShape) - ) - } -} - -@Preview -@Composable -fun PreviewUserWelcomeHeader() { - UserWelcomeHeader() -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/qrcodescanner/QRCodeDrawable.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/qrcodescanner/QRCodeDrawable.kt deleted file mode 100644 index f405d3b..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/qrcodescanner/QRCodeDrawable.kt +++ /dev/null @@ -1,71 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.qrcodescanner - -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.ColorFilter -import android.graphics.Paint -import android.graphics.PixelFormat -import android.graphics.Rect -import android.graphics.drawable.Drawable - -class QRCodeDrawable(private val qrCodeViewModel: QRCodeScannerViewModel) : Drawable() { - private val boundingRectPaint = Paint().apply { - style = Paint.Style.STROKE - color = Color.YELLOW - strokeWidth = 5F - alpha = 200 - } - - private val contentRectPaint = Paint().apply { - style = Paint.Style.FILL - color = Color.YELLOW - alpha = 255 - } - - private val contentTextPaint = Paint().apply { - color = Color.DKGRAY - alpha = 255 - textSize = 36F - } - - private val contentPadding = 25 - private var textWidth = contentTextPaint.measureText(qrCodeViewModel.qrContent).toInt() - - override fun draw(canvas: Canvas) { - qrCodeViewModel.boundingRect?.let { rect -> - canvas.apply { - drawRect(rect, boundingRectPaint) - drawRect( - Rect( - rect.left, - rect.bottom + contentPadding / 2, - rect.left + textWidth + contentPadding * 2, - rect.bottom + contentTextPaint.textSize.toInt() + contentPadding - ), - contentRectPaint - ) - drawText( - qrCodeViewModel.qrContent, - (rect.left + contentPadding).toFloat(), - (rect.bottom + contentPadding * 2).toFloat(), - contentTextPaint - ) - } - } - } - - override fun setAlpha(alpha: Int) { - boundingRectPaint.alpha = alpha - contentRectPaint.alpha = alpha - contentTextPaint.alpha = alpha - } - - override fun setColorFilter(colorFiter: ColorFilter?) { - boundingRectPaint.colorFilter = colorFilter - contentRectPaint.colorFilter = colorFilter - contentTextPaint.colorFilter = colorFilter - } - - @Deprecated("Deprecated in Java") - override fun getOpacity(): Int = PixelFormat.TRANSLUCENT -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/qrcodescanner/QRCodeScannerActivity.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/qrcodescanner/QRCodeScannerActivity.kt deleted file mode 100644 index e51c468..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/qrcodescanner/QRCodeScannerActivity.kt +++ /dev/null @@ -1,129 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.qrcodescanner - -import android.Manifest -import android.content.pm.PackageManager -import android.os.Bundle -import android.widget.Button -import android.widget.Toast -import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AppCompatActivity -import androidx.camera.core.ImageAnalysis -import androidx.camera.mlkit.vision.MlKitAnalyzer -import androidx.camera.view.LifecycleCameraController -import androidx.camera.view.PreviewView -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import dev.arkbuilders.arkdrop.R -import com.google.mlkit.vision.barcode.BarcodeScanner -import com.google.mlkit.vision.barcode.BarcodeScannerOptions -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode - - -class QRCodeScannerActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(R.layout.activity_qrcode_scanner) - val button: Button = findViewById(R.id.backButton) - button.setOnClickListener { - finish() - } - supportActionBar?.hide() - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - // Request camera permissions - if (allPermissionsGranted()) { - startCamera() - } else { - ActivityCompat.requestPermissions( - this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS - ) - } - } - - private lateinit var barcodeScanner: BarcodeScanner - - private fun startCamera() { - val cameraController = LifecycleCameraController(baseContext) - val previewView: PreviewView = findViewById(R.id.viewFinder) - - val options = BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .build() - barcodeScanner = BarcodeScanning.getClient(options) - - cameraController.setImageAnalysisAnalyzer( - ContextCompat.getMainExecutor(this), - MlKitAnalyzer( - listOf(barcodeScanner), - ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED , - ContextCompat.getMainExecutor(this) - ) { result: MlKitAnalyzer.Result? -> - val barcodeResults = result?.getValue(barcodeScanner) - if (barcodeResults.isNullOrEmpty()) { - previewView.overlay.clear() - previewView.setOnTouchListener { _, _ -> false } //no-op - return@MlKitAnalyzer - } - - val qrCodeViewModel = QRCodeScannerViewModel(barcodeResults[0]) - val qrCodeDrawable = QRCodeDrawable(qrCodeViewModel) - previewView.apply { - setOnTouchListener(qrCodeViewModel.qrCodeTouchCallback) - overlay.apply { - clear() - add(qrCodeDrawable) - } - } - } - ) - - cameraController.bindToLifecycle(this) - previewView.controller = cameraController - } - - private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { - ContextCompat.checkSelfPermission( - baseContext, it - ) == PackageManager.PERMISSION_GRANTED - } - - override fun onDestroy() { - super.onDestroy() - barcodeScanner.close() - } - - companion object { - private const val REQUEST_CODE_PERMISSIONS = 10 - private val REQUIRED_PERMISSIONS = - mutableListOf( - Manifest.permission.CAMERA - ).toTypedArray() - } - - override fun onRequestPermissionsResult( - requestCode: Int, permissions: Array, grantResults: - IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == REQUEST_CODE_PERMISSIONS) { - if (allPermissionsGranted()) { - startCamera() - } else { - Toast.makeText( - this, - "Permissions not granted by the user.", - Toast.LENGTH_SHORT - ).show() - finish() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/qrcodescanner/QRCodeScannerViewModel.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/qrcodescanner/QRCodeScannerViewModel.kt deleted file mode 100644 index ad26929..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/qrcodescanner/QRCodeScannerViewModel.kt +++ /dev/null @@ -1,38 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.qrcodescanner - -import android.content.Intent -import android.graphics.Rect -import android.net.Uri -import android.view.MotionEvent -import android.view.View -import com.google.mlkit.vision.barcode.common.Barcode - -class QRCodeScannerViewModel(barcode: Barcode) { - - var boundingRect: Rect? = barcode.boundingBox - var qrContent: String = "" - var qrCodeTouchCallback = { v: View, e: MotionEvent -> false } //no-op - - init { - when (barcode.valueType) { - Barcode.TYPE_URL -> { - qrContent = barcode.url?.url ?: "" - qrCodeTouchCallback = { v: View, e: MotionEvent -> - if (e.action == MotionEvent.ACTION_DOWN - && boundingRect?.contains(e.x.toInt(), e.y.toInt()) == true - ) { - val openBrowserIntent = Intent(Intent.ACTION_VIEW) - openBrowserIntent.data = Uri.parse(qrContent) - v.context.startActivity(openBrowserIntent) - } - true // return true from the callback to signify the event was handled - } - } - // Add other QR Code types here to handle other types of data, - // like Wifi credentials. - else -> { - qrContent = "Unsupported data type: ${barcode.rawValue.toString()}" - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/settings/SettingsScreen.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/settings/SettingsScreen.kt deleted file mode 100644 index e775e2d..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/settings/SettingsScreen.kt +++ /dev/null @@ -1,101 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.settings - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.TextSnippet -import androidx.compose.material.icons.outlined.PrivacyTip -import androidx.compose.material.icons.outlined.Quiz -import androidx.compose.material.icons.outlined.Star -import androidx.compose.material3.ButtonDefaults -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.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import dev.arkbuilders.arkdrop.presentation.feature.settings.composables.SettingsHeader -import dev.arkbuilders.arkdrop.ui.theme.Background -import dev.arkbuilders.arkdrop.ui.theme.LightBlack - -@Composable -fun SettingsScreen(modifier: Modifier = Modifier) { - Column( - modifier = modifier - .fillMaxSize() - .background(Background), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - SettingsHeader() - settingsItemList.map { item -> - Row( - modifier = modifier - .fillMaxWidth() - .padding( - horizontal = 20.dp, - vertical = 8.dp - ) - .clickable { - - }, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = item.icon, - contentDescription = null, - tint = LightBlack, - ) - Spacer(modifier = modifier.width(ButtonDefaults.IconSpacing)) - Text( - text = item.text, - color = LightBlack, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold - ) - } - } - } -} - -private data class SettingsItem( - val icon: ImageVector, - val text: String -) - -private val settingsItemList = listOf( - SettingsItem( - icon = Icons.AutoMirrored.Outlined.TextSnippet, - text = "Terms of service" - ), - SettingsItem( - icon = Icons.Outlined.PrivacyTip, - text = "Terms of service" - ), - SettingsItem( - icon = Icons.Outlined.Star, - text = "Rate Us" - ), - SettingsItem( - icon = Icons.Outlined.Quiz, - text = "Feedback" - ) -) - -@Preview -@Composable -fun PreviewSettingsScreen() { - SettingsScreen() -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/settings/composables/SettingsHeader.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/settings/composables/SettingsHeader.kt deleted file mode 100644 index 0764315..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/settings/composables/SettingsHeader.kt +++ /dev/null @@ -1,86 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.settings.composables - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -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.Edit -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.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import dev.arkbuilders.arkdrop.R -import dev.arkbuilders.arkdrop.ui.theme.Background -import dev.arkbuilders.arkdrop.ui.theme.BlueDark600 - -@Composable -fun SettingsHeader(modifier: Modifier = Modifier) { - Column( - modifier = modifier - .background( - BlueDark600 - ) - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - "Settings", - style = MaterialTheme.typography.titleLarge, - color = Color.White - ) - Spacer(modifier = modifier.height(24.dp)) - Row( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(Background.copy(alpha = 0.2f)) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = painterResource(id = R.drawable.avatar_mock), - contentDescription = null, - modifier = modifier - .size(64.dp) - .clip(CircleShape) - ) - Spacer(modifier = modifier.width(12.dp)) - Text( - text = "Gillbert", - modifier = modifier.weight(1.0f), - color = Color.White, - style = MaterialTheme.typography.titleLarge - ) - Icon( - modifier = modifier - .clip(RoundedCornerShape(6.dp)) - .background(Color.White) - .padding(4.dp), - imageVector = Icons.Filled.Edit, - contentDescription = null - ) - } - } -} - -@Preview -@Composable -fun PreviewSettingsHeader() { - SettingsHeader() -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferconfirmation/TransferConfirmationScreen.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferconfirmation/TransferConfirmationScreen.kt deleted file mode 100644 index 97646bd..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferconfirmation/TransferConfirmationScreen.kt +++ /dev/null @@ -1,233 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.transferconfirmation - -import android.content.Intent -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.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.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBackIosNew -import androidx.compose.material.icons.outlined.Link -import androidx.compose.material.icons.outlined.Lock -import androidx.compose.material.icons.outlined.QrCode2 -import androidx.compose.material.icons.outlined.QrCodeScanner -import androidx.compose.material.icons.outlined.Visibility -import androidx.compose.material.icons.outlined.VisibilityOff -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -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.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController -import dev.arkbuilders.arkdrop.R -import dev.arkbuilders.arkdrop.presentation.feature.qrcodescanner.QRCodeScannerActivity -import dev.arkbuilders.arkdrop.presentation.navigation.TransferProgressDestination -import dev.arkbuilders.arkdrop.presentation.permission.PermissionManager -import dev.arkbuilders.arkdrop.ui.theme.Background -import dev.arkbuilders.arkdrop.ui.theme.BlueDark600 - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TransferConfirmationScreen( - modifier: Modifier = Modifier, - navController: NavController -) { - Scaffold( - topBar = { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.White - ), - title = {}, - navigationIcon = { - TextButton( - onClick = { - navController.navigateUp() - }, - colors = ButtonDefaults.textButtonColors( - contentColor = Color.Black - ) - ) { - Icon( - imageVector = Icons.Filled.ArrowBackIosNew, - contentDescription = null - ) - Spacer(modifier = modifier.width(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.back)) - } - }, - actions = { - val context = LocalContext.current - TextButton( - onClick = { - // Launch camera for QR scanning - if (PermissionManager.isCameraPermissionGranted(context)) { - context.startActivity( - Intent( - context, - QRCodeScannerActivity::class.java - ) - ) - } else { - PermissionManager.requestCameraPermission() - } - }, - colors = ButtonDefaults.textButtonColors( - contentColor = BlueDark600 - ) - ) { - Icon( - imageVector = Icons.Outlined.QrCodeScanner, - contentDescription = null - ) - Spacer(modifier = modifier.width(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.scan)) - } - } - ) - } - ) { padding -> - val isConfirmationCodeShown = remember { - mutableStateOf(false) - } - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .background(Background) - .padding(padding) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = Icons.Outlined.Lock, contentDescription = null) - Spacer(modifier = modifier.width(12.dp)) - Text( - stringResource(R.string.transfer_confirmation_confirmation_code), - style = MaterialTheme.typography.bodyLarge - ) - } - Spacer(modifier = modifier.height(12.dp)) - Text( - modifier = modifier - .clip(RoundedCornerShape(100 / 2)) - .background(Color.Gray.copy(0.2f)) - .padding(12.dp), - text = maskedText(isConfirmationCodeShown.value), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = modifier.height(8.dp)) - TextButton( - onClick = { - isConfirmationCodeShown.value = !isConfirmationCodeShown.value - }, - colors = ButtonDefaults.textButtonColors( - contentColor = BlueDark600 - ) - ) { - Icon( - imageVector = extractIcon(isConfirmationCodeShown.value), - contentDescription = null - ) - Spacer(modifier = modifier.width(ButtonDefaults.IconSpacing)) - Text(extractText(isConfirmationCodeShown.value)) - } - Spacer(modifier = modifier.height(64.dp)) - Icon( - imageVector = Icons.Outlined.QrCode2, - modifier = modifier.size(320.dp), - contentDescription = null - ) - Spacer(modifier = modifier.height(24.dp)) - Text( - modifier = modifier - .fillMaxWidth() - .clickable { - navController.navigate(TransferProgressDestination.route) - }, - text = stringResource(id = R.string.waiting_for_connect), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = modifier.height(12.dp)) - Row( - modifier = modifier - .border( - width = 1.dp, color = Color.LightGray, - shape = RoundedCornerShape(4.dp) - ) - .fillMaxWidth() - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.Outlined.Link, contentDescription = null, - modifier = modifier.size(24.dp) - ) - Text("Hash Code:", modifier = modifier.padding(horizontal = 6.dp)) - Text( - "3910-LKA9-28HS-HAXX-72LA", - fontWeight = FontWeight.SemiBold - ) - } - } - } -} - -private fun extractIcon(isShown: Boolean): ImageVector { - return if (isShown) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility -} - -private fun extractText(isShown: Boolean): String { - return if (isShown) "Hide" else "Show" -} - -private fun maskedText(isShown: Boolean): String { - return if (isShown) "23" else "β€’ β€’" -} - - -@Preview -@Composable -private fun PreviewTransferConfirmation() { - TransferConfirmationScreen(navController = rememberNavController()) -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/TransferProgressScreen.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/TransferProgressScreen.kt deleted file mode 100644 index ee108d2..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/TransferProgressScreen.kt +++ /dev/null @@ -1,143 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.transferprogress - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -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.filled.Close -import androidx.compose.material.icons.outlined.AddCircleOutline -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.compose.rememberNavController -import dev.arkbuilders.arkdrop.presentation.feature.transferprogress.composables.FileItem -import dev.arkbuilders.arkdrop.presentation.feature.transferprogress.composables.FileTransferAlertDialog -import dev.arkbuilders.arkdrop.presentation.feature.transferprogress.composables.TransferParticipantHeader -import dev.arkbuilders.arkdrop.ui.theme.Background -import dev.arkbuilders.arkdrop.ui.theme.BlueDark600 - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TransferProgressScreen( - modifier: Modifier = Modifier, - navController: NavController -) { - val openAlertDialog = remember { mutableStateOf(false) } - - Scaffold( - topBar = { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.White - ), - title = { Text("Transferring Files") }, - navigationIcon = { - IconButton( - onClick = { - navController.navigateUp() - }, - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = null - ) - } - }) - } - ) { padding -> - Column( - modifier = modifier - .background(Background) - .fillMaxSize() - .padding(padding), - horizontalAlignment = Alignment.CenterHorizontally - ) { - TransferParticipantHeader() - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(listOf(1, 2, 3)) { item -> - Box( - modifier = modifier - .fillMaxWidth() - .border( - width = 0.5.dp, color = Color.LightGray, - shape = RoundedCornerShape(25) - ) - .padding(16.dp) - ) { - FileItem(modifier = modifier, - onCloseIconClick = { - openAlertDialog.value = true - } - ) - } - } - } - OutlinedButton( - onClick = { - - }, - colors = ButtonDefaults.outlinedButtonColors( - contentColor = BlueDark600 - ), - border = BorderStroke( - width = 1.dp, - color = BlueDark600, - )) { - Icon( - imageVector = Icons.Outlined.AddCircleOutline, - contentDescription = null - ) - Text("Send more") - } - - if (openAlertDialog.value) { - FileTransferAlertDialog( - onDismissRequest = { openAlertDialog.value = false }, - onConfirmation = { /*TODO*/ }, - dialogTitle = "Cancel this file", - dialogText = "When you remove this file it cannot be undone.", - ) { - FileItem(modifier = modifier) - } - } else { - - } - } - } - - -} - -@Preview -@Composable -fun PreviewTransferProgressScreen() { - TransferProgressScreen(navController = rememberNavController()) -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/composables/FileItem.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/composables/FileItem.kt deleted file mode 100644 index c7f96b3..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/composables/FileItem.kt +++ /dev/null @@ -1,77 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.transferprogress.composables - -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -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.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.FileCopy -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.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import dev.arkbuilders.arkdrop.ui.theme.BlueDark600 - -@Composable -fun FileItem( - modifier: Modifier = Modifier, - onCloseIconClick: (() -> Unit)? = null -) { - Row( - modifier = modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Filled.FileCopy, contentDescription = null, - tint = Color.Red, - modifier = modifier - .size(48.dp) - .border( - width = 0.5.dp, - color = Color.LightGray, - shape = RoundedCornerShape(50) - ) - .padding(8.dp) - ) - Column( - modifier = modifier - .padding(horizontal = 12.dp) - .weight(1.0f) - ) { - Text( - text = "Img 2718.JPG", - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = modifier.height(4.dp)) - Text(text = "1.5 MB of 4.7 MB β€’ 4 secs left") - } - - if (onCloseIconClick != null) { - Icon( - modifier = modifier - .clickable { onCloseIconClick.invoke() }, - imageVector = Icons.Filled.Close, - contentDescription = null, - tint = BlueDark600 - ) - } - } -} - -@Preview -@Composable -fun PreviewFileItem() { - FileItem(onCloseIconClick = {}) -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/composables/FileTransferAlertDialog.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/composables/FileTransferAlertDialog.kt deleted file mode 100644 index 3d4e142..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/composables/FileTransferAlertDialog.kt +++ /dev/null @@ -1,109 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.transferprogress.composables - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -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.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import dev.arkbuilders.arkdrop.ui.theme.Background -import dev.arkbuilders.arkdrop.ui.theme.DarkRed - -@Composable -fun FileTransferAlertDialog( - modifier: Modifier = Modifier, - onDismissRequest: () -> Unit, - onConfirmation: () -> Unit, - dialogTitle: String, - dialogText: String, - content: @Composable ColumnScope.() -> Unit -) { - - Dialog( - onDismissRequest = onDismissRequest - ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - , shape = RoundedCornerShape(16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .background( - Background - ) - .padding(16.dp) - ) { - Text( - text = dialogTitle, - style = MaterialTheme.typography.titleMedium - ) - Spacer(modifier = modifier.height(8.dp)) - Text(text = dialogText) - Spacer(modifier = modifier.height(12.dp)) - content() - Spacer(modifier = modifier.height(12.dp)) - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - OutlinedButton( - onClick = { - onDismissRequest() - }, - colors = ButtonDefaults.textButtonColors( - contentColor = Color.DarkGray - ) - ) { - Text("Cancel") - } - Spacer(modifier = modifier.width(8.dp)) - Button( - onClick = { - onConfirmation() - }, - colors = ButtonDefaults.buttonColors( - containerColor = DarkRed - ) - ) { - Text("Remove") - } - } - } - } - } -} - -@Preview -@Composable -fun PreviewFileTransferAlertDialog() { - FileTransferAlertDialog( - onConfirmation = {}, - onDismissRequest = {}, - dialogTitle = "Cancel this file", - dialogText = "When you remove this file it cannot be undone.", - ) { - FileItem { - - } - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/composables/TransferParticipantHeader.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/composables/TransferParticipantHeader.kt deleted file mode 100644 index ad781b7..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/feature/transferprogress/composables/TransferParticipantHeader.kt +++ /dev/null @@ -1,79 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.feature.transferprogress.composables - -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.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -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.res.painterResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import dev.arkbuilders.arkdrop.R -import dev.arkbuilders.arkdrop.ui.theme.BlueDark600 - -@Composable -fun TransferParticipantHeader(modifier: Modifier = Modifier) { - Column { - Spacer(modifier = modifier.height(24.dp)) - LazyRow( - modifier = modifier - .fillMaxWidth() - .align(Alignment.CenterHorizontally), - horizontalArrangement = Arrangement.spacedBy( - space = (-24).dp, - alignment = Alignment.CenterHorizontally - ), - ) { - items(listOf(0, 1)) { avatarItem -> - Image( - painter = painterResource(id = R.drawable.avatar_mock), - contentDescription = null, - modifier = modifier - .size(64.dp) - .clip(CircleShape) - ) - } - } - Spacer(modifier = modifier.height(24.dp)) - Text( - text = "Wait a moment while transferring...", - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - modifier = modifier.fillMaxWidth() - ) - Row( - modifier = modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = "Sending to") - Spacer(modifier = modifier.width(6.dp)) - Text( - text = "Bob", - color = BlueDark600, - style = MaterialTheme.typography.titleMedium - ) - } - } -} - -@Preview -@Composable -fun PreviewTransferParticipantHeader() { - TransferParticipantHeader() -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/BottomTabBar.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/BottomTabBar.kt deleted file mode 100644 index 9f14198..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/BottomTabBar.kt +++ /dev/null @@ -1,59 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.navigation - -import androidx.compose.animation.animateColorAsState -import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationBarItemDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.navigation.NavController -import androidx.navigation.NavDestination -import androidx.navigation.compose.rememberNavController -import dev.arkbuilders.arkdrop.ui.theme.BlueDark600 -import com.hieuwu.gofocus.presentation.navigation.navigateSingleTopTo - -@Composable -fun BottomTab(currentDestination: NavDestination?, navController: NavController) { - NavigationBar( - containerColor = Color.White, - ) { - bottomTabRowScreens.forEach { screen -> - val isSelected = currentDestination?.route == screen.route - val tabTintColor by animateColorAsState( - targetValue = if (isSelected) BlueDark600 else - Color.LightGray - ) - val bottomTabIcon = if (isSelected) screen.activeIcon else screen.inActiveIcon - NavigationBarItem( - colors = NavigationBarItemDefaults.colors( - indicatorColor = Color.Transparent, - selectedIconColor = tabTintColor, - selectedTextColor = tabTintColor, - ), - selected = isSelected, - onClick = { navController.navigateSingleTopTo(screen.route) }, - label = { - Text( - text = screen.title, - ) - }, - icon = { - Icon( - imageVector = bottomTabIcon, - contentDescription = null, - ) - }, - ) - } - } -} - -@Preview -@Composable -fun BottomTabPreview() { - BottomTab(currentDestination = null, navController = rememberNavController()) -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/Destination.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/Destination.kt deleted file mode 100644 index 79b4538..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/Destination.kt +++ /dev/null @@ -1,56 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.navigation - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.SwapVert -import androidx.compose.material.icons.outlined.History -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.SwapVert -import androidx.compose.ui.graphics.vector.ImageVector - - -interface Destination { - val route: String - val title: String -} - -interface BottomTabDestination : Destination { - val activeIcon: ImageVector - val inActiveIcon: ImageVector -} - -object TransfersDestination : BottomTabDestination { - // Put information needed to navigate here - override val route = "transfer" - override val activeIcon = Icons.Filled.SwapVert - override val inActiveIcon: ImageVector = Icons.Outlined.SwapVert - override val title = "Transfer" -} - -object HistoryDestination : BottomTabDestination { - override val route = "history" - override val activeIcon = Icons.Filled.History - override val inActiveIcon: ImageVector = Icons.Outlined.History - override val title = "History" -} - -object SettingsDestination : BottomTabDestination { - override val route = "settings" - override val activeIcon = Icons.Filled.Person - override val inActiveIcon: ImageVector = Icons.Outlined.Person - override val title = "Settings" -} - -object TransferConfirmationDestination : Destination { - override val route: String = "transfer_confirmation" - override val title: String = "Transfer confirmation" -} - -object TransferProgressDestination : Destination { - override val route: String = "transfer_progress_destination" - override val title: String = "Transfer progress destination" -} - - -val bottomTabRowScreens = listOf(TransfersDestination, HistoryDestination, SettingsDestination) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/NavControllerExt.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/NavControllerExt.kt deleted file mode 100644 index b6ee1b5..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/NavControllerExt.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.hieuwu.gofocus.presentation.navigation - -import androidx.navigation.NavController - -fun NavController.navigateSingleTopTo(route: String) { - this.navigate(route) { - launchSingleTop = true - } -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/NavRegister.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/NavRegister.kt deleted file mode 100644 index fcc8e77..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/navigation/NavRegister.kt +++ /dev/null @@ -1,28 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.navigation - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import dev.arkbuilders.arkdrop.Greeting -import dev.arkbuilders.arkdrop.presentation.feature.filestransfers.FilesTransferScreen -import dev.arkbuilders.arkdrop.presentation.feature.settings.SettingsScreen -import dev.arkbuilders.arkdrop.presentation.feature.transferconfirmation.TransferConfirmationScreen -import dev.arkbuilders.arkdrop.presentation.feature.transferprogress.TransferProgressScreen - -fun NavGraphBuilder.navRegistration(navController: NavController) { - composable(TransfersDestination.route) { - FilesTransferScreen(navController = navController) - } - composable(HistoryDestination.route) { - Greeting(name = "HistoryDestination") - } - composable(SettingsDestination.route) { - SettingsScreen() - } - composable(TransferConfirmationDestination.route) { - TransferConfirmationScreen(navController = navController) - } - composable(TransferProgressDestination.route) { - TransferProgressScreen(navController = navController) - } -} diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/permission/PermissionManager.kt b/app/src/main/java/dev/arkbuilders/arkdrop/presentation/permission/PermissionManager.kt deleted file mode 100644 index ad7484b..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/presentation/permission/PermissionManager.kt +++ /dev/null @@ -1,76 +0,0 @@ -package dev.arkbuilders.arkdrop.presentation.permission - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import androidx.activity.result.ActivityResultLauncher -import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat - -object PermissionManager { - - private lateinit var requestPermissionLauncher: ActivityResultLauncher> - - fun initialize(requestPermissions: ActivityResultLauncher>) { - requestPermissionLauncher = requestPermissions - } - - @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - private val requiredPermissionApi34 = arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED, - Manifest.permission.CAMERA, - ) - - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - private val requiredPermissionApi33 = arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.CAMERA, - ) - - fun requestPermission(context: Context) { - if (allPermissionGranted(context)) { - return - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - requestPermissionLauncher.launch(requiredPermissionApi34) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - requestPermissionLauncher.launch(requiredPermissionApi33) - } - } - - fun isCameraPermissionGranted(context: Context) = run { - ContextCompat.checkSelfPermission( - context, Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED - } - - fun requestCameraPermission() { - requestPermissionLauncher.launch( - arrayOf( - Manifest.permission.CAMERA, - ) - ) - } - - private fun allPermissionGranted(context: Context): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - return requiredPermissionApi34.all { - ContextCompat.checkSelfPermission( - context, it - ) == PackageManager.PERMISSION_GRANTED - } - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return requiredPermissionApi33.all { - ContextCompat.checkSelfPermission( - context, it - ) == PackageManager.PERMISSION_GRANTED - } - } - return false - } - -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/ui/theme/Color.kt b/app/src/main/java/dev/arkbuilders/arkdrop/ui/theme/Color.kt deleted file mode 100644 index bb69521..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/ui/theme/Color.kt +++ /dev/null @@ -1,15 +0,0 @@ -package dev.arkbuilders.arkdrop.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) -val BlueDark600 = Color(0xFF155EEF) -val Background = Color(0xFFFCFCFD) -val DarkRed = Color(0xFFD92D20) -val LightBlack = Color(0xFF344054) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/ui/theme/Theme.kt b/app/src/main/java/dev/arkbuilders/arkdrop/ui/theme/Theme.kt deleted file mode 100644 index cc6fac9..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/ui/theme/Theme.kt +++ /dev/null @@ -1,70 +0,0 @@ -package dev.arkbuilders.arkdrop.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.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) - -@Composable -fun ARKDropTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - 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 = BlueDark600.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme - } - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkdrop/ui/theme/Type.kt b/app/src/main/java/dev/arkbuilders/arkdrop/ui/theme/Type.kt deleted file mode 100644 index f5ef4f5..0000000 --- a/app/src/main/java/dev/arkbuilders/arkdrop/ui/theme/Type.kt +++ /dev/null @@ -1,34 +0,0 @@ -package dev.arkbuilders.arkdrop.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 - -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/drop/app/DropApplication.kt b/app/src/main/java/dev/arkbuilders/drop/app/DropApplication.kt new file mode 100644 index 0000000..0ed5b59 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/DropApplication.kt @@ -0,0 +1,7 @@ +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/MainActivity.kt b/app/src/main/java/dev/arkbuilders/drop/app/MainActivity.kt new file mode 100644 index 0000000..37c4d36 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/MainActivity.kt @@ -0,0 +1,114 @@ +package dev.arkbuilders.drop.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +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 + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject + lateinit var transferManager: TransferManager + + @Inject + lateinit var profileManager: ProfileManager + + @Inject + lateinit var historyRepository: HistoryRepository + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + DropTheme { + Scaffold( + modifier = Modifier + .fillMaxSize() + ) { innerPadding -> + DropNavigation( + modifier = Modifier + .padding(innerPadding), + transferManager = transferManager, + profileManager = profileManager, + historyRepository = historyRepository + ) + } + } + } + } +} + +@Composable +fun DropNavigation( + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController(), + transferManager: TransferManager, + profileManager: ProfileManager, + historyRepository: HistoryRepository +) { + NavHost( + navController = navController, + startDestination = DropDestination.Home.route, + modifier = modifier + ) { + composable(DropDestination.Home.route) { + Home( + navController = navController, + profileManager = profileManager, + historyRepository = historyRepository + ) + } + composable(DropDestination.Send.route) { + Send( + navController = navController, + transferManager = transferManager + ) + } + composable( + DropDestination.Receive.route, + deepLinks = listOf( + navDeepLink { + uriPattern = DropDestination.Receive.DEEP_LINK_PATTERN + } + ) + ) { + Receive( + navController = navController, + transferManager = transferManager + ) + } + composable(DropDestination.History.route) { + History( + navController = navController, + historyRepository = historyRepository + ) + } + composable(DropDestination.EditProfile.route) { + EditProfileEnhanced( + navController = navController, + profileManager = profileManager + ) + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ProfileManager.kt b/app/src/main/java/dev/arkbuilders/drop/app/ProfileManager.kt new file mode 100644 index 0000000..b8bdea9 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ProfileManager.kt @@ -0,0 +1,98 @@ +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 new file mode 100644 index 0000000..28756c9 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/TransferManager.kt @@ -0,0 +1,476 @@ +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 new file mode 100644 index 0000000..9ce02e6 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/HistoryRepository.kt @@ -0,0 +1,148 @@ +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 new file mode 100644 index 0000000..3f9a885 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/ReceiveFilesSubscriberImpl.kt @@ -0,0 +1,168 @@ +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 java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +data class ReceivingProgress( + val isConnected: Boolean = false, + val senderName: String = "", + val senderAvatar: String? = null, + val files: List = emptyList(), + val fileProgress: Map = emptyMap() +) + +data class ReceiveFileInfo( + val id: String, + val name: String, + val size: ULong +) + +data class FileProgressInfo( + val receivedBytes: Long = 0L, + val isComplete: Boolean = false +) + +class ReceiveFilesSubscriberImpl : ReceiveFilesSubscriber { + + companion object { + private const val TAG = "ReceiveFilesSubscriber" + } + + private val id = UUID.randomUUID().toString() + + // 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) + } + + override fun notifyReceiving(event: ReceiveFilesReceivingEvent) { + Log.d(TAG, "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 + ) + + // Emit new state + _progress.value = currentProgress.copy( + fileProgress = updatedFileProgress.toMap() + ) + + if (isComplete) { + Log.d(TAG, "File ${fileInfo.name} completed: $receivedBytes bytes") + } + } + } + + override fun notifyConnecting(event: ReceiveFilesConnectingEvent) { + Log.d(TAG, "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 + ) + } + + _progress.value = _progress.value.copy( + isConnected = true, + senderName = event.sender.name, + senderAvatar = event.sender.avatarB64, + files = fileInfos + ) + } + + fun reset() { + // Clear all data streams + receivedDataStreams.clear() + _progress.value = ReceivingProgress() + } + + /** + * Get files that have been completely received + */ + fun getCompleteFiles(): List> { + val currentProgress = _progress.value + return currentProgress.files.mapNotNull { fileInfo -> + val progressInfo = currentProgress.fileProgress[fileInfo.id] + if (progressInfo?.isComplete == true) { + val stream = receivedDataStreams[fileInfo.id] + if (stream != null) { + synchronized(stream) { + val data = stream.toByteArray() + Pair(fileInfo, data) + } + } else { + null + } + } else { + null + } + } + } + + /** + * Get progress for a specific file (0.0 to 1.0) + */ + fun getFileProgress(fileId: String): Float { + 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 { + val currentProgress = _progress.value + 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 new file mode 100644 index 0000000..4705990 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/SendFilesSubscriberImpl.kt @@ -0,0 +1,61 @@ +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 java.util.UUID + +data class SendingProgress( + val fileName: String = "", + val sent: ULong = 0UL, + val remaining: ULong = 0UL, + val isConnected: Boolean = false, + val receiverName: String = "", + 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) + } + + 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 + ) + } + + 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 + ) + } + + 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 new file mode 100644 index 0000000..9379dcd --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/data/SenderFileDataImpl.kt @@ -0,0 +1,87 @@ +package dev.arkbuilders.drop.app.data + +import android.content.Context +import android.net.Uri +import android.util.Log +import dev.arkbuilders.drop.SenderFileData +import java.io.InputStream + +class SenderFileDataImpl( + private val context: Context, private val uri: Uri +) : SenderFileData { + + companion object { + private const val TAG = "SenderFileDataImpl" + } + + private var inputStream: InputStream? = null + private var totalLength: ULong = 0UL + private var isInitialized = false + + private fun initialize() { + if (isInitialized) return + + try { + // Get file size + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) + if (sizeIndex >= 0) { + totalLength = cursor.getLong(sizeIndex).toULong() + } + } + } + + // Open input stream + inputStream = context.contentResolver.openInputStream(uri) + isInitialized = true + + Log.d(TAG, "Initialized SenderFileData for URI: $uri, size: $totalLength") + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize SenderFileData", e) + } + } + + override fun len(): ULong { + initialize() + return totalLength + } + + override fun read(): UByte? { + initialize() + return try { + val byte = inputStream?.read() + if (byte == -1) { + inputStream?.close() + null + } else { + byte?.toUByte() + } + } catch (e: Exception) { + Log.e(TAG, "Error reading byte", e) + null + } + } + + override fun readChunk(size: Int): ByteArray { + initialize() + return try { + var size = size + inputStream?.available()?.let { + if (it == 0) { + inputStream?.close() + return ByteArray(0) + } + if (it < size) { + size = it + } + } + val bytes = ByteArray(size) + inputStream?.read(bytes) ?: 0 + bytes + } catch (e: Exception) { + Log.e(TAG, "Error reading chunk of size $size", e) + ByteArray(0) + } + } +} 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 new file mode 100644 index 0000000..9315d85 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/di/AppModule.kt @@ -0,0 +1,33 @@ +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 + +@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) + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/navigation/DropDestination.kt b/app/src/main/java/dev/arkbuilders/drop/app/navigation/DropDestination.kt new file mode 100644 index 0000000..86f6c82 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/navigation/DropDestination.kt @@ -0,0 +1,15 @@ +package dev.arkbuilders.drop.app.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 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 { + return "receive?ticket=$ticket&confirmation=$confirmation" + } + } +} diff --git a/app/src/main/java/dev/arkbuilders/drop/app/ui/Home/Home.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/Home/Home.kt new file mode 100644 index 0000000..e32a31e --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/Home/Home.kt @@ -0,0 +1,377 @@ +package dev.arkbuilders.drop.app.ui.Home + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +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.foundation.lazy.LazyColumn +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.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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +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.unit.dp +import androidx.navigation.NavController +import compose.icons.TablerIcons +import compose.icons.tablericons.ArrowDownCircle +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.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.DropLogoWithBackground +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 kotlinx.coroutines.delay +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun Home( + navController: NavController, + profileManager: ProfileManager, + historyRepository: HistoryRepository, +) { + val profile = remember { profileManager.getCurrentProfile() } + val historyItems by historyRepository.historyItems.collectAsState() + + var logoScale by remember { mutableStateOf(0f) } + + // Animate logo entrance + LaunchedEffect(Unit) { + delay(300) + logoScale = 1f + } + + val animatedLogoScale by animateFloatAsState( + targetValue = logoScale, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "logoScale" + ) + + LazyColumn( + 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 + ) + } + + // Quick Actions Section + item { + QuickActionsSection( + onSendClick = { navController.navigate(DropDestination.Send.route) }, + onReceiveClick = { navController.navigate(DropDestination.Receive.route) } + ) + } + + // Recent Transfers Section + item { + if (historyItems.isNotEmpty()) { + RecentTransfersSection( + historyItems = historyItems.take(5), + onViewAllClick = { navController.navigate(DropDestination.History.route) }, + showViewAll = historyItems.isNotEmpty() + ) + } else { + EmptyTransfersSection() + } + } + } +} + +@Composable +private fun HeaderSection( + logoScale: Float, + onProfileClick: () -> Unit, + profile: UserProfile +) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "App header with logo and profile access" }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // App branding + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.lg) + ) { + Box( + modifier = Modifier.scale(logoScale) + ) { + DropLogoWithBackground(size = 56.dp) + } + + Column { + Text( + text = "Drop", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Share files instantly", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Profile access + IconButton( + onClick = onProfileClick, + modifier = Modifier.semantics { + contentDescription = "Open profile settings" + } + ) { + AvatarUtils.AvatarImageWithFallback(profile.avatarB64) + } + } +} + +@Composable +private fun QuickActionsSection( + onSendClick: () -> Unit, + onReceiveClick: () -> Unit +) { + DropCard( + variant = DropCardVariant.Elevated, + size = DropCardSize.Large, + contentDescription = "Quick actions for sending and receiving files" + ) { + DropCardContent(size = DropCardSize.Large) { + Text( + text = "What would you like to do?", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(DesignTokens.Spacing.xl)) + + // Send button + DropButton( + onClick = onSendClick, + variant = DropButtonVariant.Primary, + size = DropButtonSize.Large, + modifier = Modifier.fillMaxWidth(), + contentDescription = "Send files to another device" + ) { + Icon( + TablerIcons.ArrowUpCircle, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(DesignTokens.Spacing.md)) + Text( + "Send Files", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) + + // Receive button + DropOutlinedButton( + onClick = onReceiveClick, + size = DropButtonSize.Large, + modifier = Modifier.fillMaxWidth(), + contentDescription = "Receive files from another device" + ) { + Icon( + TablerIcons.ArrowDownCircle, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(DesignTokens.Spacing.md)) + Text( + "Receive Files", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} + +@Composable +private fun RecentTransfersSection( + historyItems: List, + onViewAllClick: () -> Unit, + showViewAll: Boolean +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Recent Transfers", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + if (showViewAll) { + DropOutlinedButton( + onClick = onViewAllClick, + size = DropButtonSize.Small, + contentDescription = "View all transfer history" + ) { + Icon( + TablerIcons.History, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(DesignTokens.Spacing.xs)) + Text("View All") + } + } + } + + Spacer(modifier = Modifier.height(DesignTokens.Spacing.lg)) + + Column( + verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) + ) { + historyItems.forEach { item -> + EnhancedTransferHistoryCard(item = item) + } + } + } +} + +@Composable +private fun EmptyTransfersSection() { + DropCard( + variant = DropCardVariant.Outlined, + size = DropCardSize.Large, + 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." + ) + } +} + +@Composable +private fun EnhancedTransferHistoryCard(item: TransferHistoryItem) { + DropCard( + variant = DropCardVariant.Elevated, + size = DropCardSize.Medium, + 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 + ) { + // Transfer type icon with semantic color + Icon( + 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 + ) + + Spacer(modifier = Modifier.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}", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(DesignTokens.Spacing.xs)) + Text( + text = "${item.fileCount} file${if (item.fileCount != 1) "s" else ""} β€’ ${formatTimestamp(item.timestamp)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Peer avatar + AvatarUtils.AvatarImageWithFallback( + base64String = item.peerAvatar, + fallbackText = item.peerName, + size = 40.dp + ) + } + } + } +} + +private fun formatTimestamp(timestamp: Long): String { + val now = System.currentTimeMillis() + val diff = now - timestamp + + 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)) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..8854788 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropButton.kt @@ -0,0 +1,191 @@ +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 new file mode 100644 index 0000000..a903d2b --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropCard.kt @@ -0,0 +1,130 @@ +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/DropLogo.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropLogo.kt new file mode 100644 index 0000000..7f819de --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/DropLogo.kt @@ -0,0 +1,195 @@ +package dev.arkbuilders.drop.app.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.cos +import kotlin.math.sin + +@Composable +fun DropLogo( + modifier: Modifier = Modifier, + size: Dp = 48.dp, + primaryColor: Color = MaterialTheme.colorScheme.primary, + secondaryColor: Color = MaterialTheme.colorScheme.secondary, + showBackground: Boolean = false, + backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer +) { + Canvas( + modifier = modifier.size(size) + ) { + val canvasSize = this.size + val center = canvasSize.center + val radius = minOf(canvasSize.width, canvasSize.height) / 2f * 0.8f + + // Background circle if requested + if (showBackground) { + drawCircle( + color = backgroundColor, + radius = radius * 1.2f, + center = center + ) + } + + // Draw the main drop shape with gradient + val gradient = Brush.radialGradient( + colors = listOf( + primaryColor.copy(alpha = 0.9f), + primaryColor, + primaryColor.copy(alpha = 0.8f) + ), + center = center, + radius = radius + ) + + drawDropShape( + center = center, + radius = radius * 0.7f, + brush = gradient + ) + + // Draw connection lines representing file sharing + val connectionColor = secondaryColor.copy(alpha = 0.7f) + val strokeWidth = radius * 0.08f + + // Draw three curved connection lines + repeat(3) { index -> + rotate(degrees = index * 120f, pivot = center) { + drawConnectionLine( + center = center, + radius = radius * 0.9f, + color = connectionColor, + strokeWidth = strokeWidth + ) + } + } + + // Draw small dots at connection points + repeat(6) { index -> + val angle = (index * 60f) * (Math.PI / 180f) + val dotRadius = radius * 0.12f + val connectionRadius = radius * 0.9f + + val dotX = center.x + connectionRadius * cos(angle).toFloat() + val dotY = center.y + connectionRadius * sin(angle).toFloat() + + drawCircle( + color = secondaryColor, + radius = dotRadius, + center = Offset(dotX, dotY) + ) + } + + // Inner highlight for depth + drawCircle( + color = Color.White.copy(alpha = 0.3f), + radius = radius * 0.3f, + center = Offset(center.x - radius * 0.1f, center.y - radius * 0.1f) + ) + } +} + +private fun DrawScope.drawDropShape( + center: Offset, + radius: Float, + brush: Brush +) { + val path = Path().apply { + // Create a drop/teardrop shape + val topY = center.y - radius * 1.2f + val bottomY = center.y + radius * 0.8f + val leftX = center.x - radius * 0.8f + val rightX = center.x + radius * 0.8f + + // Start at the top point + moveTo(center.x, topY) + + // Right curve + cubicTo( + rightX, topY + radius * 0.3f, + rightX, bottomY - radius * 0.3f, + center.x, bottomY + ) + + // Left curve + cubicTo( + leftX, bottomY - radius * 0.3f, + leftX, topY + radius * 0.3f, + center.x, topY + ) + + close() + } + + drawPath( + path = path, + brush = brush + ) +} + +private fun DrawScope.drawConnectionLine( + center: Offset, + radius: Float, + color: Color, + strokeWidth: Float +) { + val path = Path().apply { + val startX = center.x - radius * 0.3f + val startY = center.y + val endX = center.x + radius * 0.3f + val endY = center.y + val controlY = center.y - radius * 0.2f + + moveTo(startX, startY) + quadraticBezierTo(center.x, controlY, endX, endY) + } + + drawPath( + path = path, + color = color, + style = Stroke( + width = strokeWidth, + cap = StrokeCap.Round + ) + ) +} + +@Composable +fun DropLogoIcon( + modifier: Modifier = Modifier, + size: Dp = 24.dp, + tint: Color = MaterialTheme.colorScheme.primary +) { + DropLogo( + modifier = modifier, + size = size, + primaryColor = tint, + secondaryColor = tint.copy(alpha = 0.7f), + showBackground = false + ) +} + +@Composable +fun DropLogoWithBackground( + modifier: Modifier = Modifier, + size: Dp = 72.dp +) { + DropLogo( + modifier = modifier, + size = size, + showBackground = true + ) +} 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 new file mode 100644 index 0000000..8287c39 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/ErrorStates.kt @@ -0,0 +1,159 @@ +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/components/LoadingStates.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/LoadingStates.kt new file mode 100644 index 0000000..883bcbd --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/components/LoadingStates.kt @@ -0,0 +1,185 @@ +package dev.arkbuilders.drop.app.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 + +@Composable +fun LoadingIndicator( + modifier: Modifier = Modifier, + message: String? = null +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary, + 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 + ) + } + } +} + +@Composable +fun SkeletonLoader( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(DesignTokens.Spacing.md) + ) { + repeat(3) { + SkeletonCard() + } + } +} + +@Composable +private fun SkeletonCard() { + DropCard( + variant = DropCardVariant.Elevated, + size = DropCardSize.Medium + ) { + DropCardContent(size = DropCardSize.Medium) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar skeleton + Box( + 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() + ) + + Spacer(modifier = Modifier.height(DesignTokens.Spacing.xs)) + + // Subtitle skeleton + Box( + modifier = Modifier + .fillMaxWidth(0.5f) + .height(12.dp) + .clip(RoundedCornerShape(DesignTokens.CornerRadius.xs)) + .shimmerEffect() + ) + } + } + } + } +} + +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 +) { + Column( + modifier = modifier.padding(DesignTokens.Spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(DesignTokens.Spacing.sm)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + 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/ui/history/History.kt new file mode 100644 index 0000000..614e40f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/history/History.kt @@ -0,0 +1,395 @@ +package dev.arkbuilders.drop.app.ui.history + +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.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.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 +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +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.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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import compose.icons.TablerIcons +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 java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun History( + navController: NavController, + historyRepository: HistoryRepository +) { + val historyItems by historyRepository.historyItems.collectAsState() + var showClearDialog by remember { mutableStateOf(false) } + + 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 = "Transfer History", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + + // Clear all button + if (historyItems.isNotEmpty()) { + IconButton(onClick = { showClearDialog = true }) { + Icon( + TablerIcons.ClearAll, + contentDescription = "Clear All", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (historyItems.isEmpty()) { + // Empty state + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + TablerIcons.History, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "No Transfer History", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + 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 + ) + } + } + } else { + // History list + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(historyItems) { item -> + HistoryItemCard( + item = item, + onDelete = { + historyRepository.deleteHistoryItem(item.id) + } + ) + } + } + } + } + + // Clear all confirmation dialog + if (showClearDialog) { + AlertDialog( + onDismissRequest = { showClearDialog = false }, + title = { + Text( + "Clear All History", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + text = { + Text( + "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 + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Clear All", fontWeight = FontWeight.Medium) + } + }, + dismissButton = { + TextButton(onClick = { showClearDialog = false }) { + Text("Cancel", fontWeight = FontWeight.Medium) + } + }, + shape = RoundedCornerShape(16.dp) + ) + } +} + +@Composable +private fun HistoryItemCard( + item: TransferHistoryItem, + 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) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Peer avatar + AvatarUtils.AvatarImageWithFallback( + base64String = item.peerAvatar, + fallbackText = item.peerName, + size = 48.dp + ) + + Spacer(modifier = Modifier.width(16.dp)) + + // Transfer info + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Transfer type icon + Icon( + 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 + } + ) + + Text( + 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 + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = item.fileName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + 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 + ) + + if (item.fileCount > 1) { + Text( + text = "β€’", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "${item.fileCount} files", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Status + Text( + 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 + ) + } + + // Delete button + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Delete confirmation dialog + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { + Text( + "Delete History Item", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + text = { + Text( + "Are you sure you want to delete this transfer from history?", + style = MaterialTheme.typography.bodyLarge + ) + }, + confirmButton = { + Button( + onClick = { + onDelete() + showDeleteDialog = false + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete", fontWeight = FontWeight.Medium) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Cancel", fontWeight = FontWeight.Medium) + } + }, + shape = RoundedCornerShape(16.dp) + ) + } +} + +private fun formatFileSize(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) +} + +private fun formatTimestamp(timestamp: Long): String { + val now = System.currentTimeMillis() + val diff = now - timestamp + + 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 --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 new file mode 100644 index 0000000..ca1b5cc --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/AvatarUtils.kt @@ -0,0 +1,209 @@ +//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 new file mode 100644 index 0000000..46dfdf9 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/AvatarUtilsEnhanced.kt @@ -0,0 +1,252 @@ +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 new file mode 100644 index 0000000..6542d27 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/EditProfile.kt @@ -0,0 +1,287 @@ +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 new file mode 100644 index 0000000..baf5fc0 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/profile/EditProfileEnhanced.kt @@ -0,0 +1,817 @@ +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 new file mode 100644 index 0000000..f32f3a5 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/receive/Receive.kt @@ -0,0 +1,1759 @@ +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.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.TransferManager +import dev.arkbuilders.drop.app.data.ReceiveFileInfo +import dev.arkbuilders.drop.app.data.ReceivingProgress +import dev.arkbuilders.drop.app.ui.components.DropLogoIcon +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)) + + DropLogoIcon( + size = 32.dp, + tint = MaterialTheme.colorScheme.primary + ) + + 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 new file mode 100644 index 0000000..816b842 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/send/Send.kt @@ -0,0 +1,1931 @@ +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.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.TransferManager +import dev.arkbuilders.drop.app.ui.components.DropLogoIcon +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) + ) { + DropLogoIcon( + size = 24.dp, tint = MaterialTheme.colorScheme.primary + ) + 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/Color.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Color.kt new file mode 100644 index 0000000..09f14d8 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Color.kt @@ -0,0 +1,42 @@ +package dev.arkbuilders.drop.app.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary Colors - Modern Blue Palette +val Blue80 = Color(0xFF82B1FF) +val BlueGrey80 = Color(0xFFB0BEC5) +val Teal80 = Color(0xFF80CBC4) + +val Blue40 = Color(0xFF1976D2) +val BlueGrey40 = Color(0xFF546E7A) +val Teal40 = Color(0xFF00695C) + +// Success Colors +val Green80 = Color(0xFFA5D6A7) +val Green40 = Color(0xFF388E3C) + +// Warning Colors +val Orange80 = Color(0xFFFFCC02) +val Orange40 = Color(0xFFF57C00) + +// Error Colors +val Red80 = Color(0xFFEF9A9A) +val Red40 = Color(0xFFD32F2F) + +// Neutral Colors for better contrast +val Grey10 = Color(0xFFFAFAFA) +val Grey20 = Color(0xFFF5F5F5) +val Grey30 = Color(0xFFEEEEEE) +val Grey40 = Color(0xFFE0E0E0) +val Grey50 = Color(0xFFBDBDBD) +val Grey60 = Color(0xFF9E9E9E) +val Grey70 = Color(0xFF757575) +val Grey80 = Color(0xFF424242) +val Grey90 = Color(0xFF212121) +val Grey95 = Color(0xFF121212) + +// Surface Colors +val SurfaceLight = Color(0xFFFFFBFE) +val SurfaceDark = Color(0xFF1C1B1F) +val SurfaceVariantLight = Color(0xFFF3F0F4) +val SurfaceVariantDark = Color(0xFF49454F) 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 new file mode 100644 index 0000000..0d48435 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/DesignTokens.kt @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..d66aa91 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Theme.kt @@ -0,0 +1,134 @@ +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/TopBar.kt b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/TopBar.kt new file mode 100644 index 0000000..0690a17 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/TopBar.kt @@ -0,0 +1,61 @@ +package dev.arkbuilders.drop.app.ui.theme + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +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.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun TopBar(modifier: Modifier = Modifier) { + Column(modifier) { + val innerModifier = Modifier.fillMaxWidth() + Row( + modifier = innerModifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val innerModifier = Modifier.padding(horizontal = 20.dp, vertical = 5.dp) + Column(modifier = innerModifier) { + Text( + text = "Hi, stranger!", + style = Typography.bodySmall, + ) + Text( + text = "Welcome back!", + style = Typography.titleSmall, + ) + } + IconButton( + 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) + } + } + HorizontalDivider(modifier = innerModifier, thickness = 0.3.dp) + } +} + +@Preview +@Composable +fun AppBarPreview() { + TopBar() +} \ No newline at end of file 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 new file mode 100644 index 0000000..8256ae0 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/ui/theme/Type.kt @@ -0,0 +1,124 @@ +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 new file mode 100644 index 0000000..70d6b8b --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/drop/app/utils/RandomNameGenerator.kt @@ -0,0 +1,181 @@ +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/avatar_00.png b/app/src/main/res/drawable/avatar_00.png new file mode 100644 index 0000000..89b9e31 Binary files /dev/null and b/app/src/main/res/drawable/avatar_00.png differ diff --git a/app/src/main/res/drawable/avatar_01.png b/app/src/main/res/drawable/avatar_01.png new file mode 100644 index 0000000..e6f2f9a Binary files /dev/null and b/app/src/main/res/drawable/avatar_01.png differ diff --git a/app/src/main/res/drawable/avatar_02.png b/app/src/main/res/drawable/avatar_02.png new file mode 100644 index 0000000..fdbbd28 Binary files /dev/null and b/app/src/main/res/drawable/avatar_02.png differ diff --git a/app/src/main/res/drawable/avatar_03.png b/app/src/main/res/drawable/avatar_03.png new file mode 100644 index 0000000..7c4975f Binary files /dev/null and b/app/src/main/res/drawable/avatar_03.png differ diff --git a/app/src/main/res/drawable/avatar_04.png b/app/src/main/res/drawable/avatar_04.png new file mode 100644 index 0000000..353dfef Binary files /dev/null and b/app/src/main/res/drawable/avatar_04.png differ diff --git a/app/src/main/res/drawable/avatar_05.png b/app/src/main/res/drawable/avatar_05.png new file mode 100644 index 0000000..6a55086 Binary files /dev/null and b/app/src/main/res/drawable/avatar_05.png differ diff --git a/app/src/main/res/drawable/avatar_06.png b/app/src/main/res/drawable/avatar_06.png new file mode 100644 index 0000000..3955267 Binary files /dev/null and b/app/src/main/res/drawable/avatar_06.png differ diff --git a/app/src/main/res/drawable/avatar_07.png b/app/src/main/res/drawable/avatar_07.png new file mode 100644 index 0000000..602be18 Binary files /dev/null and b/app/src/main/res/drawable/avatar_07.png differ diff --git a/app/src/main/res/drawable/avatar_08.png b/app/src/main/res/drawable/avatar_08.png new file mode 100644 index 0000000..5c6d7cf Binary files /dev/null and b/app/src/main/res/drawable/avatar_08.png differ diff --git a/app/src/main/res/drawable/avatar_mock.png b/app/src/main/res/drawable/avatar_mock.png deleted file mode 100644 index 52248b9..0000000 Binary files a/app/src/main/res/drawable/avatar_mock.png and /dev/null differ diff --git a/app/src/main/res/drawable/transfer_background.xml b/app/src/main/res/drawable/banner.xml similarity index 91% rename from app/src/main/res/drawable/transfer_background.xml rename to app/src/main/res/drawable/banner.xml index 265d162..a348289 100644 --- a/app/src/main/res/drawable/transfer_background.xml +++ b/app/src/main/res/drawable/banner.xml @@ -91,7 +91,7 @@ android:fillColor="#00000000" android:strokeColor="#E3E8EF"/> @@ -131,7 +131,7 @@ android:fillColor="#00000000" android:strokeColor="#E3E8EF"/> @@ -171,7 +171,7 @@ android:fillColor="#00000000" android:strokeColor="#E3E8EF"/> @@ -211,7 +211,7 @@ android:fillColor="#00000000" android:strokeColor="#E3E8EF"/> @@ -243,7 +243,7 @@ android:fillColor="#00000000" android:strokeColor="#E3E8EF"/> diff --git a/app/src/main/res/drawable/border_radius.xml b/app/src/main/res/drawable/border_radius.xml deleted file mode 100644 index ca2c1c2..0000000 --- a/app/src/main/res/drawable/border_radius.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/corner_borders.xml b/app/src/main/res/drawable/corner_borders.xml deleted file mode 100644 index 1b870d0..0000000 --- a/app/src/main/res/drawable/corner_borders.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_back_new.xml b/app/src/main/res/drawable/ic_back_new.xml deleted file mode 100644 index a3f2097..0000000 --- a/app/src/main/res/drawable/ic_back_new.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_document.xml b/app/src/main/res/drawable/ic_document.xml new file mode 100644 index 0000000..705c9b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_document.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_image.xml b/app/src/main/res/drawable/ic_image.xml new file mode 100644 index 0000000..827d974 --- /dev/null +++ b/app/src/main/res/drawable/ic_image.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9..646201c 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -4,167 +4,21 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d1..3cccda0 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,120 @@ + - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 0000000..9b4dac7 --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_music.xml b/app/src/main/res/drawable/ic_music.xml new file mode 100644 index 0000000..7d7912c --- /dev/null +++ b/app/src/main/res/drawable/ic_music.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pdf.xml b/app/src/main/res/drawable/ic_pdf.xml new file mode 100644 index 0000000..9e7e936 --- /dev/null +++ b/app/src/main/res/drawable/ic_pdf.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_video.xml b/app/src/main/res/drawable/ic_video.xml new file mode 100644 index 0000000..1db746d --- /dev/null +++ b/app/src/main/res/drawable/ic_video.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/layout/activity_qrcode_scanner.xml b/app/src/main/res/layout/activity_qrcode_scanner.xml deleted file mode 100644 index 21d3678..0000000 --- a/app/src/main/res/layout/activity_qrcode_scanner.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - -