commit 7ea14ab925907bb577b289a72dd11bbe061fd88f Author: zhanglei <350328959@qq.com> Date: Wed Apr 22 16:34:35 2026 +0800 feat(init): 初始化健康专家客户端 Android 项目 - 搭建项目基础架构,包含 Activity/Fragment/ViewModel 层 - 添加登录、主页、AI聊天、工作台、消息、知识库等核心模块 - 配置 Gradle 构建脚本及依赖管理 - 添加基础 UI 组件:自定义密码输入框、标题栏、Tab 布局等 - 添加图标、主题、drawable 等资源文件 diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..5feac3b --- /dev/null +++ b/app/build.gradle @@ -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 + + // Coil:Compose 网络图片加载 + implementation "io.coil-kt:coil-compose:2.6.0" + +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..72e7942 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/tab.json b/app/src/main/assets/tab.json new file mode 100644 index 0000000..45e8ae3 --- /dev/null +++ b/app/src/main/assets/tab.json @@ -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" + } + ] +} diff --git a/app/src/main/java/com/sw/healthexpertclient/AppViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/AppViewModel.kt new file mode 100644 index 0000000..6c9bc07 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/AppViewModel.kt @@ -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() {} + +} diff --git a/app/src/main/java/com/sw/healthexpertclient/MainActivity.kt b/app/src/main/java/com/sw/healthexpertclient/MainActivity.kt new file mode 100644 index 0000000..e37f788 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/MainActivity.kt @@ -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(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 +} diff --git a/app/src/main/java/com/sw/healthexpertclient/MyApplication.kt b/app/src/main/java/com/sw/healthexpertclient/MyApplication.kt new file mode 100644 index 0000000..75a78dc --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/MyApplication.kt @@ -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!! +} diff --git a/app/src/main/java/com/sw/healthexpertclient/base/BaseVMBActivity.kt b/app/src/main/java/com/sw/healthexpertclient/base/BaseVMBActivity.kt new file mode 100644 index 0000000..5f08170 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/base/BaseVMBActivity.kt @@ -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( + 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() { + override fun onBound(binding: B) { + super.onBound(binding) + setTransparentStatusBar(transparentStatusBar(), statusBarDarkFont()) + } + }) + } + + /** 初始化沉浸式状态栏 */ + open fun initImmersionBar() { + initToolBar() + } + + /** 布局绘制完成后初始化空状态视图 */ + open fun onDrawFinish() { + mRootView = findViewById(R.id.rl_empty_root_view) + mRootView?.let { + mEmpty = LayoutInflater.from(mContext) + .inflate(R.layout.layout_empty, it, false).apply { + findViewById(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(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 = + (this.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class + 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) + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/base/BaseVMBFragment.kt b/app/src/main/java/com/sw/healthexpertclient/base/BaseVMBFragment.kt new file mode 100644 index 0000000..2961b30 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/base/BaseVMBFragment.kt @@ -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( + 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 = + (this.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class + 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(R.id.rl_empty_root_view) + mRootView?.let { + mEmpty = LayoutInflater.from(context) + .inflate(R.layout.layout_empty, it, false).apply { + findViewById(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(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?) {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/base/repository/BaseRepository.kt b/app/src/main/java/com/sw/healthexpertclient/base/repository/BaseRepository.kt new file mode 100644 index 0000000..046d535 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/base/repository/BaseRepository.kt @@ -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 apiCall(api: suspend () -> ApiResponse): ApiResponse { + return withContext(Dispatchers.IO) { api.invoke() } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/base/viewmodel/BaseViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/base/viewmodel/BaseViewModel.kt new file mode 100644 index 0000000..1c8f394 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/base/viewmodel/BaseViewModel.kt @@ -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() + + /** 网络请求失败异常(如:连接超时) */ + val exception = MutableLiveData() + + /** 服务器返回业务错误(请求成功但 code 非正常) */ + val errorResponse = MutableLiveData?>() + + /** Toast 消息文本 */ + var toastMessage = MutableStateFlow(null) + + /** 是否自动展示空页面(数据为空时自动触发) */ + var isAutoEmpty = MutableLiveData() + + /** 是否展示空页面 */ + var showEmpty = MutableLiveData(false) + + /** + * 界面初始化时调用,子类实现网络请求或数据初始化 + */ + abstract fun init() +} diff --git a/app/src/main/java/com/sw/healthexpertclient/base/viewmodel/CommonViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/base/viewmodel/CommonViewModel.kt new file mode 100644 index 0000000..ac2a973 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/base/viewmodel/CommonViewModel.kt @@ -0,0 +1,12 @@ +package com.sw.healthexpertclient.base.viewmodel + +/** + * 通用 ViewModel + * + * 用于不需要独立业务逻辑的页面(如 MainActivity、纯展示页等) + */ +class CommonViewModel : BaseViewModel() { + override fun init() { + // 通用 ViewModel 无需初始化逻辑 + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/bean/AppUpdateBean.kt b/app/src/main/java/com/sw/healthexpertclient/bean/AppUpdateBean.kt new file mode 100644 index 0000000..a4fe5b1 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bean/AppUpdateBean.kt @@ -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 +) diff --git a/app/src/main/java/com/sw/healthexpertclient/bean/common/CommonSettingMenuBean.kt b/app/src/main/java/com/sw/healthexpertclient/bean/common/CommonSettingMenuBean.kt new file mode 100644 index 0000000..e29c26c --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bean/common/CommonSettingMenuBean.kt @@ -0,0 +1,9 @@ +package com.sw.healthexpertclient.bean.common + +/** + * 配置菜单条目数据模型 + */ +data class CommonSettingMenuBean( + val value: String = "", + val text: String = "" +) diff --git a/app/src/main/java/com/sw/healthexpertclient/bean/common/UploadFileBean.kt b/app/src/main/java/com/sw/healthexpertclient/bean/common/UploadFileBean.kt new file mode 100644 index 0000000..81b3d63 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bean/common/UploadFileBean.kt @@ -0,0 +1,10 @@ +package com.sw.healthexpertclient.bean.common + +/** + * 文件上传接口返回数据 + */ +data class UploadFileBean( + val baseUrl: String?, + val path: String?, + val size: Long? +) diff --git a/app/src/main/java/com/sw/healthexpertclient/bean/login/LoginBean.kt b/app/src/main/java/com/sw/healthexpertclient/bean/login/LoginBean.kt new file mode 100644 index 0000000..4f47490 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bean/login/LoginBean.kt @@ -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 = "" +) diff --git a/app/src/main/java/com/sw/healthexpertclient/bean/login/LoginInfoBean.kt b/app/src/main/java/com/sw/healthexpertclient/bean/login/LoginInfoBean.kt new file mode 100644 index 0000000..67fd966 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bean/login/LoginInfoBean.kt @@ -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 +) diff --git a/app/src/main/java/com/sw/healthexpertclient/bean/phone/PhoneBean.kt b/app/src/main/java/com/sw/healthexpertclient/bean/phone/PhoneBean.kt new file mode 100644 index 0000000..cb3821a --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bean/phone/PhoneBean.kt @@ -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 +) diff --git a/app/src/main/java/com/sw/healthexpertclient/bottomtab/HomeBottomTabLayout.kt b/app/src/main/java/com/sw/healthexpertclient/bottomtab/HomeBottomTabLayout.kt new file mode 100644 index 0000000..4cb7312 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bottomtab/HomeBottomTabLayout.kt @@ -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() + private var mTabContainerList: ArrayList? = null + private var mOverSideTab = ArrayList(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? = 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(R.id.TabItem_tvUnReadTip) + tvUnRead?.visibility = View.VISIBLE + tvUnRead?.text = countStr + } else { + val viewPoint = mTabContainerList?.get(index) + ?.findViewById(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(R.id.TabItem_tvUnReadTip)?.visibility = View.GONE + mTabContainerList?.get(index) + ?.findViewById(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(R.id.TabItem_ivIcon) + val tvName = viewGroup.findViewById(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 + } + } + } + + /** 切换 Fragment(show/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(R.id.TabItem_tvTitle) + val ivIcon = view.findViewById(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?) + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/bottomtab/TabConfig.kt b/app/src/main/java/com/sw/healthexpertclient/bottomtab/TabConfig.kt new file mode 100644 index 0000000..2d49dd5 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bottomtab/TabConfig.kt @@ -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 名称是否为字符串资源 ID(true=resId,false=直接文字) + * @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 +) : 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 diff --git a/app/src/main/java/com/sw/healthexpertclient/bottomtab/TabFragmentPageAdapter.kt b/app/src/main/java/com/sw/healthexpertclient/bottomtab/TabFragmentPageAdapter.kt new file mode 100644 index 0000000..d04417b --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bottomtab/TabFragmentPageAdapter.kt @@ -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 +) : FragmentPagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + + override fun getItem(position: Int): Fragment = fragments[position] + + override fun getCount(): Int = fragments.size +} diff --git a/app/src/main/java/com/sw/healthexpertclient/bottomtab/TabUtils.kt b/app/src/main/java/com/sw/healthexpertclient/bottomtab/TabUtils.kt new file mode 100644 index 0000000..3f4b846 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/bottomtab/TabUtils.kt @@ -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() + 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) + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/data/api/ApiConfig.kt b/app/src/main/java/com/sw/healthexpertclient/data/api/ApiConfig.kt new file mode 100644 index 0000000..7d5698e --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/data/api/ApiConfig.kt @@ -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" +} diff --git a/app/src/main/java/com/sw/healthexpertclient/data/api/GuidanceApi.kt b/app/src/main/java/com/sw/healthexpertclient/data/api/GuidanceApi.kt new file mode 100644 index 0000000..5970150 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/data/api/GuidanceApi.kt @@ -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 +} diff --git a/app/src/main/java/com/sw/healthexpertclient/data/api/UserInfoApi.kt b/app/src/main/java/com/sw/healthexpertclient/data/api/UserInfoApi.kt new file mode 100644 index 0000000..d5507d8 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/data/api/UserInfoApi.kt @@ -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 + + /** 用户登录 */ + @POST("${ApiConfig.HEALTH_SYS}/auth/appLogin") + suspend fun mLogin(@Body body: RequestBody): ApiResponse + + /** 身份验证(忘记密码第一步) */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/password/verify") + suspend fun verifyIdentity(@Body body: RequestBody): ApiResponse + + /** 下载文件 */ + @Streaming + @GET + suspend fun downloadFile(@Url bookUrl: String?): ResponseBody + + /** 查询个人信息 */ + @GET("${ApiConfig.HEALTH_SYS}/sys-user/app/userInfo") + suspend fun getUserInfo(): ApiResponse + + /** 更新用户头像 */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/avatar") + suspend fun updateAvatar(@Body body: RequestBody): ApiResponse + + /** 设置默认手机号 */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/phone/setDefault") + suspend fun setDefaultPhone(@QueryMap params: MutableMap): ApiResponse + + /** 新增手机号 */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/phone/add") + suspend fun addPhone(@Body body: RequestBody): ApiResponse + + /** 身份证核验 */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/idCard/verify") + suspend fun verifyIdCard(@QueryMap params: MutableMap): ApiResponse + + /** 密码重置前的身份核验 */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/password/verify") + suspend fun verifyPasswordIdentity(@Body body: RequestBody): ApiResponse + + /** 重置密码 */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/password/reset") + suspend fun resetPasswordByToken(@Body body: RequestBody): ApiResponse + + /** 登录状态下修改密码 */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/password/change") + suspend fun changePassword(@Body body: RequestBody): ApiResponse + + /** 查询用户手机号列表 */ + @GET("${ApiConfig.HEALTH_SYS}/sys-user/app/phoneList") + suspend fun getPhoneList(): ApiResponse> + + /** 删除手机号 */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/phone/delete") + suspend fun deletePhone(@QueryMap params: MutableMap): ApiResponse + + /** 验证原手机号短信验证码 */ + @POST("${ApiConfig.HEALTH_SYS}/sys-user/app/phone/verifyCode") + suspend fun verifyPhoneCode(@Body body: RequestBody): ApiResponse + + /** 上传文件 */ + @Multipart + @POST("${ApiConfig.HEALTH_SYS}/file/upload") + suspend fun uploadFile(@Part file: MultipartBody.Part): ApiResponse +} diff --git a/app/src/main/java/com/sw/healthexpertclient/data/bean/ApiResponse.kt b/app/src/main/java/com/sw/healthexpertclient/data/bean/ApiResponse.kt new file mode 100644 index 0000000..99ebccd --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/data/bean/ApiResponse.kt @@ -0,0 +1,30 @@ +package com.sw.healthexpertclient.data.bean + +/** + * 服务器通用响应体 + */ +data class ApiResponse( + var code: String, + val msg: String?, + val total: Int?, + val data: T? +) + +/** + * 是否请求成功 + */ +val ApiResponse.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", "请求必填参数为空"), +} diff --git a/app/src/main/java/com/sw/healthexpertclient/data/repository/GuidanceRepository.kt b/app/src/main/java/com/sw/healthexpertclient/data/repository/GuidanceRepository.kt new file mode 100644 index 0000000..0091260 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/data/repository/GuidanceRepository.kt @@ -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 { + val params = mutableMapOf() + 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()) } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/data/repository/UserInfoRepository.kt b/app/src/main/java/com/sw/healthexpertclient/data/repository/UserInfoRepository.kt new file mode 100644 index 0000000..938745f --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/data/repository/UserInfoRepository.kt @@ -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 { + val params = mapOf("phone" to phone, "codeType" to codeType) + return apiCall { service.sendSmsCode(params.toJson().toRequestBody()) } + } + + /** 用户登录 */ + suspend fun login(loginBean: LoginBean): ApiResponse { + return apiCall { service.mLogin(loginBean.toJson().toRequestBody()) } + } + + /** 身份验证(忘记密码第一步) */ + suspend fun verifyIdentity(name: String, phone: String, idCard: String): ApiResponse { + val params = mapOf("realName" to name, "phone" to phone, "idCard" to idCard) + return apiCall { service.verifyIdentity(params.toJson().toRequestBody()) } + } + + /** 查询个人信息 */ + suspend fun getUserInfo(): ApiResponse { + return apiCall { service.getUserInfo() } + } + + /** 更新用户头像 */ + suspend fun updateAvatar(id: String, avatar: String): ApiResponse { + val params = mapOf("id" to id, "avatar" to avatar) + return apiCall { service.updateAvatar(params.toJson().toRequestBody()) } + } + + /** 设置默认手机号 */ + suspend fun setDefaultPhone(phone: String): ApiResponse { + val params = mutableMapOf() + params["phone"] = phone + return apiCall { service.setDefaultPhone(params) } + } + + /** 新增手机号 */ + suspend fun addPhone(phone: String, smsCode: String, isDefault: Boolean, oldPhone: String): ApiResponse { + 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 { + val params = mutableMapOf() + params["idCard"] = idCard + return apiCall { service.verifyIdCard(params) } + } + + /** 密码重置前的身份核验 */ + suspend fun verifyPasswordIdentity(realName: String, phone: String, idCard: String): ApiResponse { + 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 { + 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 { + val params = mapOf("oldPassword" to oldPassword, "newPassword" to newPassword) + return apiCall { service.changePassword(params.toJson().toRequestBody()) } + } + + /** 查询用户手机号列表 */ + suspend fun getPhoneList(): ApiResponse> { + return apiCall { service.getPhoneList() } + } + + /** 删除手机号 */ + suspend fun deletePhone(phone: String): ApiResponse { + val params = mutableMapOf() + params["phone"] = phone + return apiCall { service.deletePhone(params) } + } + + /** 验证原手机号短信验证码 */ + suspend fun verifyPhoneCode(phone: String, smsCode: String): ApiResponse { + val params = mapOf("phone" to phone, "smsCode" to smsCode) + return apiCall { service.verifyPhoneCode(params.toJson().toRequestBody()) } + } + + /** 上传文件 */ + suspend fun uploadFile(file: File): ApiResponse { + val photoPart = MultipartBody.Part.createFormData( + "file", file.name, + file.asRequestBody( + android.webkit.MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(file.extension) + ?.toMediaTypeOrNull() + ) + ) + return apiCall { service.uploadFile(photoPart) } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/event/GlobalEvent.kt b/app/src/main/java/com/sw/healthexpertclient/event/GlobalEvent.kt new file mode 100644 index 0000000..90f20a4 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/event/GlobalEvent.kt @@ -0,0 +1,7 @@ +package com.sw.healthexpertclient.event + +/** + * 全局事件,用于 EventBus 广播 + * @param message 事件类型编码(0 = 强制登出) + */ +data class GlobalEvent(val message: Int) diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/AIChatFragment.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/AIChatFragment.kt new file mode 100644 index 0000000..7753a05 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/AIChatFragment.kt @@ -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(R.layout.fragment_aichat) { + + override fun initView(root: View?, savedInstanceState: Bundle?) {} + + override fun lazyLoadData() { + // TODO: 首次可见时初始化 AI 对话 + } + + override fun bindEvent() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/KnowledgeFragment.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/KnowledgeFragment.kt new file mode 100644 index 0000000..0f5da25 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/KnowledgeFragment.kt @@ -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(R.layout.fragment_knowledge) { + + override fun initView(root: View?, savedInstanceState: Bundle?) {} + + override fun lazyLoadData() { + // TODO: 首次可见时加载知识数据 + } + + override fun bindEvent() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/MessageFragment.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/MessageFragment.kt new file mode 100644 index 0000000..2997d42 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/MessageFragment.kt @@ -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(R.layout.fragment_message) { + + override fun initView(root: View?, savedInstanceState: Bundle?) {} + + override fun lazyLoadData() { + // TODO: 首次可见时加载消息列表 + } + + override fun bindEvent() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/MineFragment.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/MineFragment.kt new file mode 100644 index 0000000..128f073 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/MineFragment.kt @@ -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(R.layout.fragment_mine) { + + override fun initView(root: View?, savedInstanceState: Bundle?) {} + + override fun lazyLoadData() { + // TODO: 首次可见时加载用户信息 + } + + override fun bindEvent() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/WorkbenchFragment.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/WorkbenchFragment.kt new file mode 100644 index 0000000..ec9b3e4 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/WorkbenchFragment.kt @@ -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(R.layout.fragment_workbench) { + + override fun initView(root: View?, savedInstanceState: Bundle?) {} + + override fun lazyLoadData() { + // TODO: 首次可见时加载工作台数据 + } + + override fun bindEvent() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/AIChatViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/AIChatViewModel.kt new file mode 100644 index 0000000..9c922c0 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/AIChatViewModel.kt @@ -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() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/KnowledgeViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/KnowledgeViewModel.kt new file mode 100644 index 0000000..9578b16 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/KnowledgeViewModel.kt @@ -0,0 +1,8 @@ +package com.sw.healthexpertclient.fragment.viewmodel + +import com.sw.healthexpertclient.base.viewmodel.BaseViewModel + +/** 知识 ViewModel */ +class KnowledgeViewModel : BaseViewModel() { + override fun init() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/MessageViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/MessageViewModel.kt new file mode 100644 index 0000000..8757dbd --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/MessageViewModel.kt @@ -0,0 +1,8 @@ +package com.sw.healthexpertclient.fragment.viewmodel + +import com.sw.healthexpertclient.base.viewmodel.BaseViewModel + +/** 消息 ViewModel */ +class MessageViewModel : BaseViewModel() { + override fun init() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/MineViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/MineViewModel.kt new file mode 100644 index 0000000..397c6b9 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/MineViewModel.kt @@ -0,0 +1,8 @@ +package com.sw.healthexpertclient.fragment.viewmodel + +import com.sw.healthexpertclient.base.viewmodel.BaseViewModel + +/** 我的 ViewModel */ +class MineViewModel : BaseViewModel() { + override fun init() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/WorkbenchViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/WorkbenchViewModel.kt new file mode 100644 index 0000000..79a780a --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/fragment/viewmodel/WorkbenchViewModel.kt @@ -0,0 +1,8 @@ +package com.sw.healthexpertclient.fragment.viewmodel + +import com.sw.healthexpertclient.base.viewmodel.BaseViewModel + +/** 工作台 ViewModel */ +class WorkbenchViewModel : BaseViewModel() { + override fun init() {} +} diff --git a/app/src/main/java/com/sw/healthexpertclient/retrofit/RetrofitManager.kt b/app/src/main/java/com/sw/healthexpertclient/retrofit/RetrofitManager.kt new file mode 100644 index 0000000..5114b43 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/retrofit/RetrofitManager.kt @@ -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 getService(serviceClass: Class, 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()) + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/retrofit/UrlConfig.kt b/app/src/main/java/com/sw/healthexpertclient/retrofit/UrlConfig.kt new file mode 100644 index 0000000..2a5256c --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/retrofit/UrlConfig.kt @@ -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 = 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=" + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/retrofit/gson/GsonConverterFactoryNew.java b/app/src/main/java/com/sw/healthexpertclient/retrofit/gson/GsonConverterFactoryNew.java new file mode 100644 index 0000000..9e0c469 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/retrofit/gson/GsonConverterFactoryNew.java @@ -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 responseBodyConverter(Type type, Annotation[] annotations, Retrofit retrofit) { + TypeAdapter adapter = gson.getAdapter(TypeToken.get(type)); + return new GsonResponseBodyConverter<>(gson, adapter); + } + + @Override + public Converter requestBodyConverter(Type type, Annotation[] parameterAnnotations, Annotation[] methodAnnotations, Retrofit retrofit) { + TypeAdapter adapter = gson.getAdapter(TypeToken.get(type)); + return new GsonRequestBodyConverter<>(gson, adapter); + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/retrofit/gson/GsonRequestBodyConverter.java b/app/src/main/java/com/sw/healthexpertclient/retrofit/gson/GsonRequestBodyConverter.java new file mode 100644 index 0000000..de96d2f --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/retrofit/gson/GsonRequestBodyConverter.java @@ -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 implements Converter { + 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 adapter; + + public GsonRequestBodyConverter(Gson gson, TypeAdapter 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()); + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/retrofit/gson/GsonResponseBodyConverter.java b/app/src/main/java/com/sw/healthexpertclient/retrofit/gson/GsonResponseBodyConverter.java new file mode 100644 index 0000000..e61c0ec --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/retrofit/gson/GsonResponseBodyConverter.java @@ -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, + * 业务码非成功时将 data 置为 null,由上层 handleRequest 通过 isOk 判断。 + */ +public final class GsonResponseBodyConverter implements Converter { + private final Gson gson; + private final TypeAdapter adapter; + + public GsonResponseBodyConverter(Gson gson, TypeAdapter 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(); + } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/retrofit/interceptor/ApiRequestInterceptor.kt b/app/src/main/java/com/sw/healthexpertclient/retrofit/interceptor/ApiRequestInterceptor.kt new file mode 100644 index 0000000..00ecb2c --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/retrofit/interceptor/ApiRequestInterceptor.kt @@ -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. 注入鉴权 Header(abtoken) + * 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() + 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() + 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): TreeMap { + 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") + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/retrofit/interceptor/HttpCodeNormalizeInterceptor.kt b/app/src/main/java/com/sw/healthexpertclient/retrofit/interceptor/HttpCodeNormalizeInterceptor.kt new file mode 100644 index 0000000..b3571e1 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/retrofit/interceptor/HttpCodeNormalizeInterceptor.kt @@ -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 = 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() + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/retrofit/interceptor/LogInterceptor.kt b/app/src/main/java/com/sw/healthexpertclient/retrofit/interceptor/LogInterceptor.kt new file mode 100644 index 0000000..6b477c9 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/retrofit/interceptor/LogInterceptor.kt @@ -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) diff --git a/app/src/main/java/com/sw/healthexpertclient/superfuntion/BaseActivityFuntion.kt b/app/src/main/java/com/sw/healthexpertclient/superfuntion/BaseActivityFuntion.kt new file mode 100644 index 0000000..261f6ad --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/superfuntion/BaseActivityFuntion.kt @@ -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) +} diff --git a/app/src/main/java/com/sw/healthexpertclient/superfuntion/BaseViewModelExt.kt b/app/src/main/java/com/sw/healthexpertclient/superfuntion/BaseViewModelExt.kt new file mode 100644 index 0000000..54bc7a2 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/superfuntion/BaseViewModelExt.kt @@ -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 BaseViewModel.handleRequest( + response: ApiResponse, + successBlock: suspend CoroutineScope.(response: ApiResponse) -> Unit = {}, + errorBlock: suspend CoroutineScope.(response: ApiResponse) -> 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 + } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/superfuntion/StartActivityManager.kt b/app/src/main/java/com/sw/healthexpertclient/superfuntion/StartActivityManager.kt new file mode 100644 index 0000000..f1812ae --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/superfuntion/StartActivityManager.kt @@ -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? = 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) +} diff --git a/app/src/main/java/com/sw/healthexpertclient/superfuntion/StringExt.kt b/app/src/main/java/com/sw/healthexpertclient/superfuntion/StringExt.kt new file mode 100644 index 0000000..380e4b9 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/superfuntion/StringExt.kt @@ -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 String?.jsonToBean(clazz: Class): T { + return GsonBuilder().create().fromJson(this, clazz) +} diff --git a/app/src/main/java/com/sw/healthexpertclient/superfuntion/ViewExt.kt b/app/src/main/java/com/sw/healthexpertclient/superfuntion/ViewExt.kt new file mode 100644 index 0000000..b68bee3 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/superfuntion/ViewExt.kt @@ -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 = 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 = 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{ + override fun onLoadFailed( + p0: GlideException?, + p1: Any?, + p2: Target?, + p3: Boolean, + ): Boolean { + method(false) + return false + } + + override fun onResourceReady( + p0: Drawable?, + p1: Any?, + p2: Target?, + 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 = 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 = 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(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, + 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, + 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, + 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, + 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, 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(R.id.tv_text) + tabText.text = tabTitle + tab.customView = tabView +} + + +fun TabLayout.attachViewPager( + viewpager: ViewPager2, + tabs: Array, + 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(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){ + this.removeAllTabs() + tabs.forEach { + this.addTab(this.newTab().setText(it.tabTitle)) + } +} + + + diff --git a/app/src/main/java/com/sw/healthexpertclient/ui/login/LoginActivity.kt b/app/src/main/java/com/sw/healthexpertclient/ui/login/LoginActivity.kt new file mode 100644 index 0000000..d7a168f --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/ui/login/LoginActivity.kt @@ -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(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 + } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/ui/login/viewmodel/LoginViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/ui/login/viewmodel/LoginViewModel.kt new file mode 100644 index 0000000..e99d1e1 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/ui/login/viewmodel/LoginViewModel.kt @@ -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?>(null) + + /** 登录成功结果 */ + var loginResult = MutableStateFlow(null) + + /** + * 用户登录 + * + * @param loginBean 登录参数 + */ + fun login(loginBean: LoginBean) { + launch({ + handleRequest( + UserInfoRepository.login(loginBean), + successBlock = { + it.data?.let { result -> loginResult.emit(result) } + }) + }) + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/ui/login/viewmodel/PhoneViewModel.kt b/app/src/main/java/com/sw/healthexpertclient/ui/login/viewmodel/PhoneViewModel.kt new file mode 100644 index 0000000..24dba60 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/ui/login/viewmodel/PhoneViewModel.kt @@ -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(null) + + /** 新增手机号结果 */ + var addPhoneResult = MutableStateFlow(null) + + /** 验证原手机号验证码结果 */ + var verifyPhoneCodeResult = MutableStateFlow(null) + + /** 身份证核验结果 */ + var verifyIdCardResult = MutableStateFlow(null) + + /** 手机号列表 */ + var phoneList = MutableStateFlow>(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) }) + }) + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/utils/ApiDns.java b/app/src/main/java/com/sw/healthexpertclient/utils/ApiDns.java new file mode 100644 index 0000000..efaf9f2 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/utils/ApiDns.java @@ -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 lookup(String hostname) throws UnknownHostException { + if (hostname == null) { + throw new UnknownHostException("hostname == null"); + } else { + try { + List 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; + } + } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/utils/BrandUtil.java b/app/src/main/java/com/sw/healthexpertclient/utils/BrandUtil.java new file mode 100644 index 0000000..fdf3476 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/utils/BrandUtil.java @@ -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; + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/utils/ConstantUtils.kt b/app/src/main/java/com/sw/healthexpertclient/utils/ConstantUtils.kt new file mode 100644 index 0000000..9fb1915 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/utils/ConstantUtils.kt @@ -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") + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/utils/CustomActivityManager.kt b/app/src/main/java/com/sw/healthexpertclient/utils/CustomActivityManager.kt new file mode 100644 index 0000000..f777e0a --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/utils/CustomActivityManager.kt @@ -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() + + 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() + } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/utils/StatusbarUtil.kt b/app/src/main/java/com/sw/healthexpertclient/utils/StatusbarUtil.kt new file mode 100644 index 0000000..8b1a6af --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/utils/StatusbarUtil.kt @@ -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 + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/utils/updateplugin/CheckUpdateAppVersion.kt b/app/src/main/java/com/sw/healthexpertclient/utils/updateplugin/CheckUpdateAppVersion.kt new file mode 100644 index 0000000..9e8b96f --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/utils/updateplugin/CheckUpdateAppVersion.kt @@ -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: 接入实际的版本检查接口 + } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/utils/updateplugin/CustomDownloadNotifier.java b/app/src/main/java/com/sw/healthexpertclient/utils/updateplugin/CustomDownloadNotifier.java new file mode 100644 index 0000000..c09a3d2 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/utils/updateplugin/CustomDownloadNotifier.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/utils/updateplugin/CustomInstallNotifier.java b/app/src/main/java/com/sw/healthexpertclient/utils/updateplugin/CustomInstallNotifier.java new file mode 100644 index 0000000..c00c31d --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/utils/updateplugin/CustomInstallNotifier.java @@ -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; + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/view/CustomPasswordView.kt b/app/src/main/java/com/sw/healthexpertclient/view/CustomPasswordView.kt new file mode 100644 index 0000000..195ebbb --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/view/CustomPasswordView.kt @@ -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 + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/view/CustomUpdateNotifier.java b/app/src/main/java/com/sw/healthexpertclient/view/CustomUpdateNotifier.java new file mode 100644 index 0000000..37d2e61 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/view/CustomUpdateNotifier.java @@ -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; + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/view/LoadingDialog.kt b/app/src/main/java/com/sw/healthexpertclient/view/LoadingDialog.kt new file mode 100644 index 0000000..3b2b42e --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/view/LoadingDialog.kt @@ -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) + } +} diff --git a/app/src/main/java/com/sw/healthexpertclient/view/dialog/TextViewDialog.kt b/app/src/main/java/com/sw/healthexpertclient/view/dialog/TextViewDialog.kt new file mode 100644 index 0000000..9729248 --- /dev/null +++ b/app/src/main/java/com/sw/healthexpertclient/view/dialog/TextViewDialog.kt @@ -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 + } +} diff --git a/app/src/main/res/color/color_tab_layout_text_theme.xml b/app/src/main/res/color/color_tab_layout_text_theme.xml new file mode 100644 index 0000000..3e83d85 --- /dev/null +++ b/app/src/main/res/color/color_tab_layout_text_theme.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xxhdpi/ic_login_bg.png b/app/src/main/res/drawable-xxhdpi/ic_login_bg.png new file mode 100644 index 0000000..3df94e8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_login_bg.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_login_name.png b/app/src/main/res/drawable-xxhdpi/ic_login_name.png new file mode 100644 index 0000000..33cf585 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_login_name.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_login_no_select.png b/app/src/main/res/drawable-xxhdpi/ic_login_no_select.png new file mode 100644 index 0000000..6a821f4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_login_no_select.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_masculino.png b/app/src/main/res/drawable-xxhdpi/ic_masculino.png new file mode 100644 index 0000000..68bb06f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_masculino.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_mulher.png b/app/src/main/res/drawable-xxhdpi/ic_mulher.png new file mode 100644 index 0000000..a316e7c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_mulher.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_selected.webp b/app/src/main/res/drawable-xxhdpi/ic_selected.webp new file mode 100644 index 0000000..b5c7e70 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_selected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_unselected.webp b/app/src/main/res/drawable-xxhdpi/ic_unselected.webp new file mode 100644 index 0000000..1e02441 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_unselected.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/icon_back_black.webp b/app/src/main/res/drawable-xxhdpi/icon_back_black.webp new file mode 100644 index 0000000..2610356 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/icon_back_black.webp differ diff --git a/app/src/main/res/drawable-xxhdpi/icon_back_white.png b/app/src/main/res/drawable-xxhdpi/icon_back_white.png new file mode 100644 index 0000000..d6f38b4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/icon_back_white.png differ diff --git a/app/src/main/res/drawable/bg_child_tab_layout_item_theme.xml b/app/src/main/res/drawable/bg_child_tab_layout_item_theme.xml new file mode 100644 index 0000000..91e6db2 --- /dev/null +++ b/app/src/main/res/drawable/bg_child_tab_layout_item_theme.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_grey_background_shap_radius23.xml b/app/src/main/res/drawable/bg_grey_background_shap_radius23.xml new file mode 100644 index 0000000..5b5e63e --- /dev/null +++ b/app/src/main/res/drawable/bg_grey_background_shap_radius23.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_grey_background_shap_radius25.xml b/app/src/main/res/drawable/bg_grey_background_shap_radius25.xml new file mode 100644 index 0000000..a3e2459 --- /dev/null +++ b/app/src/main/res/drawable/bg_grey_background_shap_radius25.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_login_green_to_white.xml b/app/src/main/res/drawable/bg_login_green_to_white.xml new file mode 100644 index 0000000..e4661df --- /dev/null +++ b/app/src/main/res/drawable/bg_login_green_to_white.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_white_bottom_right_angle_background_shap20.xml b/app/src/main/res/drawable/bg_white_bottom_right_angle_background_shap20.xml new file mode 100644 index 0000000..4f8a1e3 --- /dev/null +++ b/app/src/main/res/drawable/bg_white_bottom_right_angle_background_shap20.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/check_theme_style.xml b/app/src/main/res/drawable/check_theme_style.xml new file mode 100644 index 0000000..c917cf2 --- /dev/null +++ b/app/src/main/res/drawable/check_theme_style.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..85ffc45 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..5b87b82 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/main_tab_aichat_normal.xml b/app/src/main/res/drawable/main_tab_aichat_normal.xml new file mode 100644 index 0000000..43be4fa --- /dev/null +++ b/app/src/main/res/drawable/main_tab_aichat_normal.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/main_tab_aichat_selected.xml b/app/src/main/res/drawable/main_tab_aichat_selected.xml new file mode 100644 index 0000000..1ec98ae --- /dev/null +++ b/app/src/main/res/drawable/main_tab_aichat_selected.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/main_tab_knowledge_normal.xml b/app/src/main/res/drawable/main_tab_knowledge_normal.xml new file mode 100644 index 0000000..43be4fa --- /dev/null +++ b/app/src/main/res/drawable/main_tab_knowledge_normal.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/main_tab_knowledge_selected.xml b/app/src/main/res/drawable/main_tab_knowledge_selected.xml new file mode 100644 index 0000000..1ec98ae --- /dev/null +++ b/app/src/main/res/drawable/main_tab_knowledge_selected.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/main_tab_message_normal.xml b/app/src/main/res/drawable/main_tab_message_normal.xml new file mode 100644 index 0000000..43be4fa --- /dev/null +++ b/app/src/main/res/drawable/main_tab_message_normal.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/main_tab_message_selected.xml b/app/src/main/res/drawable/main_tab_message_selected.xml new file mode 100644 index 0000000..1ec98ae --- /dev/null +++ b/app/src/main/res/drawable/main_tab_message_selected.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/main_tab_mine_normal.xml b/app/src/main/res/drawable/main_tab_mine_normal.xml new file mode 100644 index 0000000..43be4fa --- /dev/null +++ b/app/src/main/res/drawable/main_tab_mine_normal.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/main_tab_mine_selected.xml b/app/src/main/res/drawable/main_tab_mine_selected.xml new file mode 100644 index 0000000..1ec98ae --- /dev/null +++ b/app/src/main/res/drawable/main_tab_mine_selected.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/main_tab_workbench_normal.xml b/app/src/main/res/drawable/main_tab_workbench_normal.xml new file mode 100644 index 0000000..93fbfdd --- /dev/null +++ b/app/src/main/res/drawable/main_tab_workbench_normal.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/main_tab_workbench_selected.xml b/app/src/main/res/drawable/main_tab_workbench_selected.xml new file mode 100644 index 0000000..82a28d3 --- /dev/null +++ b/app/src/main/res/drawable/main_tab_workbench_selected.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/app/src/main/res/drawable/rectangle_round_corner10_white.xml b/app/src/main/res/drawable/rectangle_round_corner10_white.xml new file mode 100644 index 0000000..a9931d7 --- /dev/null +++ b/app/src/main/res/drawable/rectangle_round_corner10_white.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rectangle_round_corner12_theme.xml b/app/src/main/res/drawable/rectangle_round_corner12_theme.xml new file mode 100644 index 0000000..c09fce9 --- /dev/null +++ b/app/src/main/res/drawable/rectangle_round_corner12_theme.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_tab_point_bg.xml b/app/src/main/res/drawable/shape_tab_point_bg.xml new file mode 100644 index 0000000..45943f2 --- /dev/null +++ b/app/src/main/res/drawable/shape_tab_point_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/shape_tab_unread_bg.xml b/app/src/main/res/drawable/shape_tab_unread_bg.xml new file mode 100644 index 0000000..e27010f --- /dev/null +++ b/app/src/main/res/drawable/shape_tab_unread_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..44f926d --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..6432cd6 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/bottom_tab_item_layout.xml b/app/src/main/res/layout/bottom_tab_item_layout.xml new file mode 100644 index 0000000..c5f2f03 --- /dev/null +++ b/app/src/main/res/layout/bottom_tab_item_layout.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/custom_password_view.xml b/app/src/main/res/layout/custom_password_view.xml new file mode 100644 index 0000000..07be271 --- /dev/null +++ b/app/src/main/res/layout/custom_password_view.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_loading.xml b/app/src/main/res/layout/dialog_loading.xml new file mode 100644 index 0000000..a434ba9 --- /dev/null +++ b/app/src/main/res/layout/dialog_loading.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/layout/dialog_text_view.xml b/app/src/main/res/layout/dialog_text_view.xml new file mode 100644 index 0000000..89cc17d --- /dev/null +++ b/app/src/main/res/layout/dialog_text_view.xml @@ -0,0 +1,109 @@ + + + + + + + + + + +