diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b51db4c..a2f8e3f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,6 +57,8 @@ dependencies { implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.splashscreen) implementation(libs.google.material) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.viewmodel) diff --git a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt index 7247a1f..dbc262c 100644 --- a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt +++ b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt @@ -1,5 +1,6 @@ package com.stec.cmd +import com.stec.cmd.feature.home.ProjectWorkspaceFragment import com.stec.cmd.feature.mine.MineFragment /** @@ -30,7 +31,7 @@ object FeatureMount { /** 挂载注册表:S1+ 替换 fragmentClass 即完成业务页挂载。 */ val mounts: List = listOf( - MountItem(Tab.HOME, HomeFragment::class.java, "M2 项目工作台"), + MountItem(Tab.HOME, ProjectWorkspaceFragment::class.java, "M2 项目工作台"), MountItem(Tab.TASK, TaskFragment::class.java, "M3 任务管理"), MountItem(Tab.COLLECT, CollectFragment::class.java, "M4+M5 数据采集"), MountItem(Tab.STATS, StatsFragment::class.java, "M6 统计与上传"), diff --git a/app/src/main/kotlin/com/stec/cmd/HomeFragment.kt b/app/src/main/kotlin/com/stec/cmd/HomeFragment.kt deleted file mode 100644 index 03c4acc..0000000 --- a/app/src/main/kotlin/com/stec/cmd/HomeFragment.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.stec.cmd - -import dagger.hilt.android.AndroidEntryPoint - -/** 首页占位(M2 项目工作台,S1 挂载项目列表)。 */ -@AndroidEntryPoint -class HomeFragment : PlaceholderFragment() diff --git a/app/src/main/kotlin/com/stec/cmd/feature/home/HomeViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/home/HomeViewModel.kt new file mode 100644 index 0000000..8325ef5 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/home/HomeViewModel.kt @@ -0,0 +1,97 @@ +package com.stec.cmd.feature.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.stec.cmd.config.ConfigRepository +import com.stec.cmd.core.common.UiState +import com.stec.cmd.core.network.ApiError +import com.stec.cmd.core.network.api.ProjectItem +import com.stec.cmd.feature.mine.userMessage +import com.stec.cmd.session.ProjectRepository +import com.stec.cmd.session.ProjectSession +import com.stec.cmd.session.SessionRepository +import com.stec.cmd.session.SessionState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 首页项目工作台(M2-01/02/03): + * 会话 / 配置 / 项目列表 / 选中项目四流组合渲染。 + * + * 自动加载门控:SignedIn 且三要素就绪且列表尚为 [UiState.Idle] 时发起加载; + * 会话离开 SignedIn 复位为 Idle(登出后重新登录可重新加载)。 + */ +@HiltViewModel +class HomeViewModel @Inject constructor( + private val projectRepository: ProjectRepository, + private val projectSession: ProjectSession, + sessionRepository: SessionRepository, + private val configRepository: ConfigRepository, +) : ViewModel() { + + /** 会话状态流(Restoring/SignedOut/SignedIn 门控首页状态机)。 */ + val sessionState = sessionRepository.state + + /** 连接配置快照流(三要素缺失时警示引导)。 */ + val configSnapshot = configRepository.snapshotStateFlow + + /** 当前选中项目(M2-03,M3/M5/M6 共享订阅)。 */ + val selectedProject = projectSession.selectedProject + + private val _projects = MutableStateFlow>>(UiState.Idle) + + /** 项目列表加载态。 */ + val projects: StateFlow>> = _projects.asStateFlow() + + init { + viewModelScope.launch { + sessionState.collect { state -> + when (state) { + is SessionState.SignedIn -> + if (configRepository.snapshotStateFlow.value.isReady && + _projects.value is UiState.Idle + ) { + load() + } + else -> _projects.value = UiState.Idle + } + } + } + viewModelScope.launch { + configRepository.snapshotStateFlow.collect { snapshot -> + if (snapshot.isReady && + sessionState.value is SessionState.SignedIn && + _projects.value is UiState.Idle + ) { + load() + } + } + } + } + + /** 拉取项目列表;成功且尚无选中时自动选中首项(给 M3/M6 可用默认值)。 */ + fun load() { + viewModelScope.launch { + _projects.value = UiState.Loading + _projects.value = try { + val list = projectRepository.projects() + if (selectedProject.value == null) { + list.firstOrNull()?.let(projectSession::select) + } + UiState.Success(list) + } catch (e: ApiError) { + UiState.Error(e.userMessage()) + } + } + } + + /** 下拉刷新 / 错误重试。 */ + fun refresh() = load() + + /** M2-03 项目切换:写入共享选中态,订阅方(M3/M6)随之刷新。 */ + fun select(project: ProjectItem) = projectSession.select(project) +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/home/ProjectSwitcherSheet.kt b/app/src/main/kotlin/com/stec/cmd/feature/home/ProjectSwitcherSheet.kt new file mode 100644 index 0000000..f0f937d --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/home/ProjectSwitcherSheet.kt @@ -0,0 +1,120 @@ +package com.stec.cmd.feature.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.stec.cmd.R +import com.stec.cmd.core.network.api.ProjectItem +import com.stec.cmd.databinding.ItemProjectSheetBinding +import com.stec.cmd.databinding.SheetProjectSwitcherBinding +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json + +/** + * 项目切换单选底部弹层(M2-03,消费级单选范式)。 + * + * 项目列表经 JSON 字符串参数传入(ProjectItem 非 Parcelable,避免依赖序列化之外的传递机制); + * 点选结果经父 Fragment 实现 [OnProjectPicked] 回调后写 [com.stec.cmd.session.ProjectSession]。 + */ +class ProjectSwitcherSheet : BottomSheetDialogFragment() { + + /** 切换结果回调,由宿主 Fragment 实现。 */ + interface OnProjectPicked { + fun onProjectPicked(project: ProjectItem) + } + + private var _binding: SheetProjectSwitcherBinding? = null + private val binding get() = checkNotNull(_binding) + + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = SheetProjectSwitcherBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.recyclerProjects.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerProjects.adapter = ProjectAdapter( + items = decodeProjects(), + selectedId = arguments?.getString(ARG_SELECTED_ID), + onPicked = { picked -> + (parentFragment as? OnProjectPicked)?.onProjectPicked(picked) + dismissAllowingStateLoss() + }, + ) + } + + /** 参数 JSON → 项目列表;解析失败退化为空列表(弹层仅展示标题)。 */ + private fun decodeProjects(): List = + arguments?.getString(ARG_PROJECTS_JSON)?.let { raw -> + runCatching { + json.decodeFromString(ListSerializer(ProjectItem.serializer()), raw) + }.getOrDefault(emptyList()) + }.orEmpty() + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + private const val ARG_PROJECTS_JSON = "projects_json" + private const val ARG_SELECTED_ID = "selected_id" + + fun newInstance(projectsJson: String, selectedId: String?): ProjectSwitcherSheet = + ProjectSwitcherSheet().apply { + arguments = Bundle().apply { + putString(ARG_PROJECTS_JSON, projectsJson) + putString(ARG_SELECTED_ID, selectedId) + } + } + } +} + +/** 单选列表适配器:当前选中项打勾,行整体可点。 */ +private class ProjectAdapter( + private val items: List, + private val selectedId: String?, + private val onPicked: (ProjectItem) -> Unit, +) : RecyclerView.Adapter() { + + class ViewHolder(val binding: ItemProjectSheetBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = + ViewHolder( + ItemProjectSheetBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = items[position] + with(holder.binding) { + tvName.text = item.SimpleName?.takeIf { it.isNotBlank() } + ?: root.context.getString(R.string.home_unnamed_project) + tvMeta.text = listOfNotNull( + item.ProjectCode?.takeIf { it.isNotBlank() }, + item.ProjectScale, + item.ProjectPhase, + ).joinToString(SEPARATOR) + radioSelected.isChecked = !item.ID.isNullOrBlank() && item.ID == selectedId + root.setOnClickListener { onPicked(item) } + } + } + + private companion object { + const val SEPARATOR = " · " + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/home/ProjectWorkspaceFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/home/ProjectWorkspaceFragment.kt new file mode 100644 index 0000000..e262dc2 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/home/ProjectWorkspaceFragment.kt @@ -0,0 +1,167 @@ +package com.stec.cmd.feature.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.stec.cmd.R +import com.stec.cmd.core.common.UiState +import com.stec.cmd.core.network.api.ProjectItem +import com.stec.cmd.databinding.FragmentHomeBinding +import com.stec.cmd.feature.mine.ConfigFragment +import com.stec.cmd.feature.mine.LoginFragment +import com.stec.cmd.session.SessionState +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json + +/** + * 首页项目工作台(M2-01 当前用户项目列表 / M2-02 项目信息展示 / M2-03 项目切换)。 + * + * 渲染状态机:Restoring → 中性加载;SignedOut → 登录 CTA; + * SignedIn → 配置门控(缺失警示卡)→ 项目列表加载/空/错误/内容四态。 + * 子页打开沿用「add + hide + addToBackStack」壳层模式(底栏随返回栈自动隐藏)。 + */ +@AndroidEntryPoint +class ProjectWorkspaceFragment : Fragment(), ProjectSwitcherSheet.OnProjectPicked { + + private var _binding: FragmentHomeBinding? = null + private val binding get() = checkNotNull(_binding) + + private val viewModel: HomeViewModel by viewModels() + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _binding = FragmentHomeBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.swipeRefresh.setColorSchemeResources(R.color.brand_primary) + binding.swipeRefresh.setOnRefreshListener { viewModel.refresh() } + binding.btnGoLogin.setOnClickListener { openOnTop(LoginFragment(), TAG_LOGIN) } + binding.cardConfigWarning.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) } + binding.btnSwitchProject.setOnClickListener { showSwitcher() } + binding.btnRetry.setOnClickListener { viewModel.refresh() } + binding.btnEmptyRefresh.setOnClickListener { viewModel.refresh() } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.sessionState.collect { render() } } + launch { viewModel.configSnapshot.collect { render() } } + launch { viewModel.projects.collect { render() } } + launch { viewModel.selectedProject.collect { render() } } + } + } + } + + /** 四流任一变化即整体重渲(读 ViewModel 当前值,避免组合流样板)。 */ + private fun render() { + val session = viewModel.sessionState.value + val config = viewModel.configSnapshot.value + val projects = viewModel.projects.value + val selected = viewModel.selectedProject.value + + binding.swipeRefresh.isRefreshing = + session is SessionState.SignedIn && config.isReady && projects is UiState.Loading + // 下拉刷新仅登录且配置就绪时可用,防止覆盖层下穿透触发无效请求 + binding.swipeRefresh.isVisible = session is SessionState.SignedIn && config.isReady + binding.progress.isVisible = false + binding.stateSignedOut.isVisible = false + binding.stateError.isVisible = false + binding.stateEmpty.isVisible = false + + when (session) { + // 冷启动恢复中:中性加载,不引导登录 + SessionState.Restoring -> binding.progress.isVisible = true + SessionState.SignedOut -> binding.stateSignedOut.isVisible = true + is SessionState.SignedIn -> { + binding.cardConfigWarning.isVisible = !config.isReady + if (!config.isReady) return + + val contentReady = projects is UiState.Success && projects.data.isNotEmpty() + binding.tvSectionCurrent.isVisible = contentReady + binding.cardProject.isVisible = contentReady && selected != null + binding.tvProjectHint.isVisible = contentReady && selected == null + binding.btnSwitchProject.isVisible = contentReady + binding.tvProjectCount.isVisible = contentReady + + when (projects) { + is UiState.Loading -> binding.progress.isVisible = true + is UiState.Error -> { + binding.stateError.isVisible = true + binding.tvErrorMessage.text = projects.message + } + is UiState.Success -> { + if (projects.data.isEmpty()) { + binding.stateEmpty.isVisible = true + } else { + selected?.let(::renderProject) + binding.tvProjectCount.text = + getString(R.string.home_project_count_fmt, projects.data.size) + } + } + UiState.Idle -> Unit // 会话/配置流将触发自动加载 + } + } + } + } + + /** M2-02 当前项目卡:名称大字 + 编号 + 等级/阶段 Chip。 */ + private fun renderProject(project: ProjectItem) { + binding.tvProjectName.text = project.SimpleName?.takeIf { it.isNotBlank() } + ?: getString(R.string.home_unnamed_project) + binding.tvProjectCode.text = project.ProjectCode?.takeIf { it.isNotBlank() } ?: "-" + binding.chipScale.text = project.ProjectScale ?: "-" + binding.chipPhase.text = project.ProjectPhase ?: "-" + } + + /** M2-03 打开切换弹层;列表经 JSON 传参(ProjectItem 非 Parcelable)。 */ + private fun showSwitcher() { + val projects = (viewModel.projects.value as? UiState.Success)?.data.orEmpty() + if (projects.isEmpty()) return + val projectsJson = json.encodeToString(ListSerializer(ProjectItem.serializer()), projects) + ProjectSwitcherSheet.newInstance(projectsJson, viewModel.selectedProject.value?.ID) + .show(childFragmentManager, TAG_SWITCHER) + } + + override fun onProjectPicked(project: ProjectItem) { + viewModel.select(project) + } + + private fun openOnTop(fragment: Fragment, tag: String) { + parentFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.fragment_container, fragment, tag) + hide(this@ProjectWorkspaceFragment) + addToBackStack(null) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private companion object { + const val TAG_LOGIN = "login" + const val TAG_CONFIG = "config" + const val TAG_SWITCHER = "project_switcher" + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordFragment.kt index f181f4a..846d9f1 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordFragment.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordFragment.kt @@ -36,7 +36,7 @@ class ChangePasswordFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() } + binding.btnBack.setNavigationOnClickListener { parentFragmentManager.popBackStack() } binding.btnSubmit.setOnClickListener { viewModel.submit( oldPassword = binding.etOldPassword.text?.toString().orEmpty(), @@ -54,7 +54,8 @@ class ChangePasswordFragment : Fragment() { is UiState.Error -> binding.tvMessage.text = state.message is UiState.Success -> { binding.tvMessage.text = getString(R.string.change_password_success) - binding.btnBack.callOnClick() + // Toolbar 导航点击不经 performClick,直接回退 + parentFragmentManager.popBackStack() } else -> Unit } diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigFragment.kt index c8fee9f..72bc3d5 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigFragment.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigFragment.kt @@ -36,7 +36,7 @@ class ConfigFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() } + binding.btnBack.setNavigationOnClickListener { parentFragmentManager.popBackStack() } binding.btnSave.setOnClickListener { viewModel.save( baseUrl = binding.etBaseUrl.text?.toString().orEmpty(), diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt index 8c8e399..c28889a 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt @@ -17,7 +17,10 @@ import com.stec.cmd.databinding.FragmentLoginBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -/** 登录页(M1-01 账号密码 / M1-02 获取验证码 / M1-03 验证码登录)。 */ +/** + * 登录页(M1-01 账号密码 / M1-02 获取验证码 / M1-03 验证码登录)。 + * 消费级动线:Toolbar 返回 + 分段切换登录方式 + 浮动标签输入 + 按钮下线性进度。 + */ @AndroidEntryPoint class LoginFragment : Fragment() { @@ -36,11 +39,16 @@ class LoginFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() } + binding.toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } binding.btnSendCode.setOnClickListener { viewModel.sendCode() } binding.btnLogin.setOnClickListener { viewModel.login() } - binding.rbAccount.setOnClickListener { viewModel.updateMode(LoginMode.ACCOUNT) } - binding.rbSms.setOnClickListener { viewModel.updateMode(LoginMode.SMS) } + binding.toggleMode.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + viewModel.updateMode( + if (checkedId == R.id.btn_mode_account) LoginMode.ACCOUNT else LoginMode.SMS, + ) + } + } binding.etLoginName.doAfterTextChanged { viewModel.updateLoginName(it?.toString().orEmpty()) } binding.etPassword.doAfterTextChanged { viewModel.updatePassword(it?.toString().orEmpty()) } @@ -55,14 +63,14 @@ class LoginFragment : Fragment() { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.form.collect { form -> - val accountMode = form.mode == LoginMode.ACCOUNT - if (binding.rbAccount.isChecked != accountMode) { - binding.radioGroup.check( - if (accountMode) binding.rbAccount.id else binding.rbSms.id, - ) + val targetButton = + if (form.mode == LoginMode.ACCOUNT) R.id.btn_mode_account + else R.id.btn_mode_sms + if (binding.toggleMode.checkedButtonId != targetButton) { + binding.toggleMode.check(targetButton) } - binding.groupAccount.isVisible = accountMode - binding.groupSms.isVisible = !accountMode + binding.groupAccount.isVisible = form.mode == LoginMode.ACCOUNT + binding.groupSms.isVisible = form.mode == LoginMode.SMS } } launch { @@ -85,7 +93,7 @@ class LoginFragment : Fragment() { ) } is UiState.Success -> { - // SessionRepository 已持久化 Token;返回「我的」 + // SessionRepository 已持久化 Token;返回「我的」/首页 parentFragmentManager.popBackStack() } else -> Unit diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt index e644888..de3fb73 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.stec.cmd.R +import com.stec.cmd.config.ConfigRepository import com.stec.cmd.databinding.FragmentMineBinding import com.stec.cmd.session.SessionState import dagger.hilt.android.AndroidEntryPoint @@ -19,7 +20,8 @@ import kotlinx.coroutines.launch /** * 「我的」业务页(M1-05 用户信息 / M1-06 配置入口 / M1-04 改密入口 / M1-07 退出登录)。 - * 未配置三要素 → 引导配置页;未登录 → 引导登录页。 + * 消费级动线:横向身份卡(未登录整卡即登录入口)+ 连接配置警示卡 + 设置列表分组卡; + * 冷启动 Restoring 渲染中性加载,不误导去登录。 */ @AndroidEntryPoint class MineFragment : Fragment() { @@ -39,12 +41,14 @@ class MineFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() } - binding.btnLoginOrLogout.setOnClickListener { onPrimaryAction() } - binding.btnChangePassword.setOnClickListener { openOnTop(ChangePasswordFragment(), TAG_CHANGE_PASSWORD) } - binding.btnOpenConfig.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) } - binding.btnGoConfig.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) } - binding.btnRefresh.setOnClickListener { viewModel.refresh() } + binding.cardUser.setOnClickListener { onUserCardClick() } + binding.btnLoginCta.setOnClickListener { openOnTop(LoginFragment(), TAG_LOGIN) } + binding.rowChangePassword.setOnClickListener { + openOnTop(ChangePasswordFragment(), TAG_CHANGE_PASSWORD) + } + binding.rowOpenConfig.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) } + binding.cardConfigWarning.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) } + binding.btnLogout.setOnClickListener { viewModel.logout() } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -56,38 +60,55 @@ class MineFragment : Fragment() { private fun renderSession(state: SessionState) { when (state) { - is SessionState.SignedIn -> { - binding.cardUser.isVisible = true - binding.tvName.text = state.user.Name ?: getString(R.string.mine_unknown_user) - binding.tvDept.text = getString(R.string.mine_dept_fmt, state.user.DeptName ?: "-") - binding.tvAccount.text = getString(R.string.mine_account_fmt, state.user.LoginName ?: "-") - binding.tvPhone.text = getString(R.string.mine_phone_fmt, state.user.MobilePhone ?: "-") - binding.btnLoginOrLogout.setText(R.string.mine_logout) - binding.btnChangePassword.isEnabled = true + is SessionState.Restoring -> { + binding.tvAvatar.text = "" + binding.tvName.setText(R.string.mine_restoring) + binding.tvDept.text = "" + binding.tvAccount.text = "" + binding.tvPhone.text = "" + binding.btnLoginCta.isVisible = false + binding.btnLogout.isVisible = false + binding.rowChangePassword.isEnabled = false + binding.cardUser.isClickable = false } is SessionState.SignedOut -> { - binding.cardUser.isVisible = true + binding.tvAvatar.setText(R.string.mine_avatar_guest) binding.tvName.setText(R.string.mine_not_logged_in) - binding.tvDept.text = getString(R.string.mine_dept_fmt, "-") - binding.tvAccount.text = getString(R.string.mine_account_fmt, "-") - binding.tvPhone.text = getString(R.string.mine_phone_fmt, "-") - binding.btnLoginOrLogout.setText(R.string.mine_login) - binding.btnChangePassword.isEnabled = false + binding.tvDept.setText(R.string.mine_not_logged_hint) + binding.tvAccount.text = "" + binding.tvPhone.text = "" + binding.btnLoginCta.isVisible = true + binding.btnLogout.isVisible = false + binding.rowChangePassword.isEnabled = false + binding.cardUser.isClickable = true + } + is SessionState.SignedIn -> { + binding.tvAvatar.text = + state.user.Name?.trim()?.firstOrNull()?.toString() ?: "?" + binding.tvName.text = state.user.Name ?: getString(R.string.mine_unknown_user) + binding.tvDept.text = getString(R.string.mine_dept_fmt, state.user.DeptName ?: "-") + binding.tvAccount.text = + getString(R.string.mine_account_fmt, state.user.LoginName ?: "-") + binding.tvPhone.text = + getString(R.string.mine_phone_fmt, state.user.MobilePhone ?: "-") + binding.btnLoginCta.isVisible = false + binding.btnLogout.isVisible = true + binding.rowChangePassword.isEnabled = true + binding.cardUser.isClickable = false } } } - private fun renderConfig(snapshot: com.stec.cmd.config.ConfigRepository.Snapshot) { - binding.tvConfigStatus.setText( - if (snapshot.isReady) R.string.mine_config_ready else R.string.mine_config_missing, - ) - binding.btnGoConfig.isVisible = !snapshot.isReady + private fun renderConfig(snapshot: ConfigRepository.Snapshot) { + binding.cardConfigWarning.isVisible = !snapshot.isReady + binding.tvConfigSubtitle.text = snapshot.baseUrl + .ifBlank { getString(R.string.mine_row_config_unset) } } - private fun onPrimaryAction() { - when (viewModel.sessionState.value) { - is SessionState.SignedIn -> viewModel.logout() - is SessionState.SignedOut -> openOnTop(LoginFragment(), TAG_LOGIN) + /** 身份卡复用为登录入口(仅未登录态可点)。 */ + private fun onUserCardClick() { + if (viewModel.sessionState.value is SessionState.SignedOut) { + openOnTop(LoginFragment(), TAG_LOGIN) } } diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/MineViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/MineViewModel.kt index a3a5162..389c388 100644 --- a/app/src/main/kotlin/com/stec/cmd/feature/mine/MineViewModel.kt +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/MineViewModel.kt @@ -10,6 +10,7 @@ import javax.inject.Inject /** * 「我的」页(M1-05/06/07):登录态 + 连接配置状态双流渲染。 + * 会话恢复已由 SessionRepository 冷启动自动化,无手动刷新入口。 */ @HiltViewModel class MineViewModel @Inject constructor( @@ -20,14 +21,9 @@ class MineViewModel @Inject constructor( /** 会话状态流。 */ val sessionState = sessionRepository.state - /** 连接配置快照流(未配置时引导配置页)。 */ + /** 连接配置快照流(未配置时警示引导配置页)。 */ val configSnapshot = configRepository.snapshotStateFlow - /** 手动刷新:按存量 Token 重新拉取用户信息。 */ - fun refresh() { - viewModelScope.launch { sessionRepository.restore() } - } - /** M1-07 退出登录。 */ fun logout() { viewModelScope.launch { sessionRepository.logout() } diff --git a/app/src/main/kotlin/com/stec/cmd/session/ProjectRepository.kt b/app/src/main/kotlin/com/stec/cmd/session/ProjectRepository.kt new file mode 100644 index 0000000..caaf94b --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/session/ProjectRepository.kt @@ -0,0 +1,64 @@ +package com.stec.cmd.session + +import com.stec.cmd.core.network.ApiCaller +import com.stec.cmd.core.network.ApiError +import com.stec.cmd.core.network.api.ProjectApi +import com.stec.cmd.core.network.api.ProjectItem +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 项目仓储(M2-01,2.5.1.1):拉取当前用户有权限的「进行中」项目列表。 + * + * 契约宽容:data 形态以 [projectListFromData] 归一提取—— + * 兼容文档口径的 `{"ProjectList":[…]}` 对象、平铺数组、以及二次 JSON 编码字符串 + * (与 SessionRepository 对登录/用户信息数据的宽容模式一致,任务 ca3394be)。 + */ +@Singleton +class ProjectRepository @Inject constructor( + private val projectApi: ProjectApi, + private val apiCaller: ApiCaller, + private val json: Json, +) { + + /** + * 当前用户项目列表;[projectId] 传值时仅返回该项目的单条列表数据(2.5.1.1 接口1)。 + * 任何失败路径以 [ApiError] 子类抛出。 + */ + suspend fun projects(projectId: String? = null): List = + projectListFromData(apiCaller.call { projectApi.getProjectList(projectId) }) + + /** + * data → 项目列表归一提取: + * - JsonObject:优先取 `ProjectList` 键(文档口径),无该键则按整对象直接解码; + * - JsonArray:文档口径的平铺数组; + * - JsonPrimitive:二次 JSON 编码的列表字符串。 + * 结构无法识别或字段不符时以 [ApiError.EmptyBodyError] 抛出。 + */ + private fun projectListFromData(data: JsonElement): List { + val payload = (data as? JsonObject)?.get(KEY_PROJECT_LIST) ?: data + val serializer = ListSerializer(ProjectItem.serializer()) + return try { + when (payload) { + is JsonArray -> json.decodeFromJsonElement(serializer, payload) + is JsonPrimitive -> json.decodeFromString(serializer, payload.content) + else -> throw ApiError.EmptyBodyError("项目列表数据结构异常") + } + } catch (e: SerializationException) { + throw ApiError.EmptyBodyError("项目列表解析失败:${e.message}") + } + } + + private companion object { + /** 文档 2.5.1.1:data 内列表键名(确切大小写)。 */ + const val KEY_PROJECT_LIST = "ProjectList" + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/session/ProjectSession.kt b/app/src/main/kotlin/com/stec/cmd/session/ProjectSession.kt new file mode 100644 index 0000000..5e4146e --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/session/ProjectSession.kt @@ -0,0 +1,51 @@ +package com.stec.cmd.session + +import com.stec.cmd.core.network.api.ProjectItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 项目选中态会话(M2-03):跨页面共享的「当前项目」单例流。 + * + * - 首页(M2)写入选中态;M3 任务 / M5 采集 / M6 统计直接订阅 + * [selectedProject] 即获得「切换后任务/统计跟随刷新」; + * - 构造器订阅 [SessionRepository.state],登出(SignedOut)自动清空, + * M1 登出代码零改动;Restoring/SignedIn 不动选中态。 + */ +@Singleton +class ProjectSession @Inject constructor( + sessionRepository: SessionRepository, +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _selectedProject = MutableStateFlow(null) + + /** 当前选中项目;未选择或已登出时为 null。 */ + val selectedProject: StateFlow = _selectedProject.asStateFlow() + + init { + scope.launch { + sessionRepository.state.collect { state -> + if (state is SessionState.SignedOut) clear() + } + } + } + + /** 选中项目(M2-03 项目切换)。 */ + fun select(project: ProjectItem) { + _selectedProject.value = project + } + + /** 清空选中态(登出联动,勿在业务页直接调用)。 */ + fun clear() { + _selectedProject.value = null + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt b/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt index 1b8b9c9..08e0d95 100644 --- a/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt +++ b/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt @@ -26,6 +26,9 @@ import javax.inject.Singleton /** 会话状态:UI 只读。 */ sealed class SessionState { + /** 冷启动恢复中(restore 未落定),UI 显示中性加载,勿引导登录。 */ + data object Restoring : SessionState() + /** 未登录(含 Token 失效被清)。 */ data object SignedOut : SessionState() @@ -53,7 +56,7 @@ class SessionRepository @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val _state = MutableStateFlow(SessionState.SignedOut) + private val _state = MutableStateFlow(SessionState.Restoring) /** 登录态流:Mine 等页面订阅渲染。 */ val state: StateFlow = _state.asStateFlow() diff --git a/app/src/main/res/drawable/bg_avatar_circle.xml b/app/src/main/res/drawable/bg_avatar_circle.xml new file mode 100644 index 0000000..32ed3f5 --- /dev/null +++ b/app/src/main/res/drawable/bg_avatar_circle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..4ae7e79 --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..61e80b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_change_password.xml b/app/src/main/res/layout/fragment_change_password.xml index 116b695..1c05995 100644 --- a/app/src/main/res/layout/fragment_change_password.xml +++ b/app/src/main/res/layout/fragment_change_password.xml @@ -1,85 +1,101 @@ - + android:orientation="vertical"> - + android:layout_height="?attr/actionBarSize" + app:navigationIcon="@drawable/ic_back" + app:navigationIconTint="?attr/colorOnSurface" + app:title="@string/change_password_title" + app:titleTextColor="?attr/colorOnSurface" /> - + - - - + android:orientation="vertical" + android:padding="24dp"> - + - + + - + - + + - - - + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_config.xml b/app/src/main/res/layout/fragment_config.xml index d339175..2b0a686 100644 --- a/app/src/main/res/layout/fragment_config.xml +++ b/app/src/main/res/layout/fragment_config.xml @@ -1,93 +1,109 @@ - + android:orientation="vertical"> - + android:layout_height="?attr/actionBarSize" + app:navigationIcon="@drawable/ic_back" + app:navigationIconTint="?attr/colorOnSurface" + app:title="@string/config_title" + app:titleTextColor="?attr/colorOnSurface" /> - + - - - + android:orientation="vertical" + android:padding="24dp"> - + - + + - + - + + - + - - - + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml new file mode 100644 index 0000000..fc8f8e7 --- /dev/null +++ b/app/src/main/res/layout/fragment_home.xml @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml index f00bdc2..8ac2ba5 100644 --- a/app/src/main/res/layout/fragment_login.xml +++ b/app/src/main/res/layout/fragment_login.xml @@ -1,160 +1,197 @@ - - + + android:orientation="vertical"> - + android:layout_height="?attr/actionBarSize" + app:navigationIcon="@drawable/ic_back" + app:navigationIconTint="?attr/colorOnSurface" + app:title="@string/login_title" + app:titleTextColor="?attr/colorOnSurface" /> - + - - - - - - - - - - - - - - - - - - - - + android:padding="24dp"> - + + + + + + + android:layout_marginTop="24dp" + app:selectionRequired="true" + app:singleSelection="true"> - - - + android:text="@string/login_mode_account" /> + android:layout_weight="1" + android:text="@string/login_mode_sms" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + diff --git a/app/src/main/res/layout/fragment_mine.xml b/app/src/main/res/layout/fragment_mine.xml index 7c22a49..b84a69b 100644 --- a/app/src/main/res/layout/fragment_mine.xml +++ b/app/src/main/res/layout/fragment_mine.xml @@ -1,5 +1,6 @@ - + - - - - + android:paddingBottom="24dp"> + + app:cardCornerRadius="16dp" + app:cardElevation="0dp"> + android:gravity="center_vertical" + android:orientation="horizontal" + android:padding="20dp"> - + android:layout_marginStart="16dp" + android:layout_weight="1" + android:orientation="vertical"> - + - + + + + + + - + + android:clickable="true" + android:focusable="true" + android:foreground="?attr/selectableItemBackground" + android:visibility="gone" + app:cardBackgroundColor="?attr/colorErrorContainer" + app:cardCornerRadius="12dp" + app:cardElevation="0dp"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + android:layout_marginHorizontal="20dp" + android:layout_marginTop="16dp" + android:text="@string/mine_logout" + android:textColor="?attr/colorError" + android:visibility="gone" /> diff --git a/app/src/main/res/layout/item_project_sheet.xml b/app/src/main/res/layout/item_project_sheet.xml new file mode 100644 index 0000000..9571a39 --- /dev/null +++ b/app/src/main/res/layout/item_project_sheet.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/sheet_project_switcher.xml b/app/src/main/res/layout/sheet_project_switcher.xml new file mode 100644 index 0000000..b8d33b8 --- /dev/null +++ b/app/src/main/res/layout/sheet_project_switcher.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 992b994..3fdc8f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,29 +10,48 @@ 占位页 —— 业务功能按 S1–S4 计划经 FeatureMount 挂载 + + 当前项目 + 未命名项目 + 尚未选择项目,点击「切换项目」选择当前工作的项目 + 切换项目 + 共 %1$d 个进行中项目 + 登录后查看您的项目 + 登录后即可同步当前账号参与的项目与任务 + 立即登录 + 尚未配置服务器与密钥 + 配置后才能加载项目数据 + 重试 + 当前账号暂无进行中的项目 + 刷新 + 切换项目 + - 返回 保存 - + 我的 + 正在恢复会话… + 未登录 + 登录后同步项目与任务 未知用户 部门:%1$s 账号:%1$s 手机号:%1$s - 登录 + 立即登录 退出登录 + 设置 修改登录密码 + 定期修改,保护账号安全 服务器与密钥配置 - 刷新用户信息 - 服务器与密钥已配置 + 未配置 尚未配置服务器地址与密钥,无法登录 去配置 - 确定退出当前账号? 登录 + 欢迎回来 上海城建勘测院 · 监测数据不落地平台 账号密码 短信验证码 diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt index d45933a..4a7964d 100644 --- a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt @@ -1,6 +1,7 @@ package com.stec.cmd.core.network import com.stec.cmd.core.network.api.AuthApi +import com.stec.cmd.core.network.api.ProjectApi import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -72,6 +73,11 @@ object NetworkModule { @Singleton fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java) + /** 项目接口(M2,2.5.1.1)。 */ + @Provides + @Singleton + fun provideProjectApi(retrofit: Retrofit): ProjectApi = retrofit.create(ProjectApi::class.java) + /** 仅满足 Retrofit 构造约束;真实地址请求期经 DynamicBaseUrlInterceptor 改写。 */ private const val PLACEHOLDER_BASE_URL = "https://placeholder.stec.invalid/" diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/ProjectApi.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/ProjectApi.kt new file mode 100644 index 0000000..d365786 --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/ProjectApi.kt @@ -0,0 +1,37 @@ +package com.stec.cmd.core.network.api + +import com.stec.cmd.core.network.ApiEnvelope +import kotlinx.serialization.json.JsonElement +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * 项目接口(接口管理规范 V4.5 · 测试环境 https://jcd.stec.p-q.co): + * - 2.5.1.1 获取当前用户项目列表(M2-01/02/03) + * + * 三要素请求头(SecretKey/SystemCode/Token)由 AuthInterceptor 统一注入。 + * 服务端仅返回当前用户有权限的、项目阶段为「进行中」的项目。 + * + * 契约宽容:文档约定 data 为 {"ProjectList":[…]};与登录接口(任务 ca3394be) + * 同理保留形态偏差可能,故以 [JsonElement] 宽容承载, + * 由 app 层 ProjectRepository.projectListFromData 归一提取。 + */ +interface ProjectApi { + + /** + * 2.5.1.1 当前用户项目列表:[projectId] 传 null 时 Retrofit 省略参数, + * 返回全部有权限项目;传值时仅返回该 ID 的单条列表数据。 + */ + @GET(PATH_GET_PROJECT_LIST) + suspend fun getProjectList( + @Query(QUERY_PROJECT_ID) projectId: String? = null, + ): ApiEnvelope + + companion object { + // ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ---- + const val PATH_GET_PROJECT_LIST = "OutWebApi/api/GetProjectList" + + /** 2.5.1.1 查询参数名(非必填,空则返回全部)。 */ + const val QUERY_PROJECT_ID = "ProjectID" + } +} diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/ProjectModels.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/ProjectModels.kt new file mode 100644 index 0000000..3e81133 --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/ProjectModels.kt @@ -0,0 +1,23 @@ +package com.stec.cmd.core.network.api + +import kotlinx.serialization.Serializable + +/** + * 项目 DTO(接口管理规范 V4.5 · 2.5.1.1 / 2.5.1.6)。 + * + * 字段名与平台契约一致(PascalCase);文档未给类型,全 `String?` 容错, + * 响应侧配合 Json.ignoreUnknownKeys 容忍字段演进。 + */ +@Serializable +data class ProjectItem( + /** 项目 ID。 */ + val ID: String? = null, + /** 项目编号。 */ + val ProjectCode: String? = null, + /** 项目简称。 */ + val SimpleName: String? = null, + /** 项目等级:总院重点 | 部门重点 | 一般项目。 */ + val ProjectScale: String? = null, + /** 项目阶段:未开始 | 进行中 | 已结束(2.5.1.1 服务端仅返回「进行中」)。 */ + val ProjectPhase: String? = null, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 42f9119..86311a4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,8 @@ activity = "1.9.3" fragment = "1.8.5" navigation = "2.8.4" material = "1.12.0" +recyclerview = "1.3.2" +swiperefreshlayout = "1.1.0" splashscreen = "1.0.1" # 测试 @@ -67,6 +69,10 @@ androidx-security-crypto = { group = "androidx.security", name = "security-crypt # Material google-material = { group = "com.google.android.material", name = "material", version.ref = "material" } +# 列表与下拉刷新(M2 首页项目切换弹层 / 下拉刷新) +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } + # Hilt hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }