From 561c9de3aaf138ffca0d208bea59c33c1e6c223f Mon Sep 17 00:00:00 2001 From: love19870821 <59876241+love19870821@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:40:16 +0800 Subject: [PATCH] Initial multitoolbox app --- README.md | 60 ++++ app/build.gradle.kts | 90 ++++++ app/src/main/AndroidManifest.xml | 26 ++ .../com/toolbox/multitool/MainActivity.kt | 31 ++ .../java/com/toolbox/multitool/ToolboxApp.kt | 7 + .../toolbox/multitool/data/db/AppDatabase.kt | 15 + .../multitool/data/db/CalculatorHistoryDao.kt | 17 ++ .../data/db/CalculatorHistoryEntity.kt | 12 + .../multitool/data/db/CurrencyRateDao.kt | 15 + .../multitool/data/db/CurrencyRateEntity.kt | 11 + .../toolbox/multitool/data/db/SettingsDao.kt | 15 + .../multitool/data/db/SettingsEntity.kt | 13 + .../multitool/data/network/CurrencyApi.kt | 9 + .../data/network/CurrencyResponse.kt | 9 + .../data/repository/CalculatorRepository.kt | 23 ++ .../data/repository/CurrencyRepository.kt | 50 ++++ .../data/repository/SettingsRepository.kt | 13 + .../com/toolbox/multitool/di/AppModule.kt | 72 +++++ .../usecase/CalculateExpressionUseCase.kt | 16 + .../usecase/CurrencyConversionUseCase.kt | 11 + .../domain/usecase/UnitConversionUseCase.kt | 44 +++ .../com/toolbox/multitool/ui/Navigation.kt | 47 +++ .../toolbox/multitool/ui/home/HomeScreen.kt | 57 ++++ .../multitool/ui/image/ImageToolboxScreen.kt | 276 ++++++++++++++++++ .../ui/image/ImageToolboxViewModel.kt | 95 ++++++ .../multitool/ui/settings/SettingsScreen.kt | 55 ++++ .../ui/settings/SettingsViewModel.kt | 32 ++ .../multitool/ui/utilities/UtilitiesScreen.kt | 219 ++++++++++++++ .../ui/utilities/UtilitiesViewModel.kt | 105 +++++++ .../multitool/ui/video/VideoToolboxScreen.kt | 117 ++++++++ .../ui/video/VideoToolboxViewModel.kt | 77 +++++ .../com/toolbox/multitool/util/BitmapUtils.kt | 54 ++++ .../multitool/util/ImageComposition.kt | 68 +++++ .../toolbox/multitool/util/ImageProcessing.kt | 87 ++++++ app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 6 + .../toolbox/multitool/UtilitiesUseCaseTest.kt | 35 +++ build.gradle.kts | 6 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 275 +++++++++++++++++ gradlew.bat | 85 ++++++ settings.gradle.kts | 18 ++ 43 files changed, 2285 insertions(+) create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/toolbox/multitool/MainActivity.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ToolboxApp.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/db/AppDatabase.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/db/CalculatorHistoryDao.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/db/CalculatorHistoryEntity.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/db/CurrencyRateDao.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/db/CurrencyRateEntity.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/db/SettingsDao.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/db/SettingsEntity.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/network/CurrencyApi.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/network/CurrencyResponse.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/repository/CalculatorRepository.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/repository/CurrencyRepository.kt create mode 100644 app/src/main/java/com/toolbox/multitool/data/repository/SettingsRepository.kt create mode 100644 app/src/main/java/com/toolbox/multitool/di/AppModule.kt create mode 100644 app/src/main/java/com/toolbox/multitool/domain/usecase/CalculateExpressionUseCase.kt create mode 100644 app/src/main/java/com/toolbox/multitool/domain/usecase/CurrencyConversionUseCase.kt create mode 100644 app/src/main/java/com/toolbox/multitool/domain/usecase/UnitConversionUseCase.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/Navigation.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/home/HomeScreen.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/image/ImageToolboxScreen.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/image/ImageToolboxViewModel.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/settings/SettingsViewModel.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/utilities/UtilitiesScreen.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/utilities/UtilitiesViewModel.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/video/VideoToolboxScreen.kt create mode 100644 app/src/main/java/com/toolbox/multitool/ui/video/VideoToolboxViewModel.kt create mode 100644 app/src/main/java/com/toolbox/multitool/util/BitmapUtils.kt create mode 100644 app/src/main/java/com/toolbox/multitool/util/ImageComposition.kt create mode 100644 app/src/main/java/com/toolbox/multitool/util/ImageProcessing.kt create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/com/toolbox/multitool/UtilitiesUseCaseTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/README.md b/README.md new file mode 100644 index 0000000..7167f43 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# 多功能工具箱 (Kotlin/Compose) + +本專案為 Android 10+ (API 29+) 的多功能工具箱示範,整合影像 OCR、圖片處理、影片裁剪、下載管理器,以及常用小工具。 + +## 技術選型 +- Kotlin + Jetpack Compose +- MVVM + UseCase +- Hilt DI +- Room (設定/歷史/匯率快取) +- Google ML Kit 繁中 OCR +- OpenCV (去噪/銳化/對比) +- Media3 Transformer (影片裁剪) +- OkHttp + Retrofit (匯率 API + 快取) + +## 專案結構 +``` +app/src/main/java/com/toolbox/multitool +├── data +│ ├── db +│ ├── network +│ └── repository +├── di +├── domain +│ └── usecase +├── ui +│ ├── home +│ ├── image +│ ├── settings +│ ├── utilities +│ └── video +└── util +``` + +## Build / Run +```bash +./gradlew assembleDebug +``` + +如需安裝至裝置: +```bash +./gradlew installDebug +``` + +## APK 打包 +```bash +./gradlew assembleRelease +``` + +輸出路徑: +`app/build/outputs/apk/` + +## 權限說明 +- 相機:拍照 OCR +- 讀取媒體:選圖/選影片 +- 網路:匯率 API 與直連下載 +- MediaStore 寫入:輸出圖片與影片 + +## 注意事項 +- 影片功能僅提供本機匯入裁剪與直連 URL 下載 (DownloadManager) +- 圖片/影片處理皆在本機端完成 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..4b4323e --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,90 @@ +import org.gradle.api.JavaVersion + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") +} + +android { + namespace = "com.toolbox.multitool" + compileSdk = 34 + + defaultConfig { + applicationId = "com.toolbox.multitool" + minSdk = 29 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.10" + } + + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.02.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3:1.2.0") + implementation("androidx.navigation:navigation-compose:2.7.7") + implementation("androidx.compose.material:material:1.6.2") + + implementation("com.google.dagger:hilt-android:2.48") + ksp("com.google.dagger:hilt-android-compiler:2.48") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.6.1") + + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.retrofit2:converter-moshi:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + implementation("com.squareup.moshi:moshi-kotlin:1.15.0") + + implementation("com.google.mlkit:text-recognition-chinese:16.0.0") + + implementation("androidx.media3:media3-transformer:1.3.1") + implementation("androidx.media3:media3-common:1.3.1") + + implementation("org.opencv:opencv-android:4.8.0") + + implementation("net.objecthunter:exp4j:0.4.8") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3") + + testImplementation("junit:junit:4.13.2") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2fee2a6 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/toolbox/multitool/MainActivity.kt b/app/src/main/java/com/toolbox/multitool/MainActivity.kt new file mode 100644 index 0000000..019b768 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/MainActivity.kt @@ -0,0 +1,31 @@ +package com.toolbox.multitool + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.toolbox.multitool.ui.NavigationRoot + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface { + NavigationRoot() + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MainPreview() { + MaterialTheme { + NavigationRoot() + } +} diff --git a/app/src/main/java/com/toolbox/multitool/ToolboxApp.kt b/app/src/main/java/com/toolbox/multitool/ToolboxApp.kt new file mode 100644 index 0000000..0f14574 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ToolboxApp.kt @@ -0,0 +1,7 @@ +package com.toolbox.multitool + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class ToolboxApp : Application() diff --git a/app/src/main/java/com/toolbox/multitool/data/db/AppDatabase.kt b/app/src/main/java/com/toolbox/multitool/data/db/AppDatabase.kt new file mode 100644 index 0000000..f1b2e48 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/db/AppDatabase.kt @@ -0,0 +1,15 @@ +package com.toolbox.multitool.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [CalculatorHistoryEntity::class, SettingsEntity::class, CurrencyRateEntity::class], + version = 1, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + abstract fun calculatorHistoryDao(): CalculatorHistoryDao + abstract fun settingsDao(): SettingsDao + abstract fun currencyRateDao(): CurrencyRateDao +} diff --git a/app/src/main/java/com/toolbox/multitool/data/db/CalculatorHistoryDao.kt b/app/src/main/java/com/toolbox/multitool/data/db/CalculatorHistoryDao.kt new file mode 100644 index 0000000..10cec99 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/db/CalculatorHistoryDao.kt @@ -0,0 +1,17 @@ +package com.toolbox.multitool.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query + +@Dao +interface CalculatorHistoryDao { + @Query("SELECT * FROM calculator_history ORDER BY createdAt DESC") + suspend fun getAll(): List + + @Insert + suspend fun insert(entity: CalculatorHistoryEntity) + + @Query("DELETE FROM calculator_history") + suspend fun clear() +} diff --git a/app/src/main/java/com/toolbox/multitool/data/db/CalculatorHistoryEntity.kt b/app/src/main/java/com/toolbox/multitool/data/db/CalculatorHistoryEntity.kt new file mode 100644 index 0000000..30bb972 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/db/CalculatorHistoryEntity.kt @@ -0,0 +1,12 @@ +package com.toolbox.multitool.data.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "calculator_history") +data class CalculatorHistoryEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val expression: String, + val result: String, + val createdAt: Long +) diff --git a/app/src/main/java/com/toolbox/multitool/data/db/CurrencyRateDao.kt b/app/src/main/java/com/toolbox/multitool/data/db/CurrencyRateDao.kt new file mode 100644 index 0000000..7e767e6 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/db/CurrencyRateDao.kt @@ -0,0 +1,15 @@ +package com.toolbox.multitool.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface CurrencyRateDao { + @Query("SELECT * FROM currency_rates WHERE base = :base") + suspend fun getByBase(base: String): CurrencyRateEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: CurrencyRateEntity) +} diff --git a/app/src/main/java/com/toolbox/multitool/data/db/CurrencyRateEntity.kt b/app/src/main/java/com/toolbox/multitool/data/db/CurrencyRateEntity.kt new file mode 100644 index 0000000..d58c5be --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/db/CurrencyRateEntity.kt @@ -0,0 +1,11 @@ +package com.toolbox.multitool.data.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "currency_rates") +data class CurrencyRateEntity( + @PrimaryKey val base: String, + val updatedAt: Long, + val ratesJson: String +) diff --git a/app/src/main/java/com/toolbox/multitool/data/db/SettingsDao.kt b/app/src/main/java/com/toolbox/multitool/data/db/SettingsDao.kt new file mode 100644 index 0000000..c0e4f55 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/db/SettingsDao.kt @@ -0,0 +1,15 @@ +package com.toolbox.multitool.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface SettingsDao { + @Query("SELECT * FROM settings WHERE id = 1") + suspend fun getSettings(): SettingsEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: SettingsEntity) +} diff --git a/app/src/main/java/com/toolbox/multitool/data/db/SettingsEntity.kt b/app/src/main/java/com/toolbox/multitool/data/db/SettingsEntity.kt new file mode 100644 index 0000000..0ccc24b --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/db/SettingsEntity.kt @@ -0,0 +1,13 @@ +package com.toolbox.multitool.data.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "settings") +data class SettingsEntity( + @PrimaryKey val id: Int = 1, + val outputFormat: String = "PNG", + val defaultExportFolder: String = "多功能工具箱", + val morningTextSize: Float = 32f, + val morningTextColor: String = "#FFFFFF" +) diff --git a/app/src/main/java/com/toolbox/multitool/data/network/CurrencyApi.kt b/app/src/main/java/com/toolbox/multitool/data/network/CurrencyApi.kt new file mode 100644 index 0000000..0b1bfed --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/network/CurrencyApi.kt @@ -0,0 +1,9 @@ +package com.toolbox.multitool.data.network + +import retrofit2.http.GET +import retrofit2.http.Path + +interface CurrencyApi { + @GET("v6/latest/{base}") + suspend fun latest(@Path("base") base: String): CurrencyResponse +} diff --git a/app/src/main/java/com/toolbox/multitool/data/network/CurrencyResponse.kt b/app/src/main/java/com/toolbox/multitool/data/network/CurrencyResponse.kt new file mode 100644 index 0000000..c591c71 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/network/CurrencyResponse.kt @@ -0,0 +1,9 @@ +package com.toolbox.multitool.data.network + +import com.squareup.moshi.Json + +data class CurrencyResponse( + @Json(name = "time_last_update_unix") val updatedAt: Long, + @Json(name = "base_code") val baseCode: String, + val rates: Map +) diff --git a/app/src/main/java/com/toolbox/multitool/data/repository/CalculatorRepository.kt b/app/src/main/java/com/toolbox/multitool/data/repository/CalculatorRepository.kt new file mode 100644 index 0000000..918df18 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/repository/CalculatorRepository.kt @@ -0,0 +1,23 @@ +package com.toolbox.multitool.data.repository + +import com.toolbox.multitool.data.db.CalculatorHistoryDao +import com.toolbox.multitool.data.db.CalculatorHistoryEntity +import javax.inject.Inject + +class CalculatorRepository @Inject constructor( + private val dao: CalculatorHistoryDao +) { + suspend fun getHistory(): List = dao.getAll() + + suspend fun insertHistory(expression: String, result: String) { + dao.insert( + CalculatorHistoryEntity( + expression = expression, + result = result, + createdAt = System.currentTimeMillis() + ) + ) + } + + suspend fun clearHistory() = dao.clear() +} diff --git a/app/src/main/java/com/toolbox/multitool/data/repository/CurrencyRepository.kt b/app/src/main/java/com/toolbox/multitool/data/repository/CurrencyRepository.kt new file mode 100644 index 0000000..f35e186 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/repository/CurrencyRepository.kt @@ -0,0 +1,50 @@ +package com.toolbox.multitool.data.repository + +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.toolbox.multitool.data.db.CurrencyRateDao +import com.toolbox.multitool.data.db.CurrencyRateEntity +import com.toolbox.multitool.data.network.CurrencyApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class CurrencyRepository @Inject constructor( + private val api: CurrencyApi, + private val dao: CurrencyRateDao, + private val moshi: Moshi +) { + private val mapAdapter = moshi.adapter>( + Types.newParameterizedType(Map::class.java, String::class.java, Double::class.javaObjectType) + ) + + suspend fun fetchRates(base: String, forceRefresh: Boolean): CurrencyRateEntity? = withContext(Dispatchers.IO) { + val cached = dao.getByBase(base) + val shouldFetch = forceRefresh || cached == null || isStale(cached.updatedAt) + if (shouldFetch) { + return@withContext try { + val response = api.latest(base) + val entity = CurrencyRateEntity( + base = response.baseCode, + updatedAt = response.updatedAt * 1000, + ratesJson = mapAdapter.toJson(response.rates) + ) + dao.upsert(entity) + entity + } catch (ex: Exception) { + cached + } + } + cached + } + + fun parseRates(entity: CurrencyRateEntity?): Map { + if (entity == null) return emptyMap() + return mapAdapter.fromJson(entity.ratesJson).orEmpty() + } + + private fun isStale(updatedAtMillis: Long): Boolean { + val oneDay = 24 * 60 * 60 * 1000L + return System.currentTimeMillis() - updatedAtMillis > oneDay + } +} diff --git a/app/src/main/java/com/toolbox/multitool/data/repository/SettingsRepository.kt b/app/src/main/java/com/toolbox/multitool/data/repository/SettingsRepository.kt new file mode 100644 index 0000000..9de3953 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/data/repository/SettingsRepository.kt @@ -0,0 +1,13 @@ +package com.toolbox.multitool.data.repository + +import com.toolbox.multitool.data.db.SettingsDao +import com.toolbox.multitool.data.db.SettingsEntity +import javax.inject.Inject + +class SettingsRepository @Inject constructor( + private val dao: SettingsDao +) { + suspend fun getSettings(): SettingsEntity = dao.getSettings() ?: SettingsEntity() + + suspend fun saveSettings(entity: SettingsEntity) = dao.upsert(entity) +} diff --git a/app/src/main/java/com/toolbox/multitool/di/AppModule.kt b/app/src/main/java/com/toolbox/multitool/di/AppModule.kt new file mode 100644 index 0000000..b7ea3b0 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/di/AppModule.kt @@ -0,0 +1,72 @@ +package com.toolbox.multitool.di + +import android.content.Context +import androidx.room.Room +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.toolbox.multitool.data.db.AppDatabase +import com.toolbox.multitool.data.network.CurrencyApi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import okhttp3.Cache +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.databaseBuilder(context, AppDatabase::class.java, "multitool.db").build() + } + + @Provides + fun provideCalculatorHistoryDao(db: AppDatabase) = db.calculatorHistoryDao() + + @Provides + fun provideSettingsDao(db: AppDatabase) = db.settingsDao() + + @Provides + fun provideCurrencyRateDao(db: AppDatabase) = db.currencyRateDao() + + @Provides + @Singleton + fun provideMoshi(): Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + + @Provides + @Singleton + fun provideOkHttp(@ApplicationContext context: Context): OkHttpClient { + val cache = Cache(context.cacheDir, 5L * 1024 * 1024) + val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + return OkHttpClient.Builder() + .cache(cache) + .addInterceptor { chain -> + val request = chain.request().newBuilder() + .header("Cache-Control", "max-age=86400") + .build() + chain.proceed(request) + } + .addInterceptor(logging) + .build() + } + + @Provides + @Singleton + fun provideCurrencyApi(okHttpClient: OkHttpClient, moshi: Moshi): CurrencyApi { + return Retrofit.Builder() + .baseUrl("https://open.er-api.com/") + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(CurrencyApi::class.java) + } +} diff --git a/app/src/main/java/com/toolbox/multitool/domain/usecase/CalculateExpressionUseCase.kt b/app/src/main/java/com/toolbox/multitool/domain/usecase/CalculateExpressionUseCase.kt new file mode 100644 index 0000000..9112105 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/domain/usecase/CalculateExpressionUseCase.kt @@ -0,0 +1,16 @@ +package com.toolbox.multitool.domain.usecase + +import net.objecthunter.exp4j.ExpressionBuilder +import javax.inject.Inject + +class CalculateExpressionUseCase @Inject constructor() { + fun calculate(expression: String): Result { + return try { + val sanitized = expression.replace("%", "/100") + val result = ExpressionBuilder(sanitized).build().evaluate() + Result.success(result) + } catch (ex: Exception) { + Result.failure(ex) + } + } +} diff --git a/app/src/main/java/com/toolbox/multitool/domain/usecase/CurrencyConversionUseCase.kt b/app/src/main/java/com/toolbox/multitool/domain/usecase/CurrencyConversionUseCase.kt new file mode 100644 index 0000000..a801aee --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/domain/usecase/CurrencyConversionUseCase.kt @@ -0,0 +1,11 @@ +package com.toolbox.multitool.domain.usecase + +import javax.inject.Inject + +class CurrencyConversionUseCase @Inject constructor() { + fun convert(rates: Map, from: String, to: String, amount: Double): Double { + val fromRate = rates[from] ?: return 0.0 + val toRate = rates[to] ?: return 0.0 + return amount / fromRate * toRate + } +} diff --git a/app/src/main/java/com/toolbox/multitool/domain/usecase/UnitConversionUseCase.kt b/app/src/main/java/com/toolbox/multitool/domain/usecase/UnitConversionUseCase.kt new file mode 100644 index 0000000..8c9452c --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/domain/usecase/UnitConversionUseCase.kt @@ -0,0 +1,44 @@ +package com.toolbox.multitool.domain.usecase + +import javax.inject.Inject +import kotlin.math.roundToLong + +class UnitConversionUseCase @Inject constructor() { + fun convert(category: UnitCategory, from: UnitItem, to: UnitItem, value: Double): Double { + return when (category) { + UnitCategory.Temperature -> convertTemperature(from, to, value) + else -> value * from.toBaseFactor / to.toBaseFactor + } + } + + private fun convertTemperature(from: UnitItem, to: UnitItem, value: Double): Double { + val celsius = when (from.id) { + "C" -> value + "F" -> (value - 32) * 5 / 9 + "K" -> value - 273.15 + else -> value + } + return when (to.id) { + "C" -> celsius + "F" -> celsius * 9 / 5 + 32 + "K" -> celsius + 273.15 + else -> celsius + } + } + + fun format(value: Double): String { + val rounded = (value * 1000).roundToLong() / 1000.0 + return rounded.toString() + } +} + +enum class UnitCategory(val label: String) { + Length("長度"), + Weight("重量"), + Area("面積"), + Volume("體積"), + Speed("速度"), + Temperature("溫度") +} + +data class UnitItem(val id: String, val label: String, val toBaseFactor: Double) diff --git a/app/src/main/java/com/toolbox/multitool/ui/Navigation.kt b/app/src/main/java/com/toolbox/multitool/ui/Navigation.kt new file mode 100644 index 0000000..74bb9ce --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/Navigation.kt @@ -0,0 +1,47 @@ +package com.toolbox.multitool.ui + +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 com.toolbox.multitool.ui.home.HomeScreen +import com.toolbox.multitool.ui.image.ImageToolboxScreen +import com.toolbox.multitool.ui.settings.SettingsScreen +import com.toolbox.multitool.ui.utilities.UtilitiesScreen +import com.toolbox.multitool.ui.video.VideoToolboxScreen + +object Routes { + const val HOME = "home" + const val IMAGE = "image" + const val VIDEO = "video" + const val UTILITIES = "utilities" + const val SETTINGS = "settings" +} + +@Composable +fun NavigationRoot(modifier: Modifier = Modifier, navController: NavHostController = rememberNavController()) { + NavHost(modifier = modifier, navController = navController, startDestination = Routes.HOME) { + composable(Routes.HOME) { + HomeScreen( + onImage = { navController.navigate(Routes.IMAGE) }, + onVideo = { navController.navigate(Routes.VIDEO) }, + onUtilities = { navController.navigate(Routes.UTILITIES) }, + onSettings = { navController.navigate(Routes.SETTINGS) } + ) + } + composable(Routes.IMAGE) { + ImageToolboxScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.VIDEO) { + VideoToolboxScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.UTILITIES) { + UtilitiesScreen(onBack = { navController.popBackStack() }) + } + composable(Routes.SETTINGS) { + SettingsScreen(onBack = { navController.popBackStack() }) + } + } +} diff --git a/app/src/main/java/com/toolbox/multitool/ui/home/HomeScreen.kt b/app/src/main/java/com/toolbox/multitool/ui/home/HomeScreen.kt new file mode 100644 index 0000000..998883f --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/home/HomeScreen.kt @@ -0,0 +1,57 @@ +package com.toolbox.multitool.ui.home + +import androidx.compose.foundation.layout.Arrangement +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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun HomeScreen( + onImage: () -> Unit, + onVideo: () -> Unit, + onUtilities: () -> Unit, + onSettings: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "多功能工具箱", style = MaterialTheme.typography.headlineMedium) + + HomeCard(title = "圖片工具箱", description = "OCR、浮水印、清晰化、拼貼、早安圖", onClick = onImage) + HomeCard(title = "影片工具箱", description = "裁剪與直連下載", onClick = onVideo) + HomeCard(title = "小工具", description = "計算機、幣值/單位換算", onClick = onUtilities) + + Button(onClick = onSettings, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp)) { + Text(text = "設定") + } + } +} + +@Composable +private fun HomeCard(title: String, description: String, onClick: () -> Unit) { + Card( + colors = CardDefaults.cardColors(), + onClick = onClick, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text(text = title, style = MaterialTheme.typography.titleLarge) + Text(text = description, style = MaterialTheme.typography.bodyMedium) + } + } +} diff --git a/app/src/main/java/com/toolbox/multitool/ui/image/ImageToolboxScreen.kt b/app/src/main/java/com/toolbox/multitool/ui/image/ImageToolboxScreen.kt new file mode 100644 index 0000000..1869630 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/image/ImageToolboxScreen.kt @@ -0,0 +1,276 @@ +package com.toolbox.multitool.ui.image + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.graphics.asImageBitmap +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.toolbox.multitool.util.BitmapUtils +import androidx.compose.ui.graphics.Color + +@Composable +fun ImageToolboxScreen(onBack: () -> Unit, viewModel: ImageToolboxViewModel = hiltViewModel()) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val ocrText by viewModel.ocrText.collectAsState() + val watermarkBitmap by viewModel.watermarkBitmap.collectAsState() + val enhancedBitmap by viewModel.enhancedBitmap.collectAsState() + val collageBitmap by viewModel.collageBitmap.collectAsState() + val goodMorningBitmap by viewModel.goodMorningBitmap.collectAsState() + + var baseImage by remember { mutableStateOf(null) } + var watermarkImage by remember { mutableStateOf(null) } + var collageImages by remember { mutableStateOf>(emptyList()) } + + val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { viewModel.runOcr(context.contentResolver, it) } + } + + val cameraUri = remember { mutableStateOf(null) } + val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> + if (success) { + cameraUri.value?.let { viewModel.runOcr(context.contentResolver, it) } + } + } + + val baseImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { baseImage = BitmapUtils.loadBitmap(context.contentResolver, it) } + } + + val watermarkImageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { watermarkImage = BitmapUtils.loadBitmap(context.contentResolver, it) } + } + + val enhanceLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { baseImage = BitmapUtils.loadBitmap(context.contentResolver, it) } + } + + val collageLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + collageImages = uris.map { BitmapUtils.loadBitmap(context.contentResolver, it) } + } + + LazyColumn(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + item { + Text(text = "圖片工具箱", style = MaterialTheme.typography.headlineSmall) + Button(onClick = onBack) { Text(text = "返回") } + } + + item { + FeatureCard(title = "圖片中文字擷取 (OCR)") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { galleryLauncher.launch("image/*") }) { Text(text = "從相簿選圖") } + Button(onClick = { + cameraUri.value = createImageUri(context) + cameraLauncher.launch(cameraUri.value) + }) { Text(text = "拍照辨識") } + } + Spacer(modifier = Modifier.height(8.dp)) + Text(text = ocrText.ifBlank { "尚未辨識" }) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { copyToClipboard(clipboardManager, ocrText) }) { Text(text = "複製結果") } + Button(onClick = { BitmapUtils.saveTextToDownloads(context, ocrText, "ocr_result.txt") }) { Text(text = "匯出 txt") } + } + } + } + + item { + var watermarkText by remember { mutableStateOf("© 多功能工具箱") } + FeatureCard(title = "圖片加浮水印") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { baseImageLauncher.launch("image/*") }) { Text(text = "選擇底圖") } + Button(onClick = { watermarkImageLauncher.launch("image/*") }) { Text(text = "選擇 Logo") } + } + OutlinedTextField(value = watermarkText, onValueChange = { watermarkText = it }, label = { Text("文字浮水印") }) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + baseImage?.let { viewModel.applyTextWatermark(it, watermarkText) } + }) { Text(text = "套用文字浮水印") } + Button(onClick = { + val base = baseImage + val mark = watermarkImage + if (base != null && mark != null) { + viewModel.applyImageWatermark(base, mark) + } + }) { Text(text = "套用圖片浮水印") } + } + watermarkBitmap?.let { + Image(bitmap = it.asImageBitmap(), contentDescription = "水印結果", modifier = Modifier.fillMaxWidth().height(200.dp)) + Button(onClick = { + BitmapUtils.saveBitmapToGallery(context, it, "watermark_${System.currentTimeMillis()}", android.graphics.Bitmap.CompressFormat.PNG) + }) { Text(text = "存到相簿") } + } + } + } + + item { + FeatureCard(title = "模糊圖片變清晰") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { enhanceLauncher.launch("image/*") }) { Text(text = "選擇圖片") } + Button(onClick = { baseImage?.let { viewModel.enhanceImage(it, 1) } }) { Text(text = "低強度") } + Button(onClick = { baseImage?.let { viewModel.enhanceImage(it, 2) } }) { Text(text = "中強度") } + Button(onClick = { baseImage?.let { viewModel.enhanceImage(it, 3) } }) { Text(text = "高強度") } + } + enhancedBitmap?.let { + Image(bitmap = it.asImageBitmap(), contentDescription = "清晰化", modifier = Modifier.fillMaxWidth().height(200.dp)) + } + } + } + + item { + FeatureCard(title = "多圖片拼貼") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { collageLauncher.launch("image/*") }) { Text(text = "選擇多張圖片") } + Button(onClick = { if (collageImages.isNotEmpty()) viewModel.createCollage(collageImages, 2, 2) }) { Text(text = "2x2") } + Button(onClick = { if (collageImages.isNotEmpty()) viewModel.createCollage(collageImages, 3, 3) }) { Text(text = "3x3") } + } + collageBitmap?.let { + Image(bitmap = it.asImageBitmap(), contentDescription = "拼貼結果", modifier = Modifier.fillMaxWidth().height(200.dp)) + Button(onClick = { + BitmapUtils.saveBitmapToGallery(context, it, "collage_${System.currentTimeMillis()}", android.graphics.Bitmap.CompressFormat.PNG) + }) { Text(text = "輸出圖片") } + } + } + } + + item { + val templates = listOf( + listOf(Color(0xFFB0E0E6), Color(0xFF87CEFA)), + listOf(Color(0xFFFFC0CB), Color(0xFFFFE4E1)), + listOf(Color(0xFFB6E3C6), Color(0xFF4CAF50)), + listOf(Color(0xFFFFF3E0), Color(0xFFFFCC80)), + listOf(Color(0xFFE1BEE7), Color(0xFF8E24AA)), + listOf(Color(0xFFFFF59D), Color(0xFFFFA000)), + listOf(Color(0xFFB3E5FC), Color(0xFF0288D1)), + listOf(Color(0xFFC8E6C9), Color(0xFF2E7D32)), + listOf(Color(0xFFFFE0B2), Color(0xFFF57C00)), + listOf(Color(0xFFD7CCC8), Color(0xFF5D4037)), + listOf(Color(0xFFF8BBD0), Color(0xFFAD1457)), + listOf(Color(0xFFBBDEFB), Color(0xFF1976D2)) + ) + val messages = listOf( + "早安!願你今天心情明亮", + "晨光灑滿窗,祝你順心", + "新的一天,微笑出發", + "早安,今天也要元氣滿滿", + "願你今日步步順利", + "早起的你最棒,保持好心情", + "天氣再忙,也別忘了微笑", + "新的日子,新的希望", + "起床啦,迎接好運", + "早安,願一切美好如期而至", + "願你今天充滿能量", + "陽光照進心裡,早安", + "祝福你,今天也很順", + "早安,願你平安喜樂", + "一天的好心情從早安開始", + "今天也要溫柔對待自己", + "早安!願你事事如意", + "新的一天,加油!", + "祝你今天充滿笑容", + "早安,願你收穫滿滿", + "一杯咖啡,一句早安", + "用微笑開啟今天", + "早安,願你身心舒暢", + "願你今天順順利利", + "早安,今天也要好好生活", + "希望你今天一切都好", + "早安,保持熱愛", + "祝你今天工作順心", + "早安,和幸福同行", + "願你今天靈感滿滿", + "早安,心想事成", + "新的開始,新的成長", + "早安,充滿勇氣與力量", + "今天也要元氣滿滿", + "早安,願你平安健康", + "願你今天好事連連", + "早安,祝你順利完成目標", + "今天也要努力發光", + "早安,迎接美好", + "願你今天心情清朗", + "早安,天天好運氣", + "開心的一天從早安開始", + "早安,願你被溫柔以待", + "今天也要記得微笑", + "早安,享受生活的美好", + "願你今日自在從容", + "早安,擁抱每個可能", + "讓好心情陪你一整天", + "早安,祝你平安順遂", + "早安,今天也要閃閃發亮" + ) + var message by remember { mutableStateOf(messages.first()) } + FeatureCard(title = "繁中早安圖生成") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { message = messages.random() }) { Text(text = "隨機文案") } + Button(onClick = { viewModel.createGoodMorning(templates.random(), message) }) { Text(text = "產生早安圖") } + } + OutlinedTextField(value = message, onValueChange = { message = it }, label = { Text("文案內容") }) + goodMorningBitmap?.let { + Image(bitmap = it.asImageBitmap(), contentDescription = "早安圖", modifier = Modifier.fillMaxWidth().height(200.dp)) + Button(onClick = { + BitmapUtils.saveBitmapToGallery(context, it, "morning_${System.currentTimeMillis()}", android.graphics.Bitmap.CompressFormat.PNG) + }) { Text(text = "輸出到相簿") } + } + } + } + + item { + Divider() + } + } +} + +@Composable +private fun FeatureCard(title: String, content: @Composable () -> Unit) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + content() + } + } +} + +private fun copyToClipboard(clipboardManager: ClipboardManager, text: String) { + clipboardManager.setText(AnnotatedString(text)) +} + +private fun createImageUri(context: Context): Uri? { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "ocr_${System.currentTimeMillis()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + } + return context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) +} diff --git a/app/src/main/java/com/toolbox/multitool/ui/image/ImageToolboxViewModel.kt b/app/src/main/java/com/toolbox/multitool/ui/image/ImageToolboxViewModel.kt new file mode 100644 index 0000000..5570455 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/image/ImageToolboxViewModel.kt @@ -0,0 +1,95 @@ +package com.toolbox.multitool.ui.image + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions +import com.toolbox.multitool.util.ImageComposition +import com.toolbox.multitool.util.ImageProcessing +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ImageToolboxViewModel @Inject constructor() : ViewModel() { + private val _ocrText = MutableStateFlow("") + val ocrText: StateFlow = _ocrText + + private val _watermarkBitmap = MutableStateFlow(null) + val watermarkBitmap: StateFlow = _watermarkBitmap + + private val _enhancedBitmap = MutableStateFlow(null) + val enhancedBitmap: StateFlow = _enhancedBitmap + + private val _collageBitmap = MutableStateFlow(null) + val collageBitmap: StateFlow = _collageBitmap + + private val _goodMorningBitmap = MutableStateFlow(null) + val goodMorningBitmap: StateFlow = _goodMorningBitmap + + fun runOcr(contentResolver: ContentResolver, uri: Uri) { + viewModelScope.launch { + try { + val image = InputImage.fromFilePath(contentResolver, uri) + val recognizer = TextRecognition.getClient(ChineseTextRecognizerOptions.Builder().build()) + val result = recognizer.process(image).awaitText() + _ocrText.value = result + } catch (ex: Exception) { + _ocrText.value = "辨識失敗:${ex.message ?: "未知錯誤"}" + } + } + } + + fun applyTextWatermark(source: Bitmap, text: String) { + _watermarkBitmap.value = ImageProcessing.addTextWatermark( + bitmap = source, + text = text, + color = android.graphics.Color.WHITE, + alpha = 180, + size = 48f, + rotation = -20f, + positionX = source.width * 0.1f, + positionY = source.height * 0.9f + ) + } + + fun applyImageWatermark(source: Bitmap, watermark: Bitmap) { + _watermarkBitmap.value = ImageProcessing.addImageWatermark( + bitmap = source, + watermark = watermark, + alpha = 180, + scale = 0.2f, + positionX = source.width * 0.7f, + positionY = source.height * 0.7f + ) + } + + fun enhanceImage(source: Bitmap, strength: Int) { + _enhancedBitmap.value = ImageProcessing.enhance(source, strength) + } + + fun createCollage(bitmaps: List, columns: Int, rows: Int) { + _collageBitmap.value = ImageComposition.createCollage(bitmaps, columns, rows, 400) + } + + fun createGoodMorning(templateColors: List, message: String) { + _goodMorningBitmap.value = ImageComposition.createGoodMorningImage( + width = 1080, + height = 1350, + backgroundColors = templateColors, + message = message, + textSize = 64f, + textColor = androidx.compose.ui.graphics.Color.White + ) + } +} + +private suspend fun com.google.android.gms.tasks.Task.awaitText(): String { + return kotlinx.coroutines.tasks.await(this).text +} diff --git a/app/src/main/java/com/toolbox/multitool/ui/settings/SettingsScreen.kt b/app/src/main/java/com/toolbox/multitool/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..f92155d --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/settings/SettingsScreen.kt @@ -0,0 +1,55 @@ +package com.toolbox.multitool.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.toolbox.multitool.data.db.SettingsEntity + +@Composable +fun SettingsScreen(onBack: () -> Unit, viewModel: SettingsViewModel = hiltViewModel()) { + val settings by viewModel.settings.collectAsState() + var outputFormat by remember { mutableStateOf(settings.outputFormat) } + var folder by remember { mutableStateOf(settings.defaultExportFolder) } + var morningSize by remember { mutableStateOf(settings.morningTextSize.toString()) } + var morningColor by remember { mutableStateOf(settings.morningTextColor) } + + LaunchedEffect(Unit) { viewModel.load() } + + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(text = "設定", style = MaterialTheme.typography.headlineSmall) + Button(onClick = onBack) { Text(text = "返回") } + + OutlinedTextField(value = outputFormat, onValueChange = { outputFormat = it }, label = { Text("輸出格式 PNG/JPG") }) + OutlinedTextField(value = folder, onValueChange = { folder = it }, label = { Text("預設匯出資料夾") }) + OutlinedTextField(value = morningSize, onValueChange = { morningSize = it }, label = { Text("早安圖文字大小") }) + OutlinedTextField(value = morningColor, onValueChange = { morningColor = it }, label = { Text("早安圖文字顏色") }) + + Button(onClick = { + viewModel.save( + SettingsEntity( + outputFormat = outputFormat, + defaultExportFolder = folder, + morningTextSize = morningSize.toFloatOrNull() ?: settings.morningTextSize, + morningTextColor = morningColor + ) + ) + }, modifier = Modifier.fillMaxWidth()) { + Text(text = "保存設定") + } + } +} diff --git a/app/src/main/java/com/toolbox/multitool/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/toolbox/multitool/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..cb92e99 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/settings/SettingsViewModel.kt @@ -0,0 +1,32 @@ +package com.toolbox.multitool.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.toolbox.multitool.data.db.SettingsEntity +import com.toolbox.multitool.data.repository.SettingsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val repository: SettingsRepository +) : ViewModel() { + private val _settings = MutableStateFlow(SettingsEntity()) + val settings: StateFlow = _settings + + fun load() { + viewModelScope.launch { + _settings.value = repository.getSettings() + } + } + + fun save(settings: SettingsEntity) { + viewModelScope.launch { + repository.saveSettings(settings) + _settings.value = settings + } + } +} diff --git a/app/src/main/java/com/toolbox/multitool/ui/utilities/UtilitiesScreen.kt b/app/src/main/java/com/toolbox/multitool/ui/utilities/UtilitiesScreen.kt new file mode 100644 index 0000000..6fb5183 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/utilities/UtilitiesScreen.kt @@ -0,0 +1,219 @@ +package com.toolbox.multitool.ui.utilities + +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.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.toolbox.multitool.domain.usecase.UnitCategory +import com.toolbox.multitool.domain.usecase.UnitItem + +@Composable +fun UtilitiesScreen(onBack: () -> Unit, viewModel: UtilitiesViewModel = hiltViewModel()) { + val display by viewModel.display.collectAsState() + val history by viewModel.history.collectAsState() + val currencyRates by viewModel.currencyRates.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadHistory() + viewModel.fetchCurrency() + } + + LazyColumn(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + item { + Text(text = "小工具", style = MaterialTheme.typography.headlineSmall) + Button(onClick = onBack) { Text(text = "返回") } + } + + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = "計算機") + Text(text = "顯示:$display") + CalculatorButtons( + onInput = viewModel::appendValue, + onClear = viewModel::clear, + onEquals = viewModel::calculate, + onMemoryPlus = viewModel::memoryPlus, + onMemoryMinus = viewModel::memoryMinus, + onMemoryRecall = viewModel::memoryRecall, + onMemoryClear = viewModel::memoryClear + ) + Button(onClick = viewModel::clearHistory) { Text(text = "清除歷史") } + history.take(5).forEach { Text(text = it) } + } + } + } + + item { + var amount by remember { mutableStateOf("100") } + var from by remember { mutableStateOf("USD") } + var to by remember { mutableStateOf("TWD") } + val result = if (currencyRates.isNotEmpty()) { + viewModel.convertCurrency(from, to, amount.toDoubleOrNull() ?: 0.0) + } else { + "尚未取得匯率" + } + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = "幣值換算") + OutlinedTextField(value = amount, onValueChange = { amount = it }, label = { Text("金額") }) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = from, onValueChange = { from = it }, label = { Text("來源幣別") }) + OutlinedTextField(value = to, onValueChange = { to = it }, label = { Text("目標幣別") }) + } + Text(text = "結果:$result") + Button(onClick = { viewModel.fetchCurrency(forceRefresh = true) }) { Text(text = "更新匯率") } + } + } + } + + item { + val categories = UnitCategory.values().toList() + var selectedCategory by remember { mutableStateOf(categories.first()) } + var fromValue by remember { mutableStateOf("1") } + var fromUnit by remember { mutableStateOf(unitItems(selectedCategory).first()) } + var toUnit by remember { mutableStateOf(unitItems(selectedCategory).last()) } + val conversionResult = viewModel.convertUnit(selectedCategory, fromUnit, toUnit, fromValue.toDoubleOrNull() ?: 0.0) + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = "單位換算") + Text(text = "類別:${selectedCategory.label}") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + selectedCategory = categories[(categories.indexOf(selectedCategory) + 1) % categories.size] + fromUnit = unitItems(selectedCategory).first() + toUnit = unitItems(selectedCategory).last() + }) { Text(text = "切換類別") } + } + OutlinedTextField(value = fromValue, onValueChange = { fromValue = it }, label = { Text("輸入值") }) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = fromUnit.label, onValueChange = { }, label = { Text("來源單位") }, readOnly = true) + OutlinedTextField(value = toUnit.label, onValueChange = { }, label = { Text("目標單位") }, readOnly = true) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { fromUnit = nextUnit(selectedCategory, fromUnit) }) { Text(text = "切換來源") } + Button(onClick = { toUnit = nextUnit(selectedCategory, toUnit) }) { Text(text = "切換目標") } + } + Text(text = "結果:$conversionResult") + } + } + } + } +} + +@Composable +private fun CalculatorButtons( + onInput: (String) -> Unit, + onClear: () -> Unit, + onEquals: () -> Unit, + onMemoryPlus: () -> Unit, + onMemoryMinus: () -> Unit, + onMemoryRecall: () -> Unit, + onMemoryClear: () -> Unit +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = onMemoryPlus) { Text(text = "M+") } + Button(onClick = onMemoryMinus) { Text(text = "M-") } + Button(onClick = onMemoryRecall) { Text(text = "MR") } + Button(onClick = onMemoryClear) { Text(text = "MC") } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = { onInput("sin(") }) { Text(text = "sin") } + Button(onClick = { onInput("cos(") }) { Text(text = "cos") } + Button(onClick = { onInput("tan(") }) { Text(text = "tan") } + Button(onClick = { onInput("log(") }) { Text(text = "log") } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = { onInput("ln(") }) { Text(text = "ln") } + Button(onClick = { onInput("sqrt(") }) { Text(text = "√") } + Button(onClick = { onInput("^2") }) { Text(text = "x²") } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = { onInput("7") }) { Text(text = "7") } + Button(onClick = { onInput("8") }) { Text(text = "8") } + Button(onClick = { onInput("9") }) { Text(text = "9") } + Button(onClick = { onInput("/") }) { Text(text = "÷") } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = { onInput("4") }) { Text(text = "4") } + Button(onClick = { onInput("5") }) { Text(text = "5") } + Button(onClick = { onInput("6") }) { Text(text = "6") } + Button(onClick = { onInput("*") }) { Text(text = "×") } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = { onInput("1") }) { Text(text = "1") } + Button(onClick = { onInput("2") }) { Text(text = "2") } + Button(onClick = { onInput("3") }) { Text(text = "3") } + Button(onClick = { onInput("-") }) { Text(text = "-") } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = { onInput("0") }) { Text(text = "0") } + Button(onClick = { onInput(".") }) { Text(text = ".") } + Button(onClick = { onInput("%") }) { Text(text = "%") } + Button(onClick = { onInput("+") }) { Text(text = "+") } + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = onClear) { Text(text = "C") } + Button(onClick = onEquals) { Text(text = "=") } + } + } +} + +private fun unitItems(category: UnitCategory): List { + return when (category) { + UnitCategory.Length -> listOf( + UnitItem("m", "公尺", 1.0), + UnitItem("km", "公里", 1000.0), + UnitItem("cm", "公分", 0.01) + ) + UnitCategory.Weight -> listOf( + UnitItem("kg", "公斤", 1.0), + UnitItem("g", "公克", 0.001), + UnitItem("lb", "磅", 0.453592) + ) + UnitCategory.Area -> listOf( + UnitItem("m2", "平方公尺", 1.0), + UnitItem("ha", "公頃", 10000.0), + UnitItem("ft2", "平方英尺", 0.092903) + ) + UnitCategory.Volume -> listOf( + UnitItem("l", "公升", 1.0), + UnitItem("ml", "毫升", 0.001), + UnitItem("m3", "立方公尺", 1000.0) + ) + UnitCategory.Speed -> listOf( + UnitItem("mps", "公尺/秒", 1.0), + UnitItem("kph", "公里/小時", 0.277778), + UnitItem("mph", "英里/小時", 0.44704) + ) + UnitCategory.Temperature -> listOf( + UnitItem("C", "攝氏", 1.0), + UnitItem("F", "華氏", 1.0), + UnitItem("K", "開氏", 1.0) + ) + } +} + +private fun nextUnit(category: UnitCategory, current: UnitItem): UnitItem { + val items = unitItems(category) + val index = items.indexOfFirst { it.id == current.id } + return items[(index + 1) % items.size] +} diff --git a/app/src/main/java/com/toolbox/multitool/ui/utilities/UtilitiesViewModel.kt b/app/src/main/java/com/toolbox/multitool/ui/utilities/UtilitiesViewModel.kt new file mode 100644 index 0000000..d7c6bd5 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/utilities/UtilitiesViewModel.kt @@ -0,0 +1,105 @@ +package com.toolbox.multitool.ui.utilities + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.toolbox.multitool.data.repository.CalculatorRepository +import com.toolbox.multitool.data.repository.CurrencyRepository +import com.toolbox.multitool.domain.usecase.CalculateExpressionUseCase +import com.toolbox.multitool.domain.usecase.CurrencyConversionUseCase +import com.toolbox.multitool.domain.usecase.UnitCategory +import com.toolbox.multitool.domain.usecase.UnitConversionUseCase +import com.toolbox.multitool.domain.usecase.UnitItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class UtilitiesViewModel @Inject constructor( + private val calculatorRepository: CalculatorRepository, + private val calculateExpressionUseCase: CalculateExpressionUseCase, + private val unitConversionUseCase: UnitConversionUseCase, + private val currencyRepository: CurrencyRepository, + private val currencyConversionUseCase: CurrencyConversionUseCase +) : ViewModel() { + private val _display = MutableStateFlow("0") + val display: StateFlow = _display + + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history + + private val _currencyRates = MutableStateFlow>(emptyMap()) + val currencyRates: StateFlow> = _currencyRates + + private var memoryValue: Double = 0.0 + + fun appendValue(value: String) { + _display.value = if (_display.value == "0") value else _display.value + value + } + + fun clear() { + _display.value = "0" + } + + fun calculate() { + val expression = _display.value + val result = calculateExpressionUseCase.calculate(expression) + result.onSuccess { + _display.value = it.toString() + viewModelScope.launch { + calculatorRepository.insertHistory(expression, it.toString()) + loadHistory() + } + }.onFailure { + _display.value = "錯誤" + } + } + + fun memoryPlus() { + memoryValue += _display.value.toDoubleOrNull() ?: 0.0 + } + + fun memoryMinus() { + memoryValue -= _display.value.toDoubleOrNull() ?: 0.0 + } + + fun memoryRecall() { + _display.value = memoryValue.toString() + } + + fun memoryClear() { + memoryValue = 0.0 + } + + fun loadHistory() { + viewModelScope.launch { + val entries = calculatorRepository.getHistory() + _history.value = entries.map { "${it.expression} = ${it.result}" } + } + } + + fun clearHistory() { + viewModelScope.launch { + calculatorRepository.clearHistory() + _history.value = emptyList() + } + } + + fun convertUnit(category: UnitCategory, from: UnitItem, to: UnitItem, value: Double): String { + val result = unitConversionUseCase.convert(category, from, to, value) + return unitConversionUseCase.format(result) + } + + fun fetchCurrency(base: String = "USD", forceRefresh: Boolean = false) { + viewModelScope.launch { + val entity = currencyRepository.fetchRates(base, forceRefresh) + _currencyRates.value = currencyRepository.parseRates(entity) + } + } + + fun convertCurrency(from: String, to: String, amount: Double): String { + val result = currencyConversionUseCase.convert(_currencyRates.value, from, to, amount) + return unitConversionUseCase.format(result) + } +} diff --git a/app/src/main/java/com/toolbox/multitool/ui/video/VideoToolboxScreen.kt b/app/src/main/java/com/toolbox/multitool/ui/video/VideoToolboxScreen.kt new file mode 100644 index 0000000..03dd761 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/video/VideoToolboxScreen.kt @@ -0,0 +1,117 @@ +package com.toolbox.multitool.ui.video + +import android.app.DownloadManager +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Environment +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.common.util.UnstableApi + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun VideoToolboxScreen(onBack: () -> Unit, viewModel: VideoToolboxViewModel = hiltViewModel()) { + val context = LocalContext.current + val status by viewModel.status.collectAsState() + var selectedVideoUri by remember { mutableStateOf(null) } + var durationMs by remember { mutableStateOf(0L) } + var startMs by remember { mutableStateOf(0f) } + var endMs by remember { mutableStateOf(0f) } + var downloadUrl by remember { mutableStateOf("") } + + val videoPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + selectedVideoUri = uri + if (uri != null) { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(context, uri) + durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + retriever.release() + startMs = 0f + endMs = durationMs.toFloat() + } + } + + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(text = "影片工具箱", style = MaterialTheme.typography.headlineSmall) + Button(onClick = onBack) { Text(text = "返回") } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = "本機影片裁剪") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { videoPicker.launch("video/*") }) { Text(text = "選擇影片") } + Button(onClick = { + val uri = selectedVideoUri + if (uri != null && durationMs > 0) { + viewModel.trimVideo(context, uri, startMs.toLong(), endMs.toLong()) + } + }) { Text(text = "開始裁剪") } + } + if (durationMs > 0) { + Text(text = "起始 ${startMs.toLong() / 1000}s / 結束 ${endMs.toLong() / 1000}s") + Slider( + value = startMs, + onValueChange = { startMs = it.coerceIn(0f, endMs) }, + valueRange = 0f..durationMs.toFloat(), + modifier = Modifier.fillMaxWidth() + ) + Slider( + value = endMs, + onValueChange = { endMs = it.coerceIn(startMs, durationMs.toFloat()) }, + valueRange = 0f..durationMs.toFloat(), + modifier = Modifier.fillMaxWidth() + ) + } + Text(text = status) + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = "直連 URL 下載器") + OutlinedTextField( + value = downloadUrl, + onValueChange = { downloadUrl = it }, + label = { Text(text = "HTTP/HTTPS 直連 URL") }, + modifier = Modifier.fillMaxWidth() + ) + Button(onClick = { startDownload(context, downloadUrl) }) { Text(text = "加入下載") } + } + } + } +} + +private fun startDownload(context: Context, url: String) { + if (!url.startsWith("http")) return + val request = DownloadManager.Request(Uri.parse(url)) + .setTitle("下載檔案") + .setDescription("多功能工具箱下載中") + .setAllowedOverMetered(true) + .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, url.substringAfterLast("/")) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + manager.enqueue(request) +} diff --git a/app/src/main/java/com/toolbox/multitool/ui/video/VideoToolboxViewModel.kt b/app/src/main/java/com/toolbox/multitool/ui/video/VideoToolboxViewModel.kt new file mode 100644 index 0000000..11c9399 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/ui/video/VideoToolboxViewModel.kt @@ -0,0 +1,77 @@ +package com.toolbox.multitool.ui.video + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.transformer.ClippingConfiguration +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.Transformer +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +@HiltViewModel +class VideoToolboxViewModel @Inject constructor() : ViewModel() { + private val _status = MutableStateFlow("") + val status: StateFlow = _status + + @UnstableApi + fun trimVideo(context: Context, uri: Uri, startMs: Long, endMs: Long) { + viewModelScope.launch { + try { + val tempFile = File(context.cacheDir, "trim_${System.currentTimeMillis()}.mp4") + val editedMediaItem = EditedMediaItem.Builder(MediaItem.fromUri(uri)) + .setClippingConfiguration( + ClippingConfiguration.Builder() + .setStartPositionMs(startMs) + .setEndPositionMs(endMs) + .build() + ) + .build() + val transformer = Transformer.Builder(context) + .addListener(object : Transformer.Listener { + override fun onCompleted(composition: androidx.media3.transformer.Composition, result: Transformer.ExportResult) { + saveToGallery(context.contentResolver, tempFile) + _status.value = "裁剪完成並已儲存" + } + + override fun onError(composition: androidx.media3.transformer.Composition, result: Transformer.ExportResult, exportException: ExportException) { + _status.value = "裁剪失敗:${exportException.message}" + } + }) + .build() + transformer.start(editedMediaItem, tempFile.absolutePath) + _status.value = "裁剪中..." + } catch (ex: Exception) { + _status.value = "裁剪失敗:${ex.message}" + } + } + } + + private fun saveToGallery(resolver: ContentResolver, file: File) { + val values = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, file.name) + put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") + put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES + "/多功能工具箱") + } + val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values) + uri?.let { + resolver.openOutputStream(it)?.use { output -> + file.inputStream().use { input -> + input.copyTo(output) + } + } + } + } +} diff --git a/app/src/main/java/com/toolbox/multitool/util/BitmapUtils.kt b/app/src/main/java/com/toolbox/multitool/util/BitmapUtils.kt new file mode 100644 index 0000000..c4b2700 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/util/BitmapUtils.kt @@ -0,0 +1,54 @@ +package com.toolbox.multitool.util + +import android.content.ContentResolver +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import java.io.OutputStream + +object BitmapUtils { + fun loadBitmap(contentResolver: ContentResolver, uri: Uri): Bitmap { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val source = ImageDecoder.createSource(contentResolver, uri) + ImageDecoder.decodeBitmap(source) + } else { + MediaStore.Images.Media.getBitmap(contentResolver, uri) + } + } + + fun saveBitmapToGallery(context: Context, bitmap: Bitmap, filename: String, format: Bitmap.CompressFormat): Uri? { + val resolver = context.contentResolver + val mimeType = if (format == Bitmap.CompressFormat.PNG) "image/png" else "image/jpeg" + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, filename) + put(MediaStore.Images.Media.MIME_TYPE, mimeType) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + uri?.let { + resolver.openOutputStream(it)?.use { stream -> + bitmap.compress(format, 95, stream) + } + } + return uri + } + + fun saveTextToDownloads(context: Context, text: String, filename: String): Uri? { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, filename) + put(MediaStore.Downloads.MIME_TYPE, "text/plain") + } + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + uri?.let { + resolver.openOutputStream(it)?.use { stream -> + stream.write(text.toByteArray()) + stream.flush() + } + } + return uri + } +} diff --git a/app/src/main/java/com/toolbox/multitool/util/ImageComposition.kt b/app/src/main/java/com/toolbox/multitool/util/ImageComposition.kt new file mode 100644 index 0000000..d7de967 --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/util/ImageComposition.kt @@ -0,0 +1,68 @@ +package com.toolbox.multitool.util + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Shader +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb + +object ImageComposition { + fun createCollage(bitmaps: List, columns: Int, rows: Int, cellSize: Int): Bitmap { + val width = columns * cellSize + val height = rows * cellSize + val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + bitmaps.take(columns * rows).forEachIndexed { index, bitmap -> + val col = index % columns + val row = index / columns + val left = col * cellSize + val top = row * cellSize + val dest = Rect(left, top, left + cellSize, top + cellSize) + canvas.drawBitmap(bitmap, null, dest, paint) + } + return output + } + + fun createGoodMorningImage( + width: Int, + height: Int, + backgroundColors: List, + message: String, + textSize: Float, + textColor: Color + ): Bitmap { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + if (backgroundColors.size >= 2) { + val shader = LinearGradient( + 0f, + 0f, + width.toFloat(), + height.toFloat(), + backgroundColors.first().toArgb(), + backgroundColors.last().toArgb(), + Shader.TileMode.CLAMP + ) + paint.shader = shader + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + paint.shader = null + } else { + canvas.drawColor(backgroundColors.firstOrNull()?.toArgb() ?: Color(0xFFB0E0E6).toArgb()) + } + + paint.color = textColor.toArgb() + paint.textSize = textSize + paint.textAlign = Paint.Align.CENTER + val x = width / 2f + val y = height / 2f - (paint.descent() + paint.ascent()) / 2 + canvas.drawText(message, x, y, paint) + return bitmap + } +} diff --git a/app/src/main/java/com/toolbox/multitool/util/ImageProcessing.kt b/app/src/main/java/com/toolbox/multitool/util/ImageProcessing.kt new file mode 100644 index 0000000..3ad6ead --- /dev/null +++ b/app/src/main/java/com/toolbox/multitool/util/ImageProcessing.kt @@ -0,0 +1,87 @@ +package com.toolbox.multitool.util + +import android.graphics.Bitmap +import org.opencv.android.OpenCVLoader +import org.opencv.android.Utils +import org.opencv.core.Mat +import org.opencv.imgproc.Imgproc +import org.opencv.photo.Photo + +object ImageProcessing { + init { + OpenCVLoader.initDebug() + } + + fun enhance(bitmap: Bitmap, strength: Int): Bitmap { + val src = Mat() + Utils.bitmapToMat(bitmap, src) + val denoised = Mat() + Photo.fastNlMeansDenoisingColored(src, denoised, 10f * strength, 10f * strength, 7, 21) + + val sharpened = Mat() + Imgproc.GaussianBlur(denoised, sharpened, org.opencv.core.Size(0.0, 0.0), 2.0) + Imgproc.addWeighted(denoised, 1.5, sharpened, -0.5, 0.0, sharpened) + + val contrasted = Mat(denoised.rows(), denoised.cols(), denoised.type()) + denoised.convertTo(contrasted, -1, 1.0 + strength * 0.2, 0.0) + + val output = Mat() + Imgproc.addWeighted(sharpened, 0.5, contrasted, 0.5, 0.0, output) + + val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + Utils.matToBitmap(output, result) + src.release() + denoised.release() + sharpened.release() + contrasted.release() + output.release() + return result + } + + fun addTextWatermark( + bitmap: Bitmap, + text: String, + color: Int, + alpha: Int, + size: Float, + rotation: Float, + positionX: Float, + positionY: Float + ): Bitmap { + val result = bitmap.copy(Bitmap.Config.ARGB_8888, true) + val canvas = android.graphics.Canvas(result) + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + this.color = color + this.textSize = size + this.alpha = alpha + } + canvas.save() + canvas.rotate(rotation, positionX, positionY) + canvas.drawText(text, positionX, positionY, paint) + canvas.restore() + return result + } + + fun addImageWatermark( + bitmap: Bitmap, + watermark: Bitmap, + alpha: Int, + scale: Float, + positionX: Float, + positionY: Float + ): Bitmap { + val result = bitmap.copy(Bitmap.Config.ARGB_8888, true) + val canvas = android.graphics.Canvas(result) + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + this.alpha = alpha + } + val scaled = Bitmap.createScaledBitmap( + watermark, + (watermark.width * scale).toInt(), + (watermark.height * scale).toInt(), + true + ) + canvas.drawBitmap(scaled, positionX, positionY, paint) + return result + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3213d5e --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 多功能工具箱 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..de08d62 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/test/java/com/toolbox/multitool/UtilitiesUseCaseTest.kt b/app/src/test/java/com/toolbox/multitool/UtilitiesUseCaseTest.kt new file mode 100644 index 0000000..fae26a1 --- /dev/null +++ b/app/src/test/java/com/toolbox/multitool/UtilitiesUseCaseTest.kt @@ -0,0 +1,35 @@ +package com.toolbox.multitool + +import com.toolbox.multitool.domain.usecase.CalculateExpressionUseCase +import com.toolbox.multitool.domain.usecase.UnitCategory +import com.toolbox.multitool.domain.usecase.UnitConversionUseCase +import com.toolbox.multitool.domain.usecase.UnitItem +import org.junit.Assert.assertEquals +import org.junit.Test + +class UtilitiesUseCaseTest { + @Test + fun calculateExpressionSupportsPercent() { + val useCase = CalculateExpressionUseCase() + val result = useCase.calculate("200*10%") + assertEquals(20.0, result.getOrNull() ?: 0.0, 0.001) + } + + @Test + fun convertLengthMetersToKilometers() { + val useCase = UnitConversionUseCase() + val meters = UnitItem("m", "公尺", 1.0) + val km = UnitItem("km", "公里", 1000.0) + val result = useCase.convert(UnitCategory.Length, meters, km, 1500.0) + assertEquals(1.5, result, 0.001) + } + + @Test + fun convertTemperatureCToF() { + val useCase = UnitConversionUseCase() + val c = UnitItem("C", "攝氏", 1.0) + val f = UnitItem("F", "華氏", 1.0) + val result = useCase.convert(UnitCategory.Temperature, c, f, 0.0) + assertEquals(32.0, result, 0.001) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ca16ea4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false + id("com.google.dagger.hilt.android") version "2.48" apply false + id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f97184b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e411586 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..542db6b --- /dev/null +++ b/gradlew @@ -0,0 +1,275 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; + *) ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX path + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, GRADLE_OPTS environment variables. +# * all command line arguments + +# Do not use "-Dorg.gradle.appname=gradlew" here +# need to use org.gradle.appname based on APP_BASE_NAME + +# shellcheck disable=SC2034 +APP_ARGS=$* + +# Build the Java command line: +# +# This script uses "<" and "-D" as special cases with default JVM options; +# see the in-line comments for details. + +# Escape application args +save () { + for i do + printf %s\\n "$i" | sed \ + -e 's,\\,\\\\,g' \ + -e 's,",\\",g' \ + -e 's,\\$,\\$,g' \ + -e 's,`\\,\\`,g' + done +} + +APP_ARGS=$( save "$@" ) + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$APP_ARGS" ) && set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# must rely on xargs to parse the saved strings. +# +# # Keep it POSIX +# # +# # xargs is not guaranteed to be available on all platforms, but it is present +# # on POSIX systems, and should be on others. +# +# for example +# +# bar=foo baz +# set -- $( xargs -n1 <<<"$bar" ) +# # set -- foo baz +# +# But `xargs -n1 <<<"$bar"` fails if `$bar` contains quotes or backslashes, or +# is a newline-separated list with trailing newlines. It is not safe to use +# in this script, and is not worth the complexity. +# +# Instead we use a loop to parse the saved strings. +# +# shellcheck disable=SC2086 +set -- $(printf '%s\n' "$APP_ARGS" | xargs -n1) "$@" + +# Execute Gradle +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5b5db5f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,85 @@ +@rem +@rem Copyright 2015-2021 the original authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +if "%OS%"=="Windows_NT" echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +if "%OS%"=="Windows_NT" echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% -Dorg.gradle.appname=%APP_BASE_NAME% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" endlocal + +:fail +set EXIT_CODE=%ERRORLEVEL% +if "%EXIT_CODE%"=="" set EXIT_CODE=1 +if not "%EXIT_CODE%"=="0" exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" exit /b 0 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..db5abe0 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Multitoolbox" +include(":app")