*
This commit is contained in:
7
app/src/main/kotlin/com/stec/cmd/CollectFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/CollectFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 采集占位(M4+M5 数据采集,S2/S3 挂载扫描与采集页)。 */
|
||||
@AndroidEntryPoint
|
||||
class CollectFragment : PlaceholderFragment()
|
||||
41
app/src/main/kotlin/com/stec/cmd/FeatureMount.kt
Normal file
41
app/src/main/kotlin/com/stec/cmd/FeatureMount.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.stec.cmd
|
||||
|
||||
/**
|
||||
* 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
|
||||
*
|
||||
* S1+ 挂载业务页面的方式:把对应 [MountItem.fragmentClass] 从占位 Fragment
|
||||
* 换成业务 Fragment 即可,导航壳(MainActivity)零改动;新增 Tab 时扩展 [Tab]。
|
||||
* 挂载映射依据《安卓客户端功能明细表》:
|
||||
* 首页=M2、任务=M3、采集=M4+M5、统计=M6、我的=M1。
|
||||
*/
|
||||
object FeatureMount {
|
||||
|
||||
/** 底部导航 Tab(itemId 与 res/menu/bottom_nav_menu.xml 一一对应)。 */
|
||||
enum class Tab(val itemId: Int, val labelRes: Int) {
|
||||
HOME(R.id.nav_home, R.string.tab_home),
|
||||
TASK(R.id.nav_task, R.string.tab_task),
|
||||
COLLECT(R.id.nav_collect, R.string.tab_collect),
|
||||
STATS(R.id.nav_stats, R.string.tab_stats),
|
||||
MINE(R.id.nav_mine, R.string.tab_mine),
|
||||
}
|
||||
|
||||
/** 单个 Tab 的挂载项。 */
|
||||
data class MountItem(
|
||||
val tab: Tab,
|
||||
val fragmentClass: Class<out androidx.fragment.app.Fragment>,
|
||||
val moduleTag: String,
|
||||
)
|
||||
|
||||
/** 挂载注册表:S1+ 替换 fragmentClass 即完成业务页挂载。 */
|
||||
val mounts: List<MountItem> = listOf(
|
||||
MountItem(Tab.HOME, HomeFragment::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 统计与上传"),
|
||||
MountItem(Tab.MINE, MineFragment::class.java, "M1 我的"),
|
||||
)
|
||||
|
||||
/** 按 menu itemId 取挂载项;未知 id 返回 null(容错)。 */
|
||||
fun byItemId(itemId: Int): MountItem? =
|
||||
mounts.firstOrNull { it.tab.itemId == itemId }
|
||||
}
|
||||
7
app/src/main/kotlin/com/stec/cmd/HomeFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/HomeFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 首页占位(M2 项目工作台,S1 挂载项目列表)。 */
|
||||
@AndroidEntryPoint
|
||||
class HomeFragment : PlaceholderFragment()
|
||||
59
app/src/main/kotlin/com/stec/cmd/MainActivity.kt
Normal file
59
app/src/main/kotlin/com/stec/cmd/MainActivity.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import com.stec.cmd.databinding.ActivityMainBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/**
|
||||
* 单 Activity 导航壳:BottomNavigationView 5 Tab 切换 Fragment。
|
||||
* show/hide 复用实例以保留各 Tab 视图状态;S1 可迁移 Navigation 组件。
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.bottomNav.setOnItemSelectedListener(::selectTab)
|
||||
if (savedInstanceState == null) {
|
||||
binding.bottomNav.selectedItemId = FeatureMount.Tab.HOME.itemId
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectTab(item: MenuItem): Boolean {
|
||||
val mount = FeatureMount.byItemId(item.itemId) ?: return false
|
||||
switchTo(mount)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun switchTo(mount: FeatureMount.MountItem) {
|
||||
val tag = mount.tab.name
|
||||
val existing = supportFragmentManager.findFragmentByTag(tag)
|
||||
val target: Fragment = existing ?: createFragment(mount)
|
||||
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
supportFragmentManager.fragments.forEach { if (it !== target) hide(it) }
|
||||
if (existing == null) add(R.id.fragment_container, target, tag)
|
||||
show(target)
|
||||
}
|
||||
supportActionBar?.title = getString(mount.tab.labelRes)
|
||||
}
|
||||
|
||||
/** 实例化挂载项声明的 Fragment;占位 Fragment 支持模块名参数。 */
|
||||
private fun createFragment(mount: FeatureMount.MountItem): Fragment {
|
||||
val fragment = mount.fragmentClass.getDeclaredConstructor().newInstance()
|
||||
if (fragment is PlaceholderFragment) {
|
||||
fragment.arguments = PlaceholderFragment.args(mount.moduleTag)
|
||||
}
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
7
app/src/main/kotlin/com/stec/cmd/MineFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/MineFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 我的占位(M1 登录与配置,S1 挂载登录/配置页)。 */
|
||||
@AndroidEntryPoint
|
||||
class MineFragment : PlaceholderFragment()
|
||||
45
app/src/main/kotlin/com/stec/cmd/PlaceholderFragment.kt
Normal file
45
app/src/main/kotlin/com/stec/cmd/PlaceholderFragment.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.stec.cmd.databinding.FragmentPlaceholderBinding
|
||||
|
||||
/**
|
||||
* S0 占位页基类:显示模块编号与挂载说明,S1+ 由各业务 Fragment 替换。
|
||||
*/
|
||||
open class PlaceholderFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentPlaceholderBinding? = null
|
||||
private val binding get() = checkNotNull(_binding)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = FragmentPlaceholderBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val moduleTag = arguments?.getString(ARG_MODULE_TAG).orEmpty()
|
||||
binding.moduleTag.text = moduleTag
|
||||
binding.hint.text = getString(R.string.placeholder_hint)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_MODULE_TAG = "module_tag"
|
||||
|
||||
fun args(moduleTag: String): Bundle = Bundle().apply {
|
||||
putString(ARG_MODULE_TAG, moduleTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/src/main/kotlin/com/stec/cmd/StatsFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/StatsFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 统计占位(M6 统计与上传,S4 挂载统计表与文件上传)。 */
|
||||
@AndroidEntryPoint
|
||||
class StatsFragment : PlaceholderFragment()
|
||||
31
app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt
Normal file
31
app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.stec.cmd.core.common.AppLog
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
/**
|
||||
* 应用入口:装配 Hilt 图并安装日志实现。
|
||||
* core:common 的 AppLog 门面不依赖 android.util.Log,LogCat sink 在壳层注入。
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
class SteCmdApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AppLog.install { level, tag, message, throwable ->
|
||||
when (level) {
|
||||
AppLog.Level.DEBUG -> Log.d(tag, message, throwable)
|
||||
AppLog.Level.INFO -> Log.i(tag, message, throwable)
|
||||
AppLog.Level.WARN -> Log.w(tag, message, throwable)
|
||||
AppLog.Level.ERROR -> Log.e(tag, message, throwable)
|
||||
}
|
||||
}
|
||||
AppLog.i(TAG, "监测数据不落地平台客户端启动(S0 骨架)")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SteCmd"
|
||||
}
|
||||
}
|
||||
7
app/src/main/kotlin/com/stec/cmd/TaskFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/TaskFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 任务占位(M3 任务管理,S2 挂载任务列表)。 */
|
||||
@AndroidEntryPoint
|
||||
class TaskFragment : PlaceholderFragment()
|
||||
49
app/src/main/kotlin/com/stec/cmd/config/AppModule.kt
Normal file
49
app/src/main/kotlin/com/stec/cmd/config/AppModule.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.stec.cmd.config
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.stec.cmd.core.network.NetworkConfig
|
||||
import com.stec.cmd.core.network.TokenProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.configDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "stec_cmd_config",
|
||||
)
|
||||
|
||||
/**
|
||||
* app 壳 Hilt 装配:
|
||||
* - DataStore 实例;
|
||||
* - [ConfigRepository] 绑定为 core:network 的 [NetworkConfig] / [TokenProvider],
|
||||
* 补全 core:network NetworkModule 的依赖图(密钥值由用户配置注入,不入库)。
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideConfigDataStore(
|
||||
@ApplicationContext context: Context,
|
||||
): DataStore<Preferences> = context.configDataStore
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ConfigBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindNetworkConfig(impl: ConfigRepository): NetworkConfig
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTokenProvider(impl: ConfigRepository): TokenProvider
|
||||
}
|
||||
127
app/src/main/kotlin/com/stec/cmd/config/ConfigRepository.kt
Normal file
127
app/src/main/kotlin/com/stec/cmd/config/ConfigRepository.kt
Normal file
@@ -0,0 +1,127 @@
|
||||
package com.stec.cmd.config
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.stec.cmd.core.common.AppLog
|
||||
import com.stec.cmd.core.network.NetworkConfig
|
||||
import com.stec.cmd.core.network.TokenProvider
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* 平台配置仓储(M1-06 前置、M7-04 落地):
|
||||
* baseUrl / SecretKey / SystemCode / Token 经 DataStore 持久化 + 内存快照。
|
||||
*
|
||||
* **默认值全空,密钥不进代码库**:S1 在「我的」页面提供配置 UI;
|
||||
* 未配置时 [NetworkConfig] 各字段为空串,AuthInterceptor 将带空三要素,
|
||||
* 业务请求由 S1 的配置检查(未配置 → 跳转配置页)拦截。
|
||||
*
|
||||
* 拦截器在 OkHttp 后台线程同步读快照(runBlocking 只在冷启动首读时短暂阻塞,
|
||||
* 之后 [snapshotStateFlow] 已被预热,直接返回缓存值)。
|
||||
*/
|
||||
@Singleton
|
||||
class ConfigRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : NetworkConfig, TokenProvider {
|
||||
|
||||
data class Snapshot(
|
||||
val baseUrl: String = "",
|
||||
val secretKey: String = "",
|
||||
val systemCode: String = "",
|
||||
val token: String = "",
|
||||
) {
|
||||
/** 三要素 + 地址是否已配置完整。 */
|
||||
val isReady: Boolean
|
||||
get() = baseUrl.isNotBlank() && secretKey.isNotBlank() && systemCode.isNotBlank()
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val snapshotFlow = MutableStateFlow(Snapshot())
|
||||
|
||||
/** 配置快照流:S1 配置页/业务层订阅。 */
|
||||
val snapshotStateFlow: StateFlow<Snapshot> = snapshotFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
dataStore.data
|
||||
.catch { e ->
|
||||
if (e is IOException) emit(emptyPreferences()) else throw e
|
||||
}
|
||||
.collect { prefs ->
|
||||
snapshotFlow.value = Snapshot(
|
||||
baseUrl = prefs[KEY_BASE_URL].orEmpty(),
|
||||
secretKey = prefs[KEY_SECRET_KEY].orEmpty(),
|
||||
systemCode = prefs[KEY_SYSTEM_CODE].orEmpty(),
|
||||
token = prefs[KEY_TOKEN].orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- NetworkConfig ----
|
||||
|
||||
override val baseUrl: String get() = snapshot().baseUrl
|
||||
|
||||
override val secretKey: String get() = snapshot().secretKey
|
||||
|
||||
override val systemCode: String get() = snapshot().systemCode
|
||||
|
||||
override val tokenProvider: TokenProvider get() = this
|
||||
|
||||
// ---- TokenProvider ----
|
||||
|
||||
override fun currentToken(): String = snapshot().token
|
||||
|
||||
// ---- 写入(S1 配置页 / 登录流程调用)----
|
||||
|
||||
/** 保存三要素与服务器地址。 */
|
||||
suspend fun updateConnection(baseUrl: String, secretKey: String, systemCode: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_BASE_URL] = baseUrl.trim().trimEnd('/')
|
||||
prefs[KEY_SECRET_KEY] = secretKey.trim()
|
||||
prefs[KEY_SYSTEM_CODE] = systemCode.trim()
|
||||
}
|
||||
AppLog.i(TAG, "连接配置已更新")
|
||||
}
|
||||
|
||||
/** 登录成功保存 Token;登出传空串。 */
|
||||
suspend fun updateToken(token: String) {
|
||||
dataStore.edit { it[KEY_TOKEN] = token }
|
||||
}
|
||||
|
||||
/** 阻塞读取当前快照(拦截器线程安全:值来自内存缓存)。 */
|
||||
private fun snapshot(): Snapshot =
|
||||
if (snapshotFlow.value != Snapshot()) {
|
||||
snapshotFlow.value
|
||||
} else {
|
||||
// 冷启动首读:预热缓存
|
||||
runBlocking { snapshotFlow.first() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ConfigRepository"
|
||||
val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||
val KEY_SECRET_KEY = stringPreferencesKey("secret_key")
|
||||
val KEY_SYSTEM_CODE = stringPreferencesKey("system_code")
|
||||
val KEY_TOKEN = stringPreferencesKey("token")
|
||||
}
|
||||
}
|
||||
|
||||
/** 只读 Token 快照接口的窄化视图(给不需要完整 NetworkConfig 的组件用)。 */
|
||||
val ConfigRepository.tokenFlow: Flow<Snapshot> get() = snapshotStateFlow
|
||||
Reference in New Issue
Block a user