diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..23732d0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+.kotlin
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..ca16a99
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..639c779
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..b2c751a
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 0000000..56d28c8
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,69 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.compose.compiler)
+}
+
+android {
+ namespace 'com.juhabi.birds'
+ compileSdk {
+ version = release(36) {
+ minorApiLevel = 1
+ }
+ }
+
+ defaultConfig {
+ applicationId "com.juhabi.birds"
+ minSdk 24
+ targetSdk 36
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+ buildFeatures {
+ viewBinding true
+ compose true
+ }
+}
+
+dependencies {
+ implementation platform(libs.androidx.compose.bom)
+ androidTestImplementation platform(libs.androidx.compose.bom)
+ implementation libs.androidx.compose.ui
+ implementation libs.androidx.compose.ui.graphics
+ implementation libs.androidx.compose.ui.tooling.preview
+ implementation libs.androidx.compose.material3
+ implementation libs.androidx.activity.compose
+ debugImplementation libs.androidx.compose.ui.tooling
+ implementation libs.androidx.appcompat
+ implementation libs.androidx.core.ktx
+ implementation libs.material
+ implementation libs.androidx.constraintlayout
+ implementation libs.androidx.viewpager2
+ implementation libs.retrofit.core
+ implementation libs.retrofit.converter.gson
+ implementation libs.okhttp
+ implementation libs.okhttp.logging
+ implementation libs.gson
+ implementation libs.luban
+ implementation libs.android.picker.view
+ implementation libs.androidx.recyclerview
+ implementation libs.base.recyclerview.adapter.helper
+ implementation libs.immersion.bar
+ testImplementation libs.junit
+ androidTestImplementation libs.androidx.espresso.core
+ androidTestImplementation libs.androidx.junit
+ //屏幕适配
+ implementation 'me.jessyan:autosize:1.1.2'
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/juhabi/birds/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/juhabi/birds/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..4f4727f
--- /dev/null
+++ b/app/src/androidTest/java/com/juhabi/birds/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package com.juhabi.birds
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.juhabi.birds", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3235e95
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/juhabi/birds/AppConfig.kt b/app/src/main/java/com/juhabi/birds/AppConfig.kt
new file mode 100644
index 0000000..9190d04
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/AppConfig.kt
@@ -0,0 +1,11 @@
+package com.juhabi.birds
+
+object AppConfig {
+ const val BASE_URL = "http://47.97.217.123:8081/"
+ const val APP_CLIENT_ID = "428a8310cd442757ae699df5d894f051"
+ const val APP_USER_TYPE = "app_user"
+ const val CONTENT_LANGUAGE = "zh_CN"
+
+ @Volatile
+ var userToken: String = ""
+}
diff --git a/app/src/main/java/com/juhabi/birds/MyApplication.kt b/app/src/main/java/com/juhabi/birds/MyApplication.kt
new file mode 100644
index 0000000..2d2625f
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/MyApplication.kt
@@ -0,0 +1,92 @@
+package com.juhabi.birds
+
+import android.app.Activity
+import android.app.ActivityManager
+import android.app.Application
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import com.juhabi.birds.util.manager.UserInfoManager
+import me.jessyan.autosize.AutoSize
+import me.jessyan.autosize.AutoSizeConfig
+
+class MyApplication : Application(), Application.ActivityLifecycleCallbacks {
+
+ override fun onCreate() {
+ super.onCreate()
+
+ // 初始化 AutoSize
+ AutoSize.initCompatMultiProcess(this) // 处理多进程:cite[1]
+
+ // 可选:进行高级配置
+ AutoSizeConfig.getInstance()
+ .setLog(false) // 设置是否打印日志,默认 true
+ .setBaseOnWidth(true) // 默认 true,表示以宽度为基准适配。false 则以高度为基准:cite[2]
+ .setUseDeviceSize(false) // 是否使用设备的实际尺寸做适配,默认为 false:cite[1]
+ .setExcludeFontScale(false) // 是否屏蔽系统字体大小对 AndroidAutoSize 的影响, 默认为 false:cite[1]
+
+ instance = this
+
+ if (!isMainProcess()) return
+
+ registerActivityLifecycleCallbacks(this)
+ initialize()
+ }
+
+ /** Add application-wide, privacy-safe SDK initialization here. */
+ private fun initialize() {
+ UserInfoManager.initialize(this)
+ // NetworkManager is created lazily by ApiClient, so it needs no eager initialization.
+ }
+
+ override fun onActivityResumed(activity: Activity) {
+ currentActivity = activity
+ }
+
+ override fun onActivityPaused(activity: Activity) {
+ if (currentActivity === activity) {
+ currentActivity = null
+ }
+ }
+
+ override fun onActivityDestroyed(activity: Activity) {
+ if (currentActivity === activity) {
+ currentActivity = null
+ }
+ }
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+ }
+
+ override fun onActivityStarted(activity: Activity) {
+ }
+
+ override fun onActivityStopped(activity: Activity) {
+ }
+
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
+ }
+
+ private fun isMainProcess(): Boolean {
+ val processName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ getProcessName()
+ } else {
+ val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager
+ activityManager.runningAppProcesses
+ ?.firstOrNull { it.pid == android.os.Process.myPid() }
+ ?.processName
+ }
+ return processName == applicationInfo.processName
+ }
+
+ companion object {
+ lateinit var instance: MyApplication
+ private set
+
+ val appContext: Context
+ get() = instance.applicationContext
+
+ var currentActivity: Activity? = null
+ private set
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/base/BaseActivity.kt b/app/src/main/java/com/juhabi/birds/base/BaseActivity.kt
new file mode 100644
index 0000000..a57ee98
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/base/BaseActivity.kt
@@ -0,0 +1,383 @@
+package com.juhabi.birds.base
+
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.WindowManager
+import android.widget.EditText
+import android.widget.ImageView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.result.ActivityResultLauncher
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.app.AlertDialog
+import androidx.core.content.ContextCompat
+import androidx.viewbinding.ViewBinding
+import com.juhabi.birds.R
+import com.juhabi.birds.dialog.LoadingDialog
+import com.gyf.immersionbar.ImmersionBar
+
+/**
+ * Activity 基类:统一管理 ViewBinding、Presenter 生命周期、弹窗和动态权限。
+ */
+abstract class BaseActivity> :
+ AppCompatActivity(), IView {
+
+ protected val mContext: BaseActivity
+ get() = this
+
+ private var _binding: VB? = null
+ protected val binding: VB
+ get() = requireNotNull(_binding) { "Binding is unavailable outside the Activity lifecycle" }
+
+ protected var presenter: P? = null
+ private set
+ private var loadingDialog: LoadingDialog? = null
+ private var permissionDialog: AlertDialog? = null
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { result ->
+ hidePermissionDialog()
+ val denied = result.filterValues { !it }.keys.toTypedArray()
+ if (denied.isEmpty()) {
+ onPermissionsGranted(result.keys.toTypedArray())
+ } else {
+ val permanentlyDenied = denied.all { !shouldShowRequestPermissionRationale(it) }
+ onPermissionsDenied(denied, permanentlyDenied)
+ }
+ }
+
+ /** 创建当前页面的 ViewBinding。 */
+ protected abstract fun createBinding(inflater: LayoutInflater): VB
+ /** 创建 Presenter;不需要 Presenter 时可以返回 null。 */
+ protected abstract fun createPresenter(): P?
+ /** ViewBinding 初始化完成后,在这里设置控件状态和监听事件。 */
+ protected abstract fun initView(savedInstanceState: Bundle?)
+ /** 初始化页面数据,子类可按需重写。 */
+ protected open fun initData(savedInstanceState: Bundle?) {
+ }
+
+ /** 创建页面并依次完成绑定、Presenter、视图和数据初始化。 */
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ _binding = createBinding(layoutInflater)
+ setContentView(binding.root)
+ initWindows()
+ presenter = createPresenter().also { it?.attachBaseView(this) }
+ initView(savedInstanceState)
+ initData(savedInstanceState)
+ }
+
+ /** 初始化系统标题栏、状态栏和导航栏,子页面可以按需重写。 */
+ protected open fun initWindows() {
+ supportActionBar?.hide()
+ ImmersionBar.with(this)
+ .fitsSystemWindows(true)
+ .statusBarColor(R.color.color_zhu)
+ .statusBarDarkFont(true)
+ .navigationBarColor(R.color.color_zhu)
+ .navigationBarDarkIcon(true)
+ .init()
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun P.attachBaseView(view: IView) {
+ (this as IPresenter).attachView(view)
+ }
+
+ /** 实现 IView,显示默认加载弹窗。 */
+ override fun showLoading() {
+ showLoadingDialog()
+ }
+
+ /** 实现 IView,隐藏默认加载弹窗。 */
+ override fun hideLoading() {
+ hideLoadingDialog()
+ }
+
+ /** 显示加载弹窗;按返回键取消时关闭当前 Activity。 */
+ fun showLoadingDialog(message: String = "加载中...") {
+ if (isFinishing || isDestroyed || loadingDialog?.isShowing == true) return
+ val dialog = LoadingDialog(this, message).apply {
+ setCanceledOnTouchOutside(false)
+ setCancelable(true)
+ setOnCancelListener { finish() }
+ }
+ try {
+ dialog.show()
+ loadingDialog = dialog
+ } catch (_: Exception) {
+ dialog.dismiss()
+ }
+ }
+
+ /** 关闭并释放加载弹窗。 */
+ fun hideLoadingDialog() {
+ loadingDialog?.dismiss()
+ loadingDialog = null
+ }
+
+ /** 使用 Toast 显示短消息。 */
+ override fun showMessage(message: String) {
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
+ }
+
+ /** 跳转到指定 Activity,可携带 Bundle 参数。 */
+ protected fun goTo(to: Class, data: Bundle? = null) {
+ startActivity(createActivityIntent(to, data))
+ }
+
+ /** 兼容 Water 项目命名,跳转到不继承当前 BaseActivity 的普通 Activity。 */
+ protected fun goToWihoutNoBase(to: Class, data: Bundle? = null) {
+ goTo(to, data)
+ }
+
+ /** 兼容 Water 项目命名,跳转到不使用 Presenter 的 Activity。 */
+ protected fun goToWihoutNoPresent(to: Class, data: Bundle? = null) {
+ goTo(to, data)
+ }
+
+ /** 跳转到目标页面并关闭当前 Activity。 */
+ protected fun goToAndFinish(to: Class, data: Bundle? = null) {
+ goTo(to, data)
+ finish()
+ }
+
+ /** 清空现有任务栈并以目标 Activity 作为新的根页面。 */
+ protected fun goToClearTask(to: Class, data: Bundle? = null) {
+ val intent = createActivityIntent(to, data).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ startActivity(intent)
+ }
+
+ /** 使用 Activity Result API 跳转,并由传入的 Launcher 接收返回结果。 */
+ protected fun goToForResult(
+ to: Class,
+ launcher: ActivityResultLauncher,
+ data: Bundle? = null
+ ) {
+ launcher.launch(createActivityIntent(to, data))
+ }
+
+ /** 创建统一的 Activity 跳转 Intent,并按需写入 Bundle 参数。 */
+ private fun createActivityIntent(to: Class, data: Bundle?): Intent {
+ return Intent(this, to).apply {
+ data?.let(::putExtras)
+ }
+ }
+
+ /** 检查单个权限是否已授权。 */
+ fun hasRecordPermission(permission: String): Boolean {
+ return hasRecordPermission(arrayOf(permission))
+ }
+
+ /** 检查一组权限是否已全部授权。 */
+ fun hasRecordPermission(permissions: Array): Boolean {
+ return permissions.all { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED }
+ }
+
+ /** 显示说明弹窗并申请单个权限。 */
+ fun getSinglePermissionRequst(permission: String, title: String, content: String, requestCode: Int) {
+ getMorePermissionRequst(arrayOf(permission), title, content, requestCode)
+ }
+
+ /** 显示说明弹窗并一次申请多个权限。 */
+ fun getMorePermissionRequst(permissions: Array, title: String, content: String, requestCode: Int) {
+ if (hasRecordPermission(permissions)) {
+ onPermissionsGranted(permissions)
+ return
+ }
+ showPermissionConfirmDialog(
+ title,
+ content,
+ onCancel = { onPermissionsDenied(permissions, false) },
+ onAllow = {
+ showPermissionDialog(title, content)
+ permissionLauncher.launch(permissions)
+ }
+ )
+ }
+
+ /** 使用默认说明文案申请一个或多个权限。 */
+ protected open fun requestPermission(vararg permissions: String) {
+ getMorePermissionRequst(permissions.toList().toTypedArray(), "需要权限", "请允许应用使用相关权限", 0)
+ }
+
+ /** 权限被拒绝后的兼容回调,子类可重写。 */
+ protected open fun noAllowPermission() {
+ }
+ /** 权限全部通过后的回调,默认继续执行下一步。 */
+ protected open fun onPermissionsGranted(permissions: Array) {
+ nextStep()
+ }
+ /** 权限被拒绝后的回调,永久拒绝时默认引导用户进入设置页。 */
+ protected open fun onPermissionsDenied(permissions: Array, permanentlyDenied: Boolean) {
+ noAllowPermission()
+ if (permanentlyDenied && !isFinishing) {
+ AlertDialog.Builder(this).setTitle("权限被拒绝")
+ .setMessage("请在应用设置中开启所需权限")
+ .setNegativeButton("取消", null)
+ .setPositiveButton("去设置") { _, _ -> openAppPermissionSettings() }
+ .show()
+ }
+ }
+ /** 权限通过后需要继续执行的业务步骤。 */
+ protected open fun nextStep() {
+ }
+
+ /** 普通确认弹窗和输入弹窗共用的点击回调。 */
+ interface OnDialogClick {
+ /** 点击取消按钮。 */
+ fun onCancelClick() {
+ }
+ /** 点击普通弹窗的确定按钮。 */
+ fun onConfirmClick() {
+ }
+ /** 点击输入弹窗的确定按钮,并返回输入内容。 */
+ fun onConfirmClick(content: String) {
+ }
+ }
+
+ /** 显示带取消和确定按钮的普通弹窗。 */
+ fun showDialogtext(
+ title: String,
+ content: String,
+ okText: String = "确定",
+ cancelText: String = "取消",
+ click: OnDialogClick? = null,
+ touchOutsideCancel: Boolean = true
+ ) {
+ val view = layoutInflater.inflate(R.layout.permission_confirm_dialog, null)
+ view.findViewById(R.id.permissionTitle).text = title
+ view.findViewById(R.id.permissionContent).text = content
+ val dialog = AlertDialog.Builder(this).setView(view).create()
+ view.findViewById(R.id.permissionCancel).apply {
+ text = cancelText
+ setOnClickListener {
+ dialog.dismiss()
+ click?.onCancelClick()
+ }
+ }
+ view.findViewById(R.id.permissionAllow).apply {
+ text = okText
+ setOnClickListener {
+ dialog.dismiss()
+ click?.onConfirmClick()
+ }
+ }
+ dialog.setCanceledOnTouchOutside(touchOutsideCancel)
+ dialog.setOnShowListener { dialog.window?.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) }
+ dialog.show()
+ }
+
+ /** 显示带输入框的弹窗,输入为空时保留弹窗并提示。 */
+ fun showEditDialog(
+ title: String,
+ hint: String,
+ content: String = "",
+ touchOutsideCancel: Boolean = true,
+ cancelText: String = "取消",
+ confirmText: String = "确定",
+ click: OnDialogClick? = null
+ ) {
+ val view = layoutInflater.inflate(R.layout.dialog_input_text, null)
+ view.findViewById(R.id.dialogTitle).text = title
+ val input = view.findViewById(R.id.dialogInput).apply {
+ this.hint = hint
+ setText(content)
+ requestFocus()
+ }
+ val dialog = AlertDialog.Builder(this).setView(view).create()
+ view.findViewById(R.id.dialogCancel).apply {
+ text = cancelText
+ setOnClickListener {
+ dialog.dismiss()
+ click?.onCancelClick()
+ }
+ }
+ view.findViewById(R.id.dialogConfirm).apply {
+ text = confirmText
+ setOnClickListener {
+ val value = input.text.toString().trim()
+ if (value.isEmpty()) {
+ input.error = hint
+ return@setOnClickListener
+ }
+ dialog.dismiss()
+ click?.onConfirmClick(value)
+ }
+ }
+ dialog.setCanceledOnTouchOutside(touchOutsideCancel)
+ dialog.setOnShowListener { dialog.window?.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) }
+ dialog.show()
+ }
+
+ /** 在屏幕顶部显示权限用途提示弹窗。 */
+ fun showPermissionDialog(title: String, content: String) {
+ permissionDialog?.dismiss()
+ val view = layoutInflater.inflate(R.layout.permission_top_dialog, null)
+ view.findViewById(R.id.permissionTopTitle).text = title
+ view.findViewById(R.id.permissionTopContent).text = content
+ permissionDialog = AlertDialog.Builder(this).setView(view).create().also { dialog ->
+ dialog.setCanceledOnTouchOutside(false)
+ dialog.show()
+ dialog.window?.apply {
+ setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
+ setGravity(Gravity.TOP)
+ setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT)
+ }
+ }
+ }
+
+ /** 在顶部显示可配置图标的权限用途提示弹窗。 */
+ fun showPermissionDialog(icon: Drawable?, showIcon: Boolean, title: String, content: String) {
+ showPermissionDialog(title, content)
+ permissionDialog?.findViewById(R.id.permissionIcon)?.apply {
+ visibility = if (showIcon) android.view.View.VISIBLE else android.view.View.GONE
+ if (icon != null) setImageDrawable(icon)
+ }
+ }
+
+ /** 在系统权限申请框弹出前,显示自定义权限说明弹窗。 */
+ private fun showPermissionConfirmDialog(title: String, content: String, onCancel: () -> Unit, onAllow: () -> Unit) {
+ val view = layoutInflater.inflate(R.layout.permission_confirm_dialog, null)
+ view.findViewById(R.id.permissionTitle).text = title
+ view.findViewById(R.id.permissionContent).text = content
+ val dialog = AlertDialog.Builder(this).setView(view).create()
+ view.findViewById(R.id.permissionCancel).setOnClickListener { dialog.dismiss(); onCancel() }
+ view.findViewById(R.id.permissionAllow).setOnClickListener { dialog.dismiss(); onAllow() }
+ dialog.setOnShowListener { dialog.window?.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) }
+ dialog.show()
+ }
+
+ /** 关闭并释放顶部权限提示弹窗。 */
+ fun hidePermissionDialog() {
+ permissionDialog?.dismiss()
+ permissionDialog = null
+ }
+
+ /** 打开当前应用的系统设置详情页。 */
+ protected fun openAppPermissionSettings() {
+ startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
+ }
+
+
+ /** 页面销毁时释放 Presenter、弹窗和 ViewBinding。 */
+ override fun onDestroy() {
+ presenter?.onDestroy()
+ presenter = null
+ hideLoading()
+ hidePermissionDialog()
+ _binding = null
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/base/BaseActivityNoPresenter.kt b/app/src/main/java/com/juhabi/birds/base/BaseActivityNoPresenter.kt
new file mode 100644
index 0000000..f82038d
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/base/BaseActivityNoPresenter.kt
@@ -0,0 +1,47 @@
+package com.juhabi.birds.base
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import androidx.appcompat.app.AppCompatActivity
+import androidx.viewbinding.ViewBinding
+import com.juhabi.birds.R
+import com.gyf.immersionbar.ImmersionBar
+
+abstract class BaseActivityNoPresenter : AppCompatActivity() {
+ protected val mContext: BaseActivityNoPresenter
+ get() = this
+
+ private var _binding: VB? = null
+ protected val binding: VB get() = requireNotNull(_binding)
+
+ protected abstract fun createBinding(inflater: LayoutInflater): VB
+ protected abstract fun initView(savedInstanceState: Bundle?)
+ protected open fun initData(savedInstanceState: Bundle?) {
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ _binding = createBinding(layoutInflater)
+ setContentView(binding.root)
+ initWindows()
+ initView(savedInstanceState)
+ initData(savedInstanceState)
+ }
+
+ /** 初始化系统标题栏、状态栏和导航栏,子页面可以按需重写。 */
+ protected open fun initWindows() {
+ supportActionBar?.hide()
+ ImmersionBar.with(this)
+ .fitsSystemWindows(true)
+ .statusBarColor(R.color.color_white)
+ .statusBarDarkFont(true)
+ .navigationBarColor(R.color.color_white)
+ .navigationBarDarkIcon(true)
+ .init()
+ }
+
+ override fun onDestroy() {
+ _binding = null
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/base/BaseFragment.kt b/app/src/main/java/com/juhabi/birds/base/BaseFragment.kt
new file mode 100644
index 0000000..9f96bf1
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/base/BaseFragment.kt
@@ -0,0 +1,379 @@
+package com.juhabi.birds.base
+
+import android.app.Activity
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.EditText
+import android.widget.ImageView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.result.ActivityResultLauncher
+import androidx.appcompat.app.AlertDialog
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewbinding.ViewBinding
+import com.juhabi.birds.R
+import com.juhabi.birds.dialog.LoadingDialog
+
+/**
+ * Fragment 基类:统一管理 ViewBinding、Presenter 生命周期、弹窗和动态权限。
+ */
+abstract class BaseFragment> : Fragment(), IView {
+ protected val mContext: FragmentActivity
+ get() = requireActivity()
+
+ private var _binding: VB? = null
+ protected val binding: VB
+ get() = requireNotNull(_binding) { "Binding is unavailable outside the Fragment view lifecycle" }
+
+ protected var presenter: P? = null
+ private set
+ private var loadingDialog: LoadingDialog? = null
+ private var permissionDialog: AlertDialog? = null
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { result ->
+ hidePermissionDialog()
+ val denied = result.filterValues { !it }.keys.toTypedArray()
+ if (denied.isEmpty()) {
+ onPermissionsGranted(result.keys.toTypedArray())
+ } else {
+ val permanentlyDenied = denied.all { !shouldShowRequestPermissionRationale(it) }
+ onPermissionsDenied(denied, permanentlyDenied)
+ }
+ }
+
+ /** 创建当前页面的 ViewBinding。 */
+ protected abstract fun createBinding(inflater: LayoutInflater, container: ViewGroup?): VB
+ /** 创建 Presenter;不需要 Presenter 时可以返回 null。 */
+ protected abstract fun createPresenter(): P?
+ /** Fragment View 创建完成后,在这里设置控件状态和监听事件。 */
+ protected abstract fun initView(savedInstanceState: Bundle?)
+ /** 初始化页面数据,子类可按需重写。 */
+ protected open fun initData(savedInstanceState: Bundle?) {
+ }
+
+ /** 创建 Fragment 视图并初始化 ViewBinding。 */
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View {
+ _binding = createBinding(inflater, container)
+ return binding.root
+ }
+
+ /** View 创建完成后依次初始化 Presenter、控件和页面数据。 */
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ initWindows()
+ presenter = createPresenter().also { it?.attachBaseView(this) }
+ initView(savedInstanceState)
+ initData(savedInstanceState)
+ }
+
+ /** Fragment 显示时配置宿主 Activity 的状态栏和导航栏,子页面可以按需重写。 */
+ protected open fun initWindows() {
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun P.attachBaseView(view: IView) {
+ (this as IPresenter).attachView(view)
+ }
+
+ /** 实现 IView,显示默认加载弹窗。 */
+ override fun showLoading() {
+ showLoadingDialog()
+ }
+
+ /** 实现 IView,隐藏默认加载弹窗。 */
+ override fun hideLoading() {
+ hideLoadingDialog()
+ }
+
+ /** 在 Fragment 已附加时显示加载弹窗。 */
+ fun showLoadingDialog(message: String = "加载中...") {
+ val currentContext = context ?: return
+ if (!isAdded || loadingDialog?.isShowing == true) return
+ val dialog = LoadingDialog(currentContext, message).apply {
+ setCanceledOnTouchOutside(false)
+ setCancelable(true)
+ }
+ try {
+ dialog.show()
+ loadingDialog = dialog
+ } catch (_: Exception) {
+ dialog.dismiss()
+ }
+ }
+
+ /** 关闭并释放加载弹窗。 */
+ fun hideLoadingDialog() {
+ loadingDialog?.dismiss()
+ loadingDialog = null
+ }
+
+ /** 使用 Toast 显示短消息。 */
+ override fun showMessage(message: String) {
+ context?.let { Toast.makeText(it, message, Toast.LENGTH_SHORT).show() }
+ }
+
+ /** 使用普通消息样式显示错误信息。 */
+ override fun showError(message: String) {
+ showMessage(message)
+ }
+
+ /** 从当前 Fragment 跳转到指定 Activity,可携带 Bundle 参数。 */
+ protected fun goTo(to: Class, data: Bundle? = null) {
+ startActivity(createActivityIntent(to, data))
+ }
+
+ /** 兼容 Water 项目命名,跳转到不继承当前 BaseActivity 的普通 Activity。 */
+ protected fun goToWihoutNoBase(to: Class, data: Bundle? = null) {
+ goTo(to, data)
+ }
+
+ /** 兼容 Water 项目命名,跳转到不使用 Presenter 的 Activity。 */
+ protected fun goToWihoutNoPresent(to: Class, data: Bundle? = null) {
+ goTo(to, data)
+ }
+
+ /** 跳转到目标页面并关闭 Fragment 所在的 Activity。 */
+ protected fun goToAndFinish(to: Class, data: Bundle? = null) {
+ goTo(to, data)
+ requireActivity().finish()
+ }
+
+ /** 清空现有任务栈并以目标 Activity 作为新的根页面。 */
+ protected fun goToClearTask(to: Class, data: Bundle? = null) {
+ val intent = createActivityIntent(to, data).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ startActivity(intent)
+ }
+
+ /** 使用 Activity Result API 跳转,并由传入的 Launcher 接收返回结果。 */
+ protected fun goToForResult(
+ to: Class,
+ launcher: ActivityResultLauncher,
+ data: Bundle? = null
+ ) {
+ launcher.launch(createActivityIntent(to, data))
+ }
+
+ /** 创建统一的 Activity 跳转 Intent,并按需写入 Bundle 参数。 */
+ private fun createActivityIntent(to: Class, data: Bundle?): Intent {
+ return Intent(requireContext(), to).apply {
+ data?.let(::putExtras)
+ }
+ }
+
+ /** 检查单个权限是否已授权。 */
+ fun hasRecordPermission(permission: String): Boolean {
+ return hasRecordPermission(arrayOf(permission))
+ }
+ /** 检查一组权限是否已全部授权。 */
+ fun hasRecordPermission(permissions: Array): Boolean {
+ return permissions.all {
+ ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED
+ }
+ }
+
+ /** 显示说明弹窗并申请单个权限。 */
+ fun getSinglePermissionRequst(permission: String, title: String, content: String, requestCode: Int) {
+ getMorePermissionRequst(arrayOf(permission), title, content, requestCode)
+ }
+
+ /** 显示说明弹窗并一次申请多个权限。 */
+ fun getMorePermissionRequst(permissions: Array, title: String, content: String, requestCode: Int) {
+ if (hasRecordPermission(permissions)) {
+ onPermissionsGranted(permissions)
+ return
+ }
+ showPermissionConfirmDialog(
+ title,
+ content,
+ onCancel = { onPermissionsDenied(permissions, false) },
+ onAllow = {
+ showPermissionDialog(title, content)
+ permissionLauncher.launch(permissions)
+ }
+ )
+ }
+
+ /** 使用默认说明文案申请一个或多个权限。 */
+ protected open fun requestPermission(vararg permissions: String) {
+ getMorePermissionRequst(permissions.toList().toTypedArray(), "需要权限", "请允许应用使用相关权限", 0)
+ }
+
+ /** 权限被拒绝后的兼容回调,子类可重写。 */
+ protected open fun noAllowPermission() {
+ }
+ /** 权限全部通过后的回调,默认继续执行下一步。 */
+ protected open fun onPermissionsGranted(permissions: Array) {
+ nextStep()
+ }
+ /** 权限被拒绝后的回调,永久拒绝时默认引导用户进入设置页。 */
+ protected open fun onPermissionsDenied(permissions: Array, permanentlyDenied: Boolean) {
+ noAllowPermission()
+ if (permanentlyDenied && isAdded) {
+ AlertDialog.Builder(requireContext()).setTitle("权限被拒绝")
+ .setMessage("请在应用设置中开启所需权限")
+ .setNegativeButton("取消", null)
+ .setPositiveButton("去设置") { _, _ -> openAppPermissionSettings() }
+ .show()
+ }
+ }
+ /** 权限通过后需要继续执行的业务步骤。 */
+ protected open fun nextStep() {
+ }
+
+ /** 普通确认弹窗和输入弹窗共用的点击回调。 */
+ interface OnDialogClick {
+ /** 点击取消按钮。 */
+ fun onCancelClick() {
+ }
+ /** 点击普通弹窗的确定按钮。 */
+ fun onConfirmClick() {
+ }
+ /** 点击输入弹窗的确定按钮,并返回输入内容。 */
+ fun onConfirmClick(content: String) {
+ }
+ }
+
+ /** 显示带取消和确定按钮的普通弹窗。 */
+ fun showDialogtext(
+ title: String,
+ content: String,
+ okText: String = "确定",
+ cancelText: String = "取消",
+ click: OnDialogClick? = null,
+ touchOutsideCancel: Boolean = true
+ ) {
+ val view = layoutInflater.inflate(R.layout.permission_confirm_dialog, null)
+ view.findViewById(R.id.permissionTitle).text = title
+ view.findViewById(R.id.permissionContent).text = content
+ val dialog = AlertDialog.Builder(requireContext()).setView(view).create()
+ view.findViewById(R.id.permissionCancel).apply {
+ text = cancelText
+ setOnClickListener {
+ dialog.dismiss()
+ click?.onCancelClick()
+ }
+ }
+ view.findViewById(R.id.permissionAllow).apply {
+ text = okText
+ setOnClickListener {
+ dialog.dismiss()
+ click?.onConfirmClick()
+ }
+ }
+ dialog.setCanceledOnTouchOutside(touchOutsideCancel)
+ dialog.setOnShowListener { dialog.window?.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) }
+ dialog.show()
+ }
+
+ /** 显示带输入框的弹窗,输入为空时保留弹窗并提示。 */
+ fun showEditDialog(
+ title: String,
+ hint: String,
+ content: String = "",
+ touchOutsideCancel: Boolean = true,
+ cancelText: String = "取消",
+ confirmText: String = "确定",
+ click: OnDialogClick? = null
+ ) {
+ val view = layoutInflater.inflate(R.layout.dialog_input_text, null)
+ view.findViewById(R.id.dialogTitle).text = title
+ val input = view.findViewById(R.id.dialogInput).apply {
+ this.hint = hint
+ setText(content)
+ requestFocus()
+ }
+ val dialog = AlertDialog.Builder(requireContext()).setView(view).create()
+ view.findViewById(R.id.dialogCancel).apply {
+ text = cancelText
+ setOnClickListener {
+ dialog.dismiss()
+ click?.onCancelClick()
+ }
+ }
+ view.findViewById(R.id.dialogConfirm).apply {
+ text = confirmText
+ setOnClickListener {
+ val value = input.text.toString().trim()
+ if (value.isEmpty()) {
+ input.error = hint
+ return@setOnClickListener
+ }
+ dialog.dismiss()
+ click?.onConfirmClick(value)
+ }
+ }
+ dialog.setCanceledOnTouchOutside(touchOutsideCancel)
+ dialog.setOnShowListener { dialog.window?.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) }
+ dialog.show()
+ }
+
+ /** 在屏幕顶部显示权限用途提示弹窗。 */
+ fun showPermissionDialog(title: String, content: String) {
+ permissionDialog?.dismiss()
+ val view = layoutInflater.inflate(R.layout.permission_top_dialog, null)
+ view.findViewById(R.id.permissionTopTitle).text = title
+ view.findViewById(R.id.permissionTopContent).text = content
+ permissionDialog = AlertDialog.Builder(requireContext()).setView(view).create().also {
+ it.setCanceledOnTouchOutside(false)
+ it.show()
+ it.window?.apply {
+ setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
+ setGravity(android.view.Gravity.TOP)
+ setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT)
+ }
+ }
+ }
+ /** 在顶部显示可配置图标的权限用途提示弹窗。 */
+ fun showPermissionDialog(icon: Drawable?, showIcon: Boolean, title: String, content: String) {
+ showPermissionDialog(title, content)
+ permissionDialog?.findViewById(R.id.permissionIcon)?.apply {
+ visibility = if (showIcon) View.VISIBLE else View.GONE
+ if (icon != null) setImageDrawable(icon)
+ }
+ }
+ /** 在系统权限申请框弹出前,显示自定义权限说明弹窗。 */
+ private fun showPermissionConfirmDialog(title: String, content: String, onCancel: () -> Unit, onAllow: () -> Unit) {
+ val view = layoutInflater.inflate(R.layout.permission_confirm_dialog, null)
+ view.findViewById(R.id.permissionTitle).text = title
+ view.findViewById(R.id.permissionContent).text = content
+ val dialog = AlertDialog.Builder(requireContext()).setView(view).create()
+ view.findViewById(R.id.permissionCancel).setOnClickListener { dialog.dismiss(); onCancel() }
+ view.findViewById(R.id.permissionAllow).setOnClickListener { dialog.dismiss(); onAllow() }
+ dialog.setOnShowListener { dialog.window?.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) }
+ dialog.show()
+ }
+ /** 关闭并释放顶部权限提示弹窗。 */
+ fun hidePermissionDialog() {
+ permissionDialog?.dismiss()
+ permissionDialog = null
+ }
+ /** 打开当前应用的系统设置详情页。 */
+ protected fun openAppPermissionSettings() {
+ startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${requireContext().packageName}")))
+ }
+
+ /** Fragment View 销毁时释放 Presenter、弹窗和 ViewBinding。 */
+ override fun onDestroyView() {
+ presenter?.onDestroy()
+ presenter = null
+ hideLoading()
+ hidePermissionDialog()
+ _binding = null
+ super.onDestroyView()
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/base/BaseFragmentNoPresenter.kt b/app/src/main/java/com/juhabi/birds/base/BaseFragmentNoPresenter.kt
new file mode 100644
index 0000000..8706926
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/base/BaseFragmentNoPresenter.kt
@@ -0,0 +1,19 @@
+package com.juhabi.birds.base
+
+import androidx.viewbinding.ViewBinding
+
+/**
+ * 不需要 Presenter 的 Fragment 基类。
+ * 子类仍可复用 BaseFragment 提供的 ViewBinding、加载弹窗、权限和页面跳转能力。
+ */
+abstract class BaseFragmentNoPresenter :
+ BaseFragment>() {
+
+ final override fun createPresenter(): BasePresenter? {
+ return null
+ }
+
+ override fun showError(message: String) {
+ showMessage(message)
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/base/BasePresenter.kt b/app/src/main/java/com/juhabi/birds/base/BasePresenter.kt
new file mode 100644
index 0000000..cd31bd6
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/base/BasePresenter.kt
@@ -0,0 +1,18 @@
+package com.juhabi.birds.base
+
+abstract class BasePresenter : IPresenter {
+ final override var view: V? = null
+ private set
+
+ final override fun attachView(view: V) {
+ this.view = view
+ onStart()
+ }
+
+ override fun onStart() {
+ }
+
+ override fun onDestroy() {
+ view = null
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/base/IPresenter.kt b/app/src/main/java/com/juhabi/birds/base/IPresenter.kt
new file mode 100644
index 0000000..fc41472
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/base/IPresenter.kt
@@ -0,0 +1,8 @@
+package com.juhabi.birds.base
+
+interface IPresenter {
+ val view: V?
+ fun attachView(view: V)
+ fun onStart()
+ fun onDestroy()
+}
diff --git a/app/src/main/java/com/juhabi/birds/base/IView.kt b/app/src/main/java/com/juhabi/birds/base/IView.kt
new file mode 100644
index 0000000..6d98927
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/base/IView.kt
@@ -0,0 +1,11 @@
+package com.juhabi.birds.base
+
+interface IView {
+ fun showLoading()
+ fun hideLoading()
+ fun showMessage(message: String)
+ fun showError(message: String)
+ fun handleError(error: Throwable) {
+ showError(error.message ?: "Unknown error")
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/component/picker/DateTimePicker.kt b/app/src/main/java/com/juhabi/birds/component/picker/DateTimePicker.kt
new file mode 100644
index 0000000..9346cad
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/component/picker/DateTimePicker.kt
@@ -0,0 +1,147 @@
+package com.juhabi.birds.component.picker
+
+import android.content.Context
+import androidx.core.content.ContextCompat
+import com.bigkoo.pickerview.builder.TimePickerBuilder
+import com.juhabi.birds.R
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+
+/**
+ * 日期时间选择器统一入口。
+ *
+ * 组件基于 Android-PickerView 封装,支持日期、时间、日期时间三种模式。
+ * 页面只需要配置模式和监听器,不需要重复设置控件颜色、单位及格式化规则。
+ */
+class DateTimePicker private constructor(
+ private val context: Context,
+ private val mode: Mode,
+ private val selectedTime: Calendar,
+ private val startTime: Calendar?,
+ private val endTime: Calendar?,
+ private val onSelected: (Result) -> Unit
+) {
+
+ /** 显示选择器。 */
+ fun show() {
+ val pickerBuilder = TimePickerBuilder(context) { date, _ ->
+ onSelected(
+ Result(
+ date = date,
+ timestamp = date.time,
+ formattedText = mode.format(date)
+ )
+ )
+ }
+ .setType(mode.visibleItems)
+ .setDate(selectedTime)
+ .setTitleText(mode.title)
+ .setSubmitText("确定")
+ .setCancelText("取消")
+ .setLabel("年", "月", "日", "时", "分", "秒")
+ .setSubmitColor(ContextCompat.getColor(context, R.color.bottom_nav_selected))
+ .setCancelColor(ContextCompat.getColor(context, R.color.bottom_nav_unselected))
+ .setTextColorCenter(ContextCompat.getColor(context, R.color.bottom_nav_selected))
+ .isCenterLabel(false)
+
+ if (startTime != null && endTime != null) {
+ pickerBuilder.setRangDate(startTime, endTime)
+ }
+
+ pickerBuilder.build().show()
+ }
+
+ /** 选择器展示模式,以及各模式对应的显示列和返回格式。 */
+ enum class Mode(
+ internal val visibleItems: BooleanArray,
+ internal val pattern: String,
+ internal val title: String
+ ) {
+ DATE(
+ booleanArrayOf(true, true, true, false, false, false),
+ "yyyy-MM-dd",
+ "选择日期"
+ ),
+ TIME(
+ booleanArrayOf(false, false, false, true, true, true),
+ "HH:mm:ss",
+ "选择时间"
+ ),
+ DATE_TIME(
+ booleanArrayOf(true, true, true, true, true, true),
+ "yyyy-MM-dd HH:mm:ss",
+ "选择日期时间"
+ );
+
+ internal fun format(date: Date): String {
+ return SimpleDateFormat(pattern, Locale.getDefault()).format(date)
+ }
+ }
+
+ /** 页面接收到的统一选择结果。 */
+ data class Result(
+ val date: Date,
+ val timestamp: Long,
+ val formattedText: String
+ )
+
+ /**
+ * 选择器构建器。
+ *
+ * 默认选中当前时间;只有开始和结束时间都设置时,时间范围才会生效。
+ */
+ class Builder(private val context: Context) {
+ private var mode: Mode = Mode.DATE_TIME
+ private var selectedTime: Calendar = Calendar.getInstance()
+ private var startTime: Calendar? = null
+ private var endTime: Calendar? = null
+ private var onSelected: ((Result) -> Unit)? = null
+
+ fun setMode(mode: Mode): Builder {
+ return apply {
+ this.mode = mode
+ }
+ }
+
+ fun setSelectedTime(timestamp: Long): Builder {
+ return apply {
+ selectedTime = Calendar.getInstance().apply { timeInMillis = timestamp }
+ }
+ }
+
+ fun setTimeRange(startTimeInMillis: Long, endTimeInMillis: Long): Builder {
+ return apply {
+ require(startTimeInMillis <= endTimeInMillis) { "开始时间不能晚于结束时间" }
+ startTime = Calendar.getInstance().apply { timeInMillis = startTimeInMillis }
+ endTime = Calendar.getInstance().apply { timeInMillis = endTimeInMillis }
+ }
+ }
+
+ fun setOnSelectedListener(listener: (Result) -> Unit): Builder {
+ return apply {
+ onSelected = listener
+ }
+ }
+
+ fun build(): DateTimePicker {
+ val listener = requireNotNull(onSelected) { "必须设置选择结果监听器" }
+ val effectiveSelectedTime = selectedTime.clone() as Calendar
+ startTime?.let { start ->
+ if (effectiveSelectedTime.before(start)) effectiveSelectedTime.timeInMillis = start.timeInMillis
+ }
+ endTime?.let { end ->
+ if (effectiveSelectedTime.after(end)) effectiveSelectedTime.timeInMillis = end.timeInMillis
+ }
+ return DateTimePicker(
+ context = context,
+ mode = mode,
+ selectedTime = effectiveSelectedTime,
+ startTime = startTime,
+ endTime = endTime,
+ onSelected = listener
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/demo/constraint/ConstraintLayoutDemoActivity.kt b/app/src/main/java/com/juhabi/birds/demo/constraint/ConstraintLayoutDemoActivity.kt
new file mode 100644
index 0000000..3d178be
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/demo/constraint/ConstraintLayoutDemoActivity.kt
@@ -0,0 +1,29 @@
+package com.juhabi.birds.demo.constraint
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import com.juhabi.birds.base.BaseActivityNoPresenter
+import com.juhabi.birds.databinding.ActivityConstraintLayoutDemoBinding
+
+/**
+ * ConstraintLayout 综合示例页。
+ * 布局知识点集中写在 activity_constraint_layout_demo.xml 中,方便边看页面边读代码。
+ */
+class ConstraintLayoutDemoActivity : BaseActivityNoPresenter() {
+
+ private var detailsVisible = true
+
+ override fun createBinding(inflater: LayoutInflater): ActivityConstraintLayoutDemoBinding {
+ return ActivityConstraintLayoutDemoBinding.inflate(inflater)
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+ binding.backButton.setOnClickListener { finish() }
+ binding.toggleDetailsButton.setOnClickListener {
+ detailsVisible = !detailsVisible
+ binding.detailGroup.visibility = if (detailsVisible) View.VISIBLE else View.GONE
+ binding.toggleDetailsButton.text = if (detailsVisible) "隐藏详情" else "显示详情"
+ }
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/demo/recycler/RecyclerViewDemoActivity.kt b/app/src/main/java/com/juhabi/birds/demo/recycler/RecyclerViewDemoActivity.kt
new file mode 100644
index 0000000..7d2c753
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/demo/recycler/RecyclerViewDemoActivity.kt
@@ -0,0 +1,59 @@
+package com.juhabi.birds.demo.recycler
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.juhabi.birds.R
+import com.juhabi.birds.base.BaseActivityNoPresenter
+import com.juhabi.birds.databinding.ActivityRecyclerViewDemoBinding
+import com.juhabi.birds.demo.recycler.adapter.RecyclerDemoAdapter
+import com.juhabi.birds.demo.recycler.model.RecyclerDemoItem
+
+/** RecyclerView 与 BaseQuickAdapter 的基础调用示例页。 */
+class RecyclerViewDemoActivity : BaseActivityNoPresenter() {
+
+ override fun createBinding(inflater: LayoutInflater): ActivityRecyclerViewDemoBinding {
+ return ActivityRecyclerViewDemoBinding.inflate(inflater)
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+ title = getString(R.string.recycler_demo_title)
+
+ val adapter = RecyclerDemoAdapter(this, R.layout.item_recycler_demo, createDemoData())
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ binding.recyclerView.adapter = adapter
+
+ // 列表点击事件由页面处理,适配器只负责展示数据。
+ adapter.setOnItemClickListener { _, _, position ->
+ showSelectedItem(adapter.data[position])
+ }
+
+ adapter.setOnItemChildClickListener { _, view, position ->
+ val item = adapter.data[position]
+ val message = when (view.id) {
+ R.id.titleTextView -> "点击了标题:${item.title}"
+ R.id.idTextView -> "点击了编号:${item.id}"
+ R.id.statusTextView -> "点击了状态:${item.status}"
+ R.id.timeTextView -> "点击了更新时间:${item.updateTime}"
+ else -> return@setOnItemChildClickListener
+ }
+ android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun showSelectedItem(item: RecyclerDemoItem) {
+ android.widget.Toast.makeText(this, "点击了:${item.title}", android.widget.Toast.LENGTH_SHORT).show()
+ }
+
+ /** 创建固定演示数据,接入接口后可直接替换为服务端返回的列表。 */
+ private fun createDemoData(): List {
+ return listOf(
+ RecyclerDemoItem(1001, "设备巡检记录", "已完成", "2026-07-23 09:30:00"),
+ RecyclerDemoItem(1002, "自动浇水计划", "执行中", "2026-07-23 10:15:20"),
+ RecyclerDemoItem(1003, "传感器数据同步", "待处理", "2026-07-23 11:08:45"),
+ RecyclerDemoItem(1004, "系统配置备份", "已完成", "2026-07-22 18:42:10"),
+ RecyclerDemoItem(1005, "固件升级任务", "失败", "2026-07-22 16:20:35"),
+ RecyclerDemoItem(1006, "用户操作日志", "已完成", "2026-07-22 14:05:00")
+ )
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/demo/recycler/adapter/RecyclerDemoAdapter.java b/app/src/main/java/com/juhabi/birds/demo/recycler/adapter/RecyclerDemoAdapter.java
new file mode 100644
index 0000000..0073ba7
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/demo/recycler/adapter/RecyclerDemoAdapter.java
@@ -0,0 +1,43 @@
+package com.juhabi.birds.demo.recycler.adapter;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.chad.library.adapter.base.BaseQuickAdapter;
+import com.chad.library.adapter.base.viewholder.BaseViewHolder;
+import com.juhabi.birds.R;
+import com.juhabi.birds.demo.recycler.model.RecyclerDemoItem;
+
+import java.util.List;
+
+/**
+ * BaseQuickAdapter 使用示例。
+ * convert() 只负责把一条数据绑定到对应的 item 控件。
+ */
+public class RecyclerDemoAdapter extends BaseQuickAdapter {
+ private final Context mContext;
+
+ public RecyclerDemoAdapter(Context context, int layoutResId, @Nullable List data) {
+ super(layoutResId, data);
+ this.mContext = context;
+ addChildClickViewIds(
+ R.id.titleTextView,
+ R.id.idTextView,
+ R.id.statusTextView,
+ R.id.timeTextView
+ );
+ }
+
+ @Override
+ protected void convert(@NonNull BaseViewHolder holder, RecyclerDemoItem item) {
+ holder.setText(R.id.titleTextView, item.getTitle());
+ holder.setText(R.id.idTextView, mContext.getString(R.string.recycler_item_id, item.getId()));
+ holder.setText(R.id.statusTextView, mContext.getString(R.string.recycler_item_status, item.getStatus()));
+ holder.setText(
+ R.id.timeTextView,
+ mContext.getString(R.string.recycler_item_update_time, item.getUpdateTime())
+ );
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/demo/recycler/model/RecyclerDemoItem.kt b/app/src/main/java/com/juhabi/birds/demo/recycler/model/RecyclerDemoItem.kt
new file mode 100644
index 0000000..b264785
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/demo/recycler/model/RecyclerDemoItem.kt
@@ -0,0 +1,9 @@
+package com.juhabi.birds.demo.recycler.model
+
+/** RecyclerView 示例页使用的简单数据模型。 */
+data class RecyclerDemoItem(
+ val id: Long,
+ val title: String,
+ val status: String,
+ val updateTime: String
+)
diff --git a/app/src/main/java/com/juhabi/birds/dialog/LoadingDialog.kt b/app/src/main/java/com/juhabi/birds/dialog/LoadingDialog.kt
new file mode 100644
index 0000000..adb555b
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/dialog/LoadingDialog.kt
@@ -0,0 +1,41 @@
+package com.juhabi.birds.dialog
+
+import android.app.Dialog
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.os.Bundle
+import android.view.Gravity
+import android.view.Window
+import android.view.WindowManager
+import android.widget.TextView
+import com.juhabi.birds.R
+
+class LoadingDialog(
+ context: Context,
+ private val message: String = "加载中..."
+) : Dialog(context, R.style.LoadingDialogTheme) {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ requestWindowFeature(Window.FEATURE_NO_TITLE)
+ setContentView(R.layout.dialog_loading)
+ findViewById(R.id.loadingText).text = message
+ window?.apply {
+ // 参考 Water:窗口全屏、内容居中,并为页面增加轻量半透明遮罩。
+ setBackgroundDrawable(ColorDrawable(Color.argb(13, 0, 0, 0)))
+ setGravity(Gravity.CENTER)
+ setDimAmount(0.2f)
+ addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+ setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT)
+ }
+ }
+
+ override fun show() {
+ super.show()
+ window?.apply {
+ setGravity(Gravity.CENTER)
+ setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT)
+ }
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/login/LoginActivity.kt b/app/src/main/java/com/juhabi/birds/login/LoginActivity.kt
new file mode 100644
index 0000000..7cac569
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/login/LoginActivity.kt
@@ -0,0 +1,71 @@
+package com.juhabi.birds.login
+
+import android.os.Bundle
+import android.content.Intent
+import android.view.LayoutInflater
+import com.juhabi.birds.base.BaseActivity
+import com.juhabi.birds.databinding.ActivityLoginBinding
+import com.juhabi.birds.login.model.LoginUser
+import com.juhabi.birds.login.mvp.LoginContract
+import com.juhabi.birds.login.mvp.LoginImpl
+import com.juhabi.birds.login.mvp.LoginPresenter
+import com.juhabi.birds.network.TokenManager
+import com.juhabi.birds.mine.MineCenterActivity
+import com.juhabi.birds.util.ToastUtil
+
+class LoginActivity : BaseActivity(),
+ LoginContract.LoginView {
+
+ override fun createBinding(inflater: LayoutInflater): ActivityLoginBinding {
+ return ActivityLoginBinding.inflate(inflater)
+ }
+
+ override fun createPresenter(): LoginPresenter {
+ return LoginPresenter(LoginImpl())
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+// if (TokenManager.get(this).isNotBlank()) {
+// startActivity(Intent(this, MineCenterActivity::class.java))
+// finish()
+// return
+// }
+ if(binding.usernameEditText.text.toString().isNotBlank()){
+ ToastUtil.showShort(mContext,"");
+ return
+ }
+ binding.loginButton.setOnClickListener {
+ presenter?.login(
+ binding.usernameEditText.text?.toString().orEmpty(),
+ binding.passwordEditText.text?.toString().orEmpty()
+ )
+ }
+ }
+
+ override fun showUsernameError(message: String) {
+ binding.usernameLayout.error = message
+ binding.usernameEditText.requestFocus()
+ }
+
+ override fun showPasswordError(message: String) {
+ binding.passwordLayout.error = message
+ binding.passwordEditText.requestFocus()
+ }
+
+ override fun clearInputErrors() {
+ binding.usernameLayout.error = null
+ binding.passwordLayout.error = null
+ }
+
+ override fun onLoginSuccess(user: LoginUser) {
+ TokenManager.save(this, user.accessToken)
+ binding.resultTextView.text = "登录成功,Token 已保存"
+ showMessage("登录成功")
+ startActivity(Intent(this, MineCenterActivity::class.java))
+ finish()
+ }
+
+ override fun showError(message: String) {
+ ToastUtil.showShort(mContext,message)
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/login/model/LoginUser.kt b/app/src/main/java/com/juhabi/birds/login/model/LoginUser.kt
new file mode 100644
index 0000000..8cf30d5
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/login/model/LoginUser.kt
@@ -0,0 +1,18 @@
+package com.juhabi.birds.login.model
+
+import com.google.gson.annotations.SerializedName
+
+data class LoginUser(
+ val scope: Any? = null,
+ val openid: Any? = null,
+ @SerializedName("access_token")
+ val accessToken: String = "",
+ @SerializedName("refresh_token")
+ val refreshToken: Any? = null,
+ @SerializedName("expire_in")
+ val expireIn: Long = 0,
+ @SerializedName("refresh_expire_in")
+ val refreshExpireIn: Any? = null,
+ @SerializedName("client_id")
+ val clientId: String = ""
+)
diff --git a/app/src/main/java/com/juhabi/birds/login/mvp/LoginContract.kt b/app/src/main/java/com/juhabi/birds/login/mvp/LoginContract.kt
new file mode 100644
index 0000000..5eb99d7
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/login/mvp/LoginContract.kt
@@ -0,0 +1,26 @@
+package com.juhabi.birds.login.mvp
+
+import com.juhabi.birds.base.IView
+import com.juhabi.birds.login.model.LoginUser
+import com.juhabi.birds.network.BaseBean
+import org.json.JSONObject
+
+interface LoginContract {
+ interface Callback {
+ fun success(data: T)
+ fun fail(message: String)
+ }
+
+ interface DataModel {
+ fun login(json: JSONObject, callback: Callback>)
+ fun cancel()
+ }
+
+ interface LoginView : IView {
+ fun showUsernameError(message: String)
+ fun showPasswordError(message: String)
+ fun clearInputErrors()
+ fun onLoginSuccess(user: LoginUser)
+ }
+
+}
diff --git a/app/src/main/java/com/juhabi/birds/login/mvp/LoginImpl.kt b/app/src/main/java/com/juhabi/birds/login/mvp/LoginImpl.kt
new file mode 100644
index 0000000..d1f29e4
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/login/mvp/LoginImpl.kt
@@ -0,0 +1,57 @@
+package com.juhabi.birds.login.mvp
+
+import android.os.Handler
+import android.os.Looper
+import com.juhabi.birds.login.model.LoginUser
+import com.juhabi.birds.network.ApiClient
+import com.juhabi.birds.network.BaseBean
+import com.juhabi.birds.network.NetworkManager
+import org.json.JSONObject
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class LoginImpl : LoginContract.DataModel {
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var loginCall: Call>? = null
+
+ override fun login(
+ json: JSONObject,
+ callback: LoginContract.Callback>
+ ) {
+ loginCall?.cancel()
+ loginCall = ApiClient.apiService
+ .login(NetworkManager.getToken(), NetworkManager.toRequestBody(json))
+ .also { call ->
+ call.enqueue(object : Callback> {
+ override fun onResponse(
+ call: Call>,
+ response: Response>
+ ) {
+ mainHandler.post {
+ val bean = response.body()
+ if (response.isSuccessful && bean != null) {
+ callback.success(bean)
+ } else {
+ callback.fail("接口响应异常:${response.code()}")
+ }
+ }
+ }
+
+ override fun onFailure(call: Call>, error: Throwable) {
+ if (!call.isCanceled) {
+ mainHandler.post {
+ callback.fail(error.message ?: "网络请求失败")
+ }
+ }
+ }
+ })
+ }
+ }
+
+ override fun cancel() {
+ loginCall?.cancel()
+ loginCall = null
+ mainHandler.removeCallbacksAndMessages(null)
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/login/mvp/LoginPresenter.kt b/app/src/main/java/com/juhabi/birds/login/mvp/LoginPresenter.kt
new file mode 100644
index 0000000..4106ebd
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/login/mvp/LoginPresenter.kt
@@ -0,0 +1,59 @@
+package com.juhabi.birds.login.mvp
+
+import com.juhabi.birds.base.BasePresenter
+import com.juhabi.birds.AppConfig
+import com.juhabi.birds.login.model.LoginUser
+import com.juhabi.birds.network.BaseBean
+import org.json.JSONObject
+
+class LoginPresenter(private val loginImpl: LoginImpl) : BasePresenter() {
+
+ fun login(username: String, password: String) {
+ val currentView = view ?: return
+ currentView.clearInputErrors()
+
+ if (username.isBlank()) {
+ currentView.showUsernameError("请输入账号")
+ return
+ }
+ if (password.isBlank()) {
+ currentView.showPasswordError("请输入密码")
+ return
+ }
+
+ currentView.showLoading()
+ val json = JSONObject();
+ json.put("grantType", "password")
+ json.put("username", username.trim())
+ json.put("password", password)
+ json.put("clientId", AppConfig.APP_CLIENT_ID)
+ json.put("userType", AppConfig.APP_USER_TYPE)
+ loginImpl.login(
+ json,
+ object : LoginContract.Callback> {
+ override fun success(data: BaseBean) {
+ val attachedView = view ?: return
+ attachedView.hideLoading()
+ val user = data.data
+ if ((data.code == 0 || data.code == 200) && user != null) {
+ attachedView.onLoginSuccess(user)
+ } else {
+ attachedView.showError(data.message.ifBlank { "登录失败" })
+ }
+ }
+
+ override fun fail(message: String) {
+ view?.apply {
+ hideLoading()
+ showError(message)
+ }
+ }
+ }
+ )
+ }
+
+ override fun onDestroy() {
+ loginImpl.cancel()
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/MainActivity.kt b/app/src/main/java/com/juhabi/birds/main/MainActivity.kt
new file mode 100644
index 0000000..a31f7ad
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/MainActivity.kt
@@ -0,0 +1,138 @@
+package com.juhabi.birds.main
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import androidx.annotation.ColorRes
+import androidx.core.content.ContextCompat
+import com.juhabi.birds.R
+import com.juhabi.birds.base.BaseActivity
+import com.juhabi.birds.databinding.ActivityMainBinding
+import com.juhabi.birds.main.adapter.MainPagerAdapter
+import com.juhabi.birds.main.fragment.HistoryFragment
+import com.juhabi.birds.main.fragment.HomeFragment
+import com.juhabi.birds.main.fragment.MineFragment
+import com.juhabi.birds.main.fragment.PlanFragment
+import com.juhabi.birds.main.model.AppVersionInfo
+import com.juhabi.birds.main.mvp.MainContract
+import com.juhabi.birds.main.mvp.MainImpl
+import com.juhabi.birds.main.mvp.MainPresenter
+import com.gyf.immersionbar.ImmersionBar
+
+class MainActivity : BaseActivity(),
+ MainContract.MainView {
+
+ override fun createBinding(inflater: LayoutInflater): ActivityMainBinding {
+ return ActivityMainBinding.inflate(inflater)
+ }
+
+ override fun createPresenter(): MainPresenter {
+ return MainPresenter(MainImpl(applicationContext))
+ }
+
+ override fun initWindows() {
+ ImmersionBar.with(this)
+ .statusBarView(binding.statusBarView)
+ .statusBarDarkFont(true)
+ .navigationBarColor(R.color.color_zhu)
+ .navigationBarDarkIcon(true)
+ .init()
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+ binding.viewPager.adapter = MainPagerAdapter(
+ this,
+ listOf(HomeFragment(), PlanFragment(), HistoryFragment(), MineFragment())
+ )
+ binding.viewPager.offscreenPageLimit = 1
+ binding.viewPager.registerOnPageChangeCallback(object : androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback() {
+ override fun onPageSelected(position: Int) {
+ binding.bottomNavigation.menu.getItem(position).isChecked = true
+ updateTopBarColor(position)
+ }
+ })
+ binding.bottomNavigation.setOnItemSelectedListener { item ->
+ onNavigationItemSelected(item.itemId)
+ }
+ binding.bottomNavigation.selectedItemId = R.id.navigation_home
+ }
+
+ private fun onNavigationItemSelected(itemId: Int): Boolean {
+ return when (itemId) {
+ R.id.navigation_home -> onHomeSelected()
+ R.id.navigation_plan -> onPlanSelected()
+ R.id.navigation_history -> onHistorySelected()
+ R.id.navigation_mine -> onMineSelected()
+ else -> false
+ }
+ }
+
+ private fun onHomeSelected(): Boolean {
+ switchPage(PAGE_HOME)
+ // Add Home-specific status bar, refresh, or Fragment communication here.
+ return true
+ }
+
+ private fun onPlanSelected(): Boolean {
+ switchPage(PAGE_PLAN)
+ // Add Plan-specific status bar, refresh, or Fragment communication here.
+ return true
+ }
+
+ private fun onHistorySelected(): Boolean {
+ switchPage(PAGE_HISTORY)
+ // Add History-specific status bar, refresh, or Fragment communication here.
+ return true
+ }
+
+ private fun onMineSelected(): Boolean {
+ switchPage(PAGE_MINE)
+ // Add Mine-specific status bar, refresh, or Fragment communication here.
+ return true
+ }
+
+ private fun switchPage(position: Int) {
+ updateTopBarColor(position)
+ if (binding.viewPager.currentItem != position) {
+ binding.viewPager.setCurrentItem(position, false)
+ }
+ }
+
+ private fun updateTopBarColor(position: Int) {
+ if (position == PAGE_PLAN) {
+ binding.immersiveTopImageView.visibility = View.VISIBLE
+ binding.statusBarView.setBackgroundColor(
+ ContextCompat.getColor(this, android.R.color.transparent)
+ )
+ return
+ }
+
+ binding.immersiveTopImageView.visibility = View.GONE
+ @ColorRes val colorRes = when (position) {
+ PAGE_HOME -> R.color.color_zhu
+ PAGE_HISTORY -> R.color.color_E9A01C
+ PAGE_MINE -> R.color.color_B85CE6
+ else -> R.color.color_zhu
+ }
+ binding.statusBarView.setBackgroundColor(ContextCompat.getColor(this, colorRes))
+ }
+
+ override fun initData(savedInstanceState: Bundle?) {
+ presenter?.loadAppVersion()
+ }
+
+ override fun onAppVersionLoaded(versionInfo: AppVersionInfo) {
+ // The version is available for a future update-check API or dialog.
+ }
+
+ override fun showError(message: String) {
+ showMessage(message)
+ }
+
+ private companion object {
+ const val PAGE_HOME = 0
+ const val PAGE_PLAN = 1
+ const val PAGE_HISTORY = 2
+ const val PAGE_MINE = 3
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/adapter/MainPagerAdapter.kt b/app/src/main/java/com/juhabi/birds/main/adapter/MainPagerAdapter.kt
new file mode 100644
index 0000000..b5faba1
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/adapter/MainPagerAdapter.kt
@@ -0,0 +1,18 @@
+package com.juhabi.birds.main.adapter
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+
+class MainPagerAdapter(
+ activity: FragmentActivity,
+ private val fragments: List
+) : FragmentStateAdapter(activity) {
+ override fun getItemCount(): Int {
+ return fragments.size
+ }
+
+ override fun createFragment(position: Int): Fragment {
+ return fragments[position]
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/HistoryFragment.kt b/app/src/main/java/com/juhabi/birds/main/fragment/HistoryFragment.kt
new file mode 100644
index 0000000..0b26a17
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/HistoryFragment.kt
@@ -0,0 +1,28 @@
+package com.juhabi.birds.main.fragment
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import com.juhabi.birds.base.BaseFragment
+import com.juhabi.birds.databinding.FragmentHistoryBinding
+import com.juhabi.birds.main.fragment.history.mvp.HistoryContract
+import com.juhabi.birds.main.fragment.history.mvp.HistoryImpl
+import com.juhabi.birds.main.fragment.history.mvp.HistoryPresenter
+import me.jessyan.autosize.internal.CustomAdapt
+
+class HistoryFragment : BaseFragment(),
+ HistoryContract.HistoryView{
+ override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHistoryBinding {
+ return FragmentHistoryBinding.inflate(inflater, container, false)
+ }
+
+ override fun createPresenter(): HistoryPresenter {
+ return HistoryPresenter(HistoryImpl())
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+ }
+
+
+
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/HomeFragment.kt b/app/src/main/java/com/juhabi/birds/main/fragment/HomeFragment.kt
new file mode 100644
index 0000000..2be01ab
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/HomeFragment.kt
@@ -0,0 +1,25 @@
+package com.juhabi.birds.main.fragment
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import com.juhabi.birds.base.BaseFragment
+import com.juhabi.birds.databinding.FragmentHomeBinding
+import com.juhabi.birds.main.fragment.home.mvp.HomeContract
+import com.juhabi.birds.main.fragment.home.mvp.HomeImpl
+import com.juhabi.birds.main.fragment.home.mvp.HomePresenter
+
+class HomeFragment : BaseFragment(),
+ HomeContract.HomeView {
+
+ override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding {
+ return FragmentHomeBinding.inflate(inflater, container, false)
+ }
+
+ override fun createPresenter(): HomePresenter {
+ return HomePresenter(HomeImpl())
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/MineFragment.kt b/app/src/main/java/com/juhabi/birds/main/fragment/MineFragment.kt
new file mode 100644
index 0000000..4359d79
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/MineFragment.kt
@@ -0,0 +1,30 @@
+package com.juhabi.birds.main.fragment
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import com.juhabi.birds.base.BaseFragment
+import com.juhabi.birds.databinding.FragmentMineBinding
+import com.juhabi.birds.main.fragment.mine.mvp.MineFragmentContract
+import com.juhabi.birds.main.fragment.mine.mvp.MineFragmentImpl
+import com.juhabi.birds.main.fragment.mine.mvp.MineFragmentPresenter
+import com.juhabi.birds.mine.MineCenterActivity
+
+class MineFragment : BaseFragment(),
+ MineFragmentContract.MineView {
+
+ override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMineBinding {
+ return FragmentMineBinding.inflate(inflater, container, false)
+ }
+
+ override fun createPresenter(): MineFragmentPresenter {
+ return MineFragmentPresenter(MineFragmentImpl())
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+ binding.openComponentCenterButton.setOnClickListener {
+ startActivity(Intent(requireContext(), MineCenterActivity::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/PlanFragment.kt b/app/src/main/java/com/juhabi/birds/main/fragment/PlanFragment.kt
new file mode 100644
index 0000000..f3e6bdd
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/PlanFragment.kt
@@ -0,0 +1,28 @@
+package com.juhabi.birds.main.fragment
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import com.juhabi.birds.base.BaseFragment
+import com.juhabi.birds.databinding.FragmentPlanBinding
+import com.juhabi.birds.main.fragment.plan.mvp.PlanContract
+import com.juhabi.birds.main.fragment.plan.mvp.PlanImpl
+import com.juhabi.birds.main.fragment.plan.mvp.PlanPresenter
+
+class PlanFragment : BaseFragment(),
+ PlanContract.PlanView {
+
+ override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPlanBinding {
+ return FragmentPlanBinding.inflate(inflater, container, false)
+ }
+
+ override fun createPresenter(): PlanPresenter {
+ return PlanPresenter(PlanImpl())
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+ binding.planBackButton.setOnClickListener {
+ requireActivity().onBackPressedDispatcher.onBackPressed()
+ }
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/history/mvp/HistoryContract.kt b/app/src/main/java/com/juhabi/birds/main/fragment/history/mvp/HistoryContract.kt
new file mode 100644
index 0000000..02faf3c
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/history/mvp/HistoryContract.kt
@@ -0,0 +1,9 @@
+package com.juhabi.birds.main.fragment.history.mvp
+
+import com.juhabi.birds.base.IView
+
+interface HistoryContract {
+ interface DataModel
+
+ interface HistoryView : IView
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/history/mvp/HistoryImpl.kt b/app/src/main/java/com/juhabi/birds/main/fragment/history/mvp/HistoryImpl.kt
new file mode 100644
index 0000000..b099b74
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/history/mvp/HistoryImpl.kt
@@ -0,0 +1,3 @@
+package com.juhabi.birds.main.fragment.history.mvp
+
+class HistoryImpl : HistoryContract.DataModel
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/history/mvp/HistoryPresenter.kt b/app/src/main/java/com/juhabi/birds/main/fragment/history/mvp/HistoryPresenter.kt
new file mode 100644
index 0000000..7526d38
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/history/mvp/HistoryPresenter.kt
@@ -0,0 +1,7 @@
+package com.juhabi.birds.main.fragment.history.mvp
+
+import com.juhabi.birds.base.BasePresenter
+
+class HistoryPresenter(
+ private val model: HistoryContract.DataModel
+) : BasePresenter()
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/home/mvp/HomeContract.kt b/app/src/main/java/com/juhabi/birds/main/fragment/home/mvp/HomeContract.kt
new file mode 100644
index 0000000..b8333ba
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/home/mvp/HomeContract.kt
@@ -0,0 +1,9 @@
+package com.juhabi.birds.main.fragment.home.mvp
+
+import com.juhabi.birds.base.IView
+
+interface HomeContract {
+ interface DataModel
+
+ interface HomeView : IView
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/home/mvp/HomeImpl.kt b/app/src/main/java/com/juhabi/birds/main/fragment/home/mvp/HomeImpl.kt
new file mode 100644
index 0000000..3c71916
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/home/mvp/HomeImpl.kt
@@ -0,0 +1,3 @@
+package com.juhabi.birds.main.fragment.home.mvp
+
+class HomeImpl : HomeContract.DataModel
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/home/mvp/HomePresenter.kt b/app/src/main/java/com/juhabi/birds/main/fragment/home/mvp/HomePresenter.kt
new file mode 100644
index 0000000..97f4193
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/home/mvp/HomePresenter.kt
@@ -0,0 +1,7 @@
+package com.juhabi.birds.main.fragment.home.mvp
+
+import com.juhabi.birds.base.BasePresenter
+
+class HomePresenter(
+ private val model: HomeContract.DataModel
+) : BasePresenter()
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/mine/mvp/MineFragmentContract.kt b/app/src/main/java/com/juhabi/birds/main/fragment/mine/mvp/MineFragmentContract.kt
new file mode 100644
index 0000000..346862c
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/mine/mvp/MineFragmentContract.kt
@@ -0,0 +1,9 @@
+package com.juhabi.birds.main.fragment.mine.mvp
+
+import com.juhabi.birds.base.IView
+
+interface MineFragmentContract {
+ interface DataModel
+
+ interface MineView : IView
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/mine/mvp/MineFragmentImpl.kt b/app/src/main/java/com/juhabi/birds/main/fragment/mine/mvp/MineFragmentImpl.kt
new file mode 100644
index 0000000..0de0694
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/mine/mvp/MineFragmentImpl.kt
@@ -0,0 +1,3 @@
+package com.juhabi.birds.main.fragment.mine.mvp
+
+class MineFragmentImpl : MineFragmentContract.DataModel
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/mine/mvp/MineFragmentPresenter.kt b/app/src/main/java/com/juhabi/birds/main/fragment/mine/mvp/MineFragmentPresenter.kt
new file mode 100644
index 0000000..1ab4e9b
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/mine/mvp/MineFragmentPresenter.kt
@@ -0,0 +1,7 @@
+package com.juhabi.birds.main.fragment.mine.mvp
+
+import com.juhabi.birds.base.BasePresenter
+
+class MineFragmentPresenter(
+ private val model: MineFragmentContract.DataModel
+) : BasePresenter()
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/plan/mvp/PlanContract.kt b/app/src/main/java/com/juhabi/birds/main/fragment/plan/mvp/PlanContract.kt
new file mode 100644
index 0000000..303eee3
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/plan/mvp/PlanContract.kt
@@ -0,0 +1,9 @@
+package com.juhabi.birds.main.fragment.plan.mvp
+
+import com.juhabi.birds.base.IView
+
+interface PlanContract {
+ interface DataModel
+
+ interface PlanView : IView
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/plan/mvp/PlanImpl.kt b/app/src/main/java/com/juhabi/birds/main/fragment/plan/mvp/PlanImpl.kt
new file mode 100644
index 0000000..fa285ec
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/plan/mvp/PlanImpl.kt
@@ -0,0 +1,3 @@
+package com.juhabi.birds.main.fragment.plan.mvp
+
+class PlanImpl : PlanContract.DataModel
diff --git a/app/src/main/java/com/juhabi/birds/main/fragment/plan/mvp/PlanPresenter.kt b/app/src/main/java/com/juhabi/birds/main/fragment/plan/mvp/PlanPresenter.kt
new file mode 100644
index 0000000..ac733b3
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/fragment/plan/mvp/PlanPresenter.kt
@@ -0,0 +1,7 @@
+package com.juhabi.birds.main.fragment.plan.mvp
+
+import com.juhabi.birds.base.BasePresenter
+
+class PlanPresenter(
+ private val model: PlanContract.DataModel
+) : BasePresenter()
diff --git a/app/src/main/java/com/juhabi/birds/main/model/AppVersionInfo.kt b/app/src/main/java/com/juhabi/birds/main/model/AppVersionInfo.kt
new file mode 100644
index 0000000..35a08ea
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/model/AppVersionInfo.kt
@@ -0,0 +1,13 @@
+package com.juhabi.birds.main.model
+
+data class AppVersionInfo(
+ val versionName: String,
+ var platform: String = "",
+ var currentVersion: String = "",
+ var latestVersion: String = "",
+ var versionCode: Int = 0,
+ var updateAvailable: Boolean = false,
+ var forceUpdate: Boolean = false,
+ var downloadUrl: String = "",
+ var releaseNotes: String = ""
+)
diff --git a/app/src/main/java/com/juhabi/birds/main/mvp/MainContract.kt b/app/src/main/java/com/juhabi/birds/main/mvp/MainContract.kt
new file mode 100644
index 0000000..f503203
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/mvp/MainContract.kt
@@ -0,0 +1,19 @@
+package com.juhabi.birds.main.mvp
+
+import com.juhabi.birds.base.IView
+import com.juhabi.birds.main.model.AppVersionInfo
+
+interface MainContract {
+ interface DataModel {
+ fun getAppVersion(callback: Callback)
+ }
+
+ interface Callback {
+ fun onSuccess(data: T)
+ fun onFailure(message: String)
+ }
+
+ interface MainView : IView {
+ fun onAppVersionLoaded(versionInfo: AppVersionInfo)
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/mvp/MainImpl.kt b/app/src/main/java/com/juhabi/birds/main/mvp/MainImpl.kt
new file mode 100644
index 0000000..249f401
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/mvp/MainImpl.kt
@@ -0,0 +1,27 @@
+package com.juhabi.birds.main.mvp
+
+import android.content.Context
+import android.os.Build
+import com.juhabi.birds.main.model.AppVersionInfo
+
+class MainImpl(context: Context) : MainContract.DataModel {
+ private val appContext = context.applicationContext
+
+ override fun getAppVersion(callback: MainContract.Callback) {
+// runCatching {
+// val packageInfo = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
+// AppVersionInfo(
+// versionName = packageInfo.versionName.orEmpty(),
+// versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+// packageInfo.longVersionCode
+// } else {
+// @Suppress("DEPRECATION")
+// packageInfo.versionCode.toLong()
+// }
+// )
+// }.onSuccess(callback::onSuccess)
+// .onFailure { error ->
+// callback.onFailure(error.message ?: "Unable to read app version")
+// }
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/main/mvp/MainPresenter.kt b/app/src/main/java/com/juhabi/birds/main/mvp/MainPresenter.kt
new file mode 100644
index 0000000..cd9a629
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/main/mvp/MainPresenter.kt
@@ -0,0 +1,21 @@
+package com.juhabi.birds.main.mvp
+
+import com.juhabi.birds.base.BasePresenter
+import com.juhabi.birds.main.model.AppVersionInfo
+
+class MainPresenter(
+ private val model: MainContract.DataModel
+) : BasePresenter() {
+
+ fun loadAppVersion() {
+ model.getAppVersion(object : MainContract.Callback {
+ override fun onSuccess(data: AppVersionInfo) {
+ view?.onAppVersionLoaded(data)
+ }
+
+ override fun onFailure(message: String) {
+ view?.showError(message)
+ }
+ })
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/mine/MineCenterActivity.kt b/app/src/main/java/com/juhabi/birds/mine/MineCenterActivity.kt
new file mode 100644
index 0000000..97a65a2
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/mine/MineCenterActivity.kt
@@ -0,0 +1,245 @@
+package com.juhabi.birds.mine
+
+import android.Manifest
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import com.juhabi.birds.R
+import com.juhabi.birds.base.BaseActivity
+import com.juhabi.birds.component.picker.DateTimePicker
+import com.juhabi.birds.databinding.ActivityMineCenterBinding
+import com.juhabi.birds.demo.constraint.ConstraintLayoutDemoActivity
+import com.juhabi.birds.demo.recycler.RecyclerViewDemoActivity
+import com.juhabi.birds.login.LoginActivity
+import com.juhabi.birds.mine.model.MineUserInfo
+import com.juhabi.birds.mine.mvp.MineContract
+import com.juhabi.birds.mine.mvp.MineImpl
+import com.juhabi.birds.mine.mvp.MinePresenter
+import com.juhabi.birds.main.model.AppVersionInfo
+import com.juhabi.birds.network.TokenManager
+import com.juhabi.birds.utils.AppUpdateManager
+import com.gyf.immersionbar.ImmersionBar
+import java.util.Calendar
+
+class MineCenterActivity : BaseActivity(),
+ MineContract.MineView, View.OnClickListener {
+
+ private val appUpdateManager by lazy {
+ AppUpdateManager(this, ::showMessage)
+ }
+
+ override fun createBinding(inflater: LayoutInflater): ActivityMineCenterBinding {
+ return ActivityMineCenterBinding.inflate(inflater)
+ }
+
+ override fun createPresenter(): MinePresenter {
+ return MinePresenter(MineImpl())
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+ TokenManager.get(this)
+ listOf(
+ binding.reloadButton,
+ binding.checkVersionButton,
+ binding.openLoginButton,
+ binding.multiplePermissionButton,
+ binding.locationPermissionButton,
+ binding.normalDialogButton,
+ binding.inputDialogButton,
+ binding.datePickerButton,
+ binding.timePickerButton,
+ binding.dateTimePickerButton,
+ binding.recyclerViewDemoButton,
+ binding.constraintLayoutDemoButton
+ ).forEach { button ->
+ button.setOnClickListener(this)
+ }
+ }
+
+ override fun initWindows() {
+ ImmersionBar.with(this)
+ .statusBarView(binding.statusBarView)
+ .statusBarDarkFont(true)
+ .navigationBarColor(R.color.color_zhu)
+ .navigationBarDarkIcon(true)
+ .init()
+ }
+
+ override fun onClick(view: View) {
+ when (view.id) {
+ R.id.reloadButton -> {
+ presenter?.getUserInfo()
+ }
+ R.id.checkVersionButton -> checkVersion()
+
+ R.id.openLoginButton -> {
+ goTo(LoginActivity::class.java, null);
+ }
+
+ R.id.multiplePermissionButton -> {
+ getMorePermissionRequst(
+ arrayOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO
+ ),
+ "相机和麦克风权限",
+ "拍照和录音功能需要使用相机及麦克风权限",
+ REQUEST_CAMERA_AND_AUDIO
+ )
+ }
+
+ R.id.locationPermissionButton -> {
+ getSinglePermissionRequst(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ "定位权限",
+ "定位功能需要获取您的位置信息",
+ REQUEST_LOCATION
+ )
+ }
+
+ R.id.normalDialogButton -> {
+ showDialogtext(
+ "操作确认",
+ "确定要执行这项操作吗?",
+ "确定",
+ "取消",
+ object : OnDialogClick {
+ override fun onCancelClick() {
+ showMessage("已取消")
+ }
+
+ override fun onConfirmClick() {
+ showMessage("已点击确定")
+ }
+ }
+ )
+ }
+
+ R.id.inputDialogButton -> {
+ showEditDialog(
+ "请输入内容",
+ "请输入测试内容",
+ click = object : OnDialogClick {
+ override fun onCancelClick() {
+ showMessage("已取消输入")
+ }
+ override fun onConfirmClick(content: String) {
+ showMessage("输入内容:$content")
+ }
+ }
+ )
+ }
+
+ R.id.datePickerButton -> showDateTimePicker(DateTimePicker.Mode.DATE)
+
+ R.id.timePickerButton -> showDateTimePicker(DateTimePicker.Mode.TIME)
+
+ R.id.dateTimePickerButton -> showDateTimePicker(DateTimePicker.Mode.DATE_TIME)
+
+ R.id.recyclerViewDemoButton -> {
+ startActivity(Intent(this, RecyclerViewDemoActivity::class.java))
+ }
+
+ R.id.constraintLayoutDemoButton -> {
+ startActivity(Intent(this, ConstraintLayoutDemoActivity::class.java))
+ }
+ }
+ }
+
+
+
+ private fun checkVersion() {
+ val currentVersion = packageManager
+ .getPackageInfo(packageName, 0)
+ .versionName
+ .orEmpty()
+ presenter?.checkVersion(currentVersion)
+ }
+
+ override fun onVersionChecked(versionInfo: AppVersionInfo) {
+ val currentVersion = packageManager.getPackageInfo(packageName, 0).versionName.orEmpty()
+ appUpdateManager.checkAndUpdate(currentVersion, versionInfo)
+ }
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ appUpdateManager.onRequestPermissionsResult(requestCode, grantResults)
+ }
+
+ /**
+ * 日期、时间和日期时间共用同一个组件调用入口。
+ * 后续页面需要限制可选范围时,可以继续调用 setTimeRange()。
+ */
+ private fun showDateTimePicker(mode: DateTimePicker.Mode) {
+ DateTimePicker.Builder(this)
+ .setMode(mode)
+ .setSelectedTime(selectedDateTime)
+ .setOnSelectedListener { result ->
+ selectedDateTime = result.timestamp
+ binding.dateTimeResultTextView.text = result.formattedText
+ showMessage("已选择:${result.formattedText}")
+ }
+ .build()
+ .show()
+ }
+
+ override fun initData(savedInstanceState: Bundle?) {
+ presenter?.getUserInfo()
+ }
+
+ override fun getUserInfoSuccess(userInfo: MineUserInfo) {
+ val user = userInfo.user ?: return showError("个人信息为空")
+ binding.nicknameTextView.text = user.nickName.ifBlank { "未设置昵称" }
+ binding.phoneTextView.text = "手机号:${user.phonenumber.ifBlank { "未绑定" }}"
+ binding.emailTextView.text = "邮箱:${user.email.ifBlank { "未绑定" }}"
+ binding.userIdTextView.text = "用户 ID:${user.userId}"
+ binding.userTypeTextView.text = "用户类型:${user.userType}"
+ binding.loginDateTextView.text = "最近登录:${user.loginDate.ifBlank { "暂无" }}"
+ }
+
+ override fun showError(message: String) {
+ showMessage(message)
+ }
+
+ override fun onPermissionsGranted(permissions: Array) {
+ showMessage("权限获取成功:${permissions.joinToString { it.substringAfterLast('.') }}")
+ }
+
+ override fun onPermissionsDenied(permissions: Array, permanentlyDenied: Boolean) {
+ showMessage("权限被拒绝:${permissions.joinToString { it.substringAfterLast('.') }}")
+ super.onPermissionsDenied(permissions, permanentlyDenied)
+ }
+
+ companion object {
+ private const val REQUEST_CAMERA_AND_AUDIO = 1001
+ private const val REQUEST_LOCATION = 1002
+ }
+
+ /** 保存最近一次选择结果,再次打开选择器时用于回显。 */
+ private var selectedDateTime: Long = Calendar.getInstance().timeInMillis
+
+}
+//@Preview(showBackground = true, name = "Test")
+//@Composable
+//fun UserInfo() {
+// Column {
+// Text("张三")
+// Text("手机号:13800138000")
+//
+// Button(
+// onClick = {}
+// ) {
+// Text("修改")
+// }
+// }
+//}
\ No newline at end of file
diff --git a/app/src/main/java/com/juhabi/birds/mine/model/MineUserInfo.kt b/app/src/main/java/com/juhabi/birds/mine/model/MineUserInfo.kt
new file mode 100644
index 0000000..20c6acf
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/mine/model/MineUserInfo.kt
@@ -0,0 +1,24 @@
+package com.juhabi.birds.mine.model
+
+data class MineUserInfo(
+ val user: User? = null,
+ val permissions: List = emptyList(),
+ val roles: List = emptyList(),
+ val url: String? = null
+) {
+ data class User(
+ val userId: String = "",
+ val tenantId: String = "",
+ val userName: String? = null,
+ val nickName: String = "",
+ val userType: String = "",
+ val email: String = "",
+ val phonenumber: String = "",
+ val sex: String = "",
+ val avatar: String? = null,
+ val status: String = "",
+ val loginIp: String = "",
+ val loginDate: String = "",
+ val createTime: String = ""
+ )
+}
diff --git a/app/src/main/java/com/juhabi/birds/mine/mvp/MineContract.kt b/app/src/main/java/com/juhabi/birds/mine/mvp/MineContract.kt
new file mode 100644
index 0000000..802b874
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/mine/mvp/MineContract.kt
@@ -0,0 +1,26 @@
+package com.juhabi.birds.mine.mvp
+
+import com.juhabi.birds.base.IView
+import com.juhabi.birds.main.model.AppVersionInfo
+import com.juhabi.birds.mine.model.MineUserInfo
+
+interface MineContract {
+ interface Callback {
+ fun success(data: T)
+ fun fail(message: String)
+ }
+
+ interface DataModel {
+ fun getUserInfo(params: Map, callback: Callback)
+ fun checkVersion(
+ params: Map,
+ callback: Callback
+ )
+ fun cancel()
+ }
+
+ interface MineView : IView {
+ fun getUserInfoSuccess(userInfo: MineUserInfo)
+ fun onVersionChecked(versionInfo: AppVersionInfo)
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/mine/mvp/MineImpl.kt b/app/src/main/java/com/juhabi/birds/mine/mvp/MineImpl.kt
new file mode 100644
index 0000000..bfa92a3
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/mine/mvp/MineImpl.kt
@@ -0,0 +1,106 @@
+package com.juhabi.birds.mine.mvp
+
+import android.os.Handler
+import android.os.Looper
+import com.juhabi.birds.mine.model.MineUserInfo
+import com.juhabi.birds.main.model.AppVersionInfo
+import com.juhabi.birds.network.ApiClient
+import com.juhabi.birds.network.BaseBean
+import com.juhabi.birds.network.NetworkManager
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
+
+class MineImpl : MineContract.DataModel {
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var userInfoCall: Call>? = null
+ private var versionCall: Call>? = null
+
+ override fun getUserInfo(
+ params: Map,
+ callback: MineContract.Callback
+ ) {
+ userInfoCall?.cancel()
+ userInfoCall = ApiClient.apiService
+ .getMineUserInfo(NetworkManager.getToken(), params)
+ .also { call ->
+ call.enqueue(object : Callback> {
+ override fun onResponse(
+ call: Call>,
+ response: Response>
+ ) {
+ mainHandler.post {
+ val bean = response.body()
+ val data = bean?.data
+ if (response.isSuccessful && bean?.code == 200 && data != null) {
+ callback.success(data)
+ } else {
+ callback.fail(bean?.message?.ifBlank { null } ?: "获取个人信息失败")
+ }
+ }
+ }
+
+ override fun onFailure(call: Call>, error: Throwable) {
+ if (!call.isCanceled) {
+ mainHandler.post {
+ callback.fail(error.message ?: "网络请求失败")
+ }
+ }
+ }
+ })
+ }
+ }
+
+ override fun checkVersion(
+ params: Map,
+ callback: MineContract.Callback) {
+ versionCall?.cancel()
+ versionCall = ApiClient.apiService
+ .checkVersionCode(NetworkManager.getToken(), params)
+ .also { call ->
+ call.enqueue(object : Callback> {
+ override fun onResponse(
+ call: Call>,
+ response: Response>
+ ) {
+ mainHandler.post {
+ val bean = response.body()
+ val data = bean?.data
+ if (response.isSuccessful &&
+ bean?.code == 200 &&
+ data != null
+ ) {
+ callback.success(data)
+ } else {
+ callback.fail(
+ bean?.message?.ifBlank { null }
+ ?: "版本检测失败"
+ )
+ }
+ }
+ }
+
+ override fun onFailure(
+ call: Call>,
+ error: Throwable
+ ) {
+ if (!call.isCanceled) {
+ mainHandler.post {
+ callback.fail(
+ error.message ?: "版本检测失败"
+ )
+ }
+ }
+ }
+ })
+ }
+ }
+
+ override fun cancel() {
+ userInfoCall?.cancel()
+ userInfoCall = null
+ versionCall?.cancel()
+ versionCall = null
+ mainHandler.removeCallbacksAndMessages(null)
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/mine/mvp/MinePresenter.kt b/app/src/main/java/com/juhabi/birds/mine/mvp/MinePresenter.kt
new file mode 100644
index 0000000..daf793e
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/mine/mvp/MinePresenter.kt
@@ -0,0 +1,53 @@
+package com.juhabi.birds.mine.mvp
+
+import com.juhabi.birds.base.BasePresenter
+import com.juhabi.birds.main.model.AppVersionInfo
+import com.juhabi.birds.mine.model.MineUserInfo
+
+class MinePresenter(private val mineImpl: MineImpl) : BasePresenter() {
+
+ fun getUserInfo() {
+ val currentView = view ?: return
+ currentView.showLoading()
+ mineImpl.getUserInfo(emptyMap(), object : MineContract.Callback {
+ override fun success(data: MineUserInfo) {
+ view?.apply {
+ hideLoading()
+ getUserInfoSuccess(data)
+ }
+ }
+
+ override fun fail(message: String) {
+ view?.apply {
+ hideLoading()
+ showError(message)
+ }
+ }
+ }
+ )
+ }
+
+ fun checkVersion(currentVersion: String) {
+ val params = mapOf(
+ "currentVersion" to currentVersion,
+ "platform" to "android"
+ )
+ mineImpl.checkVersion(
+ params,
+ object : MineContract.Callback {
+ override fun success(data: AppVersionInfo) {
+ view?.onVersionChecked(data)
+ }
+
+ override fun fail(message: String) {
+ view?.showError(message)
+ }
+ }
+ )
+ }
+
+ override fun onDestroy() {
+ mineImpl.cancel()
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/network/ApiClient.kt b/app/src/main/java/com/juhabi/birds/network/ApiClient.kt
new file mode 100644
index 0000000..b86f50f
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/network/ApiClient.kt
@@ -0,0 +1,20 @@
+package com.juhabi.birds.network
+
+import com.juhabi.birds.AppConfig
+
+/**
+ * 全局网络入口。
+ */
+object ApiClient {
+ private val networkManager: NetworkManager by lazy {
+ NetworkManager.create(
+ NetworkConfig(
+ baseUrl = AppConfig.BASE_URL,
+ enableLogging = true
+ )
+ )
+ }
+
+ val apiService: ApiService
+ get() = networkManager.apiService
+}
diff --git a/app/src/main/java/com/juhabi/birds/network/ApiResult.kt b/app/src/main/java/com/juhabi/birds/network/ApiResult.kt
new file mode 100644
index 0000000..75e1166
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/network/ApiResult.kt
@@ -0,0 +1,14 @@
+package com.juhabi.birds.network
+
+sealed interface ApiResult {
+ data class Success(val data: T) : ApiResult
+ data class Failure(val error: Throwable) : ApiResult
+}
+
+suspend inline fun safeApiCall(crossinline request: suspend () -> T): ApiResult {
+ return try {
+ ApiResult.Success(request())
+ } catch (error: Exception) {
+ ApiResult.Failure(error)
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/network/ApiService.kt b/app/src/main/java/com/juhabi/birds/network/ApiService.kt
new file mode 100644
index 0000000..2b1924e
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/network/ApiService.kt
@@ -0,0 +1,27 @@
+package com.juhabi.birds.network
+
+import com.juhabi.birds.login.model.LoginUser
+import com.juhabi.birds.main.model.AppVersionInfo
+import com.juhabi.birds.mine.model.MineUserInfo
+import okhttp3.RequestBody
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.HeaderMap
+import retrofit2.http.GET
+import retrofit2.http.QueryMap
+import retrofit2.http.POST
+
+/**
+ * 项目统一接口定义。所有业务接口地址集中维护在这里。
+ */
+interface ApiService {
+ @POST("auth/login")
+ fun login(@HeaderMap headers: Map, @Body requestBody: RequestBody): Call>
+
+ @GET("app/v1/getUserInfo")
+ fun getMineUserInfo(@HeaderMap headers: Map, @QueryMap params: Map): Call>
+
+
+ @GET("app/v1/checkVersion")
+ fun checkVersionCode(@HeaderMap headers: Map, @QueryMap params: Map): Call>
+}
diff --git a/app/src/main/java/com/juhabi/birds/network/BaseBean.kt b/app/src/main/java/com/juhabi/birds/network/BaseBean.kt
new file mode 100644
index 0000000..36d2967
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/network/BaseBean.kt
@@ -0,0 +1,25 @@
+package com.juhabi.birds.network
+
+import com.google.gson.Gson
+import java.io.Serializable
+
+data class BaseBean(
+ val code: Int = -1,
+ val msg: String = "",
+ val data: T? = null,
+ val rows: T? = null,
+ val pages: Int = 0,
+ val total: Int = 0,
+ val size: Int = 0,
+ val currentPage: Int = 0
+) : Serializable {
+ val isSuccess: Boolean get() = code == SUCCESS_CODE
+ val message: String get() = msg
+ fun dataString(): String {
+ return data?.let { Gson().toJson(it) }.orEmpty()
+ }
+
+ companion object {
+ const val SUCCESS_CODE = 0
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/network/NetworkConfig.kt b/app/src/main/java/com/juhabi/birds/network/NetworkConfig.kt
new file mode 100644
index 0000000..f6f8eb7
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/network/NetworkConfig.kt
@@ -0,0 +1,18 @@
+package com.juhabi.birds.network
+
+import okhttp3.Interceptor
+import java.util.concurrent.TimeUnit
+
+data class NetworkConfig(
+ val baseUrl: String,
+ val connectTimeout: Long = 30,
+ val readTimeout: Long = 30,
+ val writeTimeout: Long = 30,
+ val timeoutUnit: TimeUnit = TimeUnit.SECONDS,
+ val enableLogging: Boolean = false,
+ val interceptors: List = emptyList()
+) {
+ init {
+ require(baseUrl.endsWith('/')) { "baseUrl must end with '/'" }
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/network/NetworkManager.kt b/app/src/main/java/com/juhabi/birds/network/NetworkManager.kt
new file mode 100644
index 0000000..692808d
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/network/NetworkManager.kt
@@ -0,0 +1,82 @@
+package com.juhabi.birds.network
+
+import com.juhabi.birds.AppConfig
+import com.google.gson.GsonBuilder
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.logging.HttpLoggingInterceptor
+import org.json.JSONObject
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+
+class NetworkManager private constructor(config: NetworkConfig) {
+ private val retrofit: Retrofit
+
+ val apiService: ApiService by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
+ retrofit.create(ApiService::class.java)
+ }
+
+ init {
+ val clientBuilder = OkHttpClient.Builder()
+ .connectTimeout(config.connectTimeout, config.timeoutUnit)
+ .readTimeout(config.readTimeout, config.timeoutUnit)
+ .writeTimeout(config.writeTimeout, config.timeoutUnit)
+ .retryOnConnectionFailure(true)
+
+ config.interceptors.forEach(clientBuilder::addInterceptor)
+ if (config.enableLogging) {
+ clientBuilder.addInterceptor(
+ HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)
+ )
+ }
+
+ val gson = GsonBuilder().setDateFormat("yyyy-MM-dd HH:mm:ss").create()
+ retrofit = Retrofit.Builder()
+ .baseUrl(config.baseUrl)
+ .client(clientBuilder.build())
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build()
+ }
+
+ fun create(service: Class): T {
+ return retrofit.create(service)
+ }
+
+ inline fun create(): T {
+ return create(T::class.java)
+ }
+
+ companion object {
+ private val JSON = "application/json; charset=utf-8".toMediaType()
+ private val FORM_DATA = "multipart/form-data".toMediaType()
+
+ fun create(config: NetworkConfig): NetworkManager {
+ return NetworkManager(config)
+ }
+
+ /** 与 WaterProject 的 NetWorkManager.getToken() 保持一致,统一生成公共请求头。 */
+ fun getToken(): Map {
+ return buildMap {
+ put("clientId", AppConfig.APP_CLIENT_ID)
+ put("content-language", AppConfig.CONTENT_LANGUAGE)
+ AppConfig.userToken.takeIf(String::isNotBlank)?.let { token ->
+ put("Authorization", "Bearer $token")
+ }
+ }
+ }
+
+ fun toRequestBody(json: JSONObject): RequestBody {
+ return toRequestBody(json.toString())
+ }
+
+ fun toRequestBody(json: String): RequestBody {
+ return json.toRequestBody(JSON)
+ }
+
+ fun formBodies(values: Map): Map {
+ return values.mapValues { (_, value) -> value.orEmpty().toRequestBody(FORM_DATA) }
+ }
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/network/TokenManager.kt b/app/src/main/java/com/juhabi/birds/network/TokenManager.kt
new file mode 100644
index 0000000..d26382c
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/network/TokenManager.kt
@@ -0,0 +1,25 @@
+package com.juhabi.birds.network
+
+import android.content.Context
+import com.juhabi.birds.AppConfig
+import com.juhabi.birds.util.SharedPreferencesUtil
+
+object TokenManager {
+ private const val KEY_USER_TOKEN = "user_token"
+
+ fun save(context: Context, token: String) {
+ SharedPreferencesUtil.putString(context, KEY_USER_TOKEN, token)
+ AppConfig.userToken = token
+ }
+
+ fun get(context: Context): String {
+ val token = SharedPreferencesUtil.getString(context, KEY_USER_TOKEN)
+ AppConfig.userToken = token
+ return token
+ }
+
+ fun clear(context: Context) {
+ SharedPreferencesUtil.remove(context, KEY_USER_TOKEN)
+ AppConfig.userToken = ""
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/user/model/UserEntity.kt b/app/src/main/java/com/juhabi/birds/user/model/UserEntity.kt
new file mode 100644
index 0000000..f18e56b
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/user/model/UserEntity.kt
@@ -0,0 +1,15 @@
+package com.juhabi.birds.user.model
+
+/**
+ * 临时用户实体,用户接口确定后可根据实际业务替换或扩展字段。
+ */
+data class UserEntity(
+ val userId: String = "",
+ val userName: String = "",
+ val nickName: String = "",
+ val phoneNumber: String = "",
+ val email: String = "",
+ val avatar: String = "",
+ val sex: String = "",
+ val tenantId: String = ""
+)
diff --git a/app/src/main/java/com/juhabi/birds/util/LubanCompressUtil.kt b/app/src/main/java/com/juhabi/birds/util/LubanCompressUtil.kt
new file mode 100644
index 0000000..e4eb72d
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/util/LubanCompressUtil.kt
@@ -0,0 +1,65 @@
+package com.juhabi.birds.util
+
+import android.content.Context
+import java.io.File
+import java.util.Locale
+import top.zibin.luban.Luban
+import top.zibin.luban.OnCompressListener
+
+/** 使用 Luban 对图片文件进行异步压缩。 */
+object LubanCompressUtil {
+
+ private const val DEFAULT_IGNORE_SIZE_KB = 100
+
+ interface CompressCallback {
+ /** 图片压缩成功。 */
+ fun success(file: File)
+
+ /** 图片压缩失败。 */
+ fun fail(failMsg: String)
+ }
+
+ /** 兼容 WaterProject 中的单例调用方式。 */
+ fun getInstance(): LubanCompressUtil {
+ return this
+ }
+
+ /**
+ * 压缩指定图片。
+ *
+ * 小于 100KB 的图片保持原样,GIF 图片不参与压缩。
+ */
+ fun getCompressFile(
+ context: Context,
+ oldFile: File,
+ callback: CompressCallback?
+ ) {
+ if (!oldFile.exists() || !oldFile.isFile) {
+ callback?.fail("图片文件不存在")
+ return
+ }
+
+ Luban.with(context.applicationContext)
+ .load(oldFile)
+ .ignoreBy(DEFAULT_IGNORE_SIZE_KB)
+ .filter { path ->
+ path.isNotBlank() && !path.lowercase(Locale.ROOT).endsWith(".gif")
+ }
+ .setCompressListener(object : OnCompressListener {
+ /** 压缩开始。 */
+ override fun onStart() {
+ }
+
+ /** 返回压缩后的图片文件。 */
+ override fun onSuccess(file: File) {
+ callback?.success(file)
+ }
+
+ /** 将压缩异常转换为业务错误消息。 */
+ override fun onError(error: Throwable) {
+ callback?.fail(error.message ?: "图片压缩失败")
+ }
+ })
+ .launch()
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/util/SharedPreferencesUtil.kt b/app/src/main/java/com/juhabi/birds/util/SharedPreferencesUtil.kt
new file mode 100644
index 0000000..dc5c912
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/util/SharedPreferencesUtil.kt
@@ -0,0 +1,112 @@
+package com.juhabi.birds.util
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.google.gson.Gson
+import com.google.gson.JsonSyntaxException
+
+/**
+ * 使用 SharedPreferences 存储轻量级本地数据。
+ *
+ * 复杂对象会序列化为 JSON,仅适合存储用户信息等体积较小的数据。
+ */
+object SharedPreferencesUtil {
+ const val DEFAULT_PREFERENCES_NAME = "kot_mvp_user"
+
+ private val gson = Gson()
+
+ private fun preferences(
+ context: Context,
+ preferencesName: String
+ ): SharedPreferences = context.applicationContext.getSharedPreferences(
+ preferencesName,
+ Context.MODE_PRIVATE
+ )
+
+ fun putString(
+ context: Context,
+ key: String,
+ value: String,
+ preferencesName: String = DEFAULT_PREFERENCES_NAME
+ ) = preferences(context, preferencesName).edit().putString(key, value).apply()
+
+ fun getString(
+ context: Context,
+ key: String,
+ defaultValue: String = "",
+ preferencesName: String = DEFAULT_PREFERENCES_NAME
+ ): String = preferences(context, preferencesName).getString(key, defaultValue) ?: defaultValue
+
+ fun putInt(context: Context, key: String, value: Int, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).edit().putInt(key, value).apply()
+
+ fun getInt(context: Context, key: String, defaultValue: Int = 0, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).getInt(key, defaultValue)
+
+ fun putLong(context: Context, key: String, value: Long, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).edit().putLong(key, value).apply()
+
+ fun getLong(context: Context, key: String, defaultValue: Long = 0L, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).getLong(key, defaultValue)
+
+ fun putFloat(context: Context, key: String, value: Float, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).edit().putFloat(key, value).apply()
+
+ fun getFloat(context: Context, key: String, defaultValue: Float = 0f, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).getFloat(key, defaultValue)
+
+ fun putBoolean(context: Context, key: String, value: Boolean, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).edit().putBoolean(key, value).apply()
+
+ fun getBoolean(context: Context, key: String, defaultValue: Boolean = false, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).getBoolean(key, defaultValue)
+
+ fun putStringSet(
+ context: Context,
+ key: String,
+ value: Set,
+ preferencesName: String = DEFAULT_PREFERENCES_NAME
+ ) = preferences(context, preferencesName).edit().putStringSet(key, value).apply()
+
+ fun getStringSet(
+ context: Context,
+ key: String,
+ defaultValue: Set = emptySet(),
+ preferencesName: String = DEFAULT_PREFERENCES_NAME
+ ): Set = preferences(context, preferencesName)
+ .getStringSet(key, defaultValue)
+ ?.toSet()
+ ?: defaultValue
+
+ fun putObject(
+ context: Context,
+ key: String,
+ value: Any,
+ preferencesName: String = DEFAULT_PREFERENCES_NAME
+ ) = putString(context, key, gson.toJson(value), preferencesName)
+
+ fun getObject(
+ context: Context,
+ key: String,
+ clazz: Class,
+ preferencesName: String = DEFAULT_PREFERENCES_NAME
+ ): T? {
+ val json = getString(context, key, preferencesName = preferencesName)
+ if (json.isBlank()) return null
+
+ return try {
+ gson.fromJson(json, clazz)
+ } catch (_: JsonSyntaxException) {
+ null
+ }
+ }
+
+ fun contains(context: Context, key: String, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).contains(key)
+
+ fun remove(context: Context, key: String, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).edit().remove(key).apply()
+
+ fun clear(context: Context, preferencesName: String = DEFAULT_PREFERENCES_NAME) =
+ preferences(context, preferencesName).edit().clear().apply()
+}
diff --git a/app/src/main/java/com/juhabi/birds/util/ToastUtil.kt b/app/src/main/java/com/juhabi/birds/util/ToastUtil.kt
new file mode 100644
index 0000000..243ebeb
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/util/ToastUtil.kt
@@ -0,0 +1,43 @@
+package com.juhabi.birds.util
+
+import android.content.Context
+import android.widget.Toast
+
+/** Toast 统一管理工具。 */
+object ToastUtil {
+
+ var isShow = true
+
+ private var toast: Toast? = null
+
+ fun showShort(context: Context, message: CharSequence) {
+ show(context, message, Toast.LENGTH_SHORT)
+ }
+
+ fun showShort(context: Context, message: Int) {
+ show(context, message.toString(), Toast.LENGTH_SHORT)
+ }
+
+ fun showLong(context: Context, message: CharSequence) {
+ show(context, message, Toast.LENGTH_LONG)
+ }
+
+ fun showLong(context: Context, message: Int) {
+ show(context, message.toString(), Toast.LENGTH_LONG)
+ }
+
+ fun show(context: Context, message: CharSequence, duration: Int) {
+ val currentToast = toast
+ if (currentToast == null) {
+ toast = Toast.makeText(context, message, duration)
+ } else {
+ currentToast.setText(message)
+ currentToast.duration = duration
+ }
+ toast?.show()
+ }
+
+ fun show(context: Context, message: Int, duration: Int) {
+ show(context, message.toString(), duration)
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/util/click/ClickUtil.kt b/app/src/main/java/com/juhabi/birds/util/click/ClickUtil.kt
new file mode 100644
index 0000000..5f603eb
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/util/click/ClickUtil.kt
@@ -0,0 +1,20 @@
+package com.juhabi.birds.util.click
+
+import android.graphics.Rect
+import android.view.TouchDelegate
+import android.view.View
+
+object ClickUtil {
+ /** 将 [view] 的触摸区域向四周分别扩大 [size] 像素。 */
+ @JvmStatic
+ fun addClickPart(view: View, size: Int) {
+ val parent = view.parent as? View ?: return
+ val expandSize = size.coerceAtLeast(0)
+ parent.post {
+ val delegateArea = Rect()
+ view.getHitRect(delegateArea)
+ delegateArea.inset(-expandSize, -expandSize)
+ parent.touchDelegate = TouchDelegate(delegateArea, view)
+ }
+ }
+}
diff --git a/app/src/main/java/com/juhabi/birds/util/data/IDCardValidate.kt b/app/src/main/java/com/juhabi/birds/util/data/IDCardValidate.kt
new file mode 100644
index 0000000..f78ba8c
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/util/data/IDCardValidate.kt
@@ -0,0 +1,81 @@
+package com.juhabi.birds.util.data
+
+import java.time.DateTimeException
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.time.format.ResolverStyle
+
+/** 校验中国大陆地区的 15 位和 18 位居民身份证号码。 */
+object IDCardValidate {
+ private val weights = intArrayOf(7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2)
+ private val verifyCodes = charArrayOf('1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2')
+ private val areaCodes = setOf(
+ "11", "12", "13", "14", "15",
+ "21", "22", "23",
+ "31", "32", "33", "34", "35", "36", "37",
+ "41", "42", "43", "44", "45", "46",
+ "50", "51", "52", "53", "54",
+ "61", "62", "63", "64", "65",
+ "71", "81", "82", "91"
+ )
+ private val birthDateFormatter = DateTimeFormatter
+ .ofPattern("uuuuMMdd")
+ .withResolverStyle(ResolverStyle.STRICT)
+ private val earliestBirthDate = LocalDate.of(1900, 1, 1)
+
+ @JvmStatic
+ fun validate_effective(idcardNumber: String?): Boolean = validateEffective(idcardNumber)
+
+ /**
+ * 为兼容 Water 项目而保留,第二个参数不会影响校验结果。
+ */
+ @JvmStatic
+ @Suppress("UNUSED_PARAMETER")
+ fun validate_effective(idcardNumber: String?, isreturn_AutoCard: Boolean): Boolean =
+ validateEffective(idcardNumber)
+
+ @JvmStatic
+ fun validateEffective(idCardNumber: String?): Boolean {
+ val source = idCardNumber?.trim()?.uppercase() ?: return false
+ val cardNumber = when (source.length) {
+ OLD_CARD_NUMBER_LENGTH -> convertToNewCardNumber(source) ?: return false
+ NEW_CARD_NUMBER_LENGTH -> source
+ else -> return false
+ }
+
+ if (!cardNumber.take(17).all(Char::isDigit)) return false
+ if (cardNumber.last() != 'X' && !cardNumber.last().isDigit()) return false
+ if (cardNumber.substring(0, 2) !in areaCodes) return false
+ if (!hasValidBirthDate(cardNumber.substring(6, 14))) return false
+ return cardNumber.last() == calculateVerifyCode(cardNumber)
+ }
+
+ private fun convertToNewCardNumber(oldCardNumber: String): String? {
+ if (!oldCardNumber.all(Char::isDigit)) return null
+ val firstSeventeen = buildString(17) {
+ append(oldCardNumber, 0, 6)
+ append("19")
+ append(oldCardNumber, 6, oldCardNumber.length)
+ }
+ return firstSeventeen + calculateVerifyCode(firstSeventeen)
+ }
+
+ private fun hasValidBirthDate(value: String): Boolean {
+ val birthDate = try {
+ LocalDate.parse(value, birthDateFormatter)
+ } catch (_: DateTimeException) {
+ return false
+ }
+ return birthDate.isAfter(earliestBirthDate) && birthDate.isBefore(LocalDate.now())
+ }
+
+ private fun calculateVerifyCode(cardNumber: CharSequence): Char {
+ val sum = weights.indices.sumOf { index ->
+ (cardNumber[index] - '0') * weights[index]
+ }
+ return verifyCodes[sum % 11]
+ }
+
+ private const val NEW_CARD_NUMBER_LENGTH = 18
+ private const val OLD_CARD_NUMBER_LENGTH = 15
+}
diff --git a/app/src/main/java/com/juhabi/birds/util/data/PhonePatternUtil.kt b/app/src/main/java/com/juhabi/birds/util/data/PhonePatternUtil.kt
new file mode 100644
index 0000000..0d2e835
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/util/data/PhonePatternUtil.kt
@@ -0,0 +1,13 @@
+package com.juhabi.birds.util.data
+
+object PhonePatternUtil {
+ private val phonePattern = Regex("^1[3-9]\\d{9}$")
+
+ @JvmStatic
+ fun phoneReg(phoneNumber: String?): Boolean = isValidPhoneNumber(phoneNumber)
+
+ /** 校验中国大陆地区的 11 位手机号码。 */
+ @JvmStatic
+ fun isValidPhoneNumber(phoneNumber: String?): Boolean =
+ phoneNumber != null && phonePattern.matches(phoneNumber)
+}
diff --git a/app/src/main/java/com/juhabi/birds/util/file/PreviewFileUtil.kt b/app/src/main/java/com/juhabi/birds/util/file/PreviewFileUtil.kt
new file mode 100644
index 0000000..a7c0b21
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/util/file/PreviewFileUtil.kt
@@ -0,0 +1,67 @@
+package com.juhabi.birds.util.file
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import androidx.core.content.FileProvider
+import java.io.File
+
+object PreviewFileUtil {
+ @JvmStatic
+ fun previewFiles(context: Context, suffix: String?, fileUrl: String?): Boolean {
+ if (fileUrl.isNullOrBlank()) return false
+ val uri = when {
+ fileUrl.startsWith("content://", ignoreCase = true) -> Uri.parse(fileUrl)
+ fileUrl.startsWith("http://", ignoreCase = true) ||
+ fileUrl.startsWith("https://", ignoreCase = true) -> Uri.parse(fileUrl)
+ else -> fileToContentUri(context, File(fileUrl)) ?: return false
+ }
+ return preview(context, uri, resolveMimeType(suffix, uri))
+ }
+
+ @JvmStatic
+ fun preview(context: Context, uri: Uri, mimeType: String? = null): Boolean {
+ val resolvedMimeType = mimeType
+ ?.takeIf { it.isNotBlank() }
+ ?: context.contentResolver.getType(uri)
+ ?: resolveMimeType(null, uri)
+ val intent = Intent(Intent.ACTION_VIEW)
+ .setDataAndType(uri, resolvedMimeType)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ if (context !is Activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ return try {
+ context.startActivity(Intent.createChooser(intent, null).apply {
+ if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ })
+ true
+ } catch (_: ActivityNotFoundException) {
+ false
+ }
+ }
+
+ private fun fileToContentUri(context: Context, file: File): Uri? {
+ if (!file.exists() || !file.isFile) return null
+ return try {
+ FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+
+ private fun resolveMimeType(suffix: String?, uri: Uri): String {
+ val extension = suffix
+ ?.substringAfterLast('.')
+ ?.trim()
+ ?.lowercase()
+ ?.takeIf { it.isNotBlank() }
+ ?: MimeTypeMap.getFileExtensionFromUrl(uri.toString()).lowercase()
+ return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
+ ?: DEFAULT_MIME_TYPE
+ }
+
+ private const val DEFAULT_MIME_TYPE = "*/*"
+}
diff --git a/app/src/main/java/com/juhabi/birds/util/manager/ActivityManager.kt b/app/src/main/java/com/juhabi/birds/util/manager/ActivityManager.kt
new file mode 100644
index 0000000..1ec63cb
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/util/manager/ActivityManager.kt
@@ -0,0 +1,60 @@
+package com.juhabi.birds.util.manager
+
+import android.app.Activity
+import java.util.Collections
+import java.util.LinkedHashMap
+
+object ActivityManager {
+ private val activities = Collections.synchronizedMap(LinkedHashMap())
+
+ /** 返回 Activity 集合的快照,防止调用方直接修改内部集合。 */
+ @JvmStatic
+ fun getActivitys(): Map = synchronized(activities) {
+ LinkedHashMap(activities)
+ }
+
+ @JvmStatic
+ fun getActivity(key: String): Activity? = activities[key]
+
+ @JvmStatic
+ fun addActivity(value: Activity, key: String) {
+ activities[key] = value
+ }
+
+ @JvmStatic
+ fun removeActivity(key: String) {
+ activities.remove(key)
+ }
+
+ @JvmStatic
+ fun removeAllActivity() {
+ finishAndRemove { _, _ -> true }
+ }
+
+ @JvmStatic
+ fun removeAllActivityExceptMain() {
+ finishAndRemove { key, activity ->
+ !key.contains(MAIN_ACTIVITY_NAME) && activity.javaClass.simpleName != MAIN_ACTIVITY_NAME
+ }
+ }
+
+ @JvmStatic
+ fun removeActivityByTag(tag: String) {
+ if (tag.isBlank()) return
+ finishAndRemove { key, _ -> key.contains(tag) }
+ }
+
+ private fun finishAndRemove(predicate: (String, Activity) -> Boolean) {
+ val targets = synchronized(activities) {
+ activities.filter { (key, activity) -> predicate(key, activity) }
+ }
+ targets.forEach { (key, activity) ->
+ if (!activity.isFinishing && !activity.isDestroyed) {
+ activity.runOnUiThread { activity.finish() }
+ }
+ activities.remove(key, activity)
+ }
+ }
+
+ private const val MAIN_ACTIVITY_NAME = "MainActivity"
+}
diff --git a/app/src/main/java/com/juhabi/birds/util/manager/UserInfoManager.kt b/app/src/main/java/com/juhabi/birds/util/manager/UserInfoManager.kt
new file mode 100644
index 0000000..e441784
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/util/manager/UserInfoManager.kt
@@ -0,0 +1,85 @@
+package com.juhabi.birds.util.manager
+
+import android.content.Context
+import com.juhabi.birds.MyApplication
+import com.juhabi.birds.network.TokenManager
+import com.juhabi.birds.user.model.UserEntity
+import com.juhabi.birds.util.SharedPreferencesUtil
+
+/** 管理当前用户的内存缓存和轻量级本地持久化数据。 */
+object UserInfoManager {
+ @Volatile
+ private var cachedUser: UserEntity? = null
+
+ @JvmStatic
+ fun getInstance(): UserInfoManager = this
+
+ /** 应用启动时恢复本地保存的 token 和用户信息。 */
+ @JvmStatic
+ fun initialize(context: Context) {
+ TokenManager.get(context)
+ cachedUser = SharedPreferencesUtil.getObject(
+ context,
+ KEY_USER_INFO,
+ UserEntity::class.java
+ )
+ }
+
+ @JvmStatic
+ fun getUserInfo(): UserEntity? = getUserInfo(MyApplication.appContext)
+
+ @JvmStatic
+ fun getUserInfo(context: Context): UserEntity? {
+ cachedUser?.let { return it }
+ return SharedPreferencesUtil.getObject(
+ context,
+ KEY_USER_INFO,
+ UserEntity::class.java
+ )?.also { cachedUser = it }
+ }
+
+ @JvmStatic
+ fun setUserInfo(userInfo: UserEntity) {
+ setUserInfo(MyApplication.appContext, userInfo)
+ }
+
+ @JvmStatic
+ fun setUserInfo(context: Context, userInfo: UserEntity) {
+ cachedUser = userInfo
+ SharedPreferencesUtil.putObject(context, KEY_USER_INFO, userInfo)
+ }
+
+ /** 登录成功后统一保存用户信息和 token。 */
+ @JvmStatic
+ fun saveLogin(userInfo: UserEntity, token: String) {
+ saveLogin(MyApplication.appContext, userInfo, token)
+ }
+
+ @JvmStatic
+ fun saveLogin(context: Context, userInfo: UserEntity, token: String) {
+ setUserInfo(context, userInfo)
+ TokenManager.save(context, token)
+ SharedPreferencesUtil.putBoolean(context, KEY_USER_LOGIN, token.isNotBlank())
+ }
+
+ @JvmStatic
+ fun isLoggedIn(): Boolean = isLoggedIn(MyApplication.appContext)
+
+ @JvmStatic
+ fun isLoggedIn(context: Context): Boolean {
+ val hasToken = TokenManager.get(context).isNotBlank()
+ return hasToken && SharedPreferencesUtil.getBoolean(context, KEY_USER_LOGIN, hasToken)
+ }
+
+ /** 仅清除账号相关数据,不影响应用中的其他本地配置。 */
+ @JvmStatic
+ fun clearUser(context: Context) {
+ cachedUser = null
+ TokenManager.clear(context)
+ SharedPreferencesUtil.remove(context, KEY_USER_INFO)
+ SharedPreferencesUtil.remove(context, KEY_USER_LOGIN)
+ }
+
+ private const val KEY_USER_INFO = "user_info"
+ private const val KEY_USER_LOGIN = "user_login"
+}
diff --git a/app/src/main/java/com/juhabi/birds/utils/AppUpdateManager.kt b/app/src/main/java/com/juhabi/birds/utils/AppUpdateManager.kt
new file mode 100644
index 0000000..38c4f80
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/utils/AppUpdateManager.kt
@@ -0,0 +1,250 @@
+package com.juhabi.birds.utils
+
+import android.Manifest
+import android.app.Activity
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Environment
+import androidx.appcompat.app.AlertDialog
+import androidx.core.app.NotificationCompat
+import androidx.core.content.FileProvider
+import com.juhabi.birds.main.model.AppVersionInfo
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.util.concurrent.Executors
+
+class AppUpdateManager(
+ private val activity: Activity,
+ private val messageCallback: (String) -> Unit
+) {
+ private var pendingDownloadUrl: String? = null
+
+ fun checkAndUpdate(
+ currentVersion: String,
+ versionInfo: AppVersionInfo
+ ) {
+ val latestVersion = versionInfo.latestVersion.ifBlank {
+ versionInfo.versionName
+ }
+ if (!isNewerVersion(currentVersion, latestVersion)) {
+ messageCallback("当前已是最新版本")
+ return
+ }
+ showUpdateDialog(latestVersion, versionInfo)
+ }
+
+ fun onRequestPermissionsResult(
+ requestCode: Int,
+ grantResults: IntArray
+ ): Boolean {
+ if (requestCode != REQUEST_NOTIFICATIONS) return false
+
+ val downloadUrl = pendingDownloadUrl
+ pendingDownloadUrl = null
+ val permissionGranted = grantResults.firstOrNull() ==
+ PackageManager.PERMISSION_GRANTED
+ if (permissionGranted && !downloadUrl.isNullOrBlank()) {
+ downloadApk(downloadUrl)
+ } else {
+ messageCallback("需要通知权限才能显示下载进度")
+ }
+ return true
+ }
+
+ private fun showUpdateDialog(
+ latestVersion: String,
+ versionInfo: AppVersionInfo
+ ) {
+ AlertDialog.Builder(activity)
+ .setTitle("发现新版本 $latestVersion")
+ .setMessage(
+ versionInfo.releaseNotes.ifBlank {
+ "是否立即下载更新?"
+ }
+ )
+ .setNegativeButton("取消", null)
+ .setPositiveButton("下载") { _, _ ->
+ downloadApk(versionInfo.downloadUrl)
+ }
+ .setCancelable(!versionInfo.forceUpdate)
+ .show()
+ }
+
+ private fun isNewerVersion(
+ currentVersion: String,
+ latestVersion: String
+ ): Boolean {
+ val currentParts = parseVersion(currentVersion)
+ val latestParts = parseVersion(latestVersion)
+ val partCount = maxOf(currentParts.size, latestParts.size)
+
+ for (index in 0 until partCount) {
+ val currentPart = currentParts.getOrElse(index) { 0 }
+ val latestPart = latestParts.getOrElse(index) { 0 }
+ if (currentPart != latestPart) {
+ return latestPart > currentPart
+ }
+ }
+ return false
+ }
+
+ private fun parseVersion(version: String): List {
+ return version
+ .split('.', '-', '_')
+ .map { part -> part.toIntOrNull() ?: 0 }
+ }
+
+ private fun downloadApk(url: String) {
+ if (url.isBlank()) {
+ messageCallback("下载地址为空")
+ return
+ }
+ if (!hasNotificationPermission()) {
+ pendingDownloadUrl = url
+ activity.requestPermissions(
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS),
+ REQUEST_NOTIFICATIONS
+ )
+ return
+ }
+
+ val notificationManager = activity.getSystemService(
+ NotificationManager::class.java
+ )
+ createDownloadChannel(notificationManager)
+ Executors.newSingleThreadExecutor().execute {
+ runCatching {
+ downloadFile(url, notificationManager)
+ }.onSuccess { apkFile ->
+ activity.runOnUiThread {
+ showDownloadComplete(notificationManager)
+ installApk(apkFile)
+ }
+ }.onFailure { error ->
+ activity.runOnUiThread {
+ messageCallback("下载失败:${error.message}")
+ }
+ }
+ }
+ }
+
+ private fun hasNotificationPermission(): Boolean {
+ return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ activity.checkSelfPermission(
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
+ private fun createDownloadChannel(manager: NotificationManager) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+
+ manager.createNotificationChannel(
+ NotificationChannel(
+ DOWNLOAD_CHANNEL_ID,
+ "应用更新",
+ NotificationManager.IMPORTANCE_LOW
+ )
+ )
+ }
+
+ private fun downloadFile(
+ url: String,
+ notificationManager: NotificationManager
+ ): File {
+ val response = OkHttpClient().newCall(
+ Request.Builder().url(url).build()
+ ).execute()
+ if (!response.isSuccessful) error("HTTP ${response.code}")
+
+ val body = response.body ?: error("下载响应为空")
+ val apkFile = File(
+ activity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
+ UPDATE_APK_NAME
+ )
+ body.byteStream().use { input ->
+ FileOutputStream(apkFile).use { output ->
+ copyDownload(
+ input,
+ output,
+ body.contentLength(),
+ notificationManager
+ )
+ }
+ }
+ return apkFile
+ }
+
+ private fun copyDownload(
+ input: InputStream,
+ output: FileOutputStream,
+ totalBytes: Long,
+ notificationManager: NotificationManager
+ ) {
+ val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
+ var downloadedBytes = 0L
+ var readCount: Int
+
+ while (input.read(buffer).also { readCount = it } != -1) {
+ output.write(buffer, 0, readCount)
+ downloadedBytes += readCount
+ if (totalBytes > 0) {
+ val progress = (downloadedBytes * 100 / totalBytes).toInt()
+ showDownloadProgress(notificationManager, progress)
+ }
+ }
+ }
+
+ private fun showDownloadProgress(
+ manager: NotificationManager,
+ progress: Int
+ ) {
+ val notification = NotificationCompat.Builder(
+ activity,
+ DOWNLOAD_CHANNEL_ID
+ )
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentTitle("正在下载更新")
+ .setOnlyAlertOnce(true)
+ .setProgress(100, progress, false)
+ .build()
+ manager.notify(DOWNLOAD_NOTIFICATION_ID, notification)
+ }
+
+ private fun showDownloadComplete(manager: NotificationManager) {
+ val notification = NotificationCompat.Builder(
+ activity,
+ DOWNLOAD_CHANNEL_ID
+ )
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setContentTitle("下载完成")
+ .build()
+ manager.notify(DOWNLOAD_NOTIFICATION_ID, notification)
+ }
+
+ private fun installApk(file: File) {
+ val uri = FileProvider.getUriForFile(
+ activity,
+ "${activity.packageName}.fileprovider",
+ file
+ )
+ val installIntent = Intent(Intent.ACTION_VIEW)
+ .setDataAndType(uri, APK_MIME_TYPE)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ activity.startActivity(installIntent)
+ }
+
+ private companion object {
+ const val REQUEST_NOTIFICATIONS = 1003
+ const val DOWNLOAD_NOTIFICATION_ID = 10086
+ const val DOWNLOAD_CHANNEL_ID = "app_update"
+ const val DOWNLOAD_BUFFER_SIZE = 8 * 1024
+ const val UPDATE_APK_NAME = "kot-update.apk"
+ const val APK_MIME_TYPE = "application/vnd.android.package-archive"
+ }
+}
diff --git a/app/src/main/res/color/bottom_nav_item_color.xml b/app/src/main/res/color/bottom_nav_item_color.xml
new file mode 100644
index 0000000..aeb73c4
--- /dev/null
+++ b/app/src/main/res/color/bottom_nav_item_color.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_constraint_avatar.xml b/app/src/main/res/drawable/bg_constraint_avatar.xml
new file mode 100644
index 0000000..476109a
--- /dev/null
+++ b/app/src/main/res/drawable/bg_constraint_avatar.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_constraint_progress.xml b/app/src/main/res/drawable/bg_constraint_progress.xml
new file mode 100644
index 0000000..e840525
--- /dev/null
+++ b/app/src/main/res/drawable/bg_constraint_progress.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/app/src/main/res/drawable/bg_constraint_tag.xml b/app/src/main/res/drawable/bg_constraint_tag.xml
new file mode 100644
index 0000000..2d7d0f2
--- /dev/null
+++ b/app/src/main/res/drawable/bg_constraint_tag.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/dialog_input_bg.xml b/app/src/main/res/drawable/dialog_input_bg.xml
new file mode 100644
index 0000000..4a27553
--- /dev/null
+++ b/app/src/main/res/drawable/dialog_input_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/dialog_round_efefef_bg.xml b/app/src/main/res/drawable/dialog_round_efefef_bg.xml
new file mode 100644
index 0000000..1918460
--- /dev/null
+++ b/app/src/main/res/drawable/dialog_round_efefef_bg.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..2b068d1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_nav_history.xml b/app/src/main/res/drawable/ic_nav_history.xml
new file mode 100644
index 0000000..a11db8b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_history.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_home.xml b/app/src/main/res/drawable/ic_nav_home.xml
new file mode 100644
index 0000000..8483c28
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_home.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_mine.xml b/app/src/main/res/drawable/ic_nav_mine.xml
new file mode 100644
index 0000000..fbe00ab
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_mine.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nav_plan.xml b/app/src/main/res/drawable/ic_nav_plan.xml
new file mode 100644
index 0000000..5eeced3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nav_plan.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/drawable/img_back_black.png b/app/src/main/res/drawable/img_back_black.png
new file mode 100644
index 0000000..80b05ce
Binary files /dev/null and b/app/src/main/res/drawable/img_back_black.png differ
diff --git a/app/src/main/res/drawable/img_top.png b/app/src/main/res/drawable/img_top.png
new file mode 100644
index 0000000..817ac94
Binary files /dev/null and b/app/src/main/res/drawable/img_top.png differ
diff --git a/app/src/main/res/drawable/loading_dialog_bg.xml b/app/src/main/res/drawable/loading_dialog_bg.xml
new file mode 100644
index 0000000..68502be
--- /dev/null
+++ b/app/src/main/res/drawable/loading_dialog_bg.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/permission_dialog_cancel_bg.xml b/app/src/main/res/drawable/permission_dialog_cancel_bg.xml
new file mode 100644
index 0000000..b0cfe93
--- /dev/null
+++ b/app/src/main/res/drawable/permission_dialog_cancel_bg.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/permission_dialog_confirm_bg.xml b/app/src/main/res/drawable/permission_dialog_confirm_bg.xml
new file mode 100644
index 0000000..030cb23
--- /dev/null
+++ b/app/src/main/res/drawable/permission_dialog_confirm_bg.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/recycler_item_bg.xml b/app/src/main/res/drawable/recycler_item_bg.xml
new file mode 100644
index 0000000..45668ac
--- /dev/null
+++ b/app/src/main/res/drawable/recycler_item_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_constraint_layout_demo.xml b/app/src/main/res/layout/activity_constraint_layout_demo.xml
new file mode 100644
index 0000000..fcad020
--- /dev/null
+++ b/app/src/main/res/layout/activity_constraint_layout_demo.xml
@@ -0,0 +1,357 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
new file mode 100644
index 0000000..5f88e20
--- /dev/null
+++ b/app/src/main/res/layout/activity_login.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..bfe34c9
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_mine_center.xml b/app/src/main/res/layout/activity_mine_center.xml
new file mode 100644
index 0000000..97845bf
--- /dev/null
+++ b/app/src/main/res/layout/activity_mine_center.xml
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_recycler_view_demo.xml b/app/src/main/res/layout/activity_recycler_view_demo.xml
new file mode 100644
index 0000000..4414d8a
--- /dev/null
+++ b/app/src/main/res/layout/activity_recycler_view_demo.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/app/src/main/res/layout/dialog_input_text.xml b/app/src/main/res/layout/dialog_input_text.xml
new file mode 100644
index 0000000..8670dca
--- /dev/null
+++ b/app/src/main/res/layout/dialog_input_text.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_loading.xml b/app/src/main/res/layout/dialog_loading.xml
new file mode 100644
index 0000000..97784e1
--- /dev/null
+++ b/app/src/main/res/layout/dialog_loading.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_history.xml b/app/src/main/res/layout/fragment_history.xml
new file mode 100644
index 0000000..6a1655a
--- /dev/null
+++ b/app/src/main/res/layout/fragment_history.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
new file mode 100644
index 0000000..7450be1
--- /dev/null
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_mine.xml b/app/src/main/res/layout/fragment_mine.xml
new file mode 100644
index 0000000..61aab2f
--- /dev/null
+++ b/app/src/main/res/layout/fragment_mine.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_plan.xml b/app/src/main/res/layout/fragment_plan.xml
new file mode 100644
index 0000000..65f4afb
--- /dev/null
+++ b/app/src/main/res/layout/fragment_plan.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_activity_back_whitebg_layout.xml b/app/src/main/res/layout/include_activity_back_whitebg_layout.xml
new file mode 100644
index 0000000..1e945ac
--- /dev/null
+++ b/app/src/main/res/layout/include_activity_back_whitebg_layout.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_title_history_layout.xml b/app/src/main/res/layout/include_title_history_layout.xml
new file mode 100644
index 0000000..372da75
--- /dev/null
+++ b/app/src/main/res/layout/include_title_history_layout.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_title_home_layout.xml b/app/src/main/res/layout/include_title_home_layout.xml
new file mode 100644
index 0000000..fca8037
--- /dev/null
+++ b/app/src/main/res/layout/include_title_home_layout.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_title_mine_layout.xml b/app/src/main/res/layout/include_title_mine_layout.xml
new file mode 100644
index 0000000..0093511
--- /dev/null
+++ b/app/src/main/res/layout/include_title_mine_layout.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/include_title_plan_layout.xml b/app/src/main/res/layout/include_title_plan_layout.xml
new file mode 100644
index 0000000..b248a2f
--- /dev/null
+++ b/app/src/main/res/layout/include_title_plan_layout.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_recycler_demo.xml b/app/src/main/res/layout/item_recycler_demo.xml
new file mode 100644
index 0000000..db1696f
--- /dev/null
+++ b/app/src/main/res/layout/item_recycler_demo.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/permission_confirm_dialog.xml b/app/src/main/res/layout/permission_confirm_dialog.xml
new file mode 100644
index 0000000..20a04bb
--- /dev/null
+++ b/app/src/main/res/layout/permission_confirm_dialog.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/permission_top_dialog.xml b/app/src/main/res/layout/permission_top_dialog.xml
new file mode 100644
index 0000000..3ab556c
--- /dev/null
+++ b/app/src/main/res/layout/permission_top_dialog.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/bottom_navigation_menu.xml b/app/src/main/res/menu/bottom_navigation_menu.xml
new file mode 100644
index 0000000..2ebdc84
--- /dev/null
+++ b/app/src/main/res/menu/bottom_navigation_menu.xml
@@ -0,0 +1,19 @@
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6f3b755
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c209e78
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..b2dfe3d
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..4f0f1d6
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..62b611d
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..948a307
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..1b9a695
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..28d4b77
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9287f50
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..aa7d642
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9126ae3
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..2936ac3
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..88deb7f
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,144 @@
+
+
+ #3F51B5
+ #303F9F
+ #FF4081
+ #FFFFFF
+ #222222
+ #838080
+ #666666
+ #F9F9F9
+ #FFFFFF
+
+
+
+ #00000000
+ #FFFFFF
+
+ #EEEEEE
+
+
+ #F2F4F7
+ #999999
+ #999999
+
+ #333333
+ #222222
+ #333333
+ #000000
+ #E0E0E0
+ #F5F5F5
+ #EBEBEB
+ #F7F7F7
+ #838080
+ #F3F3F3
+ #E4FBFA
+ #D9D9D9
+ #DCDFE6
+ #E34D59
+
+
+ #707070
+
+ #ff999999
+
+
+
+ #1D76FE
+ #1A72FF
+ #00CA9D
+ #C8C8C8
+ #F6F6F6
+ #2E2E2E
+ #E1E1E1
+ #676767
+ #C5D7E1
+ #EFEFEF
+
+ #666666
+
+
+
+
+ #F9F9F9
+ #DDE6F5
+ #2E9CF3
+ #F7F9FA
+ #F8F9FA
+ #F0F6FF
+ #D1DCEE
+ #319EF4
+ #E5F2FB
+ #E9A01C
+ #C1CFE6
+ #FAEFFF
+ #ECFAED
+ #ECF2FA
+ #FFF5E0
+ #B85CE6
+ #24B326
+ #2A5CAA
+ #E69900
+ #E9EEF6
+ #F4F7FB
+ #4CAF50
+ #A8AFBC
+ #6BB96F
+ #E4F3E4
+ #FEE6E0
+ #4FB153
+ #FE5733
+ #D5AC26
+ #FFC107
+ #FFFBE5
+ #FFD700
+ #7A8699
+ #F0F0F2
+ #F5F6F7
+ #CCE0FF
+ #F7F7F7
+ #E6E6E6
+ #E60000
+ #E8EDF5
+ #FFF7CC
+ #FDF8D8
+ #E6BF00
+ #F7D73B
+ #FEDC18
+ #60B864
+ #FF3B30
+ #EDF6ED
+ #F39E0F
+ #FEF3E1
+ #F7D49A
+ #E7EEF8
+ #CCCCCC
+ #ECF6FF
+ #F0F8F1
+ #FFFCEB
+ #1D90FF
+
+ #8000CA9D
+
+
+
+
+
+ #0d000000
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+ #2E7D32
+ #8A8A8A
+ #F7F8FA
+ #FFFFFFFF
+ #C51609
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..d8fe5ac
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,38 @@
+
+ KotMvpProject
+ 账号登录
+ 请输入账号和密码
+ 手机号
+ 密码
+ 登录
+ 登录成功后将自动保存 Token
+ 前往登录页面
+ 计划页顶部背景图
+ 返回
+ 个人中心
+ 重新加载
+ 检测新版本
+ 测试相机和麦克风权限
+ 测试定位权限
+ 测试普通弹窗
+ 测试输入弹窗
+ 首页
+ 计划
+ 历史
+ 我的
+ 首页
+ 计划
+ 历史
+ 我的
+ 打开功能组件中心
+ 日期时间选择组件
+ 选择日期
+ 选择时间
+ 选择日期时间
+ 暂未选择
+ RecyclerView 列表示例
+ 列表组件示例
+ 编号:%1$d
+ 状态:%1$s
+ 更新时间:%1$s
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..febfdb5
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..5fc6532
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..4df9255
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..8a9185b
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/test/java/com/juhabi/birds/ExampleUnitTest.kt b/app/src/test/java/com/juhabi/birds/ExampleUnitTest.kt
new file mode 100644
index 0000000..33dee2b
--- /dev/null
+++ b/app/src/test/java/com/juhabi/birds/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package com.juhabi.birds
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..8b72b4e
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,5 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.compose.compiler) apply false
+}
diff --git a/chengkong.jks b/chengkong.jks
new file mode 100644
index 0000000..7ac287d
Binary files /dev/null and b/chengkong.jks differ
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..d621849
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,16 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+android.useAndroidX=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..36e0b6f
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,52 @@
+[versions]
+agp = "9.2.1"
+coreKtx = "1.17.0"
+junit = "4.13.2"
+junitVersion = "1.1.5"
+espressoCore = "3.5.1"
+appcompat = "1.7.1"
+material = "1.14.0"
+constraintLayout = "2.2.1"
+viewpager2 = "1.1.0"
+retrofit = "3.0.0"
+okhttp = "5.3.2"
+gson = "2.13.2"
+luban = "1.1.8"
+androidPickerView = "4.1.9"
+recyclerView = "1.4.0"
+baseRecyclerViewAdapterHelper = "3.0.14"
+immersionBar = "3.2.2"
+composeBom = "2025.08.00"
+activityCompose = "1.10.1"
+composeCompiler = "2.2.21"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintLayout" }
+androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "viewpager2" }
+retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
+luban = { group = "com.github.Curzibn", name = "Luban", version.ref = "luban" }
+android-picker-view = { group = "com.contrarywind", name = "Android-PickerView", version.ref = "androidPickerView" }
+androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerView" }
+base-recyclerview-adapter-helper = { group = "io.github.cymchad", name = "BaseRecyclerViewAdapterHelper", version.ref = "baseRecyclerViewAdapterHelper" }
+immersion-bar = { group = "com.geyifeng.immersionbar", name = "immersionbar", version.ref = "immersionBar" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "composeCompiler" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..8a48a68
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+#Tue Jul 21 15:10:34 CST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..ef07e01
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..db3a6ac
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..68c165e
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,29 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+plugins {
+ id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ // Android-PickerView 4.1.9 发布在 JCenter,使用镜像保证旧依赖仍可解析。
+ maven { url = uri("https://maven.aliyun.com/repository/jcenter") }
+ maven { url = uri("https://jitpack.io") }
+ }
+}
+
+rootProject.name = "BirdsProject"
+include ':app'