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'