*
This commit is contained in:
74
app/build.gradle.kts
Normal file
74
app/build.gradle.kts
Normal file
@@ -0,0 +1,74 @@
|
||||
// app 业务壳:导航、主题、权限、配置注入;M1–M6 页面经 FeatureMount 挂载
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.stec.cmd"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.stec.cmd"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0-S0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// core 基础层 + 协议解析(业务模块只允许经 core 层访问能力)
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:network"))
|
||||
implementation(project(":core:database"))
|
||||
implementation(project(":core:ble"))
|
||||
implementation(project(":ble-protocol"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.splashscreen)
|
||||
implementation(libs.google.material)
|
||||
|
||||
implementation(libs.androidx.lifecycle.runtime)
|
||||
implementation(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.androidx.lifecycle.livedata)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
12
app/proguard-rules.pro
vendored
Normal file
12
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# app 壳混淆规则(release)
|
||||
# S0 骨架期保持最小规则集,S5 发布阶段随业务实体补全
|
||||
|
||||
# kotlinx.serialization:保留序列化器生成逻辑(S1 业务 DTO 接入后按类型名细化)
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
|
||||
# OkHttp / Retrofit
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn javax.annotation.**
|
||||
-keepattributes Signature, Exceptions
|
||||
54
app/src/main/AndroidManifest.xml
Normal file
54
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- 蓝牙:Android 12L+ -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- 蓝牙:Android 12L 以下(随 12L+ 权限一同声明,按系统版本生效) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<!-- 12L 以下 BLE 扫描依赖定位权限 -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- 通知(33+,上传进度/采集会话通知预留) -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 网络(core:network) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- 蓝牙为可选硬件:无蓝牙设备仍可安装 -->
|
||||
<uses-feature android:name="android.hardware.bluetooth_le"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".SteCmdApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SteCmd"
|
||||
tools:targetApi="34">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.SteCmd.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
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
|
||||
7
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
7
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="@color/launcher_background"
|
||||
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
|
||||
</vector>
|
||||
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<!-- 蓝牙风格前景:安全区中央(内容控制在 66dp 内圆内) -->
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="4"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M54,32 L54,76 M54,32 L64,41.5 L44,66.5 M54,76 L64,66.5 L44,41.5" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/ic_nav_collect.xml
Normal file
12
app/src/main/res/drawable/ic_nav_collect.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?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">
|
||||
<!-- 蓝牙符号:采集 Tab -->
|
||||
<path android:strokeColor="#FF000000"
|
||||
android:strokeWidth="1.8"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M12,2.5 L12,21.5 M12,2.5 L16.5,6.8 L7.5,17.2 M12,21.5 L16.5,17.2 L7.5,6.8" />
|
||||
</vector>
|
||||
7
app/src/main/res/drawable/ic_nav_home.xml
Normal file
7
app/src/main/res/drawable/ic_nav_home.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?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="M12,3 L4,10 L4,21 L10,21 L10,15 L14,15 L14,21 L20,21 L20,10 Z" />
|
||||
</vector>
|
||||
7
app/src/main/res/drawable/ic_nav_mine.xml
Normal file
7
app/src/main/res/drawable/ic_nav_mine.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?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="M12,12 C14.2,12 16,10.2 16,8 C16,5.8 14.2,4 12,4 C9.8,4 8,5.8 8,8 C8,10.2 9.8,12 12,12 Z M4,20 C4,16.7 7.6,14 12,14 C16.4,14 20,16.7 20,20 Z" />
|
||||
</vector>
|
||||
7
app/src/main/res/drawable/ic_nav_stats.xml
Normal file
7
app/src/main/res/drawable/ic_nav_stats.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?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="M5,20 L5,12 L8,12 L8,20 Z M11,20 L11,6 L14,6 L14,20 Z M17,20 L17,9 L20,9 L20,20 Z" />
|
||||
</vector>
|
||||
7
app/src/main/res/drawable/ic_nav_task.xml
Normal file
7
app/src/main/res/drawable/ic_nav_task.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?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="M4,5 L20,5 L20,7 L4,7 Z M4,11 L20,11 L20,13 L4,13 Z M4,17 L14,17 L14,19 L4,19 Z" />
|
||||
</vector>
|
||||
22
app/src/main/res/layout/activity_main.xml
Normal file
22
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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:orientation="vertical">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/fragment_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
android:id="@+id/bottom_nav"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSurface"
|
||||
app:labelVisibilityMode="labeled"
|
||||
app:menu="@menu/bottom_nav_menu" />
|
||||
|
||||
</LinearLayout>
|
||||
34
app/src/main/res/layout/fragment_placeholder.xml
Normal file
34
app/src/main/res/layout/fragment_placeholder.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
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:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/module_tag"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/hint"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textSize="14sp"
|
||||
android:alpha="0.7"
|
||||
app:layout_constraintTop_toBottomOf="@id/module_tag"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
23
app/src/main/res/menu/bottom_nav_menu.xml
Normal file
23
app/src/main/res/menu/bottom_nav_menu.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/nav_home"
|
||||
android:icon="@drawable/ic_nav_home"
|
||||
android:title="@string/tab_home" />
|
||||
<item
|
||||
android:id="@+id/nav_task"
|
||||
android:icon="@drawable/ic_nav_task"
|
||||
android:title="@string/tab_task" />
|
||||
<item
|
||||
android:id="@+id/nav_collect"
|
||||
android:icon="@drawable/ic_nav_collect"
|
||||
android:title="@string/tab_collect" />
|
||||
<item
|
||||
android:id="@+id/nav_stats"
|
||||
android:icon="@drawable/ic_nav_stats"
|
||||
android:title="@string/tab_stats" />
|
||||
<item
|
||||
android:id="@+id/nav_mine"
|
||||
android:icon="@drawable/ic_nav_mine"
|
||||
android:title="@string/tab_mine" />
|
||||
</menu>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
9
app/src/main/res/values/colors.xml
Normal file
9
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 品牌色占位(S1 视觉规范化时校准) -->
|
||||
<color name="brand_primary">#1E6FD9</color>
|
||||
<color name="brand_on_primary">#FFFFFF</color>
|
||||
<color name="brand_background">#F7F9FC</color>
|
||||
<color name="brand_surface">#FFFFFF</color>
|
||||
<color name="launcher_background">#1E6FD9</color>
|
||||
</resources>
|
||||
12
app/src/main/res/values/strings.xml
Normal file
12
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">监测数据不落地</string>
|
||||
|
||||
<string name="tab_home">首页</string>
|
||||
<string name="tab_task">任务</string>
|
||||
<string name="tab_collect">采集</string>
|
||||
<string name="tab_stats">统计</string>
|
||||
<string name="tab_mine">我的</string>
|
||||
|
||||
<string name="placeholder_hint">占位页 —— 业务功能按 S1–S4 计划经 FeatureMount 挂载</string>
|
||||
</resources>
|
||||
20
app/src/main/res/values/themes.xml
Normal file
20
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- 应用主主题:Material 3 DayNight,无标题栏(自管理标题) -->
|
||||
<style name="Theme.SteCmd" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/brand_primary</item>
|
||||
<item name="colorOnPrimary">@color/brand_on_primary</item>
|
||||
<item name="android:colorBackground">@color/brand_background</item>
|
||||
<item name="colorSurface">@color/brand_surface</item>
|
||||
<item name="android:statusBarColor">@color/brand_background</item>
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
|
||||
</style>
|
||||
|
||||
<!-- 启动闪屏(core-splashscreen 兼容 12 以下) -->
|
||||
<style name="Theme.SteCmd.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/launcher_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.SteCmd</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
app/src/main/res/xml/network_security_config.xml
Normal file
18
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
网络安全白名单:
|
||||
- 默认全域强制 HTTPS(usesCleartextTraffic=false 于 Manifest);
|
||||
- 仅对接口文档 1.2.4 测试环境 IP 开放明文 HTTP(外场联调),发布前如生产全 HTTPS 可移除;
|
||||
- 测试域名 jcd.stec.p-q.co 为 HTTPS,无需额外配置。
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">106.15.183.20</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
Reference in New Issue
Block a user