first commit

This commit is contained in:
fengjignqi
2026-09-17 08:59:24 +08:00
parent ea41ddbe74
commit 6e9b97e42e
149 changed files with 5948 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

69
app/build.gradle Normal file
View File

@@ -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'
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:name=".MyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.KotMvpProject">
<meta-data
android:name="design_width_in_dp"
android:value="393"/>
<meta-data
android:name="design_height_in_dp"
android:value="852"/>
<activity
android:name=".mine.MineCenterActivity"
android:exported="false" />
<activity
android:name=".demo.recycler.RecyclerViewDemoActivity"
android:exported="false" />
<activity
android:name=".demo.constraint.ConstraintLayoutDemoActivity"
android:exported="false" />
<activity
android:name=".main.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".login.LoginActivity"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -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 = ""
}

View File

@@ -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
}
}

View File

@@ -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<VB : ViewBinding, P : IPresenter<*>> :
AppCompatActivity(), IView {
protected val mContext: BaseActivity<VB, P>
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<IView>).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<out Activity>, data: Bundle? = null) {
startActivity(createActivityIntent(to, data))
}
/** 兼容 Water 项目命名,跳转到不继承当前 BaseActivity 的普通 Activity。 */
protected fun goToWihoutNoBase(to: Class<out Activity>, data: Bundle? = null) {
goTo(to, data)
}
/** 兼容 Water 项目命名,跳转到不使用 Presenter 的 Activity。 */
protected fun goToWihoutNoPresent(to: Class<out Activity>, data: Bundle? = null) {
goTo(to, data)
}
/** 跳转到目标页面并关闭当前 Activity。 */
protected fun goToAndFinish(to: Class<out Activity>, data: Bundle? = null) {
goTo(to, data)
finish()
}
/** 清空现有任务栈并以目标 Activity 作为新的根页面。 */
protected fun goToClearTask(to: Class<out Activity>, 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<out Activity>,
launcher: ActivityResultLauncher<Intent>,
data: Bundle? = null
) {
launcher.launch(createActivityIntent(to, data))
}
/** 创建统一的 Activity 跳转 Intent,并按需写入 Bundle 参数。 */
private fun createActivityIntent(to: Class<out Activity>, data: Bundle?): Intent {
return Intent(this, to).apply {
data?.let(::putExtras)
}
}
/** 检查单个权限是否已授权。 */
fun hasRecordPermission(permission: String): Boolean {
return hasRecordPermission(arrayOf(permission))
}
/** 检查一组权限是否已全部授权。 */
fun hasRecordPermission(permissions: Array<String>): 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<String>, 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<String>) {
nextStep()
}
/** 权限被拒绝后的回调,永久拒绝时默认引导用户进入设置页。 */
protected open fun onPermissionsDenied(permissions: Array<String>, 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<TextView>(R.id.permissionTitle).text = title
view.findViewById<TextView>(R.id.permissionContent).text = content
val dialog = AlertDialog.Builder(this).setView(view).create()
view.findViewById<TextView>(R.id.permissionCancel).apply {
text = cancelText
setOnClickListener {
dialog.dismiss()
click?.onCancelClick()
}
}
view.findViewById<TextView>(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<TextView>(R.id.dialogTitle).text = title
val input = view.findViewById<EditText>(R.id.dialogInput).apply {
this.hint = hint
setText(content)
requestFocus()
}
val dialog = AlertDialog.Builder(this).setView(view).create()
view.findViewById<TextView>(R.id.dialogCancel).apply {
text = cancelText
setOnClickListener {
dialog.dismiss()
click?.onCancelClick()
}
}
view.findViewById<TextView>(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<TextView>(R.id.permissionTopTitle).text = title
view.findViewById<TextView>(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<ImageView>(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<TextView>(R.id.permissionTitle).text = title
view.findViewById<TextView>(R.id.permissionContent).text = content
val dialog = AlertDialog.Builder(this).setView(view).create()
view.findViewById<TextView>(R.id.permissionCancel).setOnClickListener { dialog.dismiss(); onCancel() }
view.findViewById<TextView>(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()
}
}

View File

@@ -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<VB : ViewBinding> : AppCompatActivity() {
protected val mContext: BaseActivityNoPresenter<VB>
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()
}
}

View File

@@ -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<VB : ViewBinding, P : IPresenter<*>> : 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<IView>).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<out Activity>, data: Bundle? = null) {
startActivity(createActivityIntent(to, data))
}
/** 兼容 Water 项目命名,跳转到不继承当前 BaseActivity 的普通 Activity。 */
protected fun goToWihoutNoBase(to: Class<out Activity>, data: Bundle? = null) {
goTo(to, data)
}
/** 兼容 Water 项目命名,跳转到不使用 Presenter 的 Activity。 */
protected fun goToWihoutNoPresent(to: Class<out Activity>, data: Bundle? = null) {
goTo(to, data)
}
/** 跳转到目标页面并关闭 Fragment 所在的 Activity。 */
protected fun goToAndFinish(to: Class<out Activity>, data: Bundle? = null) {
goTo(to, data)
requireActivity().finish()
}
/** 清空现有任务栈并以目标 Activity 作为新的根页面。 */
protected fun goToClearTask(to: Class<out Activity>, 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<out Activity>,
launcher: ActivityResultLauncher<Intent>,
data: Bundle? = null
) {
launcher.launch(createActivityIntent(to, data))
}
/** 创建统一的 Activity 跳转 Intent,并按需写入 Bundle 参数。 */
private fun createActivityIntent(to: Class<out Activity>, data: Bundle?): Intent {
return Intent(requireContext(), to).apply {
data?.let(::putExtras)
}
}
/** 检查单个权限是否已授权。 */
fun hasRecordPermission(permission: String): Boolean {
return hasRecordPermission(arrayOf(permission))
}
/** 检查一组权限是否已全部授权。 */
fun hasRecordPermission(permissions: Array<String>): 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<String>, 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<String>) {
nextStep()
}
/** 权限被拒绝后的回调,永久拒绝时默认引导用户进入设置页。 */
protected open fun onPermissionsDenied(permissions: Array<String>, 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<TextView>(R.id.permissionTitle).text = title
view.findViewById<TextView>(R.id.permissionContent).text = content
val dialog = AlertDialog.Builder(requireContext()).setView(view).create()
view.findViewById<TextView>(R.id.permissionCancel).apply {
text = cancelText
setOnClickListener {
dialog.dismiss()
click?.onCancelClick()
}
}
view.findViewById<TextView>(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<TextView>(R.id.dialogTitle).text = title
val input = view.findViewById<EditText>(R.id.dialogInput).apply {
this.hint = hint
setText(content)
requestFocus()
}
val dialog = AlertDialog.Builder(requireContext()).setView(view).create()
view.findViewById<TextView>(R.id.dialogCancel).apply {
text = cancelText
setOnClickListener {
dialog.dismiss()
click?.onCancelClick()
}
}
view.findViewById<TextView>(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<TextView>(R.id.permissionTopTitle).text = title
view.findViewById<TextView>(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<ImageView>(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<TextView>(R.id.permissionTitle).text = title
view.findViewById<TextView>(R.id.permissionContent).text = content
val dialog = AlertDialog.Builder(requireContext()).setView(view).create()
view.findViewById<TextView>(R.id.permissionCancel).setOnClickListener { dialog.dismiss(); onCancel() }
view.findViewById<TextView>(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()
}
}

View File

@@ -0,0 +1,19 @@
package com.juhabi.birds.base
import androidx.viewbinding.ViewBinding
/**
* 不需要 Presenter 的 Fragment 基类。
* 子类仍可复用 BaseFragment 提供的 ViewBinding、加载弹窗、权限和页面跳转能力。
*/
abstract class BaseFragmentNoPresenter<VB : ViewBinding> :
BaseFragment<VB, BasePresenter<IView>>() {
final override fun createPresenter(): BasePresenter<IView>? {
return null
}
override fun showError(message: String) {
showMessage(message)
}
}

View File

@@ -0,0 +1,18 @@
package com.juhabi.birds.base
abstract class BasePresenter<V : IView> : IPresenter<V> {
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
}
}

View File

@@ -0,0 +1,8 @@
package com.juhabi.birds.base
interface IPresenter<V : IView> {
val view: V?
fun attachView(view: V)
fun onStart()
fun onDestroy()
}

View File

@@ -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")
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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<ActivityConstraintLayoutDemoBinding>() {
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 "显示详情"
}
}
}

View File

@@ -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<ActivityRecyclerViewDemoBinding>() {
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<RecyclerDemoItem> {
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")
)
}
}

View File

@@ -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<RecyclerDemoItem, BaseViewHolder> {
private final Context mContext;
public RecyclerDemoAdapter(Context context, int layoutResId, @Nullable List<RecyclerDemoItem> 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())
);
}
}

View File

@@ -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
)

View File

@@ -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<TextView>(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)
}
}
}

View File

@@ -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<ActivityLoginBinding, LoginPresenter>(),
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)
}
}

View File

@@ -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 = ""
)

View File

@@ -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<T> {
fun success(data: T)
fun fail(message: String)
}
interface DataModel {
fun login(json: JSONObject, callback: Callback<BaseBean<LoginUser>>)
fun cancel()
}
interface LoginView : IView {
fun showUsernameError(message: String)
fun showPasswordError(message: String)
fun clearInputErrors()
fun onLoginSuccess(user: LoginUser)
}
}

View File

@@ -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<BaseBean<LoginUser>>? = null
override fun login(
json: JSONObject,
callback: LoginContract.Callback<BaseBean<LoginUser>>
) {
loginCall?.cancel()
loginCall = ApiClient.apiService
.login(NetworkManager.getToken(), NetworkManager.toRequestBody(json))
.also { call ->
call.enqueue(object : Callback<BaseBean<LoginUser>> {
override fun onResponse(
call: Call<BaseBean<LoginUser>>,
response: Response<BaseBean<LoginUser>>
) {
mainHandler.post {
val bean = response.body()
if (response.isSuccessful && bean != null) {
callback.success(bean)
} else {
callback.fail("接口响应异常:${response.code()}")
}
}
}
override fun onFailure(call: Call<BaseBean<LoginUser>>, error: Throwable) {
if (!call.isCanceled) {
mainHandler.post {
callback.fail(error.message ?: "网络请求失败")
}
}
}
})
}
}
override fun cancel() {
loginCall?.cancel()
loginCall = null
mainHandler.removeCallbacksAndMessages(null)
}
}

View File

@@ -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<LoginContract.LoginView>() {
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<BaseBean<LoginUser>> {
override fun success(data: BaseBean<LoginUser>) {
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()
}
}

View File

@@ -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<ActivityMainBinding, MainPresenter>(),
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
}
}

View File

@@ -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<Fragment>
) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int {
return fragments.size
}
override fun createFragment(position: Int): Fragment {
return fragments[position]
}
}

View File

@@ -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<FragmentHistoryBinding, HistoryPresenter>(),
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?) {
}
}

View File

@@ -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<FragmentHomeBinding, HomePresenter>(),
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?) {
}
}

View File

@@ -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<FragmentMineBinding, MineFragmentPresenter>(),
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))
}
}
}

View File

@@ -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<FragmentPlanBinding, PlanPresenter>(),
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()
}
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
package com.juhabi.birds.main.fragment.history.mvp
class HistoryImpl : HistoryContract.DataModel

View File

@@ -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<HistoryContract.HistoryView>()

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
package com.juhabi.birds.main.fragment.home.mvp
class HomeImpl : HomeContract.DataModel

View File

@@ -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<HomeContract.HomeView>()

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
package com.juhabi.birds.main.fragment.mine.mvp
class MineFragmentImpl : MineFragmentContract.DataModel

View File

@@ -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<MineFragmentContract.MineView>()

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
package com.juhabi.birds.main.fragment.plan.mvp
class PlanImpl : PlanContract.DataModel

View File

@@ -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<PlanContract.PlanView>()

View File

@@ -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 = ""
)

View File

@@ -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<AppVersionInfo>)
}
interface Callback<T> {
fun onSuccess(data: T)
fun onFailure(message: String)
}
interface MainView : IView {
fun onAppVersionLoaded(versionInfo: AppVersionInfo)
}
}

View File

@@ -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<AppVersionInfo>) {
// 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")
// }
}
}

View File

@@ -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<MainContract.MainView>() {
fun loadAppVersion() {
model.getAppVersion(object : MainContract.Callback<AppVersionInfo> {
override fun onSuccess(data: AppVersionInfo) {
view?.onAppVersionLoaded(data)
}
override fun onFailure(message: String) {
view?.showError(message)
}
})
}
}

View File

@@ -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<ActivityMineCenterBinding, MinePresenter>(),
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<out String>,
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<String>) {
showMessage("权限获取成功:${permissions.joinToString { it.substringAfterLast('.') }}")
}
override fun onPermissionsDenied(permissions: Array<String>, 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("修改")
// }
// }
//}

View File

@@ -0,0 +1,24 @@
package com.juhabi.birds.mine.model
data class MineUserInfo(
val user: User? = null,
val permissions: List<Any> = emptyList(),
val roles: List<Any> = 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 = ""
)
}

View File

@@ -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<T> {
fun success(data: T)
fun fail(message: String)
}
interface DataModel {
fun getUserInfo(params: Map<String, String>, callback: Callback<MineUserInfo>)
fun checkVersion(
params: Map<String, String>,
callback: Callback<AppVersionInfo>
)
fun cancel()
}
interface MineView : IView {
fun getUserInfoSuccess(userInfo: MineUserInfo)
fun onVersionChecked(versionInfo: AppVersionInfo)
}
}

View File

@@ -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<BaseBean<MineUserInfo>>? = null
private var versionCall: Call<BaseBean<AppVersionInfo>>? = null
override fun getUserInfo(
params: Map<String, String>,
callback: MineContract.Callback<MineUserInfo>
) {
userInfoCall?.cancel()
userInfoCall = ApiClient.apiService
.getMineUserInfo(NetworkManager.getToken(), params)
.also { call ->
call.enqueue(object : Callback<BaseBean<MineUserInfo>> {
override fun onResponse(
call: Call<BaseBean<MineUserInfo>>,
response: Response<BaseBean<MineUserInfo>>
) {
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<BaseBean<MineUserInfo>>, error: Throwable) {
if (!call.isCanceled) {
mainHandler.post {
callback.fail(error.message ?: "网络请求失败")
}
}
}
})
}
}
override fun checkVersion(
params: Map<String, String>,
callback: MineContract.Callback<AppVersionInfo>) {
versionCall?.cancel()
versionCall = ApiClient.apiService
.checkVersionCode(NetworkManager.getToken(), params)
.also { call ->
call.enqueue(object : Callback<BaseBean<AppVersionInfo>> {
override fun onResponse(
call: Call<BaseBean<AppVersionInfo>>,
response: Response<BaseBean<AppVersionInfo>>
) {
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<BaseBean<AppVersionInfo>>,
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)
}
}

View File

@@ -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<MineContract.MineView>() {
fun getUserInfo() {
val currentView = view ?: return
currentView.showLoading()
mineImpl.getUserInfo(emptyMap(), object : MineContract.Callback<MineUserInfo> {
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<AppVersionInfo> {
override fun success(data: AppVersionInfo) {
view?.onVersionChecked(data)
}
override fun fail(message: String) {
view?.showError(message)
}
}
)
}
override fun onDestroy() {
mineImpl.cancel()
super.onDestroy()
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,14 @@
package com.juhabi.birds.network
sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class Failure(val error: Throwable) : ApiResult<Nothing>
}
suspend inline fun <T> safeApiCall(crossinline request: suspend () -> T): ApiResult<T> {
return try {
ApiResult.Success(request())
} catch (error: Exception) {
ApiResult.Failure(error)
}
}

View File

@@ -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<String, String>, @Body requestBody: RequestBody): Call<BaseBean<LoginUser>>
@GET("app/v1/getUserInfo")
fun getMineUserInfo(@HeaderMap headers: Map<String, String>, @QueryMap params: Map<String, String>): Call<BaseBean<MineUserInfo>>
@GET("app/v1/checkVersion")
fun checkVersionCode(@HeaderMap headers: Map<String, String>, @QueryMap params: Map<String, String>): Call<BaseBean<AppVersionInfo>>
}

View File

@@ -0,0 +1,25 @@
package com.juhabi.birds.network
import com.google.gson.Gson
import java.io.Serializable
data class BaseBean<T>(
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
}
}

View File

@@ -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<Interceptor> = emptyList()
) {
init {
require(baseUrl.endsWith('/')) { "baseUrl must end with '/'" }
}
}

View File

@@ -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 <T : Any> create(service: Class<T>): T {
return retrofit.create(service)
}
inline fun <reified T : Any> 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<String, String> {
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<String, String?>): Map<String, RequestBody> {
return values.mapValues { (_, value) -> value.orEmpty().toRequestBody(FORM_DATA) }
}
}
}

View File

@@ -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 = ""
}
}

View File

@@ -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 = ""
)

View File

@@ -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()
}
}

View File

@@ -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<String>,
preferencesName: String = DEFAULT_PREFERENCES_NAME
) = preferences(context, preferencesName).edit().putStringSet(key, value).apply()
fun getStringSet(
context: Context,
key: String,
defaultValue: Set<String> = emptySet(),
preferencesName: String = DEFAULT_PREFERENCES_NAME
): Set<String> = 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 <T> getObject(
context: Context,
key: String,
clazz: Class<T>,
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()
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 = "*/*"
}

View File

@@ -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<String, Activity>())
/** 返回 Activity 集合的快照,防止调用方直接修改内部集合。 */
@JvmStatic
fun getActivitys(): Map<String, Activity> = 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"
}

View File

@@ -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"
}

View File

@@ -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<Int> {
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"
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/bottom_nav_selected" android:state_checked="true" />
<item android:color="@color/bottom_nav_unselected" />
</selector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#00A884" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape><corners android:radius="3dp" /><solid android:color="#E4E7EC" /></shape>
</item>
<item android:gravity="start" android:width="180dp">
<shape><corners android:radius="3dp" /><solid android:color="#00A884" /></shape>
</item>
</layer-list>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="4dp" />
<solid android:color="#E7F7F2" />
</shape>

View File

@@ -0,0 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFFFF" />
<stroke android:width="1dp" android:color="#FFD0D5DD" />
<corners android:radius="6dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFEFEFEF" />
<corners android:radius="14dp" />
</shape>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M12,4a8,8 0,1 1,-7.75 6H2l3,-3 3,3H6.3A6,6 0,1 0,12 6zM11,7h2v5l3,2 -1,1 -4,-2.5z" />
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M3,10.5L12,3l9,7.5V21a1,1 0,0 1,-1 1H4a1,1 0,0 1,-1 -1zM9,22v-7h6v7" />
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M12,12a4,4 0,1 0,0 -8a4,4 0,0 0,0 8zM4,21a8,8 0,0 1,16 0z" />
</vector>

View File

@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FF000000" android:pathData="M7,2h2v3h6V2h2v3h2a2,2 0,0 1,2 2v14a2,2 0,0 1,-2 2H5a2,2 0,0 1,-2 -2V7a2,2 0,0 1,2 -2h2zM5,9v12h14V9zM7,12h3v3H7zM12,12h3v3h-3zM7,17h3v2H7zM12,17h3v2h-3z" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#F2F2F2" />
<corners android:radius="8dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFD9DDE3" />
<corners android:radius="8dp" />
</shape>

View File

@@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FF1A72FF" />
<corners android:radius="8dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/white" />
<stroke android:width="1dp" android:color="#E4E7EC" />
<corners android:radius="6dp" />
</shape>

View File

@@ -0,0 +1,357 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F7FA">
<!-- ConstraintLayout 不依赖摆放顺序,每个 View 都通过约束描述自己与其他 View 的关系。 -->
<ImageView
android:id="@+id/backButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="返回"
android:padding="14dp"
android:src="@drawable/img_back_black"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/pageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="ConstraintLayout"
android:textColor="#18212F"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/backButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/backButton" />
<androidx.core.widget.NestedScrollView
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/backButton">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp">
<!-- Guideline 是不可见的辅助线。这里固定在父布局宽度的 38%,比计算屏幕像素更适配。 -->
<androidx.constraintlayout.widget.Guideline
android:id="@+id/contentGuideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.38" />
<TextView
android:id="@+id/introText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="下面用一个项目概览页演示常用约束。可以结合 XML 注释观察效果。"
android:textColor="#667085"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/profileCard"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="5dp"
android:layout_marginHorizontal="5dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/introText"
app:strokeColor="#E4E7EC"
app:strokeWidth="1dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/avatarText"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="@drawable/bg_constraint_avatar"
android:gravity="center"
android:text="CL"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/nameText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:text="智能灌溉升级项目"
android:textColor="#18212F"
android:textSize="17sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/statusText"
app:layout_constraintStart_toEndOf="@id/avatarText"
app:layout_constraintTop_toTopOf="@id/avatarText" />
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="26dp"
android:background="@drawable/bg_constraint_tag"
android:gravity="center"
android:paddingHorizontal="8dp"
android:text="进行中"
android:textColor="#00866A"
android:textSize="12sp"
app:layout_constraintBaseline_toBaselineOf="@id/nameText"
app:layout_constraintEnd_toEndOf="parent" />
<!-- baseline 约束让字号不同的文本共享文字基线,这是 RelativeLayout 不易表达的。 -->
<TextView
android:id="@+id/ownerText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="8dp"
android:text="负责人:Android 学习者"
android:textColor="#667085"
android:textSize="13sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/avatarText"
app:layout_constraintTop_toBottomOf="@id/nameText" />
<!-- 0dp 表示 MATCH_CONSTRAINT:宽度由 start/end 两端约束共同决定,不是没有宽度。 -->
<View
android:id="@+id/progressView"
android:layout_width="0dp"
android:layout_height="6dp"
android:layout_marginTop="18dp"
android:background="@drawable/bg_constraint_progress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/avatarText" />
<TextView
android:id="@+id/progressLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="完成进度"
android:textColor="#667085"
android:textSize="12sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressView" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="72%"
android:textColor="#00866A"
android:textSize="12sp"
android:textStyle="bold"
app:layout_constraintBaseline_toBaselineOf="@id/progressLabel"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/detailTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="项目详情"
android:textColor="#18212F"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/profileCard" />
<TextView
android:id="@+id/toggleDetailsButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="隐藏详情"
android:textColor="#00866A"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/detailTitle"/>
<TextView
android:id="@+id/labelStart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="开始时间"
android:textColor="#667085"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/detailTitle" />
<TextView
android:id="@+id/labelOwner"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="责任部门很长"
android:textColor="#667085"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/labelStart" />
<!-- Barrier 会取多个参照 View 的最远边界;标签文字变化时,右侧值仍自动对齐且不重叠。 -->
<androidx.constraintlayout.widget.Barrier
android:id="@+id/labelBarrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="end"
app:constraint_referenced_ids="labelStart,labelOwner" />
<TextView
android:id="@+id/valueStart"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:text="2026-07-27"
android:textColor="#344054"
android:textSize="14sp"
app:layout_constraintBaseline_toBaselineOf="@id/labelStart"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/labelBarrier" />
<TextView
android:id="@+id/valueOwner"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:text="移动研发组"
android:textColor="#344054"
android:textSize="14sp"
app:layout_constraintBaseline_toBaselineOf="@id/labelOwner"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/labelBarrier" />
<!-- Group 自身不绘制内容,可一次控制一组散落 View 的 visibility。 -->
<androidx.constraintlayout.widget.Group
android:id="@+id/detailGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="labelStart,labelOwner,valueStart,valueOwner" />
<TextView
android:id="@+id/statsTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="26dp"
android:text="本周数据"
android:textColor="#18212F"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/labelOwner" />
<!-- 三个 View 首尾互相约束形成水平 Chain。spread 模式自动均分剩余空间,免去 LinearLayout + weight。 -->
<TextView
android:id="@+id/statOne"
android:layout_width="0dp"
android:layout_height="72dp"
android:layout_marginTop="14dp"
android:background="#FFFFFF"
android:gravity="center"
android:text="12\n任务"
android:textColor="#344054"
android:textSize="14sp"
app:layout_constraintEnd_toStartOf="@id/statTwo"
app:layout_constraintHorizontal_chainStyle="spread"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statsTitle" />
<TextView
android:id="@+id/statTwo"
android:layout_width="0dp"
android:layout_height="72dp"
android:layout_marginStart="8dp"
android:background="#FFFFFF"
android:gravity="center"
android:text="9\n完成"
android:textColor="#344054"
android:textSize="14sp"
app:layout_constraintEnd_toStartOf="@id/statThree"
app:layout_constraintStart_toEndOf="@id/statOne"
app:layout_constraintTop_toTopOf="@id/statOne" />
<TextView
android:id="@+id/statThree"
android:layout_width="0dp"
android:layout_height="72dp"
android:layout_marginStart="8dp"
android:background="#FFFFFF"
android:gravity="center"
android:text="3\n待处理"
android:textColor="#344054"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/statTwo"
app:layout_constraintTop_toTopOf="@id/statOne" />
<TextView
android:id="@+id/ratioTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="16:9 比例区域"
android:textColor="#18212F"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/contentGuideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statOne" />
<!-- dimensionRatio 配合一边 0dp,可由另一边自动计算尺寸,图片/视频预览常用。 -->
<TextView
android:id="@+id/ratioPreview"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="12dp"
android:background="#DDEFEA"
android:gravity="center"
android:text="宽度由两侧约束\n高度按 16:9 自动计算"
android:textColor="#26705F"
android:textSize="14sp"
app:layout_constraintDimensionRatio="16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ratioTitle" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="24dp"
android:background="#FFF7E6"
android:padding="14dp"
android:text="核心区别:LinearLayout 强调排列,RelativeLayout 强调相对规则;ConstraintLayout 用双向约束与辅助工具描述关系,复杂页面通常能减少嵌套层级。"
android:textColor="#7A4E00"
android:textSize="13sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ratioPreview" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F7FA"
android:padding="24dp">
<LinearLayout
android:id="@+id/loginContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.38">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/login_title"
android:textColor="#18212F"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/login_subtitle"
android:textColor="#667085"
android:textSize="15sp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/usernameLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:hint="@string/login_username">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/usernameEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="phone"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/login_password"
app:endIconMode="password_toggle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/loginButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="24dp"
android:text="@string/login_button"
android:textSize="16sp" />
<TextView
android:id="@+id/resultTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:text="@string/login_demo_hint"
android:textColor="#667085"
android:textSize="14sp" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/immersiveTopImageView"
android:layout_width="0dp"
android:layout_height="320dp"
android:contentDescription="@string/plan_top_image_description"
android:scaleType="centerCrop"
android:src="@drawable/img_top"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/statusBarView"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/color_zhu"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/bottomNavigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusBarView" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@color/bottom_nav_background"
app:itemActiveIndicatorStyle="@null"
app:itemIconTint="@color/bottom_nav_item_color"
app:itemTextColor="@color/bottom_nav_item_color"
app:labelVisibilityMode="labeled"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="@menu/bottom_navigation_menu" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,196 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F7FA"
android:orientation="vertical">
<View
android:id="@+id/statusBarView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/color_zhu" />
<RelativeLayout
android:id="@+id/rl_title"
android:layout_width="match_parent"
android:layout_height="48dp">
<include
android:id="@+id/included_title"
layout="@layout/include_activity_back_whitebg_layout" />
</RelativeLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/mine_title"
android:textColor="#18212F"
android:textSize="28sp"
android:textStyle="bold" />
<TextView
android:id="@+id/nicknameTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:textColor="#18212F"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/phoneTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:textColor="#344054"
android:textSize="16sp" />
<TextView
android:id="@+id/emailTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="#344054"
android:textSize="16sp" />
<TextView
android:id="@+id/userIdTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="#344054"
android:textIsSelectable="true"
android:textSize="16sp" />
<TextView
android:id="@+id/userTypeTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="#344054"
android:textSize="16sp" />
<TextView
android:id="@+id/loginDateTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="#344054"
android:textSize="16sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/reloadButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="32dp"
android:text="@string/mine_reload" />
<com.google.android.material.button.MaterialButton
android:id="@+id/checkVersionButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:text="@string/mine_check_version" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openLoginButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:text="@string/mine_open_login" />
<com.google.android.material.button.MaterialButton
android:id="@+id/multiplePermissionButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="16dp"
android:text="@string/permission_test_multiple" />
<com.google.android.material.button.MaterialButton
android:id="@+id/locationPermissionButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:text="@string/permission_test_location" />
<com.google.android.material.button.MaterialButton
android:id="@+id/normalDialogButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:text="@string/dialog_test_normal" />
<com.google.android.material.button.MaterialButton
android:id="@+id/inputDialogButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:text="@string/dialog_test_input" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="@string/picker_component_title"
android:textColor="#18212F"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/dateTimeResultTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:padding="12dp"
android:text="@string/picker_result_empty"
android:textColor="#344054"
android:textIsSelectable="true"
android:textSize="16sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/datePickerButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:text="@string/picker_select_date" />
<com.google.android.material.button.MaterialButton
android:id="@+id/timePickerButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:text="@string/picker_select_time" />
<com.google.android.material.button.MaterialButton
android:id="@+id/dateTimePickerButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:text="@string/picker_select_date_time" />
<com.google.android.material.button.MaterialButton
android:id="@+id/recyclerViewDemoButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:text="@string/recycler_demo_entry" />
<com.google.android.material.button.MaterialButton
android:id="@+id/constraintLayoutDemoButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="24dp"
android:text="ConstraintLayout 综合示例" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/page_background"
android:clipToPadding="false"
android:padding="16dp" />

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/contentDialog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="20dp"
android:background="@drawable/dialog_round_efefef_bg"
android:orientation="vertical"
android:paddingTop="28dp"
android:paddingBottom="24dp">
<TextView
android:id="@+id/dialogTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="#FF333333"
android:textSize="17sp"
android:textStyle="bold" />
<EditText
android:id="@+id/dialogInput"
android:layout_width="match_parent"
android:layout_height="42dp"
android:layout_marginStart="25dp"
android:layout_marginTop="14dp"
android:layout_marginEnd="25dp"
android:background="@drawable/dialog_input_bg"
android:hint="请输入内容"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:textColor="#FF222222"
android:textColorHint="#FF98A2B3"
android:textSize="16sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginStart="35dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="35dp"
android:orientation="horizontal">
<TextView
android:id="@+id/dialogCancel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="13dp"
android:layout_weight="1"
android:background="@drawable/permission_dialog_cancel_bg"
android:gravity="center"
android:text="取消"
android:textColor="#FF666666" />
<TextView
android:id="@+id/dialogConfirm"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/permission_dialog_confirm_bg"
android:gravity="center"
android:text="确定"
android:textColor="#FFFFFFFF" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_gravity="center"
android:background="@drawable/loading_dialog_bg"
android:gravity="center"
android:orientation="vertical">
<ProgressBar
android:layout_width="48dp"
android:layout_height="48dp"
android:indeterminate="true"
android:indeterminateTint="@color/bottom_nav_selected" />
<TextView
android:id="@+id/loadingText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="加载中..."
android:textColor="@color/bottom_nav_selected"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/page_background">
<include
android:id="@+id/included_title"
layout="@layout/include_title_history_layout"
android:layout_width="match_parent"
android:layout_height="48dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/history_page_title"
android:textColor="@color/black"
android:textSize="24sp" />
</FrameLayout>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/page_background">
<RelativeLayout
android:id="@+id/rl_title"
android:layout_width="match_parent"
android:layout_height="48dp">
<include
android:id="@+id/included_title"
layout="@layout/include_title_home_layout" />
</RelativeLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/home_page_title"
android:textColor="@color/black"
android:textSize="24sp" />
</FrameLayout>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/page_background">
<include
android:id="@+id/included_title"
layout="@layout/include_title_mine_layout"
android:layout_width="match_parent"
android:layout_height="48dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/mine_page_title"
android:textColor="@color/black"
android:textSize="24sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openComponentCenterButton"
android:layout_width="match_parent"
android:layout_height="52dp"
android:layout_marginTop="24dp"
android:text="@string/open_component_center" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
<View
android:id="@+id/planContentBackground"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="280dp"
android:background="@color/page_background" />
<ImageView
android:id="@+id/planBackButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:contentDescription="@string/back_button_description"
android:padding="12dp"
android:src="@drawable/img_back_black" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/plan_page_title"
android:textColor="@color/black"
android:textSize="24sp" />
</FrameLayout>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RelativeLayout
android:id="@+id/relative_title"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@color/color_zhu">
<ImageView
android:id="@+id/img_back"
android:layout_width="22dp"
android:layout_height="match_parent"
android:layout_centerVertical="true"
android:src="@drawable/img_back_black"
android:layout_marginLeft="10dp"/>
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="18sp"
android:textColor="#2E2E2E"
android:textStyle="bold"
android:text="标题名称"
android:layout_marginLeft="10dp"
android:layout_centerVertical="true"
android:layout_centerHorizontal="true"/>
<TextView
android:id="@+id/tv_title_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toRightOf="@+id/tv_title"
android:textSize="14sp"
android:textColor="#2E2E2E"
android:text="(剩余时间)"
android:layout_marginLeft="5dp"
android:visibility="gone"/>
<TextView
android:id="@+id/tv_title_right"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:layout_alignParentRight="true"
android:layout_marginRight="10dp"
android:textSize="14sp"
android:textColor="#333333"
android:text=""
android:layout_marginLeft="5dp"
android:visibility="visible"/>
<ImageView
android:id="@+id/img_setting"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_centerVertical="true"
android:layout_alignParentRight="true"
android:src="@android:drawable/ic_menu_preferences"
android:layout_marginRight="20dp"
android:visibility="gone"/>
</RelativeLayout>
</RelativeLayout>

Some files were not shown because too many files have changed in this diff Show More