Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
- 圖片/影片處理皆在本機端完成
90 changes: 90 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
26 changes: 26 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />

<application
android:name=".ToolboxApp"
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Multitoolbox">

<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
31 changes: 31 additions & 0 deletions app/src/main/java/com/toolbox/multitool/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
7 changes: 7 additions & 0 deletions app/src/main/java/com/toolbox/multitool/ToolboxApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.toolbox.multitool

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class ToolboxApp : Application()
15 changes: 15 additions & 0 deletions app/src/main/java/com/toolbox/multitool/data/db/AppDatabase.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<CalculatorHistoryEntity>

@Insert
suspend fun insert(entity: CalculatorHistoryEntity)

@Query("DELETE FROM calculator_history")
suspend fun clear()
}
Original file line number Diff line number Diff line change
@@ -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
)
15 changes: 15 additions & 0 deletions app/src/main/java/com/toolbox/multitool/data/db/CurrencyRateDao.kt
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
)
15 changes: 15 additions & 0 deletions app/src/main/java/com/toolbox/multitool/data/db/SettingsDao.kt
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 13 additions & 0 deletions app/src/main/java/com/toolbox/multitool/data/db/SettingsEntity.kt
Original file line number Diff line number Diff line change
@@ -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"
)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<String, Double>
)
Original file line number Diff line number Diff line change
@@ -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<CalculatorHistoryEntity> = dao.getAll()

suspend fun insertHistory(expression: String, result: String) {
dao.insert(
CalculatorHistoryEntity(
expression = expression,
result = result,
createdAt = System.currentTimeMillis()
)
)
}

suspend fun clearHistory() = dao.clear()
}
Loading