feat(init): 初始化健康专家客户端 Android 项目

- 搭建项目基础架构,包含 Activity/Fragment/ViewModel 层
- 添加登录、主页、AI聊天、工作台、消息、知识库等核心模块
- 配置 Gradle 构建脚本及依赖管理
- 添加基础 UI 组件:自定义密码输入框、标题栏、Tab 布局等
- 添加图标、主题、drawable 等资源文件
This commit is contained in:
zhanglei
2026-04-22 16:34:35 +08:00
commit 7ea14ab925
129 changed files with 6307 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
id 'kotlin-parcelize'
}
android {
compileSdk rootProject.ext.compileSdkVersion
namespace "com.sw.healthexpertclient"
defaultConfig {
applicationId "com.sw.healthexpertclient"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode rootProject.ext.versionCode
versionName rootProject.ext.versionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a'
}
}
buildTypes {
debug {
minifyEnabled false
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
packagingOptions {
resources {
excludes += ['META-INF/gradle/incremental.annotation.processors', 'DebugProbesKt.bin']
}
}
testOptions {
unitTests.returnDefaultValues = true
}
dataBinding {
enabled = true
}
buildFeatures {
buildConfig true
compose true
}
kapt {
generateStubs = true
}
sourceSets {
main {
jniLibs.srcDirs = ['libs']
res.srcDirs = ['src/main/res']
}
}
}
configurations {
all*.exclude group: 'org.jetbrains', module: 'annotations-java5'
}
dependencies {
implementation project(path: ':core')
// implementation project(':tuichat')
// implementation project(':tuicontact')
// implementation project(':tuiconversation')
// implementation project(':tuigroup')
// implementation project(':tuicallkit')
// api project(':fluid-markdown')
implementation("com.squareup.okhttp3:okhttp-sse:4.12.0")
/*Compose*/
implementation platform(rootProject.ext.composeDeps["compose-bom"])
implementation rootProject.ext.composeLibs
// Compose 调试工具(仅 debug 包含)
debugImplementation rootProject.ext.composeDebugLibs
// CoilCompose 网络图片加载
implementation "io.coil-kt:coil-compose:2.6.0"
}
+64
View File
@@ -0,0 +1,64 @@
<?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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- 存储权限 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- Android 13 细化存储权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- 相机 / 录音 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- 定位 -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- 通知 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 悬浮窗 -->
<!-- <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />-->
<!-- 手机状态 -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:name=".MyApplication"
android:allowBackup="false"
android:allowNativeHeapPointerTagging="false"
android:icon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:taskAffinity="com.sw.healthexpertclient"
android:theme="@style/Theme.HearthyClients"
android:usesCleartextTraffic="true"
tools:replace="android:allowBackup">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+40
View File
@@ -0,0 +1,40 @@
{
"textColorNormal": "#A4A3A3",
"textColorSelected": "#1677FF",
"textSizeNormal": 11,
"textSizeSelected": 11,
"isNameResId": false,
"isTitleVisible": true,
"tabs": [
{
"tabName": "工作台",
"tabTag": "key_workbench_fragment",
"iconNormal": "main_tab_workbench_normal",
"iconSelected": "main_tab_workbench_selected"
},
{
"tabName": "知识",
"tabTag": "key_knowledge_fragment",
"iconNormal": "main_tab_knowledge_normal",
"iconSelected": "main_tab_knowledge_selected"
},
{
"tabName": "AI助手",
"tabTag": "key_aichat_fragment",
"iconNormal": "main_tab_aichat_normal",
"iconSelected": "main_tab_aichat_selected"
},
{
"tabName": "消息",
"tabTag": "key_message_fragment",
"iconNormal": "main_tab_message_normal",
"iconSelected": "main_tab_message_selected"
},
{
"tabName": "我的",
"tabTag": "key_mine_fragment",
"iconNormal": "main_tab_mine_normal",
"iconSelected": "main_tab_mine_selected"
}
]
}
@@ -0,0 +1,19 @@
package com.sw.healthexpertclient
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
import com.sw.healthexpertclient.bean.AppUpdateBean
import com.sw.healthexpertclient.data.repository.GuidanceRepository
import com.sw.healthexpertclient.retrofit.UrlConfig
import com.sw.healthexpertclient.superfuntion.addImageBaseUrl
import com.sw.healthexpertclient.superfuntion.handleRequest
import com.sw.healthexpertclient.superfuntion.hideLoading
import com.sw.healthexpertclient.superfuntion.launch
import com.sw.sdk.core.data.local.DataStoreManager
import com.sw.sdk.core.utils.CustomActivityManager
import kotlinx.coroutines.flow.MutableStateFlow
class AppViewModel : BaseViewModel() {
override fun init() {}
}
@@ -0,0 +1,110 @@
package com.sw.healthexpertclient
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Handler
import android.os.Message
import android.view.KeyEvent
import android.view.View
import androidx.fragment.app.Fragment
import com.sw.healthexpertclient.base.BaseVMBActivity
import com.sw.healthexpertclient.base.viewmodel.CommonViewModel
import com.sw.healthexpertclient.bottomtab.HomeBottomTabLayout
import com.sw.healthexpertclient.databinding.ActivityMainBinding
import com.sw.healthexpertclient.fragment.AIChatFragment
import com.sw.healthexpertclient.fragment.KnowledgeFragment
import com.sw.healthexpertclient.fragment.MessageFragment
import com.sw.healthexpertclient.fragment.MineFragment
import com.sw.healthexpertclient.fragment.WorkbenchFragment
import com.sw.healthexpertclient.utils.CustomActivityManager
/**
* 主页面
*
* 承载底部 5 个 Tab 的导航:工作台、知识、AI助手、消息、我的
* 实现 HomeBottomTabLayoutCallback 接口,提供 Fragment 实例和 Tab 切换回调
*/
class MainActivity :
BaseVMBActivity<CommonViewModel, ActivityMainBinding>(R.layout.activity_main),
HomeBottomTabLayout.HomeBottomTabLayoutCallback {
// 各 Tab tag 标识(与 assets/tab.json 中保持一致)
private val TAG_WORKBENCH = "key_workbench_fragment" // 工作台
private val TAG_KNOWLEDGE = "key_knowledge_fragment" // 知识
private val TAG_AICHAT = "key_aichat_fragment" // AI 助手
private val TAG_MESSAGE = "key_message_fragment" // 消息
private val TAG_MINE = "key_mine_fragment" // 我的
override fun initView(savedInstanceState: Bundle?) {
mBinding.apply {
// 设置 Tab 回调
mainTabLayout.setHomeBottomTabLayoutCallback(this@MainActivity)
// 初始化默认显示第 0 个 Tab(工作台)
mainTabLayout.initFirstTab(0)
}
}
override fun initData() {
// TODO: 初始化数据(隐私协议、权限申请等在此处理)
}
override fun bindEvent() {}
override fun processClick(v: View?) {}
/**
* 根据 Tab tag 返回对应的 Fragment 实例
*
* @param tabTag Tab 的唯一标识
* @return 对应 Fragment 实例,未匹配时返回空 Fragment
*/
override fun getFragmentByTag(tabTag: String): Fragment? {
return when (tabTag) {
TAG_WORKBENCH -> WorkbenchFragment()
TAG_KNOWLEDGE -> KnowledgeFragment()
TAG_AICHAT -> AIChatFragment()
TAG_MESSAGE -> MessageFragment()
TAG_MINE -> MineFragment()
else -> Fragment()
}
}
/**
* Tab 切换回调
*
* @param selectedIndex 当前选中 Tab 索引
* @param selectedTag 当前选中 Tab tag
*/
override fun onClickChangeTab(selectedIndex: Int, selectedTag: String?) {
// TODO: Tab 切换联动逻辑(如悬浮按钮显隐等)
}
// ---- 双击返回键退出应用 ----
private var isExit = false
@SuppressLint("HandlerLeak")
private val mHandler: Handler = object : Handler() {
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
isExit = false
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK) {
if (!isExit) {
isExit = true
showToast("再按一次退出程序")
mHandler.sendEmptyMessageDelayed(0, 2000)
} else {
CustomActivityManager.getInstance().finishAllActivity()
}
return false
}
return super.onKeyDown(keyCode, event)
}
override fun transparentStatusBar(): Boolean = true
}
@@ -0,0 +1,166 @@
package com.sw.healthexpertclient
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 android.os.StrictMode
import android.util.Log
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import com.orhanobut.logger.AndroidLogAdapter
import com.orhanobut.logger.Logger
import com.sw.healthexpertclient.bean.AppUpdateBean
import com.sw.healthexpertclient.retrofit.UrlConfig
import com.sw.healthexpertclient.superfuntion.jsonToBean
import com.sw.healthexpertclient.superfuntion.startLoginActivity
import com.sw.healthexpertclient.utils.updateplugin.CheckUpdateAppVersion
import com.sw.healthexpertclient.utils.updateplugin.CustomDownloadNotifier
import com.sw.healthexpertclient.utils.updateplugin.CustomInstallNotifier
import com.sw.healthexpertclient.view.CustomUpdateNotifier
import com.sw.sdk.core.data.local.DataStoreManager.initialize
import com.sw.sdk.core.utils.CustomActivityManager
import com.sw.sdk.core.utils.DebuggerUtils
import com.sw.sdk.core.utils.DevicesInfoUtils
import com.sw.sdk.core.utils.DownloadFileUtil
import org.json.JSONObject
import org.lzh.framework.updatepluginlib.UpdateConfig
import org.lzh.framework.updatepluginlib.base.UpdateParser
import org.lzh.framework.updatepluginlib.base.UpdateStrategy
import org.lzh.framework.updatepluginlib.model.CheckEntity
import org.lzh.framework.updatepluginlib.model.Update
import retrofit2.HttpException
import kotlin.properties.Delegates
class MyApplication : Application(), ViewModelStoreOwner {
private var mAppViewModelStore: ViewModelStore? = null
private var mFactory: ViewModelProvider.Factory? = null
companion object {
@JvmStatic
var appContext: MyApplication by Delegates.notNull()
@JvmStatic
lateinit var appViewModel: AppViewModel
val TAG = MyApplication::class.java.simpleName
}
override fun onCreate() {
Log.i(TAG, "onCreate")
super.onCreate()
initialize(this)
Logger.addLogAdapter(AndroidLogAdapter())
appContext = this
DownloadFileUtil.init(this)
mAppViewModelStore = ViewModelStore()
appViewModel = getAppViewModelProvider()[AppViewModel::class.java]
appViewModel.init()
createObserve()
createNewConfig()
initUpdateApp()
// 禁止网络相关安全检查
if (Build.VERSION.SDK_INT > 9) {
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
}
registerActivityLifecycleCallbacks(AdjustLifecycleCallbacks())
DebuggerUtils.checkDebuggableInNotDebugModel(this)
if (BuildConfig.DEBUG) {
val androidId = DevicesInfoUtils.getAndroidId(this)
println("设备id:${androidId}")
}
}
/** 获取一个全局的 ViewModel */
private fun getAppViewModelProvider(): ViewModelProvider {
return ViewModelProvider(this, getAppFactory())
}
private fun getAppFactory(): ViewModelProvider.Factory {
if (mFactory == null) {
mFactory = ViewModelProvider.AndroidViewModelFactory.getInstance(this)
}
return mFactory as ViewModelProvider.Factory
}
private fun createNewConfig(): UpdateConfig {
return UpdateConfig.createConfig()
.setUrl("http://o1wh05aeh.qnssl.com/image/view/app_icons")
.setUpdateParser(object : UpdateParser() {
@Throws(Exception::class)
override fun parse(httpResponse: String): Update {
val obj = JSONObject(httpResponse)
val update = Update()
update.updateUrl = obj.optString("update_url")
update.versionCode = obj.optInt("update_ver_code")
update.versionName = obj.optString("update_ver_name")
update.updateContent = obj.optString("update_content")
update.isForced = true
update.isIgnore = obj.optBoolean("ignore_able", false)
update.md5 = obj.optString("md5")
return update
}
})
}
/** 全局 ViewModel 异常监听 */
fun createObserve() {
appViewModel.exception.observeForever { e: Exception? ->
if (e is HttpException) {
if (e.code() == 401) {
val activity = CustomActivityManager.getInstance().currentActivity()
startLoginActivity(activity)
activity.finish()
}
}
}
}
private inner class AdjustLifecycleCallbacks : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
println("路径" + activity.javaClass.name)
CustomActivityManager.getInstance().addActivity(activity)
}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {
CustomActivityManager.getInstance().removeActivity(activity)
}
}
private fun initUpdateApp() {
UpdateConfig.getConfig()
.setCheckWorker(CheckUpdateAppVersion::class.java)
.setCheckEntity(CheckEntity().setUrl("https://www.baidu.com"))
.setCheckNotifier(CustomUpdateNotifier())
.setInstallNotifier(CustomInstallNotifier())
.setDownloadNotifier(CustomDownloadNotifier())
.setUpdateParser(object : UpdateParser() {
override fun parse(response: String?): Update {
val resultBean: AppUpdateBean? = response.jsonToBean(AppUpdateBean::class.java)
val update = Update()
if (resultBean == null) return update
update.updateUrl = resultBean.yybUrl
update.versionCode = resultBean.versionNo ?: 0
update.versionName = resultBean.versionName
update.updateContent = resultBean.updateContent
update.isForced = 1 == resultBean.forced
update.isIgnore = 1 == resultBean.ignoreFlag
return update
}
}).updateStrategy = object : UpdateStrategy() {
override fun isShowUpdateDialog(update: Update): Boolean = true
override fun isAutoInstall(): Boolean = true
override fun isShowDownloadDialog(): Boolean = true
}
}
override val viewModelStore: ViewModelStore
get() = mAppViewModelStore!!
}
@@ -0,0 +1,365 @@
package com.sw.healthexpertclient.base
import android.app.Activity
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.content.res.Resources
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.Window
import android.widget.RelativeLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.databinding.DataBindingUtil
import androidx.databinding.OnRebindCallback
import androidx.databinding.ViewDataBinding
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.gyf.immersionbar.ktx.immersionBar
import com.sw.healthexpertclient.BR
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel.Companion.LOADING_STATE_HIDE
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel.Companion.LOADING_STATE_SHOW
import com.sw.healthexpertclient.data.bean.ApiCode
import com.sw.healthexpertclient.event.GlobalEvent
import com.sw.healthexpertclient.utils.CustomActivityManager
import com.sw.healthexpertclient.utils.StatusbarUtil
import com.sw.healthexpertclient.view.LoadingDialog
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import retrofit2.HttpException
import java.lang.reflect.ParameterizedType
/**
* 封装了 ViewModel 和 DataBinding 的 Activity 基类
*
* 功能:
* - 自动反射初始化 ViewModel
* - DataBinding 绑定生命周期
* - 沉浸式状态栏(ImmersionBar
* - EventBus 自动注册/注销
* - 统一加载框管理
* - 统一网络异常/业务错误处理
* - 空状态页面
* - Activity 栈管理
*/
abstract class BaseVMBActivity<VM : BaseViewModel, B : ViewDataBinding>(
private val contentViewResId: Int
) : AppCompatActivity(), View.OnClickListener {
var mContext: Context? = null
var mActivity: Activity? = null
lateinit var mViewModel: VM
lateinit var mBinding: B
var dialog: LoadingDialog? = null
var mEmpty: View? = null
var mRootView: RelativeLayout? = null
/**
* 重写 getResources(),使 APP 字体不受系统字体大小设置影响
*/
override fun getResources(): Resources? {
val config = Configuration()
config.setToDefaults()
createConfigurationContext(config)
return super.getResources()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EventBus.getDefault().register(this)
mContext = this
mActivity = this
CustomActivityManager.getInstance().addActivity(this)
initViewModel()
initDataBinding()
initImmersionBar()
onDrawFinish()
createObserve()
createDialog()
initView(savedInstanceState)
initData()
bindEvent()
mBinding.addOnRebindCallback(object : OnRebindCallback<B>() {
override fun onBound(binding: B) {
super.onBound(binding)
setTransparentStatusBar(transparentStatusBar(), statusBarDarkFont())
}
})
}
/** 初始化沉浸式状态栏 */
open fun initImmersionBar() {
initToolBar()
}
/** 布局绘制完成后初始化空状态视图 */
open fun onDrawFinish() {
mRootView = findViewById<RelativeLayout>(R.id.rl_empty_root_view)
mRootView?.let {
mEmpty = LayoutInflater.from(mContext)
.inflate(R.layout.layout_empty, it, false).apply {
findViewById<TextView>(R.id.tv_empty).text = "暂无数据"
}
val layoutParams = RelativeLayout.LayoutParams(
RelativeLayout.LayoutParams.MATCH_PARENT,
RelativeLayout.LayoutParams.MATCH_PARENT
)
layoutParams.addRule(RelativeLayout.BELOW, R.id.toolbar_lay)
mEmpty?.layoutParams = layoutParams
}
}
/** 显示空状态页面 */
fun showEmpty(emptyMessage: String = "暂无数据") {
if (mEmpty?.parent == null) {
mRootView?.addView(mEmpty)
} else {
mRootView?.removeView(mEmpty)
mRootView?.addView(mEmpty)
}
}
/** 隐藏空状态页面 */
fun hideEmpty() {
mRootView?.removeView(mEmpty)
}
/**
* 设置状态栏透明度和字体颜色
*
* @param isTransparent 是否透明状态栏
* @param isStatusBarDarkFont 状态栏字体是否深色
*/
open fun setTransparentStatusBar(isTransparent: Boolean, isStatusBarDarkFont: Boolean) {
immersionBar {
titleBar(getToolBar())
if (isTransparent) {
statusBarColorInt(
ContextCompat.getColor(this@BaseVMBActivity, R.color.transparent)
).fitsSystemWindows(false)
statusBarDarkFont(isStatusBarDarkFont, 0.2f)
} else {
statusBarDarkFont(isStatusBarDarkFont, 0.2f)
}
}
}
/**
* 判断一个颜色是否是亮色
*/
fun isLightColor(color: Int): Boolean {
return ColorUtils.calculateLuminance(color) >= 0.5
}
/** 初始化标题栏(状态栏适配) */
private fun initToolBar() {
var titleLay: View? = findViewById(R.id.rl_title_lay)
if (titleLay == null) titleLay = findViewById(R.id.toolbar_lay)
if (titleLay == null) titleLay = getToolBar()
fitTransparentStatusBar(titleLay)
findViewById<View>(R.id.title_iv_back)?.setOnClickListener {
onBackEvent()
}
}
/** 为指定 View 添加状态栏高度的 padding,实现沉浸式适配 */
fun fitTransparentStatusBar(view: View?) {
view?.let {
val statusHeight = StatusbarUtil.getStatusBarHeight(this@BaseVMBActivity)
it.setPadding(0, statusHeight, 0, 0)
val height: Int = it.layoutParams?.height ?: 0
it.layoutParams.height = height + statusHeight
}
}
/** 批量注册点击事件 */
fun addClickViews(vararg views: View) {
for (view in views) view.setOnClickListener(this)
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
onBackEvent()
}
/** 返回事件(子类可重写自定义行为) */
open fun onBackEvent() {
@Suppress("DEPRECATION")
super.onBackPressed()
}
override fun onClick(view: View) {
processClick(view)
}
/** 子类可重写,返回 Toolbar 视图(用于沉浸式状态栏) */
open fun getToolBar(): View? = null
/** 是否透明状态栏,默认 false */
open fun transparentStatusBar(): Boolean = false
/** 状态栏字体是否深色,默认 false(白色) */
open fun statusBarDarkFont(): Boolean = false
/** 利用反射初始化 ViewModel */
@Suppress("UNCHECKED_CAST")
open fun initViewModel() {
val type: Class<VM> =
(this.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class<VM>
mViewModel = ViewModelProvider(this)[type]
mViewModel.init()
}
/** 初始化 DataBinding,绑定 lifecycleOwner 和 viewModel */
private fun initDataBinding() {
mBinding = DataBindingUtil.setContentView(this, contentViewResId)
mBinding.apply {
lifecycleOwner = this@BaseVMBActivity
setVariable(BR.viewModel, mViewModel)
}
}
/** View 初始化(子类实现) */
abstract fun initView(savedInstanceState: Bundle?)
/** 数据初始化(子类实现) */
abstract fun initData()
/** 事件绑定(子类实现) */
protected abstract fun bindEvent()
/** 点击事件分发(子类实现) */
abstract fun processClick(v: View?)
fun showToast(message: String?) {
if (!message.isNullOrEmpty()) {
Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show()
}
}
/** 监听 LiveData 数据变化(子类可重写扩展) */
open fun createObserve() {
mViewModel.apply {
// 加载框状态监听
loadingDialog.observe(this@BaseVMBActivity) {
when (it) {
LOADING_STATE_SHOW -> dialog?.show()
LOADING_STATE_HIDE -> dialog?.dismiss()
}
}
// 空状态监听
showEmpty.observe(this@BaseVMBActivity) {
if (isAutoEmpty.value == true && it) showEmpty() else hideEmpty()
}
// Toast 消息监听
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
toastMessage.collect { message ->
message?.let {
Toast.makeText(
this@BaseVMBActivity, message, Toast.LENGTH_SHORT
).show()
}
}
}
}
// 网络异常监听
exception.observe(this@BaseVMBActivity) {
requestError(it.message)
when (it) {
is HttpException -> {
if (it.code() == 401) {
// TODO: 跳转登录页
} else {
showToast(getString(R.string.network_error))
}
}
else -> showToast(getString(R.string.network_error))
}
}
// 服务器业务错误监听
errorResponse.observe(this@BaseVMBActivity) {
requestError(it?.msg)
when (it?.code) {
ApiCode.LOGIN_ERROR.code -> {
// TODO: 跳转登录页
}
ApiCode.SYSTEM_EXECUTION_ERROR.code -> {
showToast(if (!it.msg.isNullOrEmpty()) it.msg else getString(R.string.network_error))
}
else -> showToast(getString(R.string.network_error))
}
}
}
}
/** 请求出错时的统一处理(关闭加载框等) */
open fun requestError(msg: String?) {
mViewModel.loadingDialog.value = LOADING_STATE_HIDE
}
override fun onDestroy() {
dialog?.dismiss()
dialog = null
mViewModel.loadingDialog.value = LOADING_STATE_HIDE
EventBus.getDefault().unregister(this)
CustomActivityManager.getInstance().removeActivity(this)
super.onDestroy()
}
@Subscribe(threadMode = ThreadMode.MAIN)
open fun onMessageEvent(event: Any?) {
try {
if (event is GlobalEvent && event.message == 0) {
// TODO: 全局强制登出处理
}
} catch (e: Exception) {
// 忽略事件处理异常
}
}
/** 跳转指定 Activity */
fun toActivity(clazz: Class<*>?) {
mActivity?.startActivity(Intent(mActivity, clazz))
}
/** 携带参数跳转指定 Activity */
fun toActivity(clazz: Class<*>?, bundle: Bundle?) {
val intent = Intent(mActivity, clazz)
bundle?.let { intent.putExtras(it) }
mActivity?.startActivity(intent)
}
/** 创建加载对话框 */
private fun createDialog() {
dialog = LoadingDialog(mContext, ProgressDialog.STYLE_SPINNER, "数据加载中")
dialog!!.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog!!.setCanceledOnTouchOutside(false)
dialog!!.setCancelable(false)
dialog!!.setMessage("请稍后...")
}
/** 字体大小不跟随系统设置 */
override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(getConfigurationContext(newBase))
}
private fun getConfigurationContext(context: Context): Context {
val configuration = context.resources.configuration
configuration.fontScale = 1f
return context.createConfigurationContext(configuration)
}
}
@@ -0,0 +1,299 @@
package com.sw.healthexpertclient.base
import android.app.ProgressDialog
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.widget.RelativeLayout
import android.widget.TextView
import android.widget.Toast
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.sw.healthexpertclient.BR
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel.Companion.LOADING_STATE_HIDE
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel.Companion.LOADING_STATE_SHOW
import com.sw.healthexpertclient.data.bean.ApiCode
import com.sw.healthexpertclient.event.GlobalEvent
import com.sw.healthexpertclient.utils.StatusbarUtil
import com.sw.healthexpertclient.view.LoadingDialog
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.lang.reflect.ParameterizedType
/**
* 封装了 ViewModel 和 DataBinding 的 Fragment 基类
*
* 功能:
* - 自动反射初始化 ViewModel
* - DataBinding 绑定生命周期
* - EventBus 自动注册/注销
* - 懒加载数据(onResume 首次触发)
* - 统一加载框管理
* - 统一网络异常/业务错误处理
* - 空状态页面
*/
abstract class BaseVMBFragment<VM : BaseViewModel, B : ViewDataBinding>(
private val contentViewResId: Int
) : Fragment(), View.OnClickListener {
var mRootView: RelativeLayout? = null
/** 是否第一次加载(用于懒加载控制) */
private var mIsFirstLoading = true
var dialog: LoadingDialog? = null
protected lateinit var mViewModel: VM
lateinit var mBinding: B
var mEmpty: View? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
mBinding = DataBindingUtil.inflate(inflater, contentViewResId, container, false)
return mBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (!EventBus.getDefault().isRegistered(this)) {
EventBus.getDefault().register(this)
}
createDialog()
mIsFirstLoading = true
initViewModel()
onDrawFinish()
initView(view, savedInstanceState)
initData()
setupDataBinding()
createObserve()
initToolBar()
bindEvent()
}
/** 利用反射初始化 ViewModel */
@Suppress("UNCHECKED_CAST")
open fun initViewModel() {
val type: Class<VM> =
(this.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class<VM>
mViewModel = ViewModelProvider(this)[type]
mViewModel.init()
}
/** DataBinding 相关设置,绑定 lifecycleOwner 和 viewModel */
private fun setupDataBinding() {
mBinding.apply {
lifecycleOwner = viewLifecycleOwner
setVariable(BR.viewModel, mViewModel)
}
}
/** 布局绘制完成后初始化空状态视图 */
open fun onDrawFinish() {
mRootView = mBinding.root.findViewById<RelativeLayout>(R.id.rl_empty_root_view)
mRootView?.let {
mEmpty = LayoutInflater.from(context)
.inflate(R.layout.layout_empty, it, false).apply {
findViewById<TextView>(R.id.tv_empty).text = "暂无数据"
}
val layoutParams = RelativeLayout.LayoutParams(
RelativeLayout.LayoutParams.MATCH_PARENT,
RelativeLayout.LayoutParams.MATCH_PARENT
)
layoutParams.addRule(RelativeLayout.BELOW, R.id.toolbar_lay)
mEmpty?.layoutParams = layoutParams
}
}
/** 显示空状态页面 */
fun showEmpty(emptyMessage: String = "暂无数据", bgColor: Int = 0) {
try {
if (bgColor != 0) mEmpty?.setBackgroundColor(bgColor)
mEmpty?.findViewById<TextView>(R.id.tv_empty)?.text = emptyMessage
if (mEmpty?.parent == null) {
mRootView?.addView(mEmpty)
} else {
mRootView?.removeView(mEmpty)
mRootView?.addView(mEmpty)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/** 隐藏空状态页面 */
fun hideEmpty() {
try {
mRootView?.removeView(mEmpty)
} catch (e: Exception) {
// 忽略移除异常
}
}
/** 初始化标题栏(沉浸式状态栏适配) */
private fun initToolBar() {
if (transparentStatusBar()) {
var titleLay: View? = mBinding.root.findViewById(R.id.rl_title_lay)
if (titleLay == null) titleLay = mBinding.root.findViewById(R.id.toolbar_lay)
fitTransparentStatusBar(titleLay)
}
}
/** 为指定 View 添加状态栏高度的 padding */
fun fitTransparentStatusBar(view: View?) {
view?.let {
val statusHeight = StatusbarUtil.getStatusBarHeight(requireContext())
it.setPadding(0, statusHeight, 0, 0)
val height: Int = it.layoutParams?.height ?: 0
it.layoutParams.height = height + statusHeight
}
}
/** 是否透明状态栏,默认 false */
open fun transparentStatusBar(): Boolean = false
override fun onResume() {
super.onResume()
// 懒加载:第一次进入时触发
if (lifecycle.currentState == Lifecycle.State.STARTED && mIsFirstLoading) {
lazyLoadData()
mIsFirstLoading = false
}
}
/** 批量注册点击事件 */
fun addClickViews(vararg views: View) {
for (view in views) view.setOnClickListener(this)
}
/** 数据懒加载,在 Fragment 首次可见时调用(子类可重写) */
open fun lazyLoadData() {}
/** 监听 LiveData 数据变化(子类可重写扩展) */
open fun createObserve() {
mViewModel.apply {
// 加载框状态监听
loadingDialog.observe(viewLifecycleOwner) {
when (it) {
LOADING_STATE_SHOW -> dialog?.show()
LOADING_STATE_HIDE -> dialog?.dismiss()
}
}
// Toast 消息监听
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
toastMessage.collect { message ->
message?.let {
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
}
}
}
}
// 网络异常监听
exception.observe(viewLifecycleOwner) {
requestError()
Toast.makeText(requireContext(), getString(R.string.network_error), Toast.LENGTH_SHORT).show()
}
// 服务器业务错误监听
errorResponse.observe(viewLifecycleOwner) {
requestError(it?.msg)
when (it?.code) {
ApiCode.LOGIN_ERROR.code -> {
// TODO: 跳转登录页
requireActivity().finish()
}
ApiCode.SYSTEM_EXECUTION_ERROR.code -> {
val message = if (!it.msg.isNullOrEmpty()) it.msg
else getString(R.string.network_error)
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
}
else -> Toast.makeText(requireContext(), getString(R.string.network_error), Toast.LENGTH_SHORT).show()
}
}
}
}
/** 请求出错时的统一处理(关闭加载框等) */
open fun requestError(msg: String? = null) {
mViewModel.loadingDialog.value = LOADING_STATE_HIDE
}
override fun onDestroyView() {
dialog?.dismiss()
dialog = null
mBinding.unbind()
super.onDestroyView()
}
override fun onDestroy() {
super.onDestroy()
if (EventBus.getDefault().isRegistered(this)) {
EventBus.getDefault().unregister(this)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
open fun onMessageEvent(event: Any?) {
try {
if (event is GlobalEvent) {
// 子类可重写处理全局事件
}
} catch (e: Exception) {
// 忽略事件处理异常
}
}
fun showToast(message: String?) {
if (!message.isNullOrEmpty() && context != null) {
try {
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
// 忽略 Toast 异常
}
}
}
/** 跳转指定 Activity */
fun toActivity(clazz: Class<*>?) {
activity?.startActivity(Intent(activity, clazz))
}
/** 携带参数跳转指定 Activity */
fun toActivity(clazz: Class<*>?, bundle: Bundle?) {
val intent = Intent(activity, clazz)
bundle?.let { intent.putExtras(it) }
activity?.startActivity(intent)
}
/** 创建加载对话框 */
private fun createDialog() {
dialog = LoadingDialog(activity, ProgressDialog.STYLE_SPINNER, "数据加载中")
dialog!!.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog!!.setCanceledOnTouchOutside(false)
dialog!!.setCancelable(false)
dialog!!.setMessage("请稍后")
}
/** View 初始化(子类实现) */
abstract fun initView(root: View?, savedInstanceState: Bundle?)
/** 数据初始化(子类可重写) */
protected open fun initData() {}
/** 事件绑定(子类实现) */
protected abstract fun bindEvent()
override fun onClick(v: View?) {}
}
@@ -0,0 +1,12 @@
package com.sw.healthexpertclient.base.repository
import com.sw.healthexpertclient.data.bean.ApiResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Repository 数据仓库基类,主要用于协程的调用 */
open class BaseRepository {
suspend fun <T> apiCall(api: suspend () -> ApiResponse<T>): ApiResponse<T> {
return withContext(Dispatchers.IO) { api.invoke() }
}
}
@@ -0,0 +1,49 @@
package com.sw.healthexpertclient.base.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.sw.healthexpertclient.data.bean.ApiResponse
import kotlinx.coroutines.flow.MutableStateFlow
/**
* ViewModel 基类
*
* 统一管理:
* - 加载框显示/隐藏
* - 网络异常(连接超时等)
* - 服务器业务错误(登录过期等)
* - Toast 消息
* - 空状态页面控制
*/
abstract class BaseViewModel : ViewModel() {
companion object {
/** 显示加载框 */
const val LOADING_STATE_SHOW = 1
/** 隐藏加载框 */
const val LOADING_STATE_HIDE = 2
}
/** 加载框控制:值为 LOADING_STATE_SHOW / LOADING_STATE_HIDE */
var loadingDialog = MutableLiveData<Int>()
/** 网络请求失败异常(如:连接超时) */
val exception = MutableLiveData<Exception>()
/** 服务器返回业务错误(请求成功但 code 非正常) */
val errorResponse = MutableLiveData<ApiResponse<*>?>()
/** Toast 消息文本 */
var toastMessage = MutableStateFlow<String?>(null)
/** 是否自动展示空页面(数据为空时自动触发) */
var isAutoEmpty = MutableLiveData<Boolean>()
/** 是否展示空页面 */
var showEmpty = MutableLiveData<Boolean>(false)
/**
* 界面初始化时调用,子类实现网络请求或数据初始化
*/
abstract fun init()
}
@@ -0,0 +1,12 @@
package com.sw.healthexpertclient.base.viewmodel
/**
* 通用 ViewModel
*
* 用于不需要独立业务逻辑的页面(如 MainActivity、纯展示页等)
*/
class CommonViewModel : BaseViewModel() {
override fun init() {
// 通用 ViewModel 无需初始化逻辑
}
}
@@ -0,0 +1,17 @@
package com.sw.healthexpertclient.bean
data class AppUpdateBean(
var appId: String = "",
var appName: String = "",
var appStatus: Int = 0,
var appUrl: String = "",
var deviceType: Int = 0,
var forced: Int = 0,
var id: String = "",
var ignoreFlag: Int = 0,
var updateContent: String = "",
var updateTime: Any? = null,
var versionName: String = "",
var yybUrl: String = "",
var versionNo: Int = 0
)
@@ -0,0 +1,9 @@
package com.sw.healthexpertclient.bean.common
/**
* 配置菜单条目数据模型
*/
data class CommonSettingMenuBean(
val value: String = "",
val text: String = ""
)
@@ -0,0 +1,10 @@
package com.sw.healthexpertclient.bean.common
/**
* 文件上传接口返回数据
*/
data class UploadFileBean(
val baseUrl: String?,
val path: String?,
val size: Long?
)
@@ -0,0 +1,8 @@
package com.sw.healthexpertclient.bean.login
data class LoginBean(
val loginType: Int,
val account: String,
val password: String = "",
val smsCode: String = ""
)
@@ -0,0 +1,9 @@
package com.sw.healthexpertclient.bean.login
import com.sw.sdk.core.bean.common.TokenInfoBean
import com.sw.sdk.core.bean.common.UserInfoBean
data class LoginInfoBean(
var saTokenInfo: TokenInfoBean,
var userInfo: UserInfoBean
)
@@ -0,0 +1,17 @@
package com.sw.healthexpertclient.bean.phone
/**
* 手机号列表条目
*/
data class PhoneBean(
val phone: String,
val isDefault: Boolean
)
/**
* 忘记密码身份核验返回参数
*/
data class ForgetPasswordBean(
val resetToken: String,
val userId: String
)
@@ -0,0 +1,399 @@
package com.sw.healthexpertclient.bottomtab
import android.content.Context
import android.content.res.TypedArray
import android.text.TextUtils
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.viewpager.widget.ViewPager
import com.sw.healthexpertclient.R
import java.io.IOException
/**
* 自定义底部 Tab 导航控件
*
* 支持两种模式:
* 1. Fragment 容器模式(show/hide 切换,节省内存)
* 2. ViewPager 模式(滑动切换)
*
* Tab 配置从 assets 目录的 JSON 文件中动态读取,
* 通过 app:config_file_name 属性指定配置文件名,
* 通过 app:container_id 或 app:viewPager_id 属性指定容器。
*
* 使用步骤:
* 1. 在布局中声明控件并设置属性
* 2. 调用 setHomeBottomTabLayoutCallback() 设置回调
* 3. 调用 initFirstTab() 初始化默认 Tab
*/
class HomeBottomTabLayout(context: Context, attrs: AttributeSet?) :
LinearLayout(context, attrs), View.OnClickListener {
private lateinit var mTabConfig: TabConfig
private var mFragmentList = ArrayList<Fragment?>()
private var mTabContainerList: ArrayList<ViewGroup>? = null
private var mOverSideTab = ArrayList<View>(1)
private var mCurrentSelectedIndex = 0
private var mViewPager: ViewPager? = null
private var mFragmentManager: FragmentManager? = null
private var mCallback: HomeBottomTabLayoutCallback? = null
private var mFragmentContainerId = -1
private var mViewPagerId = -1
private var isCurrentViewPager = false
private var mIsCanClickTab = true
init {
val array: TypedArray =
context.obtainStyledAttributes(attrs, R.styleable.HomeBottomTabLayout)
mFragmentContainerId =
array.getResourceId(R.styleable.HomeBottomTabLayout_container_id, -1)
mViewPagerId =
array.getResourceId(R.styleable.HomeBottomTabLayout_viewPager_id, -1)
val configFile =
array.getString(R.styleable.HomeBottomTabLayout_config_file_name)
isCurrentViewPager = mViewPagerId != -1
array.recycle()
this.orientation = HORIZONTAL
this.clipChildren = false
mFragmentManager = (context as FragmentActivity).supportFragmentManager
configFile?.let {
try {
val tabConfigFileInputStream = context.assets.open(it)
mTabConfig =
TabUtils.parseJsonToConfig(tabConfigFileInputStream)
?: throw IllegalArgumentException("配置文件解析失败,请检查 $it 格式")
} catch (e: IOException) {
throw IllegalArgumentException("请检查 $it 是否存在于 assets 目录中!")
}
} ?: throw IllegalArgumentException("请使用 config_file_name 属性设置配置文件名称!")
initTabs()
}
/** 获取 Fragment 数量 */
fun getFragmentSize(): Int = mFragmentList.size
/** 初始化所有 Tab 视图 */
private fun initTabs() {
if (mTabConfig.tabs.isNullOrEmpty()) return
mTabContainerList = ArrayList(mTabConfig.tabs.size)
mTabConfig.tabs.forEach { tab ->
generateItemView(tab)
mFragmentList.add(null)
}
// 处理超出边界的 Tab(中间凸起)
mOverSideTab.forEach {
viewTreeObserver.addOnGlobalLayoutListener(object :
ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
val layoutParams = it.layoutParams as MarginLayoutParams
val height = (it.height * 2.5f).toInt()
layoutParams.height = height
layoutParams.width = height
if (!mTabConfig.isTitleVisible) {
layoutParams.setMargins(0, 0, 0, (it.height * 1.5f).toInt())
}
it.layoutParams = layoutParams
viewTreeObserver.removeOnGlobalLayoutListener(this)
mOverSideTab.remove(it)
}
})
}
}
/**
* 初始化默认显示的 Tab
*
* 必须在 setHomeBottomTabLayoutCallback() 之后调用
* @param index 默认选中的 Tab 索引(从 0 开始)
*/
fun initFirstTab(index: Int) {
if (index < 0 || index > (mTabContainerList?.size ?: 0)) {
throw IndexOutOfBoundsException("Tab 索引超出范围:$index")
}
if (mCallback == null) {
throw IllegalStateException("请先调用 setHomeBottomTabLayoutCallback() 设置回调")
}
if (isCurrentViewPager) {
setupViewPager(index)
} else {
changeTab(index)
}
}
/** 隐藏指定索引的 Tab */
fun hideTab(index: Int) {
if (index < 0 || index > (mTabContainerList?.size ?: 0)) {
throw IndexOutOfBoundsException("Tab 索引超出范围:$index")
}
mTabContainerList?.get(index)?.visibility = View.GONE
}
/**
* 通过 Tag 切换到对应 Tab
*
* @param tabTag 目标 Tab 的 tag 标识
*/
fun selectByTag(tabTag: String) {
mTabConfig.tabs.forEachIndexed { index, tabConfig ->
if (tabTag == tabConfig.tabTag) {
changeTab(index)
return
}
}
}
/** 获取当前选中 Tab 的 tag 标识 */
fun getCurrentSelectedTag(): String = mTabConfig.tabs[mCurrentSelectedIndex].tabTag
/** 获取当前选中 Tab 的索引 */
fun getCurrentSelectedIndex(): Int = mCurrentSelectedIndex
/** 获取当前选中的 Fragment */
fun getCurrentSelectedFragment(): Fragment? = mFragmentList[mCurrentSelectedIndex]
/** 根据 tag 查找已创建的 Fragment */
fun getFragmentByTag(tabTag: String): Fragment? =
mFragmentManager?.findFragmentByTag(tabTag)
/** 获取所有 Fragment 列表 */
fun getFragmentList(): List<Fragment?>? = mFragmentList
/**
* 设置 Tab 是否可点击(如:登录验证中禁用 Tab 切换)
*
* @param isCanClick 是否允许点击
*/
fun setIsCanClickTab(isCanClick: Boolean) {
this.mIsCanClickTab = isCanClick
}
/**
* 设置未读消息提示
*
* @param tag 目标 Tab 的 tag 标识
* @param countStr 未读数量文字(null 时显示红点)
* @param isShowCount true=显示数量气泡,false=显示小红点
*/
fun setUnreadTip(tag: String, countStr: String?, isShowCount: Boolean = true) {
mTabConfig.tabs.forEachIndexed { index, tabConfig ->
if (tag == tabConfig.tabTag) {
if (isShowCount) {
val tvUnRead = mTabContainerList?.get(index)
?.findViewById<TextView>(R.id.TabItem_tvUnReadTip)
tvUnRead?.visibility = View.VISIBLE
tvUnRead?.text = countStr
} else {
val viewPoint = mTabContainerList?.get(index)
?.findViewById<View>(R.id.TabItem_viewPoint)
viewPoint?.visibility = View.VISIBLE
}
return
}
}
}
/**
* 隐藏未读消息提示
*
* @param tag 目标 Tab 的 tag 标识
*/
fun hideUnReadTip(tag: String) {
mTabConfig.tabs.forEachIndexed { index, tabConfig ->
if (tag == tabConfig.tabTag) {
mTabContainerList?.get(index)
?.findViewById<TextView>(R.id.TabItem_tvUnReadTip)?.visibility = View.GONE
mTabContainerList?.get(index)
?.findViewById<View>(R.id.TabItem_viewPoint)?.visibility = View.GONE
return
}
}
}
/** 初始化 ViewPager 模式 */
private fun setupViewPager(selectedIndex: Int) {
mViewPager = rootView.findViewById(mViewPagerId)
mTabConfig.tabs.forEachIndexed { index, itemConfig ->
mFragmentList[index] = mCallback!!.getFragmentByTag(itemConfig.tabTag)
}
val map = mFragmentList.map { it!! }
mFragmentManager?.let {
mViewPager?.adapter = TabFragmentPageAdapter(it, map)
}
mViewPager?.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrollStateChanged(state: Int) {}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
override fun onPageSelected(position: Int) {
setTabSelected(position)
}
})
mViewPager?.currentItem = selectedIndex
}
override fun onClick(v: View?) {
if (!mIsCanClickTab) return
var tempIndex = 0
val tag = v?.tag as String
mTabContainerList?.forEachIndexed { index, viewGroup ->
if ((viewGroup.tag as String) == tag) {
tempIndex = index
return@forEachIndexed
}
}
changeTab(tempIndex)
mCallback?.onClickChangeTab(tempIndex, tag)
}
/**
* 切换 Tab 操作(分两步:切换按钮状态 + 切换 Fragment
*
* @param selectedIndex 目标 Tab 索引
*/
fun changeTab(selectedIndex: Int) {
if (isCurrentViewPager) {
mViewPager?.currentItem = selectedIndex
} else {
setTabSelected(selectedIndex)
setFragmentSelected(selectedIndex)
}
mCurrentSelectedIndex = selectedIndex
}
/** 切换按钮选中状态(图标 + 文字颜色) */
private fun setTabSelected(selectedIndex: Int) {
mTabContainerList?.forEachIndexed { index, viewGroup ->
val ivIcon = viewGroup.findViewById<ImageView>(R.id.TabItem_ivIcon)
val tvName = viewGroup.findViewById<TextView>(R.id.TabItem_tvTitle)
val itemConfig = mTabConfig.tabs[index]
if (index == selectedIndex) {
tvName.setTextColor(mTabConfig.tabSelectedColor)
tvName.setTextSize(TypedValue.COMPLEX_UNIT_DIP, mTabConfig.tabSelectedTextSize.toFloat())
ivIcon.setImageResource(TabUtils.getResourceDrawableId(context, itemConfig.iconSelected))
viewGroup.isEnabled = false
} else {
tvName.setTextColor(mTabConfig.tabNormalColor)
tvName.setTextSize(TypedValue.COMPLEX_UNIT_DIP, mTabConfig.tabNormalTextSize.toFloat())
ivIcon.setImageResource(TabUtils.getResourceDrawableId(context, itemConfig.iconNormal))
viewGroup.isEnabled = true
}
}
}
/** 切换 Fragmentshow/hide 方式,避免重复创建) */
private fun setFragmentSelected(selectedIndex: Int) {
val beginTransaction = mFragmentManager?.beginTransaction()
val itemConfig = mTabConfig.tabs[selectedIndex]
var fragmentByTag = mFragmentManager?.findFragmentByTag(itemConfig.tabTag)
val isFirstInit: Boolean
if (fragmentByTag == null) {
fragmentByTag = mCallback?.getFragmentByTag(itemConfig.tabTag)
mFragmentList[selectedIndex] = fragmentByTag
isFirstInit = true
} else {
isFirstInit = false
}
// 隐藏所有已显示的 Fragment
mFragmentList.forEach { fragment ->
if (fragment != null && fragment.isAdded) {
beginTransaction?.hide(fragment)
}
}
fragmentByTag?.let {
if (isFirstInit) {
beginTransaction?.add(mFragmentContainerId, it, itemConfig.tabTag)
} else {
beginTransaction?.show(it)
}
}
beginTransaction?.commitAllowingStateLoss()
}
/** 生成单个 Tab 视图并添加到底部栏 */
private fun generateItemView(itemConfig: ItemConfig) {
val view: ViewGroup =
View.inflate(context, R.layout.bottom_tab_item_layout, null) as ViewGroup
val tvName = view.findViewById<TextView>(R.id.TabItem_tvTitle)
val ivIcon = view.findViewById<ImageView>(R.id.TabItem_ivIcon)
// 设置 Tab 标题
if (mTabConfig.isTabNameResId) {
tvName.text = TabUtils.getStringByResId(context, itemConfig.tabName)
} else {
tvName.text = itemConfig.tabName
}
tvName.setTextSize(TypedValue.COMPLEX_UNIT_DIP, mTabConfig.tabNormalTextSize.toFloat())
tvName.setTextColor(mTabConfig.tabNormalColor)
// 设置 Tab 图标
ivIcon.setImageResource(TabUtils.getResourceDrawableId(context, itemConfig.iconNormal))
// 设置 Tab 图标背景(可选)
if (!TextUtils.isEmpty(itemConfig.itemBg)) {
ivIcon.setBackgroundResource(
TabUtils.getResourceDrawableId(context, itemConfig.itemBg!!)
)
}
// 中间凸起样式处理
if (itemConfig.isOverSide) {
mOverSideTab.add(ivIcon)
}
// 标题不可见时隐藏文字
if (!mTabConfig.isTitleVisible) {
tvName.visibility = View.GONE
}
val weight = if (itemConfig.isOverSide) 1.1f else 1f
val params = LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, weight)
view.setOnClickListener(this)
view.tag = itemConfig.tabTag
this.addView(view, params)
mTabContainerList?.add(view)
}
/**
* 设置 Tab 回调监听(必须在 initFirstTab() 之前调用)
*
* @param homeBottomTabLayoutCallback 实现 HomeBottomTabLayoutCallback 的对象
*/
fun setHomeBottomTabLayoutCallback(homeBottomTabLayoutCallback: HomeBottomTabLayoutCallback?) {
this.mCallback = homeBottomTabLayoutCallback
}
/**
* Tab 回调接口
*
* Activity 需实现此接口来提供 Fragment 实例并响应 Tab 切换事件
*/
interface HomeBottomTabLayoutCallback {
/**
* 根据 Tab 的 tag 标识返回对应的 Fragment 实例
*
* @param tabTag Tab 的唯一标识(与 JSON 配置一致)
* @return 对应的 Fragment 实例,返回 null 或空 Fragment 表示无对应页面
*/
fun getFragmentByTag(tabTag: String): Fragment?
/**
* Tab 点击切换时的回调
*
* @param selectedIndex 当前选中 Tab 的索引
* @param selectedTag 当前选中 Tab 的 tag 标识
*/
fun onClickChangeTab(selectedIndex: Int, selectedTag: String?)
}
}
@@ -0,0 +1,43 @@
package com.sw.healthexpertclient.bottomtab
import java.io.Serializable
/**
* Tab 整体配置数据类
*
* @param tabNormalColor 未选中状态文字颜色(ARGB Int)
* @param tabSelectedColor 选中状态文字颜色(ARGB Int)
* @param tabNormalTextSize 未选中状态文字大小(dp)
* @param tabSelectedTextSize 选中状态文字大小(dp
* @param isTabNameResId Tab 名称是否为字符串资源 IDtrue=resIdfalse=直接文字)
* @param isTitleVisible 标题文字是否可见
* @param tabs 各 Tab 项配置列表
*/
data class TabConfig(
var tabNormalColor: Int,
var tabSelectedColor: Int,
var tabNormalTextSize: Int,
var tabSelectedTextSize: Int,
var isTabNameResId: Boolean = true,
var isTitleVisible: Boolean = true,
var tabs: List<ItemConfig>
) : Serializable
/**
* 单个 Tab 项配置数据类
*
* @param tabName Tab 显示名称(或字符串资源 ID 名)
* @param tabTag Tab 唯一标识(与 Fragment 对应)
* @param iconNormal 未选中图标资源名(drawable 名称)
* @param iconSelected 选中图标资源名(drawable 名称)
* @param isOverSide 是否超出底部栏(中间凸起样式)
* @param itemBg Tab 项背景资源名(可选)
*/
data class ItemConfig(
var tabName: String,
var tabTag: String,
var iconNormal: String,
var iconSelected: String,
var isOverSide: Boolean = false,
var itemBg: String? = null
) : Serializable
@@ -0,0 +1,21 @@
package com.sw.healthexpertclient.bottomtab
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
/**
* Tab Fragment 适配器(ViewPager 模式使用)
*
* 使用 BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT 策略,
* 确保只有当前可见的 Fragment 处于 RESUMED 状态
*/
class TabFragmentPageAdapter(
fragmentManager: FragmentManager,
private val fragments: List<Fragment>
) : FragmentPagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
override fun getItem(position: Int): Fragment = fragments[position]
override fun getCount(): Int = fragments.size
}
@@ -0,0 +1,110 @@
package com.sw.healthexpertclient.bottomtab
import android.content.Context
import android.graphics.Color
import android.text.TextUtils
import org.json.JSONException
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.util.regex.Pattern
/**
* Tab 工具类
*
* 提供 JSON 配置解析、资源 ID 获取、颜色校验等工具方法
*/
object TabUtils {
/**
* 检查颜色字符串是否合法(支持 #rgb / #rrggbb / #aarrggbb 格式)
*/
fun isColorLegal(color: String): Boolean {
if (TextUtils.isEmpty(color)) return false
val regix1 = "^#[0-9a-fA-F]{6}"
val regix2 = "^#[0-9a-fA-F]{8}"
val regix3 = "^#[0-9a-fA-F]{3}"
if (Pattern.compile(regix1).matcher(color).find()) return true
if (Pattern.compile(regix2).matcher(color).find()) return true
if (Pattern.compile(regix3).matcher(color).find()) return true
return false
}
/**
* 解析 assets 中的 JSON 配置文件为 TabConfig 对象
*
* @param inputStream JSON 文件输入流
* @return 解析成功返回 TabConfig,失败返回 null
*/
fun parseJsonToConfig(inputStream: InputStream): TabConfig? {
val resultArray = ByteArrayOutputStream()
val temp = ByteArray(256)
var read = inputStream.read(temp)
while (read != -1) {
resultArray.write(temp, 0, read)
read = inputStream.read(temp)
resultArray.flush()
}
val readJson = resultArray.toString("utf-8")
inputStream.close()
resultArray.close()
if (readJson.isEmpty()) return null
return try {
val obj = JSONObject(readJson)
val textColorNormal = obj.optString("textColorNormal")
val textColorSelected = obj.optString("textColorSelected")
val normalColor = if (isColorLegal(textColorNormal)) Color.parseColor(textColorNormal) else Color.BLACK
val selectedColor = if (isColorLegal(textColorSelected)) Color.parseColor(textColorSelected) else Color.GRAY
val textSizeNormal = obj.optInt("textSizeNormal", 11)
val textSizeSelected = obj.optInt("textSizeSelected", 11)
val isTabNameResId = obj.optBoolean("isNameResId", false)
val isTitleVisible = obj.optBoolean("isTitleVisible", true)
val itemList = ArrayList<ItemConfig>()
val tabs = obj.optJSONArray("tabs")
if (tabs != null && tabs.length() > 0) {
for (i in 0 until tabs.length()) {
val tabJson = tabs.optJSONObject(i)
val tabName = tabJson.optString("tabName")
val tabTag = tabJson.optString("tabTag")
val iconNormal = tabJson.optString("iconNormal")
val iconSelected = tabJson.optString("iconSelected")
val isOverSide = tabJson.optBoolean("isOverSide", false)
val itemBg = tabJson.optString("itemBg", "")
itemList.add(
ItemConfig(tabName, tabTag, iconNormal, iconSelected, isOverSide, itemBg)
)
}
}
TabConfig(normalColor, selectedColor, textSizeNormal, textSizeSelected, isTabNameResId, isTitleVisible, itemList)
} catch (e: JSONException) {
e.printStackTrace()
null
}
}
/**
* 根据 drawable 资源名获取资源 ID
*
* @param context Context
* @param drawableName drawable 资源名(不含扩展名)
* @return 资源 ID,未找到返回 0
*/
fun getResourceDrawableId(context: Context, drawableName: String): Int {
return context.resources.getIdentifier(drawableName, "drawable", context.packageName)
}
/**
* 根据字符串资源名获取字符串内容
*
* @param context Context
* @param strResId 字符串资源名(不含 R.string. 前缀)
* @return 字符串内容
*/
fun getStringByResId(context: Context, strResId: String): String {
val resId = context.resources.getIdentifier(strResId, "string", context.packageName)
return context.resources.getString(resId)
}
}
@@ -0,0 +1,10 @@
package com.sw.healthexpertclient.data.api
/**
* 接口路径前缀常量
*/
object ApiConfig {
const val HEALTH_SYS = "sys"
const val HEALTH_ASSESSMENT = "platform-assessment"
const val HEALTH_WATCH = "platform-watch"
}
@@ -0,0 +1,12 @@
package com.sw.healthexpertclient.data.api
import com.sw.healthexpertclient.data.bean.ApiResponse
import okhttp3.RequestBody
import retrofit2.http.Body
import retrofit2.http.POST
interface GuidanceApi {
/** 咨询评价 */
@POST("health-consultation/api/consult/conEvaluate/insertConEvaluate")
suspend fun submitConsultAppraise(@Body requestBody: RequestBody): ApiResponse<String>
}
@@ -0,0 +1,91 @@
package com.sw.healthexpertclient.data.api
import com.sw.healthexpertclient.bean.common.UploadFileBean
import com.sw.sdk.core.bean.common.UserInfoBean
import com.sw.healthexpertclient.bean.login.LoginInfoBean
import com.sw.healthexpertclient.bean.phone.ForgetPasswordBean
import com.sw.healthexpertclient.bean.phone.PhoneBean
import com.sw.healthexpertclient.data.bean.ApiResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.QueryMap
import retrofit2.http.Streaming
import retrofit2.http.Url
/**
* 用户信息相关接口
*/
interface UserInfoApi {
/** 发送短信验证码 */
@POST("${ApiConfig.HEALTH_SYS}/auth/sms/sendCode")
suspend fun sendSmsCode(@Body body: RequestBody): ApiResponse<String?>
/** 用户登录 */
@POST("${ApiConfig.HEALTH_SYS}/auth/appLogin")
suspend fun mLogin(@Body body: RequestBody): ApiResponse<LoginInfoBean>
/** 身份验证(忘记密码第一步) */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/password/verify")
suspend fun verifyIdentity(@Body body: RequestBody): ApiResponse<ForgetPasswordBean?>
/** 下载文件 */
@Streaming
@GET
suspend fun downloadFile(@Url bookUrl: String?): ResponseBody
/** 查询个人信息 */
@GET("${ApiConfig.HEALTH_SYS}/sys-user/app/userInfo")
suspend fun getUserInfo(): ApiResponse<UserInfoBean>
/** 更新用户头像 */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/avatar")
suspend fun updateAvatar(@Body body: RequestBody): ApiResponse<String?>
/** 设置默认手机号 */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/phone/setDefault")
suspend fun setDefaultPhone(@QueryMap params: MutableMap<String, Any?>): ApiResponse<String?>
/** 新增手机号 */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/phone/add")
suspend fun addPhone(@Body body: RequestBody): ApiResponse<String?>
/** 身份证核验 */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/idCard/verify")
suspend fun verifyIdCard(@QueryMap params: MutableMap<String, Any?>): ApiResponse<String?>
/** 密码重置前的身份核验 */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/password/verify")
suspend fun verifyPasswordIdentity(@Body body: RequestBody): ApiResponse<String?>
/** 重置密码 */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/password/reset")
suspend fun resetPasswordByToken(@Body body: RequestBody): ApiResponse<String?>
/** 登录状态下修改密码 */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/password/change")
suspend fun changePassword(@Body body: RequestBody): ApiResponse<String?>
/** 查询用户手机号列表 */
@GET("${ApiConfig.HEALTH_SYS}/sys-user/app/phoneList")
suspend fun getPhoneList(): ApiResponse<List<PhoneBean>>
/** 删除手机号 */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/phone/delete")
suspend fun deletePhone(@QueryMap params: MutableMap<String, Any?>): ApiResponse<String?>
/** 验证原手机号短信验证码 */
@POST("${ApiConfig.HEALTH_SYS}/sys-user/app/phone/verifyCode")
suspend fun verifyPhoneCode(@Body body: RequestBody): ApiResponse<Boolean?>
/** 上传文件 */
@Multipart
@POST("${ApiConfig.HEALTH_SYS}/file/upload")
suspend fun uploadFile(@Part file: MultipartBody.Part): ApiResponse<UploadFileBean>
}
@@ -0,0 +1,30 @@
package com.sw.healthexpertclient.data.bean
/**
* 服务器通用响应体
*/
data class ApiResponse<T>(
var code: String,
val msg: String?,
val total: Int?,
val data: T?
)
/**
* 是否请求成功
*/
val <T> ApiResponse<T>.isOk: Boolean
get() = ApiCode.SUCCESS.code == code
/**
* 统一接口返回码枚举
*/
enum class ApiCode(val code: String, val msg: String) {
SUCCESS("00000", "成功"),
FAILURE("99999", "失败"),
SYSTEM_EXECUTION_ERROR("A0500", "系统错误"),
PARAM_ERROR("A0400", "请求参数错误"),
LOGIN_ERROR("A0401", "请重新登录"),
RESOURCE_NOT_FOUND("A0404", "请求资源不存在"),
PARAM_IS_NULL("A0402", "请求必填参数为空"),
}
@@ -0,0 +1,30 @@
package com.sw.healthexpertclient.data.repository
import com.sw.healthexpertclient.base.repository.BaseRepository
import com.sw.healthexpertclient.data.api.GuidanceApi
import com.sw.healthexpertclient.data.bean.ApiResponse
import com.sw.healthexpertclient.retrofit.RetrofitManager
import com.sw.healthexpertclient.retrofit.RetrofitManager.toRequestBody
import com.sw.healthexpertclient.superfuntion.toJson
object GuidanceRepository : BaseRepository() {
private val service by lazy { RetrofitManager.getService(GuidanceApi::class.java) }
suspend fun submitConsultAppraise(
doctorId: String?,
sessionId: String?,
score: Float,
content: String,
anonymity: Boolean
): ApiResponse<String> {
val params = mutableMapOf<String, Any?>()
if (!doctorId.isNullOrEmpty()) {
params["expert"] = doctorId
}
params["sessionId"] = sessionId
params["score"] = score.toString()
params["context"] = content
params["tfUnknow"] = if (anonymity) "1" else "0"
return apiCall { service.submitConsultAppraise(params.toJson().toRequestBody()) }
}
}
@@ -0,0 +1,122 @@
package com.sw.healthexpertclient.data.repository
import com.sw.healthexpertclient.bean.common.UploadFileBean
import com.sw.sdk.core.bean.common.UserInfoBean
import com.sw.healthexpertclient.bean.login.LoginBean
import com.sw.healthexpertclient.bean.login.LoginInfoBean
import com.sw.healthexpertclient.bean.phone.ForgetPasswordBean
import com.sw.healthexpertclient.bean.phone.PhoneBean
import com.sw.healthexpertclient.data.api.UserInfoApi
import com.sw.healthexpertclient.data.bean.ApiResponse
import com.sw.healthexpertclient.data.repository.GuidanceRepository.apiCall
import com.sw.healthexpertclient.retrofit.RetrofitManager
import com.sw.healthexpertclient.retrofit.RetrofitManager.toRequestBody
import com.sw.healthexpertclient.superfuntion.toJson
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
/**
* 用户信息数据仓库
*/
object UserInfoRepository {
private val service by lazy { RetrofitManager.getService(UserInfoApi::class.java) }
/** 发送短信验证码 */
suspend fun sendSmsCode(phone: String, codeType: Int): ApiResponse<String?> {
val params = mapOf("phone" to phone, "codeType" to codeType)
return apiCall { service.sendSmsCode(params.toJson().toRequestBody()) }
}
/** 用户登录 */
suspend fun login(loginBean: LoginBean): ApiResponse<LoginInfoBean> {
return apiCall { service.mLogin(loginBean.toJson().toRequestBody()) }
}
/** 身份验证(忘记密码第一步) */
suspend fun verifyIdentity(name: String, phone: String, idCard: String): ApiResponse<ForgetPasswordBean?> {
val params = mapOf("realName" to name, "phone" to phone, "idCard" to idCard)
return apiCall { service.verifyIdentity(params.toJson().toRequestBody()) }
}
/** 查询个人信息 */
suspend fun getUserInfo(): ApiResponse<UserInfoBean> {
return apiCall { service.getUserInfo() }
}
/** 更新用户头像 */
suspend fun updateAvatar(id: String, avatar: String): ApiResponse<String?> {
val params = mapOf("id" to id, "avatar" to avatar)
return apiCall { service.updateAvatar(params.toJson().toRequestBody()) }
}
/** 设置默认手机号 */
suspend fun setDefaultPhone(phone: String): ApiResponse<String?> {
val params = mutableMapOf<String, Any?>()
params["phone"] = phone
return apiCall { service.setDefaultPhone(params) }
}
/** 新增手机号 */
suspend fun addPhone(phone: String, smsCode: String, isDefault: Boolean, oldPhone: String): ApiResponse<String?> {
val params = mapOf("phone" to phone, "smsCode" to smsCode, "isDefault" to isDefault, "oldPhone" to oldPhone)
return apiCall { service.addPhone(params.toJson().toRequestBody()) }
}
/** 身份证核验 */
suspend fun verifyIdCard(idCard: String): ApiResponse<String?> {
val params = mutableMapOf<String, Any?>()
params["idCard"] = idCard
return apiCall { service.verifyIdCard(params) }
}
/** 密码重置前的身份核验 */
suspend fun verifyPasswordIdentity(realName: String, phone: String, idCard: String): ApiResponse<String?> {
val params = mapOf("realName" to realName, "phone" to phone, "idCard" to idCard)
return apiCall { service.verifyPasswordIdentity(params.toJson().toRequestBody()) }
}
/** 重置密码 */
suspend fun resetPasswordByToken(userId: String, resetToken: String, newPassword: String): ApiResponse<String?> {
val params = mapOf("userId" to userId, "resetToken" to resetToken, "newPassword" to newPassword)
return apiCall { service.resetPasswordByToken(params.toJson().toRequestBody()) }
}
/** 登录状态下修改密码 */
suspend fun changePassword(oldPassword: String, newPassword: String): ApiResponse<String?> {
val params = mapOf("oldPassword" to oldPassword, "newPassword" to newPassword)
return apiCall { service.changePassword(params.toJson().toRequestBody()) }
}
/** 查询用户手机号列表 */
suspend fun getPhoneList(): ApiResponse<List<PhoneBean>> {
return apiCall { service.getPhoneList() }
}
/** 删除手机号 */
suspend fun deletePhone(phone: String): ApiResponse<String?> {
val params = mutableMapOf<String, Any?>()
params["phone"] = phone
return apiCall { service.deletePhone(params) }
}
/** 验证原手机号短信验证码 */
suspend fun verifyPhoneCode(phone: String, smsCode: String): ApiResponse<Boolean?> {
val params = mapOf("phone" to phone, "smsCode" to smsCode)
return apiCall { service.verifyPhoneCode(params.toJson().toRequestBody()) }
}
/** 上传文件 */
suspend fun uploadFile(file: File): ApiResponse<UploadFileBean> {
val photoPart = MultipartBody.Part.createFormData(
"file", file.name,
file.asRequestBody(
android.webkit.MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(file.extension)
?.toMediaTypeOrNull()
)
)
return apiCall { service.uploadFile(photoPart) }
}
}
@@ -0,0 +1,7 @@
package com.sw.healthexpertclient.event
/**
* 全局事件,用于 EventBus 广播
* @param message 事件类型编码(0 = 强制登出)
*/
data class GlobalEvent(val message: Int)
@@ -0,0 +1,25 @@
package com.sw.healthexpertclient.fragment
import android.os.Bundle
import android.view.View
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.base.BaseVMBFragment
import com.sw.healthexpertclient.databinding.FragmentAichatBinding
import com.sw.healthexpertclient.fragment.viewmodel.AIChatViewModel
/**
* AI 助手 Fragment
*
* 底部导航第 3 个 Tab(AI助手)的页面容器
*/
class AIChatFragment :
BaseVMBFragment<AIChatViewModel, FragmentAichatBinding>(R.layout.fragment_aichat) {
override fun initView(root: View?, savedInstanceState: Bundle?) {}
override fun lazyLoadData() {
// TODO: 首次可见时初始化 AI 对话
}
override fun bindEvent() {}
}
@@ -0,0 +1,25 @@
package com.sw.healthexpertclient.fragment
import android.os.Bundle
import android.view.View
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.base.BaseVMBFragment
import com.sw.healthexpertclient.databinding.FragmentKnowledgeBinding
import com.sw.healthexpertclient.fragment.viewmodel.KnowledgeViewModel
/**
* 知识 Fragment
*
* 底部导航第 2 个 Tab(知识)的页面容器
*/
class KnowledgeFragment :
BaseVMBFragment<KnowledgeViewModel, FragmentKnowledgeBinding>(R.layout.fragment_knowledge) {
override fun initView(root: View?, savedInstanceState: Bundle?) {}
override fun lazyLoadData() {
// TODO: 首次可见时加载知识数据
}
override fun bindEvent() {}
}
@@ -0,0 +1,25 @@
package com.sw.healthexpertclient.fragment
import android.os.Bundle
import android.view.View
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.base.BaseVMBFragment
import com.sw.healthexpertclient.databinding.FragmentMessageBinding
import com.sw.healthexpertclient.fragment.viewmodel.MessageViewModel
/**
* 消息 Fragment
*
* 底部导航第 4 个 Tab(消息)的页面容器
*/
class MessageFragment :
BaseVMBFragment<MessageViewModel, FragmentMessageBinding>(R.layout.fragment_message) {
override fun initView(root: View?, savedInstanceState: Bundle?) {}
override fun lazyLoadData() {
// TODO: 首次可见时加载消息列表
}
override fun bindEvent() {}
}
@@ -0,0 +1,25 @@
package com.sw.healthexpertclient.fragment
import android.os.Bundle
import android.view.View
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.base.BaseVMBFragment
import com.sw.healthexpertclient.databinding.FragmentMineBinding
import com.sw.healthexpertclient.fragment.viewmodel.MineViewModel
/**
* 我的 Fragment
*
* 底部导航第 5 个 Tab(我的)的页面容器
*/
class MineFragment :
BaseVMBFragment<MineViewModel, FragmentMineBinding>(R.layout.fragment_mine) {
override fun initView(root: View?, savedInstanceState: Bundle?) {}
override fun lazyLoadData() {
// TODO: 首次可见时加载用户信息
}
override fun bindEvent() {}
}
@@ -0,0 +1,25 @@
package com.sw.healthexpertclient.fragment
import android.os.Bundle
import android.view.View
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.base.BaseVMBFragment
import com.sw.healthexpertclient.databinding.FragmentWorkbenchBinding
import com.sw.healthexpertclient.fragment.viewmodel.WorkbenchViewModel
/**
* 工作台 Fragment
*
* 底部导航第 1 个 Tab(工作台)的页面容器
*/
class WorkbenchFragment :
BaseVMBFragment<WorkbenchViewModel, FragmentWorkbenchBinding>(R.layout.fragment_workbench) {
override fun initView(root: View?, savedInstanceState: Bundle?) {}
override fun lazyLoadData() {
// TODO: 首次可见时加载工作台数据
}
override fun bindEvent() {}
}
@@ -0,0 +1,8 @@
package com.sw.healthexpertclient.fragment.viewmodel
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
/** AI 助手 ViewModel */
class AIChatViewModel : BaseViewModel() {
override fun init() {}
}
@@ -0,0 +1,8 @@
package com.sw.healthexpertclient.fragment.viewmodel
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
/** 知识 ViewModel */
class KnowledgeViewModel : BaseViewModel() {
override fun init() {}
}
@@ -0,0 +1,8 @@
package com.sw.healthexpertclient.fragment.viewmodel
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
/** 消息 ViewModel */
class MessageViewModel : BaseViewModel() {
override fun init() {}
}
@@ -0,0 +1,8 @@
package com.sw.healthexpertclient.fragment.viewmodel
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
/** 我的 ViewModel */
class MineViewModel : BaseViewModel() {
override fun init() {}
}
@@ -0,0 +1,8 @@
package com.sw.healthexpertclient.fragment.viewmodel
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
/** 工作台 ViewModel */
class WorkbenchViewModel : BaseViewModel() {
override fun init() {}
}
@@ -0,0 +1,90 @@
package com.sw.healthexpertclient.retrofit
import com.franmontiel.persistentcookiejar.PersistentCookieJar
import com.franmontiel.persistentcookiejar.cache.SetCookieCache
import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor
import com.orhanobut.logger.Logger
import com.sw.healthexpertclient.MyApplication.Companion.appContext
import com.sw.healthexpertclient.retrofit.UrlConfig.getDefaultBaseUrl
import com.sw.healthexpertclient.retrofit.gson.GsonConverterFactoryNew
import com.sw.healthexpertclient.retrofit.interceptor.ApiRequestInterceptor
import com.sw.healthexpertclient.retrofit.interceptor.HttpCodeNormalizeInterceptor
import com.sw.healthexpertclient.retrofit.interceptor.logInterceptor
import com.sw.healthexpertclient.utils.ApiDns
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Retrofit
import java.util.concurrent.TimeUnit
/**
* Retrofit 管理类,全局单例
*/
object RetrofitManager {
/** 请求超时时间(秒) */
private const val TIME_OUT_SECONDS = 30
/** 持久化 Cookie */
val cookieJar: PersistentCookieJar by lazy {
PersistentCookieJar(
SetCookieCache(),
SharedPrefsCookiePersistor(appContext)
)
}
/** 请求根地址 */
private val BASE_URL: String get() = getDefaultBaseUrl()
/**
* OkHttpClient 单例,全局复用
*/
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(ApiRequestInterceptor())
// 将服务端业务错误码归一化为 200,使响应体可被 GsonResponseBodyConverter 正常解析
.addInterceptor(HttpCodeNormalizeInterceptor())
// 请求日志拦截器
.addInterceptor(logInterceptor)
.dns(ApiDns())
.connectTimeout(TIME_OUT_SECONDS.toLong(), TimeUnit.SECONDS)
.readTimeout(TIME_OUT_SECONDS.toLong(), TimeUnit.SECONDS)
.writeTimeout(TIME_OUT_SECONDS.toLong(), TimeUnit.SECONDS)
.build()
}
/** 构建 Retrofit 实例 */
private fun initRetrofit(client: OkHttpClient, baseUrl: String?): Retrofit {
return Retrofit.Builder()
.client(client)
.addConverterFactory(GsonConverterFactoryNew.create())
.baseUrl(baseUrl ?: BASE_URL)
.build()
}
private val retrofit: Retrofit by lazy {
initRetrofit(client, BASE_URL)
}
fun getRetrofits(): Retrofit = retrofit
/**
* 获取 API Service 实例
*
* @param serviceClass Service 接口 Class
* @param baseUrl 自定义 baseUrl,为空时复用全局单例
*/
fun <T> getService(serviceClass: Class<T>, baseUrl: String? = null): T {
Logger.e(BASE_URL)
return if (baseUrl.isNullOrEmpty()) {
retrofit.create(serviceClass)
} else {
initRetrofit(client, baseUrl).create(serviceClass)
}
}
/** 将字符串转为 JSON RequestBody */
fun String.toRequestBody(): RequestBody {
return toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
}
}
@@ -0,0 +1,67 @@
package com.sw.healthexpertclient.retrofit
import com.sw.sdk.core.data.local.DataStoreManager
import okhttp3.HttpUrl.Companion.toHttpUrl
object UrlConfig {
private const val DEBUG_DEFAULT_IP_ADDRESS_REMOTE = "http://192.168.1.201:24801/"
private const val TEST_DEFAULT_IP_ADDRESS_REMOTE = "https://dev.yixiong-tech.com:8081/"
private const val PRODUCT_DEFAULT_IP_ADDRESS_REMOTE = "https://api.cqygjk.com/"
private const val DEBUG_H5_ADDRESS_REMOTE = "http://192.168.1.201:8085"
private const val TEST_H5_ADDRESS_REMOTE = "https://dev.yixiong-tech.com:8085"
private const val PRODUCT_H5_ADDRESS_REMOTE = "https://health-mirror.tzgl.shuziweidao.com"
var baseUrlType: BaseUrlType = BaseUrlType.DEBUG
var isOpenIm: Boolean = false
enum class BaseUrlType(val type: Int) {
DEBUG(1),
TEST(2),
PRODUCT(3)
}
private fun getBaseUrl(baseUrlType: BaseUrlType = BaseUrlType.DEBUG): String {
return when (baseUrlType) {
BaseUrlType.DEBUG -> DEBUG_DEFAULT_IP_ADDRESS_REMOTE
BaseUrlType.TEST -> TEST_DEFAULT_IP_ADDRESS_REMOTE
BaseUrlType.PRODUCT -> PRODUCT_DEFAULT_IP_ADDRESS_REMOTE
}
}
fun getH5BaseUrl(baseUrlType: BaseUrlType = BaseUrlType.DEBUG): String {
return when (baseUrlType) {
BaseUrlType.DEBUG -> DEBUG_H5_ADDRESS_REMOTE
BaseUrlType.TEST -> TEST_H5_ADDRESS_REMOTE
BaseUrlType.PRODUCT -> PRODUCT_H5_ADDRESS_REMOTE
}
}
fun getDefaultBaseUrl(): String {
return getBaseUrl(baseUrlType)
}
val IMAGE_BASE_URL: String get() = getDefaultBaseUrl() + "file/show/"
fun getH5Url(): String {
return getH5BaseUrl(baseUrlType)
}
fun buildStdH5Url(path: String, extraParams: Map<String, String> = emptyMap()): String {
val extraStr = extraParams.entries.joinToString("") { (key, value) -> "&$key=$value" }
return "${getH5Url()}$path" +
"?token=${DataStoreManager.getToken()}" +
extraStr
}
fun getH5PdfUrl(): String {
val httpUrl = getDefaultBaseUrl().toHttpUrl()
return getH5BaseUrl(baseUrlType) + "/static/mobile/indexSon?" +
"scheme=${httpUrl.scheme}" +
"&host=${httpUrl.host}" +
"&title=文件详情" +
"&pdfUrl="
}
}
@@ -0,0 +1,45 @@
package com.sw.healthexpertclient.retrofit.gson;
import com.google.gson.Gson;
import com.google.gson.TypeAdapter;
import com.google.gson.reflect.TypeToken;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
import retrofit2.Converter;
import retrofit2.Retrofit;
/**
* 自定义 Gson Converter Factory,替换默认实现以支持业务码判断
*/
public final class GsonConverterFactoryNew extends Converter.Factory {
public static GsonConverterFactoryNew create() {
return create(new Gson());
}
public static GsonConverterFactoryNew create(Gson gson) {
return new GsonConverterFactoryNew(gson);
}
private final Gson gson;
private GsonConverterFactoryNew(Gson gson) {
if (gson == null) throw new NullPointerException("gson == null");
this.gson = gson;
}
@Override
public Converter<ResponseBody, ?> responseBodyConverter(Type type, Annotation[] annotations, Retrofit retrofit) {
TypeAdapter<?> adapter = gson.getAdapter(TypeToken.get(type));
return new GsonResponseBodyConverter<>(gson, adapter);
}
@Override
public Converter<?, RequestBody> requestBodyConverter(Type type, Annotation[] parameterAnnotations, Annotation[] methodAnnotations, Retrofit retrofit) {
TypeAdapter<?> adapter = gson.getAdapter(TypeToken.get(type));
return new GsonRequestBodyConverter<>(gson, adapter);
}
}
@@ -0,0 +1,41 @@
package com.sw.healthexpertclient.retrofit.gson;
import com.google.gson.Gson;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.Charset;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okio.Buffer;
import retrofit2.Converter;
/**
* Gson 请求体转换器,将对象序列化为 JSON RequestBody
*/
public final class GsonRequestBodyConverter<T> implements Converter<T, RequestBody> {
private static final MediaType MEDIA_TYPE = MediaType.get("application/json; charset=UTF-8");
private static final Charset UTF_8 = Charset.forName("UTF-8");
private final Gson gson;
private final TypeAdapter<T> adapter;
public GsonRequestBodyConverter(Gson gson, TypeAdapter<T> adapter) {
this.gson = gson;
this.adapter = adapter;
}
@Override
public RequestBody convert(T value) throws IOException {
Buffer buffer = new Buffer();
Writer writer = new OutputStreamWriter(buffer.outputStream(), UTF_8);
JsonWriter jsonWriter = gson.newJsonWriter(writer);
adapter.write(jsonWriter, value);
jsonWriter.close();
return RequestBody.create(MEDIA_TYPE, buffer.readByteString());
}
}
@@ -0,0 +1,69 @@
package com.sw.healthexpertclient.retrofit.gson;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.gson.Gson;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.sw.healthexpertclient.data.bean.ApiCode;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import okhttp3.MediaType;
import okhttp3.ResponseBody;
import retrofit2.Converter;
/**
* Gson 响应体转换器
* 将响应 JSON 交由 Gson TypeAdapter 解析为 ApiResponse<T>
* 业务码非成功时将 data 置为 null,由上层 handleRequest 通过 isOk 判断。
*/
public final class GsonResponseBodyConverter<T> implements Converter<ResponseBody, T> {
private final Gson gson;
private final TypeAdapter<T> adapter;
public GsonResponseBodyConverter(Gson gson, TypeAdapter<T> adapter) {
this.gson = gson;
this.adapter = adapter;
}
@Override
public T convert(ResponseBody value) throws IOException {
try {
String response = value.string();
JSONObject jsonObject = null;
try {
jsonObject = new JSONObject(response);
} catch (JSONException e) {
e.printStackTrace();
}
String code = jsonObject.optString("code");
MediaType mediaType = value.contentType();
Charset charset = mediaType != null ? mediaType.charset(UTF_8) : UTF_8;
InputStream inputStream = new ByteArrayInputStream(response.getBytes());
JsonReader jsonReader = gson.newJsonReader(new InputStreamReader(inputStream, charset));
if (code.equals(ApiCode.SUCCESS.getCode())) {
return adapter.read(jsonReader);
} else {
value.close();
try {
jsonObject.put("data", JSONObject.NULL);
JsonReader jsonReader2 = gson.newJsonReader(new InputStreamReader(
new ByteArrayInputStream(jsonObject.toString().getBytes()), charset));
return adapter.read(jsonReader2);
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
} finally {
value.close();
}
}
}
@@ -0,0 +1,163 @@
package com.sw.healthexpertclient.retrofit.interceptor
import android.text.TextUtils
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.orhanobut.logger.Logger
import com.sw.sdk.core.data.local.DataStoreManager
import com.sw.healthexpertclient.superfuntion.toJson
import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MultipartBody.Part.Companion.createFormData
import okhttp3.RequestBody.Companion.toRequestBody
import okio.Buffer
import java.io.IOException
import java.io.UnsupportedEncodingException
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.*
/**
* 统一请求拦截器
*
* 职责:
* 1. 注入鉴权 Headerabtoken
* 2. 处理 GET 查询参数的动态注入
* 3. 处理 POST FormBody / MultipartBody / JSON body 的动态参数与加密
*/
class ApiRequestInterceptor : Interceptor {
/** 不需要对 body 做签名加密的标记字段名 */
private val BODY_NO_ENCODE = "bodyNoEncode"
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
// 注入鉴权 Header
val builder = request.newBuilder().apply {
addHeader("abtoken", DataStoreManager.getToken())
}
request = builder.build()
// 根据请求方式处理动态参数
request = when (request.method) {
"GET" -> addGetParams(request)
"POST" -> addPostParams(request)
else -> request
}
return chain.proceed(request)
}
@Throws(UnsupportedEncodingException::class)
private fun addGetParams(request: Request): Request {
val httpUrl = request.url
val newBuilder = httpUrl.newBuilder()
val oldParams = TreeMap<String, String>()
for (name in httpUrl.queryParameterNames) {
val value = httpUrl.queryParameterValues(name).firstOrNull() ?: ""
oldParams[name] = value
}
val newParams = injectDynamicParams(oldParams)
val existingKeys = httpUrl.queryParameterNames
for ((key, value) in newParams) {
if (!existingKeys.contains(key)) {
newBuilder.addQueryParameter(key, URLEncoder.encode(value, Charsets.UTF_8.name()))
}
}
return request.newBuilder().url(newBuilder.build()).build()
}
@Throws(UnsupportedEncodingException::class)
private fun addPostParams(request: Request): Request {
return when (val body = request.body) {
is FormBody -> handleFormBody(request, body)
is MultipartBody -> handleMultipartBody(request, body)
else -> if (isJsonBody(body?.contentType())) {
handleJsonBody(request)
} else {
request
}
}
}
private fun handleFormBody(request: Request, formBody: FormBody): Request {
val oldParams = TreeMap<String, String>()
for (i in 0 until formBody.size) {
oldParams[formBody.encodedName(i)] = formBody.encodedValue(i)
}
val bodyBuilder = FormBody.Builder()
val newParams = injectDynamicParams(oldParams)
for ((key, value) in newParams) {
bodyBuilder.addEncoded(key, URLDecoder.decode(value, Charsets.UTF_8.name()))
}
return request.newBuilder().post(bodyBuilder.build()).build()
}
private fun handleMultipartBody(request: Request, multipart: MultipartBody): Request {
val bodyBuilder = MultipartBody.Builder().setType(MultipartBody.FORM)
for (part in multipart.parts) {
bodyBuilder.addPart(part)
}
val extra = injectDynamicParams(TreeMap())
for ((key, value) in extra) {
bodyBuilder.addPart(createFormData(key, value))
}
return request.newBuilder().post(bodyBuilder.build()).build()
}
private fun handleJsonBody(request: Request): Request {
val originalJson = bodyToString(request)
val processedJson = processJsonBody(originalJson)
val requestBody = processedJson.toRequestBody(request.body!!.contentType())
return request.newBuilder().post(requestBody).build()
}
private fun processJsonBody(json: String?): String {
val jsonElem = if (TextUtils.isEmpty(json)) JsonObject()
else JsonParser.parseString(json)
json?.let { Logger.d(it) }
return if (jsonElem is JsonObject) {
val noEncode = jsonElem.has(BODY_NO_ENCODE)
jsonElem.remove(BODY_NO_ENCODE)
encrypt(jsonElem.toJson(), !noEncode)
} else {
json ?: ""
}
}
/** 请求体加密扩展点,目前直通 */
private fun encrypt(requestJson: String, isBodySign: Boolean): String {
Logger.d(requestJson)
return requestJson
}
/** 动态参数注入扩展点,目前直通 */
private fun injectDynamicParams(params: TreeMap<String, String>): TreeMap<String, String> {
return params
}
private fun bodyToString(request: Request): String? {
return try {
val buffer = Buffer()
request.newBuilder().build().body!!.writeTo(buffer)
buffer.readUtf8()
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private fun isJsonBody(mediaType: MediaType?): Boolean {
if (mediaType == null) return false
return mediaType.subtype.lowercase(Locale.getDefault()).contains("json")
}
}
@@ -0,0 +1,43 @@
package com.sw.healthexpertclient.retrofit.interceptor
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
/**
* HTTP 状态码归一化拦截器
*
* 将服务端业务失败时返回的 401/402/500 改写为 200
* 由上层统一通过 ApiResponse.isOk 判断业务成功与否。
*/
class HttpCodeNormalizeInterceptor(
private val codesNeedNormalize: Set<Int> = setOf(401, 402, 500)
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code !in codesNeedNormalize) return response
// 401/402 视为登录态失效,替换为固定提示 body
if (response.code == 401 || response.code == 402) {
val reloginBody = """{"code":"A0401","msg":"请重新登录"}"""
return response.newBuilder()
.code(200)
.message("OK")
.body(reloginBody.toResponseBody("application/json".toMediaType()))
.build()
}
val originalBody = response.body ?: return response
val contentType = originalBody.contentType()
val bodyBytes = originalBody.bytes()
return response.newBuilder()
.code(200)
.message("OK")
.body(bodyBytes.toResponseBody(contentType))
.build()
}
}
@@ -0,0 +1,14 @@
package com.sw.healthexpertclient.retrofit.interceptor
import com.orhanobut.logger.Logger
import com.sw.healthexpertclient.BuildConfig
import okhttp3.logging.HttpLoggingInterceptor
/**
* OkHttp 日志拦截器,Debug 模式输出完整 body,Release 模式仅输出基本信息
*/
val logInterceptor = HttpLoggingInterceptor(object : HttpLoggingInterceptor.Logger {
override fun log(message: String) {
Logger.d(message)
}
}).setLevel(if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC)
@@ -0,0 +1,14 @@
package com.sw.healthexpertclient.superfuntion
import android.content.Context
import com.sw.healthexpertclient.ui.login.LoginActivity
import com.sw.sdk.core.utils.CustomActivityManager
/** 跳转登录页并清空返回栈 */
fun startLoginActivity(context: Context?) {
context ?: return
CustomActivityManager.getInstance().finishAllActivity()
val intent = android.content.Intent(context, LoginActivity::class.java)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
}
@@ -0,0 +1,136 @@
package com.sw.healthexpertclient.superfuntion
import androidx.lifecycle.viewModelScope
import com.google.gson.JsonSyntaxException
import com.orhanobut.logger.Logger
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel.Companion.LOADING_STATE_HIDE
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel.Companion.LOADING_STATE_SHOW
import com.sw.healthexpertclient.data.bean.ApiResponse
import com.sw.healthexpertclient.data.bean.isOk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.json.JSONException
import retrofit2.HttpException
/**
* 启动协程(带加载框控制)
*
* @param showDialog 是否显示加载框
* @param tryBlock try 块执行的协程函数
* @param catchBlock catch 块执行的协程函数,默认空实现
* @param finallyBlock finally 块执行的协程函数,默认空实现
*/
fun BaseViewModel.launch(
showDialog: Boolean,
tryBlock: suspend CoroutineScope.() -> Unit,
catchBlock: suspend CoroutineScope.() -> Unit = {},
finallyBlock: suspend CoroutineScope.() -> Unit = {}
) {
viewModelScope.launch {
try {
if (showDialog) {
loadingDialog.value = LOADING_STATE_SHOW
}
tryBlock()
} catch (e: Exception) {
when (e) {
is HttpException -> {
val errorBody = e.response()?.errorBody()?.string()
val classInfo = this@launch.javaClass.simpleName
Logger.e("HTTP异常:${classInfo} code=${e.code()} body=${errorBody}")
exception.value = e
}
is JsonSyntaxException -> {
val classInfo = this@launch.javaClass.simpleName
Logger.e("json解析异常:${classInfo}----${e}")
exception.value = JSONException("数据解析异常")
}
else -> {
exception.value = e
}
}
catchBlock()
} finally {
finallyBlock()
}
}
}
/**
* 启动协程(默认显示加载框)
*
* @param tryBlock try 块执行的协程函数
* @param catchBlock catch 块执行的协程函数,默认空实现
* @param finallyBlock finally 块执行的协程函数,默认空实现
* @param isAutoShowEmpty 是否自动显示空状态
*/
fun BaseViewModel.launch(
tryBlock: suspend CoroutineScope.() -> Unit,
catchBlock: suspend CoroutineScope.() -> Unit = {},
finallyBlock: suspend CoroutineScope.() -> Unit = {},
isAutoShowEmpty: Boolean = false
) {
isAutoEmpty.value = isAutoShowEmpty
launch(true, tryBlock, catchBlock, finallyBlock)
}
/**
* 启动协程(带加载框控制和空状态控制)
*/
fun BaseViewModel.launch(
showDialog: Boolean,
tryBlock: suspend CoroutineScope.() -> Unit,
catchBlock: suspend CoroutineScope.() -> Unit = {},
finallyBlock: suspend CoroutineScope.() -> Unit = {},
isAutoShowEmpty: Boolean = false
) {
isAutoEmpty.value = isAutoShowEmpty
launch(showDialog, tryBlock, catchBlock, finallyBlock)
}
/**
* 处理网络请求结果
*
* @param response ApiResponse 响应体
* @param successBlock 请求成功且业务码正确时的回调,默认空实现
* @param errorBlock 请求成功但业务码错误时的回调,返回 true 拦截统一错误处理,false 不拦截
*/
suspend fun <T> BaseViewModel.handleRequest(
response: ApiResponse<T>,
successBlock: suspend CoroutineScope.(response: ApiResponse<T>) -> Unit = {},
errorBlock: suspend CoroutineScope.(response: ApiResponse<T>) -> Boolean = { false }
) {
coroutineScope {
if (response.isOk) {
if (loadingDialog.value == LOADING_STATE_SHOW) {
loadingDialog.value = LOADING_STATE_HIDE
}
if (response.data == null) {
if (isAutoEmpty.value == true) {
showEmpty.value = isAutoEmpty.value == true
}
} else if (response.data is List<*>) {
if (isAutoEmpty.value == true) {
if (response.data.size == 0) {
showEmpty.value = isAutoEmpty.value == true
} else {
showEmpty.value = isAutoEmpty.value == false
}
}
} else {
if (isAutoEmpty.value == true) {
showEmpty.value = isAutoEmpty.value == false
}
}
successBlock(response)
} else {
if (!errorBlock(response)) {
// errorBlock 返回 false 时,走统一错误提示
errorResponse.value = response
}
showEmpty.value = isAutoEmpty.value == true
}
}
}
@@ -0,0 +1,44 @@
package com.sw.healthexpertclient.superfuntion
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.view.ContentInfoCompat
import com.sw.healthexpertclient.ui.login.LoginActivity
fun startActivity(
context: Context, @ContentInfoCompat.Flags flags: MutableList<Int>? = null,
bundle: Bundle? = null, targetClass: Class<*>
) {
var intent = Intent(context, targetClass)
if (!flags.isNullOrEmpty()) {
for (flag in flags) {
intent.flags = flag
}
}
if (bundle != null) {
intent.putExtras(bundle)
}
context.startActivity(intent)
}
/**
* 登录
*/
fun startLoginActivity(context: Context) {
startActivity(context, targetClass = LoginActivity::class.java)
}
/**
* Web页面 source == 1 紫色主题 darkStyle=false 白色顶部
*/
fun Context.startWebActivity(url: String, scale: Boolean = false, myTitle: String = "", darkStyle: Boolean = false, isFull: Boolean = false, webType: Int = -1) {
val bundle = Bundle()
bundle.putString("url", url)
bundle.putBoolean("Scale", scale)
bundle.putString("title", myTitle)
bundle.putBoolean("darkStyle", darkStyle)
bundle.putBoolean("isFull", isFull) // 是否全屏
bundle.putInt("webType", webType) // 网页类型
// startActivity(this, bundle = bundle, targetClass = WebActivity::class.java)
}
@@ -0,0 +1,13 @@
package com.sw.healthexpertclient.superfuntion
import com.google.gson.GsonBuilder
/** 将对象转为 JSON 字符串 */
fun Any?.toJson(): String {
return GsonBuilder().create().toJson(this)
}
/** 将 JSON 字符串反序列化为对象 */
fun <T> String?.jsonToBean(clazz: Class<T>): T {
return GsonBuilder().create().fromJson(this, clazz)
}
@@ -0,0 +1,587 @@
package com.sw.healthexpertclient.superfuntion
import android.annotation.SuppressLint
import android.app.ProgressDialog
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.widget.ImageView
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.MultiTransformation
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.bitmap.CircleCrop
import com.bumptech.glide.load.resource.bitmap.FitCenter
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.Target
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.retrofit.UrlConfig
import com.sw.sdk.core.adapter.common.ViewPagerAdapter
import com.sw.sdk.core.bean.common.TabItemBean
import com.sw.sdk.core.data.local.DataStoreManager
import com.sw.sdk.core.utils.ScreenUtil.dp2px
import com.sw.sdk.core.view.LoadingDialog
import java.io.File
import com.sw.sdk.core.R as RSDK
fun addImageBaseUrl(url: String?): String? {
if (url != null) {
if (url.startsWith("http")) {
return url
}
} else {
return null
}
return UrlConfig.IMAGE_BASE_URL + url.replace("\\","/")
}
/**
* ImageView利用Glide加载图片
* @param url 图片url(可远程可本地)
* @param showPlaceholder 是否展示placeholder,默认为true
*/
fun ImageView.load(
url: String?,
showPlaceholder: Boolean = true,
@DrawableRes defaultResId: Int = 0
) {
if (TextUtils.isEmpty(url)) {
if (defaultResId != 0) {
setImageResource(defaultResId)
}
return
}
if (showPlaceholder) {
val options = RequestOptions().transform()
if (defaultResId == 0) {
options.placeholder(RSDK.drawable.ic_default)
.error(RSDK.drawable.ic_default)
} else {
options.placeholder(defaultResId)
.error(defaultResId)
}
Glide.with(context).load(url)
.apply(options)
.into(this)
} else {
Glide.with(context).load(url)
.into(this)
}
}
//fun ImageView.loadGif(
// url: String?,
// showPlaceholder: Boolean = true,
// @DrawableRes defaultResId: Int = 0
//) {
// if (TextUtils.isEmpty(url)) {
// if (defaultResId != 0) {
// setImageResource(defaultResId)
// }
// return
// }
//
// if (showPlaceholder) {
// val options = RequestOptions().transform()
// if (defaultResId == 0) {
// options.placeholder(RSDK.drawable.ic_default)
// .error(RSDK.drawable.ic_default)
// } else {
// options.placeholder(defaultResId)
// .error(defaultResId)
// }
// Glide.with(context).asGif().com.sw.healthexpertclient.superfuntion.load(url).timeout(10*1000)
// .apply(options)
// .into(this)
// } else {
// Glide.with(context).asGif().com.sw.healthexpertclient.superfuntion.load(url).timeout(10*1000)
// .into(this)
// }
//}
/**
* ImageView利用Glide加载图片
* @param url 图片url(可远程可本地)
* @param showPlaceholder 是否展示placeholder,默认为true
*/
fun ImageView.load(
file: File,
showPlaceholder: Boolean = true,
@DrawableRes defaultResId: Int = 0
) {
Glide.with(context).load(file)
.into(this)
}
/**
* ImageView利用Glide加载图片
* @param resourceId 本地图片资源Id
* @param showPlaceholder 是否展示placeholder,默认为false
*/
fun ImageView.load(
@DrawableRes resourceId: Int,
showPlaceholder: Boolean = false,
@DrawableRes defaultResId: Int = 0
) {
Glide.with(context).load(resourceId)
.into(this)
}
/**
* ImageView利用Glide加载圆形图片
* @param url 图片url(可远程可本地)
*/
fun ImageView.loadCircle(url: String?, @DrawableRes defaultResId: Int = 0) {
if (TextUtils.isEmpty(url)) {
if (defaultResId != 0) {
setImageResource(defaultResId)
}
return
}
val options = RequestOptions
.bitmapTransform(CircleCrop())
if (defaultResId == 0) {
options.placeholder(RSDK.drawable.ic_default_doctor_head_img)
.error(RSDK.drawable.ic_default_doctor_head_img)
} else {
options.placeholder(defaultResId)
.error(defaultResId)
}
Glide.with(context).load(url)
.apply(options)
.into(this)
}
fun getDefaultImg(): Int {
val bean= DataStoreManager.getUserInfo()
var defaultImg: Int = if (bean?.sex=="1") {
R.drawable.ic_mulher
}else{
R.drawable.ic_masculino
}
return defaultImg
}
/**
* ImageView利用Glide加载圆形图片 并使用用户性别对应的默认头像
* @param url 图片url(可远程可本地)
*/
fun ImageView.loadCircleAndSex(url: String?,defaultResId: Int = 0) {
val bean= DataStoreManager.getUserInfo()
var defaultImg: Int = if (bean?.sex=="1") {
R.drawable.ic_mulher
}else{
R.drawable.ic_masculino
}
if (defaultResId > 0) {
defaultImg = defaultResId
}
if (TextUtils.isEmpty(url)) {
setImageResource(defaultImg)
return
}
val options = RequestOptions
.bitmapTransform(CircleCrop())
Glide.with(context).load(url)
.apply(options)
.error(defaultImg)
.into(this)
}
/**
* ImageView利用Glide加载圆形图片
* @param url 图片url(可远程可本地)
*/
fun ImageView.loadCircle(file: File, @DrawableRes defaultResId: Int = 0) {
val options = RequestOptions
.bitmapTransform(CircleCrop())
if (defaultResId == 0) {
options.placeholder(RSDK.drawable.ic_default_doctor_head_img)
.error(RSDK.drawable.ic_default_doctor_head_img)
}
Glide.with(context).load(file)
.apply(options)
.into(this)
}
/**
* ImageView利用Glide加载圆形图片
* @param resourceId 本地图片资源Id
*/
fun ImageView.loadCircle(@DrawableRes resourceId: Int) {
Glide.with(context).load(resourceId)
// .placeholder(R.drawable.ic_default_img)
.apply(RequestOptions.bitmapTransform(CircleCrop()))
.into(this)
}
/**
* 加载图片到ImageView
* @param imageUrl 图片地址
* @param imageView View
* @param placeholder 占位图
*/
fun ImageView.loadRoundedImage(
url: String?,
roundingRadius: Float,
@DrawableRes defaultResId: Int = 0,
isCenterCrop:Boolean = true,//是否中心裁剪
thumbnail:Float = 1.0F//是否缩略图
) {
if (TextUtils.isEmpty(url)) {
if (defaultResId != 0) {
setImageResource(defaultResId)
}
return
}
//设置图片圆角角度
val roundedCorners = RoundedCorners(dp2px(roundingRadius))
val multiTransformation: MultiTransformation<Bitmap> = MultiTransformation(
if (isCenterCrop) CenterCrop() else FitCenter(), roundedCorners
)
val options = RequestOptions
.bitmapTransform(multiTransformation)
if (defaultResId == 0) {
options.placeholder(RSDK.drawable.ic_default)
.error(RSDK.drawable.ic_default)
} else {
options.placeholder(defaultResId)
.error(defaultResId)
}
Glide.with(context)
.load(url)
.thumbnail(thumbnail)
.apply(options)
.into(this)
}
/**
* 加载图片到ImageView
* @param imageUrl 图片地址
* @param imageView View
* @param placeholder 占位图
*/
fun ImageView.loadRoundedImage(
url: String?,
roundingRadius: Float,
@DrawableRes defaultResId: Int = 0,
method:(Boolean)->Unit
) {
if (TextUtils.isEmpty(url)) {
if (defaultResId != 0) {
setImageResource(defaultResId)
}
return
}
//设置图片圆角角度
val roundedCorners = RoundedCorners(dp2px(roundingRadius))
val multiTransformation: MultiTransformation<Bitmap> = MultiTransformation(
CenterCrop(), roundedCorners
)
val options = RequestOptions
.bitmapTransform(multiTransformation)
if (defaultResId == 0) {
options.placeholder(RSDK.drawable.ic_default)
.error(RSDK.drawable.ic_default)
} else {
options.placeholder(defaultResId)
.error(defaultResId)
}
Glide.with(context)
.load(url)
.listener(object :RequestListener<Drawable>{
override fun onLoadFailed(
p0: GlideException?,
p1: Any?,
p2: Target<Drawable>?,
p3: Boolean,
): Boolean {
method(false)
return false
}
override fun onResourceReady(
p0: Drawable?,
p1: Any?,
p2: Target<Drawable>?,
p3: DataSource?,
p4: Boolean,
): Boolean {
method(true)
return false
}
})
.apply(options)
.into(this)
}
/**
* 加载图片到ImageView
* @param imageUrl 图片地址
* @param imageView View
* @param placeholder 占位图
*/
fun ImageView.loadRoundedImage(
file: File,
roundingRadius: Float
) {
//设置图片圆角角度
val roundedCorners = RoundedCorners(dp2px(roundingRadius))
val multiTransformation: MultiTransformation<Bitmap> = MultiTransformation(
CenterCrop(), roundedCorners
)
val options = RequestOptions
.bitmapTransform(multiTransformation)
Glide.with(context)
.load(file)
.apply(options)
.into(this)
}
/**
* 加载图片到ImageView
* @param imageUrl 图片地址
* @param imageView View
* @param placeholder 占位图
*/
fun ImageView.loadRoundedImage(
imageRes: Int,
roundingRadius: Float
) {
//设置图片圆角角度
val roundedCorners = RoundedCorners(dp2px(roundingRadius))
val multiTransformation: MultiTransformation<Bitmap> = MultiTransformation(
CenterCrop(), roundedCorners
)
val options = RequestOptions
.bitmapTransform(multiTransformation)
if (imageRes != 0) {
Glide.with(context)
.load(imageRes)
.apply(options)
.into(this)
}
}
/**
* RecyclerView列表为空时的显示视图
*/
fun RecyclerView.getEmptyView(message: String = context.getString(RSDK.string.list_is_empty)): View {
return LayoutInflater.from(context)
.inflate(R.layout.layout_empty, parent as ViewGroup, false).apply {
findViewById<TextView>(R.id.tv_empty).text = message
}
}
/** 加载框 */
@SuppressLint("StaticFieldLeak")
private var mLoadingDialog: LoadingDialog? = null
/** 打开加载框 */
fun AppCompatActivity.showLoading(message: String = "请稍后") {
if (!this.isFinishing&&!this.isDestroyed) {
if (mLoadingDialog == null) {
mLoadingDialog = LoadingDialog(
this,
ProgressDialog.STYLE_SPINNER, "请稍后"
)
mLoadingDialog!!.requestWindowFeature(Window.FEATURE_NO_TITLE)
mLoadingDialog!!.setCanceledOnTouchOutside(false)
mLoadingDialog!!.setCancelable(false)
mLoadingDialog!!.setMessage(if (message.isNullOrEmpty()) "请稍后" else message)
}
if (!this.isDestroyed) {
mLoadingDialog?.show()
}
}
}
/** 打开加载框 */
fun Fragment.showLoading(message: String = "请稍后") {
if (!this.isRemoving) {
if (mLoadingDialog == null) {
mLoadingDialog = LoadingDialog(
this.activity,
ProgressDialog.STYLE_SPINNER, "请稍后"
)
mLoadingDialog!!.requestWindowFeature(Window.FEATURE_NO_TITLE)
mLoadingDialog!!.setCanceledOnTouchOutside(false)
mLoadingDialog!!.setCancelable(false)
mLoadingDialog!!.setMessage(if (message.isNullOrEmpty()) "请稍后" else message)
}
if (this.activity?.isFinishing == false) {
mLoadingDialog?.show()
}
}
}
/** 隐藏Loading加载框 */
fun hideLoading() {
mLoadingDialog?.dismiss()
mLoadingDialog = null
}
fun ViewPager2.init(
fa: FragmentActivity,
tabs: Array<String>,
onCreateFragmentListener: ViewPagerAdapter.OnCreateFragmentListener,
offscreenPageLimit: Int = ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT
) {
if (tabs.isNullOrEmpty()) {
return
}
this.offscreenPageLimit = offscreenPageLimit
this.adapter = ViewPagerAdapter(fa, tabs, onCreateFragmentListener)
}
fun TabLayout.attachViewPager(
viewpager: ViewPager2,
tabs: Array<String>,
isCustomStyle: Boolean = false,
tabStyle: Int = 1
){
if (tabs.isNullOrEmpty()) {
return
}
var mediator = TabLayoutMediator(
this, viewpager
) { tab, position -> //这里可以自定义TabView
if (isCustomStyle){
val tabView = LayoutInflater.from(this.context)
.inflate(getTabStyle(tabStyle), null) as TextView
tabView.text = tabs[position]
tab.customView = tabView
}else{
tab.text = tabs[position]
}
}
//要执行这一句才是真正将两者绑定起来
mediator.attach()
}
fun ViewPager2.init(
fa: FragmentActivity,
tabs: Array<TabItemBean>,
onCreateFragmentListener: ViewPagerAdapter.OnCreateFragmentListener,
offscreenPageLimit: Int = ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT
) {
if (tabs.isNullOrEmpty()) {
return
}
this.offscreenPageLimit = offscreenPageLimit
this.adapter = ViewPagerAdapter(fa.supportFragmentManager, LifecycleRegistry(fa).apply { currentState = Lifecycle.State.RESUMED }, tabs, onCreateFragmentListener)
}
fun ViewPager2.init(
fa: Fragment,
tabs: Array<TabItemBean>,
onCreateFragmentListener: ViewPagerAdapter.OnCreateFragmentListener,
offscreenPageLimit: Int = ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT
) {
if (tabs.isNullOrEmpty()) {
return
}
this.offscreenPageLimit = offscreenPageLimit
this.adapter = ViewPagerAdapter(fa, tabs, onCreateFragmentListener)
}
/**
* 白色按钮,灰色背景的TabLayout
*/
fun TabLayout.onCreateTab(tabList: List<String>, tabStyle: Int = 1,listener: (TabLayout.Tab?) -> Unit) {
for (tabTitle in tabList) {
val tab = newTab()
onConfigureTab(context, tab, tabTitle,tabStyle)
addTab(tab, false)
}
selectTab(getTabAt(0))
addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
listener.invoke(tab)
}
override fun onTabUnselected(p0: TabLayout.Tab?) {
}
override fun onTabReselected(p0: TabLayout.Tab?) {
}
})
}
private fun onConfigureTab(context: Context, tab: TabLayout.Tab, tabTitle: String, tabStyle: Int) {
val tabView = LayoutInflater.from(context)
.inflate(getTabStyle(tabStyle), null) as RelativeLayout
val tabText = tabView.findViewById<TextView>(R.id.tv_text)
tabText.text = tabTitle
tab.customView = tabView
}
fun TabLayout.attachViewPager(
viewpager: ViewPager2,
tabs: Array<TabItemBean>,
tabStyle: Int = 1
) {
val mediator = TabLayoutMediator(
this, viewpager
) { tab, position -> //这里可以自定义TabView
val tabView = LayoutInflater.from(this.context)
.inflate(getTabStyle(tabStyle), null) as RelativeLayout
val tabText = tabView.findViewById<TextView>(R.id.tv_text)
tabText.text = tabs[position].tabTitle
tab.customView = tabView
}
//要执行这一句才是真正将两者绑定起来
mediator.attach()
}
/**
* tab 样式
*/
fun getTabStyle(i: Int): Int {
return when (i) {
1 -> R.layout.item_tablayout_group_theme//默认主题色
else -> R.layout.item_tablayout_group_theme
}
}
fun TabLayout.init(tabs: Array<TabItemBean>){
this.removeAllTabs()
tabs.forEach {
this.addTab(this.newTab().setText(it.tabTitle))
}
}
@@ -0,0 +1,118 @@
package com.sw.healthexpertclient.ui.login
import android.os.Bundle
import android.text.SpannableString
import android.text.Spanned
import android.text.TextPaint
import android.text.TextUtils
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.UnderlineSpan
import android.view.Gravity
import android.view.View
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.sw.sdk.core.data.local.DataStoreManager
import com.sw.healthexpertclient.MyApplication.Companion.appViewModel
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.base.BaseVMBActivity
import com.sw.healthexpertclient.databinding.ActivityLoginBinding
import com.sw.healthexpertclient.retrofit.UrlConfig
import com.sw.healthexpertclient.superfuntion.startWebActivity
import com.sw.healthexpertclient.ui.login.viewmodel.LoginViewModel
import com.sw.healthexpertclient.utils.ConstantUtils
import com.sw.healthexpertclient.view.dialog.TextViewDialog
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* 登录页面
*/
class LoginActivity :
BaseVMBActivity<LoginViewModel, ActivityLoginBinding>(R.layout.activity_login) {
override fun transparentStatusBar(): Boolean = true
override fun initView(savedInstanceState: Bundle?) {
initAgreement()
}
override fun createObserve() {
super.createObserve()
}
override fun initData() {
}
override fun bindEvent() {
mBinding.apply {
addClickViews(loginSubmit, loginRetrievePassword)
}
}
override fun processClick(paramView: View?) {
when (paramView?.id) {
R.id.login_submit -> {
// mBinding.apply {
// val account = loginEtUserName.text.toString()
// val password = loginEtUserPassword.getInputContext()
// if (TextUtils.isEmpty(account)) {
// showToast(resources?.getString(R.string.login_account_hint))
// return
// }
// if (TextUtils.isEmpty(password)) {
// showToast(resources?.getString(R.string.login_password_hint))
// return
// }
// if (password.length < 12 || password.length > 32) {
// // 密码不符合规范,弹窗提示修改
// mTextViewDialog?.show()
// } else {
// mViewModel.login(account, password)
// }
// }
}
R.id.login_retrieve_password -> {
// toActivity(RetrievePassWordActivity::class.java)
}
}
}
private fun initAgreement() {
val span = SpannableString(getString(R.string.login_privacy_agreement))
span.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
DataStoreManager.getUserAgreementUrl()?.let {
if (it.isNotEmpty()) startWebActivity(it)
}
}
}, 6, 12, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
span.setSpan(NoUnderlineSpan(), 6, 12, Spanned.SPAN_MARK_MARK)
span.setSpan(ForegroundColorSpan(ContextCompat.getColor(this, R.color.link_blue_color)), 6, 12, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
span.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
DataStoreManager.getPrivacyAgreementUrl()?.let {
if (it.isNotEmpty()) startWebActivity(it)
}
}
}, 13, 19, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
span.setSpan(NoUnderlineSpan(), 13, 19, Spanned.SPAN_MARK_MARK)
span.setSpan(ForegroundColorSpan(ContextCompat.getColor(this, R.color.link_blue_color)), 13, 19, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
mBinding.loginTvAgreement.text = span
mBinding.loginTvAgreement.movementMethod = LinkMovementMethod.getInstance()
}
internal class NoUnderlineSpan : UnderlineSpan() {
override fun updateDrawState(ds: TextPaint) {
ds.color = ds.linkColor
ds.isUnderlineText = false
}
}
}
@@ -0,0 +1,37 @@
package com.sw.healthexpertclient.ui.login.viewmodel
import com.sw.healthexpertclient.bean.common.CommonSettingMenuBean
import com.sw.healthexpertclient.bean.login.LoginBean
import com.sw.healthexpertclient.bean.login.LoginInfoBean
import com.sw.healthexpertclient.data.repository.UserInfoRepository
import com.sw.healthexpertclient.superfuntion.handleRequest
import com.sw.healthexpertclient.superfuntion.launch
import kotlinx.coroutines.flow.MutableStateFlow
/**
* 登录页 ViewModel
*/
class LoginViewModel : PhoneViewModel() {
val agreement = MutableStateFlow(false)
/** 协议菜单列表 */
var list = MutableStateFlow<MutableList<CommonSettingMenuBean>?>(null)
/** 登录成功结果 */
var loginResult = MutableStateFlow<LoginInfoBean?>(null)
/**
* 用户登录
*
* @param loginBean 登录参数
*/
fun login(loginBean: LoginBean) {
launch({
handleRequest(
UserInfoRepository.login(loginBean),
successBlock = {
it.data?.let { result -> loginResult.emit(result) }
})
})
}
}
@@ -0,0 +1,95 @@
package com.sw.healthexpertclient.ui.login.viewmodel
import com.sw.healthexpertclient.base.viewmodel.BaseViewModel
import com.sw.healthexpertclient.bean.phone.PhoneBean
import com.sw.healthexpertclient.data.repository.UserInfoRepository
import com.sw.healthexpertclient.retrofit.UrlConfig
import com.sw.healthexpertclient.retrofit.UrlConfig.BaseUrlType
import com.sw.healthexpertclient.superfuntion.handleRequest
import com.sw.healthexpertclient.superfuntion.launch
import kotlinx.coroutines.flow.MutableStateFlow
/**
* 手机号相关功能基础 ViewModel
*/
open class PhoneViewModel : BaseViewModel() {
/** 发送验证码结果 */
var sendCodeResult = MutableStateFlow<String?>(null)
/** 新增手机号结果 */
var addPhoneResult = MutableStateFlow<Boolean?>(null)
/** 验证原手机号验证码结果 */
var verifyPhoneCodeResult = MutableStateFlow<Boolean?>(null)
/** 身份证核验结果 */
var verifyIdCardResult = MutableStateFlow<Boolean?>(null)
/** 手机号列表 */
var phoneList = MutableStateFlow<List<PhoneBean>>(emptyList())
override fun init() {}
/** 发送短信验证码 */
fun sendSmsCode(phone: String, codeType: Int) {
launch({
handleRequest(UserInfoRepository.sendSmsCode(phone, codeType),
successBlock = {
if (UrlConfig.baseUrlType != BaseUrlType.PRODUCT) {
sendCodeResult.emit(it.data)
}
})
})
}
/** 查询手机号列表 */
fun getPhoneList() {
launch({
handleRequest(UserInfoRepository.getPhoneList(),
successBlock = {
phoneList.emit(it.data ?: emptyList())
})
})
}
/** 设置默认手机号 */
fun setDefaultPhone(phone: String) {
launch({
handleRequest(UserInfoRepository.setDefaultPhone(phone),
successBlock = { getPhoneList() })
})
}
/** 删除手机号 */
fun deletePhone(phone: String) {
launch({
handleRequest(UserInfoRepository.deletePhone(phone),
successBlock = { getPhoneList() })
})
}
/** 新增手机号 */
fun addPhone(phone: String, smsCode: String, isDefault: Boolean, oldPhone: String) {
launch({
handleRequest(UserInfoRepository.addPhone(phone, smsCode, isDefault, oldPhone),
successBlock = { addPhoneResult.emit(true) })
})
}
/** 验证原手机号短信验证码 */
fun verifyPhoneCode(phone: String, smsCode: String) {
launch({
handleRequest(UserInfoRepository.verifyPhoneCode(phone, smsCode),
successBlock = { verifyPhoneCodeResult.emit(it.data) })
})
}
/** 身份证核验 */
fun verifyIdCard(idCard: String) {
launch({
handleRequest(UserInfoRepository.verifyIdCard(idCard),
successBlock = { verifyIdCardResult.emit(true) })
})
}
}
@@ -0,0 +1,38 @@
package com.sw.healthexpertclient.utils;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import okhttp3.Dns;
/**
* 自定义 DNS 解析,优先返回 IPv4 地址
*/
public class ApiDns implements Dns {
@Override
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
if (hostname == null) {
throw new UnknownHostException("hostname == null");
} else {
try {
List<InetAddress> mInetAddressesList = new ArrayList<>();
InetAddress[] mInetAddresses = InetAddress.getAllByName(hostname);
for (InetAddress address : mInetAddresses) {
if (address instanceof Inet4Address) {
mInetAddressesList.add(0, address);
} else {
mInetAddressesList.add(address);
}
}
return mInetAddressesList;
} catch (NullPointerException var4) {
UnknownHostException unknownHostException = new UnknownHostException("Broken system behaviour");
unknownHostException.initCause(var4);
throw unknownHostException;
}
}
}
}
@@ -0,0 +1,63 @@
package com.sw.healthexpertclient.utils;
import android.os.Build;
/**
* 设备品牌工具类,使用 Android 原生 Build API
*/
public class BrandUtil {
public static boolean isBrandXiaoMi() {
return "xiaomi".equalsIgnoreCase(getBuildBrand())
|| "xiaomi".equalsIgnoreCase(getBuildManufacturer());
}
public static boolean isBrandHuawei() {
return "huawei".equalsIgnoreCase(getBuildBrand()) ||
"huawei".equalsIgnoreCase(getBuildManufacturer()) ||
"honor".equalsIgnoreCase(getBuildBrand());
}
public static boolean isBrandMeizu() {
return "meizu".equalsIgnoreCase(getBuildBrand())
|| "meizu".equalsIgnoreCase(getBuildManufacturer())
|| "22c4185e".equalsIgnoreCase(getBuildBrand());
}
public static boolean isBrandOppo() {
return "oppo".equalsIgnoreCase(getBuildBrand()) ||
"realme".equalsIgnoreCase(getBuildBrand()) ||
"oneplus".equalsIgnoreCase(getBuildBrand()) ||
"oppo".equalsIgnoreCase(getBuildManufacturer()) ||
"realme".equalsIgnoreCase(getBuildManufacturer()) ||
"oneplus".equalsIgnoreCase(getBuildManufacturer());
}
public static boolean isBrandVivo() {
return "vivo".equalsIgnoreCase(getBuildBrand())
|| "vivo".equalsIgnoreCase(getBuildManufacturer());
}
public static boolean isBrandHonor() {
return "honor".equalsIgnoreCase(getBuildBrand()) && "honor".equalsIgnoreCase(getBuildManufacturer());
}
public static String getBuildBrand() {
return Build.BRAND;
}
public static String getBuildManufacturer() {
return Build.MANUFACTURER;
}
public static String getBuildModel() {
return Build.MODEL;
}
public static String getBuildVersionRelease() {
return Build.VERSION.RELEASE;
}
public static int getBuildVersionSDKInt() {
return Build.VERSION.SDK_INT;
}
}
@@ -0,0 +1,84 @@
package com.sw.healthexpertclient.utils
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
object ConstantUtils {
const val mRSAKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmZfR/bA9X3vp86y1aEpvwzXJYKRRF1fLau2+05/ZtaITLpV8bhkmSf3neSy/Q9gAdvG75Fr73E+GWE+K5b0BpvIS1jDGo319+PpZR39SaZTKZ27XFXrosmJTZutN79t819HS1VseleunHAFgMVufE9U5jP6LGzl/wbkSy01GhzwIDAQAB"
var mCurrentLat = 34.327271
var mCurrentLon = 108.949845
var mNutritionMonitor = false
var mIsCloseDialog = false
var mPrivacy = "本平台由西安仁康信息科技有限公司开发和运维,平台深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则,保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则等。同时,我们承诺将采取相应的安全保护措施来保护您的个人信息。请在使用平台服务前,仔细阅读并了解本隐私权政策,我们将按照本政策收集、处理及披露您的信息。\n" +
"本政策将帮助您了解以下内容:\n" +
"1、定义\n" +
"2、我们可能收集的信息\n" +
"3、我们如何收集和使用您的个人信息\n" +
"4、我们如何使用 Cookie 和同类技术\n" +
"5、我们如何共享、转让、公开披露您的个人信息\n" +
"6、员工业务数据和公开信息\n" +
"7、我们如何保护您的个人信息\n" +
"8、您如何管理您的个人信息\n" +
"9、我们如何处理未成年人的个人信息\n" +
"10、本声明和政策如何更新\n" +
"11、如何联系我们\n" +
"一、定义\n" +
"1. 个人信息:\n" +
"指以电子或者其他方式记录的与已识别或者可识别的自然人有关的各种信息,不包括匿名化处理后的信息\n" +
"2. 个人健康信息\n" +
"指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种健康信息,一旦泄露或者非法使用,可能导致个人受到歧视或者人身、财产受到严重危害的个人信息,包括如姓名、出生日期、身份证件号码、体检信息、就医信息、住址、通讯联系方式、通信记录和内容、账号密码、行踪轨迹、民族、个人生物特征、医疗健康、金融账户、个人行踪、不满十四周岁未成年人信息等。\n" +
"3. 未成年人:指不满18周岁的自然人。\n" +
"4. 儿童:指不满14周岁的自然人。\n" +
"5. 设备信息\n" +
"包括设备标识符 (IMEI、IDFA、Android1D、MAC、OAID、IMSI、SIM 卡序列号 (ICCID) 、设备序列号及其他设备相关信息),应用信息 (应用崩溃信息、通知开关状态、运行中进程、应用安装列表及其他应用相关信息) ,设备参数及系统信息(设备类型、设备型号、操作系统及硬件相关信息),设备网络环境信息 (IP地址,WiFi信息,基站信息及其他网络相关信息),蓝牙信息,广播组件通讯信息,运营商信息,传感器 (陀螺仪、重力传感器、加速度传感器、环境传感器、光学心率传感器),剪切板。以产品实际采集情况为准。\n" +
"6. 业务数据\n" +
"不同于个人信息,是指平台员工利用平台的服务上传、下载、分发等通过平台的技术服务处理的数据。\n" +
"二、我们可能收集的信息\n" +
"我们提供服务时,可能会收集、储存和使用下列与个人有关的信息。如果您不提供相关信息,可能无法注册成为平台员工或无法享受平台的某些服务,或者无法达到相关服务拟达到的效果。\n" +
"2.1 与个人身份无关\n" +
"当您使用平台的服务时,我们可能收集和汇总诸如员工的来源途径、访问顺序、停留时间等信息,例如记录使用平台服务的每个员工的来源途径、浏览器软件等。\n" +
"2.2 与个人身份有关\n" +
"当您使用平台的服务时,我们可能收集和汇总或要求您提供有关个人身份的信息,例如:\n" +
"(1) 个人身份证明(包括身份证图片和身份证号、手机号、邮箱等);\n" +
"(2) 个人补充信息(姓名、生日、籍贯、性别、兴趣爱好、学历、个人联系电话、住址、工作信息、婚姻状况、健康状况、;\n" +
"(3) 网络身份标识信息(包括系统账号、IP地址、电子邮箱地址及与前述有关的密码、口令、口令保护答案、个人数字证书等);\n" +
"(4) 个人健康信息(包括:姓名、性别、出生日期、身高体重、就诊卡、所在城市,所患病症、患病时长、体检报告、住院记录、诊治情况、检验报告、体检报告、用药记录、以往病史、过敏信息、家族史、患病史等,和历史咨询记录。\n" +
"(5) 相机、相册(设备的相机功能,以拍摄照片或录制视频);\n" +
"(6) 麦克风(通话、发送录音文件);\n" +
"(7) 悬浮窗(在其他程序页面仍能保持应用内通话);\n" +
"(8) 日历(访问用户的日历,以便将预约信息添加到日历中);\n" +
"(9) 存储(访问设备存储器中的文件、照片和视频);\n" +
"(10) 个人上网记录和日志信息(包括网站浏览记录、软件使用记录、点击记录、操作日志等);\n" +
"(11) 设备信息(包括设备型号、设备MAC地址、操作系统类型、设备设置);\n" +
"(12) 软件列表唯一设备识别码(如IMEI/android ID/IDFA/OPENUDID/GUID、SIM卡IMSI信息等在内的描述个人常用设备基本情况的信息);\n" +
"(13) 拨打电话(唤起手机拨号界面并自动填充电话)\n" +
"(14) 个人位置信息(包括精准定位信息、经纬度等);\n" +
"(15) 其他平台基于为您提供服务需要而收集或汇总的您的信息。\n" +
"三、我们如何收集和使用您的个人信息\n" +
"1. 您直接提供的信息:我们可能会收集您在健康长庆注册账号、申请或使用我们的服务时填写,通过现场交谈、电话、视频或其他联系方式与我们联系时所提供的信息,以及您在使用我们服务过程中产生的与您的交易和活动有关的信息。\n" +
"(1) 帮助您成为我们的一员\n" +
"您需要向平台提供真实姓名、邮箱、有效身份证件(包括但不限于身份证)的号码,以便于我们进行实名认证。您可以修改和补充您的昵称、性别、生日、职级、职称、学历、亲属、紧急联系人、工作信息(单位部门)、婚姻状况、健康类型、兴趣爱好以及您的实名认证等一系列相关信息相关信息,这些信息均属于您的账号信息。您补充的账户信息将有助于我们为您提供与您信息更匹配的相关业务服务(急救协助、医院体检、健康知识推送等)。如果您不提供这些信息,可能会影响您对平台部分核心业务功能的正常使用,如咨询专家等,但不会影响您进行基本的浏览、搜索。如果您不提供这些信息,将会影响您使用平台的部分功能,如咨询专家,但不会影响您使用平台服务的基本浏览、搜索功能。在您主动注销账号时,我们将根据相关法律法规的要求尽快使其匿名或删除您的个人信息。\n" +
"(2) 咨询专家\n" +
"为便于您能够咨询专家,您需提供真实姓名、手机号、身份证号码、性别、出生日期、身高体重等信息。为更好地向您提供咨询服务解决您的问题,更加精准、快速地向您推荐医生,我们可能需要您在健康咨询时提供个人健康信息,例如:病症、患病时长、检验报告、体检报告、医院就诊信息、诊治情况、用药记录、以往病史、过敏信息、家族史、患病史等。如您不提供上述信息,将会影响您使用部分健康咨询等功能,但不影响您使用平台产品或服务的基本浏览、搜索功能。应急救援\n" +
"(1) 您实名成为健康长庆的用户后,若您或您的朋友发生了意外事件,你可以通过查看系统内的相关医疗资源,并向平台发起帮助请求。在此过程中,我们可能会通过您的基本信息和聊天功能获取您的手机号、您的紧急联系人姓名和联系 电话,位置、姓名、性别、年龄、健康状况、事故详情介绍的图文、音视频沟通的信息。搜索\n" +
"(3) 搜索\n" +
"为了让您快速地找到您所需要的专家、知识文章、软件功能、医院等,我们可能会收集您使用我们的产品记录为您提供这些信息展示的最优方式。我们也会为了不断改进和优化上述的功能来使用您的上述个人信息。\n" +
"您也可以通过搜索来精准地找到您所需要的专家、医院、系统功能。我们会保留您的搜索内容以方便您重复输入或为您展示与您搜索内容相关联的产品或服务。\n" +
"(4) 应急救援\n" +
"您实名成为健康长庆的用户后,若您或您的朋友发生了意外事件,你可以通过查看系统内的相关医疗资源,并向平台发起帮助请求。在此过程中,我们可能会通过您的基本信息和聊天功能获取您的手机号、您的紧急联系人姓名和联系 电话,位置、姓名、性别、年龄、健康状况、事故详情介绍的图文、音视频沟通的信息。\n" +
"(5) 大病就医(挂号)\n" +
"为了帮您预约医院的专家,我们需要您在预约时提供个人基本信息:姓名、性别、年龄、预约医院、挂号类型、预约时间等基本信息;你可补充病况、就诊卡号、预约科室、医师来进一步享受更为准确的服务。\n" +
"十一、如何联系我们\n" +
"1.如果您对本政策有任何疑问、意见或建议,可通过以下方式与我们联系:\n" +
"(1)拨打客服热线电话029-86581752\n" +
"2)发送电子邮件至123456789@qq.com\n" +
"(3)邮寄地址:陕西省西安市未央区未央路151号,收件人:陈天宇\n" +
"我们的客服部门将会同个人信息保护部门30天内进行回复,并协助解决您的问题。\n" +
"2.如果您对我们的回复不满意,特别是我们的个人信息处理行为损害了您的合法权益,您还可以通过以下外部途径寻求解决方式:向网信、工商、公安等监管部门进行投诉或举报。"
@Parcelize
enum class ConsultType(val type: String): Parcelable {
IMAGE_TEXT_CONSULT("1"),
AUDIO_VIDEO_CONSULT("2")
}
}
@@ -0,0 +1,56 @@
package com.sw.healthexpertclient.utils
import android.app.Activity
import java.util.Stack
/**
* Activity 栈管理器
*
* 统一管理应用内所有 Activity 的生命周期,支持关闭指定 Activity 或全部 Activity
*/
class CustomActivityManager private constructor() {
private val activityStack = Stack<Activity>()
companion object {
@Volatile
private var instance: CustomActivityManager? = null
/** 获取单例实例 */
fun getInstance(): CustomActivityManager {
return instance ?: synchronized(this) {
instance ?: CustomActivityManager().also { instance = it }
}
}
}
/** 将 Activity 压入栈 */
fun addActivity(activity: Activity) {
activityStack.push(activity)
}
/** 将 Activity 从栈中移除 */
fun removeActivity(activity: Activity) {
activityStack.remove(activity)
}
/** 获取栈顶 Activity(当前可见) */
fun currentActivity(): Activity? {
return if (activityStack.isEmpty()) null else activityStack.peek()
}
/** 关闭指定 Activity */
fun finishActivity(activity: Activity?) {
activity?.let {
activityStack.remove(it)
it.finish()
}
}
/** 关闭全部 Activity */
fun finishAllActivity() {
while (!activityStack.isEmpty()) {
activityStack.pop()?.finish()
}
}
}
@@ -0,0 +1,23 @@
package com.sw.healthexpertclient.utils
import android.content.Context
/**
* 状态栏工具类
*/
object StatusbarUtil {
/**
* 获取状态栏高度(px
*/
fun getStatusBarHeight(context: Context): Int {
var result = 0
val resourceId = context.resources.getIdentifier(
"status_bar_height", "dimen", "android"
)
if (resourceId > 0) {
result = context.resources.getDimensionPixelSize(resourceId)
}
return result
}
}
@@ -0,0 +1,21 @@
package com.sw.healthexpertclient.utils.updateplugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.lzh.framework.updatepluginlib.base.CheckWorker
import org.lzh.framework.updatepluginlib.model.CheckEntity
class CheckUpdateAppVersion : CheckWorker() {
override fun useAsync(): Boolean = true
override fun check(entity: CheckEntity?): String {
return super.check(entity)
}
override fun asyncCheck(entity: CheckEntity?) {
CoroutineScope(Dispatchers.Unconfined).launch {
// TODO: 接入实际的版本检查接口
}
}
}
@@ -0,0 +1,128 @@
package com.sw.healthexpertclient.utils.updateplugin;
import android.app.Activity;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.ProgressDialog;
import android.content.Context;
import android.view.Gravity;
import android.view.Window;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import com.sw.healthexpertclient.view.dialog.TextViewDialog;
import com.sw.sdk.core.view.LoadingProgressDialog;
import org.lzh.framework.updatepluginlib.base.DownloadCallback;
import org.lzh.framework.updatepluginlib.base.DownloadNotifier;
import org.lzh.framework.updatepluginlib.model.Update;
import org.lzh.framework.updatepluginlib.util.ActivityManager;
import org.lzh.framework.updatepluginlib.util.SafeDialogHandle;
import java.io.File;
public class CustomDownloadNotifier extends DownloadNotifier {
NotificationManager manager;
NotificationCompat.Builder builder;
int id;
int preProgress;
String channelID = "downloadNotice";
String channelName = "文件下载";
@Override
public DownloadCallback create(Update update, Activity activity) {
LoadingProgressDialog dialog = new LoadingProgressDialog(activity, ProgressDialog.STYLE_SPINNER, "资源准备中...");
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
dialog.setCanceledOnTouchOutside(false);
dialog.setCancelable(false);
dialog.setMessage("资源准备中...");
SafeDialogHandle.safeShowDialog(dialog);
String channelId = createNotificationChannel(activity, channelID, channelName, NotificationManager.IMPORTANCE_HIGH);
this.manager = (NotificationManager) activity.getSystemService(Context.NOTIFICATION_SERVICE);
builder = new NotificationCompat.Builder(activity, channelId);
builder.setProgress(100, 0, false)
.setSmallIcon(activity.getApplicationInfo().icon)
.setAutoCancel(false)
.setContentTitle("文件下载")
.setContentText("文件下载进度:0%")
.build();
id = 1;
builder.setContentText("文件下载进度:" + 0 + "%");
builder.setProgress(100, 0, false);
manager.notify(id, builder.build());
return new DownloadCallback() {
@Override
public void onDownloadStart() {
}
@Override
public void onDownloadComplete(File file) {
SafeDialogHandle.safeDismissDialog(dialog);
builder.setContentText("文件下载进度:" + 100 + "%");
builder.setProgress(100, 100, false);
manager.notify(id, builder.build());
}
@Override
public void onDownloadProgress(long current, long total) {
int percent = (int) (current * 1.0f / total * 100);
dialog.setMessageProgress("下载中(" + percent + "%)");
builder.setContentText("文件下载进度:" + percent + "%");
builder.setProgress(100, percent, false);
manager.notify(id, builder.build());
}
@Override
public void onDownloadError(Throwable t) {
SafeDialogHandle.safeDismissDialog(dialog);
createRestartDialog();
}
};
}
private void createRestartDialog() {
String updateContent = "是否重新下载?";
TextViewDialog textViewDialog = new TextViewDialog(ActivityManager.get().topActivity());
textViewDialog.setDialogTitle("下载失败", 18f);
textViewDialog.setContent(updateContent, 14f);
textViewDialog.setContentStyle(Gravity.LEFT);
textViewDialog.setBtnText("确定", 18f);
textViewDialog.setDialogCancelable(false);
if (!update.isForced()) {
textViewDialog.setCancelBtnText("退出", 18f);
}
textViewDialog.setOnAffirmClickListener(new TextViewDialog.OnAffirmClickListener() {
@Override
public void onAffirmClick(@NonNull TextViewDialog viewDialog) {
restartDownload();
}
@Override
public void onCancelClick(@NonNull TextViewDialog viewDialog) {
if (update.isForced()) {
ActivityManager.get().exit();
} else {
viewDialog.dismiss();
}
}
});
if (!textViewDialog.isShowing()) {
textViewDialog.show();
}
}
private String createNotificationChannel(Context context, String channelID, String channelNAME, int level) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = new NotificationChannel(channelID, channelNAME, level);
manager.createNotificationChannel(channel);
return channelID;
} else {
return null;
}
}
}
@@ -0,0 +1,44 @@
package com.sw.healthexpertclient.utils.updateplugin;
import android.app.Activity;
import android.app.Dialog;
import android.view.Gravity;
import androidx.annotation.NonNull;
import com.sw.healthexpertclient.view.dialog.TextViewDialog;
import org.lzh.framework.updatepluginlib.base.CheckNotifier;
import org.lzh.framework.updatepluginlib.base.InstallNotifier;
import org.lzh.framework.updatepluginlib.util.SafeDialogHandle;
public class CustomInstallNotifier extends InstallNotifier {
@Override
public Dialog create(Activity activity) {
String updateContent = "版本号: " + update.getVersionName() + " 安装资源已就绪";
TextViewDialog textViewDialog = new TextViewDialog(activity);
textViewDialog.setDialogTitle("新版本安装", 18f);
textViewDialog.setContent(updateContent, 14f);
textViewDialog.setContentStyle(Gravity.LEFT);
textViewDialog.setBtnText("立即安装", 18f);
textViewDialog.setDialogCancelable(false);
if (!update.isForced()) {
textViewDialog.setCancelBtnText("取消", 18f);
}
textViewDialog.setOnAffirmClickListener(new TextViewDialog.OnAffirmClickListener() {
@Override
public void onAffirmClick(@NonNull TextViewDialog viewDialog) {
sendToInstall();
SafeDialogHandle.safeDismissDialog((Dialog) viewDialog);
}
@Override
public void onCancelClick(@NonNull TextViewDialog viewDialog) {
sendCheckIgnore();
SafeDialogHandle.safeDismissDialog((Dialog) viewDialog);
}
});
return textViewDialog;
}
}
@@ -0,0 +1,148 @@
package com.sw.healthexpertclient.view
import android.content.Context
import android.os.CountDownTimer
import android.text.method.HideReturnsTransformationMethod
import android.text.method.PasswordTransformationMethod
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.RelativeLayout
import androidx.core.content.ContextCompat
import androidx.databinding.DataBindingUtil
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.databinding.CustomPasswordViewBinding
/**
* 自定义密码/验证码输入框
* 支持:密码明文切换、发送验证码(120s 倒计时)
*/
class CustomPasswordView
(context: Context?, attrs: AttributeSet?) : RelativeLayout(context, attrs, 0) {
var mContext: Context? = context
// DataBinding 初始化后不应被重新赋值,改为 val 防止误操作
val mBinding: CustomPasswordViewBinding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.custom_password_view, this, true
)
/** 倒计时器,View 销毁时需要取消,防止内存泄漏 */
private var countDownTimer: CountDownTimer? = null
/** 发送验证码点击回调,由外部注入具体发送逻辑 */
private var onSendCodeListener: (() -> Unit)? = null
init {
// 默认显示为密码(圆点遮盖)
mBinding.loginEtUserPassword.setTransformationMethod(PasswordTransformationMethod.getInstance())
// 点击眼睛图标切换明文/密文(mBinding 是 val,不可为 null,无需 ?. 判空)
mBinding.loginIvEye.setOnClickListener {
val selection = mBinding.loginEtUserPassword.selectionEnd
if (mBinding.loginEtUserPassword.transformationMethod == PasswordTransformationMethod.getInstance()) {
// 当前密文 -> 切换为明文
mBinding.loginEtUserPassword.setTransformationMethod(
HideReturnsTransformationMethod.getInstance()
)
mBinding.loginIvEye.setImageResource(R.mipmap.ic_password_show)
} else {
// 当前明文 -> 切换为密文
mBinding.loginEtUserPassword.setTransformationMethod(PasswordTransformationMethod.getInstance())
mBinding.loginIvEye.setImageResource(R.mipmap.ic_password_hind)
}
// 保持光标位置不跳动
mBinding.loginEtUserPassword.setSelection(selection)
}
// 点击发送验证码:先回调外部逻辑,再启动倒计时
mBinding.loginTvSendCode.setOnClickListener {
onSendCodeListener?.invoke()
startCountdown()
}
}
/**
* 显示明文密码
*/
fun showPassword() {
if (mBinding.loginEtUserPassword.transformationMethod == PasswordTransformationMethod.getInstance()) {
// 当前密文 -> 切换为明文
mBinding.loginEtUserPassword.setTransformationMethod(
HideReturnsTransformationMethod.getInstance()
)
mBinding.loginIvEye.setImageResource(R.mipmap.ic_password_show)
}
}
/**
* 设置发送验证码点击回调
* 在此回调内处理实际的短信发送请求,View 负责倒计时 UI
*
* @param listener 点击时触发,用于外部执行发送逻辑
*/
fun setOnSendCodeClickListener(listener: () -> Unit) {
onSendCodeListener = listener
}
/**
* 启动 120s 倒计时
* 期间按钮不可点击,显示"XXs后重发";结束后自动恢复"发送验证码"
*/
private fun startCountdown() {
// 禁用按钮,防止重复点击
mBinding.loginTvSendCode.isEnabled = false
mBinding.loginTvSendCode.setTextColor(
ContextCompat.getColor(context, R.color.text_black_99)
)
// 取消上一次未结束的倒计时(如有)
countDownTimer?.cancel()
countDownTimer = object : CountDownTimer(120_000L, 1000L) {
override fun onTick(millisUntilFinished: Long) {
val seconds = millisUntilFinished / 1000
mBinding.loginTvSendCode.text = "${seconds}s后重发"
}
override fun onFinish() {
// 倒计时结束,恢复按钮为可点击状态
mBinding.loginTvSendCode.isEnabled = true
mBinding.loginTvSendCode.text = "发送验证码"
// mBinding.loginTvSendCode.setTextColor(
// ContextCompat.getColor(context, R.color.theme_color_purple)
// )
}
}.start()
}
/**
* View 从窗口移除时取消倒计时,防止内存泄漏
*/
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
countDownTimer?.cancel()
countDownTimer = null
}
/**
* 获取输入框中的文本(去除首尾空格)
*
* @return 输入内容字符串
*/
fun getInputContext(): String {
return mBinding.loginEtUserPassword.text.toString().trim()
}
/**
* 主动设置输入框文本
*
* @param password 要填入的文本
*/
fun setInputContext(password: String) {
mBinding.loginEtUserPassword.setText(password)
}
fun setInputHint(hint: String) {
mBinding.loginEtUserPassword.hint = hint
}
}
@@ -0,0 +1,45 @@
package com.sw.healthexpertclient.view;
import android.app.Activity;
import android.app.Dialog;
import android.view.Gravity;
import androidx.annotation.NonNull;
import com.sw.healthexpertclient.view.dialog.TextViewDialog;
import com.sw.sdk.core.utils.SystemHelper;
import org.lzh.framework.updatepluginlib.base.CheckNotifier;
import org.lzh.framework.updatepluginlib.util.SafeDialogHandle;
public class CustomUpdateNotifier extends CheckNotifier {
@Override
public Dialog create(Activity activity) {
String updateContent = "版本号: " + update.getVersionName() + "\n\n"
+ update.getUpdateContent();
TextViewDialog textViewDialog = new TextViewDialog(activity);
textViewDialog.setDialogTitle("发现新版本", 18f);
textViewDialog.setContent(updateContent, 14f);
textViewDialog.setContentStyle(Gravity.LEFT);
textViewDialog.setBtnText("立即更新", 18f);
textViewDialog.setDismissOnConfirm(!update.isForced());
textViewDialog.setDialogCancelable(false);
if (!update.isForced()) {
textViewDialog.setCancelBtnText("取消", 18f);
}
textViewDialog.setOnAffirmClickListener(new TextViewDialog.OnAffirmClickListener() {
@Override
public void onAffirmClick(@NonNull TextViewDialog viewDialog) {
SystemHelper.downApk(activity, update.getUpdateUrl());
}
@Override
public void onCancelClick(@NonNull TextViewDialog viewDialog) {
sendUserCancel();
SafeDialogHandle.safeDismissDialog((Dialog) viewDialog);
}
});
return textViewDialog;
}
}
@@ -0,0 +1,23 @@
package com.sw.healthexpertclient.view
import android.app.AlertDialog
import android.content.Context
import android.view.LayoutInflater
import com.sw.healthexpertclient.R
/**
* 全局加载对话框
*
* 统一管理网络请求加载中的 UI 展示,避免在每个页面重复实现
*/
class LoadingDialog(
context: Context?,
style: Int,
title: String
) : AlertDialog(context) {
init {
val view = LayoutInflater.from(context).inflate(R.layout.dialog_loading, null)
setView(view)
}
}
@@ -0,0 +1,105 @@
package com.sw.healthexpertclient.view.dialog
import android.app.AlertDialog
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import androidx.databinding.DataBindingUtil
import com.sw.healthexpertclient.R
import com.sw.healthexpertclient.databinding.DialogTextViewBinding
open class TextViewDialog constructor(
context: Context
) : AlertDialog(context) {
lateinit var mOnAffirmClickListener: OnAffirmClickListener
protected lateinit var binding: DialogTextViewBinding
var autoCancel: Boolean = true
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
create()
} else {
onCreate(null)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.inflate(
LayoutInflater.from(context),
R.layout.dialog_text_view, null, false
)
setContentView(binding.root)
window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
window?.setLayout(
context.resources.displayMetrics.widthPixels * 21 / 25,
LinearLayout.LayoutParams.WRAP_CONTENT
)
binding.btnAffirm.setOnClickListener {
mOnAffirmClickListener?.onAffirmClick(this@TextViewDialog)
if (autoCancel) this.cancel()
}
binding.btnCancel.setOnClickListener {
mOnAffirmClickListener?.onCancelClick(this@TextViewDialog)
this.cancel()
}
}
fun setOnAffirmClickListener(onAffirmClickListener: OnAffirmClickListener): TextViewDialog {
this.mOnAffirmClickListener = onAffirmClickListener
return this
}
interface OnAffirmClickListener {
fun onAffirmClick(viewDialog: TextViewDialog)
fun onCancelClick(viewDialog: TextViewDialog)
}
fun setDialogTitle(title: String, titleSize: Float = 18f): TextViewDialog {
binding.tvTitle.visibility = View.VISIBLE
binding.tvTitle.text = title
binding.tvTitle.textSize = titleSize
return this
}
fun setContent(text: String, textSize: Float = 13f): TextViewDialog {
binding.tvText.visibility = View.VISIBLE
binding.tvText.text = text
binding.tvText.textSize = textSize
return this
}
fun setContentStyle(gravity: Int): TextViewDialog {
binding.tvText.gravity = gravity
return this
}
fun setBtnText(text: String, btnTextSize: Float = 18f): TextViewDialog {
binding.btnAffirm.text = text
binding.btnAffirm.textSize = btnTextSize
return this
}
fun setCancelBtnText(text: String, btnTextSize: Float = 18f): TextViewDialog {
binding.btnCancel.visibility = View.VISIBLE
binding.line2.visibility = View.VISIBLE
binding.btnCancel.text = text
binding.btnCancel.textSize = btnTextSize
return this
}
fun setDialogCancelable(flag: Boolean): TextViewDialog {
setCancelable(flag)
return this
}
fun setDismissOnConfirm(flag: Boolean): TextViewDialog {
autoCancel = flag
return this
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorPrimary" android:state_selected="true" />
<item android:color="@color/text_black_33" android:state_selected="false"/>
<item android:color="@color/text_black_33"/>
</selector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 选中状态-->
<item android:drawable="@drawable/rectangle_round_corner12_theme" android:state_checked="true"/>
<item android:drawable="@drawable/rectangle_round_corner12_theme" android:state_selected="true"/>
<!-- 默认状态-->
<item android:drawable="@drawable/rectangle_round_corner20_white"/>
</selector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#EEEEEE" />
<corners android:radius="20dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F6F9FE" />
<corners android:radius="25dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="180"
android:endColor="#21BEBD"
android:startColor="#54ECCB" />
<corners android:radius="20dp" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners
android:topLeftRadius="20dp"
android:topRightRadius="20dp"
android:bottomLeftRadius="0dp"
android:bottomRightRadius="0dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_selected" android:state_checked="true"/>
<item android:drawable="@drawable/ic_unselected" android:state_checked="false"/>
<item android:drawable="@drawable/ic_unselected"/>
</selector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- ic_launcher 占位(后续替换为实际图标) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1677FF" />
<size android:width="48dp" android:height="48dp" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 启动图标前景占位(纯色矩形,后续替换为实际 LOGO) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFF" />
<size android:width="48dp" android:height="48dp" />
<corners android:radius="8dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#A4A3A3" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#1677FF" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#A4A3A3" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#1677FF" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#A4A3A3" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#1677FF" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#A4A3A3" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#1677FF" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 工作台 Tab 图标 - 未选中(占位,后续替换为实际图标) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#A4A3A3" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 工作台 Tab 图标 - 选中(占位,后续替换为实际图标) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1677FF" />
<size android:width="24dp" android:height="24dp" />
<corners android:radius="4dp" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:innerRadius="1.0dip"
android:shape="rectangle"
android:useLevel="false">
<corners android:radius="10dp" />
<solid android:color="@color/white" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:innerRadius="1.0dip"
android:shape="rectangle"
android:useLevel="false">
<corners android:radius="12dp" />
<solid android:color="?attr/colorPrimary" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Tab 小红点背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF4444" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Tab 未读数量气泡背景 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF4444" />
</shape>
+132
View File
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewModel"
type="com.sw.healthexpertclient.ui.login.viewmodel.LoginViewModel" />
</data>
<RelativeLayout
android:id="@+id/login_root_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/login_view_bg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:scaleType="fitXY"
android:src="@drawable/ic_login_bg" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:layout_width="68dp"
android:layout_height="68dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="72dp"
android:src="@mipmap/ic_launcher_round" />
<ImageView
android:layout_width="110dp"
android:layout_height="24dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp"
android:src="@drawable/ic_login_name" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="30dp"
android:background="@drawable/bg_white_bottom_right_angle_background_shap20"
android:orientation="vertical">
<EditText
android:id="@+id/login_et_user_name"
android:longClickable="false"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginLeft="35dp"
android:layout_marginTop="36dp"
android:layout_marginRight="35dp"
android:background="@drawable/bg_grey_background_shap_radius23"
android:hint="@string/login_account_hint"
android:maxLength="20"
android:maxLines="1"
android:paddingLeft="16dp"
android:singleLine="true"
android:textColor="@color/text_black_33"
android:textColorHint="@color/text_black_99"
android:textSize="14sp" />
<com.sw.healthexpertclient.view.CustomPasswordView
android:layout_marginTop="36dp"
android:id="@+id/login_et_user_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/login_submit"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginLeft="36dp"
android:layout_marginTop="40dp"
android:layout_marginRight="36dp"
android:background="@drawable/bg_login_green_to_white"
android:gravity="center"
android:text="@string/login_submit"
android:textColor="@color/white"
android:textSize="14sp" />
<TextView
android:id="@+id/login_retrieve_password"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="10dp"
android:text="@string/login_retrieve_password"
android:textColor="@color/text_black_99"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/login_agreement_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_marginLeft="35dp"
android:layout_marginTop="14dp"
android:layout_marginRight="35dp"
android:layout_marginBottom="35dp"
android:orientation="horizontal">
<CheckBox
android:id="@+id/login_iv_read_state"
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginTop="2dp"
android:button="@drawable/check_theme_style"
android:checked="@={viewModel.agreement}" />
<TextView
android:id="@+id/login_tv_agreement"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="5dp"
android:lineSpacingExtra="8dp"
android:textColor="@color/text_black_99"
android:textSize="12sp" />
</LinearLayout>
</RelativeLayout>
</layout>
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.sw.healthexpertclient.base.viewmodel.CommonViewModel" />
</data>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<!-- Fragment 容器 -->
<FrameLayout
android:id="@+id/main_content_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="58dp" />
<!-- 底部 Tab 分割线 -->
<View
android:layout_width="match_parent"
android:layout_height="0.5dp"
android:layout_above="@id/main_tab_layout"
android:background="#E5E5E5" />
<!-- 自定义底部 Tab 导航控件 -->
<com.sw.healthexpertclient.bottomtab.HomeBottomTabLayout
android:id="@+id/main_tab_layout"
android:layout_width="match_parent"
android:layout_height="58dp"
android:layout_alignParentBottom="true"
android:background="#FFFFFF"
android:clipChildren="false"
app:config_file_name="tab.json"
app:container_id="@id/main_content_layout" />
</RelativeLayout>
</layout>

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