diff --git a/app/build.gradle.kts b/app/build.gradle.kts index decde6c..fecda5a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,13 +1,17 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) + alias(libs.plugins.aboutlibraries) } + ksp { arg("room.schemaLocation", "$projectDir/schemas") } -val baseVersionName = "1.0.6" +// 配置版本信息 +val baseVersionName = "compose-refactor" val commitHash by lazy { "git rev-parse --short HEAD".exec() } val verCode by lazy { "git rev-list --count HEAD".exec().toInt() } @@ -53,50 +57,70 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_18 - targetCompatibility = JavaVersion.VERSION_18 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = "18" + jvmTarget = "21" + } + aboutLibraries { + configPath = "$projectDir/licences" } buildFeatures { - viewBinding = true + compose = true } } dependencies { // Android X - implementation(libs.androidx.core) implementation(libs.androidx.core.ktx) - implementation(libs.androidx.core.core.splashscreen) - implementation(libs.androidx.appcompat) - implementation(libs.androidx.activity) - implementation(libs.androidx.activity.ktx) - implementation(libs.androidx.constraintlayout) - implementation(libs.androidx.fragment) - implementation(libs.androidx.fragment.ktx) - implementation(libs.androidx.recyclerview) - implementation(libs.androidx.lifecycle.viewmodel) - implementation(libs.androidx.lifecycle.viewmodel.ktx) - implementation(libs.androidx.preference) - implementation(libs.androidx.preference.ktx) - implementation(libs.androidx.security) - // Material Design - implementation(libs.material) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.compose.runtime.livedata) + // implementation(libs.androidx.security) + // Compose + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.animation) + implementation(libs.androidx.navigation) + implementation(libs.androidx.material3.adaptive) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.android) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icon.core) + implementation(libs.androidx.material.icon.extended) + // About Libraries + implementation(libs.aboutlibraries.core) + implementation(libs.aboutlibraries.compose) + // M3 Color + implementation(libs.com.kyant0.m3color) + // Konfetti + implementation(libs.nl.dionsegijn.konfetti.compose) + // Lazy Column Scrollbar + implementation(libs.lazycolumnscrollbar) + // Kotlin Coroutines + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) // Room implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) annotationProcessor(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler) - // FastScroll - implementation(project(":fastscroll")) // Test testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) } +// 命令执行工具类 fun String.exec(): String = exec(this) fun Project.exec(command: String): String = providers.exec { diff --git a/app/licences/libraries/eastereggs.json b/app/licences/libraries/eastereggs.json new file mode 100644 index 0000000..249317c --- /dev/null +++ b/app/licences/libraries/eastereggs.json @@ -0,0 +1,16 @@ +{ + "uniqueId": "com.dede.android_eggs:3.4.0", + "artifactVersion": "3.4.0", + "name": "AndroidEasterEggs", + "developers": [ + { + "name": "hushenghao" + } + ], + "description": "Collections the Android release Easter Egg", + "website": "https://github.com/hushenghao/AndroidEasterEggs", + "tag": "custom", + "licenses": [ + "Apache-2.0" + ] +} \ No newline at end of file diff --git a/app/licences/libraries/libchecker.json b/app/licences/libraries/libchecker.json new file mode 100644 index 0000000..4c6d340 --- /dev/null +++ b/app/licences/libraries/libchecker.json @@ -0,0 +1,16 @@ +{ + "uniqueId": "com.absinthe.libchecker:2.5.0", + "artifactVersion": "2.5.0", + "name": "LibChecker", + "developers": [ + { + "name": "LibChecker" + } + ], + "description": "An app to view libraries used in apps in your device.", + "website": "https://github.com/LibChecker/LibChecker", + "tag": "custom", + "licenses": [ + "Apache-2.0" + ] +} \ No newline at end of file diff --git a/app/licences/libraries/mauth.json b/app/licences/libraries/mauth.json new file mode 100644 index 0000000..3a58df7 --- /dev/null +++ b/app/licences/libraries/mauth.json @@ -0,0 +1,16 @@ +{ + "uniqueId": "com.xinto.mauth:0.9.0", + "artifactVersion": "0.9.0", + "name": "Mauth", + "developers": [ + { + "name": "X1nto" + } + ], + "description": "A Material You Two-factor Authentication app", + "website": "https://github.com/X1nto/Mauth/", + "tag": "custom", + "licenses": [ + "GPL-3.0-or-later" + ] +} \ No newline at end of file diff --git a/app/licences/libraries/readyou.json b/app/licences/libraries/readyou.json new file mode 100644 index 0000000..3ef049f --- /dev/null +++ b/app/licences/libraries/readyou.json @@ -0,0 +1,16 @@ +{ + "uniqueId": "me.ash.reader:0.11.1", + "artifactVersion": "0.11.1", + "name": "Read You", + "developers": [ + { + "name": "Ashinch" + } + ], + "description": "An Android RSS reader presented in Material You style.", + "website": "https://github.com/Ashinch/ReadYou/", + "tag": "custom", + "licenses": [ + "GPL-3.0-or-later" + ] +} \ No newline at end of file diff --git a/app/licences/libraries/skipad.json b/app/licences/libraries/skipad.json new file mode 100644 index 0000000..4374c79 --- /dev/null +++ b/app/licences/libraries/skipad.json @@ -0,0 +1,13 @@ +{ + "uniqueId": "com.github.crayonxiaoxin.abc:1.0.1", + "artifactVersion": "1.0.1", + "name": "SkipAD", + "developers": [ + { + "name": "crayonxiaoxin" + } + ], + "description": "Android无障碍跳过开屏广告", + "website": "https://github.com/crayonxiaoxin/SkipAD", + "tag": "custom" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f4ec64c..481bb43 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,6 +18,4 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile --dontwarn javax.annotation.Nullable --dontwarn javax.annotation.concurrent.GuardedBy \ No newline at end of file +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/schemas/cn.super12138.todo.logic.dao.ToDoRoomDB/1.json b/app/schemas/cn.super12138.todo.logic.dao.ToDoRoomDB/1.json deleted file mode 100644 index 0bd7a91..0000000 --- a/app/schemas/cn.super12138.todo.logic.dao.ToDoRoomDB/1.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 1, - "identityHash": "bf23f9a3f857e7cfe28dce9eb1657534", - "entities": [ - { - "tableName": "todo", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `state` INTEGER NOT NULL, `subject` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`uuid`))", - "fields": [ - { - "fieldPath": "uuid", - "columnName": "uuid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "state", - "columnName": "state", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "subject", - "columnName": "subject", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "content", - "columnName": "content", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "uuid" - ] - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf23f9a3f857e7cfe28dce9eb1657534')" - ] - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e3e6b4..6ca0ddb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,11 +3,9 @@ xmlns:tools="http://schemas.android.com/tools"> - + android:label="@string/app_name"> - - + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..3786179 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/kotlin/cn/super12138/todo/ToDoApp.kt b/app/src/main/kotlin/cn/super12138/todo/TodoApp.kt similarity index 51% rename from app/src/main/kotlin/cn/super12138/todo/ToDoApp.kt rename to app/src/main/kotlin/cn/super12138/todo/TodoApp.kt index 8308a25..b2397d5 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ToDoApp.kt +++ b/app/src/main/kotlin/cn/super12138/todo/TodoApp.kt @@ -3,27 +3,25 @@ package cn.super12138.todo import android.annotation.SuppressLint import android.app.Application import android.content.Context -import cn.super12138.todo.logic.dao.ToDoRoomDB -import cn.super12138.todo.views.activities.CrashHandler -import com.google.android.material.color.DynamicColors +import cn.super12138.todo.logic.database.TodoDatabase +import cn.super12138.todo.ui.pages.crash.CrashHandler -class ToDoApp : Application() { - private val database by lazy { ToDoRoomDB.getDatabase(this) } +class TodoApp : Application() { + private val database by lazy { TodoDatabase.getDatabase(this) } companion object { @SuppressLint("StaticFieldLeak") lateinit var context: Context - lateinit var db: ToDoRoomDB + lateinit var db: TodoDatabase } override fun onCreate() { super.onCreate() - DynamicColors.applyToActivitiesIfAvailable(this) + + db = database context = applicationContext - val crashHandler = CrashHandler(this) + val crashHandler = CrashHandler(applicationContext) Thread.setDefaultUncaughtExceptionHandler(crashHandler) - - db = database } } \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/constant/Constants.kt b/app/src/main/kotlin/cn/super12138/todo/constant/Constants.kt deleted file mode 100644 index 8a54be8..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/constant/Constants.kt +++ /dev/null @@ -1,38 +0,0 @@ -package cn.super12138.todo.constant - -object Constants { - const val WELCOME_PAGE= "welcome_page" - - const val AUTHOR_GITHUB_URL = "https://github.com/Super12138/" - const val REPO_GITHUB_URL = "https://github.com/Super12138/ToDo" - const val UPDATE_URL = "https://github.com/Super12138/ToDo/releases" - - const val SP_NAME = "cn.super12138.todo_preferences" - - const val PREF_DARK_MODE = "dark_mode" - const val PREF_SECURE_MODE = "secure_mode" - const val PREF_HAPTIC_FEEDBACK = "haptic_feedback" - const val PREF_ALL_TASKS = "all_tasks" - const val PREF_REENTER_WELCOME_ACTIVITY = "reenter_welcome_activity" - const val PREF_ABOUT = "about" - const val PREF_DEV_MODE = "dev_mode" - // const val PREF_SPRING_FESTIVAL_THEME = "spring_festival_theme" - const val PREF_BACKUP_DB = "backup_db" - const val PREF_RESTORE_DB = "restore_db" - - - const val STRING_DEV_MODE = "/DEV_MODE" - - const val TAG_INFO_BOTTOM_SHEET = "InfoBottomSheet" - const val TAG_TODO_BOTTOM_SHEET = "ToDoBottomSheet" - - const val BUNDLE_EDIT_MODE = "editMode" - const val BUNDLE_POSITION = "todoPosition" - const val BUNDLE_TODO_CONTENT = "todoContent" - const val BUNDLE_TODO_SUBJECT = "todoSubject" - const val BUNDLE_TODO_STATE = "todoState" - const val BUNDLE_TODO_UUID = "todoUUID" - - const val EMPTY_VIEW_TYPE = 0 - const val DEFAULT_VIEW_TYPE = 1 -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/constant/GlobalValues.kt b/app/src/main/kotlin/cn/super12138/todo/constant/GlobalValues.kt deleted file mode 100644 index 430ed93..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/constant/GlobalValues.kt +++ /dev/null @@ -1,12 +0,0 @@ -package cn.super12138.todo.constant - -import cn.super12138.todo.utils.sp.SPDelegates - -object GlobalValues { - var welcomePage: Boolean by SPDelegates(Constants.WELCOME_PAGE, false) - var darkMode: String by SPDelegates(Constants.PREF_DARK_MODE, "0") - var devMode: Boolean by SPDelegates(Constants.PREF_DEV_MODE, false) - // var springFestivalTheme: Boolean by SPDelegates(Constants.PREF_SPRING_FESTIVAL_THEME, false) - var secureMode: Boolean by SPDelegates(Constants.PREF_SECURE_MODE, false) - var hapticFeedback: Boolean by SPDelegates(Constants.PREF_HAPTIC_FEEDBACK, true) -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/constants/Constants.kt b/app/src/main/kotlin/cn/super12138/todo/constants/Constants.kt new file mode 100644 index 0000000..79d453e --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/constants/Constants.kt @@ -0,0 +1,32 @@ +package cn.super12138.todo.constants + +object Constants { + const val DEVELOPER_GITHUB = "https://github.com/Super12138/" + const val GITHUB_REPO = "https://github.com/Super12138/ToDo/" + + const val KEY_TODO_FAB_TRANSITION = "todo_fab" + const val KEY_TODO_CONTENT_TRANSITION = "todo_content" + + const val SP_NAME = "cn.super12138.todo_preferences" + + const val PREF_DYNAMIC_COLOR = "dynamic_color" + const val PREF_DYNAMIC_COLOR_DEFAULT = true + + const val PREF_PALETTE_STYLE = "palette_style" + const val PREF_PALETTE_STYLE_DEFAULT = 1 // TonalSpot + + const val PREF_DARK_MODE = "dark_mode" + const val PREF_DARK_MODE_DEFAULT = -1 // Follow System + + const val PREF_CONTRAST_LEVEL = "contrast_level" + const val PREF_CONTRAST_LEVEL_DEFAULT = 0f // Normal + + const val PREF_SHOW_COMPLETED = "show_completed" + const val PREF_SHOW_COMPLETED_DEFAULT = true + + const val PREF_SORTING_METHOD = "sorting_method" + const val PREF_SORTING_METHOD_DEFAULT = 1 + + const val PREF_HAPTIC_FEEDBACK = "haptic_feedback" + const val PREF_HAPTIC_FEEDBACK_DEFAULT = true +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/constants/GlobalValues.kt b/app/src/main/kotlin/cn/super12138/todo/constants/GlobalValues.kt new file mode 100644 index 0000000..aa4630c --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/constants/GlobalValues.kt @@ -0,0 +1,34 @@ +package cn.super12138.todo.constants + +import cn.super12138.todo.utils.SPDelegates + +object GlobalValues { + var dynamicColor: Boolean by SPDelegates( + key = Constants.PREF_DYNAMIC_COLOR, + default = Constants.PREF_DYNAMIC_COLOR_DEFAULT + ) + var paletteStyle: Int by SPDelegates( + key = Constants.PREF_PALETTE_STYLE, + default = Constants.PREF_PALETTE_STYLE_DEFAULT + ) + var darkMode: Int by SPDelegates( + key = Constants.PREF_DARK_MODE, + default = Constants.PREF_DARK_MODE_DEFAULT + ) + var contrastLevel: Float by SPDelegates( + key = Constants.PREF_CONTRAST_LEVEL, + default = Constants.PREF_CONTRAST_LEVEL_DEFAULT + ) + var showCompleted: Boolean by SPDelegates( + key = Constants.PREF_SHOW_COMPLETED, + default = Constants.PREF_SHOW_COMPLETED_DEFAULT + ) + var sortingMethod: Int by SPDelegates( + key = Constants.PREF_SORTING_METHOD, + default = Constants.PREF_SORTING_METHOD_DEFAULT + ) + var hapticFeedback: Boolean by SPDelegates( + key = Constants.PREF_HAPTIC_FEEDBACK, + default = Constants.PREF_HAPTIC_FEEDBACK_DEFAULT + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/Repository.kt b/app/src/main/kotlin/cn/super12138/todo/logic/Repository.kt index 077682f..034a5c2 100644 --- a/app/src/main/kotlin/cn/super12138/todo/logic/Repository.kt +++ b/app/src/main/kotlin/cn/super12138/todo/logic/Repository.kt @@ -1,87 +1,32 @@ package cn.super12138.todo.logic -import cn.super12138.todo.ToDoApp -import cn.super12138.todo.logic.dao.ToDoRoom -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import cn.super12138.todo.TodoApp +import cn.super12138.todo.logic.database.TodoEntity +import kotlinx.coroutines.flow.Flow object Repository { + private val db get() = TodoApp.db + private val toDoDao = db.toDoDao() - // Room - private val db get() = ToDoApp.db - private val todoDao = db.toDoRoomDao() - - /** - * @param toDoRoom 要插入的数据 - */ - suspend fun insert(toDoRoom: ToDoRoom) { - withContext(Dispatchers.IO) { - todoDao.insert(toDoRoom) - } - } - - /** - * 获取全部未完成的待办 - * @return List - */ - suspend fun getAllIncomplete(): List { - return withContext(Dispatchers.IO) { - todoDao.getAllUnfinished() - } + suspend fun insertTodo(toDo: TodoEntity) { + toDoDao.insert(toDo) } - /** - * 获取全部已完成的待办 - * @return List - */ - suspend fun getAllComplete(): List { - return withContext(Dispatchers.IO) { - todoDao.getAllComplete() - } - } + fun getAllTodos(): Flow> = toDoDao.getAll() - /** - * 获取全部待办 - * @return List - */ - suspend fun getAll(): List { - return withContext(Dispatchers.IO) { - todoDao.getAll() - } + suspend fun updateTodo(toDo: TodoEntity) { + toDoDao.update(toDo) } - /** - * 根据待办的UUID删除指定待办 - * @param uuid 待办的UUID - */ - suspend fun deleteByUUID(uuid: String) { - withContext(Dispatchers.IO) { - todoDao.deleteByUUID(uuid) - } + suspend fun deleteTodo(toDo: TodoEntity) { + toDoDao.delete(toDo) } - /** - * 删除全部代办 - */ - suspend fun deleteAll() { - withContext(Dispatchers.IO) { - todoDao.deleteAll() - } + suspend fun deleteTodoFromIds(toDoItems: List) { + toDoDao.deleteFromIds(toDoItems.toSet()) } - /** - * 根据代办的UUID来把待办状态更新为“已完成” - * @param uuid 待办的UUID - */ - suspend fun updateStateByUUID(uuid: String) { - withContext(Dispatchers.IO) { - todoDao.updateStateByUUID(uuid) - } - } - - suspend fun update(toDoRoom: ToDoRoom) { - withContext(Dispatchers.IO) { - todoDao.update(toDoRoom) - } - } + /*suspend fun deleteAllTodo() { + toDoDao.deleteAllTodo() + }*/ } \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/dao/ToDoRoom.kt b/app/src/main/kotlin/cn/super12138/todo/logic/dao/ToDoRoom.kt deleted file mode 100644 index 69cf07c..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/logic/dao/ToDoRoom.kt +++ /dev/null @@ -1,19 +0,0 @@ -package cn.super12138.todo.logic.dao - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -/** - * @param uuid String 待办的uuid - * @param state Int 待办的完成状态,0表示未完成,1表示完成 - * @param subject String 待办的学科 - * @param content String 待办的内容 - */ -@Entity(tableName = "todo") -data class ToDoRoom( - @PrimaryKey @ColumnInfo(name = "uuid") val uuid: String, - @ColumnInfo(name = "state") val state: Int, - @ColumnInfo(name = "subject") val subject: String, - @ColumnInfo(name = "content") val content: String -) \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/dao/ToDoRoomDao.kt b/app/src/main/kotlin/cn/super12138/todo/logic/dao/ToDoRoomDao.kt deleted file mode 100644 index c05fbd3..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/logic/dao/ToDoRoomDao.kt +++ /dev/null @@ -1,33 +0,0 @@ -package cn.super12138.todo.logic.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query -import androidx.room.Update - -@Dao -interface ToDoRoomDao { - @Insert - suspend fun insert(toDoRoom: ToDoRoom) - - @Query("SELECT * FROM todo") - suspend fun getAll(): List - - @Query("SELECT * FROM todo WHERE state = 0") - suspend fun getAllUnfinished(): List - - @Query("SELECT * FROM todo WHERE state = 1") - suspend fun getAllComplete(): List - - @Query("DELETE FROM todo") - suspend fun deleteAll() - - @Query("DELETE FROM todo WHERE uuid = :uuid") - suspend fun deleteByUUID(uuid: String) - - @Query("UPDATE todo SET state = 1 WHERE uuid = :uuid") - suspend fun updateStateByUUID(uuid: String) - - @Update - suspend fun update(toDoRoom: ToDoRoom) -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDao.kt b/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDao.kt new file mode 100644 index 0000000..239a664 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDao.kt @@ -0,0 +1,30 @@ +package cn.super12138.todo.logic.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface TodoDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(toDo: TodoEntity) + + @Query("SELECT * FROM todo") + fun getAll(): Flow> + + @Update + suspend fun update(toDo: TodoEntity) + + @Delete + suspend fun delete(toDo: TodoEntity) + + @Query("DELETE FROM todo WHERE id in (:toDoIds)") + suspend fun deleteFromIds(toDoIds: Set) + + /*@Query("DELETE FROM todo") + suspend fun deleteAllTodo()*/ +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/dao/ToDoRoomDB.kt b/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDatabase.kt similarity index 50% rename from app/src/main/kotlin/cn/super12138/todo/logic/dao/ToDoRoomDB.kt rename to app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDatabase.kt index bcc1f2f..8f72c65 100644 --- a/app/src/main/kotlin/cn/super12138/todo/logic/dao/ToDoRoomDB.kt +++ b/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoDatabase.kt @@ -1,24 +1,26 @@ -package cn.super12138.todo.logic.dao +package cn.super12138.todo.logic.database import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase -@Database(entities = [ToDoRoom::class], version = 1) -abstract class ToDoRoomDB : RoomDatabase() { - abstract fun toDoRoomDao(): ToDoRoomDao +@Database(entities = [TodoEntity::class], version = 2) +abstract class TodoDatabase : RoomDatabase() { + abstract fun toDoDao(): TodoDao companion object { @Volatile - private var INSTANCE: ToDoRoomDB? = null - fun getDatabase(context: Context): ToDoRoomDB { + private var INSTANCE: TodoDatabase? = null + fun getDatabase(context: Context): TodoDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, - ToDoRoomDB::class.java, + TodoDatabase::class.java, "todo" - ).build() + ) + .fallbackToDestructiveMigration() + .build() INSTANCE = instance return instance diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoEntity.kt b/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoEntity.kt new file mode 100644 index 0000000..90957b1 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/logic/database/TodoEntity.kt @@ -0,0 +1,14 @@ +package cn.super12138.todo.logic.database + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "todo") +data class TodoEntity( + @ColumnInfo(name = "content") val content: String, + @ColumnInfo(name = "subject") val subject: Int, + @ColumnInfo(name = "completed") val isCompleted: Boolean = false, + @ColumnInfo(name = "priority") val priority: Float, + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Int = 0, +) diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/model/ContrastLevel.kt b/app/src/main/kotlin/cn/super12138/todo/logic/model/ContrastLevel.kt new file mode 100644 index 0000000..09fd77d --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/logic/model/ContrastLevel.kt @@ -0,0 +1,29 @@ +package cn.super12138.todo.logic.model + +import android.content.Context +import cn.super12138.todo.R + +enum class ContrastLevel(val value: Float) { + VeryLow(-1f), + Low(-0.5f), + Default(0f), + Medium(0.5f), + High(1f); + + fun getDisplayName(context: Context): String { + val resId = when (this) { + VeryLow -> R.string.contrast_very_low + Low -> R.string.contrast_low + Default -> R.string.contrast_default + Medium -> R.string.contrast_high + High -> R.string.contrast_very_high + } + return context.getString(resId) + } + + companion object { + fun fromFloat(float: Float): ContrastLevel { + return ContrastLevel.entries.find { it.value == float } ?: Default + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/model/DarkMode.kt b/app/src/main/kotlin/cn/super12138/todo/logic/model/DarkMode.kt new file mode 100644 index 0000000..7bd0cc7 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/logic/model/DarkMode.kt @@ -0,0 +1,33 @@ +package cn.super12138.todo.logic.model + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.SettingsSuggest +import androidx.compose.ui.graphics.vector.ImageVector +import cn.super12138.todo.R + +enum class DarkMode( + val id: Int, + val icon: ImageVector +) { + FollowSystem(-1, Icons.Outlined.SettingsSuggest), + Light(1, Icons.Outlined.LightMode), + Dark(2, Icons.Outlined.DarkMode); + + fun getDisplayName(context: Context): String { + val resId = when (this) { + FollowSystem -> R.string.dark_mode_system + Light -> R.string.dark_mode_light + Dark -> R.string.dark_mode_dark + } + return context.getString(resId) + } + + companion object { + fun fromId(id: Int): DarkMode { + return DarkMode.entries.find { it.id == id } ?: FollowSystem + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/model/Priority.kt b/app/src/main/kotlin/cn/super12138/todo/logic/model/Priority.kt new file mode 100644 index 0000000..859c3f7 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/logic/model/Priority.kt @@ -0,0 +1,29 @@ +package cn.super12138.todo.logic.model + +import android.content.Context +import cn.super12138.todo.R + +enum class Priority(val value: Float) { + Urgent(10f), + Important(5f), + Default(0f), + NotImportant(-5f), + NotUrgent(-10f); + + fun getDisplayName(context: Context): String { + val resId = when (this) { + Urgent -> R.string.priority_urgent + Important -> R.string.priority_important + Default -> R.string.priority_default + NotImportant -> R.string.priority_not_important + NotUrgent -> R.string.priority_not_urgent + } + return context.getString(resId) + } + + companion object { + fun fromFloat(float: Float): Priority { + return Priority.entries.find { it.value == float } ?: Default + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/model/SortingMethod.kt b/app/src/main/kotlin/cn/super12138/todo/logic/model/SortingMethod.kt new file mode 100644 index 0000000..036a2b9 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/logic/model/SortingMethod.kt @@ -0,0 +1,31 @@ +package cn.super12138.todo.logic.model + +import android.content.Context +import cn.super12138.todo.R + +enum class SortingMethod(val id: Int) { + Date(1), + Subject(2), + Priority(3), + Completion(4), + AlphabeticalAscending(5), + AlphabeticalDescending(6); + + fun getDisplayName(context: Context): String { + val resId = when (this) { + Date -> R.string.sorting_date + Subject -> R.string.sorting_subject + Priority -> R.string.sorting_priority + Completion -> R.string.sorting_completion + AlphabeticalAscending -> R.string.sorting_alphabetical_ascending + AlphabeticalDescending -> R.string.sorting_alphabetical_descending + } + return context.getString(resId) + } + + companion object { + fun fromId(id: Int): SortingMethod { + return SortingMethod.entries.find { it.id == id } ?: Date + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/model/Subjects.kt b/app/src/main/kotlin/cn/super12138/todo/logic/model/Subjects.kt new file mode 100644 index 0000000..5fde68e --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/logic/model/Subjects.kt @@ -0,0 +1,40 @@ +package cn.super12138.todo.logic.model + +import android.content.Context +import cn.super12138.todo.R + +enum class Subjects(val id: Int) { + Chinese(0), + Math(1), + English(2), + Biology(3), + Geography(4), + Physics(5), + Moral(6), + Chemistry(7), + History(8), + Others(99); + + fun getDisplayName(context: Context): String { + val resId = when (this) { + Chinese -> R.string.subject_chinese + Math -> R.string.subject_math + English -> R.string.subject_english + Biology -> R.string.subject_biology + Geography -> R.string.subject_geography + Physics -> R.string.subject_physics + Moral -> R.string.subject_moral + Chemistry -> R.string.subject_chemistry + History -> R.string.subject_history + Others -> R.string.subject_others + } + return context.getString(resId) // 返回资源中的文本 + } + + companion object { + // 根据 ID 获取 Subjects + fun fromId(id: Int): Subjects { + return entries.find { it.id == id } ?: Others + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/logic/model/ToDo.kt b/app/src/main/kotlin/cn/super12138/todo/logic/model/ToDo.kt deleted file mode 100644 index 86a7caa..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/logic/model/ToDo.kt +++ /dev/null @@ -1,5 +0,0 @@ -package cn.super12138.todo.logic.model - -data class ToDo(val uuid: String, val state: Int, val content: String, val subject: String) { - // var isAnimated = false -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/TodoDefaults.kt b/app/src/main/kotlin/cn/super12138/todo/ui/TodoDefaults.kt new file mode 100644 index 0000000..7b962d4 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/TodoDefaults.kt @@ -0,0 +1,14 @@ +package cn.super12138.todo.ui + +import androidx.compose.ui.unit.dp + +object TodoDefaults { + /** + * 屏幕左右两边预留边距(防止内容全部贴边显示过丑) + */ + val screenPadding = 16.dp + /** + * 待办卡片默认高度 + */ + val toDoCardHeight = 80.dp +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/activities/CrashActivity.kt b/app/src/main/kotlin/cn/super12138/todo/ui/activities/CrashActivity.kt new file mode 100644 index 0000000..e69a0b5 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/activities/CrashActivity.kt @@ -0,0 +1,69 @@ +package cn.super12138.todo.ui.activities + +import android.os.Build +import android.os.Bundle +import android.view.HapticFeedbackConstants +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import cn.super12138.todo.R +import cn.super12138.todo.ui.pages.crash.CrashPage +import cn.super12138.todo.ui.theme.ToDoTheme +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +class CrashActivity : ComponentActivity() { + companion object { + const val BRAND_PREFIX = "Brand: " + const val MODEL_PREFIX = "Model: " + const val DEVICE_SDK_PREFIX = "Device SDK: " + const val CRASH_TIME_PREFIX = "Crash time: " + const val BEGINNING_CRASH = "======beginning of crash======" + } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val crashLogs = intent.getStringExtra("crash_logs") + + val deviceBrand = Build.BRAND + val deviceModel = Build.MODEL + val sdkLevel = Build.VERSION.SDK_INT + val currentDateTime = Calendar.getInstance().time + val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val formattedDateTime = formatter.format(currentDateTime) + + val deviceInfo = StringBuilder().apply { + append(BRAND_PREFIX).append("").append(deviceBrand).append('\n') + append(MODEL_PREFIX).append(deviceModel).append('\n') + append(DEVICE_SDK_PREFIX).append(sdkLevel).append('\n').append('\n') + append(CRASH_TIME_PREFIX).append(formattedDateTime).append('\n').append('\n') + append(BEGINNING_CRASH).append('\n') + } + + val crashLogFormatted = StringBuilder().apply { + append(deviceInfo) + append(crashLogs) + }.toString() + + setContent { + ToDoTheme { + val view = LocalView.current + CrashPage( + crashLog = if (crashLogs == null) stringResource(R.string.tip_no_crash_logs) else crashLogFormatted, + exitApp = { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + finishAffinity() + }, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/activities/MainActivity.kt b/app/src/main/kotlin/cn/super12138/todo/ui/activities/MainActivity.kt new file mode 100644 index 0000000..86bb871 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/activities/MainActivity.kt @@ -0,0 +1,61 @@ +package cn.super12138.todo.ui.activities + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.WindowCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import cn.super12138.todo.logic.model.DarkMode.Dark +import cn.super12138.todo.logic.model.DarkMode.FollowSystem +import cn.super12138.todo.logic.model.DarkMode.Light +import cn.super12138.todo.ui.components.Konfetti +import cn.super12138.todo.ui.navigation.TodoNavigation +import cn.super12138.todo.ui.theme.ToDoTheme +import cn.super12138.todo.ui.viewmodels.MainViewModel + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + val mainViewModel: MainViewModel = viewModel() + val showConfetti = mainViewModel.showConfetti + // 深色模式 + val darkTheme = when (mainViewModel.appDarkMode) { + FollowSystem -> isSystemInDarkTheme() + Light -> false + Dark -> true + } + // 配置状态栏和底部导航栏的颜色(在用户切换深色模式时) + // https://github.com/dn0ne/lotus/blob/master/app/src/main/java/com/dn0ne/player/MainActivity.kt#L266 + LaunchedEffect(mainViewModel.appDarkMode) { + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + } + + ToDoTheme( + darkTheme = darkTheme, + contrastLevel = mainViewModel.appContrastLevel.value.toDouble() + ) { + Surface(color = MaterialTheme.colorScheme.background) { + TodoNavigation( + viewModel = mainViewModel, + modifier = Modifier.fillMaxSize() + ) + Konfetti(state = showConfetti) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/components/ChipGroup.kt b/app/src/main/kotlin/cn/super12138/todo/ui/components/ChipGroup.kt new file mode 100644 index 0000000..4776d3e --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/components/ChipGroup.kt @@ -0,0 +1,67 @@ +package cn.super12138.todo.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R +import cn.super12138.todo.utils.VibrationUtils + +/** + * 部分参考:https://github.com/Rhythamtech/FilterChipGroup-Compose-Android/blob/main/FilterChipGroup.kt + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FilterChipGroup( + items: List, + defaultSelectedItemIndex: Int = 0, + onSelectedChanged: (Int) -> Unit = {}, + modifier: Modifier = Modifier +) { + val view = LocalView.current + var selectedItemIndex by rememberSaveable { mutableIntStateOf(defaultSelectedItemIndex) } + + FlowRow(modifier = modifier) { + items.forEachIndexed { index, item -> + FilterChip( + selected = items[selectedItemIndex] == items[index], + onClick = { + selectedItemIndex = index + VibrationUtils.performHapticFeedback(view) + onSelectedChanged(index) + }, + leadingIcon = { + AnimatedVisibility( + visible = items[selectedItemIndex] == items[index] + ) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = stringResource(R.string.tip_select_this), + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + } + }, + label = { + Text(item) + }, + modifier = Modifier.padding(end = 10.dp) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/components/Dialogs.kt b/app/src/main/kotlin/cn/super12138/todo/ui/components/Dialogs.kt new file mode 100644 index 0000000..25e9966 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/components/Dialogs.kt @@ -0,0 +1,120 @@ +package cn.super12138.todo.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import cn.super12138.todo.R +import cn.super12138.todo.utils.VibrationUtils + +@Composable +fun WarningDialog( + visible: Boolean, + icon: ImageVector = Icons.Outlined.ErrorOutline, + description: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + BasicDialog( + visible = visible, + icon = icon, + title = stringResource(R.string.title_warning), + text = { Text(description) }, + confirmButton = stringResource(R.string.action_confirm), + dismissButton = stringResource(R.string.action_cancel), + onConfirm = { + onConfirm() + onDismiss() + }, + onDismiss = onDismiss, + modifier = modifier + ) +} + +@Composable +fun BasicDialog( + visible: Boolean, + icon: ImageVector, + title: String, + text: @Composable (() -> Unit)? = null, + confirmButton: String, + dismissButton: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + BasicDialog( + visible = visible, + icon = { + Icon( + imageVector = icon, + contentDescription = null // 会跟下面的文本重复,所以设置为 null + ) + }, + title = { Text(title) }, + text = text, + confirmButton = { + FilledTonalButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + onConfirm() + } + ) { + Text(confirmButton) + } + }, + dismissButton = { + TextButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + onDismiss() + } + ) { + Text(dismissButton) + } + }, + onDismissRequest = onDismiss, + modifier = modifier + ) +} + +@Composable +fun BasicDialog( + visible: Boolean, + icon: @Composable (() -> Unit)? = null, + title: @Composable () -> Unit, + text: @Composable (() -> Unit)? = null, + confirmButton: (@Composable () -> Unit), + dismissButton: (@Composable () -> Unit)? = null, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier +) { + if (visible) { + AlertDialog( + icon = icon, + title = title, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + text?.let { it() } + } + }, + confirmButton = confirmButton, + dismissButton = dismissButton, + onDismissRequest = onDismissRequest, + modifier = modifier + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/components/FloatingActionButton.kt b/app/src/main/kotlin/cn/super12138/todo/ui/components/FloatingActionButton.kt new file mode 100644 index 0000000..895b615 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/components/FloatingActionButton.kt @@ -0,0 +1,83 @@ +package cn.super12138.todo.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FloatingActionButtonElevation +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cn.super12138.todo.utils.VibrationUtils + +/** + * 带动画且比 Material 3 内置组件动画好看的的可扩展 FAB + * * (内置组件的动画总是卡一下。。。) + * + * 将缩放部分转为最简单的`AnimatedVisibility`实现 + * @param icon FAB 的前置图标 + * @param text FAB 的文本 + * @param textOverflow FAB 文本溢出显示方案 + * @param expanded 是否为展开状态 + * @param containerColor FAB 容器的颜色 + * @param contentColor FAB 文本和图标颜色,通常不需要自己指定,会自动依据容器颜色设置 + * @param elevation FAB 的高度(阴影大小) + * @param onClick 点击 FAB 后的回调 + * @param modifier `Modifier` 修改器 + */ +@Composable +fun AnimatedExtendedFloatingActionButton( + icon: ImageVector, + text: String, + textOverflow: TextOverflow = TextOverflow.Clip, + expanded: Boolean, + containerColor: Color = FloatingActionButtonDefaults.containerColor, + contentColor: Color = contentColorFor(containerColor), + elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + FloatingActionButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + onClick() + }, + elevation = elevation, + containerColor = containerColor, + contentColor = contentColor, + modifier = modifier + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null + ) + // Spacer(Modifier.width(if (expanded) 8.dp else 0.dp)) + AnimatedVisibility(expanded) { + Row { + Spacer(Modifier.width(8.dp)) + Text( + text = text, + maxLines = 1, + overflow = textOverflow + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/components/Konfetti.kt b/app/src/main/kotlin/cn/super12138/todo/ui/components/Konfetti.kt new file mode 100644 index 0000000..107a518 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/components/Konfetti.kt @@ -0,0 +1,106 @@ +package cn.super12138.todo.ui.components + +import androidx.annotation.FloatRange +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils +import nl.dionsegijn.konfetti.compose.KonfettiView +import nl.dionsegijn.konfetti.compose.OnParticleSystemUpdateListener +import nl.dionsegijn.konfetti.core.Angle +import nl.dionsegijn.konfetti.core.Party +import nl.dionsegijn.konfetti.core.PartySystem +import nl.dionsegijn.konfetti.core.Position +import nl.dionsegijn.konfetti.core.Spread +import nl.dionsegijn.konfetti.core.emitter.Emitter +import java.util.concurrent.TimeUnit + +/** + * 参照于:https://github.com/hushenghao/AndroidEasterEggs/blob/main/app/src/main/java/com/dede/android_eggs/views/main/compose/Konfetti.kt + */ + +@Composable +fun Konfetti( + state: MutableState, + primary: Color = MaterialTheme.colorScheme.primary, + modifier: Modifier = Modifier +) { + var visible by state + if (!visible) { + return + } + val listener = remember(state) { + object : OnParticleSystemUpdateListener { + override fun onParticleSystemEnded(system: PartySystem, activeSystems: Int) { + if (activeSystems == 0) + visible = false + } + } + } + KonfettiView( + modifier = Modifier + .fillMaxSize() + .then(modifier), + parties = remember { particles(primary.toArgb()) }, + updateListener = listener + ) +} + +private val defaultColors = listOf( + 0xFCE18A, + 0x009688, + 0xFF726D, + 0xF4306D, + 0xB48DEF, + 0x95FF82, + 0x82ECFF, + 0xFF9800, + 0x0E008A, +) + +private const val colorBlendFraction = 0.3f + +private fun particles(primary: Int) = listOf( + Party( + speed = 0f, + maxSpeed = 12f, + damping = 0.9f, + angle = Angle.BOTTOM, + spread = Spread.ROUND, + colors = defaultColors.map { it.blend(primary, colorBlendFraction) }, + emitter = Emitter(duration = 2, TimeUnit.SECONDS).perSecond(100), + position = Position.Relative(0.0, 0.0).between(Position.Relative(1.0, 0.0)), + ), + Party( + speed = 10f, + maxSpeed = 30f, + damping = 0.9f, + angle = Angle.RIGHT - 55, + spread = 60, + colors = defaultColors.map { it.blend(primary, colorBlendFraction) }, + emitter = Emitter(duration = 2, TimeUnit.SECONDS).perSecond(100), + position = Position.Relative(0.0, 1.0) + ), + Party( + speed = 10f, + maxSpeed = 30f, + damping = 0.9f, + angle = Angle.RIGHT - 125, + spread = 60, + colors = defaultColors.map { it.blend(primary, colorBlendFraction) }, + emitter = Emitter(duration = 2, TimeUnit.SECONDS).perSecond(100), + position = Position.Relative(1.0, 1.0) + ) +) + +fun Int.blend( + color: Int, + @FloatRange(from = 0.0, to = 1.0) fraction: Float = 0.5f, +): Int = ColorUtils.blendARGB(this, color, fraction) diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/components/Scaffolds.kt b/app/src/main/kotlin/cn/super12138/todo/ui/components/Scaffolds.kt new file mode 100644 index 0000000..12ece54 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/components/Scaffolds.kt @@ -0,0 +1,115 @@ +package cn.super12138.todo.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.safeContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import cn.super12138.todo.R +import cn.super12138.todo.utils.VibrationUtils + +/** + * 带有顶部大标题栏的通用脚手架 + * * 内容默认由 Box 容器包裹;实际使用时推荐配合 Column 或 Row + * + * @param title 标题文本 + * @param scrollBehavior 滚动行为,用于支持页面内容滚动时标题栏的压缩效果 + * @param onBack 当返回按钮被按下时的操作 + * @param contentWindowInsets 内容边距,通常用于将内容和系统状态栏等隔开;可以使用 `WindowInsets.safeContent` + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LargeTopAppBarScaffold( + title: String, + scrollBehavior: TopAppBarScrollBehavior, + onBack: () -> Unit, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + contentWindowInsets: WindowInsets = WindowInsets.safeContent.exclude(WindowInsets.ime), + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit +) { + val view = LocalView.current + LargeTopAppBarScaffold( + title = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + onBack() + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.action_back) + ) + } + }, + scrollBehavior = scrollBehavior, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + contentWindowInsets = contentWindowInsets + ) { innerPadding -> + Box { content(innerPadding) } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LargeTopAppBarScaffold( + title: @Composable () -> Unit = {}, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + modifier: Modifier = Modifier, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + modifier = modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = title, + navigationIcon = navigationIcon, + actions = actions, + scrollBehavior = scrollBehavior + ) + }, + snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, + contentWindowInsets = contentWindowInsets + ) { innerPadding -> + Box { content(innerPadding) } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/icons/GithubIcons.kt b/app/src/main/kotlin/cn/super12138/todo/ui/icons/GithubIcons.kt new file mode 100644 index 0000000..e1066de --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/icons/GithubIcons.kt @@ -0,0 +1,56 @@ +package cn.super12138.todo.ui.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val GithubIcon: ImageVector + get() { + if (_GithubIcon != null) { + return _GithubIcon!! + } + _GithubIcon = ImageVector.Builder( + name = "GithubIcon", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(12.5f, 0.75f) + curveTo(6.146f, 0.75f, 1f, 5.896f, 1f, 12.25f) + curveToRelative(0f, 5.089f, 3.292f, 9.387f, 7.863f, 10.91f) + curveToRelative(0.575f, 0.101f, 0.79f, -0.244f, 0.79f, -0.546f) + curveToRelative(0f, -0.273f, -0.014f, -1.178f, -0.014f, -2.142f) + curveToRelative(-2.889f, 0.532f, -3.636f, -0.704f, -3.866f, -1.35f) + curveToRelative(-0.13f, -0.331f, -0.69f, -1.352f, -1.18f, -1.625f) + curveToRelative(-0.402f, -0.216f, -0.977f, -0.748f, -0.014f, -0.762f) + curveToRelative(0.906f, -0.014f, 1.553f, 0.834f, 1.769f, 1.179f) + curveToRelative(1.035f, 1.74f, 2.688f, 1.25f, 3.349f, 0.948f) + curveToRelative(0.1f, -0.747f, 0.402f, -1.25f, 0.733f, -1.538f) + curveToRelative(-2.559f, -0.287f, -5.232f, -1.279f, -5.232f, -5.678f) + curveToRelative(0f, -1.25f, 0.445f, -2.285f, 1.178f, -3.09f) + curveToRelative(-0.115f, -0.288f, -0.517f, -1.467f, 0.115f, -3.048f) + curveToRelative(0f, 0f, 0.963f, -0.302f, 3.163f, 1.179f) + curveToRelative(0.92f, -0.259f, 1.897f, -0.388f, 2.875f, -0.388f) + curveToRelative(0.977f, 0f, 1.955f, 0.13f, 2.875f, 0.388f) + curveToRelative(2.2f, -1.495f, 3.162f, -1.179f, 3.162f, -1.179f) + curveToRelative(0.633f, 1.581f, 0.23f, 2.76f, 0.115f, 3.048f) + curveToRelative(0.733f, 0.805f, 1.179f, 1.825f, 1.179f, 3.09f) + curveToRelative(0f, 4.413f, -2.688f, 5.39f, -5.247f, 5.678f) + curveToRelative(0.417f, 0.36f, 0.776f, 1.05f, 0.776f, 2.128f) + curveToRelative(0f, 1.538f, -0.014f, 2.774f, -0.014f, 3.162f) + curveToRelative(0f, 0.302f, 0.216f, 0.662f, 0.79f, 0.547f) + curveTo(20.709f, 21.637f, 24f, 17.324f, 24f, 12.25f) + curveTo(24f, 5.896f, 18.854f, 0.75f, 12.5f, 0.75f) + close() + } + }.build() + + return _GithubIcon!! + } + +@Suppress("ObjectPropertyName") +private var _GithubIcon: ImageVector? = null diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/navigation/TodoNavigation.kt b/app/src/main/kotlin/cn/super12138/todo/ui/navigation/TodoNavigation.kt new file mode 100644 index 0000000..a4fda68 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/navigation/TodoNavigation.kt @@ -0,0 +1,129 @@ +package cn.super12138.todo.ui.navigation + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +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 cn.super12138.todo.ui.pages.editor.TodoEditorPage +import cn.super12138.todo.ui.pages.main.MainPage +import cn.super12138.todo.ui.pages.settings.SettingsAbout +import cn.super12138.todo.ui.pages.settings.SettingsAboutLicence +import cn.super12138.todo.ui.pages.settings.SettingsAppearance +import cn.super12138.todo.ui.pages.settings.SettingsInterface +import cn.super12138.todo.ui.pages.settings.SettingsMain +import cn.super12138.todo.ui.theme.materialSharedAxisXIn +import cn.super12138.todo.ui.theme.materialSharedAxisXOut +import cn.super12138.todo.ui.viewmodels.MainViewModel + +private const val INITIAL_OFFSET_FACTOR = 0.10f + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun TodoNavigation( + navController: NavHostController = rememberNavController(), + startDestination: String = TodoScreen.Main.name, + viewModel: MainViewModel, + modifier: Modifier = Modifier +) { + SharedTransitionLayout { + NavHost( + navController = navController, + startDestination = startDestination, + enterTransition = { + materialSharedAxisXIn( + initialOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() } + ) + }, + exitTransition = { + materialSharedAxisXOut( + targetOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() } + ) + }, + popEnterTransition = { + materialSharedAxisXIn( + initialOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() } + ) + }, + popExitTransition = { + materialSharedAxisXOut( + targetOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() } + ) + }, + modifier = modifier + ) { + composable(TodoScreen.Main.name) { + MainPage( + viewModel = viewModel, + toTodoEditPage = { navController.navigate(TodoScreen.TodoEditor.name) }, + toSettingsPage = { navController.navigate(TodoScreen.SettingsMain.name) }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@composable + ) + } + + composable(TodoScreen.TodoEditor.name) { + TodoEditorPage( + toDo = viewModel.selectedEditTodo, + onSave = { + viewModel.addTodo(it) + // viewModel.setEditTodoItem(null) + navController.navigateUp() + }, + onDelete = { + if (viewModel.selectedEditTodo !== null) { + viewModel.deleteTodo(viewModel.selectedEditTodo!!) + viewModel.setEditTodoItem(null) + } + navController.navigateUp() + }, + onNavigateUp = { + navController.navigateUp() + // viewModel.setEditTodoItem(null) + }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@composable + ) + } + + composable(TodoScreen.SettingsMain.name) { + SettingsMain( + toAppearancePage = { navController.navigate(TodoScreen.SettingsAppearance.name) }, + toAboutPage = { navController.navigate(TodoScreen.SettingsAbout.name) }, + toInterfacePage = { navController.navigate(TodoScreen.SettingsInterface.name) }, + onNavigateUp = { navController.navigateUp() }, + ) + } + + composable(TodoScreen.SettingsAppearance.name) { + SettingsAppearance( + viewModel = viewModel, + onNavigateUp = { navController.navigateUp() } + ) + } + + composable(TodoScreen.SettingsInterface.name) { + SettingsInterface( + viewModel = viewModel, + onNavigateUp = { navController.navigateUp() } + ) + } + + composable(TodoScreen.SettingsAbout.name) { + SettingsAbout( + onNavigateUp = { navController.navigateUp() }, + toLicencePage = { navController.navigate(TodoScreen.SettingsAboutLicence.name) } + ) + } + + composable(TodoScreen.SettingsAboutLicence.name) { + SettingsAboutLicence( + onNavigateUp = { navController.navigateUp() } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/navigation/TodoScreen.kt b/app/src/main/kotlin/cn/super12138/todo/ui/navigation/TodoScreen.kt new file mode 100644 index 0000000..68a5837 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/navigation/TodoScreen.kt @@ -0,0 +1,11 @@ +package cn.super12138.todo.ui.navigation + +enum class TodoScreen { + Main, + TodoEditor, + SettingsMain, + SettingsAppearance, + SettingsInterface, + SettingsAbout, + SettingsAboutLicence +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/activities/CrashHandler.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/crash/CrashHandler.kt similarity index 90% rename from app/src/main/kotlin/cn/super12138/todo/views/activities/CrashHandler.kt rename to app/src/main/kotlin/cn/super12138/todo/ui/pages/crash/CrashHandler.kt index 07325db..7ecaecf 100644 --- a/app/src/main/kotlin/cn/super12138/todo/views/activities/CrashHandler.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/crash/CrashHandler.kt @@ -1,12 +1,12 @@ -package cn.super12138.todo.views.activities +package cn.super12138.todo.ui.pages.crash import android.content.Context import android.content.Intent import android.os.Process +import cn.super12138.todo.ui.activities.CrashActivity import kotlin.system.exitProcess class CrashHandler(private val context: Context) : Thread.UncaughtExceptionHandler { - private val defaultUEH = Thread.getDefaultUncaughtExceptionHandler() override fun uncaughtException(thread: Thread, ex: Throwable) { diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/crash/CrashPage.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/crash/CrashPage.kt new file mode 100644 index 0000000..be089fa --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/crash/CrashPage.kt @@ -0,0 +1,94 @@ +package cn.super12138.todo.ui.pages.crash + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R +import cn.super12138.todo.ui.TodoDefaults +import cn.super12138.todo.ui.components.AnimatedExtendedFloatingActionButton + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashPage( + crashLog: String, + exitApp: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val scrollState = rememberScrollState() + val isExpanded by remember { + derivedStateOf { + scrollState.value == 0 + } + } + + Scaffold( + modifier = modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = { + Text( + text = stringResource(R.string.page_crash), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + scrollBehavior = scrollBehavior + ) + }, + floatingActionButton = { + AnimatedExtendedFloatingActionButton( + onClick = exitApp, + icon = Icons.AutoMirrored.Outlined.ExitToApp, + text = stringResource(R.string.action_exit_app), + expanded = isExpanded + ) + }, + contentWindowInsets = WindowInsets.safeContent + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = TodoDefaults.screenPadding) + .fillMaxSize() + .verticalScroll(scrollState) + ) { + Spacer(Modifier.height(5.dp)) + SelectionContainer { + Text( + text = crashLog, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/editor/TodoEditorPage.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/editor/TodoEditorPage.kt new file mode 100644 index 0000000..f9ea722 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/editor/TodoEditorPage.kt @@ -0,0 +1,264 @@ +package cn.super12138.todo.ui.pages.editor + +import android.view.HapticFeedbackConstants +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Undo +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Label +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R +import cn.super12138.todo.constants.Constants +import cn.super12138.todo.logic.database.TodoEntity +import cn.super12138.todo.logic.model.Priority +import cn.super12138.todo.logic.model.Subjects +import cn.super12138.todo.ui.TodoDefaults +import cn.super12138.todo.ui.components.AnimatedExtendedFloatingActionButton +import cn.super12138.todo.ui.components.FilterChipGroup +import cn.super12138.todo.ui.components.LargeTopAppBarScaffold +import cn.super12138.todo.ui.components.WarningDialog +import cn.super12138.todo.utils.VibrationUtils + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun TodoEditorPage( + toDo: TodoEntity? = null, + onSave: (TodoEntity) -> Unit, + onDelete: () -> Unit, + onNavigateUp: () -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + modifier: Modifier = Modifier +) { + var showExitConfirmDialog by rememberSaveable { mutableStateOf(false) } + var showDeleteConfirmDialog by rememberSaveable { mutableStateOf(false) } + + val view = LocalView.current + val context = LocalContext.current + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + var toDoContent by rememberSaveable { mutableStateOf(toDo?.content ?: "") } + var isError by rememberSaveable { mutableStateOf(false) } + var selectedSubjectIndex by rememberSaveable { mutableIntStateOf(toDo?.subject ?: 0) } + var priorityState by rememberSaveable { mutableFloatStateOf(toDo?.priority ?: 0f) } + + fun checkModifiedBeforeBack() { + var isModified = false + if ((toDo?.content ?: "") != toDoContent) isModified = true + if ((toDo?.subject ?: 0) != selectedSubjectIndex) isModified = true + if ((toDo?.priority ?: 0f) != priorityState) isModified = true + if (isModified) { + showExitConfirmDialog = true + } else { + onNavigateUp() + } + } + + BackHandler { + checkModifiedBeforeBack() + } + + LargeTopAppBarScaffold( + title = stringResource(if (toDo != null) R.string.title_edit_task else R.string.action_add_task), + scrollBehavior = scrollBehavior, + floatingActionButton = { + with(sharedTransitionScope) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (toDo !== null) { + AnimatedExtendedFloatingActionButton( + icon = Icons.Outlined.Delete, + text = stringResource(R.string.action_delete), + expanded = true, + containerColor = MaterialTheme.colorScheme.errorContainer, + onClick = { showDeleteConfirmDialog = true } + ) + } + AnimatedExtendedFloatingActionButton( + icon = Icons.Outlined.Save, + text = stringResource(R.string.action_save), + expanded = true, + onClick = { + if (toDoContent.trim().isEmpty()) { + isError = true + return@AnimatedExtendedFloatingActionButton + } + + isError = false + onSave( + TodoEntity( + content = toDoContent, + subject = selectedSubjectIndex, + isCompleted = toDo?.isCompleted ?: false, + priority = priorityState, + id = toDo?.id ?: 0 + ) + ) + }, + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = Constants.KEY_TODO_FAB_TRANSITION), + animatedVisibilityScope = animatedVisibilityScope + ) + ) + } + } + }, + onBack = { + checkModifiedBeforeBack() + }, + modifier = modifier + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = TodoDefaults.screenPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + with(sharedTransitionScope) { + TextField( + value = toDoContent, + onValueChange = { toDoContent = it }, + label = { Text(stringResource(R.string.placeholder_add_todo)) }, + isError = isError, + supportingText = { + AnimatedVisibility(isError) { + Text(stringResource(R.string.error_no_task_content)) + } + }, + modifier = Modifier + .fillMaxWidth() + .sharedBounds( + sharedContentState = rememberSharedContentState("${Constants.KEY_TODO_CONTENT_TRANSITION}_${toDo?.id}"), + animatedVisibilityScope = animatedVisibilityScope + ) + ) + } + + Spacer(Modifier.size(5.dp)) + + val subjects = remember { + Subjects.entries.map { + it.getDisplayName(context) + } + } + Text( + text = stringResource(R.string.label_subject), + style = MaterialTheme.typography.titleMedium + ) + + Spacer(Modifier.size(5.dp)) + + FilterChipGroup( + items = subjects, + defaultSelectedItemIndex = toDo?.subject ?: 0, + onSelectedChanged = { + selectedSubjectIndex = it + }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.size(10.dp)) + + Text( + text = stringResource(R.string.label_priority), + style = MaterialTheme.typography.titleMedium + ) + + Spacer(Modifier.size(5.dp)) + + val interactionSource = remember { MutableInteractionSource() } + + Slider( + modifier = Modifier.semantics { + contentDescription = context.getString(R.string.label_priority) + }, + value = priorityState, + onValueChange = { + VibrationUtils.performHapticFeedback(view, HapticFeedbackConstants.LONG_PRESS) + priorityState = it + }, + valueRange = -10f..10f, + steps = 3, + interactionSource = interactionSource, + thumb = { + Label( + label = { + PlainTooltip( + modifier = Modifier + .sizeIn(45.dp, 25.dp) + .wrapContentWidth() + ) { + Text(Priority.fromFloat(priorityState).getDisplayName(context)) + } + }, + interactionSource = interactionSource + ) { + SliderDefaults.Thumb(interactionSource) + } + } + ) + + Spacer(Modifier.size(40.dp)) + } + } + + WarningDialog( + visible = showExitConfirmDialog, + icon = Icons.AutoMirrored.Outlined.Undo, + description = stringResource(R.string.tip_discard_changes), + onConfirm = { + showExitConfirmDialog = false + onNavigateUp() + }, + onDismiss = { showExitConfirmDialog = false } + ) + + WarningDialog( + visible = showDeleteConfirmDialog, + icon = Icons.Outlined.Delete, + description = stringResource(R.string.tip_delete_task, 1), + onConfirm = onDelete, + onDismiss = { showDeleteConfirmDialog = false } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/MainPage.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/MainPage.kt new file mode 100644 index 0000000..7ec9e91 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/MainPage.kt @@ -0,0 +1,219 @@ +package cn.super12138.todo.ui.pages.main + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.Scaffold +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.window.core.layout.WindowWidthSizeClass +import cn.super12138.todo.R +import cn.super12138.todo.constants.Constants +import cn.super12138.todo.logic.database.TodoEntity +import cn.super12138.todo.ui.components.WarningDialog +import cn.super12138.todo.ui.pages.main.components.TodoFAB +import cn.super12138.todo.ui.pages.main.components.TodoTopAppBar +import cn.super12138.todo.ui.viewmodels.MainViewModel + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun MainPage( + viewModel: MainViewModel, + toTodoEditPage: () -> Unit, + toSettingsPage: () -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + modifier: Modifier = Modifier +) { + val toDoList = viewModel.sortedTodos.collectAsState(initial = emptyList()) + val listState = rememberLazyListState() + /*val isExpanded by remember { + derivedStateOf { + listState.firstVisibleItemIndex == 0 + } + }*/ + + val selectedTodoIds = viewModel.selectedTodoIds.collectAsState() + var showDeleteConfirmDialog by rememberSaveable { mutableStateOf(false) } + + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + + val isSelectedIdsEmpty by remember { + derivedStateOf { + selectedTodoIds.value.isEmpty() + } + } + + val showCompleted = viewModel.showCompletedTodos + val filteredTodoList = + if (showCompleted) toDoList.value else toDoList.value.filter { item -> !item.isCompleted } + + BackHandler(enabled = !isSelectedIdsEmpty) { + // 当按下返回键(或进行返回操作)时清空选择 + viewModel.clearAllTodoSelection() + } + + Scaffold( + topBar = { + TodoTopAppBar( + selectedTodoIds = selectedTodoIds.value, + selectedMode = !isSelectedIdsEmpty, + onCancelSelect = { viewModel.clearAllTodoSelection() }, + onSelectAll = { viewModel.toggleAllSelected() }, + onDeleteSelectedTodo = { showDeleteConfirmDialog = true }, + toSettingsPage = toSettingsPage + ) + }, + floatingActionButton = { + with(sharedTransitionScope) { + AnimatedVisibility( + visible = isSelectedIdsEmpty, + enter = fadeIn() + expandIn(), + exit = shrinkOut() + fadeOut() + ) { + // TODO: 修复在滑动列表时FAB位移导致的动画不连贯(临时方案为底部加padding) + TodoFAB( + expanded = true, + onClick = { + viewModel.setEditTodoItem(null) // 每次添加待办前清除上一次已选待办 + toTodoEditPage() + }, + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = Constants.KEY_TODO_FAB_TRANSITION), + animatedVisibilityScope = animatedVisibilityScope + ) + ) + } + } + }, + contentWindowInsets = WindowInsets.safeContent.exclude(WindowInsets.ime), + modifier = modifier + ) { innerPadding -> + if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT) { + Column(modifier = Modifier.padding(innerPadding)) { + ProgressFragment( + totalTasks = toDoList.value.size, + completedTasks = toDoList.value.count { it.isCompleted }, + modifier = Modifier + .weight(2f) + .fillMaxSize() + ) + + ManagerFragment( + state = listState, + list = filteredTodoList, + onItemClick = { item -> + if (isSelectedIdsEmpty) { + viewModel.setEditTodoItem(item) + toTodoEditPage() + } else { + viewModel.toggleTodoSelection(item) + } + }, + onItemLongClick = { item -> + viewModel.toggleTodoSelection(item) + }, + onItemChecked = { item -> + item.apply { + viewModel.updateTodo( + TodoEntity( + content = content, + subject = subject, + isCompleted = true, + priority = priority, + id = id + ) + ) + viewModel.playConfetti() + } + }, + selectedTodoIds = selectedTodoIds.value, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + modifier = Modifier + .weight(3f) + .fillMaxSize() + ) + } + } else { + Row(modifier = Modifier.padding(innerPadding)) { + ProgressFragment( + totalTasks = toDoList.value.size, + completedTasks = toDoList.value.count { it.isCompleted }, + modifier = Modifier + .weight(2f) + .fillMaxSize() + ) + ManagerFragment( + state = listState, + list = filteredTodoList, + onItemClick = { item -> + if (isSelectedIdsEmpty) { + viewModel.setEditTodoItem(item) + toTodoEditPage() + } else { + viewModel.toggleTodoSelection(item) + } + }, + onItemLongClick = { item -> + viewModel.toggleTodoSelection(item) + }, + onItemChecked = { item -> + item.apply { + viewModel.updateTodo( + TodoEntity( + content = content, + subject = subject, + isCompleted = true, + priority = priority, + id = id + ) + ) + viewModel.playConfetti() + } + }, + selectedTodoIds = selectedTodoIds.value, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + modifier = Modifier + .weight(3f) + .fillMaxSize() + ) + } + } + } + + WarningDialog( + visible = showDeleteConfirmDialog, + icon = Icons.Outlined.Delete, + description = stringResource(R.string.tip_delete_task, selectedTodoIds.value.size), + onConfirm = { viewModel.deleteSelectedTodo() }, + onDismiss = { showDeleteConfirmDialog = false } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ManagerFragment.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ManagerFragment.kt new file mode 100644 index 0000000..038afe2 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ManagerFragment.kt @@ -0,0 +1,89 @@ +package cn.super12138.todo.ui.pages.main + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R +import cn.super12138.todo.logic.database.TodoEntity +import cn.super12138.todo.logic.model.Subjects +import cn.super12138.todo.ui.TodoDefaults +import cn.super12138.todo.ui.pages.main.components.TodoCard +import my.nanihadesuka.compose.LazyColumnScrollbar +import my.nanihadesuka.compose.ScrollbarSettings + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ManagerFragment( + state: LazyListState, + list: List, + onItemClick: (TodoEntity) -> Unit = {}, + onItemLongClick: (TodoEntity) -> Unit = {}, + onItemChecked: (TodoEntity) -> Unit = {}, + selectedTodoIds: List, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + LazyColumnScrollbar( + state = state, + settings = ScrollbarSettings( + thumbUnselectedColor = MaterialTheme.colorScheme.secondary, + thumbSelectedColor = MaterialTheme.colorScheme.primary + ), + modifier = modifier + ) { + LazyColumn( + state = state, + contentPadding = PaddingValues(start = TodoDefaults.screenPadding, bottom = TodoDefaults.toDoCardHeight / 2, end = TodoDefaults.screenPadding) + ) { + if (list.isEmpty()) { + item { + Text( + text = stringResource(R.string.tip_no_task), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + } + } else { + items( + items = list, + key = { it.id } + ) { item -> + TodoCard( + id = item.id, + content = item.content, + subject = Subjects.fromId(item.subject).getDisplayName(context), + completed = item.isCompleted, + priority = item.priority, + selected = selectedTodoIds.contains(item.id), + onCardClick = { onItemClick(item) }, + onCardLongClick = { onItemLongClick(item) }, + onChecked = { onItemChecked(item) }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + modifier = Modifier + .padding(vertical = 5.dp) + .animateItem() // TODO: 设置动画时间 + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ProgressFragment.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ProgressFragment.kt new file mode 100644 index 0000000..08b6e6c --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/ProgressFragment.kt @@ -0,0 +1,79 @@ +package cn.super12138.todo.ui.pages.main + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R + +@Composable +fun ProgressFragment( + totalTasks: Int, + completedTasks: Int, + modifier: Modifier = Modifier +) { + val remainTasks = totalTasks - completedTasks + val progress = if (totalTasks != 0) { + completedTasks / totalTasks.toFloat() + } else { + // 没有任务时 + 0f + } + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "To Do Progress" + ) + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + progress = { animatedProgress }, + strokeWidth = 10.dp, + gapSize = 10.dp, + modifier = Modifier.size(170.dp) + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = completedTasks.toString(), + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Bold + ) + ) + Text( + text = stringResource(R.string.placeholder_divider), + style = MaterialTheme.typography.displayMedium.copy( + fontWeight = FontWeight.Bold + ) + ) + Text( + text = totalTasks.toString(), + style = MaterialTheme.typography.displayMedium.copy( + fontWeight = FontWeight.Bold + ) + ) + } + AnimatedVisibility(remainTasks != 0) { + Text( + text = stringResource(R.string.tip_remain_tasks, remainTasks), + style = MaterialTheme.typography.labelMedium + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoCard.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoCard.kt new file mode 100644 index 0000000..c50d641 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoCard.kt @@ -0,0 +1,204 @@ +package cn.super12138.todo.ui.pages.main.components + +import android.view.HapticFeedbackConstants +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R +import cn.super12138.todo.constants.Constants +import cn.super12138.todo.logic.model.Priority +import cn.super12138.todo.ui.TodoDefaults +import cn.super12138.todo.utils.VibrationUtils + +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) +@Composable +fun TodoCard( + id: Int, + content: String, + subject: String, + completed: Boolean, + priority: Float, + selected: Boolean, + onCardClick: () -> Unit = {}, + onCardLongClick: () -> Unit = {}, + onChecked: () -> Unit = {}, + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, + modifier: Modifier = Modifier +) { + val view = LocalView.current + val context = LocalContext.current + ElevatedCard( + modifier = modifier + .fillMaxWidth() + .height(TodoDefaults.toDoCardHeight) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .combinedClickable( + onClick = { + VibrationUtils.performHapticFeedback(view) + onCardClick() + }, + onLongClick = { + VibrationUtils.performHapticFeedback( + view, + HapticFeedbackConstants.LONG_PRESS + ) + onCardLongClick() + } + ) + .padding(horizontal = 15.dp) + ) { + AnimatedVisibility(selected) { + Box( + Modifier + .padding(end = 15.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondary) + .padding(5.dp) + ) { + Icon( + imageVector = Icons.Outlined.Check, + tint = contentColorFor(MaterialTheme.colorScheme.secondary), + contentDescription = stringResource(R.string.tip_select_this) + ) + } + } + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) { + BadgedBox( + badge = { + Badge( + containerColor = when (priority) { + -10f -> MaterialTheme.colorScheme.surfaceContainerHighest + -5f -> MaterialTheme.colorScheme.surfaceContainerHighest + 0f -> MaterialTheme.colorScheme.secondary + 5f -> MaterialTheme.colorScheme.tertiary + 10f -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.secondary + }, + modifier = Modifier.padding(start = 5.dp) + ) { + Text(Priority.fromFloat(priority).getDisplayName(context)) + } + } + ) { + with(sharedTransitionScope) { + Text( + text = content, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None, + modifier = Modifier + .sharedBounds( + sharedContentState = rememberSharedContentState("${Constants.KEY_TODO_CONTENT_TRANSITION}_$id"), + animatedVisibilityScope = animatedVisibilityScope + ) + .basicMarquee() // TODO: 后续评估性能影响 + ) + } + } + + Text( + text = subject, + style = MaterialTheme.typography.labelMedium, + textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None, + maxLines = 1 + ) + } + + AnimatedVisibility(!selected && !completed) { + IconButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + onChecked() + } + ) { + Icon( + imageVector = Icons.Outlined.Check, + tint = MaterialTheme.colorScheme.primary, + contentDescription = stringResource(R.string.tip_mark_completed) + ) + } + } + + /*Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .width(50.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.tertiaryContainer) + .clickable { + onChecked() + } + ) { + Icon( + imageVector = Icons.Outlined.Check, + tint = MaterialTheme.colorScheme.primary, + contentDescription = "" + ) + }*/ + } + } +} + +/* +@Preview(locale = "zh-rCN", showBackground = true) +@Composable +private fun TodoCardPreview() { + TodoCard( + content = "背《岳阳楼记》《出师表》《琵琶行》", + subject = "语文", + completed = false, + priority = Priority.Important.value, + selected = false, + onCardClick = {}, + onCardLongClick = {}, + onChecked = {}, + sharedTransitionScope = , + animatedVisibilityScope = + ) +}*/ diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoFAB.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoFAB.kt new file mode 100644 index 0000000..4d6ca5f --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoFAB.kt @@ -0,0 +1,37 @@ +package cn.super12138.todo.ui.pages.main.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.FloatingActionButtonElevation +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import cn.super12138.todo.R +import cn.super12138.todo.ui.components.AnimatedExtendedFloatingActionButton + +/** + * 待办页面底部 FAB + * + * @param expanded 是否为展开状态(展开状态显示内容和图标,收起状态只显示图标) + * @param onClick 当点击 FAB 时的回调 + */ +@Composable +fun TodoFAB( + expanded: Boolean, + onClick: () -> Unit, + elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), + modifier: Modifier = Modifier +) { + AnimatedExtendedFloatingActionButton( + icon = Icons.Outlined.Add, + text = stringResource(R.string.action_add_task), + expanded = expanded, + elevation = elevation, + onClick = onClick, + modifier = modifier + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoTopAppBar.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoTopAppBar.kt new file mode 100644 index 0000000..32559da --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/main/components/TodoTopAppBar.kt @@ -0,0 +1,149 @@ +package cn.super12138.todo.ui.pages.main.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.SelectAll +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.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.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import cn.super12138.todo.R +import cn.super12138.todo.utils.VibrationUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TodoTopAppBar( + selectedTodoIds: List, + selectedMode: Boolean, + onCancelSelect: () -> Unit, + onSelectAll: () -> Unit, + onDeleteSelectedTodo: () -> Unit, + toSettingsPage: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + val animatedTopAppBarColors by animateColorAsState( + targetValue = if (selectedMode) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.surface + ) + + TopAppBar( + navigationIcon = { + AnimatedVisibility(selectedMode) { + IconButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + onCancelSelect() + } + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.tip_clear_selected_items) + ) + } + } + }, + title = { + Text( + text = if (!selectedMode) { + stringResource(R.string.app_name) + } else { + stringResource( + R.string.title_selected_count, + selectedTodoIds.size + ) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + AnimatedContent( + targetState = selectedMode, + modifier = Modifier.windowInsetsPadding( + WindowInsets.safeContent.exclude(WindowInsets.ime) + ) + ) { inSelectedMode -> + if (!inSelectedMode) { + IconButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + toSettingsPage() + } + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.page_settings) + ) + } + } else { + Row { + IconButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + onSelectAll() + } + ) { + Icon( + imageVector = Icons.Outlined.SelectAll, + contentDescription = stringResource(R.string.tip_select_all) + ) + } + IconButton( + onClick = { + VibrationUtils.performHapticFeedback(view) + onDeleteSelectedTodo() + } + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.action_delete) + ) + } + } + } + } + + }, + colors = TopAppBarDefaults.topAppBarColors().copy( + containerColor = animatedTopAppBarColors + ) + ) +} + +@Preview(locale = "zh-rCN", showBackground = true) +@Composable +private fun TodoTopAppBarPreview() { + var selectedMode by remember { mutableStateOf(false) } + TodoTopAppBar( + selectedTodoIds = (1..10).toList(), + selectedMode = selectedMode, + onCancelSelect = { selectedMode = !selectedMode }, + onSelectAll = { }, + onDeleteSelectedTodo = { }, + toSettingsPage = { selectedMode = !selectedMode } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAbout.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAbout.kt new file mode 100644 index 0000000..7cc6ed9 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAbout.kt @@ -0,0 +1,80 @@ +package cn.super12138.todo.ui.pages.settings + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Balance +import androidx.compose.material.icons.outlined.Numbers +import androidx.compose.material.icons.outlined.Person4 +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import cn.super12138.todo.R +import cn.super12138.todo.constants.Constants +import cn.super12138.todo.ui.components.LargeTopAppBarScaffold +import cn.super12138.todo.ui.icons.GithubIcon +import cn.super12138.todo.ui.pages.settings.components.SettingsItem +import cn.super12138.todo.utils.SystemUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsAbout( + onNavigateUp: () -> Unit, + toLicencePage: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + LargeTopAppBarScaffold( + title = stringResource(R.string.pref_about), + onBack = onNavigateUp, + scrollBehavior = scrollBehavior, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { innerPadding -> + val context = LocalContext.current + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { + SettingsItem( + leadingIcon = Icons.Outlined.Numbers, + title = stringResource(R.string.pref_app_version), + description = SystemUtils.getAppVersion(context) + ) + SettingsItem( + leadingIcon = Icons.Outlined.Person4, + title = stringResource(R.string.pref_developer), + description = stringResource(R.string.developer_name), + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(Constants.DEVELOPER_GITHUB)) + context.startActivity(intent) + } + ) + SettingsItem( + leadingIcon = GithubIcon, + title = stringResource(R.string.pref_view_on_github), + description = stringResource(R.string.pref_view_on_github_desc), + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(Constants.GITHUB_REPO)) + context.startActivity(intent) + } + ) + SettingsItem( + leadingIcon = Icons.Outlined.Balance, + title = stringResource(R.string.pref_licence), + description = stringResource(R.string.pref_licence_desc), + onClick = toLicencePage + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAboutLicence.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAboutLicence.kt new file mode 100644 index 0000000..2c67406 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAboutLicence.kt @@ -0,0 +1,36 @@ +package cn.super12138.todo.ui.pages.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import cn.super12138.todo.R +import cn.super12138.todo.ui.components.LargeTopAppBarScaffold +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsAboutLicence( + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + LargeTopAppBarScaffold( + title = stringResource(R.string.pref_licence), + scrollBehavior = scrollBehavior, + onBack = onNavigateUp, + modifier = modifier + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + LibrariesContainer() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAppearance.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAppearance.kt new file mode 100644 index 0000000..414df85 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAppearance.kt @@ -0,0 +1,67 @@ +package cn.super12138.todo.ui.pages.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ColorLens +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import cn.super12138.todo.R +import cn.super12138.todo.constants.Constants +import cn.super12138.todo.ui.components.LargeTopAppBarScaffold +import cn.super12138.todo.ui.pages.settings.components.SwitchSettingsItem +import cn.super12138.todo.ui.pages.settings.components.contrast.ContrastPicker +import cn.super12138.todo.ui.pages.settings.components.darkmode.DarkModePicker +import cn.super12138.todo.ui.pages.settings.components.palette.PalettePicker +import cn.super12138.todo.ui.theme.appPaletteStyle +import cn.super12138.todo.ui.theme.isDynamicColorEnable +import cn.super12138.todo.ui.viewmodels.MainViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsAppearance( + viewModel: MainViewModel, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + LargeTopAppBarScaffold( + title = stringResource(R.string.pref_appearance), + onBack = onNavigateUp, + scrollBehavior = scrollBehavior, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { + DarkModePicker(onDarkModeChange = { viewModel.setDarkMode(it) }) + + PalettePicker( + isDarkMode = viewModel.appDarkMode, + contrastLevel = viewModel.appContrastLevel, + onPaletteChange = { appPaletteStyle = it } + ) + + ContrastPicker(onContrastChange = { viewModel.setContrastLevel(it) }) + + SwitchSettingsItem( + key = Constants.PREF_DYNAMIC_COLOR, + default = Constants.PREF_DYNAMIC_COLOR_DEFAULT, + leadingIcon = Icons.Outlined.ColorLens, + title = stringResource(R.string.pref_appearance_dynamic_color), + description = stringResource(R.string.pref_appearance_dynamic_color_desc), + onCheckedChange = { isDynamicColorEnable = it }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsInterfaceInteraction.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsInterfaceInteraction.kt new file mode 100644 index 0000000..fbd6bb4 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsInterfaceInteraction.kt @@ -0,0 +1,109 @@ +package cn.super12138.todo.ui.pages.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Sort +import androidx.compose.material.icons.outlined.Checklist +import androidx.compose.material.icons.outlined.Vibration +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import cn.super12138.todo.R +import cn.super12138.todo.constants.Constants +import cn.super12138.todo.logic.model.SortingMethod +import cn.super12138.todo.ui.components.LargeTopAppBarScaffold +import cn.super12138.todo.ui.pages.settings.components.SettingsCategory +import cn.super12138.todo.ui.pages.settings.components.SettingsItem +import cn.super12138.todo.ui.pages.settings.components.SettingsPlainBox +import cn.super12138.todo.ui.pages.settings.components.SettingsRadioDialog +import cn.super12138.todo.ui.pages.settings.components.SettingsRadioOptions +import cn.super12138.todo.ui.pages.settings.components.SwitchSettingsItem +import cn.super12138.todo.ui.viewmodels.MainViewModel +import cn.super12138.todo.utils.VibrationUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsInterface( + viewModel: MainViewModel, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + var showSortingMethodDialog by rememberSaveable { mutableStateOf(false) } + LargeTopAppBarScaffold( + title = stringResource(R.string.pref_interface_interaction), + onBack = onNavigateUp, + scrollBehavior = scrollBehavior, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + ) { innerPadding -> + + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { + SettingsCategory(stringResource(R.string.pref_category_todo_list)) + + SwitchSettingsItem( + key = Constants.PREF_SHOW_COMPLETED, + default = Constants.PREF_SHOW_COMPLETED_DEFAULT, + leadingIcon = Icons.Outlined.Checklist, + title = stringResource(R.string.pref_show_completed), + description = stringResource(R.string.pref_show_completed_desc), + onCheckedChange = { viewModel.setShowCompleted(it) }, + ) + SettingsItem( + leadingIcon = Icons.AutoMirrored.Outlined.Sort, + title = stringResource(R.string.pref_sorting_method), + description = viewModel.appSortingMethod.getDisplayName(context), + onClick = { showSortingMethodDialog = true } + ) + + SettingsCategory(stringResource(R.string.pref_category_global_interaction)) + SwitchSettingsItem( + key = Constants.PREF_HAPTIC_FEEDBACK, + default = Constants.PREF_HAPTIC_FEEDBACK_DEFAULT, + leadingIcon = Icons.Outlined.Vibration, + title = stringResource(R.string.pref_haptic_feedback), + description = stringResource(R.string.pref_haptic_feedback_desc), + onCheckedChange = { VibrationUtils.setEnabled(it) } + ) + SettingsPlainBox(stringResource(R.string.pref_haptic_feedback_more_info)) + } + } + + val sortingList = remember { + SortingMethod.entries.map { + SettingsRadioOptions( + id = it.id, + text = it.getDisplayName(context) + ) + } + } + SettingsRadioDialog( + key = Constants.PREF_SORTING_METHOD, + defaultIndex = Constants.PREF_SORTING_METHOD_DEFAULT, + visible = showSortingMethodDialog, + title = stringResource(R.string.pref_sorting_method), + options = sortingList, + onSelect = { id -> + viewModel.setSortingMethod(SortingMethod.fromId(id)) + }, + onDismiss = { showSortingMethodDialog = false } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsMain.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsMain.kt new file mode 100644 index 0000000..25b349e --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsMain.kt @@ -0,0 +1,62 @@ +package cn.super12138.todo.ui.pages.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ColorLens +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.ViewComfy +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import cn.super12138.todo.R +import cn.super12138.todo.ui.components.LargeTopAppBarScaffold +import cn.super12138.todo.ui.pages.settings.components.SettingsItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsMain( + toAppearancePage: () -> Unit, + toInterfacePage: () -> Unit, + toAboutPage: () -> Unit, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + LargeTopAppBarScaffold( + title = stringResource(R.string.page_settings), + scrollBehavior = scrollBehavior, + onBack = onNavigateUp + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { + SettingsItem( + leadingIcon = Icons.Outlined.ColorLens, + title = stringResource(R.string.pref_appearance), + description = stringResource(R.string.pref_appearance_desc), + onClick = toAppearancePage + ) + SettingsItem( + leadingIcon = Icons.Outlined.ViewComfy, + title = stringResource(R.string.pref_interface_interaction), + description = stringResource(R.string.pref_interface_interaction_desc), + onClick = toInterfacePage + ) + SettingsItem( + leadingIcon = Icons.Outlined.Info, + title = stringResource(R.string.pref_about), + description = stringResource(R.string.pref_about_desc), + onClick = toAboutPage + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/RowSettingsItem.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/RowSettingsItem.kt new file mode 100644 index 0000000..a4948be --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/RowSettingsItem.kt @@ -0,0 +1,108 @@ +package cn.super12138.todo.ui.pages.settings.components + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun RowSettingsItem( + title: String, + description: String? = null, + shape: Shape = MaterialTheme.shapes.large, + modifier: Modifier = Modifier, + state: LazyListState = rememberLazyListState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + horizontalArrangement: Arrangement.Horizontal = + if (!reverseLayout) Arrangement.Start else Arrangement.End, + verticalAlignment: Alignment.Vertical = Alignment.Top, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit +) { + MoreContentSettingsItem( + title = title, + description = description, + shape = shape + ) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + horizontalArrangement = horizontalArrangement, + verticalAlignment = verticalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + content = content + ) + } +} + +@Composable +fun MoreContentSettingsItem( + title: String, + description: String? = null, + shape: Shape = MaterialTheme.shapes.large, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(shape) + .padding(horizontal = 24.dp, vertical = 20.dp), + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp + ) + ) + description?.let { + Text( + text = it, + // maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + + Spacer(Modifier.size(8.dp)) + + /*Box( + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) {*/ + content() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsCategory.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsCategory.kt new file mode 100644 index 0000000..4a51636 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsCategory.kt @@ -0,0 +1,29 @@ +package cn.super12138.todo.ui.pages.settings.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsCategory( + title: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp, start = 24.dp, end = 24.dp) + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsDialogs.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsDialogs.kt new file mode 100644 index 0000000..9392098 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsDialogs.kt @@ -0,0 +1,119 @@ +package cn.super12138.todo.ui.pages.settings.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import cn.super12138.todo.ui.TodoDefaults +import cn.super12138.todo.ui.components.BasicDialog +import cn.super12138.todo.ui.pages.settings.state.rememberPrefIntState +import cn.super12138.todo.utils.VibrationUtils + +@Composable +fun SettingsRadioDialog( + key: String, + defaultIndex: Int, + visible: Boolean, + title: String, + options: List, + onSelect: (id: Int) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + SettingsDialog( + visible = visible, + title = title, + text = { + var selectedItemIndex by rememberPrefIntState(key, defaultIndex) + // Modifier.selectableGroup() 用来确保无障碍功能运行正确 + Column(Modifier.selectableGroup()) { + options.forEach { option -> + RadioItem( + selected = option.id == selectedItemIndex, + text = option.text, + onClick = { + selectedItemIndex = option.id + onSelect(option.id) + onDismiss() + } + ) + } + } + }, + onDismissRequest = onDismiss, + modifier = modifier + ) +} + +@Composable +fun RadioItem( + selected: Boolean, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + Row( + Modifier + .fillMaxWidth() + .height(56.dp) + .selectable( + selected = selected, + onClick = { + VibrationUtils.performHapticFeedback(view) + onClick() + }, + role = Role.RadioButton + ) + .padding(horizontal = TodoDefaults.screenPadding), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selected, + onClick = null // 设置为 null 有利于屏幕阅读器 + ) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } +} + +data class SettingsRadioOptions( + val id: Int, + val text: String, +) + +@Composable +fun SettingsDialog( + visible: Boolean, + title: String, + text: @Composable (() -> Unit)? = null, + onDismissRequest: () -> Unit = {}, + modifier: Modifier = Modifier +) { + BasicDialog( + visible = visible, + title = { Text(title) }, + text = text, + confirmButton = {}, + dismissButton = {}, + onDismissRequest = onDismissRequest, + modifier = modifier + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsItem.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsItem.kt new file mode 100644 index 0000000..8707a16 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsItem.kt @@ -0,0 +1,134 @@ +package cn.super12138.todo.ui.pages.settings.components + +import androidx.compose.foundation.clickable +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.wrapContentHeight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cn.super12138.todo.utils.VibrationUtils + +@Composable +fun SettingsItem( + leadingIcon: ImageVector? = null, + title: String, + description: String? = null, + enableClick: Boolean = true, + onClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + SettingsItem( + leadingIcon = leadingIcon, + title = title, + description = description, + trailingContent = null, + enableClick = enableClick, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun SettingsItem( + leadingIcon: ImageVector? = null, + title: String, + description: String? = null, + trailingContent: (@Composable () -> Unit)? = null, + enableClick: Boolean = true, + onClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + SettingsItem( + leadingIcon = { + leadingIcon?.let { + Icon( + imageVector = it, + contentDescription = title, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(end = 24.dp), + ) + } + }, + title = title, + description = description, + trailingContent = trailingContent, + enableClick = enableClick, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun SettingsItem( + leadingIcon: (@Composable () -> Unit)? = null, + title: String, + description: String? = null, + trailingContent: (@Composable () -> Unit)? = null, + shape: Shape = MaterialTheme.shapes.large, + enableClick: Boolean = true, + onClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val view = LocalView.current + Row( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(shape) + .clickable( + enabled = enableClick, + onClick = { + VibrationUtils.performHapticFeedback(view) + onClick() + } + ) + .padding(horizontal = 24.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + leadingIcon?.let { + it() + } + + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 20.sp + ) + ) + description?.let { + Text( + text = it, + // maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + + trailingContent?.let { + it() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsPlainBox.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsPlainBox.kt new file mode 100644 index 0000000..50b9cf6 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsPlainBox.kt @@ -0,0 +1,43 @@ +package cn.super12138.todo.ui.pages.settings.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R + +@Composable +fun SettingsPlainBox( + text: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 24.dp, end = 24.dp, bottom = 20.dp), + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = stringResource(R.string.tip_tips) + ) + Spacer(Modifier.size(20.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SwitchSettingsItem.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SwitchSettingsItem.kt new file mode 100644 index 0000000..3a31e33 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SwitchSettingsItem.kt @@ -0,0 +1,48 @@ +package cn.super12138.todo.ui.pages.settings.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import cn.super12138.todo.ui.pages.settings.state.rememberPrefBooleanState +import cn.super12138.todo.utils.VibrationUtils + +@Composable +fun SwitchSettingsItem( + key: String, + default: Boolean, + leadingIcon: ImageVector? = null, + title: String, + description: String? = null, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + var switchState by rememberPrefBooleanState(key, default) + SettingsItem( + leadingIcon = leadingIcon, + title = title, + description = description, + trailingContent = { + Switch( + checked = switchState, + onCheckedChange = { + switchState = it + VibrationUtils.performHapticFeedback(view) + onCheckedChange(it) + }, + modifier = Modifier.padding(start = 14.dp) + ) + }, + onClick = { + switchState = !switchState + onCheckedChange(switchState) + }, + modifier = modifier + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/contrast/ContrastPicker.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/contrast/ContrastPicker.kt new file mode 100644 index 0000000..ad31ab9 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/contrast/ContrastPicker.kt @@ -0,0 +1,76 @@ +package cn.super12138.todo.ui.pages.settings.components.contrast + +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R +import cn.super12138.todo.constants.Constants +import cn.super12138.todo.logic.model.ContrastLevel +import cn.super12138.todo.ui.pages.settings.components.MoreContentSettingsItem +import cn.super12138.todo.ui.pages.settings.state.rememberPrefFloatState +import cn.super12138.todo.utils.VibrationUtils + +@Composable +fun ContrastPicker( + onContrastChange: (ContrastLevel) -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + val context = LocalContext.current + MoreContentSettingsItem( + title = stringResource(R.string.pref_contrast_level), + description = stringResource(R.string.pref_contrast_level_desc) + ) { + var contrastState by rememberPrefFloatState( + Constants.PREF_CONTRAST_LEVEL, + Constants.PREF_CONTRAST_LEVEL_DEFAULT + ) + + Slider( + modifier = Modifier.semantics { + contentDescription = context.getString(R.string.tip_change_contrast_level) + }, + value = contrastState, + onValueChange = { + VibrationUtils.performHapticFeedback(view, HapticFeedbackConstants.LONG_PRESS) + contrastState = it + onContrastChange(ContrastLevel.fromFloat(it)) + }, + valueRange = -1f..1f, + steps = 3, + ) + + Spacer(Modifier.size(5.dp)) + + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(R.string.contrast_very_low)) + Text(stringResource(R.string.contrast_low)) + Text(stringResource(R.string.contrast_default)) + Text(stringResource(R.string.contrast_high)) + Text(stringResource(R.string.contrast_very_high)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/darkmode/DarkModeItem.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/darkmode/DarkModeItem.kt new file mode 100644 index 0000000..fa76169 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/darkmode/DarkModeItem.kt @@ -0,0 +1,76 @@ +package cn.super12138.todo.ui.pages.settings.components.darkmode + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import cn.super12138.todo.utils.VibrationUtils + +@Composable +fun DarkModeItem( + icon: ImageVector, + contentDescription: String, + contentColor: Color, + containerColor: Color, + selected: Boolean, + onSelect: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + val borderWidth by animateDpAsState(if (selected) 3.dp else (-1).dp) + Column( + modifier = modifier + .clip(MaterialTheme.shapes.large) + .clickable { + VibrationUtils.performHapticFeedback(view) + onSelect() + } + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(90.dp) + .clip(MaterialTheme.shapes.large) + .background(containerColor) + .border( + width = borderWidth, + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.large + ), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = contentColor, + modifier = Modifier + .size(30.dp) + .align(Alignment.Center) + ) + } + + Spacer(Modifier.size(8.dp)) + + Text( + text = contentDescription, + style = MaterialTheme.typography.bodyMedium + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/darkmode/DarkModePicker.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/darkmode/DarkModePicker.kt new file mode 100644 index 0000000..a78de22 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/darkmode/DarkModePicker.kt @@ -0,0 +1,80 @@ +package cn.super12138.todo.ui.pages.settings.components.darkmode + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R +import cn.super12138.todo.constants.Constants +import cn.super12138.todo.logic.model.DarkMode +import cn.super12138.todo.ui.pages.settings.components.RowSettingsItem +import cn.super12138.todo.ui.pages.settings.state.rememberPrefIntState + +@Composable +fun DarkModePicker( + onDarkModeChange: (darkMode: DarkMode) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + val isInDarkTheme = isSystemInDarkTheme() + + var darkModeState by rememberPrefIntState( + Constants.PREF_DARK_MODE, + Constants.PREF_DARK_MODE_DEFAULT + ) + + RowSettingsItem( + title = stringResource(R.string.pref_dark_mode), + description = stringResource(R.string.pref_dark_mode_desc), + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + item { + DarkModeItem( + icon = DarkMode.FollowSystem.icon, + contentDescription = DarkMode.FollowSystem.getDisplayName(context), + contentColor = if (isInDarkTheme) Color.White else Color.Black, + containerColor = if (isInDarkTheme) Color.Black else Color.White, + selected = DarkMode.fromId(darkModeState) == DarkMode.FollowSystem, + onSelect = { + darkModeState = DarkMode.FollowSystem.id + onDarkModeChange(DarkMode.FollowSystem) + } + ) + } + + item { + DarkModeItem( + icon = DarkMode.Light.icon, + contentDescription = DarkMode.Light.getDisplayName(context), + contentColor = Color.Black, + containerColor = Color.White, + selected = DarkMode.fromId(darkModeState) == DarkMode.Light, + onSelect = { + darkModeState = DarkMode.Light.id + onDarkModeChange(DarkMode.Light) + } + ) + } + + item { + DarkModeItem( + icon = DarkMode.Dark.icon, + contentDescription = DarkMode.Dark.getDisplayName(context), + contentColor = Color.White, + containerColor = Color.Black, + selected = DarkMode.fromId(darkModeState) == DarkMode.Dark, + onSelect = { + darkModeState = DarkMode.Dark.id + onDarkModeChange(DarkMode.Dark) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/palette/PaletteItem.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/palette/PaletteItem.kt new file mode 100644 index 0000000..9e4bea9 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/palette/PaletteItem.kt @@ -0,0 +1,109 @@ +package cn.super12138.todo.ui.pages.settings.components.palette + +import android.os.Build +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import cn.super12138.todo.constants.GlobalValues +import cn.super12138.todo.logic.model.ContrastLevel +import cn.super12138.todo.ui.theme.PaletteStyle +import cn.super12138.todo.ui.theme.dynamicColorScheme +import cn.super12138.todo.utils.VibrationUtils + +@Composable +fun PaletteItem( + isDark: Boolean, + paletteStyle: PaletteStyle, + contrastLevel: ContrastLevel, + selected: Boolean, + onSelect: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + val context = LocalContext.current + Column( + modifier = Modifier + .width(90.dp) + .clip(MaterialTheme.shapes.large) + .clickable { + VibrationUtils.performHapticFeedback(view) + onSelect() + } + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 为不同主题样式设置不同色板 + MaterialTheme( + colorScheme = dynamicColorScheme( + keyColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && GlobalValues.dynamicColor) { + colorResource(id = android.R.color.system_accent1_500) + } else { + Color(0xFF0061A4) + }, + isDark = isDark, + contrastLevel = contrastLevel.value.toDouble(), + style = paletteStyle + ) + ) { + val borderWidth by animateDpAsState(if (selected) 3.dp else (-1).dp) + // 颜色预览区域 + Column( + modifier = Modifier + .width(70.dp) + .clip(MaterialTheme.shapes.large) + .border( + width = borderWidth, + color = MaterialTheme.colorScheme.primary, + shape = MaterialTheme.shapes.large + ), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.primaryContainer, + ).fastForEach { + Box( + Modifier + .fillMaxWidth() + .height(24.dp) + .background(it) + ) + } + } + } + + Spacer(Modifier.size(8.dp)) + + Text( + text = paletteStyle.getDisplayName(context), + style = MaterialTheme.typography.bodyMedium + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/palette/PalettePicker.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/palette/PalettePicker.kt new file mode 100644 index 0000000..424b381 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/palette/PalettePicker.kt @@ -0,0 +1,59 @@ +package cn.super12138.todo.ui.pages.settings.components.palette + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import cn.super12138.todo.R +import cn.super12138.todo.constants.Constants +import cn.super12138.todo.logic.model.ContrastLevel +import cn.super12138.todo.logic.model.DarkMode +import cn.super12138.todo.ui.pages.settings.components.RowSettingsItem +import cn.super12138.todo.ui.pages.settings.state.rememberPrefIntState +import cn.super12138.todo.ui.theme.PaletteStyle + +@Composable +fun PalettePicker( + isDarkMode: DarkMode, + contrastLevel: ContrastLevel, + onPaletteChange: (paletteStyle: PaletteStyle) -> Unit, + modifier: Modifier = Modifier +) { + var paletteState by rememberPrefIntState( + Constants.PREF_PALETTE_STYLE, + Constants.PREF_PALETTE_STYLE_DEFAULT + ) + + val paletteOptions = remember { + PaletteStyle.entries.toList() + } + + RowSettingsItem( + title = stringResource(R.string.pref_palette_style), + description = stringResource(R.string.pref_palette_style_desc), + horizontalArrangement = Arrangement.spacedBy(5.dp) + ) { + items(items = paletteOptions, key = { it.id }) { paletteStyle -> + PaletteItem( + isDark = when (isDarkMode) { + DarkMode.FollowSystem -> isSystemInDarkTheme() + DarkMode.Light -> false + DarkMode.Dark -> true + }, + paletteStyle = paletteStyle, + selected = PaletteStyle.fromId(paletteState) == paletteStyle, + contrastLevel = contrastLevel, + onSelect = { + paletteState = paletteStyle.id + onPaletteChange(paletteStyle) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/state/PrefMutableState.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/state/PrefMutableState.kt new file mode 100644 index 0000000..d5604b0 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/state/PrefMutableState.kt @@ -0,0 +1,118 @@ +package cn.super12138.todo.ui.pages.settings.state + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +/** + * 来自:https://github.com/hushenghao/AndroidEasterEggs/blob/main/app/src/main/java/com/dede/android_eggs/views/settings/compose/basic/PrefMutableState.kt + * 在命名上有改动 + * @author hushenghao + */ + +@Composable +fun rememberPrefBooleanState(key: String, default: Boolean): MutableState { + val context = LocalContext.current + return remember { PrefMutableBooleanState(context, key, default) } +} + +@Composable +fun rememberPrefIntState(key: String, default: Int): MutableIntState { + val context = LocalContext.current + return remember { PrefMutableIntState(context, key, default) } +} + +@Composable +fun rememberPrefFloatState(key: String, default: Float): MutableFloatState { + val context = LocalContext.current + return remember { PrefMutableFloatState(context, key, default) } +} + +private class PrefMutableBooleanState( + val context: Context, + val key: String, + default: Boolean, +) : MutableState { + private val delegate = mutableStateOf(context.pref.getBoolean(key, default)) + + override var value: Boolean + get() = delegate.value + set(value) { + delegate.value = value + context.pref.edit().putBoolean(key, value).apply() + } + + override fun component1(): Boolean { + return delegate.component1() + } + + override fun component2(): (Boolean) -> Unit { + return delegate.component2() + } +} + +private class PrefMutableIntState( + val context: Context, + val key: String, + default: Int, +) : MutableIntState { + private val delegate = mutableIntStateOf(context.pref.getInt(key, default)) + + override var intValue: Int + get() = delegate.intValue + set(value) { + delegate.intValue = value + context.pref.edit().putInt(key, value).apply() + } + + override fun component1(): Int { + return delegate.component1() + } + + override fun component2(): (Int) -> Unit { + return delegate.component2() + } +} + +private class PrefMutableFloatState( + val context: Context, + val key: String, + default: Float, +) : MutableFloatState { + private val delegate = mutableFloatStateOf(context.pref.getFloat(key, default)) + + override var floatValue: Float + get() = delegate.floatValue + set(value) { + delegate.floatValue = value + context.pref.edit().putFloat(key, value).apply() + } + + override fun component1(): Float { + return delegate.component1() + } + + override fun component2(): (Float) -> Unit { + return delegate.component2() + } +} + +/** + * 来自:https://github.com/hushenghao/AndroidEasterEggs/blob/main/basic/src/main/java/com/dede/android_eggs/util/Pref.kt + * @author hushenghao + */ +val Context.pref: SharedPreferences + get() { + return applicationContext.getSharedPreferences( + applicationContext.packageName + "_preferences", + Context.MODE_PRIVATE + ) + } \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/theme/Motion.kt b/app/src/main/kotlin/cn/super12138/todo/ui/theme/Motion.kt new file mode 100644 index 0000000..cdaf9c4 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/theme/Motion.kt @@ -0,0 +1,87 @@ +package cn.super12138.todo.ui.theme + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally + +/** + * 来自:https://github.com/Ashinch/ReadYou/blob/main/app/src/main/java/me/ash/reader/ui/motion/MaterialSharedAxis.kt + */ + +private const val ProgressThreshold = 0.35f + +private val Int.ForOutgoing: Int + get() = (this * ProgressThreshold).toInt() + +private val Int.ForIncoming: Int + get() = this - this.ForOutgoing + + +private const val DefaultMotionDuration: Int = 300 + +/** + * [materialSharedAxisX] allows to switch a layout with shared X-axis transition. + * + */ +fun materialSharedAxisX( + initialOffsetX: (fullWidth: Int) -> Int, + targetOffsetX: (fullWidth: Int) -> Int, + durationMillis: Int = DefaultMotionDuration, +): ContentTransform = ContentTransform( + materialSharedAxisXIn( + initialOffsetX = initialOffsetX, + durationMillis = durationMillis + ), materialSharedAxisXOut( + targetOffsetX = targetOffsetX, + durationMillis = durationMillis + ) +) + +/** + * [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition. + */ +fun materialSharedAxisXIn( + initialOffsetX: (fullWidth: Int) -> Int, + durationMillis: Int = DefaultMotionDuration, +): EnterTransition = slideInHorizontally( + animationSpec = tween( + durationMillis = durationMillis, + easing = FastOutSlowInEasing + ), + initialOffsetX = initialOffsetX +) + fadeIn( + animationSpec = tween( + durationMillis = durationMillis.ForIncoming, + delayMillis = durationMillis.ForOutgoing, + easing = LinearOutSlowInEasing + ) +) + +/** + * [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition. + * + */ +fun materialSharedAxisXOut( + targetOffsetX: (fullWidth: Int) -> Int, + durationMillis: Int = DefaultMotionDuration, +): ExitTransition = slideOutHorizontally( + animationSpec = tween( + durationMillis = durationMillis, + easing = FastOutSlowInEasing + ), + targetOffsetX = targetOffsetX +) + fadeOut( + animationSpec = tween( + durationMillis = durationMillis.ForOutgoing, + delayMillis = 0, + easing = FastOutLinearInEasing + ) +) \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/theme/PaletteStyle.kt b/app/src/main/kotlin/cn/super12138/todo/ui/theme/PaletteStyle.kt new file mode 100644 index 0000000..1205ff2 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/theme/PaletteStyle.kt @@ -0,0 +1,37 @@ +package cn.super12138.todo.ui.theme + +import android.content.Context +import cn.super12138.todo.R + +enum class PaletteStyle(val id: Int) { + TonalSpot(1), + Neutral(2), + Vibrant(3), + Expressive(4), + Rainbow(5), + FruitSalad(6), + Monochrome(7), + Fidelity(8), + Content(9); + + fun getDisplayName(context: Context): String { + val resId = when (this) { + TonalSpot -> R.string.palette_tonal_spot + Neutral -> R.string.palette_neutral + Vibrant -> R.string.palette_vibrant + Expressive -> R.string.palette_expressive + Rainbow -> R.string.palette_rainbow + FruitSalad -> R.string.palette_fruit_salad + Monochrome -> R.string.palette_monochrome + Fidelity -> R.string.palette_fidelity + Content -> R.string.palette_content + } + return context.getString(resId) + } + + companion object { + fun fromId(id: Int): PaletteStyle { + return PaletteStyle.entries.find { it.id == id } ?: TonalSpot + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/theme/Theme.kt b/app/src/main/kotlin/cn/super12138/todo/ui/theme/Theme.kt new file mode 100644 index 0000000..e97059c --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/theme/Theme.kt @@ -0,0 +1,48 @@ +package cn.super12138.todo.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import cn.super12138.todo.constants.GlobalValues + +var isDynamicColorEnable by mutableStateOf(GlobalValues.dynamicColor) +var appPaletteStyle by mutableStateOf(PaletteStyle.fromId(GlobalValues.paletteStyle)) + +@Composable +fun ToDoTheme( + color: Color? = null, + darkTheme: Boolean = isSystemInDarkTheme(), + style: PaletteStyle = appPaletteStyle, + contrastLevel: Double = 0.0, + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = isDynamicColorEnable, + content: @Composable () -> Unit +) { + val baseColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && dynamicColor) { + colorResource(id = android.R.color.system_accent1_500) + } else { + Color(0xFF0061A4) + } + + // 关键色,如果指定就使用 + val keyColor = color ?: baseColor + + val colorScheme = dynamicColorScheme( + keyColor = keyColor, + isDark = darkTheme, + style = style, + contrastLevel = contrastLevel + ) + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/theme/ThemeExt.kt b/app/src/main/kotlin/cn/super12138/todo/ui/theme/ThemeExt.kt new file mode 100644 index 0000000..6389357 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/theme/ThemeExt.kt @@ -0,0 +1,91 @@ +package cn.super12138.todo.ui.theme + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.spring +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.kyant.m3color.hct.Hct +import com.kyant.m3color.scheme.SchemeContent +import com.kyant.m3color.scheme.SchemeExpressive +import com.kyant.m3color.scheme.SchemeFidelity +import com.kyant.m3color.scheme.SchemeFruitSalad +import com.kyant.m3color.scheme.SchemeMonochrome +import com.kyant.m3color.scheme.SchemeNeutral +import com.kyant.m3color.scheme.SchemeRainbow +import com.kyant.m3color.scheme.SchemeTonalSpot +import com.kyant.m3color.scheme.SchemeVibrant + +@Composable +@Stable +fun dynamicColorScheme( + keyColor: Color, + isDark: Boolean, + style: PaletteStyle = PaletteStyle.TonalSpot, + contrastLevel: Double = 0.0, + animationSpec: AnimationSpec = spring() +): ColorScheme { + val hct = Hct.fromInt(keyColor.toArgb()) + val scheme = when (style) { + PaletteStyle.TonalSpot -> SchemeTonalSpot(hct, isDark, contrastLevel) + PaletteStyle.Neutral -> SchemeNeutral(hct, isDark, contrastLevel) + PaletteStyle.Vibrant -> SchemeVibrant(hct, isDark, contrastLevel) + PaletteStyle.Expressive -> SchemeExpressive(hct, isDark, contrastLevel) + PaletteStyle.Rainbow -> SchemeRainbow(hct, isDark, contrastLevel) + PaletteStyle.FruitSalad -> SchemeFruitSalad(hct, isDark, contrastLevel) + PaletteStyle.Monochrome -> SchemeMonochrome(hct, isDark, contrastLevel) + PaletteStyle.Fidelity -> SchemeFidelity(hct, isDark, contrastLevel) + PaletteStyle.Content -> SchemeContent(hct, isDark, contrastLevel) + } + + return ColorScheme( + background = scheme.background.toColor().animate(animationSpec), + error = scheme.error.toColor().animate(animationSpec), + errorContainer = scheme.errorContainer.toColor().animate(animationSpec), + inverseOnSurface = scheme.inverseOnSurface.toColor().animate(animationSpec), + inversePrimary = scheme.inversePrimary.toColor().animate(animationSpec), + inverseSurface = scheme.inverseSurface.toColor().animate(animationSpec), + onBackground = scheme.onBackground.toColor().animate(animationSpec), + onError = scheme.onError.toColor().animate(animationSpec), + onErrorContainer = scheme.onErrorContainer.toColor().animate(animationSpec), + onPrimary = scheme.onPrimary.toColor().animate(animationSpec), + onPrimaryContainer = scheme.onPrimaryContainer.toColor().animate(animationSpec), + onSecondary = scheme.onSecondary.toColor().animate(animationSpec), + onSecondaryContainer = scheme.onSecondaryContainer.toColor().animate(animationSpec), + onSurface = scheme.onSurface.toColor().animate(animationSpec), + onSurfaceVariant = scheme.onSurfaceVariant.toColor().animate(animationSpec), + onTertiary = scheme.onTertiary.toColor().animate(animationSpec), + onTertiaryContainer = scheme.onTertiaryContainer.toColor().animate(animationSpec), + outline = scheme.outline.toColor().animate(animationSpec), + outlineVariant = scheme.outlineVariant.toColor().animate(animationSpec), + primary = scheme.primary.toColor().animate(animationSpec), + primaryContainer = scheme.primaryContainer.toColor().animate(animationSpec), + scrim = scheme.scrim.toColor().animate(animationSpec), + secondary = scheme.secondary.toColor().animate(animationSpec), + secondaryContainer = scheme.secondaryContainer.toColor().animate(animationSpec), + surface = scheme.surface.toColor().animate(animationSpec), + surfaceBright = scheme.surfaceBright.toColor().animate(animationSpec), + surfaceContainer = scheme.surfaceContainer.toColor().animate(animationSpec), + surfaceContainerLow = scheme.surfaceContainerLow.toColor().animate(animationSpec), + surfaceContainerLowest = scheme.surfaceContainerLowest.toColor().animate(animationSpec), + surfaceContainerHigh = scheme.surfaceContainerHigh.toColor().animate(animationSpec), + surfaceContainerHighest = scheme.surfaceContainerHighest.toColor().animate(animationSpec), + surfaceDim = scheme.surfaceDim.toColor().animate(animationSpec), + surfaceTint = scheme.surfaceTint.toColor().animate(animationSpec), + surfaceVariant = scheme.surfaceVariant.toColor().animate(animationSpec), + tertiary = scheme.tertiary.toColor().animate(animationSpec), + tertiaryContainer = scheme.tertiaryContainer.toColor().animate(animationSpec), + ) +} + +@Suppress("NOTHING_TO_INLINE") +private inline fun Int.toColor(): Color = Color(this) + +// https://github.com/jordond/MaterialKolor/blob/main/material-kolor/src/commonMain/kotlin/com/materialkolor/DynamicMaterialTheme.kt +@Composable +private fun Color.animate(animationSpec: AnimationSpec = spring()): Color { + return animateColorAsState(this, animationSpec).value +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/theme/Type.kt b/app/src/main/kotlin/cn/super12138/todo/ui/theme/Type.kt new file mode 100644 index 0000000..265850d --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package cn.super12138.todo.ui.theme + +import androidx.compose.material3.Typography + +val Typography = Typography() \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/viewmodels/MainViewModel.kt b/app/src/main/kotlin/cn/super12138/todo/ui/viewmodels/MainViewModel.kt new file mode 100644 index 0000000..6001e8a --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/viewmodels/MainViewModel.kt @@ -0,0 +1,153 @@ +package cn.super12138.todo.ui.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import cn.super12138.todo.constants.GlobalValues +import cn.super12138.todo.logic.Repository +import cn.super12138.todo.logic.database.TodoEntity +import cn.super12138.todo.logic.model.ContrastLevel +import cn.super12138.todo.logic.model.DarkMode +import cn.super12138.todo.logic.model.SortingMethod +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class MainViewModel : ViewModel() { + // 待办 + private val toDos: Flow> = Repository.getAllTodos() + var appSortingMethod by mutableStateOf(SortingMethod.fromId(GlobalValues.sortingMethod)) + val sortedTodos: Flow> = toDos.map { list -> + when (appSortingMethod) { + SortingMethod.Date -> list.sortedBy { it.id } + SortingMethod.Subject -> list.sortedBy { it.subject } + SortingMethod.Priority -> list.sortedByDescending { it.priority } // 优先级高的在前 + SortingMethod.Completion -> list.sortedBy { it.isCompleted } // 未完成的在前 + SortingMethod.AlphabeticalAscending -> list.sortedBy { it.content } + SortingMethod.AlphabeticalDescending -> list.sortedByDescending { it.content } + } + } + val showConfetti = mutableStateOf(false) + var selectedEditTodo by mutableStateOf(null) + private set + var showCompletedTodos by mutableStateOf(GlobalValues.showCompleted) + + // 主题颜色 + var appDarkMode by mutableStateOf(DarkMode.fromId(GlobalValues.darkMode)) + private set + var appContrastLevel by mutableStateOf(ContrastLevel.fromFloat(GlobalValues.contrastLevel)) + private set + + // 多选逻辑参考:https://github.com/X1nto/Mauth + private val _selectedTodoIds = MutableStateFlow(listOf()) + val selectedTodoIds = _selectedTodoIds.asStateFlow() + + fun addTodo(toDo: TodoEntity) { + viewModelScope.launch { + Repository.insertTodo(toDo) + } + } + + fun updateTodo(toDo: TodoEntity) { + viewModelScope.launch { + Repository.updateTodo(toDo) + } + } + + fun deleteTodo(toDo: TodoEntity) { + viewModelScope.launch { + Repository.deleteTodo(toDo) + } + } + + /*fun deleteAllTodo() { + viewModelScope.launch { + Repository.deleteAllTodo() + } + }*/ + + fun setEditTodoItem(toDo: TodoEntity?) { + selectedEditTodo = toDo + } + + /** + * 切换待办的选择状态 + */ + fun toggleTodoSelection(toDo: TodoEntity) { + _selectedTodoIds.update { idList -> + if (idList.contains(toDo.id)) { + // 若已经选择取消选择 + idList - toDo.id + } else { + // 若未选择添加到列表中,立即选中 + idList + toDo.id + } + } + } + + /** + * 切换是否全选 + */ + fun toggleAllSelected() { + viewModelScope.launch { + toDos.firstOrNull()?.let { todos -> + val allIds = todos.map { it.id } + _selectedTodoIds.update { currentSelectedIds -> + if (currentSelectedIds.containsAll(allIds)) { + // 如果当前是全选状态,取消所有选择(切换为全不选) + emptyList() + } else { + // 如果当前不是全选状态,选中所有项 + allIds + } + } + } + } + } + + /** + * 清除全部已选择的待办 + */ + fun clearAllTodoSelection() { + _selectedTodoIds.update { emptyList() } + } + + /** + * 删除选择的待办 + */ + fun deleteSelectedTodo() { + viewModelScope.launch { + Repository.deleteTodoFromIds(selectedTodoIds.value) + clearAllTodoSelection() + } + } + + fun playConfetti() { + showConfetti.value = true + } + + /** + * 应用设置 + */ + fun setDarkMode(darkMode: DarkMode) { + appDarkMode = darkMode + } + + fun setContrastLevel(contrastLevel: ContrastLevel) { + appContrastLevel = contrastLevel + } + + fun setShowCompleted(show: Boolean) { + showCompletedTodos = show + } + + fun setSortingMethod(sortingMethod: SortingMethod) { + appSortingMethod = sortingMethod + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/utils/sp/SPDelegates.kt b/app/src/main/kotlin/cn/super12138/todo/utils/SPDelegates.kt similarity index 92% rename from app/src/main/kotlin/cn/super12138/todo/utils/sp/SPDelegates.kt rename to app/src/main/kotlin/cn/super12138/todo/utils/SPDelegates.kt index b00405a..5af97cd 100644 --- a/app/src/main/kotlin/cn/super12138/todo/utils/sp/SPDelegates.kt +++ b/app/src/main/kotlin/cn/super12138/todo/utils/SPDelegates.kt @@ -1,4 +1,4 @@ -package cn.super12138.todo.utils.sp +package cn.super12138.todo.utils import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty diff --git a/app/src/main/kotlin/cn/super12138/todo/utils/sp/SPUtils.kt b/app/src/main/kotlin/cn/super12138/todo/utils/SPUtils.kt similarity index 85% rename from app/src/main/kotlin/cn/super12138/todo/utils/sp/SPUtils.kt rename to app/src/main/kotlin/cn/super12138/todo/utils/SPUtils.kt index dfbf247..453627f 100644 --- a/app/src/main/kotlin/cn/super12138/todo/utils/sp/SPUtils.kt +++ b/app/src/main/kotlin/cn/super12138/todo/utils/SPUtils.kt @@ -1,13 +1,13 @@ -package cn.super12138.todo.utils.sp +package cn.super12138.todo.utils import android.content.Context import android.content.SharedPreferences -import cn.super12138.todo.ToDoApp -import cn.super12138.todo.constant.Constants +import cn.super12138.todo.TodoApp +import cn.super12138.todo.constants.Constants object SPUtils { private val sp: SharedPreferences by lazy { - ToDoApp.context.getSharedPreferences(Constants.SP_NAME, Context.MODE_PRIVATE) + TodoApp.context.getSharedPreferences(Constants.SP_NAME, Context.MODE_PRIVATE) } fun getValue(name: String, default: T): T = with(sp) { @@ -33,4 +33,5 @@ object SPUtils { else -> throw IllegalArgumentException("This type can't be saved into Preferences") }.apply() } -} \ No newline at end of file +} + diff --git a/app/src/main/kotlin/cn/super12138/todo/utils/VersionUtils.kt b/app/src/main/kotlin/cn/super12138/todo/utils/SystemUtils.kt similarity index 96% rename from app/src/main/kotlin/cn/super12138/todo/utils/VersionUtils.kt rename to app/src/main/kotlin/cn/super12138/todo/utils/SystemUtils.kt index 56c6dc9..63401a4 100644 --- a/app/src/main/kotlin/cn/super12138/todo/utils/VersionUtils.kt +++ b/app/src/main/kotlin/cn/super12138/todo/utils/SystemUtils.kt @@ -3,7 +3,7 @@ package cn.super12138.todo.utils import android.content.Context import android.os.Build -object VersionUtils { +object SystemUtils { /** * 获取应用版本号 * @return 版本名称(版本代码) diff --git a/app/src/main/kotlin/cn/super12138/todo/utils/TextUtils.kt b/app/src/main/kotlin/cn/super12138/todo/utils/TextUtils.kt deleted file mode 100644 index 93adf32..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/utils/TextUtils.kt +++ /dev/null @@ -1,49 +0,0 @@ -package cn.super12138.todo.utils - -import android.text.Editable -import cn.super12138.todo.R -import cn.super12138.todo.ToDoApp - -object TextUtils { - // 更换为id匹配(数据库) - private val subjectMap = mapOf( - globalGetString(R.string.subject_chinese) to R.id.subject_chinese, - globalGetString(R.string.subject_math) to R.id.subject_math, - globalGetString(R.string.subject_english) to R.id.subject_english, - globalGetString(R.string.subject_biology) to R.id.subject_biology, - globalGetString(R.string.subject_geography) to R.id.subject_geography, - globalGetString(R.string.subject_history) to R.id.subject_history, - globalGetString(R.string.subject_physics) to R.id.subject_physics, - globalGetString(R.string.subject_chemistry) to R.id.subject_chemistry, - globalGetString(R.string.subject_law) to R.id.subject_law, - globalGetString(R.string.subject_other) to R.id.subject_other - ) - - fun getSubjectName(id: Int): String { - return when (id) { - R.id.subject_chinese -> globalGetString(R.string.subject_chinese) - R.id.subject_math -> globalGetString(R.string.subject_math) - R.id.subject_english -> globalGetString(R.string.subject_english) - R.id.subject_biology -> globalGetString(R.string.subject_biology) - R.id.subject_geography -> globalGetString(R.string.subject_geography) - R.id.subject_history -> globalGetString(R.string.subject_history) - R.id.subject_physics -> globalGetString(R.string.subject_physics) - R.id.subject_chemistry -> globalGetString(R.string.subject_chemistry) - R.id.subject_law -> globalGetString(R.string.subject_law) - R.id.subject_other -> globalGetString(R.string.subject_other) - else -> globalGetString(R.string.subject_unknown) - } - } - - fun getSubjectID(name: String): Int? { - return subjectMap[name] - } -} - -fun globalGetString(resID: Int): String { - return ToDoApp.context.resources.getString(resID) -} - -fun String.toEditable(): Editable? { - return Editable.Factory.getInstance().newEditable(this) -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/utils/Utils.kt b/app/src/main/kotlin/cn/super12138/todo/utils/Utils.kt deleted file mode 100644 index 6d8d67f..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/utils/Utils.kt +++ /dev/null @@ -1,36 +0,0 @@ -package cn.super12138.todo.utils - -import android.content.Context -import android.os.SystemClock -import android.view.View -import android.widget.Toast -import cn.super12138.todo.ToDoApp - -private var clickInterval = 380L -private var lastTime = 0L - -/** - * 延迟点击 - */ -fun View.setOnIntervalClickListener(onIntervalClickListener: (View) -> Unit) { - this.setOnClickListener { - if (SystemClock.elapsedRealtime() - lastTime > clickInterval) { - lastTime = SystemClock.elapsedRealtime() - onIntervalClickListener.invoke(it) - } - } -} - -/** - * Toast - */ -fun String.showToast( - context: Context = ToDoApp.context, - duration: Int = Toast.LENGTH_SHORT -) { - Toast.makeText(context, this, duration).show() -} - -fun Int.showToast(context: Context = ToDoApp.context, duration: Int = Toast.LENGTH_SHORT) { - Toast.makeText(context, this, duration).show() -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/utils/VibrationUtils.kt b/app/src/main/kotlin/cn/super12138/todo/utils/VibrationUtils.kt index 6d4d4bb..6c8e0a5 100644 --- a/app/src/main/kotlin/cn/super12138/todo/utils/VibrationUtils.kt +++ b/app/src/main/kotlin/cn/super12138/todo/utils/VibrationUtils.kt @@ -2,15 +2,21 @@ package cn.super12138.todo.utils import android.view.HapticFeedbackConstants import android.view.View -import cn.super12138.todo.constant.GlobalValues +import cn.super12138.todo.constants.GlobalValues object VibrationUtils { + private var isEnabled: Boolean = GlobalValues.hapticFeedback + + fun setEnabled(enabled: Boolean) { + isEnabled = enabled + } + fun performHapticFeedback( - view: View?, - hapticFeedbackConstants: Int = HapticFeedbackConstants.KEYBOARD_TAP + view: View, + feedbackConstants: Int = HapticFeedbackConstants.CONTEXT_CLICK ) { - if (GlobalValues.hapticFeedback) { - view?.performHapticFeedback(hapticFeedbackConstants) + if (isEnabled) { + view.performHapticFeedback(feedbackConstants) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/BaseActivity.kt b/app/src/main/kotlin/cn/super12138/todo/views/BaseActivity.kt deleted file mode 100644 index 384583d..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/BaseActivity.kt +++ /dev/null @@ -1,45 +0,0 @@ -package cn.super12138.todo.views - -import android.os.Build -import android.os.Bundle -import android.view.WindowManager -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDelegate -import androidx.viewbinding.ViewBinding -import cn.super12138.todo.constant.GlobalValues - -abstract class BaseActivity : AppCompatActivity() { - lateinit var binding: T - override fun onCreate(savedInstanceState: Bundle?) { - /*if (GlobalValues.springFestivalTheme) { - setTheme(R.style.Theme_SpringFestival) - }*/ - // enableEdgeToEdge(navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)) - super.onCreate(savedInstanceState) - - // 确保 Navigation Bar 区域会被显示 - // WindowCompat.setDecorFitsSystemWindows(window, false) - - binding = getViewBinding() - setContentView(binding.root) - - // 深色模式 - when (GlobalValues.darkMode) { - "0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - - "1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - - "2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - } - - // 适配刘海屏 - val lp = window.attributes - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - lp.layoutInDisplayCutoutMode = - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - } - window.attributes = lp - } - - abstract fun getViewBinding(): T -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/BaseFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/BaseFragment.kt deleted file mode 100644 index 2e58b76..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/BaseFragment.kt +++ /dev/null @@ -1,50 +0,0 @@ -package cn.super12138.todo.views - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.viewbinding.ViewBinding -import com.google.android.material.color.MaterialColors -import com.google.android.material.transition.MaterialSharedAxis - -abstract class BaseFragment : Fragment() { - private var _binding: T? = null - protected val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, /* forward= */ true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, /* forward= */ false) - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, /* forward= */ true) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, /* forward= */ false) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - _binding = getViewBinding(inflater, container, false) - return binding.root - } - - // https://github.com/material-components/material-components-android/issues/1984#issuecomment-1089710991 - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - // Overlap colors. - view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.colorBackground)) - } - - abstract fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): T - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/activities/CrashActivity.kt b/app/src/main/kotlin/cn/super12138/todo/views/activities/CrashActivity.kt deleted file mode 100644 index b8cfce2..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/activities/CrashActivity.kt +++ /dev/null @@ -1,51 +0,0 @@ -package cn.super12138.todo.views.activities - -import android.os.Build -import android.os.Bundle -import cn.super12138.todo.databinding.ActivityCrashBinding -import cn.super12138.todo.utils.VersionUtils -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.views.BaseActivity -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Locale - -class CrashActivity : BaseActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val crashLogs = intent.getStringExtra("crash_logs") - - val deviceBrand = Build.BRAND - val deviceModel = Build.MODEL - val sdkLevel = Build.VERSION.SDK_INT - val currentDateTime = Calendar.getInstance().time - val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - val formattedDateTime = formatter.format(currentDateTime) - - val deviceInfo = StringBuilder().apply { - append("ToDo version: ").append(VersionUtils.getAppVersion(this@CrashActivity)) - .append('\n') - append("Brand: ").append("").append(deviceBrand).append('\n') - append("Model: ").append(deviceModel).append('\n') - append("Device SDK: ").append(sdkLevel).append('\n').append('\n') - append("Crash time: ").append(formattedDateTime).append('\n').append('\n') - append("======beginning of crash======").append('\n') - } - - binding.crashLog.text = StringBuilder().apply { - append(deviceInfo) - append(crashLogs) - } - - binding.exitApp.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - this.finishAffinity() - } - } - - override fun getViewBinding(): ActivityCrashBinding { - return ActivityCrashBinding.inflate(layoutInflater) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/activities/MainActivity.kt b/app/src/main/kotlin/cn/super12138/todo/views/activities/MainActivity.kt deleted file mode 100644 index e718af2..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/activities/MainActivity.kt +++ /dev/null @@ -1,66 +0,0 @@ -package cn.super12138.todo.views.activities -// 2023.11.18立项 -import android.app.ComponentCaller -import android.content.Intent -import android.os.Bundle -import android.view.WindowManager -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.fragment.app.Fragment -import androidx.fragment.app.commit -import cn.super12138.todo.R -import cn.super12138.todo.constant.GlobalValues -import cn.super12138.todo.databinding.ActivityMainBinding -import cn.super12138.todo.views.BaseActivity -import cn.super12138.todo.views.fragments.SettingsParentFragment -import cn.super12138.todo.views.fragments.welcome.WelcomeFragment -import de.raphaelebner.roomdatabasebackup.core.RoomBackup - -class MainActivity : BaseActivity() { - lateinit var roomBackup: RoomBackup - override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() - super.onCreate(savedInstanceState) - - if (!GlobalValues.welcomePage) { - startFragment(WelcomeFragment()) - } - - when (GlobalValues.secureMode) { - true -> window.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE - ) - - false -> window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - handleIntent(intent) - - roomBackup = RoomBackup(this) - } - - override fun onNewIntent(intent: Intent, caller: ComponentCaller) { - super.onNewIntent(intent, caller) - handleIntent(intent) - } - - override fun getViewBinding(): ActivityMainBinding { - return ActivityMainBinding.inflate(layoutInflater) - } - - fun startFragment(fragment: Fragment, args: (Bundle.() -> Unit)? = null) { - supportFragmentManager.commit { - addToBackStack(System.currentTimeMillis().toString()) - hide(supportFragmentManager.fragments.last()) - add( - R.id.app_container, - fragment.apply { args?.let { arguments = Bundle().apply(it) } } - ) - } - } - - private fun handleIntent(intent: Intent) { - when (intent.action) { - Intent.ACTION_APPLICATION_PREFERENCES -> startFragment(SettingsParentFragment()) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/adapters/AllTasksAdapter.kt b/app/src/main/kotlin/cn/super12138/todo/views/adapters/AllTasksAdapter.kt deleted file mode 100644 index 260d503..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/adapters/AllTasksAdapter.kt +++ /dev/null @@ -1,85 +0,0 @@ -package cn.super12138.todo.views.adapters - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.LinearLayout -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentManager -import androidx.recyclerview.widget.RecyclerView -import cn.super12138.todo.R -import cn.super12138.todo.ToDoApp -import cn.super12138.todo.constant.Constants.DEFAULT_VIEW_TYPE -import cn.super12138.todo.constant.Constants.EMPTY_VIEW_TYPE -import cn.super12138.todo.logic.model.ToDo -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.views.fragments.InfoBottomSheet - -class AllTasksAdapter( - private val todoList: MutableList, - private val fragmentManager: FragmentManager -) : RecyclerView.Adapter() { - // 默认待办项 - inner class DefaultViewHolder(view: View) : RecyclerView.ViewHolder(view) { - val todoContext: TextView = view.findViewById(R.id.todo_content) - val todoSubject: TextView = view.findViewById(R.id.todo_subject) - val itemBackground: LinearLayout = view.findViewById(R.id.item_background) - val checkBtn: Button = view.findViewById(R.id.check_item_btn) - } - - // 空项目提示 - inner class EmptyViewHolder(view: View) : RecyclerView.ViewHolder(view) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return if (viewType == EMPTY_VIEW_TYPE) { // 如果列表是空的 - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_empty, parent, false) - EmptyViewHolder(view) - } else { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_todo, parent, false) - DefaultViewHolder(view) - } - } - - override fun getItemViewType(position: Int): Int { - return if (todoList.isEmpty()) EMPTY_VIEW_TYPE else DEFAULT_VIEW_TYPE - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - // 判断当前的holder是不是待办项目的holder - if (holder is DefaultViewHolder) { - val todo = todoList[position] - holder.apply { - checkBtn.visibility = View.GONE - todoContext.text = todo.content - todoSubject.text = todo.subject - } - - if (todo.state == 1) { - holder.itemBackground.background = - ContextCompat.getDrawable(ToDoApp.context, R.drawable.bg_item_complete) - } else { - holder.itemBackground.background = null - } - - holder.itemView.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - val infoBottomSheet = InfoBottomSheet.newInstance( - todo.content, - todo.subject, - todo.state, - todo.uuid - ) - infoBottomSheet.show(fragmentManager, InfoBottomSheet.TAG) - } - } - } - - override fun getItemCount(): Int { - return if (todoList.isEmpty()) 1 else todoList.size - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/adapters/ToDoAdapter.kt b/app/src/main/kotlin/cn/super12138/todo/views/adapters/ToDoAdapter.kt deleted file mode 100644 index 99c7537..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/adapters/ToDoAdapter.kt +++ /dev/null @@ -1,108 +0,0 @@ -package cn.super12138.todo.views.adapters - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.TextView -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStoreOwner -import androidx.recyclerview.widget.RecyclerView -import cn.super12138.todo.R -import cn.super12138.todo.constant.Constants.DEFAULT_VIEW_TYPE -import cn.super12138.todo.constant.Constants.EMPTY_VIEW_TYPE -import cn.super12138.todo.constant.GlobalValues -import cn.super12138.todo.logic.model.ToDo -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.utils.showToast -import cn.super12138.todo.views.fragments.ToDoBottomSheet -import cn.super12138.todo.views.viewmodels.ProgressViewModel -import cn.super12138.todo.views.viewmodels.ToDoViewModel - -class ToDoAdapter( - private val todoList: MutableList, - private val viewModelStoreOwner: ViewModelStoreOwner, - private val fragmentManager: FragmentManager -) : RecyclerView.Adapter() { - // 默认待办项 - inner class DefaultViewHolder(view: View) : RecyclerView.ViewHolder(view) { - val todoContext: TextView = view.findViewById(R.id.todo_content) - val todoSubject: TextView = view.findViewById(R.id.todo_subject) - val checkToDoBtn: Button = view.findViewById(R.id.check_item_btn) - } - - // 空项目提示 - inner class EmptyViewHolder(view: View) : RecyclerView.ViewHolder(view) - - // 判断列表是否为空,为空显示空项目提示 - override fun getItemViewType(position: Int): Int { - return if (todoList.isEmpty()) EMPTY_VIEW_TYPE else DEFAULT_VIEW_TYPE - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return if (viewType == EMPTY_VIEW_TYPE) { // 如果列表是空的 - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_empty, parent, false) - EmptyViewHolder(view) - } else { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.item_todo, parent, false) - DefaultViewHolder(view) - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - // 判断当前的holder是不是待办项目的holder - if (holder is DefaultViewHolder) { - val todo = todoList[position] - holder.todoContext.text = todo.content - holder.todoSubject.text = todo.subject - - val progressViewModel = - ViewModelProvider(viewModelStoreOwner)[ProgressViewModel::class.java] - val todoViewModel = - ViewModelProvider(viewModelStoreOwner)[ToDoViewModel::class.java] - - holder.checkToDoBtn.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - if (position >= todoList.size) { - return@setOnClickListener - } - - todoList.removeAt(position) - notifyItemRemoved(position) - notifyItemRangeChanged(position, todoList.size) - - todoViewModel.updateTaskState(todo.uuid) - - progressViewModel.updateProgress() - } - - holder.itemView.setOnClickListener { - if (GlobalValues.devMode) { - VibrationUtils.performHapticFeedback(it) - "Current position: $position".showToast() - } - } - - holder.itemView.setOnLongClickListener { - val toDoBottomSheet = ToDoBottomSheet.newInstance( - true, - position, - todo.uuid, - todo.state, - todo.subject, - todo.content - ) - toDoBottomSheet.show(fragmentManager, ToDoBottomSheet.TAG) - true - } - } - } - - override fun getItemCount(): Int { - return if (todoList.isEmpty()) 1 else todoList.size - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/AboutFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/AboutFragment.kt deleted file mode 100644 index f5fb487..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/AboutFragment.kt +++ /dev/null @@ -1,85 +0,0 @@ -package cn.super12138.todo.views.fragments - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import cn.super12138.todo.constant.Constants -import cn.super12138.todo.databinding.FragmentAboutBinding -import cn.super12138.todo.utils.VersionUtils -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.utils.showToast -import cn.super12138.todo.views.BaseFragment - -class AboutFragment : BaseFragment() { - private var clickCount = 0 - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.appVersion.text = VersionUtils.getAppVersion(requireActivity()) - - binding.toolBar.setNavigationOnClickListener { - VibrationUtils.performHapticFeedback(it) - - requireActivity().supportFragmentManager.popBackStack() - } - - binding.checkUpdate.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - val intent = Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(Constants.UPDATE_URL) - } - startActivity(intent) - } - - binding.openSource.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - val intent = Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(Constants.REPO_GITHUB_URL) - } - startActivity(intent) - } - - - binding.developerInfo.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - val intent = Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(Constants.AUTHOR_GITHUB_URL) - } - startActivity(intent) - } - - binding.appVersion.setOnClickListener { - clickCount++ - when (clickCount) { - 5 -> { - clickCount = 0 - // GlobalValues.springFestivalTheme = !GlobalValues.springFestivalTheme - "🍂".showToast() - /*when (java.util.Calendar.getInstance(Locale.getDefault()) - .get(java.util.Calendar.MONTH) + 1) { - 3, 4, 5 -> - 6, 7, 8 -> SUMMER - 9, 10, 11 -> AUTUMN - 12, 1, 2 -> WINTER - else -> -12 - }*/ - } - } - } - } - - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentAboutBinding { - return FragmentAboutBinding.inflate(inflater, container, attachToRoot) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/AllTasksFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/AllTasksFragment.kt deleted file mode 100644 index 0e69f0a..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/AllTasksFragment.kt +++ /dev/null @@ -1,53 +0,0 @@ -package cn.super12138.todo.views.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer -import androidx.recyclerview.widget.LinearLayoutManager -import cn.super12138.todo.ToDoApp -import cn.super12138.todo.databinding.FragmentAllTasksBinding -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.views.BaseFragment -import cn.super12138.todo.views.adapters.AllTasksAdapter -import cn.super12138.todo.views.viewmodels.AllTasksViewModel -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -class AllTasksFragment : BaseFragment() { - private val viewModel by viewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val todoListAll = viewModel.todoListAll - val layoutManager = LinearLayoutManager(ToDoApp.context) - binding.allTasksList.layoutManager = layoutManager - val adapter = AllTasksAdapter(todoListAll, childFragmentManager) - binding.allTasksList.adapter = adapter - - FastScrollerBuilder(binding.allTasksList).apply { - useMd2Style() - build() - } - - binding.toolBar.setNavigationOnClickListener { - VibrationUtils.performHapticFeedback(it) - - requireActivity().supportFragmentManager.popBackStack() - } - - viewModel.refreshData.observe(viewLifecycleOwner, Observer { - binding.allTasksList.adapter?.notifyItemRangeChanged(0, todoListAll.size + 1) - }) - } - - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentAllTasksBinding { - return FragmentAllTasksBinding.inflate(inflater, container, attachToRoot) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/InfoBottomSheet.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/InfoBottomSheet.kt deleted file mode 100644 index a1f151a..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/InfoBottomSheet.kt +++ /dev/null @@ -1,81 +0,0 @@ -package cn.super12138.todo.views.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import cn.super12138.todo.R -import cn.super12138.todo.constant.Constants -import cn.super12138.todo.constant.GlobalValues -import cn.super12138.todo.databinding.BottomSheetInfoBinding -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment - -class InfoBottomSheet : BottomSheetDialogFragment() { - - private lateinit var binding: BottomSheetInfoBinding - private lateinit var todoContent: String - private lateinit var todoSubject: String - private var todoState: Int = 0 - private lateinit var todoUUID: String - - companion object { - const val TAG = Constants.TAG_INFO_BOTTOM_SHEET - fun newInstance( - todoContent: String, - todoSubject: String, - todoState: Int, - todoUUID: String - ) = InfoBottomSheet().apply { - arguments = Bundle().apply { - putString(Constants.BUNDLE_TODO_CONTENT, todoContent) - putString(Constants.BUNDLE_TODO_SUBJECT, todoSubject) - putInt(Constants.BUNDLE_TODO_STATE, todoState) - putString(Constants.BUNDLE_TODO_UUID, todoUUID) - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - todoContent = it.getString(Constants.BUNDLE_TODO_CONTENT, "") - todoSubject = it.getString(Constants.BUNDLE_TODO_SUBJECT, "") - todoState = it.getInt(Constants.BUNDLE_TODO_STATE, 0) - todoUUID = it.getString(Constants.BUNDLE_TODO_UUID, "") - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = BottomSheetInfoBinding.inflate(inflater, container, false) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val bottomSheetDialog = dialog as BottomSheetDialog - bottomSheetDialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED - bottomSheetDialog.dismissWithAnimation = true - - binding.todoContentInfo.text = todoContent - binding.todoSubjectInfo.text = String.format(getString(R.string.info_subject), todoSubject) - if (todoState == 0) { - binding.todoState.text = getString(R.string.info_state_incomplete) - } else { - binding.todoState.text = getString(R.string.info_state_complete) - } - if (GlobalValues.devMode) { - binding.todoUuid.apply { - visibility = View.VISIBLE - text = String.format(getString(R.string.info_uuid), todoUUID) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/MainFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/MainFragment.kt deleted file mode 100644 index f21b23b..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/MainFragment.kt +++ /dev/null @@ -1,41 +0,0 @@ -package cn.super12138.todo.views.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import cn.super12138.todo.R -import cn.super12138.todo.databinding.FragmentMainBinding -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.views.BaseFragment -import cn.super12138.todo.views.activities.MainActivity - -class MainFragment : BaseFragment() { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.toolBar.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.item_settings -> { - (requireActivity() as MainActivity).startFragment(SettingsParentFragment()) - - VibrationUtils.performHapticFeedback(binding.toolBar) - - true - } - - else -> false - } - } - - // setSupportActionBar(binding.toolbar) - } - - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentMainBinding { - return FragmentMainBinding.inflate(inflater, container, attachToRoot) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/ProgressFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/ProgressFragment.kt deleted file mode 100644 index ebeed5f..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/ProgressFragment.kt +++ /dev/null @@ -1,52 +0,0 @@ -package cn.super12138.todo.views.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer -import cn.super12138.todo.R -import cn.super12138.todo.databinding.FragmentProgressBinding -import cn.super12138.todo.views.BaseFragment -import cn.super12138.todo.views.viewmodels.ProgressViewModel - - -class ProgressFragment : BaseFragment() { - private val viewModel: ProgressViewModel by viewModels({ requireActivity() }) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - viewModel.progress.observe(viewLifecycleOwner, Observer { value -> - binding.progressBar.setProgressCompat(value, true) - }) - - viewModel.totalCount.observe(viewLifecycleOwner, Observer { total -> - binding.totalCount.text = total.toString() - }) - - viewModel.completeCount.observe(viewLifecycleOwner, Observer { complete -> - binding.completeCount.text = complete.toString() - }) - - viewModel.remainCount.observe(viewLifecycleOwner, Observer { remain -> - if (remain == 0) { - binding.remainCount.visibility = View.GONE - } else { - binding.remainCount.visibility = View.VISIBLE - binding.remainCount.text = - String.format(getString(R.string.remain_text), remain.toString()) - } - }) - - viewModel.updateProgress() - } - - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentProgressBinding { - return FragmentProgressBinding.inflate(inflater, container, attachToRoot) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsFragment.kt deleted file mode 100644 index ccbd0f3..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsFragment.kt +++ /dev/null @@ -1,195 +0,0 @@ -package cn.super12138.todo.views.fragments - -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable -import android.os.Bundle -import androidx.appcompat.app.AppCompatDelegate -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat -import cn.super12138.todo.R -import cn.super12138.todo.constant.Constants -import cn.super12138.todo.constant.GlobalValues -import cn.super12138.todo.logic.dao.ToDoRoomDB -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.views.activities.MainActivity -import cn.super12138.todo.views.fragments.welcome.WelcomeFragment -import com.google.android.material.snackbar.Snackbar -import de.raphaelebner.roomdatabasebackup.core.RoomBackup -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import kotlin.system.exitProcess - -class SettingsFragment : PreferenceFragmentCompat() { - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.preferences, rootKey) - val mainActivity = requireActivity() as MainActivity - val roomBackup = mainActivity.roomBackup - - findPreference(Constants.PREF_DARK_MODE)?.apply { - setOnPreferenceClickListener { - VibrationUtils.performHapticFeedback(view) - true - } - setOnPreferenceChangeListener { _, newValue -> - when (newValue) { - "0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - - "1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - - "2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - } - activity?.recreate() - true - } - } - - findPreference(Constants.PREF_SECURE_MODE)?.apply { - setOnPreferenceChangeListener { _, _ -> - VibrationUtils.performHapticFeedback(view) - - - view?.let { - Snackbar.make(it, R.string.need_restart_app, Snackbar.LENGTH_LONG) - .setAction(R.string.restart_app_now) { - VibrationUtils.performHapticFeedback(view) - restartApp(context) - } - .show() - } - - true - } - } - - findPreference(Constants.PREF_HAPTIC_FEEDBACK)?.apply { - setOnPreferenceChangeListener { _, _ -> - VibrationUtils.performHapticFeedback(view) - - true - } - } - - findPreference(Constants.PREF_BACKUP_DB)?.apply { - setOnPreferenceClickListener { - VibrationUtils.performHapticFeedback(view) - val simpleDateFormat = - SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault()) - val formattedDate = simpleDateFormat.format(Date()) - roomBackup - .database(ToDoRoomDB.getDatabase(requireContext())) - .enableLogDebug(GlobalValues.devMode) - .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) - .customBackupFileName("ToDo-DataBase-$formattedDate.sqlite3") - .apply { - onCompleteListener { success, _, exitCode -> - if (success) { - view?.let { it1 -> - Snackbar.make( - it1, R.string.tips_backup_success, Snackbar.LENGTH_LONG - ).show() - } - } else { - view?.let { it1 -> - Snackbar.make( - it1, getString( - R.string.tips_backup_failed, - exitCode - ), Snackbar.LENGTH_LONG - ).show() - } - } - } - } - .backup() - true - } - } - - findPreference(Constants.PREF_RESTORE_DB)?.apply { - setOnPreferenceClickListener { - VibrationUtils.performHapticFeedback(view) - - roomBackup - .database(ToDoRoomDB.getDatabase(requireContext())) - .enableLogDebug(GlobalValues.devMode) - .backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG) - .apply { - onCompleteListener { success, _, exitCode -> - if (success) { - view?.let { it1 -> - Snackbar.make( - it1, - R.string.tips_restore_success, - Snackbar.LENGTH_LONG - ) - .setAction(R.string.restart_app_now) { - VibrationUtils.performHapticFeedback(view) - restartApp(context) - } - .show() - } - - } else { - view?.let { it1 -> - Snackbar.make( - it1, getString( - R.string.tips_restore_failed, - exitCode - ), Snackbar.LENGTH_LONG - ).show() - } - } - } - } - .restore() - true - } - } - - findPreference(Constants.PREF_ALL_TASKS)?.apply { - setOnPreferenceClickListener { - mainActivity.startFragment(AllTasksFragment()) - VibrationUtils.performHapticFeedback(view) - true - } - } - - findPreference(Constants.PREF_REENTER_WELCOME_ACTIVITY)?.apply { - setOnPreferenceClickListener { - mainActivity.startFragment(WelcomeFragment()) - VibrationUtils.performHapticFeedback(view) - true - } - } - - findPreference(Constants.PREF_ABOUT)?.apply { - setOnPreferenceClickListener { - mainActivity.startFragment(AboutFragment()) - VibrationUtils.performHapticFeedback(view) - true - } - } - } - - override fun setDivider(divider: Drawable?) { - super.setDivider(ColorDrawable(Color.TRANSPARENT)) - } - - override fun setDividerHeight(height: Int) { - super.setDividerHeight(0) - } - - private fun restartApp(restartContext: Context) { - val intent = Intent(restartContext, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - restartContext.startActivity(intent) - exitProcess(0) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsParentFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsParentFragment.kt deleted file mode 100644 index 87b0c26..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/SettingsParentFragment.kt +++ /dev/null @@ -1,29 +0,0 @@ -package cn.super12138.todo.views.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import cn.super12138.todo.databinding.FragmentSettingsBinding -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.views.BaseFragment - -class SettingsParentFragment : BaseFragment() { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.toolBar.setNavigationOnClickListener { - VibrationUtils.performHapticFeedback(it) - - requireActivity().supportFragmentManager.popBackStack() - } - } - - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentSettingsBinding { - return FragmentSettingsBinding.inflate(inflater, container, attachToRoot) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/ToDoBottomSheet.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/ToDoBottomSheet.kt deleted file mode 100644 index 0138b2d..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/ToDoBottomSheet.kt +++ /dev/null @@ -1,192 +0,0 @@ -package cn.super12138.todo.views.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.viewModels -import cn.super12138.todo.R -import cn.super12138.todo.constant.Constants -import cn.super12138.todo.constant.GlobalValues -import cn.super12138.todo.databinding.BottomSheetTodoBinding -import cn.super12138.todo.logic.dao.ToDoRoom -import cn.super12138.todo.logic.model.ToDo -import cn.super12138.todo.utils.TextUtils -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.utils.showToast -import cn.super12138.todo.utils.toEditable -import cn.super12138.todo.views.viewmodels.ProgressViewModel -import cn.super12138.todo.views.viewmodels.ToDoViewModel -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import java.util.UUID - -class ToDoBottomSheet : BottomSheetDialogFragment() { - private lateinit var binding: BottomSheetTodoBinding - - private val progressViewModel: ProgressViewModel by viewModels({ requireActivity() }) - private val todoViewModel: ToDoViewModel by viewModels({ requireActivity() }) - - private var editMode: Boolean = false - private var todoState: Int = 0 - private var todoPosition: Int = 0 - private lateinit var todoUUID: String - private lateinit var todoOrigSubject: String - private lateinit var todoOrigContent: String - - companion object { - const val TAG = Constants.TAG_TODO_BOTTOM_SHEET - - fun newInstance( - editMode: Boolean, - todoPosition: Int, - todoUUID: String, - todoState: Int, - todoSubject: String, - todoContent: String, - ) = ToDoBottomSheet().apply { - arguments = Bundle().apply { - putBoolean(Constants.BUNDLE_EDIT_MODE, editMode) - putInt(Constants.BUNDLE_POSITION, todoPosition) - putString(Constants.BUNDLE_TODO_UUID, todoUUID) - putInt(Constants.BUNDLE_TODO_STATE, todoState) - putString(Constants.BUNDLE_TODO_SUBJECT, todoSubject) - putString(Constants.BUNDLE_TODO_CONTENT, todoContent) - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - editMode = it.getBoolean(Constants.BUNDLE_EDIT_MODE, false) - todoPosition = it.getInt(Constants.BUNDLE_POSITION, 0) - todoOrigContent = it.getString(Constants.BUNDLE_TODO_CONTENT, "") - todoOrigSubject = it.getString(Constants.BUNDLE_TODO_SUBJECT, "") - todoState = it.getInt(Constants.BUNDLE_TODO_STATE, 0) - todoUUID = it.getString(Constants.BUNDLE_TODO_UUID, "") - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = BottomSheetTodoBinding.inflate(inflater, container, false) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // BottomSheet 基本参数设置 - val bottomSheetDialog = dialog as BottomSheetDialog - bottomSheetDialog.behavior.apply { - state = BottomSheetBehavior.STATE_EXPANDED - saveFlags = BottomSheetBehavior.SAVE_ALL - } - bottomSheetDialog.dismissWithAnimation = true - - val todoList = todoViewModel.todoList - - binding.todoSubject.setOnCheckedStateChangeListener { _, _ -> - VibrationUtils.performHapticFeedback(binding.todoSubject) - } - - // 编辑模式 - if (editMode) { - binding.btnCancel.visibility = View.GONE - binding.btnDelete.visibility = View.VISIBLE - binding.todoSheetTitle.text = getString(R.string.update_task) - binding.todoContent.editText?.text = todoOrigContent.toEditable() - binding.btnSave.text = getString(R.string.update) - - TextUtils.getSubjectID(todoOrigSubject)?.let { binding.todoSubject.check(it) } - - if (GlobalValues.devMode) { - binding.todoUuid.apply { - text = "UUID: $todoUUID" - visibility = View.VISIBLE - } - } - } - - binding.btnSave.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - val todoContent = binding.todoContent.editText?.text.toString() - // 内容判空 - if (todoContent.isEmpty()) { - binding.todoContent.error = - getString(R.string.content_cannot_be_empty) - return@setOnClickListener - } else { - // 开发者模式 - if (todoContent == Constants.STRING_DEV_MODE) { - if (GlobalValues.devMode) { - GlobalValues.devMode = false - } else { - GlobalValues.devMode = true - "Dev Mode".showToast() - } - } else { - // 随机 UUID - val randomUUID = UUID.randomUUID().toString() - // 待办学科 - val todoSubject = TextUtils.getSubjectName(binding.todoSubject.checkedChipId) - - // 更新待办 - if (editMode) { - todoViewModel.updateTask( - todoPosition, - ToDoRoom( - todoUUID, - todoState, - todoSubject, - todoContent - ) - ) - todoViewModel.refreshData.value = 1 - } else { - // 添加到 RecyclerView - todoList.add( - ToDo(randomUUID, 0, todoContent, todoSubject) - ) - - // 插入数据库 - todoViewModel.insertTask( - ToDoRoom( - randomUUID, - 0, - todoSubject, - todoContent - ) - ) - progressViewModel.updateProgress() - todoViewModel.addData.value = 1 - } - } - } - dismiss() - } - - binding.btnCancel.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - dismiss() - } - - binding.btnDelete.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - todoViewModel.deleteTask(todoPosition, todoUUID) - progressViewModel.updateProgress() - todoViewModel.removeData.value = 1 - - dismiss() - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/ToDoFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/ToDoFragment.kt deleted file mode 100644 index 051252e..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/ToDoFragment.kt +++ /dev/null @@ -1,122 +0,0 @@ -package cn.super12138.todo.views.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updateLayoutParams -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import cn.super12138.todo.R -import cn.super12138.todo.ToDoApp -import cn.super12138.todo.databinding.FragmentTodoBinding -import cn.super12138.todo.logic.Repository -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.views.adapters.ToDoAdapter -import cn.super12138.todo.views.viewmodels.ProgressViewModel -import cn.super12138.todo.views.viewmodels.ToDoViewModel -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -class ToDoFragment : Fragment() { - private val progressViewModel: ProgressViewModel by viewModels({ requireActivity() }) - private val todoViewModel: ToDoViewModel by viewModels({ requireActivity() }) - private lateinit var binding: FragmentTodoBinding - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = FragmentTodoBinding.inflate(inflater, container, false) - - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - ViewCompat.setOnApplyWindowInsetsListener(binding.addItem) { v, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.updateLayoutParams { - leftMargin = if (insets.left == 0) 16 else insets.left - bottomMargin = if (insets.bottom == 0) 48 else insets.bottom + 32 - rightMargin = if (insets.right == 0) 48 else insets.right + 32 - } - WindowInsetsCompat.CONSUMED - } - - FastScrollerBuilder(binding.todoList).apply { - useMd2Style() - build() - } - - val layoutManager = LinearLayoutManager(ToDoApp.context) - binding.todoList.layoutManager = layoutManager - val todoList = todoViewModel.todoList - val adapter = ToDoAdapter(todoList, requireActivity(), parentFragmentManager) - binding.todoList.adapter = adapter - - binding.addItem.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - - val toDoBottomSheet = ToDoBottomSheet() - toDoBottomSheet.show(parentFragmentManager, ToDoBottomSheet.TAG) - } - - binding.addItem.setOnLongClickListener { - activity?.let { it1 -> - MaterialAlertDialogBuilder(it1) - .setTitle(R.string.warning) - .setMessage(R.string.delete_confirm) - .setPositiveButton(R.string.ok) { _, _ -> - VibrationUtils.performHapticFeedback(it) - - // 清除 Recycler View - todoList.clear() - // 清除数据库 - lifecycleScope.launch { - Repository.deleteAll() - progressViewModel.updateProgress() - } - // 通知数据更改 - binding.todoList.adapter?.notifyDataSetChanged() - } - .setNegativeButton(R.string.cancel, null) - .show() - } - true - } - - binding.todoList.addOnScrollListener(object : RecyclerView.OnScrollListener() { - val fab = binding.addItem - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - // 列表下滑,隐藏FAB - if (dy > 0 && fab.isShown) { - fab.hide() - } - // 列表上滑,显示FAB - if (dy < 0 && !fab.isShown) { - fab.show() - } - } - }) - - todoViewModel.addData.observe(viewLifecycleOwner) { - binding.todoList.adapter?.notifyItemInserted(todoList.size + 1) - } - - todoViewModel.removeData.observe(viewLifecycleOwner) { - binding.todoList.adapter?.notifyItemRemoved(todoList.size + 1) - } - - todoViewModel.refreshData.observe(viewLifecycleOwner) { - binding.todoList.adapter?.notifyItemRangeChanged(0, todoList.size + 1) - } - } -} diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/WelcomeFragment.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/WelcomeFragment.kt deleted file mode 100644 index 7fc68e8..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/WelcomeFragment.kt +++ /dev/null @@ -1,161 +0,0 @@ -package cn.super12138.todo.views.fragments.welcome - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.content.res.AppCompatResources -import androidx.fragment.app.Fragment -import androidx.fragment.app.commit -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import cn.super12138.todo.R -import cn.super12138.todo.constant.GlobalValues -import cn.super12138.todo.databinding.FragmentWelcomeBinding -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.utils.setOnIntervalClickListener -import cn.super12138.todo.views.BaseFragment -import cn.super12138.todo.views.activities.MainActivity -import cn.super12138.todo.views.fragments.MainFragment -import cn.super12138.todo.views.fragments.welcome.pages.IntroPage -import cn.super12138.todo.views.fragments.welcome.pages.ProgressPage -import cn.super12138.todo.views.fragments.welcome.pages.ToDoBtnPage -import cn.super12138.todo.views.fragments.welcome.pages.ToDoItemPage -import cn.super12138.todo.views.viewmodels.WelcomeViewModel -import kotlinx.coroutines.launch - -class WelcomeFragment : BaseFragment() { - private val viewModel by viewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val previousBtn = binding.previousBtn - val nextBtn = binding.nextBtn - val centerBtn = binding.centerBtn - - val currentPage = viewModel.currentPage // 0: Intro 1: Progress 2: ToDo Btn 3: ToDo Item - - // 返回回调 - val callback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (currentPage.value == 0) { - requireActivity().finishAffinity() - } else { - childFragmentManager.popBackStack() - viewModel.decreasePage() - } - } - } - - - centerBtn.setOnIntervalClickListener { - VibrationUtils.performHapticFeedback(it) - - centerBtn.hide() - nextBtn.show() - previousBtn.show() - - if (currentPage.value == 3) { - GlobalValues.welcomePage = true - (requireActivity() as MainActivity).startFragment(MainFragment()) - } else { - viewModel.setCurrentPage(1) - nextPage(1) - } - } - - previousBtn.setOnIntervalClickListener { - VibrationUtils.performHapticFeedback(it) - - childFragmentManager.popBackStack() - - viewModel.decreasePage() - } - - nextBtn.setOnIntervalClickListener { - VibrationUtils.performHapticFeedback(it) - - nextPage(currentPage.value + 1) - - callback.isEnabled = false - viewModel.increasePage() - } - - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.currentPage.collect { page -> - when (page) { - 0 -> { - centerBtn.apply { - text = getString(R.string.start) - icon = AppCompatResources.getDrawable( - requireContext(), - R.drawable.ic_arrow_forward - ) - show() - } - nextBtn.hide() - previousBtn.hide() - } - - in 1..2 -> { - centerBtn.hide() - previousBtn.show() - nextBtn.show() - } - - 3 -> { - centerBtn.apply { - text = getString(R.string.enter_app) - icon = AppCompatResources.getDrawable( - requireContext(), - R.drawable.ic_focus - ) - show() - } - previousBtn.show() - nextBtn.hide() - } - - else -> { - if (page > 3) viewModel.setCurrentPage(3) - - if (page < 0) viewModel.setCurrentPage(0) - } - } - } - } - } - requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback) - } - - private fun getCurrentPage(pageIndex: Int): Fragment { - return when (pageIndex) { - 0 -> IntroPage() - 1 -> ProgressPage() - 2 -> ToDoBtnPage() - 3 -> ToDoItemPage() - else -> IntroPage() - } - } - - private fun nextPage(page: Int) { - childFragmentManager.commit { - addToBackStack(System.currentTimeMillis().toString()) - hide(childFragmentManager.fragments.last()) - add(R.id.welcome_page_container, getCurrentPage(page)) - } - } - - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentWelcomeBinding { - return FragmentWelcomeBinding.inflate(inflater, container, attachToRoot) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/IntroPage.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/IntroPage.kt deleted file mode 100644 index 515a337..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/IntroPage.kt +++ /dev/null @@ -1,16 +0,0 @@ -package cn.super12138.todo.views.fragments.welcome.pages - -import android.view.LayoutInflater -import android.view.ViewGroup -import cn.super12138.todo.databinding.FragmentWelcomeIntroBinding -import cn.super12138.todo.views.BaseFragment - -class IntroPage : BaseFragment() { - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentWelcomeIntroBinding { - return FragmentWelcomeIntroBinding.inflate(inflater, container, attachToRoot) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/ProgressPage.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/ProgressPage.kt deleted file mode 100644 index 588ba0d..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/ProgressPage.kt +++ /dev/null @@ -1,16 +0,0 @@ -package cn.super12138.todo.views.fragments.welcome.pages - -import android.view.LayoutInflater -import android.view.ViewGroup -import cn.super12138.todo.databinding.FragmentWelcomeProgressBinding -import cn.super12138.todo.views.BaseFragment - -class ProgressPage : BaseFragment() { - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentWelcomeProgressBinding { - return FragmentWelcomeProgressBinding.inflate(inflater, container, attachToRoot) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/ToDoBtnPage.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/ToDoBtnPage.kt deleted file mode 100644 index d4ea77c..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/ToDoBtnPage.kt +++ /dev/null @@ -1,32 +0,0 @@ -package cn.super12138.todo.views.fragments.welcome.pages - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import cn.super12138.todo.databinding.FragmentWelcomeTodoBtnBinding -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.views.BaseFragment - -class ToDoBtnPage : BaseFragment() { - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentWelcomeTodoBtnBinding { - return FragmentWelcomeTodoBtnBinding.inflate(inflater, container, attachToRoot) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - - binding.addItem.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - } - - binding.addItem.setOnLongClickListener { - true - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/ToDoItemPage.kt b/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/ToDoItemPage.kt deleted file mode 100644 index baa168a..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/fragments/welcome/pages/ToDoItemPage.kt +++ /dev/null @@ -1,29 +0,0 @@ -package cn.super12138.todo.views.fragments.welcome.pages - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import cn.super12138.todo.databinding.FragmentWelcomeTodoItemBinding -import cn.super12138.todo.utils.VibrationUtils -import cn.super12138.todo.views.BaseFragment - -class ToDoItemPage : BaseFragment() { - override fun getViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - attachToRoot: Boolean - ): FragmentWelcomeTodoItemBinding { - return FragmentWelcomeTodoItemBinding.inflate(inflater, container, attachToRoot) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.checkItemBtn.setOnClickListener { - VibrationUtils.performHapticFeedback(it) - } - binding.todoItem.setOnLongClickListener { - true - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/AllTasksViewModel.kt b/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/AllTasksViewModel.kt deleted file mode 100644 index eeac4f6..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/AllTasksViewModel.kt +++ /dev/null @@ -1,29 +0,0 @@ -package cn.super12138.todo.views.viewmodels - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import cn.super12138.todo.logic.Repository -import cn.super12138.todo.logic.model.ToDo -import kotlinx.coroutines.launch - -class AllTasksViewModel : ViewModel() { - val todoListAll = ArrayList() - val refreshData = MutableLiveData(0) - - init { - loadToDos() - } - - private fun loadToDos() { - viewModelScope.launch { - val todos = Repository.getAll() - for (todo in todos) { - todoListAll.add(ToDo(todo.uuid, todo.state, todo.content, todo.subject)) - } - if (todoListAll.size > 0) { - refreshData.value = 1 - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/ProgressViewModel.kt b/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/ProgressViewModel.kt deleted file mode 100644 index ab45d6a..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/ProgressViewModel.kt +++ /dev/null @@ -1,29 +0,0 @@ -package cn.super12138.todo.views.viewmodels - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import cn.super12138.todo.logic.Repository -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -class ProgressViewModel : ViewModel() { - val totalCount: MutableLiveData = MutableLiveData() - val completeCount: MutableLiveData = MutableLiveData() - val remainCount: MutableLiveData = MutableLiveData() - val progress: MutableLiveData = MutableLiveData() - - fun updateProgress() { - viewModelScope.launch { - delay(30) - val total = Repository.getAll().size - val complete = Repository.getAllComplete().size - - val calcProgress = (complete.toDouble() / total.toDouble()) * 100 - progress.postValue(calcProgress.toInt()) - completeCount.postValue(complete) - totalCount.postValue(total) - remainCount.postValue(total - complete) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/ToDoViewModel.kt b/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/ToDoViewModel.kt deleted file mode 100644 index 6111382..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/ToDoViewModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -package cn.super12138.todo.views.viewmodels - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import cn.super12138.todo.logic.Repository -import cn.super12138.todo.logic.dao.ToDoRoom -import cn.super12138.todo.logic.model.ToDo -import kotlinx.coroutines.launch - -class ToDoViewModel : ViewModel() { - val addData = MutableLiveData(0) - val removeData = MutableLiveData(0) - val refreshData = MutableLiveData(0) - - val todoList = ArrayList() - - init { - loadTasks() - } - - private fun loadTasks() { - viewModelScope.launch { - val todos = Repository.getAllIncomplete() - for (todo in todos) { - todoList.add(ToDo(todo.uuid, todo.state, todo.content, todo.subject)) - } - if (todoList.size > 0) { - refreshData.value = 1 - } - } - } - - fun deleteTask(position: Int, uuid: String) { - todoList.removeAt(position) - viewModelScope.launch { - Repository.deleteByUUID(uuid) - } - } - - fun insertTask(todo: ToDoRoom) { - viewModelScope.launch { - Repository.insert(todo) - } - } - - fun updateTaskState(uuid: String) { - viewModelScope.launch { - Repository.updateStateByUUID(uuid) - } - } - - fun updateTask(position: Int, todo: ToDoRoom) { - todoList.removeAt(position) - todoList.add( - position, - ToDo( - todo.uuid, - todo.state, - todo.content, - todo.subject - ) - ) - viewModelScope.launch { - Repository.update(todo) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/WelcomeViewModel.kt b/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/WelcomeViewModel.kt deleted file mode 100644 index e8d1f7a..0000000 --- a/app/src/main/kotlin/cn/super12138/todo/views/viewmodels/WelcomeViewModel.kt +++ /dev/null @@ -1,22 +0,0 @@ -package cn.super12138.todo.views.viewmodels - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow - -class WelcomeViewModel : ViewModel() { - private val _currentPage = MutableStateFlow(0) - val currentPage = _currentPage.asStateFlow() - - fun setCurrentPage(page: Int) { - _currentPage.value = page - } - - fun increasePage() { - _currentPage.value += 1 - } - - fun decreasePage() { - _currentPage.value -= 1 - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionHelper.kt b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionHelper.kt deleted file mode 100644 index 3226c09..0000000 --- a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionHelper.kt +++ /dev/null @@ -1,129 +0,0 @@ -package de.raphaelebner.roomdatabasebackup.core - -import android.annotation.SuppressLint -import android.content.SharedPreferences -import java.io.* -import java.security.NoSuchAlgorithmException -import java.security.spec.InvalidKeySpecException -import java.security.spec.KeySpec -import javax.crypto.SecretKey -import javax.crypto.SecretKeyFactory -import javax.crypto.spec.PBEKeySpec -import javax.crypto.spec.SecretKeySpec - -/** - * MIT License - * - * Copyright (c) 2024 Raphael Ebner - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and - * associated documentation files (the "Software"), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, - * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or - * substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT - * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -class AESEncryptionHelper { - - companion object { - private const val BACKUP_SECRET_KEY = "backupsecretkey" - private const val TAG = "debug_AESEncryptionHelper" - } - - /** - * This method will convert a file to ByteArray - * @param file : the Path where the file is located - * @return ByteArray of the file - */ - @Throws(Exception::class) - fun readFile(file: File): ByteArray { - val fileContents = file.readBytes() - val inputBuffer = BufferedInputStream(FileInputStream(file)) - inputBuffer.read(fileContents) - inputBuffer.close() - return fileContents - } - - /** - * This method will convert a ByteArray to a file, and saves it to the path - * @param fileData : the ByteArray - * @param file : the path where the ByteArray should be saved - */ - @Throws(Exception::class) - fun saveFile(fileData: ByteArray, file: File) { - val bos = BufferedOutputStream(FileOutputStream(file, false)) - bos.write(fileData) - bos.flush() - bos.close() - } - - /** - * This method will convert a random password, saved in sharedPreferences to a SecretKey - * @param sharedPref : the sharedPref, to fetch / save the key - * @param iv : the encryption nonce - * @return SecretKey - */ - @SuppressLint("ApplySharedPref") - fun getSecretKey(sharedPref: SharedPreferences, iv: ByteArray): SecretKey { - - // get key: String from sharedpref - var password = sharedPref.getString(BACKUP_SECRET_KEY, null) - - // If no key is stored in shared pref, create one and save it - if (password == null) { - // generate random string - val stringLength = 15 - val charset = ('a'..'z') + ('A'..'Z') + ('1'..'9') - password = (1..stringLength).map { charset.random() }.joinToString("") - - val secretKey = generateSecretKey(password, iv) - // the key can be saved plain, because i am using EncryptedSharedPreferences - val editor = sharedPref.edit() - editor.putString(BACKUP_SECRET_KEY, password) - // I use .commit because when using .apply the needed app restart is faster then apply - // and the preferences wont be saved - editor.commit() - - return secretKey - } - - // generate secretKey, and return it - return generateSecretKey(password, iv) - } - - /** - * This method will convert a custom password to a SecretKey - * @param encryptPassword : the custom user password as String - * @param iv : the encryption nonce - * @return SecretKey - */ - fun getSecretKeyWithCustomPw(encryptPassword: String, iv: ByteArray): SecretKey { - // generate secretKey, and return it - return generateSecretKey(encryptPassword, iv) - } - - /** - * Function to generate a 128 bit key from the given password and iv - * @param password - * @param iv - * @return Secret key - * @throws NoSuchAlgorithmException - * @throws InvalidKeySpecException - */ - @Throws(NoSuchAlgorithmException::class, InvalidKeySpecException::class) - private fun generateSecretKey(password: String, iv: ByteArray?): SecretKey { - // convert random string to secretKey - val spec: KeySpec = PBEKeySpec(password.toCharArray(), iv, 65536, 128) // AES-128 - val secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") - val key = secretKeyFactory.generateSecret(spec).encoded - return SecretKeySpec(key, "AES") - } -} diff --git a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionManager.kt b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionManager.kt deleted file mode 100644 index 7611362..0000000 --- a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/AESEncryptionManager.kt +++ /dev/null @@ -1,114 +0,0 @@ -package de.raphaelebner.roomdatabasebackup.core - -import android.content.SharedPreferences -import java.nio.ByteBuffer -import java.security.InvalidAlgorithmParameterException -import java.security.InvalidKeyException -import java.security.NoSuchAlgorithmException -import java.security.SecureRandom -import java.security.spec.InvalidKeySpecException -import javax.crypto.BadPaddingException -import javax.crypto.Cipher -import javax.crypto.IllegalBlockSizeException -import javax.crypto.NoSuchPaddingException -import javax.crypto.spec.GCMParameterSpec - -/** - * Encryption / Decryption service using the AES algorithm - * example for nullbeans.com - * https://nullbeans.com/how-to-encrypt-decrypt-files-byte-arrays-in-java-using-aes-gcm/#Generating_an_AES_key - */ -class AESEncryptionManager { - - private val aesEncryptionHelper = AESEncryptionHelper() - - /** - * This method will encrypt the given data - * @param sharedPref : the sharedPref, to fetch the key - * @param data : the data that will be encrypted - * @return Encrypted data in a byte array - */ - @Throws( - NoSuchPaddingException::class, - NoSuchAlgorithmException::class, - InvalidAlgorithmParameterException::class, - InvalidKeyException::class, - BadPaddingException::class, - IllegalBlockSizeException::class, - InvalidKeySpecException::class - ) - fun encryptData(sharedPref: SharedPreferences, encryptPassword: String?, data: ByteArray): ByteArray { - - //Prepare the nonce - val secureRandom = SecureRandom() - - //Noonce should be 12 bytes - val iv = ByteArray(12) - secureRandom.nextBytes(iv) - - //Prepare your key/password - val secretKey = if (encryptPassword != null) { aesEncryptionHelper.getSecretKeyWithCustomPw(encryptPassword, iv) - } else aesEncryptionHelper.getSecretKey(sharedPref, iv) - - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val parameterSpec = GCMParameterSpec(128, iv) - - //Encryption mode on! - cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec) - - //Encrypt the data - val encryptedData = cipher.doFinal(data) - - //Concatenate everything and return the final data - val byteBuffer = ByteBuffer.allocate(4 + iv.size + encryptedData.size) - byteBuffer.putInt(iv.size) - byteBuffer.put(iv) - byteBuffer.put(encryptedData) - return byteBuffer.array() - } - - /** - * This method will decrypt the given data - * @param sharedPref : the sharedPref, to fetch the key - * @param encryptedData : the data that will be decrypted - * @return decrypted data in a byte array - */ - @Throws( - NoSuchPaddingException::class, - NoSuchAlgorithmException::class, - InvalidAlgorithmParameterException::class, - InvalidKeyException::class, - BadPaddingException::class, - IllegalBlockSizeException::class, - InvalidKeySpecException::class - ) - fun decryptData(sharedPref: SharedPreferences, encryptPassword: String?, encryptedData: ByteArray): ByteArray { - - - //Wrap the data into a byte buffer to ease the reading process - val byteBuffer = ByteBuffer.wrap(encryptedData) - val noonceSize = byteBuffer.int - - //Make sure that the file was encrypted properly - require(!(noonceSize < 12 || noonceSize >= 16)) { "Nonce size is incorrect. Make sure that the incoming data is an AES encrypted file." } - val iv = ByteArray(noonceSize) - byteBuffer[iv] - - //Prepare your key/password - val secretKey = if (encryptPassword != null) { aesEncryptionHelper.getSecretKeyWithCustomPw(encryptPassword, iv) - } else aesEncryptionHelper.getSecretKey(sharedPref, iv) - - //get the rest of encrypted data - val cipherBytes = ByteArray(byteBuffer.remaining()) - byteBuffer[cipherBytes] - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val parameterSpec = GCMParameterSpec(128, iv) - - //Encryption mode on! - cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec) - - //Encrypt the data - return cipher.doFinal(cipherBytes) - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/OnCompleteListener.kt b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/OnCompleteListener.kt deleted file mode 100644 index 43dd44a..0000000 --- a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/OnCompleteListener.kt +++ /dev/null @@ -1,78 +0,0 @@ -package de.raphaelebner.roomdatabasebackup.core - -import de.raphaelebner.roomdatabasebackup.core.RoomBackup.Companion.BACKUP_FILE_LOCATION_CUSTOM_FILE - -/** - * MIT License - * - * Copyright (c) 2024 Raphael Ebner - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and - * associated documentation files (the "Software"), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, - * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or - * substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT - * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -interface OnCompleteListener { - fun onComplete(success: Boolean, message: String, exitCode: Int) - - companion object { - - /** Other Error */ - const val EXIT_CODE_ERROR = 1 - - /** Error while choosing backup to restore. Maybe no file selected */ - const val EXIT_CODE_ERROR_BACKUP_FILE_CHOOSER = 2 - - /** Error while choosing backup file to create. Maybe no file selected */ - const val EXIT_CODE_ERROR_BACKUP_FILE_CREATOR = 3 - - /** - * [BACKUP_FILE_LOCATION_CUSTOM_FILE] is set but [RoomBackup.backupLocationCustomFile] is - * not set - */ - const val EXIT_CODE_ERROR_BACKUP_LOCATION_FILE_MISSING = 4 - - /** [RoomBackup.backupLocation] is not set */ - const val EXIT_CODE_ERROR_BACKUP_LOCATION_MISSING = 5 - - /** Restore dialog for internal/external storage was canceled by user */ - const val EXIT_CODE_ERROR_BY_USER_CANCELED = 6 - - /** Cannot decrypt provided backup file */ - const val EXIT_CODE_ERROR_DECRYPTION_ERROR = 7 - - /** Cannot encrypt database backup */ - const val EXIT_CODE_ERROR_ENCRYPTION_ERROR = 8 - - /** - * You tried to restore a encrypted backup but [RoomBackup.backupIsEncrypted] is set to - * false - */ - const val EXIT_CODE_ERROR_RESTORE_BACKUP_IS_ENCRYPTED = 9 - - /** No backups to restore are available in internal/external sotrage */ - const val EXIT_CODE_ERROR_RESTORE_NO_BACKUPS_AVAILABLE = 10 - - /** No room database to backup is provided */ - const val EXIT_CODE_ERROR_ROOM_DATABASE_MISSING = 11 - - /** Storage permissions not granted for custom dialog */ - const val EXIT_CODE_ERROR_STORAGE_PERMISSONS_NOT_GRANTED = 12 - - /** Cannot decrypt provided backup file because the password is incorrect */ - const val EXIT_CODE_ERROR_WRONG_DECRYPTION_PASSWORD = 13 - - /** No error, action successful */ - const val EXIT_CODE_SUCCESS = 0 - } -} diff --git a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/RoomBackup.kt b/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/RoomBackup.kt deleted file mode 100644 index a6b7018..0000000 --- a/app/src/main/kotlin/de/raphaelebner/roomdatabasebackup/core/RoomBackup.kt +++ /dev/null @@ -1,853 +0,0 @@ -package de.raphaelebner.roomdatabasebackup.core - -import android.app.Activity -import android.app.AlertDialog -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.os.Build -import android.util.Log -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.contract.ActivityResultContracts.CreateDocument -import androidx.room.RoomDatabase -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import kotlinx.coroutines.runBlocking -import java.io.BufferedOutputStream -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Locale -import javax.crypto.BadPaddingException - -/** - * MIT License - * - * Copyright (c) 2024 Raphael Ebner - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and - * associated documentation files (the "Software"), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, - * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or - * substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT - * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -class RoomBackup(var context: Context) { - - companion object { - private const val SHARED_PREFS = "de.raphaelebner.roomdatabasebackup" - private var TAG = "debug_RoomBackup" - private lateinit var INTERNAL_BACKUP_PATH: File - private lateinit var TEMP_BACKUP_PATH: File - private lateinit var TEMP_BACKUP_FILE: File - private lateinit var EXTERNAL_BACKUP_PATH: File - private lateinit var DATABASE_FILE: File - - private var currentProcess: Int? = null - private const val PROCESS_BACKUP = 1 - private const val PROCESS_RESTORE = 2 - private var backupFilename: String? = null - - /** Code for internal backup location, used for [backupLocation] */ - const val BACKUP_FILE_LOCATION_INTERNAL = 1 - - /** Code for external backup location, used for [backupLocation] */ - const val BACKUP_FILE_LOCATION_EXTERNAL = 2 - - /** Code for custom backup location dialog, used for [backupLocation] */ - const val BACKUP_FILE_LOCATION_CUSTOM_DIALOG = 3 - - /** Code for custom backup file location, used for [backupLocation] */ - const val BACKUP_FILE_LOCATION_CUSTOM_FILE = 4 - } - - private lateinit var sharedPreferences: SharedPreferences - private lateinit var dbName: String - - private var roomDatabase: RoomDatabase? = null - private var enableLogDebug: Boolean = false - private var restartIntent: Intent? = null - private var onCompleteListener: OnCompleteListener? = null - private var customRestoreDialogTitle: String = "Choose file to restore" - private var customBackupFileName: String? = null - private var backupIsEncrypted: Boolean = false - private var maxFileCount: Int? = null - private var encryptPassword: String? = null - private var backupLocation: Int = BACKUP_FILE_LOCATION_INTERNAL - private var backupLocationCustomFile: File? = null - - /** - * Set RoomDatabase instance - * - * @param roomDatabase RoomDatabase - */ - fun database(roomDatabase: RoomDatabase): RoomBackup { - this.roomDatabase = roomDatabase - return this - } - - /** - * Set LogDebug enabled / disabled - * - * @param enableLogDebug Boolean - */ - fun enableLogDebug(enableLogDebug: Boolean): RoomBackup { - this.enableLogDebug = enableLogDebug - return this - } - - /** - * Set Intent in which to boot after App restart - * - * @param restartIntent Intent - */ - fun restartApp(restartIntent: Intent): RoomBackup { - this.restartIntent = restartIntent - restartApp() - return this - } - - /** - * Set onCompleteListener, to run code when tasks completed - * - * @param onCompleteListener OnCompleteListener - */ - fun onCompleteListener(onCompleteListener: OnCompleteListener): RoomBackup { - this.onCompleteListener = onCompleteListener - return this - } - - /** - * Set onCompleteListener, to run code when tasks completed - * - * @param listener (success: Boolean, message: String) -> Unit - */ - fun onCompleteListener( - listener: (success: Boolean, message: String, exitCode: Int) -> Unit - ): RoomBackup { - this.onCompleteListener = - object : OnCompleteListener { - override fun onComplete(success: Boolean, message: String, exitCode: Int) { - listener(success, message, exitCode) - } - } - return this - } - - /** - * Set custom log tag, for detailed debugging - * - * @param customLogTag String - */ - fun customLogTag(customLogTag: String): RoomBackup { - TAG = customLogTag - return this - } - - /** - * Set custom Restore Dialog Title, default = "Choose file to restore" - * - * @param customRestoreDialogTitle String - */ - fun customRestoreDialogTitle(customRestoreDialogTitle: String): RoomBackup { - this.customRestoreDialogTitle = customRestoreDialogTitle - return this - } - - /** - * Set custom Backup File Name, default = "$dbName-$currentTime.sqlite3" - * - * @param customBackupFileName String - */ - fun customBackupFileName(customBackupFileName: String): RoomBackup { - this.customBackupFileName = customBackupFileName - return this - } - - /** - * Set you backup location. Available values see: [BACKUP_FILE_LOCATION_INTERNAL], - * [BACKUP_FILE_LOCATION_EXTERNAL], [BACKUP_FILE_LOCATION_CUSTOM_DIALOG] or - * [BACKUP_FILE_LOCATION_CUSTOM_FILE] - * - * @param backupLocation Int, default = [BACKUP_FILE_LOCATION_INTERNAL] - */ - fun backupLocation(backupLocation: Int): RoomBackup { - this.backupLocation = backupLocation - return this - } - - /** - * Set a custom file where to save or restore a backup. can be used for backup and restore - * - * Only available if [backupLocation] is set to [BACKUP_FILE_LOCATION_CUSTOM_FILE] - * - * @param backupLocationCustomFile File - */ - fun backupLocationCustomFile(backupLocationCustomFile: File): RoomBackup { - this.backupLocationCustomFile = backupLocationCustomFile - return this - } - - /** - * Set file encryption to true / false can be used for backup and restore - * - * @param backupIsEncrypted Boolean, default = false - */ - fun backupIsEncrypted(backupIsEncrypted: Boolean): RoomBackup { - this.backupIsEncrypted = backupIsEncrypted - return this - } - - /** - * Set max backup files count if fileCount is > maxFileCount the oldest backup file will be - * deleted is for both internal and external storage - * - * @param maxFileCount Int, default = null - */ - fun maxFileCount(maxFileCount: Int): RoomBackup { - this.maxFileCount = maxFileCount - return this - } - - /** - * Set custom backup encryption password - * - * @param encryptPassword String - */ - fun customEncryptPassword(encryptPassword: String): RoomBackup { - this.encryptPassword = encryptPassword - return this - } - - /** Init vars, and return true if no error occurred */ - private fun initRoomBackup(): Boolean { - if (roomDatabase == null) { - if (enableLogDebug) Log.d(TAG, "roomDatabase is missing") - onCompleteListener?.onComplete( - false, - "roomDatabase is missing", - OnCompleteListener.EXIT_CODE_ERROR_ROOM_DATABASE_MISSING - ) - // throw IllegalArgumentException("roomDatabase is not initialized") - return false - } - - // Create or retrieve the Master Key for encryption/decryption - val masterKeyAlias = - MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - - if (backupLocation !in - listOf( - BACKUP_FILE_LOCATION_INTERNAL, - BACKUP_FILE_LOCATION_EXTERNAL, - BACKUP_FILE_LOCATION_CUSTOM_DIALOG, - BACKUP_FILE_LOCATION_CUSTOM_FILE - ) - ) { - if (enableLogDebug) Log.d(TAG, "backupLocation is missing") - onCompleteListener?.onComplete( - false, - "backupLocation is missing", - OnCompleteListener.EXIT_CODE_ERROR_BACKUP_LOCATION_MISSING - ) - return false - } - - if (backupLocation == BACKUP_FILE_LOCATION_CUSTOM_FILE && backupLocationCustomFile == null - ) { - if (enableLogDebug) - Log.d( - TAG, - "backupLocation is set to custom backup file, but no file is defined" - ) - onCompleteListener?.onComplete( - false, - "backupLocation is set to custom backup file, but no file is defined", - OnCompleteListener.EXIT_CODE_ERROR_BACKUP_LOCATION_FILE_MISSING - ) - return false - } - - // Initialize/open an instance of EncryptedSharedPreferences - // Encryption key is stored in plain text in an EncryptedSharedPreferences --> it is saved - // encrypted - sharedPreferences = - EncryptedSharedPreferences.create( - context, - SHARED_PREFS, - masterKeyAlias, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - - dbName = roomDatabase!!.openHelper.databaseName!! - INTERNAL_BACKUP_PATH = File("${context.filesDir}/databasebackup/") - TEMP_BACKUP_PATH = File("${context.filesDir}/databasebackup-temp/") - TEMP_BACKUP_FILE = File("$TEMP_BACKUP_PATH/tempbackup.sqlite3") - EXTERNAL_BACKUP_PATH = File(context.getExternalFilesDir("backup")!!.toURI()) - DATABASE_FILE = File(context.getDatabasePath(dbName).toURI()) - - // Create internal and temp backup directory if does not exist - try { - INTERNAL_BACKUP_PATH.mkdirs() - TEMP_BACKUP_PATH.mkdirs() - } catch (_: FileAlreadyExistsException) { - } catch (_: IOException) { - } - - if (enableLogDebug) { - Log.d(TAG, "DatabaseName: $dbName") - Log.d(TAG, "Database Location: $DATABASE_FILE") - Log.d(TAG, "INTERNAL_BACKUP_PATH: $INTERNAL_BACKUP_PATH") - Log.d(TAG, "EXTERNAL_BACKUP_PATH: $EXTERNAL_BACKUP_PATH") - if (backupLocationCustomFile != null) - Log.d(TAG, "backupLocationCustomFile: $backupLocationCustomFile") - } - return true - } - - /** restart App with custom Intent */ - private fun restartApp() { - restartIntent!!.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(restartIntent) - if (context is Activity) { - (context as Activity).finish() - } - Runtime.getRuntime().exit(0) - } - - /** - * Start Backup process, and set onComplete Listener to success, if no error occurred, else - * onComplete Listener success is false and error message is passed - * - * if custom storage ist selected, the [openBackupfileCreator] will be launched - */ - fun backup() { - if (enableLogDebug) Log.d(TAG, "Starting Backup ...") - val success = initRoomBackup() - if (!success) return - - // Needed for storage permissions request - currentProcess = PROCESS_BACKUP - - // Create name for backup file, if no custom name is set: Database name + currentTime + - // .sqlite3 - var filename = - if (customBackupFileName == null) "$dbName-${getTime()}.sqlite3" - else customBackupFileName as String - // Add .aes extension to filename, if file is encrypted - if (backupIsEncrypted) filename += ".aes" - if (enableLogDebug) Log.d(TAG, "backupFilename: $filename") - - when (backupLocation) { - BACKUP_FILE_LOCATION_INTERNAL -> { - val backupFile = File("$INTERNAL_BACKUP_PATH/$filename") - doBackup(backupFile) - } - - BACKUP_FILE_LOCATION_EXTERNAL -> { - val backupFile = File("$EXTERNAL_BACKUP_PATH/$filename") - doBackup(backupFile) - } - - BACKUP_FILE_LOCATION_CUSTOM_DIALOG -> { - backupFilename = filename - permissionRequestLauncher.launch(arrayOf()) - return - } - - BACKUP_FILE_LOCATION_CUSTOM_FILE -> { - doBackup(backupLocationCustomFile!!) - } - - else -> return - } - } - - /** - * This method will do the backup action - * - * @param destination File - */ - private fun doBackup(destination: File) { - // Close the database - roomDatabase!!.close() - roomDatabase = null - if (backupIsEncrypted) { - val encryptedBytes = encryptBackup() ?: return - val bos = BufferedOutputStream(FileOutputStream(destination, false)) - bos.write(encryptedBytes) - bos.flush() - bos.close() - } else { - // Copy current database to save location (/files dir) - DATABASE_FILE.copyTo(destination) - } - - // If maxFileCount is set and is reached, delete oldest file - if (maxFileCount != null) { - val deleted = deleteOldBackup() - if (!deleted) return - } - - if (enableLogDebug) - Log.d(TAG, "Backup done, encrypted($backupIsEncrypted) and saved to $destination") - onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS) - } - - /** - * This method will do the backup action - * - * @param destination OutputStream - */ - private fun doBackup(destination: OutputStream) { - // Close the database - roomDatabase!!.close() - roomDatabase = null - if (backupIsEncrypted) { - val encryptedBytes = encryptBackup() ?: return - destination.write(encryptedBytes) - } else { - // Copy current database to save location (/files dir) - DATABASE_FILE.inputStream().use { input -> - destination.use { output -> - input.copyTo(output) - } - } - } - - // If maxFileCount is set and is reached, delete oldest file - if (maxFileCount != null) { - val deleted = deleteOldBackup() - if (!deleted) return - } - if (enableLogDebug) - Log.d(TAG, "Backup done, encrypted($backupIsEncrypted) and saved to $destination") - onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS) - } - - /** - * Encrypts the current Database and return it's content as ByteArray. The original Database is - * not encrypted only a current copy of this database - * - * @return encrypted backup as ByteArray - */ - private fun encryptBackup(): ByteArray? { - try { - // Copy database you want to backup to temp directory - DATABASE_FILE.copyTo(TEMP_BACKUP_FILE) - - // encrypt temp file, and save it to backup location - val encryptDecryptBackup = AESEncryptionHelper() - val fileData = encryptDecryptBackup.readFile(TEMP_BACKUP_FILE) - - val aesEncryptionManager = AESEncryptionManager() - val encryptedBytes = - aesEncryptionManager.encryptData(sharedPreferences, encryptPassword, fileData) - - // Delete temp file - TEMP_BACKUP_FILE.delete() - - return encryptedBytes - } catch (e: Exception) { - if (enableLogDebug) Log.d(TAG, "error during encryption: ${e.message}") - onCompleteListener?.onComplete( - false, - "error during encryption", - OnCompleteListener.EXIT_CODE_ERROR_ENCRYPTION_ERROR - ) - return null - } - } - - /** - * Start Restore process, and set onComplete Listener to success, if no error occurred, else - * onComplete Listener success is false and error message is passed - * - * if internal or external storage is selected, this function shows a list of all available - * backup files in a MaterialAlertDialog and calls [restoreSelectedInternalExternalFile] to - * restore selected file - * - * if custom storage ist selected, the [openBackupfileChooser] will be launched - */ - fun restore() { - if (enableLogDebug) Log.d(TAG, "Starting Restore ...") - val success = initRoomBackup() - if (!success) return - - // Needed for storage permissions request - currentProcess = PROCESS_RESTORE - - // Path of Backup Directory - val backupDirectory: File - - when (backupLocation) { - BACKUP_FILE_LOCATION_INTERNAL -> { - backupDirectory = INTERNAL_BACKUP_PATH - } - - BACKUP_FILE_LOCATION_EXTERNAL -> { - backupDirectory = File("$EXTERNAL_BACKUP_PATH/") - } - - BACKUP_FILE_LOCATION_CUSTOM_DIALOG -> { - permissionRequestLauncher.launch(arrayOf()) - return - } - - BACKUP_FILE_LOCATION_CUSTOM_FILE -> { - Log.d( - TAG, - "backupLocationCustomFile!!.exists()? : ${backupLocationCustomFile!!.exists()}" - ) - doRestore(backupLocationCustomFile!!) - return - } - - else -> return - } - - // All Files in an Array of type File - val arrayOfFiles = backupDirectory.listFiles() - - // If array is null or empty show "error" and return - if (arrayOfFiles.isNullOrEmpty()) { - if (enableLogDebug) Log.d(TAG, "No backups available to restore") - onCompleteListener?.onComplete( - false, - "No backups available", - OnCompleteListener.EXIT_CODE_ERROR_RESTORE_NO_BACKUPS_AVAILABLE - ) - Toast.makeText(context, "No backups available to restore", Toast.LENGTH_SHORT).show() - return - } - - // Sort Array: lastModified - arrayOfFiles.sortBy { it.lastModified() } - - // New empty MutableList of String - val mutableListOfFilesAsString = mutableListOf() - - // Add each filename to mutablelistOfFilesAsString - runBlocking { - for (i in arrayOfFiles.indices) { - mutableListOfFilesAsString.add(arrayOfFiles[i].name) - } - } - - // Convert MutableList to Array - val filesStringArray = mutableListOfFilesAsString.toTypedArray() - - // Show MaterialAlertDialog, with all available files, and on click Listener - - AlertDialog.Builder(context) - .setTitle(customRestoreDialogTitle) - .setItems(filesStringArray) { _, which -> - restoreSelectedInternalExternalFile(filesStringArray[which]) - } - .setOnCancelListener { - if (enableLogDebug) Log.d(TAG, "Restore dialog canceled") - onCompleteListener?.onComplete( - false, - "Restore dialog canceled", - OnCompleteListener.EXIT_CODE_ERROR_BY_USER_CANCELED - ) - } - .show() - } - - /** - * This method will do the restore action - * - * @param source File - */ - private fun doRestore(source: File) { - // Close the database - roomDatabase!!.close() - roomDatabase = null - val fileExtension = source.extension - if (backupIsEncrypted) { - source.copyTo(TEMP_BACKUP_FILE) - val decryptedBytes = decryptBackup() ?: return - val bos = BufferedOutputStream(FileOutputStream(DATABASE_FILE, false)) - bos.write(decryptedBytes) - bos.flush() - bos.close() - } else { - if (fileExtension == "aes") { - if (enableLogDebug) - Log.d( - TAG, - "Cannot restore database, it is encrypted. Maybe you forgot to add the property .fileIsEncrypted(true)" - ) - onCompleteListener?.onComplete( - false, - "cannot restore database, see Log for more details (if enabled)", - OnCompleteListener.EXIT_CODE_ERROR_RESTORE_BACKUP_IS_ENCRYPTED - ) - return - } - // Copy back database and replace current database - source.copyTo(DATABASE_FILE) - } - - if (enableLogDebug) - Log.d(TAG, "Restore done, decrypted($backupIsEncrypted) and restored from $source") - onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS) - } - - /** - * This method will do the restore action - * - * @param source InputStream - */ - private fun doRestore(source: InputStream) { - if (backupIsEncrypted) { - // Save inputstream to temp file - source.use { input -> - TEMP_BACKUP_FILE.outputStream().use { output -> input.copyTo(output) } - } - // Decrypt tempfile and write to database file - val decryptedBytes = decryptBackup() ?: return - - // Close the database if decryption is succesfull - roomDatabase!!.close() - roomDatabase = null - - val bos = BufferedOutputStream(FileOutputStream(DATABASE_FILE, false)) - bos.write(decryptedBytes) - bos.flush() - bos.close() - } else { - // Close the database - roomDatabase!!.close() - roomDatabase = null - - // Copy back database and replace current database - source.use { input -> - DATABASE_FILE.outputStream().use { output -> input.copyTo(output) } - } - } - - if (enableLogDebug) - Log.d(TAG, "Restore done, decrypted($backupIsEncrypted) and restored from $source") - onCompleteListener?.onComplete(true, "success", OnCompleteListener.EXIT_CODE_SUCCESS) - } - - /** - * Restores the selected file from internal or external storage - * - * @param filename String - */ - private fun restoreSelectedInternalExternalFile(filename: String) { - if (enableLogDebug) Log.d(TAG, "Restore selected file...") - - when (backupLocation) { - BACKUP_FILE_LOCATION_INTERNAL -> { - doRestore(File("$INTERNAL_BACKUP_PATH/$filename")) - } - - BACKUP_FILE_LOCATION_EXTERNAL -> { - doRestore(File("$EXTERNAL_BACKUP_PATH/$filename")) - } - - else -> return - } - } - - /** - * Decrypts the [TEMP_BACKUP_FILE] and return it's content as ByteArray. A valid encrypted - * backup file must be present on [TEMP_BACKUP_FILE] - * - * @return decrypted backup as ByteArray - */ - private fun decryptBackup(): ByteArray? { - try { - // Decrypt temp file, and save it to database location - val encryptDecryptBackup = AESEncryptionHelper() - val fileData = encryptDecryptBackup.readFile(TEMP_BACKUP_FILE) - - val aesEncryptionManager = AESEncryptionManager() - val decryptedBytes = - aesEncryptionManager.decryptData(sharedPreferences, encryptPassword, fileData) - - // Delete tem file - TEMP_BACKUP_FILE.delete() - - return decryptedBytes - } catch (e: BadPaddingException) { - if (enableLogDebug) Log.d(TAG, "error during decryption (wrong password): ${e.message}") - onCompleteListener?.onComplete( - false, - "error during decryption (wrong password) see Log for more details (if enabled)", - OnCompleteListener.EXIT_CODE_ERROR_WRONG_DECRYPTION_PASSWORD - ) - return null - } catch (e: Exception) { - if (enableLogDebug) Log.d(TAG, "error during decryption: ${e.message}") - onCompleteListener?.onComplete( - false, - "error during decryption see Log for more details (if enabled)", - OnCompleteListener.EXIT_CODE_ERROR_DECRYPTION_ERROR - ) - return null - } - } - - /** - * Opens the [ActivityResultContracts.RequestMultiplePermissions] and prompts the user to grant - * storage permissions - * - * If granted backup or restore process starts - */ - private val permissionRequestLauncher = - (context as ComponentActivity).registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - permissions.entries.forEach { - if (!it.value) { - onCompleteListener?.onComplete( - false, - "storage permissions are required, please allow!", - OnCompleteListener.EXIT_CODE_ERROR_STORAGE_PERMISSONS_NOT_GRANTED - ) - return@registerForActivityResult - } - } - when (currentProcess) { - PROCESS_BACKUP -> { - backupFilename?.let { openBackupfileCreator.launch(it) } - } - - PROCESS_RESTORE -> { - openBackupfileChooser.launch(arrayOf("application/octet-stream")) - } - } - } - - /** - * Opens the [ActivityResultContracts.OpenDocument] and prompts the user to open a document for - * restoring a backup file - */ - private val openBackupfileChooser = - (context as ComponentActivity).registerForActivityResult( - ActivityResultContracts.OpenDocument() - ) { result -> - if (result != null) { - val inputstream = context.contentResolver.openInputStream(result)!! - doRestore(inputstream) - return@registerForActivityResult - } - onCompleteListener?.onComplete( - false, - "failure", - OnCompleteListener.EXIT_CODE_ERROR_BACKUP_FILE_CHOOSER - ) - } - - /** - * Opens the [ActivityResultContracts.CreateDocument] and prompts the user to select a path for - * creating the new backup file - */ - private val openBackupfileCreator = - (context as ComponentActivity).registerForActivityResult( - CreateDocument("application/octet-stream") - ) { result -> - if (result != null) { - val out = context.contentResolver.openOutputStream(result)!! - doBackup(out) - return@registerForActivityResult - } - onCompleteListener?.onComplete( - false, - "failure", - OnCompleteListener.EXIT_CODE_ERROR_BACKUP_FILE_CREATOR - ) - } - - /** @return current time formatted as String */ - private fun getTime(): String { - - val currentTime = Calendar.getInstance().time - - val sdf = - if (Build.VERSION.SDK_INT <= 28) { - SimpleDateFormat("yyyy-MM-dd-HH_mm_ss", Locale.getDefault()) - } else { - SimpleDateFormat("yyyy-MM-dd-HH:mm:ss", Locale.getDefault()) - } - - return sdf.format(currentTime) - } - - /** - * If maxFileCount is set, and reached, all old files will be deleted. Only if - * [BACKUP_FILE_LOCATION_INTERNAL] or [BACKUP_FILE_LOCATION_EXTERNAL] - * - * @return true if old files deleted or nothing to do - */ - private fun deleteOldBackup(): Boolean { - // Path of Backup Directory - - val backupDirectory: File = - when (backupLocation) { - BACKUP_FILE_LOCATION_INTERNAL -> { - INTERNAL_BACKUP_PATH - } - - BACKUP_FILE_LOCATION_EXTERNAL -> { - File("$EXTERNAL_BACKUP_PATH/") - } - - BACKUP_FILE_LOCATION_CUSTOM_DIALOG -> { - // In custom backup location no backups will be removed - return true - } - - else -> return true - } - - // All Files in an Array of type File - val arrayOfFiles = backupDirectory.listFiles() - - // If array is null or empty nothing to do and return - if (arrayOfFiles.isNullOrEmpty()) { - if (enableLogDebug) Log.d(TAG, "") - onCompleteListener?.onComplete( - false, - "maxFileCount: Failed to get list of backups", - OnCompleteListener.EXIT_CODE_ERROR - ) - return false - } else if (arrayOfFiles.size > maxFileCount!!) { - // Sort Array: lastModified - arrayOfFiles.sortBy { it.lastModified() } - - // Get count of files to delete - val fileCountToDelete = arrayOfFiles.size - maxFileCount!! - - for (i in 1..fileCountToDelete) { - // Delete all old files (i-1 because array starts a 0) - arrayOfFiles[i - 1].delete() - - if (enableLogDebug) - Log.d(TAG, "maxFileCount reached: ${arrayOfFiles[i - 1]} deleted") - } - } - return true - } -} diff --git a/app/src/main/play_store_512.png b/app/src/main/play_store_512.png deleted file mode 100644 index a1a9f37..0000000 Binary files a/app/src/main/play_store_512.png and /dev/null differ diff --git a/app/src/main/res/drawable/bg_item_complete.xml b/app/src/main/res/drawable/bg_item_complete.xml deleted file mode 100644 index fc21ed9..0000000 --- a/app/src/main/res/drawable/bg_item_complete.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_about.xml b/app/src/main/res/drawable/ic_about.xml deleted file mode 100644 index 1e1faf7..0000000 --- a/app/src/main/res/drawable/ic_about.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml deleted file mode 100644 index a9503fd..0000000 --- a/app/src/main/res/drawable/ic_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_all_tasks.xml b/app/src/main/res/drawable/ic_all_tasks.xml deleted file mode 100644 index 8c3bd45..0000000 --- a/app/src/main/res/drawable/ic_all_tasks.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml deleted file mode 100644 index cd06f30..0000000 --- a/app/src/main/res/drawable/ic_arrow_back.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_arrow_forward.xml b/app/src/main/res/drawable/ic_arrow_forward.xml deleted file mode 100644 index 9e27d98..0000000 --- a/app/src/main/res/drawable/ic_arrow_forward.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_backup.xml b/app/src/main/res/drawable/ic_backup.xml deleted file mode 100644 index 59eaff5..0000000 --- a/app/src/main/res/drawable/ic_backup.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml deleted file mode 100644 index 5623ef0..0000000 --- a/app/src/main/res/drawable/ic_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_dark_mode.xml b/app/src/main/res/drawable/ic_dark_mode.xml deleted file mode 100644 index dedc9af..0000000 --- a/app/src/main/res/drawable/ic_dark_mode.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_exit.xml b/app/src/main/res/drawable/ic_exit.xml deleted file mode 100644 index 2660d94..0000000 --- a/app/src/main/res/drawable/ic_exit.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_focus.xml b/app/src/main/res/drawable/ic_focus.xml deleted file mode 100644 index 39fe24b..0000000 --- a/app/src/main/res/drawable/ic_focus.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml deleted file mode 100644 index f32ae57..0000000 --- a/app/src/main/res/drawable/ic_github.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml deleted file mode 100644 index 7b158f0..0000000 --- a/app/src/main/res/drawable/ic_launcher.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..a915451 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml deleted file mode 100644 index 0f71f1d..0000000 --- a/app/src/main/res/drawable/ic_next.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml deleted file mode 100644 index ea2ea4a..0000000 --- a/app/src/main/res/drawable/ic_person.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_previous.xml b/app/src/main/res/drawable/ic_previous.xml deleted file mode 100644 index 2e0b029..0000000 --- a/app/src/main/res/drawable/ic_previous.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_restore.xml b/app/src/main/res/drawable/ic_restore.xml deleted file mode 100644 index 1698029..0000000 --- a/app/src/main/res/drawable/ic_restore.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_screenshot.xml b/app/src/main/res/drawable/ic_screenshot.xml deleted file mode 100644 index f07d596..0000000 --- a/app/src/main/res/drawable/ic_screenshot.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml deleted file mode 100644 index 09641d1..0000000 --- a/app/src/main/res/drawable/ic_settings.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_update.xml b/app/src/main/res/drawable/ic_update.xml deleted file mode 100644 index 14c24e7..0000000 --- a/app/src/main/res/drawable/ic_update.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_vibration.xml b/app/src/main/res/drawable/ic_vibration.xml deleted file mode 100644 index 90f6ccf..0000000 --- a/app/src/main/res/drawable/ic_vibration.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/splash_screen.xml b/app/src/main/res/drawable/splash_screen.xml deleted file mode 100644 index c3c4443..0000000 --- a/app/src/main/res/drawable/splash_screen.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/layout-land/fragment_main.xml b/app/src/main/res/layout-land/fragment_main.xml deleted file mode 100644 index cf9c5d5..0000000 --- a/app/src/main/res/layout-land/fragment_main.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-small/fragment_main.xml b/app/src/main/res/layout-small/fragment_main.xml deleted file mode 100644 index e6b7a61..0000000 --- a/app/src/main/res/layout-small/fragment_main.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_crash.xml b/app/src/main/res/layout/activity_crash.xml deleted file mode 100644 index 43436f4..0000000 --- a/app/src/main/res/layout/activity_crash.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 546db6c..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/bottom_sheet_info.xml b/app/src/main/res/layout/bottom_sheet_info.xml deleted file mode 100644 index 044071e..0000000 --- a/app/src/main/res/layout/bottom_sheet_info.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/bottom_sheet_todo.xml b/app/src/main/res/layout/bottom_sheet_todo.xml deleted file mode 100644 index 4c89ffd..0000000 --- a/app/src/main/res/layout/bottom_sheet_todo.xml +++ /dev/null @@ -1,194 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -