task(0a00ccbd-6f98-4730-adf2-6f1e864823de): Mount M2 project workbench home and modernize M1 layouts

- FeatureMount: HOME tab now mounts ProjectWorkspaceFragment; delete placeholder HomeFragment
- app/build.gradle.kts + libs.versions.toml: add androidx recyclerview and swiperefreshlayout for project list and pull-to-refresh
- NetworkModule: register ProjectApi (M2 2.5.1.1 GetProjectList) via provideProjectApi
- SessionRepository: add SessionState.Restoring as initial state so cold start renders neutral loading instead of prompting login
- MineFragment/layout: consumer-grade flow with horizontal identity card (whole card acts as login CTA when signed out), config warning card, grouped settings rows; drop back button and manual refresh entry
- MineViewModel: remove manual refresh(); session restore is now automatic on cold start
- LoginFragment/layout: MaterialToolbar back, segmented MaterialButtonToggleGroup for account/SMS mode, TextInputLayout floating-label inputs, linear progress below button
- ConfigFragment/ChangePasswordFragment layouts: unify back navigation to Toolbar, switch plain EditTexts to TextInputLayout, replace ProgressBar with LinearProgressIndicator
- strings.xml: add M2 home workbench copy and mine/login labels; remove unused action_back, mine_login, mine_refresh, mine_config_ready, mine_logout_confirm
This commit is contained in:
阿猫
2026-09-03 23:50:20 +08:00
parent dec274b1af
commit ddd5009bb1
29 changed files with 1668 additions and 431 deletions

View File

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

View File

@@ -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<MountItem> = 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 统计与上传"),

View File

@@ -1,7 +0,0 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 首页占位M2 项目工作台S1 挂载项目列表)。 */
@AndroidEntryPoint
class HomeFragment : PlaceholderFragment()

View File

@@ -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-03M3/M5/M6 共享订阅)。 */
val selectedProject = projectSession.selectedProject
private val _projects = MutableStateFlow<UiState<List<ProjectItem>>>(UiState.Idle)
/** 项目列表加载态。 */
val projects: StateFlow<UiState<List<ProjectItem>>> = _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)
}

View File

@@ -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<ProjectItem> =
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<ProjectItem>,
private val selectedId: String?,
private val onPicked: (ProjectItem) -> Unit,
) : RecyclerView.Adapter<ProjectAdapter.ViewHolder>() {
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 = " · "
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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-012.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<ProjectItem> =
projectListFromData(apiCaller.call { projectApi.getProjectList(projectId) })
/**
* data → 项目列表归一提取:
* - JsonObject优先取 `ProjectList` 键(文档口径),无该键则按整对象直接解码;
* - JsonArray文档口径的平铺数组
* - JsonPrimitive二次 JSON 编码的列表字符串。
* 结构无法识别或字段不符时以 [ApiError.EmptyBodyError] 抛出。
*/
private fun projectListFromData(data: JsonElement): List<ProjectItem> {
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.1data 内列表键名(确切大小写)。 */
const val KEY_PROJECT_LIST = "ProjectList"
}
}

View File

@@ -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<ProjectItem?>(null)
/** 当前选中项目;未选择或已登出时为 null。 */
val selectedProject: StateFlow<ProjectItem?> = _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
}
}

View File

@@ -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>(SessionState.SignedOut)
private val _state = MutableStateFlow<SessionState>(SessionState.Restoring)
/** 登录态流Mine 等页面订阅渲染。 */
val state: StateFlow<SessionState> = _state.asStateFlow()

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 「我的」身份卡圆形头像底(姓名首字叠于其上) -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/brand_primary" />
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 返回箭头Toolbar navigationIcon 共用;着色经 app:navigationIconTint 控制) -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 右箭头(设置列表行 / 切换项目入口) -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z" />
</vector>

View File

@@ -1,85 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 修改登录密码页M1-042.3.1):成功后本地登出,需用新密码重登 -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:fillViewport="true">
android:orientation="vertical">
<LinearLayout
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/btn_back"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_back"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/change_password_title"
app:titleTextColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_back"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_back" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/change_password_title"
android:textColor="?attr/colorOnSurface"
android:textSize="24sp"
android:textStyle="bold" />
<EditText
android:id="@+id/et_old_password"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:hint="@string/change_password_old_hint"
android:imeOptions="actionNext"
android:inputType="textPassword"
android:maxLines="1" />
android:orientation="vertical"
android:padding="24dp">
<EditText
android:id="@+id/et_new_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/change_password_new_hint"
android:imeOptions="actionNext"
android:inputType="textPassword"
android:maxLines="1" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/change_password_old_hint">
<EditText
android:id="@+id/et_confirm_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/change_password_confirm_hint"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="1" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_old_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="textPassword"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/change_password_submit" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/change_password_new_hint">
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_new_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="textPassword"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="?attr/colorError"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/change_password_confirm_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_confirm_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="@string/change_password_submit" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="?attr/colorError"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -1,93 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 服务器与密钥配置页M1-06仅存本机 DataStore不入代码库M7-04 -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:fillViewport="true">
android:orientation="vertical">
<LinearLayout
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/btn_back"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_back"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/config_title"
app:titleTextColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_back"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_back" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/config_title"
android:textColor="?attr/colorOnSurface"
android:textSize="24sp"
android:textStyle="bold" />
<EditText
android:id="@+id/et_base_url"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:hint="@string/config_base_url_hint"
android:imeOptions="actionNext"
android:inputType="textUri"
android:maxLines="1" />
android:orientation="vertical"
android:padding="24dp">
<EditText
android:id="@+id/et_secret_key"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/config_secret_key_hint"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/config_base_url_hint">
<EditText
android:id="@+id/et_system_code"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/config_system_code_hint"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_base_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="textUri"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/action_save" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/config_secret_key_hint">
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_secret_key"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="?attr/colorError"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/config_system_code_hint">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:alpha="0.6"
android:text="@string/config_note"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_system_code"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="@string/action_save" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="?attr/colorError"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:alpha="0.6"
android:text="@string/config_note"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,297 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 首页项目工作台M2-01 项目列表 / M2-02 项目卡 / M2-03 切换入口) -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:overScrollMode="never">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="24dp">
<!-- 连接配置警示卡(仅三要素缺失时显示) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_config_warning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="16dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:visibility="gone"
app:cardBackgroundColor="?attr/colorErrorContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_config_warning_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/home_config_missing_title"
android:textColor="?attr/colorOnErrorContainer"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_config_warning_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.8"
android:text="@string/home_config_missing_hint"
android:textColor="?attr/colorOnErrorContainer"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/mine_go_config"
android:textColor="?attr/colorOnErrorContainer"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tv_section_current"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp"
android:text="@string/home_section_current"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold"
android:visibility="gone" />
<!-- 当前项目卡M2-02 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_project"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:visibility="gone"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/tv_project_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?attr/colorOnSurface"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_project_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.65"
android:textSize="14sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<com.google.android.material.chip.Chip
android:id="@+id/chip_scale"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:focusable="false" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_phase"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:clickable="false"
android:focusable="false" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tv_project_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="8dp"
android:alpha="0.7"
android:text="@string/home_no_project_selected_hint"
android:textSize="14sp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_switch_project"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="4dp"
android:text="@string/home_switch_project"
android:visibility="gone"
app:icon="@drawable/ic_chevron_right"
app:iconGravity="textEnd"
app:iconTint="?attr/colorPrimary" />
<TextView
android:id="@+id/tv_project_count"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="8dp"
android:alpha="0.6"
android:textSize="13sp"
android:visibility="gone" />
</LinearLayout>
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
android:visibility="gone" />
<!-- 未登录空态 -->
<LinearLayout
android:id="@+id/state_signed_out"
android:clickable="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/home_signed_out_title"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.7"
android:gravity="center"
android:text="@string/home_signed_out_hint"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_go_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/home_go_login" />
</LinearLayout>
<!-- 错误态 -->
<LinearLayout
android:id="@+id/state_error"
android:clickable="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:id="@+id/tv_error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="?attr/colorError"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_retry"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/home_retry" />
</LinearLayout>
<!-- 空列表态 -->
<LinearLayout
android:id="@+id/state_empty"
android:clickable="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/home_empty"
android:alpha="0.7"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_empty_refresh"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/home_empty_refresh" />
</LinearLayout>
</FrameLayout>

View File

@@ -1,160 +1,197 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 登录页M1-01 账号密码 / M1-02 获取验证码 / M1-03 验证码登录) -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
<!-- 登录页M1-01 账号密码 / M1-02 获取验证码 / M1-03 验证码登录)
消费级动线Toolbar 返回 + 品牌头部 + 分段切换 + 浮动标签输入 + 按钮下线性进度 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:fillViewport="true">
android:orientation="vertical">
<LinearLayout
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
android:layout_height="?attr/actionBarSize"
app:navigationIcon="@drawable/ic_back"
app:navigationIconTint="?attr/colorOnSurface"
app:title="@string/login_title"
app:titleTextColor="?attr/colorOnSurface" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_back"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_back" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/login_title"
android:textColor="?attr/colorOnSurface"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.7"
android:text="@string/login_subtitle"
android:textSize="14sp" />
<RadioGroup
android:id="@+id/radio_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="horizontal">
<RadioButton
android:id="@+id/rb_account"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:checked="true"
android:text="@string/login_mode_account" />
<RadioButton
android:id="@+id/rb_sms"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/login_mode_sms" />
</RadioGroup>
<!-- 账号密码模式 -->
<LinearLayout
android:id="@+id/group_account"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical">
<EditText
android:id="@+id/et_login_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_name_hint"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1" />
<EditText
android:id="@+id/et_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/login_password_hint"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="1" />
</LinearLayout>
<!-- 短信验证码模式 -->
<LinearLayout
android:id="@+id/group_sms"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical"
android:visibility="gone">
android:padding="24dp">
<EditText
android:id="@+id/et_phone"
<!-- 品牌头部 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/login_welcome"
android:textColor="?attr/colorOnSurface"
android:textSize="26sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.7"
android:text="@string/login_subtitle"
android:textSize="14sp" />
<!-- 登录方式分段切换 -->
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/toggle_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_phone_hint"
android:imeOptions="actionNext"
android:inputType="phone"
android:maxLength="11"
android:maxLines="1" />
android:layout_marginTop="24dp"
app:selectionRequired="true"
app:singleSelection="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<EditText
android:id="@+id/et_sms_code"
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_mode_account"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/login_sms_code_hint"
android:inputType="number"
android:maxLength="6"
android:maxLines="1" />
android:text="@string/login_mode_account" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_send_code"
android:id="@+id/btn_mode_sms"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/login_send_code" />
android:layout_weight="1"
android:text="@string/login_mode_sms" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- 账号密码模式 -->
<LinearLayout
android:id="@+id/group_account"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_name_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_login_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/login_password_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<!-- 短信验证码模式 -->
<LinearLayout
android:id="@+id/group_sms"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="vertical"
android:visibility="gone">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_phone_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_phone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="phone"
android:maxLength="11"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/login_sms_code_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_sms_code"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLength="6"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_send_code"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/login_send_code" />
</LinearLayout>
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="@string/login_submit" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="?attr/colorError"
android:textSize="14sp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/login_submit" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="?attr/colorError"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>
</ScrollView>
</LinearLayout>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 「我的」业务页M1-05 用户信息 / M1-06 配置入口 / M1-04 改密入口 / M1-07 退出登录) -->
<!-- 「我的」业务页M1-05 用户信息 / M1-06 配置入口 / M1-04 改密入口 / M1-07 退出登录)
消费级动线:横向身份卡 + 连接配置警示卡 + 设置列表分组卡 + 登录 CTA / 退出登录 -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
@@ -11,120 +12,278 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_back"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_back" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/mine_title"
android:textColor="?attr/colorOnSurface"
android:textSize="24sp"
android:textStyle="bold" />
android:paddingBottom="24dp">
<!-- 横向身份卡:未登录时整卡即登录入口 -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_user"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="24dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="12dp"
app:cardElevation="1dp">
app:cardCornerRadius="16dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="20dp">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/mine_not_logged_in"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:id="@+id/tv_avatar"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="@drawable/bg_avatar_circle"
android:gravity="center"
android:textColor="@color/brand_on_primary"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_dept"
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.8"
android:textSize="14sp" />
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_account"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.8"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/mine_not_logged_in"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_phone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.8"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_dept"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.75"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_account"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.6"
android:textSize="13sp" />
<TextView
android:id="@+id/tv_phone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.6"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tv_config_status"
<!-- 连接配置警示卡(仅三要素缺失时显示) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_config_warning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="12dp"
android:alpha="0.7"
android:textSize="13sp" />
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:visibility="gone"
app:cardBackgroundColor="?attr/colorErrorContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_go_config"
style="?attr/materialButtonOutlinedStyle"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/mine_config_missing"
android:textColor="?attr/colorOnErrorContainer"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.8"
android:text="@string/home_config_missing_hint"
android:textColor="?attr/colorOnErrorContainer"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/mine_go_config"
android:textColor="?attr/colorOnErrorContainer"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 设置列表分组卡 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/mine_go_config"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp"
android:alpha="0.6"
android:text="@string/mine_settings_section"
android:textSize="13sp" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/row_change_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/mine_change_password"
android:textColor="?attr/colorOnSurface"
android:textSize="15sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:alpha="0.6"
android:text="@string/mine_row_change_password_subtitle"
android:textSize="12sp" />
</LinearLayout>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/mine_change_password"
android:src="@drawable/ic_chevron_right"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="16dp"
android:alpha="0.15"
android:background="?attr/colorOnSurface" />
<LinearLayout
android:id="@+id/row_open_config"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/mine_open_config"
android:textColor="?attr/colorOnSurface"
android:textSize="15sp" />
<TextView
android:id="@+id/tv_config_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:alpha="0.6"
android:text="@string/mine_row_config_unset"
android:textSize="12sp" />
</LinearLayout>
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/mine_open_config"
android:src="@drawable/ic_chevron_right"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 主 CTA未登录显示立即登录已登录显示红字退出登录 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login_cta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="24dp"
android:text="@string/mine_login_now"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login_or_logout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/mine_login" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_change_password"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/mine_change_password" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_open_config"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/mine_open_config" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_refresh"
android:id="@+id/btn_logout"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/mine_refresh" />
android:layout_marginHorizontal="20dp"
android:layout_marginTop="16dp"
android:text="@string/mine_logout"
android:textColor="?attr/colorError"
android:visibility="gone" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 项目切换单选行M2-03当前选中项打勾行整体可点 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="20dp"
android:paddingVertical="14dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/colorOnSurface"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_meta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.6"
android:textSize="12sp" />
</LinearLayout>
<RadioButton
android:id="@+id/radio_selected"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:clickable="false"
android:focusable="false" />
</LinearLayout>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 项目切换底部弹层M2-03 -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="20dp"
android:paddingTop="20dp"
android:paddingBottom="8dp"
android:text="@string/sheet_switch_title"
android:textColor="?attr/colorOnSurface"
android:textSize="16sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_projects"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:overScrollMode="never" />
</LinearLayout>

View File

@@ -10,29 +10,48 @@
<string name="placeholder_hint">占位页 —— 业务功能按 S1S4 计划经 FeatureMount 挂载</string>
<!-- M2 首页项目工作台M2-01/02/03 -->
<string name="home_section_current">当前项目</string>
<string name="home_unnamed_project">未命名项目</string>
<string name="home_no_project_selected_hint">尚未选择项目,点击「切换项目」选择当前工作的项目</string>
<string name="home_switch_project">切换项目</string>
<string name="home_project_count_fmt">共 %1$d 个进行中项目</string>
<string name="home_signed_out_title">登录后查看您的项目</string>
<string name="home_signed_out_hint">登录后即可同步当前账号参与的项目与任务</string>
<string name="home_go_login">立即登录</string>
<string name="home_config_missing_title">尚未配置服务器与密钥</string>
<string name="home_config_missing_hint">配置后才能加载项目数据</string>
<string name="home_retry">重试</string>
<string name="home_empty">当前账号暂无进行中的项目</string>
<string name="home_empty_refresh">刷新</string>
<string name="sheet_switch_title">切换项目</string>
<!-- 通用 -->
<string name="action_back">返回</string>
<string name="action_save">保存</string>
<!-- M1 我的M1-05/06/07 -->
<!-- M1 我的M1-04/05/06/07 -->
<string name="mine_title">我的</string>
<string name="mine_restoring">正在恢复会话…</string>
<string name="mine_avatar_guest"></string>
<string name="mine_not_logged_in">未登录</string>
<string name="mine_not_logged_hint">登录后同步项目与任务</string>
<string name="mine_unknown_user">未知用户</string>
<string name="mine_dept_fmt">部门:%1$s</string>
<string name="mine_account_fmt">账号:%1$s</string>
<string name="mine_phone_fmt">手机号:%1$s</string>
<string name="mine_login">登录</string>
<string name="mine_login_now">立即登录</string>
<string name="mine_logout">退出登录</string>
<string name="mine_settings_section">设置</string>
<string name="mine_change_password">修改登录密码</string>
<string name="mine_row_change_password_subtitle">定期修改,保护账号安全</string>
<string name="mine_open_config">服务器与密钥配置</string>
<string name="mine_refresh">刷新用户信息</string>
<string name="mine_config_ready">服务器与密钥已配置</string>
<string name="mine_row_config_unset">未配置</string>
<string name="mine_config_missing">尚未配置服务器地址与密钥,无法登录</string>
<string name="mine_go_config">去配置</string>
<string name="mine_logout_confirm">确定退出当前账号?</string>
<!-- M1 登录M1-01/02/03 -->
<string name="login_title">登录</string>
<string name="login_welcome">欢迎回来</string>
<string name="login_subtitle">上海城建勘测院 · 监测数据不落地平台</string>
<string name="login_mode_account">账号密码</string>
<string name="login_mode_sms">短信验证码</string>

View File

@@ -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)
/** 项目接口M22.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/"

View File

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

View File

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

View File

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