初始化:多比比Android客户端(占坑版)
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
build/
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
*.aab
|
||||
*.keystore
|
||||
*.jks
|
||||
release/
|
||||
|
||||
# 本地调试/截图/日志副产品,不进仓库(只指根目录,不递归 ignore app/src/main/java/.../data/*)
|
||||
/data/
|
||||
@@ -0,0 +1,166 @@
|
||||
# duobibi-android
|
||||
|
||||
多比比(占坑版)Android 客户端。
|
||||
|
||||
跨平台比价工具:输入商品名,在「比价擂台」里把 淘宝 / 京东 / 拼多多 / 抖音 的到手价
|
||||
拉到一张表对比、自动标最低;另配「降价雷达」盯价提醒与「值不值得买」AI 评分。
|
||||
也保留在 7 个购物 App 内的悬浮「比价」按钮一键记价(本地存储,喂给擂台)。
|
||||
|
||||
> **占坑期**:与姊妹品牌「傻瓜比价」复用同一套底层引擎(无障碍+浮窗、Room、Retrofit、
|
||||
> 心跳、推送、一键登录),但包名 / 签名 / 配色 / 导航 / 文案 / 功能组合全部独立重做,
|
||||
> 通过应用市场"套壳/重复"检测与"功能过于简单"审核。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Kotlin 2.1 + Jetpack Compose(Material3,BOM 2024.12)
|
||||
- minSdk 26 / targetSdk 34 / compileSdk 36
|
||||
- JDK 17(`sourceCompatibility = VERSION_17`)
|
||||
- Room + Hilt + Retrofit(kotlinx.serialization)+ OkHttp
|
||||
- AGP 8.9 / Gradle Wrapper 内置
|
||||
- 包名 `com.jishisongfu.duobibi`,版本 `1.0.0` (versionCode 1)
|
||||
- 小米推送 SDK 7.9.2 + 极光 jverification 3.1.7(ABI:armeabi-v7a / arm64-v8a)
|
||||
|
||||
## 工程结构
|
||||
|
||||
```
|
||||
app/src/main/java/com/jishisongfu/duobibi/
|
||||
├── DuobibiApplication.kt # @HiltAndroidApp
|
||||
├── MainActivity.kt # 单 Activity:隐私同意 → onboarding → AppNavHost
|
||||
├── di/ # Hilt: DatabaseModule / NetworkModule
|
||||
├── data/
|
||||
│ ├── db/ # Room v1: Cluster / PriceRecord / PriceAlert + DAO
|
||||
│ ├── api/ # Retrofit: Parse / Ocr / Protect / Auth / Arena / WorthBuy
|
||||
│ └── repo/PriceRepository.kt # 业务编排:记价入库 + 擂台 + 值不值 + 雷达查价
|
||||
├── service/
|
||||
│ ├── PriceBotAccessibilityService # 监听窗口切换 + 抓取页面控件树
|
||||
│ └── FloatingWindowService # 前台服务,承载比价浮窗(自我修复)
|
||||
├── floating/ # AccessibilityServiceHolder / FloatingButtonView / TreeSerializer
|
||||
├── heartbeat/ # 心跳上报(AlarmManager,占位 PoC)
|
||||
├── push/ # 小米推送 bootstrap + receiver
|
||||
├── auth/ # 极光一键登录(JgInit / OneClickLogin / PhoneBindViewModel)
|
||||
├── ui/
|
||||
│ ├── theme/ # DbbColors(蓝青)+ DuobibiTheme
|
||||
│ ├── nav/AppNavHost.kt # 5 Tab + 详情路由
|
||||
│ ├── arena/ # 比价擂台(首屏 Hero)
|
||||
│ ├── alert/ # 降价雷达(想买的 / 已买的)
|
||||
│ ├── worth/ # AI 值不值得买
|
||||
│ ├── records/ # 记录列表 / 商品组详情 / 趋势图 / 手动记一笔
|
||||
│ ├── mine/ # 我的:账号绑定 / 权限 / 自检 / 关于
|
||||
│ ├── onboarding/ + privacy/ # 首启引导 + 隐私同意/政策
|
||||
│ └── tools/ # 单价对比 / 凑单 / 分期真实利率
|
||||
└── util/ # Constants / *Prefs / PermissionUtil / AlertNotifier / DemoDataSeeder ...
|
||||
```
|
||||
|
||||
## 底部 5 Tab
|
||||
|
||||
| Tab | 内容 |
|
||||
|---|---|
|
||||
| 比价 | **比价擂台**:搜商品 → 多平台到手价对比表,标最低,可一键跳最低价平台搜 |
|
||||
| 雷达 | **降价提醒雷达**:想买的(设心理价)/ 已买的(盯价保),降价推送提醒 |
|
||||
| 值不值 | **AI 值不值得买**:输入商品+到手价 → 评分 + 建议 + 更划算时机 |
|
||||
| 记录 | 浮窗 / 手动记的价格,按商品组聚合,看趋势图 |
|
||||
| 我的 | 账号一键登录、权限、算价工具、自检、关于、隐私 |
|
||||
|
||||
## 关键设计
|
||||
|
||||
### 比价擂台(首屏 Hero)
|
||||
|
||||
搜索 → 调后端 `/arena-quote` 得各平台 AI 估到手价 + 市场常见价;若本机记录过同款,
|
||||
本机真实价直接覆盖对应平台行并标「本机」,最低行高亮。
|
||||
|
||||
### 浮窗显隐 + 自我修复
|
||||
|
||||
`AccessibilityService` 监听 `TYPE_WINDOW_STATE_CHANGED`,在代码层比对
|
||||
`Constants.WHITELIST_PACKAGES`(7 个购物 App)决定浮窗显隐;`FloatingWindowService.notifyVisibility`
|
||||
在服务被杀后能按白名单事件自动重启。浮窗按钮为蓝青「比价」圆钮。
|
||||
|
||||
### 无障碍只读
|
||||
|
||||
无障碍只在用户点击浮窗时被动抓取一次当前页面控件树,不做任何自动点击 / 输入 / 滑动等代操作。
|
||||
`accessibility_service_config.xml` 仅声明 `TYPE_WINDOW_STATE_CHANGED`,不申请截屏与手势权限。
|
||||
|
||||
### 降价雷达
|
||||
|
||||
`PriceAlert`(mode = WANT / BOUGHT)统一承载「想买的设心理价」与「已买的盯价保」。
|
||||
后台复用 `/track-price` 估当前价,跌破锚价即 `AlertNotifier` 本地通知。
|
||||
|
||||
### 算价工具(纯本地计算,不联网)
|
||||
|
||||
- **单价对比**:g/kg/ml/L/件 统一换算成 100g·100ml·件 单价
|
||||
- **凑单计算器**:阶梯满减 300-50 / 600-100 / 1000-200 / 1500-300,提示"再凑 X 反而少花 Y"
|
||||
- **分期真实利率**:二分法求月 IRR × 12,揭穿"月费率 × 12"低估的名义年化
|
||||
|
||||
### 隐私
|
||||
|
||||
首启全屏同意页(不同意即退出);价格记录默认仅存本机(Room),仅在主动记价 / 擂台 / 值不值时
|
||||
临时联网识别,不持久化。卸载即清。
|
||||
|
||||
## 配置
|
||||
|
||||
- **后端域名**:`app/build.gradle.kts` → release `BASE_URL = "https://api.duobibi.com/"`,
|
||||
debug `BASE_URL = "http://127.0.0.1:8766/"`,强制 HTTPS(`usesCleartextTraffic="false"`)
|
||||
- **release 签名**:不硬编码,从 `gradle.properties` / `-P` 注入
|
||||
`DUOBIBI_KEYSTORE / DUOBIBI_STORE_PASSWORD / DUOBIBI_KEY_ALIAS / DUOBIBI_KEY_PASSWORD`;
|
||||
未配置时 debug 走默认 debug.keystore,可直接 `assembleDebug`
|
||||
- **三方 key**(占位,上线前到对应控制台注册「多比比」新应用替换):
|
||||
`build.gradle.kts` 的 `JPUSH_APPKEY`、`push/MiPushBootstrap.kt` 的小米 AppID/Key
|
||||
- **白名单包名**:`util/Constants.kt`(7 个购物 App)
|
||||
|
||||
## 构建与启动
|
||||
|
||||
**环境要求**
|
||||
|
||||
- JDK 17(`compileOptions.sourceCompatibility = VERSION_17`)
|
||||
- Android Studio Hedgehog (2023.1) 及以上
|
||||
- Android SDK Platform 34(targetSdk)/ 36(compileSdk)
|
||||
- 测试设备:Android 8.0 (API 26) 及以上
|
||||
|
||||
**1. 同步项目**
|
||||
|
||||
用 Android Studio 直接 Open 本目录,首次同步会自动生成 `local.properties` 指向你的 Android SDK。
|
||||
|
||||
**2. 构建 APK**
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug # debug 包,开箱即用
|
||||
./gradlew :app:assembleRelease # 需先配置 DUOBIBI_KEYSTORE 等签名属性
|
||||
```
|
||||
|
||||
**3. 安装到设备**
|
||||
|
||||
```bash
|
||||
./gradlew :app:installDebug # 编译并安装到当前连接的设备
|
||||
# 或手动安装已有 apk:
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
**4. 联调本地后端(debug 模式必做)**
|
||||
|
||||
debug 包的 `BASE_URL` 为 `http://127.0.0.1:8766/`(见 `app/build.gradle.kts`),需先启动后端,再把设备的 8766 反向映射到 PC:
|
||||
|
||||
```bash
|
||||
adb reverse tcp:8766 tcp:8766
|
||||
```
|
||||
|
||||
release 包直接走 `https://api.duobibi.com/`,无需此步。
|
||||
|
||||
## 真机调试关键步骤
|
||||
|
||||
安装后,首次打开 App:
|
||||
|
||||
1. 阅读并同意隐私 → 看 4 屏引导
|
||||
2. 进入「我的」Tab,挨个开三项权限:
|
||||
- 无障碍服务 → 系统设置 → 已下载的服务 → 找到「多比比」→ 开启
|
||||
- 悬浮窗 → 系统授权页直接开
|
||||
- 自启动 → 跳厂商自启动管理页(识别失败有 toast 提示手动)
|
||||
3. 「比价」Tab 输入商品名 → 看多平台对比表
|
||||
4. 切到 淘宝 / 京东 / 拼多多 / 抖音 / 美团 / 美团外卖 / 饿了么 任意商品页 →
|
||||
点蓝色「比价」浮窗 → toast「已记 ¥XX(平台)」→ 回 App「记录」Tab 查看,并自动进入擂台对比
|
||||
5. 「雷达」加一条想买的设心理价;「值不值」输入商品+价格看 AI 评分
|
||||
|
||||
## 待办(占坑期遗留)
|
||||
|
||||
- 三方 key(极光 / 小米 AppKey、智谱 LLM)需注册「多比比」新应用后替换占位值
|
||||
- release 签名属性 `DUOBIBI_KEYSTORE` 未配置时 release 无签名(debug 不受影响)
|
||||
- 心跳 `HEARTBEAT_BASE_URL` 仍是局域网占位 IP(`192.168.0.109:8766`),生产化需改真后端 https
|
||||
- 极光一键登录在控制台按"包名+签名 MD5"校验,真机联调登录请用配置了 `DUOBIBI_KEYSTORE` 的 release 包
|
||||
@@ -0,0 +1,139 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.jishisongfu.duobibi"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.jishisongfu.duobibi"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
vectorDrawables { useSupportLibrary = true }
|
||||
|
||||
// 极光一键登录 / 运营商 SDK 含 native 库,只保留主流 ABI
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
|
||||
}
|
||||
|
||||
// 极光 AppKey 通过 manifest 占位符注入(SDK 校验 包名+签名+AppKey 三元组)。
|
||||
// 占位值,需在极光控制台注册"多比比"新应用后替换。
|
||||
manifestPlaceholders["JPUSH_APPKEY"] = "966b451a8d9cfe12d173ea9d"
|
||||
manifestPlaceholders["JPUSH_CHANNEL"] = "developer-default"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
// 多比比自己的 keystore:从 ~/.gradle/gradle.properties 或 -P 注入,不硬编码路径/密码。
|
||||
// 未提供时 release 不签名;debug 走默认 debug.keystore,可直接 assembleDebug。
|
||||
val ksPath = (project.findProperty("DUOBIBI_KEYSTORE") as String?).orEmpty()
|
||||
if (ksPath.isNotEmpty() && file(ksPath).exists()) {
|
||||
storeFile = file(ksPath)
|
||||
storePassword = (project.findProperty("DUOBIBI_STORE_PASSWORD") as String?).orEmpty()
|
||||
keyAlias = (project.findProperty("DUOBIBI_KEY_ALIAS") as String?).orEmpty()
|
||||
keyPassword = (project.findProperty("DUOBIBI_KEY_PASSWORD") as String?).orEmpty()
|
||||
// 4 大国产商店对 V1+V2 双签兼容性最好;V3 由 AGP 默认处理
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
buildConfigField("String", "BASE_URL", "\"https://api.duobibi.com/\"")
|
||||
// 心跳 PoC mock 后端(本机 LAN),独立端口 8766。生产化改真后端 https。
|
||||
buildConfigField("String", "HEARTBEAT_BASE_URL", "\"http://192.168.0.109:8766\"")
|
||||
if ((project.findProperty("DUOBIBI_KEYSTORE") as String?)?.isNotEmpty() == true) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
// 本机联调:debug 走 loopback,需先在宿主执行 `adb reverse tcp:8766 tcp:8766`
|
||||
// 把模拟器内 127.0.0.1:8766 反向映射到宿主 8766。
|
||||
// 为什么不直接用 10.0.2.2:模拟器 IPv6 dual-stack 默认走 IPv6 出站,但模拟器
|
||||
// 内核 IPv6 路由不通,直接 ENETUNREACH (force-stop 后必现)。走 loopback 绕开。
|
||||
// 线上/release 仍为 https://api.duobibi.com/(见上方 release 块)。
|
||||
buildConfigField("String", "BASE_URL", "\"http://127.0.0.1:8766/\"")
|
||||
buildConfigField("String", "HEARTBEAT_BASE_URL", "\"http://192.168.0.109:8766\"")
|
||||
// debug 用默认 debug.keystore,可直接 assembleDebug 出包。
|
||||
// 一键登录在极光控制台按"包名+签名 MD5"校验,真机联调登录请用配置了
|
||||
// DUOBIBI_KEYSTORE 的 release 包(签名 MD5 需与控制台一致)。
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.kotlinx.serialization)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
// 小米推送 SDK 7.9.2 (C 3rd)。aar 内置 manifest 自动 merge 全部组件 + 权限,
|
||||
// 业务方仅需注册自家 PushMessageReceiver 子类(见 push/MiPushReceiver.kt)。
|
||||
implementation(files("libs/MiPush_SDK_Client_7_9_2-C_3rd.aar"))
|
||||
|
||||
// 极光一键登录(jverification 3.0+ 自带 JCore)
|
||||
implementation("cn.jiguang.sdk:jverification:3.1.7")
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,59 @@
|
||||
# Hilt
|
||||
-keep class * extends dagger.hilt.android.lifecycle.HiltViewModel
|
||||
-keep class dagger.hilt.** { *; }
|
||||
-keep class javax.inject.** { *; }
|
||||
|
||||
# Retrofit / OkHttp
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
-keepattributes AnnotationDefault
|
||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
|
||||
-dontwarn javax.annotation.**
|
||||
-dontwarn kotlin.Unit
|
||||
-dontwarn retrofit2.KotlinExtensions
|
||||
-dontwarn retrofit2.KotlinExtensions$*
|
||||
|
||||
# kotlinx.serialization
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
-keep,includedescriptorclasses class com.jishisongfu.duobibi.**$$serializer { *; }
|
||||
-keepclassmembers class com.jishisongfu.duobibi.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.jishisongfu.duobibi.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Room
|
||||
-keep class * extends androidx.room.RoomDatabase
|
||||
-keep @androidx.room.Entity class *
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# Compose
|
||||
-keep class androidx.compose.runtime.** { *; }
|
||||
|
||||
# 小米推送:业务方继承的 PushMessageReceiver 子类不能被 R8 重命名,否则系统反射调不到回调
|
||||
-keep class com.jishisongfu.duobibi.push.** { *; }
|
||||
|
||||
# 心跳:HeartbeatReceiver 系统反射实例化;Payload 走 kotlinx.serialization
|
||||
-keep class com.jishisongfu.duobibi.heartbeat.** { *; }
|
||||
|
||||
# 极光一键登录(JVerification + JCore + 三大运营商 SDK 反射调用)
|
||||
# 来源:极光官方混淆配置。release 包混淆 SDK 内部类会让授权页拉起失败/无回调。
|
||||
-dontwarn cn.jpush.**
|
||||
-keep class cn.jpush.** { *; }
|
||||
-dontwarn cn.jiguang.**
|
||||
-keep class cn.jiguang.** { *; }
|
||||
-dontwarn cn.com.chinatelecom.**
|
||||
-keep class cn.com.chinatelecom.** { *; }
|
||||
-dontwarn com.ct.**
|
||||
-keep class com.ct.** { *; }
|
||||
-dontwarn com.cmic.**
|
||||
-keep class com.cmic.** { *; }
|
||||
-dontwarn com.unicom.**
|
||||
-keep class com.unicom.** { *; }
|
||||
-dontwarn com.sdk.**
|
||||
-keep class com.sdk.** { *; }
|
||||
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 极光一键登录需要:判断网络环境 / 切换蜂窝数据通道 -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".DuobibiApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.duobibi"
|
||||
android:usesCleartextTraffic="false"
|
||||
tools:targetApi="34">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.duobibi">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- 无障碍服务:监听窗口切换,驱动比价浮窗显隐 + 抓取当前页面控件树 -->
|
||||
<service
|
||||
android:name=".service.PriceBotAccessibilityService"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_config" />
|
||||
</service>
|
||||
|
||||
<!-- 比价浮窗前台服务 -->
|
||||
<service
|
||||
android:name=".service.FloatingWindowService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="商品价格识别浮窗" />
|
||||
</service>
|
||||
|
||||
<!-- 小米推送回调 receiver。SDK 内置组件由 aar 的 manifest merge 自动注入,
|
||||
此处只需注册业务方自家的 PushMessageReceiver 子类,监听 3 个固定 action。 -->
|
||||
<receiver
|
||||
android:name=".push.MiPushReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.xiaomi.mipush.RECEIVE_MESSAGE" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.xiaomi.mipush.MESSAGE_ARRIVED" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.xiaomi.mipush.ERROR" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- 心跳定时器接收 AlarmManager 唤起,触发后台上报。exported=false,只接受自家 alarm。 -->
|
||||
<receiver
|
||||
android:name=".heartbeat.HeartbeatReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- 极光一键登录:中国移动授权页(SDK 自带类)
|
||||
SDK 自己 manifest 已声明 unspecified,tools:replace 强制覆盖为竖屏 + dialog 主题 -->
|
||||
<activity
|
||||
android:name="com.cmic.gen.sdk.view.GenLoginAuthActivity"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/ActivityDialogStyle"
|
||||
tools:replace="android:screenOrientation,android:theme" />
|
||||
<!-- 极光一键登录:中国电信授权页 -->
|
||||
<activity
|
||||
android:name="cn.jiguang.verifysdk.CtLoginActivity"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/ActivityDialogStyle"
|
||||
tools:replace="android:screenOrientation,android:theme" />
|
||||
<!-- 联通授权页由 jverification SDK 内置,无需在此声明。 -->
|
||||
|
||||
<!-- 极光 SDK AppKey / Channel,来自 app/build.gradle.kts manifestPlaceholders -->
|
||||
<meta-data
|
||||
android:name="JPUSH_APPKEY"
|
||||
android:value="${JPUSH_APPKEY}" />
|
||||
<meta-data
|
||||
android:name="JPUSH_CHANNEL"
|
||||
android:value="${JPUSH_CHANNEL}" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.jishisongfu.duobibi
|
||||
|
||||
import android.app.Application
|
||||
import com.jishisongfu.duobibi.auth.JgInit
|
||||
import com.jishisongfu.duobibi.heartbeat.AppForegroundProbe
|
||||
import com.jishisongfu.duobibi.heartbeat.HeartbeatScheduler
|
||||
import com.jishisongfu.duobibi.heartbeat.HeartbeatSender
|
||||
import com.jishisongfu.duobibi.push.MiPushBootstrap
|
||||
import com.jishisongfu.duobibi.util.PrivacyPrefs
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltAndroidApp
|
||||
class DuobibiApplication : Application() {
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
// 模拟器 IPv6 路由不通会让 socket connect 抛 ENETUNREACH (双栈 Socket
|
||||
// 默认走 IPv6 path 然后 fallback 失败,debug 联调 10.0.2.2 必踩)。
|
||||
// 强制只走 IPv4 stack。必须在任何 InetAddress/Socket 创建前设;
|
||||
// Application.onCreate 是进程内最早可控时机。
|
||||
// (单独靠 OkHttp Dns filter 不够 — Socket 类本身仍会创建 dual-stack。)
|
||||
System.setProperty("java.net.preferIPv4Stack", "true")
|
||||
System.setProperty("java.net.preferIPv6Addresses", "false")
|
||||
super.onCreate()
|
||||
// 必须最先初始化 —— 心跳里要查"App 在前台吗",靠 ActivityLifecycleCallbacks 维护
|
||||
AppForegroundProbe.init(this)
|
||||
|
||||
// 小米推送 register(隐私 gate 内置)
|
||||
MiPushBootstrap.registerIfReady(this)
|
||||
|
||||
// 极光一键登录 SDK init(隐私 gate 内置)
|
||||
JgInit.initIfReady(this)
|
||||
|
||||
// 心跳调度 + 立刻补一次。仅在隐私同意后启动 —— 上报设备 ID/regId 属设备信息收集,
|
||||
// 合规边界跟推送一致。
|
||||
if (PrivacyPrefs.agreed(this)) {
|
||||
HeartbeatScheduler.start(this)
|
||||
appScope.launch { HeartbeatSender.send(this@DuobibiApplication, trigger = "app_create") }
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.jishisongfu.duobibi
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.jishisongfu.duobibi.auth.JgInit
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
import com.jishisongfu.duobibi.heartbeat.HeartbeatScheduler
|
||||
import com.jishisongfu.duobibi.heartbeat.HeartbeatSender
|
||||
import com.jishisongfu.duobibi.push.MiPushBootstrap
|
||||
import com.jishisongfu.duobibi.ui.nav.AppNavHost
|
||||
import com.jishisongfu.duobibi.ui.onboarding.OnboardingScreen
|
||||
import com.jishisongfu.duobibi.ui.privacy.PrivacyConsentScreen
|
||||
import com.jishisongfu.duobibi.ui.theme.DuobibiTheme
|
||||
import com.jishisongfu.duobibi.util.AlertNotifier
|
||||
import com.jishisongfu.duobibi.util.DemoDataSeeder
|
||||
import com.jishisongfu.duobibi.util.FloatingServiceLauncher
|
||||
import com.jishisongfu.duobibi.util.OnboardingPrefs
|
||||
import com.jishisongfu.duobibi.util.PrivacyPrefs
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Inject lateinit var repository: PriceRepository
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
DuobibiTheme {
|
||||
var agreed by remember { mutableStateOf(PrivacyPrefs.agreed(this)) }
|
||||
var onboarded by remember { mutableStateOf(OnboardingPrefs.completed(this)) }
|
||||
|
||||
val notificationLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* 不论结果都不阻塞流程 */ }
|
||||
|
||||
LaunchedEffect(agreed) {
|
||||
if (agreed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
if (agreed) {
|
||||
ioScope.launch { DemoDataSeeder.seedIfNeeded(applicationContext, repository) }
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
!agreed -> PrivacyConsentScreen(
|
||||
onAgree = {
|
||||
PrivacyPrefs.setAgreed(this, true)
|
||||
agreed = true
|
||||
FloatingServiceLauncher.startIfReady(this)
|
||||
// 首启同意隐私后立刻补 register/init(Application.onCreate 已跑完不会再触发)
|
||||
MiPushBootstrap.registerIfReady(this)
|
||||
JgInit.initIfReady(this)
|
||||
HeartbeatScheduler.start(this)
|
||||
ioScope.launch { HeartbeatSender.send(applicationContext, "privacy_agree") }
|
||||
},
|
||||
onReject = { finish() },
|
||||
)
|
||||
!onboarded -> OnboardingScreen(
|
||||
onFinish = {
|
||||
OnboardingPrefs.setCompleted(this, true)
|
||||
onboarded = true
|
||||
},
|
||||
)
|
||||
else -> AppNavHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
FloatingServiceLauncher.startIfReady(this)
|
||||
if (PrivacyPrefs.agreed(this)) {
|
||||
ioScope.launch { HeartbeatSender.send(applicationContext, "onResume") }
|
||||
// 降价雷达批量查价(MVP 用 onResume 触发代替 WorkManager)。到价的发本地通知。
|
||||
ioScope.launch {
|
||||
try {
|
||||
val triggered = repository.batchCheckAlerts()
|
||||
if (triggered.isNotEmpty()) AlertNotifier.notifyTriggered(applicationContext, triggered)
|
||||
} catch (_: Exception) {
|
||||
// 网络挂时静默,下次 onResume 再试
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.jishisongfu.duobibi.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import cn.jiguang.verifysdk.api.JVerificationInterface
|
||||
import cn.jiguang.verifysdk.api.JVerifyUIConfig
|
||||
import com.jishisongfu.duobibi.BuildConfig
|
||||
import com.jishisongfu.duobibi.util.PrivacyPrefs
|
||||
|
||||
/**
|
||||
* 极光一键登录 SDK 初始化统一入口。两个调用点:
|
||||
* 1) [com.jishisongfu.duobibi.DuobibiApplication.onCreate] — 已同意隐私的老用户冷启动
|
||||
* 2) [com.jishisongfu.duobibi.MainActivity] 隐私 onAgree 回调 — 首次安装同意
|
||||
*
|
||||
* 隐私 gate 跟推送/心跳一致。授权页 UI 自定义为多比比蓝青极简风。
|
||||
*/
|
||||
object JgInit {
|
||||
|
||||
private const val TAG = "DUOBIBI-AUTH"
|
||||
|
||||
// 多比比品牌色(蓝青)
|
||||
private const val TEAL = 0xFF0EA5A4.toInt()
|
||||
private const val INK = 0xFF1A1A1A.toInt()
|
||||
private const val GRAY = 0xFF666666.toInt()
|
||||
private const val BG = 0xFFFAFAFA.toInt()
|
||||
|
||||
fun initIfReady(context: Context) {
|
||||
if (!PrivacyPrefs.agreed(context)) {
|
||||
Log.i(TAG, "JG.init skipped: privacy not agreed yet")
|
||||
return
|
||||
}
|
||||
if (JVerificationInterface.isInitSuccess()) {
|
||||
Log.i(TAG, "JG.init skipped: already initialized")
|
||||
applyUiConfig()
|
||||
return
|
||||
}
|
||||
JVerificationInterface.setDebugMode(BuildConfig.DEBUG)
|
||||
JVerificationInterface.init(context.applicationContext) { code, result ->
|
||||
Log.i(TAG, "JG.init code=$code result=$result")
|
||||
if (code == 8000) applyUiConfig()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权页 UI 自定义(蓝青极简风),跟 App 主题一致。
|
||||
*
|
||||
* **public 而非 private**:SDK 每次 loginAuth 完成/取消后会清空 UIConfig 缓存,
|
||||
* 所以 OneClickLogin 在每次 loginAuth 前都补一次 [applyUiConfig],保证 UI 不回退默认样式。
|
||||
*/
|
||||
fun applyUiConfig() {
|
||||
val cfg = JVerifyUIConfig.Builder()
|
||||
.setAuthBGImgPath("jverify_bg")
|
||||
.setStatusBarColorWithNav(true)
|
||||
.setStatusBarDarkMode(true)
|
||||
.setVirtualButtonTransparent(true)
|
||||
.setNavColor(BG)
|
||||
.setNavText("")
|
||||
.setNavReturnImgPath("jverify_back")
|
||||
.setLogoImgPath("jverify_logo")
|
||||
.setLogoWidth(64)
|
||||
.setLogoHeight(64)
|
||||
.setLogoOffsetY(60)
|
||||
.setNumberColor(INK)
|
||||
.setNumberSize(28)
|
||||
.setNumFieldOffsetY(160)
|
||||
.setSloganTextColor(GRAY)
|
||||
.setSloganOffsetY(215)
|
||||
.setSloganTextSize(12)
|
||||
.setLogBtnText("一键绑定本机号码")
|
||||
.setLogBtnTextColor(0xFFFFFFFF.toInt())
|
||||
.setLogBtnImgPath("jverify_login_btn")
|
||||
.setLogBtnHeight(52)
|
||||
.setLogBtnTextSize(16)
|
||||
.setLogBtnOffsetY(275)
|
||||
.setAppPrivacyOne("多比比隐私政策", "https://duobibi.com/privacy/duobibi.html")
|
||||
.setAppPrivacyColor(GRAY, TEAL)
|
||||
.setPrivacyText("我已阅读并同意", "并授权本机号码用于注册和登录")
|
||||
.setPrivacyTextSize(11)
|
||||
.setPrivacyState(false)
|
||||
.setPrivacyCheckboxHidden(false)
|
||||
.setPrivacyCheckboxSize(22)
|
||||
.setPrivacyCheckboxInCenter(true)
|
||||
.setPrivacyCheckboxMargin(0, 0, 6, 0)
|
||||
.setPrivacyMarginB(24)
|
||||
.setPrivacyNavColor(BG)
|
||||
.setPrivacyNavTitleTextColor(INK)
|
||||
// 用户没勾 checkbox 直接点登录时弹二次确认对话框(替代 SDK 默认丑 toast)。
|
||||
// 注意 SDK 方法名大小写 bug:setPrivacyCheckDialoglogBtnTextColor 的 log 是小写 l。
|
||||
.enablePrivacyCheckDialog(true)
|
||||
.setPrivacyCheckDialogTitleText("温馨提示")
|
||||
.setPrivacyCheckDialogTitleTextColor(INK)
|
||||
.setPrivacyCheckDialogTitleTextSize(16)
|
||||
.setPrivacyCheckDialogContentTextSize(13)
|
||||
.setPrivacyCheckDialogContentTextGravity(android.view.Gravity.CENTER)
|
||||
.setPrivacyCheckDialogLogBtnText("同意并继续")
|
||||
.setPrivacyCheckDialogLogBtnImgPath("jverify_login_btn")
|
||||
.setPrivacyCheckDialoglogBtnTextColor(0xFFFFFFFF.toInt())
|
||||
.setPrivacyCheckDialogLogBtnHeight(44)
|
||||
.setPrivacyCheckDialogLogBtnMarginL(20)
|
||||
.setPrivacyCheckDialogLogBtnMarginR(20)
|
||||
.setPrivacyCheckDialogLogBtnMarginT(16)
|
||||
.setPrivacyCheckDialogLogBtnMarginB(20)
|
||||
.build()
|
||||
|
||||
JVerificationInterface.setCustomUIWithConfig(cfg)
|
||||
Log.i(TAG, "JG UI config applied")
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.jishisongfu.duobibi.auth
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import cn.jiguang.verifysdk.api.JVerificationInterface
|
||||
import cn.jiguang.verifysdk.api.LoginSettings
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* 一键登录的 suspend 包装。三个核心步骤:
|
||||
* 1) [checkEnable] 判断当前网络环境是否支持一键登录(蜂窝数据 + 三大运营商之一)
|
||||
* 2) [preLogin] 预取号:跟运营商建联 + 拿临时令牌,有效期 10~30 分钟,
|
||||
* 缓存命中可显著加速 loginAuth 的授权页拉起
|
||||
* 3) [loginAuth] 拉起授权页,用户点同意 → 拿 loginToken,上传给后端换手机号
|
||||
*
|
||||
* 错误码:
|
||||
* - preLogin 7000=成功, 其他失败(常见 2002 蜂窝不可用 / 2003 取号超时)
|
||||
* - loginAuth 6000=成功(content=loginToken), 6001=失败, 6002=用户取消, 其他见文档
|
||||
*/
|
||||
object OneClickLogin {
|
||||
|
||||
private const val TAG = "DUOBIBI-AUTH"
|
||||
|
||||
fun checkEnable(context: Context): Boolean =
|
||||
JVerificationInterface.checkVerifyEnable(context)
|
||||
|
||||
suspend fun preLogin(context: Context, timeoutMs: Int = 5_000): Result<Unit> =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
if (!JVerificationInterface.isInitSuccess()) {
|
||||
cont.resume(Result.failure(IllegalStateException("JG SDK 未初始化")))
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
// 3.1.7 的 PreLoginListener.onResult 是 3 参:(code, content, operatorReturn)
|
||||
// 显式 label cb 避免跟外层 suspend fun preLogin 同名冲突
|
||||
JVerificationInterface.preLogin(context, timeoutMs) cb@ { code, content, _ ->
|
||||
Log.i(TAG, "preLogin code=$code content=$content")
|
||||
// 协程已被 cancel(用户切后台/退页) 或 SDK 重复回调时,跳过 resume 防 IllegalStateException
|
||||
if (!cont.isActive) return@cb
|
||||
if (code == 7000) cont.resume(Result.success(Unit))
|
||||
else cont.resume(Result.failure(RuntimeException("preLogin [$code] $content")))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loginAuth(activity: Activity, timeoutMs: Int = 15_000): Result<AuthOk> =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
// SDK 每次 loginAuth 完成/取消后会清空 UIConfig 缓存,导致下次回到默认 UI
|
||||
// (现象:首次绑定自定义样式 → 解绑再绑回到 SDK 默认丑界面)。每次 loginAuth
|
||||
// 前补一次 set,保证 UI 永远是我们配置的样式。
|
||||
JgInit.applyUiConfig()
|
||||
|
||||
val settings = LoginSettings().apply {
|
||||
setAutoFinish(true)
|
||||
setTimeout(timeoutMs)
|
||||
}
|
||||
JVerificationInterface.loginAuth(activity, settings) cb@ { code, content, operator, _ ->
|
||||
Log.i(TAG, "loginAuth code=$code operator=$operator")
|
||||
// 协程已被 cancel 或 SDK 重复回调时,跳过 resume 防 IllegalStateException
|
||||
if (!cont.isActive) return@cb
|
||||
if (code == 6000) {
|
||||
cont.resume(Result.success(AuthOk(loginToken = content, operator = operator ?: "")))
|
||||
} else {
|
||||
cont.resume(Result.failure(RuntimeException("loginAuth [$code] $content")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AuthOk(val loginToken: String, val operator: String)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.jishisongfu.duobibi.auth
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.data.api.AuthApi
|
||||
import com.jishisongfu.duobibi.data.api.JverifyLoginRequest
|
||||
import com.jishisongfu.duobibi.util.AuthPrefs
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface BindPhoneState {
|
||||
data object Idle : BindPhoneState
|
||||
data class Working(val step: String) : BindPhoneState
|
||||
data class Success(val phone: String) : BindPhoneState
|
||||
data class Failed(val reason: String) : BindPhoneState
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键登录 → 绑定手机号 的状态机:
|
||||
* checkEnable → preLogin → loginAuth → 后端 jverifyLogin → 持久化手机号
|
||||
*
|
||||
* 多比比 用账号占坑(后续做 CPS 归因 / 多设备),绑定成功只持久化 phone,不做云同步。
|
||||
* 任一步失败立刻进 Failed,带 reason 给 UI 显示。
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PhoneBindViewModel @Inject constructor(
|
||||
private val authApi: AuthApi,
|
||||
@ApplicationContext private val appCtx: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<BindPhoneState>(BindPhoneState.Idle)
|
||||
val state: StateFlow<BindPhoneState> = _state.asStateFlow()
|
||||
|
||||
fun bind(activity: Activity) {
|
||||
// 防重入:Working 状态下忽略新调用,避免连点触发并发 SDK 调用 + 运营商限频
|
||||
if (_state.value !is BindPhoneState.Idle) return
|
||||
viewModelScope.launch {
|
||||
_state.value = BindPhoneState.Working("检查网络环境...")
|
||||
if (!OneClickLogin.checkEnable(activity)) {
|
||||
_state.value = BindPhoneState.Failed(
|
||||
"当前网络不支持一键登录。请打开蜂窝数据(WiFi 下需双通道)。",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
_state.value = BindPhoneState.Working("正在准备登录...")
|
||||
val pre = OneClickLogin.preLogin(activity)
|
||||
if (pre.isFailure) {
|
||||
_state.value = BindPhoneState.Failed(
|
||||
"预取号失败:${pre.exceptionOrNull()?.message ?: "未知错误"}",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
_state.value = BindPhoneState.Working("等待运营商授权...")
|
||||
val auth = OneClickLogin.loginAuth(activity)
|
||||
if (auth.isFailure) {
|
||||
_state.value = BindPhoneState.Failed(
|
||||
"授权失败:${auth.exceptionOrNull()?.message ?: "未知错误"}",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val token = auth.getOrThrow()
|
||||
|
||||
_state.value = BindPhoneState.Working("验证手机号...")
|
||||
try {
|
||||
val resp = authApi.jverifyLogin(
|
||||
JverifyLoginRequest(loginToken = token.loginToken, operator = token.operator),
|
||||
)
|
||||
AuthPrefs.setBound(appCtx, resp.phone)
|
||||
_state.value = BindPhoneState.Success(resp.phone)
|
||||
} catch (e: Exception) {
|
||||
_state.value = BindPhoneState.Failed("后端验证失败:${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
_state.value = BindPhoneState.Idle
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.jishisongfu.duobibi.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* 比价擂台 API。后端 `POST /api/v1/arena-quote`:输入商品名 → LLM 估各平台到手价 + 市场常见价。
|
||||
* 复用 [`@Named("llm")` 长超时 Retrofit](../../di/NetworkModule.kt)。
|
||||
*/
|
||||
interface ArenaApi {
|
||||
@POST("api/v1/arena-quote")
|
||||
suspend fun arenaQuote(@Body req: ArenaQuoteRequest): ArenaQuoteResponse
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ArenaQuoteRequest(
|
||||
val title: String,
|
||||
/** 不传则后端用默认 4 大电商 */
|
||||
val platforms: List<String>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArenaPlatformQuote(
|
||||
val platform: String,
|
||||
val price: Double,
|
||||
val note: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArenaQuoteResponse(
|
||||
val title: String,
|
||||
@SerialName("typical_price") val typicalPrice: Double,
|
||||
val quotes: List<ArenaPlatformQuote> = emptyList(),
|
||||
@SerialName("lowest_platform") val lowestPlatform: String? = null,
|
||||
)
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.jishisongfu.duobibi.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
@Serializable
|
||||
data class JverifyLoginRequest(
|
||||
@SerialName("login_token") val loginToken: String,
|
||||
val operator: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class JverifyLoginResponse(
|
||||
val phone: String,
|
||||
)
|
||||
|
||||
interface AuthApi {
|
||||
|
||||
/**
|
||||
* 用客户端拿到的 loginToken 去后端换明文手机号。
|
||||
* 后端调极光 loginTokenVerify REST → RSA 解密 → 返回手机号。
|
||||
*/
|
||||
@POST("api/v1/auth/jverify-login")
|
||||
suspend fun jverifyLogin(@Body req: JverifyLoginRequest): JverifyLoginResponse
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.jishisongfu.duobibi.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
||||
/**
|
||||
* OCR 截图记账 API。
|
||||
*
|
||||
* 后端复用 iOS 端已有 `POST /api/v1/parse-image`(`duobibi-server/app/api/parse_image.py`),
|
||||
* cluster_id 为字符串(为兼容 iOS UUID)。Android 端用 Long,客户端发送时 `id.toString()`,
|
||||
* 接收时 `String.toLongOrNull()` 转回。
|
||||
*
|
||||
* 请求是 multipart/form-data:
|
||||
* - image: 截图字节(JPEG,客户端已压短边 1024,< 5MB)
|
||||
* - clusters: JSON 字符串 [{"id":"<str>","title":"..."}, ...]
|
||||
* - package_name: Android 端不传(图片本身不带 packageName 上下文,后端从图片识别 source_app)
|
||||
*/
|
||||
interface OcrApi {
|
||||
|
||||
@Multipart
|
||||
@POST("api/v1/parse-image")
|
||||
suspend fun parseImage(
|
||||
@Part image: MultipartBody.Part,
|
||||
@Part("clusters") clusters: RequestBody,
|
||||
): ParseImageResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 ParseStrResponse 的客户端镜像。cluster_id 是字符串(后端为兼容 iOS UUID 设计)。
|
||||
*/
|
||||
@Serializable
|
||||
data class ParseImageResponse(
|
||||
val title: String,
|
||||
val price: Double,
|
||||
@SerialName("source_app") val sourceApp: String,
|
||||
@SerialName("cluster_id") val clusterIdStr: String? = null,
|
||||
@SerialName("typical_price") val typicalPrice: Double,
|
||||
)
|
||||
|
||||
/**
|
||||
* OCR multipart 请求里 `clusters` 字段用的 DTO。后端 `ClusterDtoStr` 期望 `id` 是字符串
|
||||
* (iOS UUID),Android 端把 Long id `toString()` 装进来,响应端再 `toLongOrNull()` 转回。
|
||||
*/
|
||||
@Serializable
|
||||
data class ClusterDtoStr(
|
||||
val id: String,
|
||||
val title: String,
|
||||
)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.jishisongfu.duobibi.data.api
|
||||
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface ParseApi {
|
||||
|
||||
@POST("api/v1/parse")
|
||||
suspend fun parse(@Body req: ParseRequest): ParseResponse
|
||||
|
||||
@POST("api/v1/quick-quote")
|
||||
suspend fun quickQuote(@Body req: QuickQuoteRequest): QuickQuoteResponse
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.jishisongfu.duobibi.data.api
|
||||
|
||||
import com.jishisongfu.duobibi.floating.NodeDto
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ClusterDto(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ParseRequest(
|
||||
@SerialName("package_name") val packageName: String,
|
||||
val tree: NodeDto? = null,
|
||||
val clusters: List<ClusterDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ParseResponse(
|
||||
val title: String,
|
||||
val price: Double,
|
||||
@SerialName("source_app") val sourceApp: String,
|
||||
@SerialName("cluster_id") val clusterId: Long? = null,
|
||||
@SerialName("typical_price") val typicalPrice: Double? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QuickQuoteRequest(
|
||||
val title: String,
|
||||
val clusters: List<ClusterDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class QuickQuoteResponse(
|
||||
val title: String,
|
||||
@SerialName("typical_price") val typicalPrice: Double,
|
||||
@SerialName("cluster_id") val clusterId: Long? = null,
|
||||
)
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.jishisongfu.duobibi.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* 价保哨兵价格查询 API。
|
||||
*
|
||||
* 后端 `POST /api/v1/track-price` 是 LLM mock 实现(没真实平台价 API),
|
||||
* 给商品标题 + 平台 + 购买价 → 返回 LLM 估的当前价 + 哈希抖动得到的趋势/差价。
|
||||
*
|
||||
* 调用频率:WorkManager 每天 1 次后台批量调,N 条监控就调 N 次。
|
||||
*
|
||||
* 复用 OCR 那套 [`@Named("ocr")` Retrofit](../../di/NetworkModule.kt) — LLM 调用
|
||||
* 也可能 5-10s,业务默认 readTimeout=10s 容易超时。
|
||||
*/
|
||||
interface ProtectApi {
|
||||
|
||||
@POST("api/v1/track-price")
|
||||
suspend fun trackPrice(@Body req: TrackPriceRequest): TrackPriceResponse
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TrackPriceRequest(
|
||||
val platform: String,
|
||||
@SerialName("product_title") val productTitle: String,
|
||||
@SerialName("purchase_price") val purchasePrice: Double,
|
||||
/** 秒级 epoch,后端转"距今 N 天"给 LLM。客户端 Room 存的是 ms,在 Repository 层 /1000 */
|
||||
@SerialName("purchase_at") val purchaseAt: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TrackPriceResponse(
|
||||
/** 抖动后的最终输出 — 客户端按这个判断价保 */
|
||||
@SerialName("current_price") val currentPrice: Double,
|
||||
/** LLM 估的"日常常见价",抖动前的锚 — 客户端可显示给用户(可选)*/
|
||||
@SerialName("base_price") val basePrice: Double,
|
||||
/** "down" / "flat" / "up" */
|
||||
val trend: String,
|
||||
/** purchasePrice - currentPrice;> 0 = 可申请价保 */
|
||||
val savings: Double,
|
||||
/** 服务端响应生成时间(秒级 epoch)*/
|
||||
@SerialName("checked_at") val checkedAt: Long,
|
||||
)
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.jishisongfu.duobibi.data.api
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* AI 值不值得买 API。后端 `POST /api/v1/worth-buy`:输入商品 + 当前价 → 评分 + 建议。
|
||||
* 复用 [`@Named("llm")` 长超时 Retrofit](../../di/NetworkModule.kt)。
|
||||
*/
|
||||
interface WorthBuyApi {
|
||||
@POST("api/v1/worth-buy")
|
||||
suspend fun worthBuy(@Body req: WorthBuyRequest): WorthBuyResponse
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class WorthBuyRequest(
|
||||
val title: String,
|
||||
val price: Double,
|
||||
val platform: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorthBuyResponse(
|
||||
/** 0-100,越高越值得现在买 */
|
||||
val score: Int,
|
||||
/** "buy" / "wait" / "neutral" */
|
||||
val verdict: String,
|
||||
val headline: String,
|
||||
val reasons: List<String> = emptyList(),
|
||||
@SerialName("typical_price") val typicalPrice: Double,
|
||||
@SerialName("best_time") val bestTime: String = "",
|
||||
)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.jishisongfu.duobibi.data.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
/**
|
||||
* 多比比 全新数据库 —— 单一 schema version 1,无历史迁移(新 App 无既有安装)。
|
||||
* 三张表:商品簇 / 价格记录 / 降价提醒。
|
||||
*/
|
||||
@Database(
|
||||
entities = [PriceRecord::class, Cluster::class, PriceAlert::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun priceRecordDao(): PriceRecordDao
|
||||
abstract fun clusterDao(): ClusterDao
|
||||
abstract fun priceAlertDao(): PriceAlertDao
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.jishisongfu.duobibi.data.db
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* 商品簇 — 一组同款商品的代表。
|
||||
* title 取首次记录该簇时 LLM 提取的标题(后续命中该簇的记录可能 title 略有差异,但簇代表保持稳定)。
|
||||
*/
|
||||
@Entity(tableName = "cluster")
|
||||
data class Cluster(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val title: String,
|
||||
val createdAt: Long,
|
||||
/** LLM 首次创建该簇时给出的"市场常见价";旧簇(v2 数据)迁移后为 null。 */
|
||||
val typicalPrice: Double? = null,
|
||||
/** 首启注入的示例数据标记;设置页"清除示例数据"按此字段批量删除 */
|
||||
val isDemo: Boolean = false,
|
||||
)
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.jishisongfu.duobibi.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ClusterDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insert(c: Cluster): Long
|
||||
|
||||
@Query("UPDATE cluster SET title = :title WHERE id = :id")
|
||||
suspend fun rename(id: Long, title: String)
|
||||
|
||||
@Query("SELECT * FROM cluster ORDER BY createdAt DESC")
|
||||
suspend fun getAll(): List<Cluster>
|
||||
|
||||
@Query("SELECT * FROM cluster ORDER BY createdAt DESC")
|
||||
fun observeAll(): Flow<List<Cluster>>
|
||||
|
||||
@Query("SELECT * FROM cluster WHERE id = :id")
|
||||
suspend fun byId(id: Long): Cluster?
|
||||
|
||||
@Query("DELETE FROM cluster WHERE id = :id")
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
@Query("DELETE FROM cluster")
|
||||
suspend fun deleteAll()
|
||||
|
||||
/** 清除"示例数据"占审用条目。仅删 isDemo=1 的,真实用户数据不受影响。 */
|
||||
@Query("DELETE FROM cluster WHERE isDemo = 1")
|
||||
suspend fun deleteDemo()
|
||||
|
||||
/** "用户已有真实数据吗?"判定。给 DemoDataSeeder 防污染老用户用。 */
|
||||
@Query("SELECT COUNT(*) FROM cluster WHERE isDemo = 0")
|
||||
suspend fun countReal(): Int
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.jishisongfu.duobibi.data.db
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* 降价提醒雷达的监控对象。统一承载两种场景(把旧"心愿单 + 价保"合并为一个模型):
|
||||
* - WANT (想买的):设一个心理价 [targetPrice],后台查价跌到 ≤ 心理价时提醒"降到心理价了"
|
||||
* - BOUGHT(已买的):记下 [purchasePrice],后台查价跌破买价时提醒"可申请价保"
|
||||
*
|
||||
* 后台查价复用后端 `/api/v1/track-price`(LLM 估当前价 + 哈希抖动)。
|
||||
* track-price 的 savings = anchor - current:WANT 用 targetPrice 当 anchor,BOUGHT 用 purchasePrice。
|
||||
*/
|
||||
@Entity(tableName = "price_alert")
|
||||
data class PriceAlert(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val title: String,
|
||||
/** 平台中文名;WANT 可不填(任意平台到价都提醒)*/
|
||||
val platform: String? = null,
|
||||
/** [AlertMode] */
|
||||
val mode: String = AlertMode.WANT,
|
||||
/** WANT:心理价 */
|
||||
val targetPrice: Double? = null,
|
||||
/** BOUGHT:购买价 */
|
||||
val purchasePrice: Double? = null,
|
||||
/** BOUGHT:购买时间(ms)*/
|
||||
val purchaseAt: Long? = null,
|
||||
val lastCheckedAt: Long? = null,
|
||||
val lastCurrentPrice: Double? = null,
|
||||
/** "down" / "flat" / "up" */
|
||||
val lastTrend: String? = null,
|
||||
/** anchor - lastCurrentPrice;> 0 = 已到价 / 可申请 */
|
||||
val lastSavings: Double? = null,
|
||||
/** [AlertStatus] */
|
||||
val status: String = AlertStatus.ACTIVE,
|
||||
val createdAt: Long,
|
||||
/** 首启注入的示例数据标记;清除示例数据时按此字段批量删 */
|
||||
val isDemo: Boolean = false,
|
||||
)
|
||||
|
||||
object AlertMode {
|
||||
const val WANT = "want" // 想买的
|
||||
const val BOUGHT = "bought" // 已买的
|
||||
}
|
||||
|
||||
object AlertStatus {
|
||||
const val ACTIVE = "active" // 监控中
|
||||
const val STOPPED = "stopped" // 用户主动停止
|
||||
const val DONE = "done" // 已买到 / 已申请价保,归档
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.jishisongfu.duobibi.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface PriceAlertDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insert(item: PriceAlert): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(item: PriceAlert)
|
||||
|
||||
@Query("SELECT * FROM price_alert ORDER BY createdAt DESC")
|
||||
fun observeAll(): Flow<List<PriceAlert>>
|
||||
|
||||
@Query("SELECT * FROM price_alert WHERE mode = :mode ORDER BY createdAt DESC")
|
||||
fun observeByMode(mode: String): Flow<List<PriceAlert>>
|
||||
|
||||
/** 后台批量查价只跑 active 的 */
|
||||
@Query("SELECT * FROM price_alert WHERE status = 'active' ORDER BY createdAt DESC")
|
||||
suspend fun getActive(): List<PriceAlert>
|
||||
|
||||
@Query("SELECT * FROM price_alert WHERE id = :id")
|
||||
suspend fun byId(id: Long): PriceAlert?
|
||||
|
||||
@Query("DELETE FROM price_alert WHERE id = :id")
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
@Query("UPDATE price_alert SET status = :status WHERE id = :id")
|
||||
suspend fun setStatus(id: Long, status: String)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM price_alert WHERE status = 'active'")
|
||||
fun observeActiveCount(): Flow<Int>
|
||||
|
||||
/** active 且已到价(lastSavings>0)条数 — 雷达 Tab 角标 / 首屏提示 */
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM price_alert
|
||||
WHERE status = 'active' AND lastSavings IS NOT NULL AND lastSavings > 0
|
||||
""")
|
||||
fun observeReadyCount(): Flow<Int>
|
||||
|
||||
/** 后台查完批量更新差价字段,不动基础字段 */
|
||||
@Query("""
|
||||
UPDATE price_alert
|
||||
SET lastCheckedAt = :ts, lastCurrentPrice = :currentPrice,
|
||||
lastTrend = :trend, lastSavings = :savings
|
||||
WHERE id = :id
|
||||
""")
|
||||
suspend fun updateCheckResult(id: Long, ts: Long, currentPrice: Double, trend: String, savings: Double)
|
||||
|
||||
@Query("DELETE FROM price_alert WHERE isDemo = 1")
|
||||
suspend fun deleteDemo()
|
||||
|
||||
@Query("DELETE FROM price_alert")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.jishisongfu.duobibi.data.db
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "price_record")
|
||||
data class PriceRecord(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val title: String,
|
||||
val price: Double,
|
||||
val sourceApp: String,
|
||||
val createdAt: Long,
|
||||
val clusterId: Long,
|
||||
/** 首启注入的示例数据标记;设置页"清除示例数据"按此字段批量删除 */
|
||||
val isDemo: Boolean = false,
|
||||
)
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.jishisongfu.duobibi.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface PriceRecordDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insert(record: PriceRecord): Long
|
||||
|
||||
@Query("SELECT * FROM price_record ORDER BY createdAt DESC")
|
||||
fun observeAll(): Flow<List<PriceRecord>>
|
||||
|
||||
@Query("SELECT * FROM price_record WHERE title LIKE '%' || :q || '%' ORDER BY createdAt DESC")
|
||||
fun search(q: String): Flow<List<PriceRecord>>
|
||||
|
||||
@Query("SELECT * FROM price_record WHERE id = :id")
|
||||
suspend fun byId(id: Long): PriceRecord?
|
||||
|
||||
@Query("DELETE FROM price_record WHERE id = :id")
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
@Query("UPDATE price_record SET clusterId = :newClusterId WHERE id = :recordId")
|
||||
suspend fun moveRecord(recordId: Long, newClusterId: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM price_record WHERE clusterId = :clusterId")
|
||||
suspend fun countInCluster(clusterId: Long): Int
|
||||
|
||||
@Query("DELETE FROM price_record WHERE clusterId = :clusterId")
|
||||
suspend fun deleteByCluster(clusterId: Long)
|
||||
|
||||
@Query("SELECT * FROM price_record WHERE clusterId = :clusterId ORDER BY createdAt DESC")
|
||||
fun observeByCluster(clusterId: Long): Flow<List<PriceRecord>>
|
||||
|
||||
@Query("SELECT * FROM price_record WHERE clusterId = :clusterId ORDER BY createdAt DESC")
|
||||
suspend fun byCluster(clusterId: Long): List<PriceRecord>
|
||||
|
||||
@Query("SELECT MIN(price) FROM price_record WHERE clusterId = :clusterId")
|
||||
suspend fun minPriceInCluster(clusterId: Long): Double?
|
||||
|
||||
@Query("DELETE FROM price_record")
|
||||
suspend fun deleteAll()
|
||||
|
||||
/** 清除"示例数据"占审用条目。仅删 isDemo=1 的,真实用户数据不受影响。 */
|
||||
@Query("DELETE FROM price_record WHERE isDemo = 1")
|
||||
suspend fun deleteDemo()
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
package com.jishisongfu.duobibi.data.repo
|
||||
|
||||
import com.jishisongfu.duobibi.data.api.ArenaApi
|
||||
import com.jishisongfu.duobibi.data.api.ArenaQuoteRequest
|
||||
import com.jishisongfu.duobibi.data.api.ClusterDto
|
||||
import com.jishisongfu.duobibi.data.api.OcrApi
|
||||
import com.jishisongfu.duobibi.data.api.ParseApi
|
||||
import com.jishisongfu.duobibi.data.api.ParseRequest
|
||||
import com.jishisongfu.duobibi.data.api.ProtectApi
|
||||
import com.jishisongfu.duobibi.data.api.TrackPriceRequest
|
||||
import com.jishisongfu.duobibi.data.api.WorthBuyApi
|
||||
import com.jishisongfu.duobibi.data.api.WorthBuyRequest
|
||||
import com.jishisongfu.duobibi.data.api.WorthBuyResponse
|
||||
import com.jishisongfu.duobibi.data.db.AlertMode
|
||||
import com.jishisongfu.duobibi.data.db.AlertStatus
|
||||
import com.jishisongfu.duobibi.data.db.Cluster
|
||||
import com.jishisongfu.duobibi.data.db.ClusterDao
|
||||
import com.jishisongfu.duobibi.data.db.PriceAlert
|
||||
import com.jishisongfu.duobibi.data.db.PriceAlertDao
|
||||
import com.jishisongfu.duobibi.data.db.PriceRecord
|
||||
import com.jishisongfu.duobibi.data.db.PriceRecordDao
|
||||
import com.jishisongfu.duobibi.floating.NodeDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** 一次记价的结果(浮窗 / OCR / 手动 共用)。 */
|
||||
data class RecordOutcome(
|
||||
val record: PriceRecord,
|
||||
val hitExistingCluster: Boolean,
|
||||
val clusterMinPriceBefore: Double?,
|
||||
val typicalPrice: Double?,
|
||||
/** 本次记价命中并达到目标价的降价雷达条目(用于发本地通知) */
|
||||
val triggeredAlerts: List<AlertHit> = emptyList(),
|
||||
)
|
||||
|
||||
/** 浮窗记价命中"想买的"降价雷达(WANT)时的事件。 */
|
||||
data class AlertHit(
|
||||
val alertId: Long,
|
||||
val title: String,
|
||||
val targetPrice: Double?,
|
||||
val newPrice: Double,
|
||||
val sourceApp: String,
|
||||
val reachedTarget: Boolean,
|
||||
)
|
||||
|
||||
/** 比价擂台一行(一个平台)。 */
|
||||
data class ArenaRow(
|
||||
val platform: String,
|
||||
val price: Double,
|
||||
val note: String,
|
||||
/** true=本机真实记录价(覆盖了 AI 估价),false=AI 估价 */
|
||||
val isLocal: Boolean,
|
||||
)
|
||||
|
||||
/** 比价擂台结果:AI 各平台估价 + 本机真实记录覆盖。 */
|
||||
data class ArenaOutcome(
|
||||
val normalizedTitle: String,
|
||||
val typicalPrice: Double,
|
||||
val rows: List<ArenaRow>,
|
||||
val lowestPlatform: String?,
|
||||
/** 本机已记录过的同款簇(可点进详情);null = 还没记过 */
|
||||
val matchedClusterId: Long?,
|
||||
val localMinPrice: Double?,
|
||||
)
|
||||
|
||||
/** 降价雷达批量查价后"本次新触发"的条目(用于发通知)。 */
|
||||
data class AlertTriggered(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val platform: String?,
|
||||
val mode: String,
|
||||
/** anchor:WANT=心理价,BOUGHT=买价 */
|
||||
val anchorPrice: Double,
|
||||
val currentPrice: Double,
|
||||
val savings: Double,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class PriceRepository @Inject constructor(
|
||||
private val api: ParseApi,
|
||||
private val ocrApi: OcrApi,
|
||||
private val protectApi: ProtectApi,
|
||||
private val arenaApi: ArenaApi,
|
||||
private val worthBuyApi: WorthBuyApi,
|
||||
private val recordDao: PriceRecordDao,
|
||||
private val clusterDao: ClusterDao,
|
||||
private val alertDao: PriceAlertDao,
|
||||
) {
|
||||
|
||||
// ========= 浮窗记价 =========
|
||||
|
||||
suspend fun parseAndInsert(packageName: String, tree: NodeDto?): RecordOutcome {
|
||||
val existing = clusterDao.getAll()
|
||||
val resp = api.parse(
|
||||
ParseRequest(
|
||||
packageName = packageName,
|
||||
tree = tree,
|
||||
clusters = existing.map { ClusterDto(it.id, it.title) },
|
||||
)
|
||||
)
|
||||
return insertParsed(
|
||||
existing = existing,
|
||||
title = resp.title,
|
||||
price = resp.price,
|
||||
sourceApp = resp.sourceApp,
|
||||
respClusterId = resp.clusterId,
|
||||
respTypical = resp.typicalPrice,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR 截图记价:复用后端 `/parse-image`(cluster_id 为字符串以兼容 iOS UUID)。
|
||||
* Android 端 Long → String 发送,String → Long? 收回并校验本机存在(防 LLM 编造)。
|
||||
*/
|
||||
suspend fun parseImageAndInsert(
|
||||
image: okhttp3.MultipartBody.Part,
|
||||
clustersJson: okhttp3.RequestBody,
|
||||
): RecordOutcome {
|
||||
val existing = clusterDao.getAll()
|
||||
val resp = ocrApi.parseImage(image = image, clusters = clustersJson)
|
||||
return insertParsed(
|
||||
existing = existing,
|
||||
title = resp.title,
|
||||
price = resp.price,
|
||||
sourceApp = resp.sourceApp,
|
||||
respClusterId = resp.clusterIdStr?.toLongOrNull(),
|
||||
respTypical = resp.typicalPrice,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun insertParsed(
|
||||
existing: List<Cluster>,
|
||||
title: String,
|
||||
price: Double,
|
||||
sourceApp: String,
|
||||
respClusterId: Long?,
|
||||
respTypical: Double?,
|
||||
): RecordOutcome {
|
||||
val now = System.currentTimeMillis()
|
||||
val targetClusterId: Long
|
||||
val hitExisting: Boolean
|
||||
val minBefore: Double?
|
||||
|
||||
if (respClusterId != null && existing.any { it.id == respClusterId }) {
|
||||
targetClusterId = respClusterId
|
||||
hitExisting = true
|
||||
minBefore = recordDao.minPriceInCluster(respClusterId)
|
||||
} else {
|
||||
targetClusterId = clusterDao.insert(
|
||||
Cluster(title = title, createdAt = now, typicalPrice = respTypical)
|
||||
)
|
||||
hitExisting = false
|
||||
minBefore = null
|
||||
}
|
||||
|
||||
val recordId = recordDao.insert(
|
||||
PriceRecord(
|
||||
title = title, price = price, sourceApp = sourceApp,
|
||||
createdAt = now, clusterId = targetClusterId,
|
||||
)
|
||||
)
|
||||
val record = recordDao.byId(recordId)!!
|
||||
val typical = if (hitExisting) clusterDao.byId(targetClusterId)?.typicalPrice else respTypical
|
||||
val triggered = checkAlertHits(record, now)
|
||||
return RecordOutcome(record, hitExisting, minBefore, typical, triggered)
|
||||
}
|
||||
|
||||
/**
|
||||
* 浮窗记到价后,扫一遍"想买的"(WANT)降价雷达:标题模糊匹配且 ≤ 心理价 → 触发通知,
|
||||
* 并顺手把该雷达的当前价/差价更新。
|
||||
*/
|
||||
private suspend fun checkAlertHits(record: PriceRecord, now: Long): List<AlertHit> {
|
||||
val wants = alertDao.getActive().filter { it.mode == AlertMode.WANT }
|
||||
if (wants.isEmpty()) return emptyList()
|
||||
val hits = mutableListOf<AlertHit>()
|
||||
wants.forEach { a ->
|
||||
if (!titleFuzzyMatches(a.title, record.title)) return@forEach
|
||||
val reached = a.targetPrice == null || record.price <= a.targetPrice
|
||||
val anchor = a.targetPrice ?: record.price
|
||||
alertDao.updateCheckResult(a.id, now, record.price, "down", anchor - record.price)
|
||||
if (reached) {
|
||||
hits += AlertHit(a.id, a.title, a.targetPrice, record.price, record.sourceApp, true)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
/** 单独插入一条手动记录。clusterId 为 null 时自动新建簇。 */
|
||||
suspend fun insertManualRecord(
|
||||
title: String,
|
||||
price: Double,
|
||||
sourceApp: String,
|
||||
clusterId: Long? = null,
|
||||
): RecordOutcome {
|
||||
val now = System.currentTimeMillis()
|
||||
val targetCluster: Long
|
||||
val hitExisting: Boolean
|
||||
val minBefore: Double?
|
||||
if (clusterId != null) {
|
||||
targetCluster = clusterId
|
||||
hitExisting = true
|
||||
minBefore = recordDao.minPriceInCluster(clusterId)
|
||||
} else {
|
||||
targetCluster = clusterDao.insert(Cluster(title = title, createdAt = now))
|
||||
hitExisting = false
|
||||
minBefore = null
|
||||
}
|
||||
val recordId = recordDao.insert(
|
||||
PriceRecord(title = title, price = price, sourceApp = sourceApp, createdAt = now, clusterId = targetCluster)
|
||||
)
|
||||
val record = recordDao.byId(recordId)!!
|
||||
val typical = clusterDao.byId(targetCluster)?.typicalPrice
|
||||
return RecordOutcome(record, hitExisting, minBefore, typical, checkAlertHits(record, now))
|
||||
}
|
||||
|
||||
// ========= 比价擂台 =========
|
||||
|
||||
/** [platforms] 为 null 时后端用默认 4 大电商;品类页选品类后传该品类对应的平台集合。 */
|
||||
suspend fun arenaQuote(title: String, platforms: List<String>? = null): ArenaOutcome {
|
||||
val resp = arenaApi.arenaQuote(ArenaQuoteRequest(title, platforms))
|
||||
val clusters = clusterDao.getAll()
|
||||
val matched = clusters.firstOrNull {
|
||||
titleFuzzyMatches(resp.title, it.title) || titleFuzzyMatches(it.title, resp.title)
|
||||
}
|
||||
val localByPlatform: Map<String, Double> = if (matched != null) {
|
||||
recordDao.byCluster(matched.id)
|
||||
.groupBy { it.sourceApp }
|
||||
.mapValues { (_, recs) -> recs.minOf { it.price } }
|
||||
} else emptyMap()
|
||||
|
||||
val rows = resp.quotes.map { q ->
|
||||
val local = localByPlatform[q.platform]
|
||||
if (local != null) ArenaRow(q.platform, local, "本机记录最低", isLocal = true)
|
||||
else ArenaRow(q.platform, q.price, q.note, isLocal = false)
|
||||
}
|
||||
val lowest = rows.minByOrNull { it.price }?.platform
|
||||
return ArenaOutcome(
|
||||
normalizedTitle = resp.title,
|
||||
typicalPrice = resp.typicalPrice,
|
||||
rows = rows,
|
||||
lowestPlatform = lowest,
|
||||
matchedClusterId = matched?.id,
|
||||
localMinPrice = localByPlatform.values.minOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
// ========= AI 值不值得买 =========
|
||||
|
||||
suspend fun worthBuy(title: String, price: Double, platform: String? = null): WorthBuyResponse =
|
||||
worthBuyApi.worthBuy(WorthBuyRequest(title = title, price = price, platform = platform))
|
||||
|
||||
// ========= 价格记录 / 商品簇 =========
|
||||
|
||||
fun observeAllRecords(): Flow<List<PriceRecord>> = recordDao.observeAll()
|
||||
fun searchRecords(q: String): Flow<List<PriceRecord>> = recordDao.search(q)
|
||||
suspend fun recordById(id: Long): PriceRecord? = recordDao.byId(id)
|
||||
suspend fun deleteRecord(id: Long) = recordDao.delete(id)
|
||||
fun observeRecordsByCluster(clusterId: Long): Flow<List<PriceRecord>> =
|
||||
recordDao.observeByCluster(clusterId)
|
||||
|
||||
fun observeAllClusters(): Flow<List<Cluster>> = clusterDao.observeAll()
|
||||
suspend fun currentClusters(): List<Cluster> = clusterDao.getAll()
|
||||
suspend fun clusterById(id: Long): Cluster? = clusterDao.byId(id)
|
||||
suspend fun renameCluster(id: Long, title: String) = clusterDao.rename(id, title)
|
||||
|
||||
suspend fun deleteCluster(id: Long) {
|
||||
recordDao.deleteByCluster(id)
|
||||
clusterDao.delete(id)
|
||||
}
|
||||
|
||||
suspend fun moveRecordToCluster(recordId: Long, newClusterId: Long) {
|
||||
val record = recordDao.byId(recordId) ?: return
|
||||
val originalClusterId = record.clusterId
|
||||
recordDao.moveRecord(recordId, newClusterId)
|
||||
if (originalClusterId != newClusterId && recordDao.countInCluster(originalClusterId) == 0) {
|
||||
clusterDao.delete(originalClusterId)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun splitRecordToNewCluster(recordId: Long): Long? {
|
||||
val record = recordDao.byId(recordId) ?: return null
|
||||
val originalClusterId = record.clusterId
|
||||
val newClusterId = clusterDao.insert(Cluster(title = record.title, createdAt = System.currentTimeMillis()))
|
||||
recordDao.moveRecord(recordId, newClusterId)
|
||||
if (recordDao.countInCluster(originalClusterId) == 0) clusterDao.delete(originalClusterId)
|
||||
return newClusterId
|
||||
}
|
||||
|
||||
// ========= 降价提醒雷达 =========
|
||||
|
||||
fun observeAllAlerts(): Flow<List<PriceAlert>> = alertDao.observeAll()
|
||||
fun observeAlertsByMode(mode: String): Flow<List<PriceAlert>> = alertDao.observeByMode(mode)
|
||||
fun observeActiveAlertCount(): Flow<Int> = alertDao.observeActiveCount()
|
||||
fun observeReadyAlertCount(): Flow<Int> = alertDao.observeReadyCount()
|
||||
suspend fun alertById(id: Long): PriceAlert? = alertDao.byId(id)
|
||||
|
||||
suspend fun addWantAlert(title: String, platform: String?, targetPrice: Double?): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
return alertDao.insert(
|
||||
PriceAlert(
|
||||
title = title.trim(),
|
||||
platform = platform?.trim()?.takeIf { it.isNotEmpty() },
|
||||
mode = AlertMode.WANT,
|
||||
targetPrice = targetPrice,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun addBoughtAlert(title: String, platform: String, purchasePrice: Double, purchaseAt: Long): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
return alertDao.insert(
|
||||
PriceAlert(
|
||||
title = title.trim(),
|
||||
platform = platform.trim(),
|
||||
mode = AlertMode.BOUGHT,
|
||||
purchasePrice = purchasePrice,
|
||||
purchaseAt = purchaseAt,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun updateAlert(item: PriceAlert) = alertDao.update(item)
|
||||
suspend fun setAlertStatus(id: Long, status: String) = alertDao.setStatus(id, status)
|
||||
suspend fun deleteAlert(id: Long) = alertDao.delete(id)
|
||||
|
||||
/**
|
||||
* 批量遍历 active 雷达,逐条调后端 track-price 更新当前价 + 差价。
|
||||
* 返回"本次新触发"(savings 从 ≤0 变 >0)的条目给调用方发通知。单条失败跳过。
|
||||
*/
|
||||
suspend fun batchCheckAlerts(): List<AlertTriggered> {
|
||||
val triggered = mutableListOf<AlertTriggered>()
|
||||
val items = alertDao.getActive()
|
||||
for (a in items) {
|
||||
val anchor = when (a.mode) {
|
||||
AlertMode.BOUGHT -> a.purchasePrice ?: 0.0
|
||||
else -> a.targetPrice ?: 0.0
|
||||
}
|
||||
if (anchor <= 0.0) continue // WANT 无心理价时无法判断到价,跳过
|
||||
val prev = a.lastSavings ?: 0.0
|
||||
try {
|
||||
val resp = protectApi.trackPrice(
|
||||
TrackPriceRequest(
|
||||
platform = a.platform ?: "主流电商",
|
||||
productTitle = a.title,
|
||||
purchasePrice = anchor,
|
||||
purchaseAt = (a.purchaseAt ?: a.createdAt) / 1000,
|
||||
)
|
||||
)
|
||||
alertDao.updateCheckResult(a.id, System.currentTimeMillis(), resp.currentPrice, resp.trend, resp.savings)
|
||||
if (resp.savings > 0.0 && prev <= 0.0) {
|
||||
triggered += AlertTriggered(
|
||||
id = a.id, title = a.title, platform = a.platform, mode = a.mode,
|
||||
anchorPrice = anchor, currentPrice = resp.currentPrice, savings = resp.savings,
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 单条失败不影响其他,下次再试
|
||||
}
|
||||
}
|
||||
return triggered
|
||||
}
|
||||
|
||||
// ========= 全清 =========
|
||||
|
||||
suspend fun deleteAll() {
|
||||
recordDao.deleteAll()
|
||||
clusterDao.deleteAll()
|
||||
alertDao.deleteAll()
|
||||
}
|
||||
|
||||
// ========= 示例数据(占审用)=========
|
||||
|
||||
suspend fun hasAnyRealData(): Boolean = clusterDao.countReal() > 0
|
||||
|
||||
suspend fun clearDemoData() {
|
||||
recordDao.deleteDemo()
|
||||
clusterDao.deleteDemo()
|
||||
alertDao.deleteDemo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入示例数据:4 个商品簇(跨平台、价差合理)+ 多条记录(散布近 30 天)+ 3 条降价雷达。
|
||||
* 全部 isDemo=true,清除时按此字段批量删,真实数据不受影响。
|
||||
*/
|
||||
suspend fun seedDemoData() {
|
||||
val now = System.currentTimeMillis()
|
||||
val dayMs = 24L * 3600 * 1000
|
||||
|
||||
val iphoneId = clusterDao.insert(
|
||||
Cluster(title = "iPhone 15 Pro 256GB 原色钛金属", createdAt = now - 26 * dayMs,
|
||||
typicalPrice = 8299.0, isDemo = true)
|
||||
)
|
||||
listOf(Triple(7799.0, "京东", 2), Triple(8099.0, "淘宝", 7),
|
||||
Triple(7888.0, "拼多多", 13), Triple(8299.0, "抖音", 20)).forEach { (p, app, d) ->
|
||||
recordDao.insert(PriceRecord(title = "iPhone 15 Pro 256GB 原色钛金属", price = p,
|
||||
sourceApp = app, createdAt = now - d * dayMs, clusterId = iphoneId, isDemo = true))
|
||||
}
|
||||
|
||||
val dysonId = clusterDao.insert(
|
||||
Cluster(title = "戴森 Dyson HD15 吹风机", createdAt = now - 22 * dayMs,
|
||||
typicalPrice = 2399.0, isDemo = true)
|
||||
)
|
||||
listOf(Triple(2199.0, "京东", 3), Triple(2299.0, "抖音", 9), Triple(2399.0, "淘宝", 17)).forEach { (p, app, d) ->
|
||||
recordDao.insert(PriceRecord(title = "戴森 Dyson HD15 吹风机", price = p,
|
||||
sourceApp = app, createdAt = now - d * dayMs, clusterId = dysonId, isDemo = true))
|
||||
}
|
||||
|
||||
val coffeeId = clusterDao.insert(
|
||||
Cluster(title = "雀巢多趣酷思咖啡胶囊 16 颗", createdAt = now - 18 * dayMs,
|
||||
typicalPrice = 99.0, isDemo = true)
|
||||
)
|
||||
listOf(Triple(79.0, "拼多多", 1), Triple(89.0, "京东", 6), Triple(99.0, "淘宝", 12)).forEach { (p, app, d) ->
|
||||
recordDao.insert(PriceRecord(title = "雀巢多趣酷思咖啡胶囊 16 颗", price = p,
|
||||
sourceApp = app, createdAt = now - d * dayMs, clusterId = coffeeId, isDemo = true))
|
||||
}
|
||||
|
||||
val switchId = clusterDao.insert(
|
||||
Cluster(title = "任天堂 Switch OLED 续航增强版", createdAt = now - 12 * dayMs,
|
||||
typicalPrice = 2249.0, isDemo = true)
|
||||
)
|
||||
listOf(Triple(2099.0, "拼多多", 4), Triple(2199.0, "京东", 8), Triple(2249.0, "抖音", 14)).forEach { (p, app, d) ->
|
||||
recordDao.insert(PriceRecord(title = "任天堂 Switch OLED 续航增强版", price = p,
|
||||
sourceApp = app, createdAt = now - d * dayMs, clusterId = switchId, isDemo = true))
|
||||
}
|
||||
|
||||
// 降价雷达:2 条想买的(WANT) + 1 条已买的(BOUGHT),已买的预填可申请价保
|
||||
alertDao.insert(PriceAlert(
|
||||
title = "iPhone 16 Pro Max 256GB", platform = "京东", mode = AlertMode.WANT,
|
||||
targetPrice = 8888.0, createdAt = now - 8 * dayMs, isDemo = true,
|
||||
))
|
||||
alertDao.insert(PriceAlert(
|
||||
title = "AirPods Pro 2 USB-C", platform = null, mode = AlertMode.WANT,
|
||||
targetPrice = 1399.0, createdAt = now - 5 * dayMs,
|
||||
lastCheckedAt = now - dayMs, lastCurrentPrice = 1499.0, lastTrend = "flat", lastSavings = -100.0,
|
||||
isDemo = true,
|
||||
))
|
||||
alertDao.insert(PriceAlert(
|
||||
title = "戴森 Dyson HD15 吹风机", platform = "京东", mode = AlertMode.BOUGHT,
|
||||
purchasePrice = 2399.0, purchaseAt = now - 6 * dayMs, createdAt = now - 6 * dayMs,
|
||||
lastCheckedAt = now - dayMs, lastCurrentPrice = 2199.0, lastTrend = "down", lastSavings = 200.0,
|
||||
isDemo = true,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简易标题模糊匹配 —— 占坑期最简版,纯本地不调 LLM。
|
||||
* 把标题按空白/标点切 token,过滤太短的(<2),≥60% 的 token 命中即算匹配。
|
||||
*/
|
||||
internal fun titleFuzzyMatches(queryTitle: String, targetTitle: String): Boolean {
|
||||
val tokens = queryTitle.lowercase().split(Regex("[\\s\\p{Punct}]+"))
|
||||
.map { it.trim() }.filter { it.length >= 2 }
|
||||
if (tokens.isEmpty()) return false
|
||||
val target = targetTitle.lowercase()
|
||||
val hits = tokens.count { target.contains(it) }
|
||||
val threshold = maxOf(1, (tokens.size * 0.6).toInt())
|
||||
return hits >= threshold
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.jishisongfu.duobibi.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.jishisongfu.duobibi.data.db.AppDatabase
|
||||
import com.jishisongfu.duobibi.data.db.ClusterDao
|
||||
import com.jishisongfu.duobibi.data.db.PriceAlertDao
|
||||
import com.jishisongfu.duobibi.data.db.PriceRecordDao
|
||||
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
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext ctx: Context): AppDatabase =
|
||||
// 全新 App,单一 schema version 1,无迁移
|
||||
Room.databaseBuilder(ctx, AppDatabase::class.java, "duobibi.db").build()
|
||||
|
||||
@Provides
|
||||
fun providePriceRecordDao(db: AppDatabase): PriceRecordDao = db.priceRecordDao()
|
||||
|
||||
@Provides
|
||||
fun provideClusterDao(db: AppDatabase): ClusterDao = db.clusterDao()
|
||||
|
||||
@Provides
|
||||
fun providePriceAlertDao(db: AppDatabase): PriceAlertDao = db.priceAlertDao()
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package com.jishisongfu.duobibi.di
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.jishisongfu.duobibi.BuildConfig
|
||||
import com.jishisongfu.duobibi.data.api.ArenaApi
|
||||
import com.jishisongfu.duobibi.data.api.AuthApi
|
||||
import com.jishisongfu.duobibi.data.api.OcrApi
|
||||
import com.jishisongfu.duobibi.data.api.ParseApi
|
||||
import com.jishisongfu.duobibi.data.api.ProtectApi
|
||||
import com.jishisongfu.duobibi.data.api.WorthBuyApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Dns
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* 强制只走 IPv4 出站。
|
||||
*
|
||||
* 背景:debug 联调时 baseUrl=10.0.2.2,Android socket 默认双栈,会优先把
|
||||
* IPv4 地址 wrap 成 ::ffff:10.0.2.2 走 IPv6 出站。但模拟器 (尤其新版 AVD)
|
||||
* 的 IPv6 路由常不通,直接报 ENETUNREACH (Network is unreachable),
|
||||
* 表现就是 App 装好后随机一两次能连上,force-stop 重启就全挂。
|
||||
* 过滤掉 IPv6 让 OkHttp 只用 IPv4,模拟器/真机都稳。
|
||||
*
|
||||
* 注:必须放在 NetworkModule object **外面** — Hilt KSP 会 introspect module
|
||||
* 的每个 field 类型,Dns lambda 类型推断会让 KSP 报 NonExistentClass。
|
||||
*/
|
||||
private val ipv4OnlyDns: Dns = object : Dns {
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
val all = Dns.SYSTEM.lookup(hostname)
|
||||
return all.filter { it is Inet4Address }.ifEmpty { all }
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.dns(ipv4OnlyDns)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(json: Json, client: OkHttpClient): Retrofit = Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.BASE_URL)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideParseApi(retrofit: Retrofit): ParseApi = retrofit.create(ParseApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||
|
||||
/**
|
||||
* LLM 类接口(OCR / 比价擂台 / 值不值得买 / 价保查价)端到端可能 5-15s,
|
||||
* 远超默认 readTimeout=10s,统一走这套长超时 Retrofit。
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
@javax.inject.Named("llm")
|
||||
fun provideLlmOkHttp(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(45, TimeUnit.SECONDS)
|
||||
.dns(ipv4OnlyDns)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@javax.inject.Named("llm")
|
||||
fun provideLlmRetrofit(
|
||||
json: Json,
|
||||
@javax.inject.Named("llm") client: OkHttpClient,
|
||||
): Retrofit = Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.BASE_URL)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOcrApi(@javax.inject.Named("llm") retrofit: Retrofit): OcrApi =
|
||||
retrofit.create(OcrApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideProtectApi(@javax.inject.Named("llm") retrofit: Retrofit): ProtectApi =
|
||||
retrofit.create(ProtectApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideArenaApi(@javax.inject.Named("llm") retrofit: Retrofit): ArenaApi =
|
||||
retrofit.create(ArenaApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWorthBuyApi(@javax.inject.Named("llm") retrofit: Retrofit): WorthBuyApi =
|
||||
retrofit.create(WorthBuyApi::class.java)
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.jishisongfu.duobibi.floating
|
||||
|
||||
import com.jishisongfu.duobibi.service.PriceBotAccessibilityService
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* 在两个 Service 之间做最薄的耦合 —— 浮窗服务通过这个 holder 拿到无障碍服务实例,
|
||||
* 调用其 [PriceBotAccessibilityService.captureCurrentTree],并读取最近一次进入前台的包名。
|
||||
*
|
||||
* 同 APK 同进程默认共享类加载器,静态引用直接可用。
|
||||
*/
|
||||
object AccessibilityServiceHolder {
|
||||
|
||||
@Volatile
|
||||
private var ref: WeakReference<PriceBotAccessibilityService>? = null
|
||||
|
||||
@Volatile
|
||||
var lastForegroundPackage: String? = null
|
||||
private set
|
||||
|
||||
fun attach(service: PriceBotAccessibilityService) {
|
||||
ref = WeakReference(service)
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
ref = null
|
||||
}
|
||||
|
||||
fun get(): PriceBotAccessibilityService? = ref?.get()
|
||||
|
||||
fun updateForegroundPackage(pkg: String) {
|
||||
lastForegroundPackage = pkg
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.jishisongfu.duobibi.floating
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import com.jishisongfu.duobibi.util.SettingsPrefs
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* 比价浮窗的视图实体 —— 圆形按钮 + 内置 ProgressBar 切换 Loading 态。支持拖动并记忆位置。
|
||||
* 多比比 蓝青科技风:按钮底色 Teal #0EA5A4。
|
||||
*/
|
||||
class FloatingButtonView(
|
||||
context: Context,
|
||||
private val windowManager: WindowManager,
|
||||
private val params: WindowManager.LayoutParams,
|
||||
private val onClick: () -> Unit,
|
||||
) : FrameLayout(context) {
|
||||
|
||||
private val label: TextView = TextView(context).apply {
|
||||
text = "比价"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
gravity = android.view.Gravity.CENTER
|
||||
}
|
||||
|
||||
private val loading: ProgressBar = ProgressBar(context).apply {
|
||||
isIndeterminate = true
|
||||
visibility = View.GONE
|
||||
}
|
||||
|
||||
private var isDragging = false
|
||||
private var downRawX = 0f
|
||||
private var downRawY = 0f
|
||||
private var downX = 0
|
||||
private var downY = 0
|
||||
|
||||
init {
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(Color.parseColor("#0EA5A4"))
|
||||
setStroke(dp(2), Color.WHITE)
|
||||
}
|
||||
addView(label, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
||||
addView(loading, LayoutParams(dp(28), dp(28), android.view.Gravity.CENTER))
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
isDragging = false
|
||||
downRawX = event.rawX
|
||||
downRawY = event.rawY
|
||||
downX = params.x
|
||||
downY = params.y
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val dx = (event.rawX - downRawX).toInt()
|
||||
val dy = (event.rawY - downRawY).toInt()
|
||||
if (!isDragging && (abs(dx) > TOUCH_SLOP || abs(dy) > TOUCH_SLOP)) {
|
||||
isDragging = true
|
||||
}
|
||||
if (isDragging) {
|
||||
params.x = downX + dx
|
||||
params.y = downY + dy
|
||||
runCatching { windowManager.updateViewLayout(this, params) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (!isDragging) {
|
||||
onClick()
|
||||
} else {
|
||||
SettingsPrefs.savePosition(context, params.x, params.y)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
fun setLoading(loading: Boolean) {
|
||||
post {
|
||||
this.loading.visibility = if (loading) View.VISIBLE else View.GONE
|
||||
this.label.visibility = if (loading) View.GONE else View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun dp(v: Int): Int = (resources.displayMetrics.density * v).toInt()
|
||||
|
||||
companion object {
|
||||
private const val TOUCH_SLOP = 12
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.jishisongfu.duobibi.floating
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class NodeDto(
|
||||
@SerialName("view_id") val viewId: String? = null,
|
||||
val text: String? = null,
|
||||
val desc: String? = null,
|
||||
val children: List<NodeDto> = emptyList(),
|
||||
)
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.jishisongfu.duobibi.floating
|
||||
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
|
||||
object TreeSerializer {
|
||||
|
||||
private const val MAX_NODES = 1500
|
||||
private const val MAX_DEPTH = 25
|
||||
private const val MAX_STR = 200
|
||||
|
||||
fun serialize(root: AccessibilityNodeInfo): NodeDto {
|
||||
var budget = MAX_NODES
|
||||
return convert(root, depth = 0) { budget-- > 0 }
|
||||
}
|
||||
|
||||
private fun convert(
|
||||
node: AccessibilityNodeInfo,
|
||||
depth: Int,
|
||||
consumeBudget: () -> Boolean,
|
||||
): NodeDto {
|
||||
if (!consumeBudget()) return NodeDto()
|
||||
val viewId = node.viewIdResourceName?.substringAfter(":id/", missingDelimiterValue = "")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val text = node.text?.toString()?.take(MAX_STR)?.takeIf { it.isNotBlank() }
|
||||
val desc = node.contentDescription?.toString()?.take(MAX_STR)?.takeIf { it.isNotBlank() }
|
||||
|
||||
val children = mutableListOf<NodeDto>()
|
||||
if (depth < MAX_DEPTH) {
|
||||
for (i in 0 until node.childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
if (!child.isVisibleToUser) {
|
||||
child.recycleSafely()
|
||||
continue
|
||||
}
|
||||
val converted = convert(child, depth + 1, consumeBudget)
|
||||
if (converted.viewId != null || converted.text != null ||
|
||||
converted.desc != null || converted.children.isNotEmpty()
|
||||
) {
|
||||
children.add(converted)
|
||||
}
|
||||
child.recycleSafely()
|
||||
}
|
||||
}
|
||||
|
||||
return NodeDto(viewId = viewId, text = text, desc = desc, children = children)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun AccessibilityNodeInfo.recycleSafely() {
|
||||
// Android API 33 起,recycle() 已被标记为 no-op。这里包一层避免反复改写。
|
||||
try { recycle() } catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.jishisongfu.duobibi.heartbeat
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import com.jishisongfu.duobibi.service.PriceBotAccessibilityService
|
||||
|
||||
/**
|
||||
* 探查"我们的无障碍服务是否仍被系统启用"。
|
||||
*
|
||||
* 注意:这查的是**系统设置开关状态**(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES),
|
||||
* 不是"我们的 service 进程是否还活着"。两者通常一致,但极端情况下可能背离:
|
||||
* - 开关 ON 但 service 进程被杀(OEM 内存压力)→ 系统会很快重启 service,本探查仍 true
|
||||
* - 开关 OFF 但进程暂时没被杀(用户刚关那一瞬)→ onDestroy 触发前 false 已生效
|
||||
*
|
||||
* 心跳上报这个字段就够后端区分"用户没用 App"和"权限已失效"两种召回路径。
|
||||
*/
|
||||
object A11yStateProbe {
|
||||
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
val expected = ComponentName(context, PriceBotAccessibilityService::class.java)
|
||||
.flattenToString()
|
||||
val enabledList = Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
|
||||
).orEmpty()
|
||||
// ENABLED_ACCESSIBILITY_SERVICES 是冒号分隔的 ComponentName 列表
|
||||
return enabledList.split(':').any { it.equals(expected, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.jishisongfu.duobibi.heartbeat
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
|
||||
/**
|
||||
* 自维护"App 是否在前台"标志,用 ActivityLifecycleCallbacks 计数已启动 Activity 数。
|
||||
*
|
||||
* 不用 ProcessLifecycleOwner 是为了避免新引入 androidx.lifecycle:lifecycle-process
|
||||
* 依赖。计数器算法简单可靠:任意 Activity onStart++、onStop--,>0 即前台。
|
||||
*
|
||||
* 必须在 [com.jishisongfu.duobibi.DuobibiApplication.onCreate] 调一次 [init]。
|
||||
*/
|
||||
object AppForegroundProbe {
|
||||
|
||||
@Volatile
|
||||
private var startedCount: Int = 0
|
||||
|
||||
fun init(app: Application) {
|
||||
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityStarted(activity: Activity) { startedCount++ }
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
startedCount = (startedCount - 1).coerceAtLeast(0)
|
||||
}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
})
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun isInForeground(context: Context): Boolean = startedCount > 0
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.jishisongfu.duobibi.heartbeat
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 持久化 device_id —— 心跳/推送召回按 device 维度判活,所以这个 id 必须卸载重装会变
|
||||
* (避免新装的 App 沿用旧 reg_id 撞车)。直接走 SharedPreferences,不进 Room 表。
|
||||
*/
|
||||
object DeviceIdProvider {
|
||||
|
||||
private const val PREFS = "device_prefs"
|
||||
private const val KEY = "device_id"
|
||||
|
||||
fun get(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
prefs.getString(KEY, null)?.let { return it }
|
||||
val id = "device_${Build.MODEL.replace(" ", "_")}_${UUID.randomUUID().toString().take(8)}"
|
||||
prefs.edit().putString(KEY, id).apply()
|
||||
return id
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.jishisongfu.duobibi.heartbeat
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** /register-device body —— 拿到 regId 立刻上报,与心跳解耦避免"鸡生蛋" */
|
||||
@Serializable
|
||||
data class RegisterDeviceBody(
|
||||
@SerialName("device_id") val deviceId: String,
|
||||
@SerialName("reg_id") val regId: String,
|
||||
@SerialName("app_version") val appVersion: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* /heartbeat body。reg_id 字段每次也带上,后端可在心跳中刷新设备最新 regId
|
||||
* (用户卸载重装后 regId 会变)。
|
||||
*
|
||||
* trigger 是哪个时机发的心跳——用于诊断:实测时若发现"心跳停太快",可看 trigger
|
||||
* 分布判断 alarm 是否被 Doze 拦截。
|
||||
*/
|
||||
@Serializable
|
||||
data class HeartbeatBody(
|
||||
@SerialName("device_id") val deviceId: String,
|
||||
@SerialName("reg_id") val regId: String?,
|
||||
@SerialName("a11y_enabled") val a11yEnabled: Boolean,
|
||||
@SerialName("floating_enabled") val floatingEnabled: Boolean,
|
||||
@SerialName("app_in_foreground") val appInForeground: Boolean,
|
||||
@SerialName("trigger") val trigger: String,
|
||||
@SerialName("client_ts") val clientTs: Long,
|
||||
@SerialName("app_version") val appVersion: String,
|
||||
)
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.jishisongfu.duobibi.heartbeat
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* AlarmManager 周期唤起 → 触发一次心跳上报 + adb broadcast 手动测试。
|
||||
*
|
||||
* 用 goAsync() 把 onReceive 异步化(BroadcastReceiver 默认 ANR 阈值 5s 内必须返回)。
|
||||
*
|
||||
* 手动触发(实测期常用):
|
||||
* adb shell am broadcast -a com.jishisongfu.duobibi.HEARTBEAT_TICK \
|
||||
* -n com.jishisongfu.duobibi/.heartbeat.HeartbeatReceiver
|
||||
*/
|
||||
class HeartbeatReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
Log.i(TAG, "alarm tick received action=${intent?.action}")
|
||||
val pending = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
HeartbeatSender.send(context.applicationContext, trigger = "alarm")
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DUOBIBI-HB"
|
||||
const val ACTION_TICK = "com.jishisongfu.duobibi.HEARTBEAT_TICK"
|
||||
|
||||
// SupervisorJob 防一次失败拖死后续
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.jishisongfu.duobibi.heartbeat
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* 用 AlarmManager 周期触发心跳。Android 19+ setRepeating 实际是 inexact(系统会
|
||||
* 合并/延迟到附近闹钟唤起,可能延迟 5-15 分钟),PoC 期足够 —— 我们关心的是
|
||||
* "心跳完全停了"vs"心跳还在",一两分钟的精度差不影响判定。
|
||||
*
|
||||
* 不用 setExactAndAllowWhileIdle:那需要 SCHEDULE_EXACT_ALARM 权限(Android 12+
|
||||
* 用户可拒绝),复杂度不值。Doze 模式下 inexact 闹钟也不会饿死太久(通常 15 分钟内能跑)。
|
||||
*
|
||||
* 如果实测发现心跳间隔被 Doze 拖到 30 分钟+,再换 setExact 方案。
|
||||
*/
|
||||
object HeartbeatScheduler {
|
||||
|
||||
private const val TAG = "DUOBIBI-HB"
|
||||
private const val ALARM_REQ = 1001
|
||||
private const val INTERVAL_MS = 5 * 60 * 1000L // 5 分钟,实测期偏短可观察更密
|
||||
|
||||
fun start(context: Context) {
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val pi = pendingIntent(context)
|
||||
// setRepeating 在 API 19+ 实际等同 setInexactRepeating
|
||||
am.setInexactRepeating(
|
||||
AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
||||
SystemClock.elapsedRealtime() + INTERVAL_MS,
|
||||
INTERVAL_MS,
|
||||
pi,
|
||||
)
|
||||
Log.i(TAG, "scheduler started, interval=${INTERVAL_MS}ms")
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
am.cancel(pendingIntent(context))
|
||||
Log.i(TAG, "scheduler stopped")
|
||||
}
|
||||
|
||||
private fun pendingIntent(context: Context): PendingIntent {
|
||||
val intent = Intent(context, HeartbeatReceiver::class.java).apply {
|
||||
action = HeartbeatReceiver.ACTION_TICK
|
||||
}
|
||||
// FLAG_IMMUTABLE 是 Android 12+ 的硬要求,FLAG_UPDATE_CURRENT 让 cancel 能匹配到
|
||||
return PendingIntent.getBroadcast(
|
||||
context,
|
||||
ALARM_REQ,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.jishisongfu.duobibi.heartbeat
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.jishisongfu.duobibi.BuildConfig
|
||||
import com.jishisongfu.duobibi.push.MiPushReceiver
|
||||
import com.jishisongfu.duobibi.util.SettingsPrefs
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 心跳发送的唯一入口。所有调用点(Application onCreate / Activity onResume /
|
||||
* a11y onServiceConnected / FloatingService onCreate / regId 收到 / Alarm 触发)
|
||||
* 都走这两个方法,trigger 标识区分。
|
||||
*
|
||||
* 网络层用 OkHttp 直发,**不走** [com.jishisongfu.duobibi.di.NetworkModule]
|
||||
* 配置的 Retrofit 实例 —— 那个 baseUrl 指向 api.duobibi.com 跑业务接口,
|
||||
* 心跳临时打到本地 mock(BuildConfig.HEARTBEAT_BASE_URL),完全独立链路。
|
||||
*
|
||||
* 调用方负责协程切线程(Dispatchers.IO),本类内部同步 execute 简化。
|
||||
*/
|
||||
object HeartbeatSender {
|
||||
|
||||
private const val TAG = "DUOBIBI-HB"
|
||||
|
||||
private val client by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
private val mediaJson = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
/** 拿到 regId 立刻调一次,后续即可只走 /heartbeat。失败静默,下个心跳还会带 regId。 */
|
||||
fun registerDevice(context: Context) {
|
||||
val regId = readRegId(context)
|
||||
if (regId.isNullOrEmpty()) {
|
||||
Log.w(TAG, "registerDevice skipped: no regId yet")
|
||||
return
|
||||
}
|
||||
val body = RegisterDeviceBody(
|
||||
deviceId = DeviceIdProvider.get(context),
|
||||
regId = regId,
|
||||
appVersion = BuildConfig.VERSION_NAME ?: "?",
|
||||
)
|
||||
post("/register-device", json.encodeToString(RegisterDeviceBody.serializer(), body))
|
||||
}
|
||||
|
||||
/**
|
||||
* 发心跳。trigger 给后端做诊断分类用(alarm / onResume / a11y_connected / floating_create / regid_received)。
|
||||
* 失败只记 log 不重试 —— 5 分钟一次 alarm 自然会下次再来。
|
||||
*/
|
||||
fun send(context: Context, trigger: String) {
|
||||
val body = HeartbeatBody(
|
||||
deviceId = DeviceIdProvider.get(context),
|
||||
regId = readRegId(context),
|
||||
a11yEnabled = A11yStateProbe.isEnabled(context),
|
||||
floatingEnabled = SettingsPrefs.floatingEnabled(context),
|
||||
appInForeground = AppForegroundProbe.isInForeground(context),
|
||||
trigger = trigger,
|
||||
clientTs = System.currentTimeMillis(),
|
||||
appVersion = BuildConfig.VERSION_NAME ?: "?",
|
||||
)
|
||||
post("/heartbeat", json.encodeToString(HeartbeatBody.serializer(), body))
|
||||
}
|
||||
|
||||
private fun post(path: String, jsonBody: String) {
|
||||
val url = BuildConfig.HEARTBEAT_BASE_URL.trimEnd('/') + path
|
||||
val req = Request.Builder()
|
||||
.url(url)
|
||||
.post(jsonBody.toRequestBody(mediaJson))
|
||||
.build()
|
||||
try {
|
||||
client.newCall(req).execute().use { resp ->
|
||||
if (resp.isSuccessful) {
|
||||
Log.i(TAG, "POST $path ok ${resp.code}")
|
||||
} else {
|
||||
Log.w(TAG, "POST $path http ${resp.code} body=${resp.body?.string()?.take(120)}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 后端不在 / WiFi 没连 / 域名不通时常见,不要崩
|
||||
Log.w(TAG, "POST $path failed: ${e.javaClass.simpleName} ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun readRegId(context: Context): String? =
|
||||
context.getSharedPreferences(MiPushReceiver.PREFS, Context.MODE_PRIVATE)
|
||||
.getString(MiPushReceiver.KEY_REG_ID, null)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.jishisongfu.duobibi.push
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.jishisongfu.duobibi.util.PrivacyPrefs
|
||||
import com.xiaomi.mipush.sdk.MiPushClient
|
||||
|
||||
/**
|
||||
* 小米推送注册的统一入口。两个调用点:
|
||||
* 1) [com.jishisongfu.duobibi.DuobibiApplication.onCreate] — 覆盖"已同意过的老用户冷启动"
|
||||
* 2) [com.jishisongfu.duobibi.MainActivity] 隐私 onAgree 回调 — 覆盖"首次安装同意"
|
||||
*
|
||||
* 抽到一处避免 AppID/AppKey 散落两份在轮换时漏改。隐私 gate 也统一判定。
|
||||
*
|
||||
* register 是异步的,首启拿到 regId 通常 1-30s,在 [MiPushReceiver.onReceiveRegisterResult]
|
||||
* 触发并打 DUOBIBI-PUSH tag log。
|
||||
*/
|
||||
object MiPushBootstrap {
|
||||
|
||||
private const val TAG = "DUOBIBI-PUSH"
|
||||
|
||||
// AppID 19 位超过 Long 范围,SDK 接收 String,不能改 Long
|
||||
private const val APP_ID = "2882303761520520291"
|
||||
private const val APP_KEY = "5602052076291"
|
||||
|
||||
fun registerIfReady(context: Context) {
|
||||
if (!PrivacyPrefs.agreed(context)) {
|
||||
Log.i(TAG, "register skipped: privacy not agreed yet")
|
||||
return
|
||||
}
|
||||
MiPushClient.registerPush(context.applicationContext, APP_ID, APP_KEY)
|
||||
Log.i(TAG, "MiPushClient.registerPush() called")
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.jishisongfu.duobibi.push
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.jishisongfu.duobibi.heartbeat.HeartbeatSender
|
||||
import com.xiaomi.mipush.sdk.ErrorCode
|
||||
import com.xiaomi.mipush.sdk.MiPushClient
|
||||
import com.xiaomi.mipush.sdk.MiPushCommandMessage
|
||||
import com.xiaomi.mipush.sdk.MiPushMessage
|
||||
import com.xiaomi.mipush.sdk.PushMessageReceiver
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 小米推送回调入口。所有日志统一打到 DUOBIBI-PUSH tag,验证时跑:
|
||||
* adb logcat -s DUOBIBI-PUSH:V
|
||||
*
|
||||
* 验证矩阵 6 个场景每个场景关注的 logcat 行:
|
||||
* - register success → 拿 regId 复制到小米后台"指定 regId"
|
||||
* - PUSH arrived → 通知到达回调(App 在线时触发)
|
||||
* - PUSH notification → 通知栏弹出回调(App 在线时触发)
|
||||
* - PUSH clicked → 用户点击通知拉起 App
|
||||
*
|
||||
* 注意:App 被 force-stop 后所有 receiver 被系统 disable,通知仍能弹(由小米
|
||||
* XMSF 系统进程负责)但本类回调不会触发——这是 Android force-stop 语义,不是 bug。
|
||||
*/
|
||||
class MiPushReceiver : PushMessageReceiver() {
|
||||
|
||||
override fun onReceiveRegisterResult(context: Context, message: MiPushCommandMessage) {
|
||||
val cmd = message.command
|
||||
val args = message.commandArguments
|
||||
val resultCode = message.resultCode
|
||||
if (MiPushClient.COMMAND_REGISTER == cmd) {
|
||||
if (resultCode == ErrorCode.SUCCESS.toLong()) {
|
||||
val regId = args?.firstOrNull().orEmpty()
|
||||
Log.i(TAG, "register success regId=$regId")
|
||||
// 持久化方便后续 SettingsScreen / 其他入口取用,验证期可 adb 拷出
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_REG_ID, regId)
|
||||
.apply()
|
||||
// 拿到 regId 立刻 register-device + 补发一次心跳 ——
|
||||
// 否则后端要等 5 分钟下次 alarm 才知道这个设备
|
||||
val app = context.applicationContext
|
||||
scope.launch {
|
||||
HeartbeatSender.registerDevice(app)
|
||||
HeartbeatSender.send(app, "regid_received")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "register failed code=$resultCode reason=${message.reason}")
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "command=$cmd args=$args resultCode=$resultCode reason=${message.reason}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCommandResult(context: Context, message: MiPushCommandMessage) {
|
||||
Log.i(TAG, "onCommandResult cmd=${message.command} args=${message.commandArguments} code=${message.resultCode}")
|
||||
}
|
||||
|
||||
/** 透传消息(本次验证不用,占位以防误判) */
|
||||
override fun onReceivePassThroughMessage(context: Context, message: MiPushMessage) {
|
||||
Log.i(TAG, "passthrough content=${message.content?.take(80)} msgId=${message.messageId}")
|
||||
}
|
||||
|
||||
/** 通知到达 — App 在线时触发,被杀 / force-stop 时不触发 */
|
||||
override fun onNotificationMessageArrived(context: Context, message: MiPushMessage) {
|
||||
Log.i(TAG, "PUSH arrived title=${message.title} desc=${message.description?.take(80)} msgId=${message.messageId}")
|
||||
}
|
||||
|
||||
/** 用户点击通知拉起 App */
|
||||
override fun onNotificationMessageClicked(context: Context, message: MiPushMessage) {
|
||||
Log.i(TAG, "PUSH clicked title=${message.title} desc=${message.description?.take(80)} msgId=${message.messageId} extra=${message.extra}")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DUOBIBI-PUSH"
|
||||
const val PREFS = "push_prefs"
|
||||
const val KEY_REG_ID = "reg_id"
|
||||
|
||||
// 静态 scope 而非每次新建,避免 receiver 多次触发时累积。SupervisorJob 防失败传染。
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package com.jishisongfu.duobibi.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.jishisongfu.duobibi.MainActivity
|
||||
import com.jishisongfu.duobibi.R
|
||||
import com.jishisongfu.duobibi.data.repo.AlertHit
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
import com.jishisongfu.duobibi.floating.AccessibilityServiceHolder
|
||||
import com.jishisongfu.duobibi.floating.FloatingButtonView
|
||||
import com.jishisongfu.duobibi.heartbeat.HeartbeatSender
|
||||
import com.jishisongfu.duobibi.util.Constants
|
||||
import com.jishisongfu.duobibi.util.PrivacyPrefs
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class FloatingWindowService : Service() {
|
||||
|
||||
@Inject lateinit var repository: PriceRepository
|
||||
|
||||
private lateinit var windowManager: WindowManager
|
||||
private var floatingView: FloatingButtonView? = null
|
||||
private var layoutParams: WindowManager.LayoutParams? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var inflightJob: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "[FLOAT] onCreate")
|
||||
instance = this
|
||||
startForegroundNotification()
|
||||
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
|
||||
attachFloatingView()
|
||||
if (PrivacyPrefs.agreed(this)) {
|
||||
scope.launch { HeartbeatSender.send(applicationContext, "floating_create") }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i(TAG, "[FLOAT] onDestroy")
|
||||
detachFloatingView()
|
||||
scope.cancel()
|
||||
instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startForegroundNotification() {
|
||||
val mgr = getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
Constants.FLOATING_NOTIFICATION_CHANNEL_ID,
|
||||
getString(R.string.floating_notification_channel),
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
)
|
||||
mgr.createNotificationChannel(channel)
|
||||
|
||||
val notification: Notification = NotificationCompat.Builder(
|
||||
this, Constants.FLOATING_NOTIFICATION_CHANNEL_ID,
|
||||
)
|
||||
.setContentTitle(getString(R.string.floating_notification_title))
|
||||
.setContentText(getString(R.string.floating_notification_text))
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
Constants.FLOATING_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(Constants.FLOATING_NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachFloatingView() {
|
||||
if (floatingView != null) return
|
||||
val sizePx = (resources.displayMetrics.density * 56).toInt()
|
||||
val params = WindowManager.LayoutParams(
|
||||
sizePx, sizePx,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
val saved = com.jishisongfu.duobibi.util.SettingsPrefs.savedPosition(this@FloatingWindowService)
|
||||
x = saved?.first ?: (resources.displayMetrics.widthPixels - sizePx - 32)
|
||||
y = saved?.second ?: (resources.displayMetrics.heightPixels / 2)
|
||||
}
|
||||
layoutParams = params
|
||||
val view = FloatingButtonView(this, windowManager, params, ::onFloatingClicked)
|
||||
floatingView = view
|
||||
try {
|
||||
windowManager.addView(view, params)
|
||||
view.visibility = View.GONE // 默认隐藏,等无障碍服务通知白名单 App 进前台再亮
|
||||
Log.i(TAG, "[FLOAT] attachFloatingView OK pos=(${params.x},${params.y}) size=$sizePx")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "[FLOAT] attachFloatingView FAILED: ${e.message}", e)
|
||||
Toast.makeText(this, "浮窗启动失败:${e.message}", Toast.LENGTH_LONG).show()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun detachFloatingView() {
|
||||
floatingView?.let { v -> try { windowManager.removeView(v) } catch (_: Exception) {} }
|
||||
floatingView = null
|
||||
}
|
||||
|
||||
private fun applyVisibility(show: Boolean) {
|
||||
val v = floatingView ?: return
|
||||
v.post { v.visibility = if (show) View.VISIBLE else View.GONE }
|
||||
}
|
||||
|
||||
private fun onFloatingClicked() {
|
||||
if (inflightJob?.isActive == true) return
|
||||
val pkg = AccessibilityServiceHolder.lastForegroundPackage
|
||||
if (pkg == null || pkg !in Constants.WHITELIST_PACKAGES) {
|
||||
Toast.makeText(this, R.string.toast_no_pkg, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
floatingView?.setLoading(true)
|
||||
inflightJob = scope.launch {
|
||||
val result = runCatching {
|
||||
val tree = withContext(Dispatchers.Default) {
|
||||
AccessibilityServiceHolder.get()?.captureCurrentTree()
|
||||
}
|
||||
withContext(Dispatchers.IO) { repository.parseAndInsert(pkg, tree) }
|
||||
}
|
||||
floatingView?.setLoading(false)
|
||||
result.fold(
|
||||
onSuccess = { outcome ->
|
||||
val r = outcome.record
|
||||
val priceText = formatPrice(r.price)
|
||||
val typical = outcome.typicalPrice
|
||||
val msg = when {
|
||||
typical != null && r.price < typical ->
|
||||
getString(R.string.toast_typical_lower, priceText, r.sourceApp, formatPrice(typical), formatPrice(typical - r.price))
|
||||
typical != null && r.price > typical ->
|
||||
getString(R.string.toast_typical_higher, priceText, r.sourceApp, formatPrice(typical), formatPrice(r.price - typical))
|
||||
typical != null ->
|
||||
getString(R.string.toast_typical_same, priceText, r.sourceApp, formatPrice(typical))
|
||||
outcome.hitExistingCluster && outcome.clusterMinPriceBefore != null && r.price < outcome.clusterMinPriceBefore ->
|
||||
getString(R.string.toast_record_lower, priceText, r.sourceApp, formatPrice(outcome.clusterMinPriceBefore - r.price))
|
||||
outcome.hitExistingCluster && outcome.clusterMinPriceBefore != null && r.price > outcome.clusterMinPriceBefore ->
|
||||
getString(R.string.toast_record_higher, priceText, r.sourceApp, formatPrice(r.price - outcome.clusterMinPriceBefore))
|
||||
outcome.hitExistingCluster ->
|
||||
getString(R.string.toast_record_same, priceText, r.sourceApp)
|
||||
else ->
|
||||
getString(R.string.toast_record_new, priceText, r.sourceApp)
|
||||
}
|
||||
Toast.makeText(this@FloatingWindowService, msg, Toast.LENGTH_LONG).show()
|
||||
if (outcome.triggeredAlerts.isNotEmpty()) notifyAlertHits(outcome.triggeredAlerts)
|
||||
},
|
||||
onFailure = {
|
||||
Toast.makeText(this@FloatingWindowService, R.string.toast_parse_failed, Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 浮窗记到价命中了"想买的"降价雷达且达到心理价 → 本地通知。 */
|
||||
private fun notifyAlertHits(hits: List<AlertHit>) {
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
Constants.ALERT_NOTIFICATION_CHANNEL_ID,
|
||||
getString(R.string.alert_notification_channel),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
)
|
||||
}
|
||||
val openAppIntent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
val openAppPi = PendingIntent.getActivity(
|
||||
this, 0, openAppIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
hits.forEach { hit ->
|
||||
val title = if (hit.targetPrice != null && hit.reachedTarget) "🎯 降到心理价啦!" else "🔔 降价提醒"
|
||||
val targetSuffix = if (hit.targetPrice != null) "(心理价 ¥${formatPrice(hit.targetPrice)})" else ""
|
||||
val text = "${hit.title}: ¥${formatPrice(hit.newPrice)}(${hit.sourceApp})$targetSuffix"
|
||||
val notif = NotificationCompat.Builder(this, Constants.ALERT_NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(openAppPi)
|
||||
.build()
|
||||
nm.notify(Constants.ALERT_NOTIFICATION_ID_BASE + hit.alertId.toInt(), notif)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPrice(p: Double): String =
|
||||
if (p == p.toLong().toDouble()) p.toLong().toString() else "%.2f".format(p)
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: FloatingWindowService? = null
|
||||
|
||||
fun isRunning(): Boolean = instance != null
|
||||
|
||||
/** 显隐总入口;服务不在且需显示时顺手拉起(自我修复)。 */
|
||||
fun notifyVisibility(ctx: Context, show: Boolean) {
|
||||
val running = instance
|
||||
if (running != null) {
|
||||
running.applyVisibility(show)
|
||||
} else if (show) {
|
||||
ContextCompat.startForegroundService(ctx, Intent(ctx, FloatingWindowService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "DUOBIBI"
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.jishisongfu.duobibi.service
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import com.jishisongfu.duobibi.floating.AccessibilityServiceHolder
|
||||
import com.jishisongfu.duobibi.floating.NodeDto
|
||||
import com.jishisongfu.duobibi.floating.TreeSerializer
|
||||
import com.jishisongfu.duobibi.heartbeat.HeartbeatSender
|
||||
import com.jishisongfu.duobibi.util.Constants
|
||||
import com.jishisongfu.duobibi.util.PrivacyPrefs
|
||||
import com.jishisongfu.duobibi.util.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PriceBotAccessibilityService : AccessibilityService() {
|
||||
|
||||
// 心跳上报独立 scope,SupervisorJob 防一次失败拖死后续。a11y 进程死时整个 scope 一起 GC。
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
Log.i(TAG, "[A11Y] onServiceConnected")
|
||||
AccessibilityServiceHolder.attach(this)
|
||||
// a11y 服务被启用 = 关键的"复活"信号 —— 后端可能正在按"a11y_enabled=false 持续 N 分钟"
|
||||
// 准备推送召回,这一帧立刻把 a11y_enabled=true 送过去清掉这个判定状态。
|
||||
if (PrivacyPrefs.agreed(this)) {
|
||||
scope.launch { HeartbeatSender.send(applicationContext, "a11y_connected") }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: android.content.Intent?): Boolean {
|
||||
// 用户在系统设置关闭无障碍 / 系统资源压力剥夺 → onUnbind 触发(通常先于 onDestroy)。
|
||||
// 这一帧 a11y 还在 ENABLED_ACCESSIBILITY_SERVICES 列表里(系统刚开始拆卸),
|
||||
// A11yStateProbe 在这一刻可能仍报 true,所以我们在心跳的 trigger 字段里用
|
||||
// a11y_unbinding 显式标识,后端可据此立刻进入"准死亡"判定。
|
||||
Log.i(TAG, "[A11Y] onUnbind")
|
||||
if (PrivacyPrefs.agreed(this)) {
|
||||
scope.launch { HeartbeatSender.send(applicationContext, "a11y_unbinding") }
|
||||
}
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (event == null) return
|
||||
if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
|
||||
val pkg = event.packageName?.toString()
|
||||
val cls = event.className?.toString()
|
||||
Log.d(TAG, "[A11Y] event pkg=$pkg cls=$cls")
|
||||
if (pkg == null) return
|
||||
|
||||
// 自家 APP 自己的窗口(包括浮窗本身) 切换时不要影响浮窗显隐 ——
|
||||
// 否则浮窗一显示就会触发自家 window 事件,又把它藏回去,死循环。
|
||||
if (pkg == packageName) {
|
||||
Log.d(TAG, "[A11Y] -> SKIP own package")
|
||||
return
|
||||
}
|
||||
|
||||
if (isTransientSystemWindow(pkg)) {
|
||||
Log.d(TAG, "[A11Y] -> SKIP transient pkg=$pkg")
|
||||
return
|
||||
}
|
||||
|
||||
AccessibilityServiceHolder.updateForegroundPackage(pkg)
|
||||
|
||||
if (!PrivacyPrefs.agreed(this) || !SettingsPrefs.floatingEnabled(this)) {
|
||||
Log.d(TAG, "[A11Y] -> HIDE (privacy/floating-off)")
|
||||
FloatingWindowService.notifyVisibility(this, false)
|
||||
return
|
||||
}
|
||||
val show = pkg in Constants.WHITELIST_PACKAGES
|
||||
Log.d(TAG, "[A11Y] -> notifyVisibility($show) for pkg=$pkg")
|
||||
FloatingWindowService.notifyVisibility(this, show)
|
||||
}
|
||||
|
||||
private fun isTransientSystemWindow(pkg: String): Boolean {
|
||||
if (pkg in SYSTEM_WINDOW_PACKAGES) return true
|
||||
if (pkg.contains("inputmethod") || pkg.contains(".ime.") || pkg.endsWith(".ime")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onInterrupt() {}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i(TAG, "[A11Y] onDestroy")
|
||||
AccessibilityServiceHolder.detach()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun captureCurrentTree(): NodeDto? {
|
||||
val root = rootInActiveWindow ?: return null
|
||||
return TreeSerializer.serialize(root)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DUOBIBI"
|
||||
|
||||
private val SYSTEM_WINDOW_PACKAGES: Set<String> = setOf(
|
||||
"com.android.systemui",
|
||||
"android",
|
||||
"com.miui.systemAdSolution",
|
||||
"com.iflytek.inputmethod",
|
||||
"com.sohu.inputmethod.sogou",
|
||||
"com.sohu.inputmethod.sogouoem",
|
||||
"com.baidu.input",
|
||||
"com.baidu.input_mi",
|
||||
"com.baidu.input_huawei",
|
||||
"com.tencent.qqpinyin",
|
||||
"com.touchtype.swiftkey",
|
||||
"com.google.android.inputmethod.latin",
|
||||
"com.google.android.inputmethod.pinyin",
|
||||
"com.android.inputmethod.latin",
|
||||
"com.android.inputmethod.pinyin",
|
||||
)
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 多比比 — 设计系统共享 UI 组件 (与 screenshots/html/_base.css 的 token 对齐)。
|
||||
*
|
||||
* - [PlatformAvatar]: 风格化品牌徽章 —— 圆角方块 + 品牌主色渐变 + 单个中文字符。
|
||||
* 刻意不复刻任何平台 logo,商店审核明确禁用未授权第三方 logo。
|
||||
* - [PriceGauge]: 简化双色价格水位条(neutral 底 + teal 填 + 深色标记三角)。css `.gauge`。
|
||||
* - [SummaryBar]: 纯 teal-dark hero 横幅(全网最低 / 省 ¥X)。css `.sumbar`。
|
||||
* - [TagChip]: 小号方角彩色标签 (本机/最低 等)。css `.tag`。
|
||||
* - [DbbProgressBar]: 迷你水平进度条 (css `.bar`)。
|
||||
*/
|
||||
package com.jishisongfu.duobibi.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EmojiEvents
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
// --- 平台品牌色 (取自 css .pbadge.jd/.tb/.pdd/.dy/.mt/.elm) ---
|
||||
// 渐变上下两色让方块徽章有"App icon"质感,与真实平台 logo 形态不同。
|
||||
private data class BrandStyle(val top: Color, val bottom: Color, val ch: String, val fg: Color = Color.White)
|
||||
|
||||
private fun platformBrand(platform: String): BrandStyle = when (platform) {
|
||||
"京东" -> BrandStyle(Color(0xFFE1251B), Color(0xFFB91C1C), "京")
|
||||
"淘宝" -> BrandStyle(Color(0xFFFF7A22), Color(0xFFE15500), "淘")
|
||||
"拼多多" -> BrandStyle(Color(0xFFE02E24), Color(0xFFB91C1C), "拼")
|
||||
"抖音" -> BrandStyle(Color(0xFF3A3A3A), Color(0xFF0F172A), "抖")
|
||||
"美团", "美团外卖" -> BrandStyle(Color(0xFFFFCB1F), Color(0xFFF0B400), if (platform == "美团外卖") "外" else "美", Color(0xFF3A2C00))
|
||||
"饿了么" -> BrandStyle(Color(0xFF1E88FF), Color(0xFF0066CC), "饿")
|
||||
else -> BrandStyle(Color(0xFF64748B), Color(0xFF334155), platform.firstOrNull()?.toString() ?: "?")
|
||||
}
|
||||
|
||||
/**
|
||||
* 风格化平台徽章 —— 圆角方块 + 品牌主色渐变 + 单个中文字符。`small=true` 紧凑场景用。
|
||||
* 名字保留 PlatformAvatar 以避免现有调用点全改;视觉上是新设计语言里的 .pbadge。
|
||||
*/
|
||||
@Composable
|
||||
fun PlatformAvatar(
|
||||
platform: String,
|
||||
modifier: Modifier = Modifier,
|
||||
small: Boolean = false,
|
||||
) {
|
||||
val style = platformBrand(platform)
|
||||
val sizeDp = if (small) 18.dp else 22.dp
|
||||
val fs = if (small) 10.sp else 11.sp
|
||||
val cornerDp = if (small) 5.dp else 6.dp
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(sizeDp)
|
||||
.clip(RoundedCornerShape(cornerDp))
|
||||
.background(Brush.verticalGradient(listOf(style.top, style.bottom))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(style.ch, color = style.fg, fontSize = fs, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台名圆角胶囊 —— 整名字躺在一个浅灰胶囊里(参考主流比价 App)。
|
||||
*
|
||||
* 跟 [PlatformAvatar] 的分工:
|
||||
* - PlatformAvatar:小方块图标(22dp),只放单字,作为缩略图用(商品组卡 / 引导页平台列阵)
|
||||
* - PlatformChip:**长胶囊**,放整个平台名("淘宝闪购"/"京东到家"/"饿了么"),
|
||||
* 用在对比表格行 —— 避免"图标 + 重复一遍平台名 Text"的双重视觉
|
||||
*
|
||||
* `isLowest = true` 时切换到品牌主色实心胶囊 + 白字,作为"最低"高亮。
|
||||
* `onClick` 可选,提供时整个胶囊可点(对应"点行去搜")。
|
||||
*/
|
||||
@Composable
|
||||
fun PlatformChip(
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
isLowest: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val bg = if (isLowest) DbbColors.Teal else DbbColors.Fill
|
||||
val fg = if (isLowest) Color.White else DbbColors.InkSec
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(bg)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
color = fg,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isLowest) FontWeight.Bold else FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 价格水位条:浅灰底 + Teal 填充到 marker 位置 + 深色三角标记。
|
||||
* 一般约定:min=全网最低价, max=最高价, marker=市场常见价 — 让用户一眼看到"现价水位"。
|
||||
* 不再用彩虹渐变(色彩太多),改成双色更克制。
|
||||
*/
|
||||
@Composable
|
||||
fun PriceGauge(
|
||||
min: Double,
|
||||
max: Double,
|
||||
marker: Double,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val range = (max - min).coerceAtLeast(0.0001)
|
||||
val frac = ((marker - min) / range).coerceIn(0.0, 1.0).toFloat()
|
||||
BoxWithConstraints(modifier.fillMaxWidth().height(14.dp)) {
|
||||
// Track:浅灰底
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.align(Alignment.BottomStart)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(DbbColors.Fill),
|
||||
)
|
||||
// Fill:teal 填到 marker
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(frac)
|
||||
.height(6.dp)
|
||||
.align(Alignment.BottomStart)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(DbbColors.Teal),
|
||||
)
|
||||
// 标记三角
|
||||
val markerLeft = (maxWidth * frac) - 5.dp
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(width = 10.dp, height = 8.dp)
|
||||
.offset(x = markerLeft, y = 0.dp),
|
||||
) {
|
||||
val path = Path().apply {
|
||||
moveTo(size.width / 2f, size.height)
|
||||
lineTo(0f, 0f)
|
||||
lineTo(size.width, 0f)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color = Color(0xFF0F172A))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero 横幅(纯 teal-dark)。从渐变 teal→sky 改成纯色,减少屏幕上的色彩数量。
|
||||
* 典型用法:"全网最低 · 京东 ¥7799 较常见价省 ¥500"。
|
||||
*/
|
||||
@Composable
|
||||
fun SummaryBar(
|
||||
label: String,
|
||||
bigText: String,
|
||||
subText: String,
|
||||
modifier: Modifier = Modifier,
|
||||
leadingIcon: ImageVector = Icons.Filled.EmojiEvents,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(13.dp))
|
||||
.background(DbbColors.TealDark)
|
||||
.padding(horizontal = 13.dp, vertical = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(leadingIcon, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(9.dp))
|
||||
Column {
|
||||
Text(label, color = Color.White.copy(alpha = 0.85f), fontSize = 11.sp)
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(bigText, color = Color.White, fontSize = 19.sp, fontWeight = FontWeight.ExtraBold)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
subText,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 小号方角彩色标签 (如 "本机" / "最低")。css `.tag`。 */
|
||||
@Composable
|
||||
fun TagChip(text: String, bg: Color, fg: Color, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(bg)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) { Text(text, color = fg, fontSize = 10.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
|
||||
/** 迷你水平进度条 (css `.bar`)。fraction 0..1,默认 teal 色,可换 save/up。 */
|
||||
@Composable
|
||||
fun DbbProgressBar(
|
||||
fraction: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = DbbColors.Teal,
|
||||
) {
|
||||
val f = fraction.coerceIn(0f, 1f)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(DbbColors.Fill),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(f)
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.jishisongfu.duobibi.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
|
||||
/**
|
||||
* 触觉反馈薄包装 —— 提供"toggle / confirm / reject"三种语义,屏蔽 Compose 版本差异。
|
||||
*
|
||||
* 为什么自己包一层:
|
||||
* - HapticFeedbackType 在 Compose 1.7+ 才有 Confirm/Reject/GestureEnd 等丰富语义,
|
||||
* 旧版本只有 LongPress / TextHandleMove,我们用 LongPress 作为通用 fallback。
|
||||
* - 调用点写 `haptics.toggle()` 比写 `LocalHapticFeedback.current.performHapticFeedback(...)`
|
||||
* 短一截,也避免每个调用点都 import HapticFeedbackType。
|
||||
*
|
||||
* 用法:
|
||||
* ```
|
||||
* val haptics = rememberHaptics()
|
||||
* Button(onClick = { haptics.toggle(); onSomething() }) { ... }
|
||||
* ```
|
||||
*/
|
||||
class DbbHaptics(private val raw: HapticFeedback) {
|
||||
/** 轻量"开关切换"反馈 —— ❤️ 收藏 / 今日新品 toggle / Tab 切换 / chip 选中。 */
|
||||
fun toggle() {
|
||||
raw.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
|
||||
/** "确认主动作"反馈 —— AI 评估 / 开始比价 / 加入降价雷达 等主 CTA。 */
|
||||
fun confirm() {
|
||||
raw.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberHaptics(): DbbHaptics {
|
||||
val raw = LocalHapticFeedback.current
|
||||
return remember(raw) { DbbHaptics(raw) }
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.jishisongfu.duobibi.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* 通用 hero 顶栏 —— 替代每个 Tab 屏的 TopAppBar。
|
||||
*
|
||||
* 关键差异(为何"看起来像 app"而不是 demo):
|
||||
* - **顶到状态栏**:用 `windowInsetsPadding(WindowInsets.statusBars)` 把渐变色一路盖到屏幕最顶,
|
||||
* 跟状态栏色融为一体,不再有"白底 + 灰状态栏"的割裂感。
|
||||
* - **品牌渐变背景**:每个 Tab 一套独立渐变(见 [DbbGradients]),切 Tab 像换了页面气质。
|
||||
* - **icon + title + subtitle 三层信息密度**:取代之前的"光秃秃白底 TopAppBar"。
|
||||
* - **trailing slot**:右侧给 actions(刷新/帮助/筛选)留口子,接管 TopAppBar 原 actions 角色。
|
||||
*
|
||||
* 调用方:Scaffold 的 `topBar` slot 留空,把 HeroBanner 作为 Scaffold content 的第一个 Composable,
|
||||
* 同时 Scaffold 设 `contentWindowInsets = WindowInsets(0)` 让 inset 由 HeroBanner 自己接管。
|
||||
*/
|
||||
@Composable
|
||||
fun HeroBanner(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: ImageVector,
|
||||
gradient: List<Color>,
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(Brush.verticalGradient(gradient))
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.White.copy(alpha = 0.20f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
Text(
|
||||
subtitle,
|
||||
color = Color.White.copy(alpha = 0.88f),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero 下方的"过渡条":一条 14dp 高的 Bg 色矩形,顶部带 18dp 圆角,
|
||||
* 视觉上像内容从 hero 下方"卷起来",参考小红书首页 hero→正文的过渡。
|
||||
* 上层是 hero 渐变,这条卷在 hero 之下,圆角处会"咬掉"一点 hero 色。
|
||||
*/
|
||||
@Composable
|
||||
fun HeroBottomFade(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp))
|
||||
.background(com.jishisongfu.duobibi.ui.theme.DbbColors.Bg),
|
||||
)
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.jishisongfu.duobibi.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.jishisongfu.duobibi.R
|
||||
|
||||
/**
|
||||
* 商品 title → 真图 drawable 反查表。
|
||||
*
|
||||
* **场景**:Arena 比价表格 / Alert 雷达卡片 / Records 商品组里显示的商品 title 是**动态**的
|
||||
* (用户输入或浮窗自动捕获),不像 Discover SEED 直接挂 imageRes。这里做关键词模糊匹配,
|
||||
* 命中就用对应真图,没命中就让上层走 vector icon fallback。
|
||||
*
|
||||
* **匹配规则**(命中即返回,顺序很重要 — 把更具体的关键词放前面):
|
||||
* - title 经 lowercase 后包含任一 keyword(小写) → 命中
|
||||
* - 同一商品多写几个 keyword(品牌名 / 英文 / 中文 / 系列名) 提高命中率
|
||||
* - **同品牌多产品**:必须把更细的子型号写在品牌名之前,否则品牌名先命中导致错图
|
||||
* (例:戴森吸尘器 / 戴森吹风机;飞利浦剃须刀 / 飞利浦电动牙刷)
|
||||
*
|
||||
* **添加新映射**:在 [MAPPINGS] 适当位置追加 `listOf("kw1", "kw2", ...) to R.drawable.img_xxx`。
|
||||
*/
|
||||
object ProductImageMap {
|
||||
|
||||
private val MAPPINGS: List<Pair<List<String>, Int>> = listOf(
|
||||
// ===== 数码家电 =====
|
||||
// 手机两分:iPhone 系列全走苹果图(img_iphone_blue 当前是 iPhone 16 蓝,
|
||||
// 后续 mentor 给 iPhone 17 图就替换 img_iphone_blue 这一个文件,代码不动)
|
||||
// 注意:iPhone 关键词必须在"手机"通用关键词之前,否则 iphone 被通用规则先吃掉
|
||||
listOf("iphone", "苹果手机", "苹果17", "苹果15", "苹果16", "苹果手机")
|
||||
to R.drawable.img_iphone_blue,
|
||||
listOf("airpods", "苹果耳机") to R.drawable.img_airpods,
|
||||
listOf("macbook", "苹果笔记本") to R.drawable.img_macbook,
|
||||
listOf("ipad") to R.drawable.img_ipad_air,
|
||||
// Android 手机统一用小米 14 Pro 图作为视觉代表 — 覆盖国内国际主流品牌全系
|
||||
listOf(
|
||||
"小米", "xiaomi", "redmi", "红米",
|
||||
"华为", "huawei", "mate", "honor", "荣耀",
|
||||
"oppo", "vivo", "魅族", "meizu",
|
||||
"三星", "samsung", "galaxy",
|
||||
"realme", "oneplus", "一加",
|
||||
"android 手机", "安卓手机", "手机", "phone",
|
||||
) to R.drawable.img_xiaomi14pro,
|
||||
|
||||
// 同品牌多产品 — 子品类必须在品牌名前面,先 match 子品类
|
||||
listOf("吸尘", "vacuum") to R.drawable.img_dyson_vacuum,
|
||||
listOf("戴森", "dyson", "supersonic", "吹风机", "电吹风") to R.drawable.img_dyson_hd,
|
||||
listOf("电动牙刷", "牙刷", "toothbrush") to R.drawable.img_philips_toothbrush,
|
||||
listOf("剃须刀", "shaver") to R.drawable.img_philips_shaver,
|
||||
|
||||
// ===== 餐饮外卖 =====
|
||||
listOf("海底捞") to R.drawable.img_haidilao,
|
||||
listOf("吉野家", "肥牛饭", "红烧肥牛") to R.drawable.img_beef_rice,
|
||||
// KFC 独立成图 — 从 hamburger 移出来,因为 KFC 自家图不是汉堡而是炸鸡桶
|
||||
listOf("肯德基", "kfc", "疯狂星期四", "9.9 元桶", "9.9元桶") to R.drawable.img_kfc,
|
||||
// 麦当劳/汉堡保持 hamburger 图
|
||||
listOf("汉堡", "鸡腿堡", "麦当劳", "麦辣", "burger", "巨无霸") to R.drawable.img_hamburger,
|
||||
listOf("喜茶", "heytea", "多肉葡萄", "奶茶") to R.drawable.img_heytea,
|
||||
|
||||
// ===== 同城买菜 =====
|
||||
listOf("五花肉", "猪肉") to R.drawable.img_pork_belly,
|
||||
listOf("鸡蛋", "土鸡蛋") to R.drawable.img_eggs,
|
||||
listOf("特仑苏", "蒙牛", "牛奶") to R.drawable.img_telunsu,
|
||||
listOf("叶菜", "青菜", "蔬菜礼包", "绿色叶菜") to R.drawable.img_greens,
|
||||
|
||||
// ===== 电商 =====
|
||||
listOf("雀巢", "nestle", "nescafe", "多趣酷思", "咖啡胶囊") to R.drawable.img_nescafe,
|
||||
listOf("三只松鼠", "每日坚果", "坚果") to R.drawable.img_squirrel,
|
||||
listOf("自嗨锅", "自热米饭", "自热饭") to R.drawable.img_zhxg,
|
||||
listOf("加多宝") to R.drawable.img_jdb,
|
||||
// 酒水 — 茅台关键词放前(避免被"白酒"等通用词误匹配)
|
||||
listOf("茅台", "飞天茅台", "飞天", "moutai") to R.drawable.img_moutai,
|
||||
|
||||
// ===== 美妆 =====
|
||||
listOf("sk-ii", "skii", "神仙水") to R.drawable.img_skii,
|
||||
listOf("雅诗兰黛", "小棕瓶", "estee", "lauder") to R.drawable.img_estee_lauder,
|
||||
listOf("兰蔻", "lancome", "lancôme", "absolue", "菁纯") to R.drawable.img_lancome_eye,
|
||||
listOf("资生堂", "红腰子", "shiseido") to R.drawable.img_shiseido_red,
|
||||
|
||||
// ===== 鞋服 =====
|
||||
listOf("nike", "air force", "af1", "空军一号") to R.drawable.img_nike_af1,
|
||||
listOf("lululemon", "瑜伽裤", "align") to R.drawable.img_lululemon,
|
||||
|
||||
// ===== 日用 / 母婴 =====
|
||||
listOf("花王", "妙而舒", "merries", "纸尿裤") to R.drawable.img_merries,
|
||||
listOf("维达", "抽纸") to R.drawable.img_vinda_tissue,
|
||||
listOf("海尔", "保温杯") to R.drawable.img_haier_cup,
|
||||
listOf("美的", "电热水壶", "热水壶", "烧水壶", "midea") to R.drawable.img_midea_kettle,
|
||||
listOf("苏泊尔", "不粘锅", "炒锅", "supor") to R.drawable.img_supor_pan,
|
||||
)
|
||||
|
||||
/**
|
||||
* 给定商品 title,返回匹配到的 drawable id;没命中返回 null。
|
||||
* caller 一般用法:`ProductImageMap.findImage(title) ?: fallbackVectorIcon`
|
||||
*/
|
||||
@DrawableRes
|
||||
fun findImage(title: String): Int? {
|
||||
if (title.isBlank()) return null
|
||||
val lower = title.lowercase()
|
||||
return MAPPINGS.firstOrNull { (kws, _) ->
|
||||
kws.any { lower.contains(it.lowercase()) }
|
||||
}?.second
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.jishisongfu.duobibi.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jishisongfu.duobibi.R
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
/** 价格格式化:整数不带小数,否则两位小数。全 UI 共用。 */
|
||||
fun fmtYuan(p: Double): String =
|
||||
if (p == p.toLong().toDouble()) p.toLong().toString() else "%.2f".format(p)
|
||||
|
||||
/** 比价擂台默认对比的 4 大电商(同一实体商品可跨平台买)。 */
|
||||
val ARENA_PLATFORMS: List<String> = listOf("淘宝", "京东", "拼多多", "抖音")
|
||||
|
||||
/** 浮窗 / 记录覆盖的全部 7 个平台。 */
|
||||
val ALL_PLATFORMS: List<String> = listOf("淘宝", "京东", "拼多多", "抖音", "美团", "美团外卖", "饿了么")
|
||||
|
||||
// ==================== 商品略缩图 ====================
|
||||
/**
|
||||
* 商品品类略缩图(线条剪影)。**故意不放真实产品图** —— 商店审核同样禁止未授权使用
|
||||
* 他人产品图/包装,且占坑期没有联盟素材渠道。改成按标题关键字映射到 6 个内置剪影:
|
||||
* 手机 / 吹风机 / 掌机 / 胶囊 / 耳机盒 / 通用购物袋。每个剪影是自绘的几何线条,
|
||||
* 不是任一具体产品的复刻。
|
||||
*/
|
||||
enum class ProductCategory(@DrawableRes val drawableRes: Int) {
|
||||
Phone(R.drawable.ic_prod_phone),
|
||||
Hairdryer(R.drawable.ic_prod_hairdryer),
|
||||
Console(R.drawable.ic_prod_console),
|
||||
Capsule(R.drawable.ic_prod_capsule),
|
||||
Earbuds(R.drawable.ic_prod_earbuds),
|
||||
Default(R.drawable.ic_prod_default),
|
||||
}
|
||||
|
||||
/** 按商品标题里的关键字推断品类。命中多组时取首个;无命中走通用购物袋。 */
|
||||
fun categoryFor(title: String): ProductCategory {
|
||||
val t = title.lowercase()
|
||||
return when {
|
||||
listOf("iphone", "手机", "phone", "mate", "redmi", "小米", "pixel", "vivo", "oppo").any { t.contains(it) } -> ProductCategory.Phone
|
||||
listOf("戴森", "dyson", "吹风", "卷发", "直发器", "美发").any { t.contains(it) } -> ProductCategory.Hairdryer
|
||||
listOf("switch", "ps5", "xbox", "掌机", "游戏机", "steam deck").any { t.contains(it) } -> ProductCategory.Console
|
||||
listOf("胶囊", "咖啡", "nespresso", "多趣酷思", "espresso").any { t.contains(it) } -> ProductCategory.Capsule
|
||||
listOf("airpods", "耳机", "earbuds", "buds", "降噪").any { t.contains(it) } -> ProductCategory.Earbuds
|
||||
else -> ProductCategory.Default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品略缩图卡:圆角方块底 + 居中商品图。
|
||||
*
|
||||
* **渲染优先级**(从高到低):
|
||||
* 1. [ProductImageMap.findImage] 命中 → 渲染**真图**(白底容器 + Fit 缩放 + 不染色)
|
||||
* 2. 否则 → 走 [categoryFor] 出品类 vector icon(Teal 染色,跟主品牌色一致)
|
||||
*
|
||||
* Arena / Alert / Records 这种动态 title 场景自动得到"有图就用真图,没图也不丑"的体验。
|
||||
*
|
||||
* @param containerColor 容器底色 — vector icon 时用,真图固定用白底(产品图本就白底)。
|
||||
*/
|
||||
@Composable
|
||||
fun ProductThumb(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 54.dp,
|
||||
containerColor: Color = DbbColors.Fill,
|
||||
) {
|
||||
val realImage = ProductImageMap.findImage(title)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(size * 0.22f))
|
||||
.background(if (realImage != null) Color.White else containerColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (realImage != null) {
|
||||
Image(
|
||||
painter = painterResource(realImage),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(size).clip(RoundedCornerShape(size * 0.22f)),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
val cat = categoryFor(title)
|
||||
Image(
|
||||
painter = painterResource(cat.drawableRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size * 0.66f),
|
||||
colorFilter = ColorFilter.tint(DbbColors.TealDark),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+552
@@ -0,0 +1,552 @@
|
||||
package com.jishisongfu.duobibi.ui.alert
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.data.db.AlertMode
|
||||
import com.jishisongfu.duobibi.data.db.AlertStatus
|
||||
import com.jishisongfu.duobibi.data.db.PriceAlert
|
||||
import com.jishisongfu.duobibi.ui.ALL_PLATFORMS
|
||||
import com.jishisongfu.duobibi.ui.DbbProgressBar
|
||||
import com.jishisongfu.duobibi.ui.HeroBanner
|
||||
import com.jishisongfu.duobibi.ui.PlatformChip
|
||||
import com.jishisongfu.duobibi.ui.ProductThumb
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.rememberHaptics
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbGradients
|
||||
import kotlin.math.abs
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlertScreen(vm: AlertViewModel = hiltViewModel()) {
|
||||
val wants by vm.wants.collectAsStateWithLifecycle()
|
||||
val boughts by vm.boughts.collectAsStateWithLifecycle()
|
||||
val readyCount by vm.readyCount.collectAsStateWithLifecycle()
|
||||
val refreshing by vm.refreshing.collectAsStateWithLifecycle()
|
||||
|
||||
// 旋转 / 进详情再返回时保留 Tab 选择和 Sheet 打开态。
|
||||
var tab by rememberSaveable { mutableStateOf(0) }
|
||||
var addOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val haptics = rememberHaptics()
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { haptics.confirm(); addOpen = true },
|
||||
containerColor = DbbColors.Teal,
|
||||
contentColor = Color.White,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("加监控")
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.background(DbbColors.Bg),
|
||||
) {
|
||||
HeroBanner(
|
||||
title = "降价雷达",
|
||||
subtitle = if (readyCount > 0) "$readyCount 件已到价,快去看看" else "盯着想买和已买的,降价就提醒你",
|
||||
icon = Icons.Filled.NotificationsActive,
|
||||
gradient = DbbGradients.Alert,
|
||||
trailing = {
|
||||
IconButton(
|
||||
onClick = { haptics.confirm(); vm.refreshAll() },
|
||||
enabled = !refreshing,
|
||||
) {
|
||||
Icon(Icons.Filled.Refresh, "立即查价", tint = Color.White)
|
||||
}
|
||||
},
|
||||
)
|
||||
TodayActivityRow(allAlerts = wants + boughts)
|
||||
Box(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
StatCard(wants.size + boughts.size, readyCount)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SegChip("想买的 ${wants.size}", tab == 0) { tab = 0 }
|
||||
SegChip("已买的 ${boughts.size}", tab == 1) { tab = 1 }
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("按到价优先", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
val list = if (tab == 0) wants else boughts
|
||||
// 用 PullToRefreshBox 包列表区:从列表中部下拉触发 vm.refreshAll(),
|
||||
// refreshing 状态由 VM 暴露的 StateFlow 反向驱动指示器(转完才收回),
|
||||
// 跟 hero 右上 Refresh 按钮共享同一个底层动作。
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = vm::refreshAll,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
if (list.isEmpty()) {
|
||||
EmptyHint(
|
||||
if (tab == 0) "还没有想买的商品。\n加一个并设置心理价,降到就提醒你。"
|
||||
else "还没有已买的商品。\n加一个,买后还降价就提醒你申请价保。",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(14.dp, 0.dp, 14.dp, 88.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(list, key = { it.id }) { a ->
|
||||
AlertCard(
|
||||
a,
|
||||
onStop = { vm.stop(a.id) },
|
||||
onArchive = { vm.archive(a.id) },
|
||||
onReactivate = { vm.reactivate(a.id) },
|
||||
onDelete = { vm.delete(a.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (addOpen) {
|
||||
AddAlertSheet(
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
onDismiss = { addOpen = false },
|
||||
onAddWant = { t, p, target -> vm.addWant(t, p, target); addOpen = false },
|
||||
onAddBought = { t, p, price -> vm.addBought(t, p, price); addOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero 下方"今日动态"横滑条 — 解决 hero 与 StatCard 之间的空荡感。
|
||||
*
|
||||
* 内容自适应:
|
||||
* - 有已到价(lastSavings > 0)→ 优先展示这些(降价金额最大者在前)
|
||||
* - 没有到价但有最近查过的 → 展示"价格无变动"的最近 3 条
|
||||
* - 完全空(列表是 0)→ 展示 3 张"占位卡":提示用户去加监控
|
||||
*/
|
||||
@Composable
|
||||
private fun TodayActivityRow(allAlerts: List<PriceAlert>) {
|
||||
val active = remember(allAlerts) {
|
||||
// 排序优先级:已到价 (lastSavings > 0) > 有查过 > 其它,各类内部按 lastCheckedAt 降序
|
||||
allAlerts
|
||||
.filter { it.lastCurrentPrice != null }
|
||||
.sortedWith(
|
||||
compareByDescending<PriceAlert> { (it.lastSavings ?: 0.0) > 0.0 }
|
||||
.thenByDescending { it.lastCheckedAt ?: 0L }
|
||||
)
|
||||
.take(5)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (active.isEmpty()) {
|
||||
ActivityPlaceholderCard("📉", "盯个想买的", "降到心理价就推送")
|
||||
ActivityPlaceholderCard("💸", "盯个已买的", "降价提示申请价保")
|
||||
ActivityPlaceholderCard("⏰", "每日自动查", "不用手动刷")
|
||||
} else {
|
||||
active.forEach { a -> ActivityCard(a) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityCard(a: PriceAlert) {
|
||||
val ready = (a.lastSavings ?: 0.0) > 0.0
|
||||
val accent = if (ready) DbbColors.Save else DbbColors.InkSec
|
||||
val accentBg = if (ready) DbbColors.SaveBg else DbbColors.Fill
|
||||
val current = a.lastCurrentPrice ?: 0.0
|
||||
// 横向布局:左图 + 右信息列。固定 195×110 dp,跟 ActivityPlaceholderCard 等大,
|
||||
// 避免横滑条里有的卡大有的卡小的视觉跳跃。
|
||||
// ProductThumb 内部按 title 查 ProductImageMap,命中真图就显图,否则 vector icon 兜底。
|
||||
Row(
|
||||
Modifier
|
||||
.width(195.dp)
|
||||
.height(110.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(DbbColors.Card)
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
ProductThumb(title = a.title, size = 44.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(accentBg)
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(
|
||||
if (ready) "已到价" else "监控中",
|
||||
color = accent,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
val arrow = when (a.lastTrend) { "down" -> "↓"; "up" -> "↑"; else -> "·" }
|
||||
Text(arrow, color = accent, fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
a.title, color = DbbColors.Ink, fontSize = 11.sp, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2, lineHeight = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
"¥${fmtYuan(current)}",
|
||||
color = accent, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp,
|
||||
letterSpacing = (-0.4).sp,
|
||||
)
|
||||
if (ready) {
|
||||
Text("省 ¥${fmtYuan(a.lastSavings ?: 0.0)}", color = DbbColors.Save, fontSize = 10.sp)
|
||||
} else {
|
||||
Text(a.platform ?: "全网", color = DbbColors.InkTer, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityPlaceholderCard(emoji: String, title: String, subtitle: String) {
|
||||
// 跟 ActivityCard 等大(195×110 dp),内容垂直居中,避免横滑条里大小跳变。
|
||||
Column(
|
||||
Modifier
|
||||
.width(195.dp)
|
||||
.height(110.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(DbbColors.Card)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(emoji, fontSize = 22.sp)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(title, color = DbbColors.Ink, fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||
Text(subtitle, color = DbbColors.InkTer, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatCard(totalCount: Int, readyCount: Int) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.TealBg),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.NotificationsActive, contentDescription = null,
|
||||
tint = DbbColors.TealDark, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"监控中 $totalCount 件 · 已到价 $readyCount 件",
|
||||
color = DbbColors.TealDark, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("自动定时查价", color = DbbColors.InkSec, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SegChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
FilterChip(
|
||||
selected = selected, onClick = onClick, label = { Text(label) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = DbbColors.Teal, selectedLabelColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyHint(text: String) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text,
|
||||
color = DbbColors.InkTer, fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlertCard(
|
||||
a: PriceAlert,
|
||||
onStop: () -> Unit,
|
||||
onArchive: () -> Unit,
|
||||
onReactivate: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val archived = a.status != AlertStatus.ACTIVE
|
||||
val ready = (a.lastSavings ?: 0.0) > 0.0
|
||||
val anchor = if (a.mode == AlertMode.WANT) a.targetPrice else a.purchasePrice
|
||||
val anchorLabel = if (a.mode == AlertMode.WANT) "心理价" else "买价"
|
||||
val current = a.lastCurrentPrice
|
||||
val platformDisplay = a.platform ?: "全网"
|
||||
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
border = BorderStroke(
|
||||
if (ready && !archived) 1.5.dp else 1.dp,
|
||||
if (ready && !archived) DbbColors.Teal else DbbColors.Border,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(13.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProductThumb(title = a.title, size = 36.dp)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
a.title, fontWeight = FontWeight.Bold, color = DbbColors.Ink,
|
||||
modifier = Modifier.weight(1f), maxLines = 2,
|
||||
)
|
||||
PlatformChip(name = platformDisplay)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
"$anchorLabel ¥${fmtYuan(anchor ?: 0.0)}",
|
||||
color = DbbColors.InkSec, fontSize = 13.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (current != null) {
|
||||
val arrow = when (a.lastTrend) { "down" -> " ↓"; "up" -> " ↑"; else -> "" }
|
||||
val priceColor = if (ready) DbbColors.Save else DbbColors.Ink
|
||||
Text(
|
||||
"当前 ¥${fmtYuan(current)}$arrow",
|
||||
fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, color = priceColor,
|
||||
letterSpacing = (-0.5).sp,
|
||||
)
|
||||
} else {
|
||||
Text("待查价", color = DbbColors.InkTer, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
if (current != null && anchor != null && anchor > 0) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val frac: Float
|
||||
val barColor: Color
|
||||
if (ready) {
|
||||
frac = 1f
|
||||
barColor = DbbColors.Save
|
||||
} else {
|
||||
frac = (anchor / current).coerceIn(0.0, 1.0).toFloat()
|
||||
// 未到价用中性灰条:把屏幕色相压到 teal/save/up 三色,不再用 Gold 第四色。
|
||||
barColor = DbbColors.InkTer
|
||||
}
|
||||
DbbProgressBar(frac, color = barColor)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row {
|
||||
val diffText = if (ready) {
|
||||
"已低于${anchorLabel} ¥${fmtYuan(abs(anchor - current))}"
|
||||
} else {
|
||||
"还差 ¥${fmtYuan(current - anchor)} 到${anchorLabel}"
|
||||
}
|
||||
Text(diffText, color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (ready) Text("近 7 天最低", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
if (ready && !archived) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val msg = if (a.mode == AlertMode.WANT) {
|
||||
"🎯 已降到心理价以下,可入手"
|
||||
} else {
|
||||
"💰 比买价低 ¥${fmtYuan(a.lastSavings ?: 0.0)},可申请价保"
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(DbbColors.SaveBg)
|
||||
.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||
) {
|
||||
Text(msg, color = DbbColors.Save, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
if (archived) {
|
||||
TextButton(onClick = onReactivate) { Text("恢复监控", color = DbbColors.Teal) }
|
||||
} else {
|
||||
if (a.mode == AlertMode.BOUGHT && ready) {
|
||||
TextButton(onClick = onArchive) { Text("已申请·归档", color = DbbColors.Teal) }
|
||||
}
|
||||
TextButton(onClick = onStop) { Text("停止", color = DbbColors.InkSec) }
|
||||
}
|
||||
TextButton(onClick = onDelete) { Text("删除", color = DbbColors.Up) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AddAlertSheet(
|
||||
sheetState: SheetState,
|
||||
onDismiss: () -> Unit,
|
||||
onAddWant: (String, String?, Double?) -> Unit,
|
||||
onAddBought: (String, String, Double) -> Unit,
|
||||
) {
|
||||
// 旋转 / 切到后台再回来时保留表单内容,避免用户重新输 — Bottom Sheet 表单丢字最痛。
|
||||
// AlertMode 是 object/String 常量,nullable 也可序列化,都能 rememberSaveable。
|
||||
var mode by rememberSaveable { mutableStateOf(AlertMode.WANT) }
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var platform by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var priceText by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = DbbColors.Card) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp).padding(bottom = 16.dp)) {
|
||||
Text("加入降价监控", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = DbbColors.Ink)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SegChip("想买的", mode == AlertMode.WANT) { mode = AlertMode.WANT }
|
||||
SegChip("已买的", mode == AlertMode.BOUGHT) { mode = AlertMode.BOUGHT }
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
OutlinedTextField(
|
||||
value = title, onValueChange = { title = it },
|
||||
label = { Text("商品名") },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
if (mode == AlertMode.WANT) "平台(可不选,任意平台到价都提醒)" else "平台",
|
||||
color = DbbColors.InkSec, fontSize = 12.sp,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (mode == AlertMode.WANT) {
|
||||
FilterChip(
|
||||
selected = platform == null, onClick = { platform = null },
|
||||
label = { Text("不限") },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = DbbColors.Teal,
|
||||
selectedLabelColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
ALL_PLATFORMS.forEach { p ->
|
||||
FilterChip(
|
||||
selected = platform == p, onClick = { platform = p },
|
||||
label = { Text(p) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = DbbColors.Teal,
|
||||
selectedLabelColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = priceText,
|
||||
onValueChange = { priceText = it.filter { c -> c.isDigit() || c == '.' } },
|
||||
label = { Text(if (mode == AlertMode.WANT) "心理价(元)" else "购买价(元)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(18.dp))
|
||||
val price = priceText.toDoubleOrNull()
|
||||
val valid = title.isNotBlank() &&
|
||||
(if (mode == AlertMode.WANT) (price == null || price > 0)
|
||||
else (price != null && price > 0 && platform != null))
|
||||
Button(
|
||||
onClick = {
|
||||
if (mode == AlertMode.WANT) onAddWant(title, platform, price)
|
||||
else onAddBought(title, platform!!, price!!)
|
||||
},
|
||||
enabled = valid,
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) { Text("开始监控", fontWeight = FontWeight.SemiBold) }
|
||||
if (mode == AlertMode.BOUGHT) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"提示:已买的需选平台 + 填买价,降价后提醒你申请价保。",
|
||||
color = DbbColors.InkTer, fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.jishisongfu.duobibi.ui.alert
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.data.db.AlertMode
|
||||
import com.jishisongfu.duobibi.data.db.AlertStatus
|
||||
import com.jishisongfu.duobibi.data.db.PriceAlert
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
import com.jishisongfu.duobibi.util.AlertNotifier
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 降价雷达 ViewModel —— 想买的(WANT)/ 已买的(BOUGHT)两段共用。
|
||||
* 列表为 reactive Flow;写操作 fire-and-forget;"立即查价"复用 batchCheckAlerts + 通知。
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AlertViewModel @Inject constructor(
|
||||
app: Application,
|
||||
private val repo: PriceRepository,
|
||||
) : AndroidViewModel(app) {
|
||||
|
||||
val wants: StateFlow<List<PriceAlert>> =
|
||||
repo.observeAlertsByMode(AlertMode.WANT)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val boughts: StateFlow<List<PriceAlert>> =
|
||||
repo.observeAlertsByMode(AlertMode.BOUGHT)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val readyCount: StateFlow<Int> =
|
||||
repo.observeReadyAlertCount()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
|
||||
|
||||
private val _refreshing = MutableStateFlow(false)
|
||||
val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
|
||||
|
||||
fun addWant(title: String, platform: String?, targetPrice: Double?) {
|
||||
if (title.isBlank()) return
|
||||
viewModelScope.launch { repo.addWantAlert(title, platform, targetPrice) }
|
||||
}
|
||||
|
||||
fun addBought(title: String, platform: String, purchasePrice: Double) {
|
||||
if (title.isBlank() || platform.isBlank() || purchasePrice <= 0) return
|
||||
viewModelScope.launch { repo.addBoughtAlert(title, platform, purchasePrice, System.currentTimeMillis()) }
|
||||
}
|
||||
|
||||
fun stop(id: Long) = viewModelScope.launch { repo.setAlertStatus(id, AlertStatus.STOPPED) }
|
||||
fun archive(id: Long) = viewModelScope.launch { repo.setAlertStatus(id, AlertStatus.DONE) }
|
||||
fun reactivate(id: Long) = viewModelScope.launch { repo.setAlertStatus(id, AlertStatus.ACTIVE) }
|
||||
fun delete(id: Long) = viewModelScope.launch { repo.deleteAlert(id) }
|
||||
|
||||
/** 立即查价:跟 MainActivity.onResume 同路径,batchCheck + 发本地通知。 */
|
||||
fun refreshAll() {
|
||||
if (_refreshing.value) return
|
||||
_refreshing.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val triggered = withContext(Dispatchers.IO) { repo.batchCheckAlerts() }
|
||||
if (triggered.isNotEmpty()) AlertNotifier.notifyTriggered(getApplication(), triggered)
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
_refreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package com.jishisongfu.duobibi.ui.arena
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.jishisongfu.duobibi.R
|
||||
|
||||
/**
|
||||
* 比价擂台首屏的品类入口(参考 shaguabijia 品类页设计语言)。
|
||||
*
|
||||
* 每个品类锁定一个固定平台集合,点击后用该集合调 arena-quote
|
||||
* (后端 ArenaQuoteRequest 已支持 platforms 参数,不传则用默认 4 大电商)。
|
||||
*
|
||||
* 配色是这一屏专属的"分类编码",故意打破其它屏的 3 色约束。icon 全部自绘
|
||||
* 几何线条(rectangle / circle / path),不复刻任何平台或商品的真实 logo /
|
||||
* 图标设计 —— 走通用商超符号系统。
|
||||
*/
|
||||
enum class ArenaCategory(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val group: String,
|
||||
val platforms: List<String>,
|
||||
val placeholder: String,
|
||||
@DrawableRes val iconRes: Int,
|
||||
val tintBg: Color,
|
||||
val tintFg: Color,
|
||||
) {
|
||||
Takeaway(
|
||||
title = "外卖比价",
|
||||
subtitle = "美团外卖、饿了么 · 同店同菜全对比",
|
||||
group = "餐饮外卖",
|
||||
platforms = listOf("美团外卖", "饿了么"),
|
||||
placeholder = "搜餐品名,如 红烧肥牛饭",
|
||||
iconRes = R.drawable.ic_cat_takeaway,
|
||||
tintBg = Color(0xFFFFEDD5),
|
||||
tintFg = Color(0xFFC2410C),
|
||||
),
|
||||
Grocery(
|
||||
title = "同城买菜",
|
||||
subtitle = "美团、饿了么 同城配送一键比",
|
||||
group = "餐饮外卖",
|
||||
platforms = listOf("美团", "饿了么"),
|
||||
placeholder = "搜生鲜,如 五花肉 500g",
|
||||
iconRes = R.drawable.ic_cat_grocery,
|
||||
tintBg = Color(0xFFD1FAE5),
|
||||
tintFg = Color(0xFF047857),
|
||||
),
|
||||
Ecommerce(
|
||||
title = "电商比价",
|
||||
subtitle = "淘宝、京东、拼多多、抖音 全网到手价",
|
||||
group = "网上购物",
|
||||
platforms = listOf("淘宝", "京东", "拼多多", "抖音"),
|
||||
placeholder = "输入商品名,如 iPhone 15 Pro 256G",
|
||||
iconRes = R.drawable.ic_cat_ecommerce,
|
||||
tintBg = Color(0xFFFEE2E2),
|
||||
tintFg = Color(0xFFB91C1C),
|
||||
),
|
||||
Digital(
|
||||
title = "数码家电",
|
||||
subtitle = "手机 / 笔电 / 家电 · 自动跨平台拉表",
|
||||
group = "网上购物",
|
||||
platforms = listOf("淘宝", "京东", "拼多多", "抖音"),
|
||||
placeholder = "搜数码,如 MacBook Air 13",
|
||||
iconRes = R.drawable.ic_cat_digital,
|
||||
tintBg = Color(0xFFDBEAFE),
|
||||
tintFg = Color(0xFF1D4ED8),
|
||||
),
|
||||
Fashion(
|
||||
title = "服饰美妆",
|
||||
subtitle = "同款同色 跨店铺到手价对比",
|
||||
group = "网上购物",
|
||||
platforms = listOf("淘宝", "京东", "拼多多", "抖音"),
|
||||
placeholder = "搜款式,如 雅诗兰黛精华液",
|
||||
iconRes = R.drawable.ic_cat_fashion,
|
||||
tintBg = Color(0xFFFCE7F3),
|
||||
tintFg = Color(0xFFBE185D),
|
||||
),
|
||||
Daily(
|
||||
title = "日用百货",
|
||||
subtitle = "淘京拼抖 + 美团闪购 全场景比",
|
||||
group = "网上购物",
|
||||
platforms = listOf("淘宝", "京东", "拼多多", "抖音", "美团"),
|
||||
placeholder = "搜日用,如 抽纸 24 包",
|
||||
iconRes = R.drawable.ic_cat_daily,
|
||||
tintBg = Color(0xFFEDE9FE),
|
||||
tintFg = Color(0xFF6D28D9),
|
||||
);
|
||||
|
||||
companion object {
|
||||
/** 用于按 group 分组渲染(餐饮外卖 / 网上购物)。 */
|
||||
val ORDERED_GROUPS: List<String> = listOf("餐饮外卖", "网上购物")
|
||||
}
|
||||
}
|
||||
+627
@@ -0,0 +1,627 @@
|
||||
package com.jishisongfu.duobibi.ui.arena
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Balance
|
||||
import androidx.compose.material.icons.filled.EmojiEvents
|
||||
import androidx.compose.material.icons.filled.HelpOutline
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.data.repo.ArenaOutcome
|
||||
import com.jishisongfu.duobibi.data.repo.ArenaRow
|
||||
import com.jishisongfu.duobibi.ui.ARENA_PLATFORMS
|
||||
import com.jishisongfu.duobibi.ui.HeroBanner
|
||||
import com.jishisongfu.duobibi.ui.PlatformChip
|
||||
import com.jishisongfu.duobibi.ui.PriceGauge
|
||||
import com.jishisongfu.duobibi.ui.ProductThumb
|
||||
import com.jishisongfu.duobibi.ui.SummaryBar
|
||||
import com.jishisongfu.duobibi.ui.TagChip
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.rememberHaptics
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbGradients
|
||||
import com.jishisongfu.duobibi.util.PlatformSearch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ArenaScreen(
|
||||
onOpenCluster: (Long) -> Unit,
|
||||
vm: ArenaViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val input by vm.input.collectAsStateWithLifecycle()
|
||||
val selectedCategory by vm.selectedCategory.collectAsStateWithLifecycle()
|
||||
val activePlatforms = selectedCategory?.platforms ?: ARENA_PLATFORMS
|
||||
val placeholderText = selectedCategory?.placeholder ?: "输入商品名,如 iPhone 15 Pro 256G"
|
||||
val haptics = rememberHaptics()
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.background(DbbColors.Bg)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
HeroBanner(
|
||||
title = "比价擂台",
|
||||
subtitle = "一个商品,多平台到手价同台 PK",
|
||||
icon = Icons.Filled.Balance,
|
||||
gradient = DbbGradients.Arena,
|
||||
trailing = {
|
||||
IconButton(onClick = { /* TODO 帮助说明 */ }) {
|
||||
Icon(Icons.Filled.HelpOutline, contentDescription = "帮助", tint = Color.White)
|
||||
}
|
||||
},
|
||||
)
|
||||
HotSearchRow(onPick = { vm.setInput(it) })
|
||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = vm::setInput,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text(placeholderText) },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, null, tint = DbbColors.Teal) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
ComparePlatformsRow(platforms = activePlatforms)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { haptics.confirm(); vm.submit() },
|
||||
enabled = state !is ArenaUiState.Loading && input.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) {
|
||||
Icon(Icons.Filled.EmojiEvents, null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("开始比价", fontWeight = FontWeight.SemiBold, fontSize = 16.sp)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
when (val s = state) {
|
||||
ArenaUiState.Idle -> {
|
||||
val cat = selectedCategory
|
||||
if (cat == null) {
|
||||
// 首屏:品类浏览(参考 shaguabijia 品类页)
|
||||
CategoryBrowse(onSelect = vm::selectCategory)
|
||||
} else {
|
||||
// 已锁定品类:顶部色条提示 + 原有引导
|
||||
CategoryActiveBanner(category = cat, onReset = { vm.selectCategory(null) })
|
||||
Spacer(Modifier.height(10.dp))
|
||||
IdleHint()
|
||||
}
|
||||
}
|
||||
ArenaUiState.Loading -> LoadingHint()
|
||||
is ArenaUiState.Error -> ErrorHint(s.message)
|
||||
is ArenaUiState.Success -> ArenaResult(s.outcome, onOpenCluster, vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比价 Hero 下方"热搜词"横滑条 —— 解决 hero 与搜索框之间空荡问题。
|
||||
* 词条写死成主流比价 App 常见热词,不涉及网络/数据库;点击直接 prefill 搜索框。
|
||||
* 配色用 TealBg 浅胶囊,避免抢搜索框焦点。
|
||||
*/
|
||||
@Composable
|
||||
private fun HotSearchRow(onPick: (String) -> Unit) {
|
||||
val hot = remember {
|
||||
listOf("iPhone 15 Pro", "MacBook Air 13", "戴森吹风机", "雅诗兰黛精华", "扫地机器人", "AirPods 4", "茅台飞天")
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("🔥 热搜", color = DbbColors.Up, fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
hot.forEachIndexed { idx, w ->
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(11.dp))
|
||||
.background(if (idx == 0) DbbColors.UpBg else DbbColors.TealBg)
|
||||
.clickable { onPick(w) }
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
) {
|
||||
Text(
|
||||
w,
|
||||
color = if (idx == 0) DbbColors.Up else DbbColors.TealDark,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComparePlatformsRow(platforms: List<String>) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("对比平台", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
platforms.forEach { p ->
|
||||
PlatformChip(name = p)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Text("· 到手价(不含大促)", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdleHint() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.TealBg),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(18.dp)) {
|
||||
Text("🏆 多比比 · 一搜知全网", fontWeight = FontWeight.Bold, color = DbbColors.TealDark)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"输入想买的东西,多比比帮你把 淘宝 / 京东 / 拼多多 / 抖音 的日常到手价拉到一张表里对比," +
|
||||
"自动标出全网最低。你在购物 App 里点蓝色浮窗记过的真实价,会直接覆盖到对应平台。",
|
||||
color = DbbColors.InkSec, fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingHint() {
|
||||
Box(Modifier.fillMaxWidth().padding(top = 40.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = DbbColors.Teal, strokeWidth = 3.dp)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text("正在把全网价格拉上擂台…", color = DbbColors.InkSec, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorHint(msg: String) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.UpBg),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(msg, modifier = Modifier.padding(16.dp), color = DbbColors.Up)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArenaResult(outcome: ArenaOutcome, onOpenCluster: (Long) -> Unit, vm: ArenaViewModel) {
|
||||
val ctx = LocalContext.current
|
||||
val minPrice = outcome.rows.minOfOrNull { it.price } ?: outcome.typicalPrice
|
||||
val maxPrice = outcome.rows.maxOfOrNull { it.price } ?: outcome.typicalPrice
|
||||
val savings = (outcome.typicalPrice - minPrice).coerceAtLeast(0.0)
|
||||
|
||||
// Hero 渐变汇总条
|
||||
outcome.lowestPlatform?.let { lowest ->
|
||||
SummaryBar(
|
||||
label = "全网最低 · $lowest",
|
||||
bigText = "¥${fmtYuan(minPrice)}",
|
||||
subText = if (savings > 0) "较常见价省 ¥${fmtYuan(savings)}" else "与常见价持平",
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
// 标题 + 水位卡
|
||||
TitleGaugeCard(outcome, minPrice, maxPrice)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// 对比表
|
||||
CompareTableCard(outcome)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// 本机已记录提示
|
||||
if (outcome.matchedClusterId != null) {
|
||||
MatchedHintCard(outcome, onOpenCluster)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
// 两个底部按钮
|
||||
BottomActions(outcome, ctx, vm)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"价格为各平台日常到手价估算(不含大促),仅供参考,以平台实际为准。",
|
||||
color = DbbColors.InkTer, fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleGaugeCard(outcome: ArenaOutcome, minPrice: Double, maxPrice: Double) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
// 标题 + 商品略缩图(按品类剪影)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProductThumb(
|
||||
title = outcome.normalizedTitle,
|
||||
size = 54.dp,
|
||||
containerColor = DbbColors.TealBg,
|
||||
)
|
||||
Spacer(Modifier.width(11.dp))
|
||||
Text(
|
||||
outcome.normalizedTitle,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = DbbColors.Ink,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"市场常见价 ¥${fmtYuan(outcome.typicalPrice)}",
|
||||
color = DbbColors.InkSec, fontSize = 12.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
val spread = (maxPrice - minPrice).coerceAtLeast(0.0)
|
||||
Text("价差区间 ¥${fmtYuan(spread)}", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PriceGauge(min = minPrice, max = maxPrice, marker = outcome.typicalPrice)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"最低 ¥${fmtYuan(minPrice)}",
|
||||
color = DbbColors.Save, fontSize = 11.sp, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
"¥${fmtYuan(outcome.typicalPrice)}",
|
||||
color = DbbColors.InkTer, fontSize = 11.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("最高 ¥${fmtYuan(maxPrice)}", color = DbbColors.Up, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompareTableCard(outcome: ArenaOutcome) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 4.dp)) {
|
||||
outcome.rows.forEachIndexed { i, row ->
|
||||
ArenaRowItem(row, isLowest = row.platform == outcome.lowestPlatform)
|
||||
if (i < outcome.rows.lastIndex) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 14.dp)
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(DbbColors.Border),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArenaRowItem(row: ArenaRow, isLowest: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isLowest) DbbColors.TealBg else Color.Transparent)
|
||||
.padding(horizontal = 14.dp, vertical = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// 最低用品牌色实心胶囊"最低"+ 后跟平台名;非最低直接平台名胶囊。参考主流比价 App 的"chip 在前"布局。
|
||||
if (isLowest) {
|
||||
TagChip("最低", bg = DbbColors.Teal, fg = Color.White)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
PlatformChip(name = row.platform)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
if (row.isLocal) {
|
||||
TagChip("本机", bg = DbbColors.SaveBg, fg = DbbColors.Save)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(
|
||||
row.note.ifBlank { if (row.isLocal) "记录最低" else "AI 估算" },
|
||||
color = DbbColors.InkTer,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
"¥${fmtYuan(row.price)}",
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
color = if (isLowest) DbbColors.Teal else DbbColors.Ink,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MatchedHintCard(outcome: ArenaOutcome, onOpenCluster: (Long) -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { outcome.matchedClusterId?.let(onOpenCluster) },
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.SaveBg),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"✓ 你已在本机记录过同款,本机最低 ¥${fmtYuan(outcome.localMinPrice ?: 0.0)} · 点击查看记录详情 ›",
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
color = DbbColors.Save,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomActions(outcome: ArenaOutcome, ctx: Context, vm: ArenaViewModel) {
|
||||
val haptics = rememberHaptics()
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
haptics.confirm()
|
||||
outcome.lowestPlatform?.let {
|
||||
PlatformSearch.copyAndSearch(ctx, it, outcome.normalizedTitle)
|
||||
}
|
||||
},
|
||||
enabled = outcome.lowestPlatform != null,
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) {
|
||||
Text(
|
||||
"去${outcome.lowestPlatform ?: "京东"}搜一下",
|
||||
fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
// 把擂台当下"全网最低"作为心理价直接加入"想买的"雷达,加完 Toast 反馈。
|
||||
// 用户后续可去雷达 Tab 改心理价 / 平台 / 删除。
|
||||
haptics.confirm()
|
||||
val target = outcome.rows.minOfOrNull { it.price } ?: outcome.typicalPrice
|
||||
if (target > 0) {
|
||||
vm.addToAlert(outcome.normalizedTitle, target) {
|
||||
Toast.makeText(
|
||||
ctx,
|
||||
"已加入降价雷达 · 心理价 ¥${fmtYuan(target)}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
border = BorderStroke(1.5.dp, DbbColors.Teal),
|
||||
) {
|
||||
Text(
|
||||
"加入降价雷达",
|
||||
color = DbbColors.TealDark,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 品类浏览 (参考 shaguabijia 品类首页) ====================
|
||||
|
||||
/**
|
||||
* 比价擂台 Idle 态的品类浏览:按 group 分两组(餐饮外卖 / 网上购物),每组列出
|
||||
* 对应 [ArenaCategory]。点击任一项 → vm.selectCategory(it) 锁定平台集合。
|
||||
*
|
||||
* 配色是这一屏专属的"分类编码",故意打破其它屏的 3 色约束。
|
||||
*/
|
||||
@Composable
|
||||
private fun CategoryBrowse(onSelect: (ArenaCategory) -> Unit) {
|
||||
val grouped = ArenaCategory.entries.groupBy { it.group }
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
ArenaCategory.ORDERED_GROUPS.forEachIndexed { idx, groupName ->
|
||||
if (idx > 0) Spacer(Modifier.height(14.dp))
|
||||
val emoji = if (groupName == "餐饮外卖") "🍱 " else "🛍 "
|
||||
Text(
|
||||
"$emoji$groupName",
|
||||
color = DbbColors.Ink,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 2.dp, bottom = 7.dp),
|
||||
)
|
||||
grouped[groupName].orEmpty().forEachIndexed { i, c ->
|
||||
if (i > 0) Spacer(Modifier.height(8.dp))
|
||||
CategoryCard(c, onClick = { onSelect(c) })
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"挑品类后会自动锁定对比的平台集合,也可在结果页里增删",
|
||||
color = DbbColors.InkTer,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryCard(c: ArenaCategory, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(13.dp))
|
||||
.background(DbbColors.Card)
|
||||
.border(1.dp, DbbColors.Border, RoundedCornerShape(13.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 13.dp, vertical = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.clip(CircleShape)
|
||||
.background(c.tintBg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(c.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
colorFilter = ColorFilter.tint(c.tintFg),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(11.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
c.title,
|
||||
color = DbbColors.Ink,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(c.tintBg)
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
"${c.platforms.size} 个平台",
|
||||
color = c.tintFg,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(c.subtitle, color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
Text("›", color = DbbColors.InkTer, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
|
||||
/** 已锁定品类时显示的顶部色条,带"切换"按钮回到品类首屏。 */
|
||||
@Composable
|
||||
private fun CategoryActiveBanner(category: ArenaCategory, onReset: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(category.tintBg)
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(category.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
colorFilter = ColorFilter.tint(category.tintFg),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"已锁定品类 · ${category.title}",
|
||||
color = category.tintFg,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Text(
|
||||
category.platforms.joinToString(" / "),
|
||||
color = category.tintFg.copy(alpha = 0.8f),
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onReset,
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
"切换品类 ›",
|
||||
color = category.tintFg,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.jishisongfu.duobibi.ui.arena
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.data.repo.ArenaOutcome
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
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
|
||||
|
||||
sealed interface ArenaUiState {
|
||||
data object Idle : ArenaUiState
|
||||
data object Loading : ArenaUiState
|
||||
data class Success(val outcome: ArenaOutcome) : ArenaUiState
|
||||
data class Error(val message: String) : ArenaUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class ArenaViewModel @Inject constructor(
|
||||
private val repo: PriceRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<ArenaUiState>(ArenaUiState.Idle)
|
||||
val state: StateFlow<ArenaUiState> = _state.asStateFlow()
|
||||
|
||||
private val _input = MutableStateFlow("")
|
||||
val input: StateFlow<String> = _input.asStateFlow()
|
||||
|
||||
/** 当前锁定的品类。null = 未选,走 ARENA_PLATFORMS 默认 4 大电商。 */
|
||||
private val _selectedCategory = MutableStateFlow<ArenaCategory?>(null)
|
||||
val selectedCategory: StateFlow<ArenaCategory?> = _selectedCategory.asStateFlow()
|
||||
|
||||
fun setInput(s: String) { _input.value = s }
|
||||
|
||||
/**
|
||||
* 切换品类。传 null 表示退出品类、回到默认电商比价。
|
||||
* 切换时清空旧结果,回到 Idle 让品类首屏或结果页正确刷新。
|
||||
*/
|
||||
fun selectCategory(c: ArenaCategory?) {
|
||||
if (_selectedCategory.value == c) return
|
||||
_selectedCategory.value = c
|
||||
_state.value = ArenaUiState.Idle
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val q = _input.value.trim()
|
||||
if (q.isEmpty()) {
|
||||
_state.value = ArenaUiState.Error("请输入要比价的商品名")
|
||||
return
|
||||
}
|
||||
_state.value = ArenaUiState.Loading
|
||||
val platforms = _selectedCategory.value?.platforms
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.arenaQuote(q, platforms) }
|
||||
.onSuccess { _state.value = ArenaUiState.Success(it) }
|
||||
.onFailure { _state.value = ArenaUiState.Error("比价失败,请稍后重试") }
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() { _state.value = ArenaUiState.Idle }
|
||||
|
||||
/** 从擂台一键加入"想买的"降价雷达。target 通常传擂台搜出来的全网最低价。 */
|
||||
fun addToAlert(title: String, targetPrice: Double, onDone: () -> Unit) {
|
||||
if (title.isBlank() || targetPrice <= 0) return
|
||||
viewModelScope.launch {
|
||||
repo.addWantAlert(title.trim(), null, targetPrice)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 给"发现"页用:外部带着 title(可选品类)直接预填 + 提交,
|
||||
* 调用方再跳到"比价"Tab,出对比表。
|
||||
*
|
||||
* 传 category 时会顺手锁定品类(影响后端 arena-quote 的 platforms 参数);
|
||||
* 不传则沿用用户当前已选品类(可能是 null = 默认 4 大电商)。
|
||||
*/
|
||||
fun submitWithTitle(title: String, category: ArenaCategory? = null) {
|
||||
if (title.isBlank()) return
|
||||
if (category != null) _selectedCategory.value = category
|
||||
_input.value = title.trim()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
+626
@@ -0,0 +1,626 @@
|
||||
package com.jishisongfu.duobibi.ui.detail
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Balance
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material.icons.outlined.IosShare
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.ui.PlatformChip
|
||||
import com.jishisongfu.duobibi.ui.TagChip
|
||||
import com.jishisongfu.duobibi.ui.rememberHaptics
|
||||
import com.jishisongfu.duobibi.ui.discover.DISCOVER_SEED
|
||||
import com.jishisongfu.duobibi.ui.discover.DiscoverItem
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import com.jishisongfu.duobibi.ui.worth.computeHistoryStats
|
||||
import com.jishisongfu.duobibi.ui.worth.generate60dPrices
|
||||
import com.jishisongfu.duobibi.util.PlatformSearch
|
||||
import com.jishisongfu.duobibi.util.WatchedPrefs
|
||||
|
||||
/**
|
||||
* 商品详情页 —— Discover/Arena 共用的三级深度枢纽。
|
||||
*
|
||||
* 进入方式:Discover 瀑布流卡点击 → 这里。
|
||||
* 出去方式:返回 / 加入降价雷达 / 跳比价 Tab(跨平台对比) / 跳同类商品(自递归)
|
||||
*
|
||||
* 内容自上而下:
|
||||
* 1) Hero 区(渐变,顶到状态栏 + 返回按钮 + 商品 emoji+ 商品名 + 大价格 + 平台 chip)
|
||||
* 2) 多平台对比表(每平台一行,可点击跳搜)
|
||||
* 3) 60 天历史价格曲线(迷你)+ 60d 最低/均价/水位分位
|
||||
* 4) 行动 CTA(加入降价雷达 / 跨平台对比 / 去最低平台搜)
|
||||
* 5) 同类好物推荐(2-4 个同 category 的其它 DiscoverItem)
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProductDetailScreen(
|
||||
itemId: String,
|
||||
onBack: () -> Unit,
|
||||
onOpenArena: (title: String) -> Unit,
|
||||
onAddAlert: (title: String, target: Double) -> Unit,
|
||||
onOpenDetail: (itemId: String) -> Unit,
|
||||
) {
|
||||
val item = remember(itemId) { DISCOVER_SEED.firstOrNull { it.id == itemId } }
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
) { padding ->
|
||||
if (item == null) {
|
||||
NotFoundView(onBack = onBack, modifier = Modifier.padding(padding))
|
||||
return@Scaffold
|
||||
}
|
||||
val ctx = LocalContext.current
|
||||
val haptics = rememberHaptics()
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 32.dp),
|
||||
) {
|
||||
item { DetailHero(item, onBack) }
|
||||
item {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PriceTableCard(item, ctx)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
item {
|
||||
TrendCard(item)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
item {
|
||||
StatsRow(item)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
item {
|
||||
CtaRow(
|
||||
item = item,
|
||||
onAddAlert = { haptics.confirm(); onAddAlert(item.title, item.minPrice) },
|
||||
onOpenArena = { haptics.confirm(); onOpenArena(item.title) },
|
||||
onSearchLowest = {
|
||||
haptics.confirm()
|
||||
PlatformSearch.copyAndSearch(ctx, item.minPlatform, item.title)
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
val similar = DISCOVER_SEED.filter { it.category == item.category && it.id != item.id }.take(4)
|
||||
if (similar.isNotEmpty()) {
|
||||
item {
|
||||
SimilarHeader(category = item.category.title)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
items(similar, key = { it.id }) { other ->
|
||||
SimilarRow(other) { onOpenDetail(other.id) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
item { DisclaimerFooter() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 1) Hero ==============
|
||||
|
||||
@Composable
|
||||
private fun DetailHero(item: DiscoverItem, onBack: () -> Unit) {
|
||||
// 用商品类目色当 hero 渐变,让不同品类详情页色感不同(类小红书品类色)。
|
||||
val top = item.category.tintFg
|
||||
val bottom = blend(item.category.tintFg, Color(0xFF1E293B), 0.35f)
|
||||
val ctx = LocalContext.current
|
||||
val haptics = rememberHaptics()
|
||||
val watchedFlow = remember { WatchedPrefs.watchedFlow(ctx) }
|
||||
val watchedIds by watchedFlow.collectAsStateWithLifecycle(initialValue = WatchedPrefs.watchedIds(ctx))
|
||||
val isWatched = item.id in watchedIds
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Brush.verticalGradient(listOf(top, bottom)))
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 6.dp, bottom = 24.dp),
|
||||
) {
|
||||
// top bar(返回 + ❤️ + 分享)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回", tint = Color.White)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(
|
||||
onClick = { haptics.toggle(); WatchedPrefs.toggle(ctx, item.id) },
|
||||
modifier = Modifier.size(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (isWatched) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
|
||||
contentDescription = if (isWatched) "取消关注" else "关注",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { /* TODO 分享卡片 */ }, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.Outlined.IosShare, contentDescription = "分享", tint = Color.White)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// 商品圆牌:imageRes 优先(白底圆,跟产品图白底一致),emoji 兜底(半透明白圆,跟 hero 渐变协调)。
|
||||
Box(
|
||||
Modifier
|
||||
.size(86.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (item.imageRes != null) Color.White
|
||||
else Color.White.copy(alpha = 0.22f)
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (item.imageRes != null) {
|
||||
Image(
|
||||
painter = painterResource(item.imageRes),
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.fillMaxSize().padding(6.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
Text(item.emoji, fontSize = 44.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.White.copy(alpha = 0.22f))
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(item.category.title, color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
if (item.highlight != null) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.White.copy(alpha = 0.85f))
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(item.highlight, color = item.category.tintFg, fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
item.title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// 大价格 + 节省
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
"¥", color = Color.White,
|
||||
fontSize = 18.sp, fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = (-0.3).sp,
|
||||
)
|
||||
Text(
|
||||
fmtYuan(item.minPrice),
|
||||
color = Color.White,
|
||||
fontSize = 42.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = (-1.5).sp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
"全网最低 · ${item.minPlatform}",
|
||||
color = Color.White.copy(alpha = 0.92f), fontSize = 11.sp,
|
||||
)
|
||||
if (item.savings > 0) {
|
||||
Text(
|
||||
"较常见价省 ¥${fmtYuan(item.savings)}",
|
||||
color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 底部圆角"咬"出过渡
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(16.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
|
||||
.background(DbbColors.Bg),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 2) 多平台对比表 ==============
|
||||
|
||||
@Composable
|
||||
private fun PriceTableCard(item: DiscoverItem, ctx: android.content.Context) {
|
||||
val sorted = item.platformPrices.entries.sortedBy { it.value }
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"多平台到手价",
|
||||
fontWeight = FontWeight.Bold, color = DbbColors.Ink, fontSize = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("点行去搜", color = DbbColors.InkTer, fontSize = 10.sp)
|
||||
}
|
||||
sorted.forEachIndexed { i, (plat, price) ->
|
||||
val isLowest = i == 0
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { PlatformSearch.copyAndSearch(ctx, plat, item.title) }
|
||||
.background(if (isLowest) DbbColors.TealBg else Color.Transparent)
|
||||
.padding(horizontal = 14.dp, vertical = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// 最低 chip 在前(主流比价 App 套路)+ 平台名胶囊;不再单字+全名重复
|
||||
if (isLowest) {
|
||||
TagChip("最低", bg = DbbColors.Teal, fg = Color.White)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
PlatformChip(name = plat)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
"¥${fmtYuan(price)}",
|
||||
fontWeight = FontWeight.ExtraBold, fontSize = 18.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
color = if (isLowest) DbbColors.Teal else DbbColors.Ink,
|
||||
)
|
||||
}
|
||||
if (i < sorted.lastIndex) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 14.dp)
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(DbbColors.Border),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 3) 60 天历史曲线 + 统计 ==============
|
||||
|
||||
@Composable
|
||||
private fun TrendCard(item: DiscoverItem) {
|
||||
val prices = remember(item.id) { generate60dPrices(item.title, item.typicalPrice, item.minPrice) }
|
||||
val minIdx = prices.indexOf(prices.min())
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("📉", fontSize = 16.sp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("60 天价格走势", fontWeight = FontWeight.Bold, color = DbbColors.Ink, fontSize = 14.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("60d 低 ¥${fmtYuan(prices.min())}", color = DbbColors.Up, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
MiniTrendCanvas(prices, minIdx)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row {
|
||||
Text("60 天前", color = DbbColors.InkTer, fontSize = 10.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("今天", color = DbbColors.InkTer, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniTrendCanvas(prices: List<Double>, minIdx: Int) {
|
||||
val minP = prices.min()
|
||||
val maxP = prices.max()
|
||||
val span = (maxP - minP).coerceAtLeast(0.01)
|
||||
val lineColor = DbbColors.Teal
|
||||
val areaColor = DbbColors.TealBg
|
||||
val gridColor = DbbColors.Border
|
||||
Box(Modifier.fillMaxWidth().height(110.dp)) {
|
||||
Canvas(Modifier.fillMaxWidth().height(110.dp)) {
|
||||
val padL = 4.dp.toPx()
|
||||
val padR = 4.dp.toPx()
|
||||
val padT = 8.dp.toPx()
|
||||
val padB = 8.dp.toPx()
|
||||
val w = size.width - padL - padR
|
||||
val h = size.height - padT - padB
|
||||
for (i in 0..2) {
|
||||
val y = padT + h * (i / 2f)
|
||||
drawLine(gridColor, Offset(padL, y), Offset(padL + w, y), 1f)
|
||||
}
|
||||
val points = prices.mapIndexed { idx, p ->
|
||||
val x = padL + (idx.toFloat() / prices.lastIndex.toFloat()) * w
|
||||
val y = padT + h - (((p - minP) / span).toFloat() * h)
|
||||
Offset(x, y)
|
||||
}
|
||||
val area = Path().apply {
|
||||
moveTo(points.first().x, padT + h)
|
||||
points.forEach { lineTo(it.x, it.y) }
|
||||
lineTo(points.last().x, padT + h)
|
||||
close()
|
||||
}
|
||||
drawPath(area, areaColor)
|
||||
val path = Path().apply {
|
||||
points.forEachIndexed { i, p -> if (i == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y) }
|
||||
}
|
||||
drawPath(path, lineColor, style = Stroke(width = 3.5f))
|
||||
val minPt = points[minIdx]
|
||||
drawCircle(DbbColors.Up, 7f, minPt)
|
||||
drawCircle(Color.White, 3f, minPt)
|
||||
val nowPt = points.last()
|
||||
drawCircle(lineColor, 8f, nowPt)
|
||||
drawCircle(Color.White, 3.5f, nowPt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsRow(item: DiscoverItem) {
|
||||
val prices = remember(item.id) { generate60dPrices(item.title, item.typicalPrice, item.minPrice) }
|
||||
val stats = remember(prices) { computeHistoryStats(prices, item.minPrice) }
|
||||
val pctColor = when {
|
||||
stats.percentile < 25 -> DbbColors.Save
|
||||
stats.percentile > 75 -> DbbColors.Up
|
||||
else -> DbbColors.Teal
|
||||
}
|
||||
val pctBg = when {
|
||||
stats.percentile < 25 -> DbbColors.SaveBg
|
||||
stats.percentile > 75 -> DbbColors.UpBg
|
||||
else -> DbbColors.TealBg
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatBox("60 天最低", "¥${fmtYuan(stats.min)}", DbbColors.SaveBg, DbbColors.Save, Modifier.weight(1f))
|
||||
StatBox("60 天均价", "¥${fmtYuan(stats.avg)}", DbbColors.Fill, DbbColors.InkSec, Modifier.weight(1f))
|
||||
StatBox("当前水位", "${stats.percentile}%", pctBg, pctColor, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatBox(label: String, value: String, bg: Color, fg: Color, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bg)
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(value, fontWeight = FontWeight.ExtraBold, fontSize = 15.sp, color = fg)
|
||||
Text(label, fontSize = 10.sp, color = fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 4) CTA 行 ==============
|
||||
|
||||
@Composable
|
||||
private fun CtaRow(
|
||||
item: DiscoverItem,
|
||||
onAddAlert: () -> Unit,
|
||||
onOpenArena: () -> Unit,
|
||||
onSearchLowest: () -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
Column(Modifier.padding(horizontal = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// 主 CTA — 大,渐变,最低价跳搜
|
||||
Button(
|
||||
onClick = onSearchLowest,
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) {
|
||||
Icon(Icons.Filled.Search, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("去最低价平台「${item.minPlatform}」搜", fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
// 副 CTA 行 — 加雷达 + 跨平台对比
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
onAddAlert()
|
||||
Toast.makeText(
|
||||
ctx,
|
||||
"已加入降价雷达 · 心理价 ¥${fmtYuan(item.minPrice)}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.5.dp, DbbColors.Teal),
|
||||
) {
|
||||
Icon(Icons.Filled.NotificationsActive, contentDescription = null, tint = DbbColors.Teal, modifier = Modifier.size(15.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("加雷达", color = DbbColors.TealDark, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onOpenArena,
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = BorderStroke(1.5.dp, DbbColors.Sky),
|
||||
) {
|
||||
Icon(Icons.Filled.Balance, contentDescription = null, tint = DbbColors.Sky, modifier = Modifier.size(15.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("跨平台比", color = DbbColors.Sky, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============== 5) 同类好物推荐 ==============
|
||||
|
||||
@Composable
|
||||
private fun SimilarHeader(category: String) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
Text("🔁", fontSize = 16.sp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
"同品类「$category」其他好物",
|
||||
fontWeight = FontWeight.ExtraBold, color = DbbColors.Ink, fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimilarRow(item: DiscoverItem, onClick: () -> Unit) {
|
||||
Card(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp)
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(1.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(item.category.tintBg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(item.emoji, fontSize = 22.sp)
|
||||
}
|
||||
Spacer(Modifier.width(11.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.title,
|
||||
fontWeight = FontWeight.Medium, color = DbbColors.Ink, fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
"${item.minPlatform} ¥${fmtYuan(item.minPrice)} · 全网最低",
|
||||
color = DbbColors.InkSec, fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
Text("›", color = DbbColors.InkTer, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisclaimerFooter() {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Text(
|
||||
"价格为各平台日常到手价估算(不含大促),仅供参考,以平台实际为准。",
|
||||
color = DbbColors.InkTer, fontSize = 10.sp,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotFoundView(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("商品不存在或已下架", color = DbbColors.InkSec, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onBack,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) { Text("返回") }
|
||||
}
|
||||
}
|
||||
|
||||
/** 颜色线性混合 — 用于 hero 渐变端点(类目色 → 偏深 ink 色)。 */
|
||||
private fun blend(a: Color, b: Color, t: Float): Color {
|
||||
val k = t.coerceIn(0f, 1f)
|
||||
return Color(
|
||||
red = a.red * (1 - k) + b.red * k,
|
||||
green = a.green * (1 - k) + b.green * k,
|
||||
blue = a.blue * (1 - k) + b.blue * k,
|
||||
alpha = a.alpha * (1 - k) + b.alpha * k,
|
||||
)
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
package com.jishisongfu.duobibi.ui.discover
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import com.jishisongfu.duobibi.R
|
||||
import com.jishisongfu.duobibi.ui.arena.ArenaCategory
|
||||
|
||||
/**
|
||||
* "发现"页瀑布流的静态种子内容(占审用)。
|
||||
*
|
||||
* **结构性约束**(故意区别于傻瓜比价 CommunityScreen 的图文好价帖):
|
||||
* - 每条**必须挂 ≥2 个平台到手价**,卡片视觉以"价格对比"而非"图文"为主
|
||||
* - 用 emoji 而非大图,避免跟旧 App 的大图笔记瀑布流撞设计
|
||||
* - 品类口径复用 [ArenaCategory],跟比价擂台的"锁定品类"模型一致
|
||||
*
|
||||
* 卡片点击 → 把 `title` 喂给 ArenaViewModel.submitWithTitle 直接跳"比价"Tab 出对比表。
|
||||
*/
|
||||
data class DiscoverItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: ArenaCategory,
|
||||
/** 兜底用的"商品类型符号"。imageRes 不为空时不显示。 */
|
||||
val emoji: String,
|
||||
/** 市场常见价(用于"较常见价省 ¥X"展示) */
|
||||
val typicalPrice: Double,
|
||||
/** 各平台到手价。≥2 个,顺序即展示顺序;最低自动加 "最低" 标。 */
|
||||
val platformPrices: Map<String, Double>,
|
||||
/** 可选高亮短标签(如"百亿补贴"/"618 预热"/"周四疯狂")。 */
|
||||
val highlight: String? = null,
|
||||
/** 瀑布流高度提示 0/1/2 — 错落用,卡片内部 padding 不同。 */
|
||||
val heightHint: Int = 0,
|
||||
/**
|
||||
* 商品实图(本地 drawable 资源)。优先于 [emoji] 渲染。
|
||||
*
|
||||
* **加图流程**:
|
||||
* 1. 把图片(PNG/JPG/WebP)放进 `app/src/main/res/drawable/`,
|
||||
* 文件名只能含小写字母 / 数字 / 下划线(如 `img_xxx.png`)
|
||||
* 2. 在本 SEED 对应条目设 `imageRes = R.drawable.img_xxx`
|
||||
* 3. 不设(保持 null)= 走 emoji 兜底渲染
|
||||
*
|
||||
* 图片版权由提供方负责;参考图源:`picture/` 目录。
|
||||
*/
|
||||
@DrawableRes val imageRes: Int? = null,
|
||||
) {
|
||||
/** 全网最低价(卡片大字)。 */
|
||||
val minPrice: Double get() = platformPrices.values.min()
|
||||
/** 最低价所在平台。 */
|
||||
val minPlatform: String get() = platformPrices.minBy { it.value }.key
|
||||
/** 较常见价省多少钱(≥0)。 */
|
||||
val savings: Double get() = (typicalPrice - minPrice).coerceAtLeast(0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 30 条占审种子。新增 / 改时只需追加到这个 list,DiscoverScreen 自动按品类筛。
|
||||
* 价格仅为参考,可能与真实情况不符;以平台实际为准(底部小字 disclaimer 已写)。
|
||||
*/
|
||||
val DISCOVER_SEED: List<DiscoverItem> = listOf(
|
||||
// ===== 外卖比价(4) ============================================================
|
||||
DiscoverItem(
|
||||
id = "tw01", title = "海底捞外卖 经典套餐 2-3 人份",
|
||||
category = ArenaCategory.Takeaway, emoji = "🍲",
|
||||
typicalPrice = 199.0,
|
||||
platformPrices = mapOf("美团外卖" to 168.0, "饿了么" to 175.0),
|
||||
highlight = "美团满减后",
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_haidilao,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "tw02", title = "麦当劳 麦辣鸡腿堡套餐(大份)",
|
||||
category = ArenaCategory.Takeaway, emoji = "🍔",
|
||||
typicalPrice = 39.0,
|
||||
platformPrices = mapOf("美团外卖" to 28.0, "饿了么" to 31.5),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_hamburger,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "tw03", title = "肯德基 疯狂星期四 9.9 元桶",
|
||||
category = ArenaCategory.Takeaway, emoji = "🍗",
|
||||
typicalPrice = 28.0,
|
||||
platformPrices = mapOf("美团外卖" to 9.9, "饿了么" to 12.0),
|
||||
highlight = "周四限定",
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_kfc,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "tw04", title = "喜茶 多肉葡萄 中杯",
|
||||
category = ArenaCategory.Takeaway, emoji = "🧋",
|
||||
typicalPrice = 25.0,
|
||||
platformPrices = mapOf("美团外卖" to 19.0, "饿了么" to 21.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_heytea,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "tw05", title = "吉野家 红烧肥牛饭 经典套餐",
|
||||
category = ArenaCategory.Takeaway, emoji = "🍱",
|
||||
typicalPrice = 35.0,
|
||||
platformPrices = mapOf("美团外卖" to 19.9, "饿了么" to 24.5),
|
||||
highlight = "美团满 30 减 10",
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_beef_rice,
|
||||
),
|
||||
|
||||
// ===== 同城买菜(4) ============================================================
|
||||
DiscoverItem(
|
||||
id = "gc01", title = "新鲜五花肉 500g · 同城配送",
|
||||
category = ArenaCategory.Grocery, emoji = "🥩",
|
||||
typicalPrice = 32.0,
|
||||
platformPrices = mapOf("美团" to 26.8, "饿了么" to 29.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_pork_belly,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "gc02", title = "本地散养土鸡蛋 30 枚",
|
||||
category = ArenaCategory.Grocery, emoji = "🥚",
|
||||
typicalPrice = 39.9,
|
||||
platformPrices = mapOf("美团" to 29.9, "饿了么" to 32.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_eggs,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "gc03", title = "蒙牛特仑苏纯牛奶 250ml*16 礼盒",
|
||||
category = ArenaCategory.Grocery, emoji = "🥛",
|
||||
typicalPrice = 88.0,
|
||||
platformPrices = mapOf("美团" to 65.0, "饿了么" to 69.0),
|
||||
highlight = "美团百亿补贴",
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_telunsu,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "gc04", title = "新鲜绿色叶菜礼包 1.5kg",
|
||||
category = ArenaCategory.Grocery, emoji = "🥬",
|
||||
typicalPrice = 35.0,
|
||||
platformPrices = mapOf("美团" to 24.5, "饿了么" to 28.9),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_greens,
|
||||
),
|
||||
|
||||
// ===== 电商比价(4) ============================================================
|
||||
DiscoverItem(
|
||||
id = "ec01", title = "雀巢多趣酷思咖啡胶囊 16 颗装",
|
||||
category = ArenaCategory.Ecommerce, emoji = "☕",
|
||||
typicalPrice = 99.0,
|
||||
platformPrices = mapOf("淘宝" to 89.0, "京东" to 95.0, "拼多多" to 79.0, "抖音" to 92.0),
|
||||
highlight = "拼多多百亿补贴",
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_nescafe,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "ec02", title = "三只松鼠 每日坚果 30 包混合装",
|
||||
category = ArenaCategory.Ecommerce, emoji = "🥜",
|
||||
typicalPrice = 89.0,
|
||||
platformPrices = mapOf("淘宝" to 79.0, "京东" to 85.0, "拼多多" to 69.9, "抖音" to 76.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_squirrel,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "ec03", title = "自嗨锅 4 桶混合自热米饭",
|
||||
category = ArenaCategory.Ecommerce, emoji = "🍱",
|
||||
typicalPrice = 99.0,
|
||||
platformPrices = mapOf("淘宝" to 79.0, "京东" to 85.0, "拼多多" to 69.0, "抖音" to 75.0),
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_zhxg,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "ec04", title = "加多宝凉茶 310ml*24 罐整箱",
|
||||
category = ArenaCategory.Ecommerce, emoji = "🥤",
|
||||
typicalPrice = 79.0,
|
||||
platformPrices = mapOf("淘宝" to 65.0, "京东" to 69.0, "拼多多" to 58.0, "抖音" to 62.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_jdb,
|
||||
),
|
||||
|
||||
// ===== 数码家电(6) ============================================================
|
||||
DiscoverItem(
|
||||
id = "dg01", title = "Apple iPhone 16 256GB 海湾蓝",
|
||||
category = ArenaCategory.Digital, emoji = "📱",
|
||||
typicalPrice = 6999.0,
|
||||
platformPrices = mapOf("淘宝" to 5999.0, "京东" to 6299.0, "拼多多" to 5799.0, "抖音" to 6099.0),
|
||||
highlight = "百亿补贴",
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_iphone_blue,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dg02", title = "Apple AirPods Pro 2 USB-C 版",
|
||||
category = ArenaCategory.Digital, emoji = "🎧",
|
||||
typicalPrice = 1899.0,
|
||||
platformPrices = mapOf("淘宝" to 1499.0, "京东" to 1699.0, "拼多多" to 1399.0, "抖音" to 1559.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_airpods,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dg03", title = "小米 14 Pro 12GB+256GB 钛金属版",
|
||||
category = ArenaCategory.Digital, emoji = "📱",
|
||||
typicalPrice = 4999.0,
|
||||
platformPrices = mapOf("淘宝" to 4699.0, "京东" to 4899.0, "拼多多" to 4499.0, "抖音" to 4799.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_xiaomi14pro,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dg04", title = "iPad Air 11 256GB WiFi 紫色",
|
||||
category = ArenaCategory.Digital, emoji = "📒",
|
||||
typicalPrice = 5299.0,
|
||||
platformPrices = mapOf("淘宝" to 4799.0, "京东" to 5099.0, "拼多多" to 4699.0, "抖音" to 4999.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_ipad_air,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dg05", title = "戴森 Dyson V12 Detect Slim 无线吸尘器",
|
||||
category = ArenaCategory.Digital, emoji = "🌀",
|
||||
typicalPrice = 4490.0,
|
||||
platformPrices = mapOf("淘宝" to 3699.0, "京东" to 3899.0, "拼多多" to 3499.0, "抖音" to 3799.0),
|
||||
highlight = "618 预热价",
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_dyson_vacuum,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dg06", title = "飞利浦 多功能旋转剃须刀 S5586",
|
||||
category = ArenaCategory.Digital, emoji = "💈",
|
||||
typicalPrice = 599.0,
|
||||
platformPrices = mapOf("淘宝" to 469.0, "京东" to 519.0, "拼多多" to 449.0, "抖音" to 489.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_philips_shaver,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dg07", title = "戴森 Supersonic 吹风机 HD15 紫红色",
|
||||
category = ArenaCategory.Digital, emoji = "💨",
|
||||
typicalPrice = 2990.0,
|
||||
platformPrices = mapOf("淘宝" to 2599.0, "京东" to 2799.0, "拼多多" to 2399.0, "抖音" to 2699.0),
|
||||
highlight = "百亿补贴",
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_dyson_hd,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dg08", title = "Apple MacBook Air 13 M3 256GB",
|
||||
category = ArenaCategory.Digital, emoji = "💻",
|
||||
typicalPrice = 8999.0,
|
||||
platformPrices = mapOf("淘宝" to 7999.0, "京东" to 8499.0, "拼多多" to 7799.0, "抖音" to 8199.0),
|
||||
highlight = "教育优惠叠加",
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_macbook,
|
||||
),
|
||||
|
||||
// ===== 服饰美妆(6) ============================================================
|
||||
DiscoverItem(
|
||||
id = "fs01", title = "SK-II 神仙水 230ml 礼盒装",
|
||||
category = ArenaCategory.Fashion, emoji = "💧",
|
||||
typicalPrice = 1699.0,
|
||||
platformPrices = mapOf("淘宝" to 1499.0, "京东" to 1599.0, "拼多多" to 1399.0, "抖音" to 1499.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_skii,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "fs02", title = "雅诗兰黛 小棕瓶套装 精华+眼霜+面霜",
|
||||
category = ArenaCategory.Fashion, emoji = "🧴",
|
||||
typicalPrice = 1980.0,
|
||||
platformPrices = mapOf("淘宝" to 1599.0, "京东" to 1799.0, "拼多多" to 1499.0, "抖音" to 1699.0),
|
||||
highlight = "新客立减",
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_estee_lauder,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "fs03", title = "Nike Air Force 1 经典纯白男款 41",
|
||||
category = ArenaCategory.Fashion, emoji = "👟",
|
||||
typicalPrice = 799.0,
|
||||
platformPrices = mapOf("淘宝" to 629.0, "京东" to 679.0, "拼多多" to 599.0, "抖音" to 649.0),
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_nike_af1,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "fs04", title = "Lululemon Align 高腰瑜伽裤 25 寸 暮蓝",
|
||||
category = ArenaCategory.Fashion, emoji = "🧘",
|
||||
typicalPrice = 980.0,
|
||||
platformPrices = mapOf("淘宝" to 850.0, "京东" to 880.0, "拼多多" to 799.0, "抖音" to 819.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_lululemon,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "fs05", title = "兰蔻 Absolue 菁纯眼霜 20ml 金管",
|
||||
category = ArenaCategory.Fashion, emoji = "👁",
|
||||
typicalPrice = 1499.0,
|
||||
platformPrices = mapOf("淘宝" to 1199.0, "京东" to 1299.0, "拼多多" to 1099.0, "抖音" to 1199.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_lancome_eye,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "fs06", title = "资生堂 红腰子精华 50ml",
|
||||
category = ArenaCategory.Fashion, emoji = "🌹",
|
||||
typicalPrice = 729.0,
|
||||
platformPrices = mapOf("淘宝" to 599.0, "京东" to 659.0, "拼多多" to 549.0, "抖音" to 589.0),
|
||||
highlight = "百亿补贴",
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_shiseido_red,
|
||||
),
|
||||
|
||||
// ===== 日用百货(6) ============================================================
|
||||
DiscoverItem(
|
||||
id = "dl01", title = "花王 妙而舒 进口纸尿裤 S82 片",
|
||||
category = ArenaCategory.Daily, emoji = "👶",
|
||||
typicalPrice = 169.0,
|
||||
platformPrices = mapOf("淘宝" to 135.0, "京东" to 149.0, "拼多多" to 125.0, "抖音" to 132.0, "美团" to 152.0),
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_merries,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dl02", title = "维达 4 层抽纸 24 包整箱",
|
||||
category = ArenaCategory.Daily, emoji = "🧻",
|
||||
typicalPrice = 79.9,
|
||||
platformPrices = mapOf("淘宝" to 59.9, "京东" to 65.0, "拼多多" to 49.9, "抖音" to 55.0, "美团" to 65.5),
|
||||
highlight = "拼多多百亿",
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_vinda_tissue,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dl03", title = "海尔 不锈钢真空保温杯 500ml",
|
||||
category = ArenaCategory.Daily, emoji = "🥤",
|
||||
typicalPrice = 129.0,
|
||||
platformPrices = mapOf("淘宝" to 89.0, "京东" to 109.0, "拼多多" to 79.0, "抖音" to 95.0),
|
||||
heightHint = 2,
|
||||
imageRes = R.drawable.img_haier_cup,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dl04", title = "飞利浦 电动牙刷 HX6730 入门款",
|
||||
category = ArenaCategory.Daily, emoji = "🪥",
|
||||
typicalPrice = 449.0,
|
||||
platformPrices = mapOf("淘宝" to 329.0, "京东" to 369.0, "拼多多" to 299.0, "抖音" to 319.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_philips_toothbrush,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dl05", title = "美的 电热水壶 1.7L 304 不锈钢",
|
||||
category = ArenaCategory.Daily, emoji = "💧",
|
||||
typicalPrice = 159.0,
|
||||
platformPrices = mapOf("淘宝" to 119.0, "京东" to 129.0, "拼多多" to 99.0, "抖音" to 109.0, "美团" to 135.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_midea_kettle,
|
||||
),
|
||||
DiscoverItem(
|
||||
id = "dl06", title = "苏泊尔 不粘锅 28cm 麦饭石色",
|
||||
category = ArenaCategory.Daily, emoji = "🍳",
|
||||
typicalPrice = 199.0,
|
||||
platformPrices = mapOf("淘宝" to 159.0, "京东" to 179.0, "拼多多" to 139.0, "抖音" to 149.0),
|
||||
heightHint = 1,
|
||||
imageRes = R.drawable.img_supor_pan,
|
||||
),
|
||||
)
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
package com.jishisongfu.duobibi.ui.discover
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Explore
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.LocalFireDepartment
|
||||
import androidx.compose.material.icons.filled.NewReleases
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.ui.HeroBanner
|
||||
import com.jishisongfu.duobibi.ui.arena.ArenaCategory
|
||||
import com.jishisongfu.duobibi.ui.rememberHaptics
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbGradients
|
||||
import com.jishisongfu.duobibi.util.WatchedPrefs
|
||||
|
||||
/**
|
||||
* 发现页:双列瀑布流"比价好物"。
|
||||
*
|
||||
* 跟傻瓜比价 CommunityScreen 的差异(避免套壳):
|
||||
* - 双列瀑布流 vs 单列大图笔记
|
||||
* - 卡片重心是"价格对比"而不是图文笔记
|
||||
* - 命名"发现"不是"社区","比价好物"不是"好价帖"
|
||||
*
|
||||
* 点击卡片 -> [onOpenDetail],进入 ProductDetailScreen(三级深度起点)。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DiscoverScreen(
|
||||
onOpenDetail: (itemId: String) -> Unit,
|
||||
onOpenWatched: () -> Unit,
|
||||
vm: DiscoverViewModel = hiltViewModel(),
|
||||
) {
|
||||
val items by vm.items.collectAsStateWithLifecycle()
|
||||
val selected by vm.selectedCategory.collectAsStateWithLifecycle()
|
||||
val query by vm.query.collectAsStateWithLifecycle()
|
||||
val ctx = LocalContext.current
|
||||
val watchedFlow = remember { WatchedPrefs.watchedFlow(ctx) }
|
||||
val watchedIds by watchedFlow.collectAsStateWithLifecycle(initialValue = WatchedPrefs.watchedIds(ctx))
|
||||
// 今日新品过滤态在 UI 层维护(纯展示开关,不污染 VM)。开启时取 SEED 后 12 条作为"新增"。
|
||||
// 跟 selectedCategory 互斥:开"今日新品"时清品类筛选,选品类时关"今日新品"。
|
||||
// 用 rememberSaveable:旋转 / 跨配置变化 / 从详情页返回时保留开关态。
|
||||
var todayOnly by rememberSaveable { mutableStateOf(false) }
|
||||
val displayedItems = remember(items, todayOnly) {
|
||||
if (todayOnly) items.takeLast(12) else items
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.padding(padding).fillMaxSize().background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
HeroBanner(
|
||||
title = "发现",
|
||||
subtitle = "精选比价好物 · 一眼看全网",
|
||||
icon = Icons.Filled.Explore,
|
||||
gradient = DbbGradients.Discover,
|
||||
trailing = {
|
||||
WatchedHeroAction(count = watchedIds.size, onClick = onOpenWatched)
|
||||
},
|
||||
)
|
||||
SearchPill(
|
||||
query = query,
|
||||
onQueryChange = {
|
||||
// 输入搜索时关闭"今日新品"过滤,搜索意图比 toggle 更具体
|
||||
if (it.isNotBlank() && todayOnly) todayOnly = false
|
||||
vm.setQuery(it)
|
||||
},
|
||||
)
|
||||
QuickStrip(
|
||||
watchedCount = watchedIds.size,
|
||||
todayOnly = todayOnly,
|
||||
hotSelected = selected == ArenaCategory.Digital,
|
||||
onWatched = onOpenWatched,
|
||||
onTodayToggle = {
|
||||
todayOnly = !todayOnly
|
||||
if (todayOnly) vm.selectCategory(null) // 两个 filter 互斥
|
||||
},
|
||||
onHotCategory = {
|
||||
todayOnly = false
|
||||
vm.selectCategory(
|
||||
if (selected == ArenaCategory.Digital) null else ArenaCategory.Digital
|
||||
)
|
||||
},
|
||||
)
|
||||
CategoryChipsRow(
|
||||
selected = selected,
|
||||
onSelect = {
|
||||
todayOnly = false // 选品类时关"今日新品"
|
||||
vm.selectCategory(it)
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
if (displayedItems.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
// 搜索无结果 vs 品类无种子 vs 今日新品截空,文案各不相同
|
||||
when {
|
||||
query.isNotBlank() -> "没找到「$query」相关商品\n换个关键词或换个品类试试"
|
||||
todayOnly -> "今日新品暂空,稍后再来看看"
|
||||
else -> "该品类暂无种子,换一个看看"
|
||||
},
|
||||
color = DbbColors.InkTer, fontSize = 13.sp,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(10.dp, 4.dp, 10.dp, 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalItemSpacing = 8.dp,
|
||||
) {
|
||||
items(displayedItems, key = { it.id }) { item ->
|
||||
PriceCompareCard(
|
||||
item = item,
|
||||
isWatched = item.id in watchedIds,
|
||||
onClick = { onOpenDetail(item.id) },
|
||||
onToggleWatch = { WatchedPrefs.toggle(ctx, item.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero 下方"搜索胶囊" —— 解决"列表 30 条用户找不到目标商品"的核心痛点。
|
||||
*
|
||||
* 设计:
|
||||
* - 圆角 24dp 胶囊,白底 + 浅边框,不抢 hero 视觉
|
||||
* - 左 🔍 用 Teal 强化"可点输入"暗示
|
||||
* - 右 × 仅 query 非空时显示,一键清空
|
||||
* - IME 显示 "Search" action,但实际过滤是逐字触发(不需要按确认),
|
||||
* 搜索动作走 VM.setQuery → combine StateFlow 立即更新列表
|
||||
* - placeholder 同时提示"商品名 / 品类",对应 VM 里的双字段匹配逻辑
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SearchPill(query: String, onQueryChange: (String) -> Unit) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
"搜商品名或品类,如 iPhone / 数码",
|
||||
fontSize = 13.sp, color = DbbColors.InkTer,
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null, tint = DbbColors.Teal) },
|
||||
trailingIcon = {
|
||||
if (query.isNotBlank()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "清空", tint = DbbColors.InkTer)
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
// 关键:把 4 条 indicator(下划线)全干掉,容器底色用 Card 白 — 一眼是个胶囊不是文本框
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = DbbColors.Card,
|
||||
unfocusedContainerColor = DbbColors.Card,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero 右上 ❤️ 入口 + 数量徽章。
|
||||
* count=0 时只显示白色空心心,>0 时叠红圆点 + 白字计数(微信小红点风格)。
|
||||
*/
|
||||
@Composable
|
||||
private fun WatchedHeroAction(count: Int, onClick: () -> Unit) {
|
||||
val haptics = rememberHaptics()
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
IconButton(onClick = { haptics.toggle(); onClick() }) {
|
||||
Icon(Icons.Filled.Favorite, contentDescription = "我的关注", tint = Color.White)
|
||||
}
|
||||
if (count > 0) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = 4.dp, end = 4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(DbbColors.Rose)
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(
|
||||
if (count > 99) "99+" else count.toString(),
|
||||
color = Color.White,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero 与品类 chip 之间的快捷条 —— 3 个 tile(我的关注 / 今日新品 / 热门品类)。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 3 列等宽 (weight=1f),value 文字必须 ≤ 4 个中文 (12sp Bold) 才不溢出折行
|
||||
* - selected 态用 tintBg 实底反馈"我正激活",未选 = 白卡
|
||||
* - 三个 tile 都可点:关注=跳页,今日=toggle 过滤,热门=toggle 数码品类
|
||||
*/
|
||||
@Composable
|
||||
private fun QuickStrip(
|
||||
watchedCount: Int,
|
||||
todayOnly: Boolean,
|
||||
hotSelected: Boolean,
|
||||
onWatched: () -> Unit,
|
||||
onTodayToggle: () -> Unit,
|
||||
onHotCategory: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
QuickTile(
|
||||
icon = Icons.Filled.Favorite,
|
||||
tint = DbbColors.Rose,
|
||||
tintBg = DbbColors.RoseBg,
|
||||
title = "我的关注",
|
||||
value = if (watchedCount > 0) "$watchedCount 件" else "去收藏",
|
||||
selected = false, // 关注是"跳页"动作,不存在选中态
|
||||
onClick = onWatched,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
QuickTile(
|
||||
icon = Icons.Filled.NewReleases,
|
||||
tint = DbbColors.Teal,
|
||||
tintBg = DbbColors.TealBg,
|
||||
title = "今日新品",
|
||||
value = if (todayOnly) "已筛" else "看新品",
|
||||
selected = todayOnly,
|
||||
onClick = onTodayToggle,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
QuickTile(
|
||||
icon = Icons.Filled.LocalFireDepartment,
|
||||
tint = DbbColors.Up,
|
||||
tintBg = DbbColors.UpBg,
|
||||
title = "热门品类",
|
||||
value = "数码", // 缩短到 2 字防 3 列均分下折行
|
||||
selected = hotSelected,
|
||||
onClick = onHotCategory,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickTile(
|
||||
icon: ImageVector,
|
||||
tint: Color,
|
||||
tintBg: Color,
|
||||
title: String,
|
||||
value: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val haptics = rememberHaptics()
|
||||
Row(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (selected) tintBg else DbbColors.Card)
|
||||
.clickable { haptics.toggle(); onClick() }
|
||||
.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) Color.White else tintBg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(15.dp))
|
||||
}
|
||||
Spacer(Modifier.size(7.dp))
|
||||
Column {
|
||||
Text(
|
||||
title,
|
||||
color = if (selected) tint else DbbColors.InkSec,
|
||||
fontSize = 10.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
color = if (selected) tint else DbbColors.Ink,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryChipsRow(selected: ArenaCategory?, onSelect: (ArenaCategory?) -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
// 顶 padding 调小到 2dp,跟 QuickStrip 之间的视觉间距从 20dp 降到 12dp,贴得更紧。
|
||||
.padding(start = 12.dp, end = 12.dp, top = 2.dp, bottom = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selected == null,
|
||||
onClick = { onSelect(null) },
|
||||
label = { Text("全部") },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = DbbColors.Teal,
|
||||
selectedLabelColor = Color.White,
|
||||
),
|
||||
)
|
||||
ArenaCategory.entries.forEach { c ->
|
||||
FilterChip(
|
||||
selected = selected == c,
|
||||
onClick = { onSelect(c) },
|
||||
label = { Text(c.title) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = DbbColors.Teal,
|
||||
selectedLabelColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.jishisongfu.duobibi.ui.discover
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.ui.arena.ArenaCategory
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* "发现"页 ViewModel — 静态种子按 [品类 × 关键词] 两维度过滤。
|
||||
*
|
||||
* 不依赖网络/数据库,纯内存运算,占审稳定。
|
||||
*
|
||||
* 过滤策略:
|
||||
* - **selectedCategory**:null=不限,其它=只显该品类
|
||||
* - **query**:trim 后非空时,匹配 title 或 category.title 任一(忽略大小写) —
|
||||
* 这样用户输"数码"能命中整个数码家电品类,输"iPhone"能命中具体商品
|
||||
* - 两维度 AND
|
||||
*/
|
||||
@HiltViewModel
|
||||
class DiscoverViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
private val _selectedCategory = MutableStateFlow<ArenaCategory?>(null)
|
||||
val selectedCategory: StateFlow<ArenaCategory?> = _selectedCategory.asStateFlow()
|
||||
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query.asStateFlow()
|
||||
|
||||
val items: StateFlow<List<DiscoverItem>> = combine(
|
||||
_selectedCategory,
|
||||
_query,
|
||||
) { cat, q ->
|
||||
val byCategory = if (cat == null) DISCOVER_SEED else DISCOVER_SEED.filter { it.category == cat }
|
||||
val needle = q.trim()
|
||||
if (needle.isEmpty()) {
|
||||
byCategory
|
||||
} else {
|
||||
byCategory.filter { item ->
|
||||
item.title.contains(needle, ignoreCase = true) ||
|
||||
item.category.title.contains(needle, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DISCOVER_SEED)
|
||||
|
||||
fun selectCategory(c: ArenaCategory?) {
|
||||
_selectedCategory.value = c
|
||||
}
|
||||
|
||||
fun setQuery(q: String) {
|
||||
_query.value = q
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package com.jishisongfu.duobibi.ui.discover
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.rememberHaptics
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
/**
|
||||
* 单张"比价好物"卡片(双列瀑布流 cell)。
|
||||
*
|
||||
* **结构性约束**(避免跟傻瓜比价 CommunityScreen 视觉撞):
|
||||
* - 不用大图,卡片头是 24dp emoji + 品类色条
|
||||
* - 卡片重心在"价格对比"(全网最低大字 + ≥2 平台 mini 行),不是图文笔记
|
||||
* - 单列宽度,文字密度高(信息卡风),而不是图卡风
|
||||
*
|
||||
* heightHint 0/1/2 控制顶部 emoji 区高度(56/72/96dp),让瀑布流错落。
|
||||
*/
|
||||
@Composable
|
||||
fun PriceCompareCard(
|
||||
item: DiscoverItem,
|
||||
onClick: () -> Unit,
|
||||
isWatched: Boolean = false,
|
||||
onToggleWatch: (() -> Unit)? = null,
|
||||
) {
|
||||
val cat = item.category
|
||||
// 实图条目的头部块抬高一档 —— emoji 32sp 在 56-96dp 够大,
|
||||
// 真图按 Fit 缩放,在 56dp 高里看起来太挤,给 96-144dp 更接近主流商品卡。
|
||||
val headerHeight = if (item.imageRes != null) {
|
||||
when (item.heightHint) { 0 -> 96.dp; 1 -> 120.dp; else -> 144.dp }
|
||||
} else {
|
||||
when (item.heightHint) { 0 -> 56.dp; 1 -> 72.dp; else -> 96.dp }
|
||||
}
|
||||
val sorted = item.platformPrices.entries.sortedBy { it.value }
|
||||
val mini = sorted.drop(1).take(3) // 除最低外再展示最多 3 个
|
||||
val haptics = rememberHaptics()
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Column {
|
||||
// 顶部头部:imageRes 优先(真图),否则 emoji 兜底。
|
||||
// 真图统一白底,因为产品图基本都是白底拍的;emoji 用 cat.tintBg 保留品类色码。
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(headerHeight)
|
||||
.background(if (item.imageRes != null) Color.White else cat.tintBg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (item.imageRes != null) {
|
||||
Image(
|
||||
painter = painterResource(item.imageRes),
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
Text(item.emoji, fontSize = 32.sp)
|
||||
}
|
||||
// 右上角品类小标签
|
||||
Box(
|
||||
Modifier.align(Alignment.TopEnd).padding(6.dp)
|
||||
.clip(RoundedCornerShape(4.dp)).background(Color.White.copy(alpha = 0.85f))
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(cat.title, color = cat.tintFg, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
// 左上角 ❤️ toggle:已关注=实心红心,未关注=白色圆底空心
|
||||
if (onToggleWatch != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(6.dp)
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.92f))
|
||||
.clickable { haptics.toggle(); onToggleWatch() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
if (isWatched) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
|
||||
contentDescription = if (isWatched) "取消关注" else "关注",
|
||||
tint = if (isWatched) DbbColors.Rose else DbbColors.InkSec,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
// 商品名(最多 2 行)
|
||||
Text(
|
||||
item.title,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = DbbColors.Ink,
|
||||
maxLines = 2,
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// 全网最低大字 — letterSpacing 收紧 + 加大,跟普通文字拉开层级
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
"¥", color = DbbColors.Teal,
|
||||
fontSize = 12.sp, fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = (-0.2).sp,
|
||||
)
|
||||
Text(
|
||||
fmtYuan(item.minPrice),
|
||||
color = DbbColors.Teal,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
letterSpacing = (-0.8).sp,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"${item.minPlatform} 最低",
|
||||
color = DbbColors.InkSec,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
if (item.savings > 0) {
|
||||
Text(
|
||||
"省 ¥${fmtYuan(item.savings)}",
|
||||
color = DbbColors.Save,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// 其它平台 mini 对比行(≤3 行,每行:平台 + ¥价)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
mini.forEach { (plat, price) ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(DbbColors.InkTer),
|
||||
)
|
||||
Spacer(Modifier.size(5.dp))
|
||||
Text(plat, color = DbbColors.InkSec, fontSize = 10.sp)
|
||||
Spacer(Modifier.size(5.dp))
|
||||
Text(
|
||||
"¥${fmtYuan(price)}",
|
||||
color = DbbColors.Ink,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.highlight != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(DbbColors.GoldBg)
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(item.highlight, color = DbbColors.Gold, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.jishisongfu.duobibi.ui.mine
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.jishisongfu.duobibi.BuildConfig
|
||||
import com.jishisongfu.duobibi.R
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutScreen(onBack: () -> Unit, onOpenPrivacy: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("关于多比比", fontWeight = FontWeight.Bold, color = DbbColors.Ink) },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回", tint = DbbColors.Ink) } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = DbbColors.Card),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.padding(padding).fillMaxSize().background(MaterialTheme.colorScheme.background).padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Box(Modifier.size(72.dp).clip(RoundedCornerShape(18.dp)).background(DbbColors.Teal), contentAlignment = Alignment.Center) {
|
||||
Icon(painterResource(R.drawable.ic_launcher_foreground), null, tint = androidx.compose.ui.graphics.Color.White, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("多比比", fontWeight = FontWeight.ExtraBold, fontSize = 20.sp, color = DbbColors.Ink)
|
||||
Text("v${BuildConfig.VERSION_NAME}", color = DbbColors.InkTer, fontSize = 12.sp)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Card(
|
||||
Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("多比比是一款跨平台比价工具:一个商品多平台到手价同台对比,降价雷达盯价提醒,AI 帮你判断值不值得买。价格记录默认只存本机。",
|
||||
color = DbbColors.InkSec, fontSize = 13.sp, lineHeight = 20.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Card(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onOpenPrivacy),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Text("隐私政策 ›", Modifier.padding(16.dp), color = DbbColors.Teal, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.jishisongfu.duobibi.ui.mine
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DiagnosticsScreen(onBack: () -> Unit, vm: DiagnosticsViewModel = hiltViewModel()) {
|
||||
val items by vm.items.collectAsStateWithLifecycle()
|
||||
val running by vm.running.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("运行自检", fontWeight = FontWeight.Bold, color = DbbColors.Ink) },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回", tint = DbbColors.Ink) } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = DbbColors.Card),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(Modifier.padding(padding).fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp)) {
|
||||
Text("检查权限、浮窗、网络与后端识别是否正常。自检会真实调用一次比价识别。",
|
||||
color = DbbColors.InkSec, fontSize = 13.sp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = vm::runAll, enabled = !running,
|
||||
modifier = Modifier.fillMaxWidth().height(46.dp), shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) { Text(if (items.any { it.status != DiagStatus.PENDING }) "重新自检" else "开始自检", fontWeight = FontWeight.SemiBold) }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
items.forEach { DiagRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiagRow(item: DiagItem) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (item.status) {
|
||||
DiagStatus.RUNNING -> CircularProgressIndicator(Modifier.size(18.dp), color = DbbColors.Teal, strokeWidth = 2.dp)
|
||||
else -> Box(Modifier.size(12.dp).clip(CircleShape).background(dotColor(item.status)))
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(item.label, fontWeight = FontWeight.Medium, color = DbbColors.Ink)
|
||||
item.detail?.let { Text(it, color = DbbColors.InkTer, fontSize = 11.sp) }
|
||||
}
|
||||
Text(statusText(item.status) + (item.latencyMs?.let { " · ${it}ms" } ?: ""),
|
||||
color = dotColor(item.status), fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dotColor(s: DiagStatus): Color = when (s) {
|
||||
DiagStatus.OK -> DbbColors.Save
|
||||
DiagStatus.FAIL -> DbbColors.Up
|
||||
DiagStatus.UNKNOWN -> DbbColors.Gold
|
||||
DiagStatus.RUNNING -> DbbColors.Teal
|
||||
DiagStatus.PENDING -> DbbColors.InkTer
|
||||
}
|
||||
|
||||
private fun statusText(s: DiagStatus): String = when (s) {
|
||||
DiagStatus.OK -> "正常"
|
||||
DiagStatus.FAIL -> "异常"
|
||||
DiagStatus.UNKNOWN -> "未知"
|
||||
DiagStatus.RUNNING -> "检测中"
|
||||
DiagStatus.PENDING -> "待检测"
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.jishisongfu.duobibi.ui.mine
|
||||
|
||||
import android.app.Application
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.BuildConfig
|
||||
import com.jishisongfu.duobibi.data.api.ArenaApi
|
||||
import com.jishisongfu.duobibi.data.api.ArenaQuoteRequest
|
||||
import com.jishisongfu.duobibi.service.FloatingWindowService
|
||||
import com.jishisongfu.duobibi.util.PermissionUtil
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class DiagStatus { PENDING, RUNNING, OK, FAIL, UNKNOWN }
|
||||
|
||||
data class DiagItem(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val status: DiagStatus = DiagStatus.PENDING,
|
||||
val detail: String? = null,
|
||||
val latencyMs: Long? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class DiagnosticsViewModel @Inject constructor(
|
||||
app: Application,
|
||||
private val arenaApi: ArenaApi,
|
||||
) : AndroidViewModel(app) {
|
||||
|
||||
private val _items = MutableStateFlow(
|
||||
listOf(
|
||||
DiagItem("accessibility", "无障碍服务"),
|
||||
DiagItem("overlay", "悬浮窗权限"),
|
||||
DiagItem("floating", "比价浮窗服务"),
|
||||
DiagItem("network", "网络连接"),
|
||||
DiagItem("backend", "后端连通"),
|
||||
DiagItem("llm", "比价识别(LLM)"),
|
||||
)
|
||||
)
|
||||
val items: StateFlow<List<DiagItem>> = _items.asStateFlow()
|
||||
|
||||
private val _running = MutableStateFlow(false)
|
||||
val running: StateFlow<Boolean> = _running.asStateFlow()
|
||||
|
||||
fun runAll() {
|
||||
if (_running.value) return
|
||||
viewModelScope.launch {
|
||||
_running.value = true
|
||||
set("accessibility", if (PermissionUtil.accessibilityGranted(getApplication())) DiagStatus.OK else DiagStatus.FAIL)
|
||||
set("overlay", if (PermissionUtil.overlayGranted(getApplication())) DiagStatus.OK else DiagStatus.FAIL)
|
||||
set("floating", if (FloatingWindowService.isRunning()) DiagStatus.OK else DiagStatus.FAIL,
|
||||
if (FloatingWindowService.isRunning()) "运行中" else "未启动,先打开 App 并授权")
|
||||
set("network", if (online()) DiagStatus.OK else DiagStatus.FAIL, if (online()) null else "未检测到可用网络")
|
||||
set("backend", DiagStatus.RUNNING)
|
||||
val (bs, bd, bl) = checkBackend()
|
||||
set("backend", bs, bd, bl)
|
||||
set("llm", DiagStatus.RUNNING)
|
||||
val (ls, ld, ll) = checkLlm()
|
||||
set("llm", ls, ld, ll)
|
||||
_running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun set(key: String, status: DiagStatus, detail: String? = null, latency: Long? = null) {
|
||||
_items.value = _items.value.map { if (it.key == key) it.copy(status = status, detail = detail, latencyMs = latency) else it }
|
||||
}
|
||||
|
||||
private fun online(): Boolean {
|
||||
val cm = getApplication<Application>().getSystemService(ConnectivityManager::class.java) ?: return false
|
||||
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
private suspend fun checkBackend(): Triple<DiagStatus, String?, Long?> = withContext(Dispatchers.IO) {
|
||||
val start = System.currentTimeMillis()
|
||||
try {
|
||||
val conn = (URL("${BuildConfig.BASE_URL}health").openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 5000; readTimeout = 5000; requestMethod = "GET"
|
||||
}
|
||||
val code = conn.responseCode
|
||||
val latency = System.currentTimeMillis() - start
|
||||
conn.disconnect()
|
||||
if (code == 200) Triple(DiagStatus.OK, null, latency) else Triple(DiagStatus.FAIL, "HTTP $code", latency)
|
||||
} catch (e: Throwable) {
|
||||
Triple(DiagStatus.FAIL, e.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkLlm(): Triple<DiagStatus, String?, Long?> = withContext(Dispatchers.IO) {
|
||||
val start = System.currentTimeMillis()
|
||||
try {
|
||||
val resp = arenaApi.arenaQuote(ArenaQuoteRequest(title = "iPhone 15 Pro 256GB"))
|
||||
Triple(DiagStatus.OK, "常见价 ¥${resp.typicalPrice}", System.currentTimeMillis() - start)
|
||||
} catch (e: Throwable) {
|
||||
Triple(DiagStatus.FAIL, e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
package com.jishisongfu.duobibi.ui.mine
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.auth.BindPhoneState
|
||||
import com.jishisongfu.duobibi.auth.PhoneBindViewModel
|
||||
import com.jishisongfu.duobibi.ui.HeroBanner
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbGradients
|
||||
import com.jishisongfu.duobibi.util.AuthPrefs
|
||||
import com.jishisongfu.duobibi.util.FloatingServiceLauncher
|
||||
import com.jishisongfu.duobibi.util.PermissionUtil
|
||||
import com.jishisongfu.duobibi.util.SettingsPrefs
|
||||
import com.jishisongfu.duobibi.util.VendorAutoStart
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MineScreen(
|
||||
onOpenUnitPrice: () -> Unit,
|
||||
onOpenBundle: () -> Unit,
|
||||
onOpenInstallment: () -> Unit,
|
||||
onOpenDiagnostics: () -> Unit,
|
||||
onOpenAbout: () -> Unit,
|
||||
onOpenPrivacy: () -> Unit,
|
||||
onOpenRecords: () -> Unit,
|
||||
bindVm: PhoneBindViewModel = hiltViewModel(),
|
||||
mineVm: MineViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val bindState by bindVm.state.collectAsStateWithLifecycle()
|
||||
val mineStats by mineVm.stats.collectAsStateWithLifecycle()
|
||||
|
||||
// 权限/绑定状态:source-of-truth 在系统/Prefs,旋转后必须重读最新值 — 保持 remember。
|
||||
var a11yOn by remember { mutableStateOf(PermissionUtil.accessibilityGranted(ctx)) }
|
||||
var overlayOn by remember { mutableStateOf(PermissionUtil.overlayGranted(ctx)) }
|
||||
var floatingOn by remember { mutableStateOf(SettingsPrefs.floatingEnabled(ctx)) }
|
||||
var boundPhone by remember { mutableStateOf(AuthPrefs.phone(ctx)) }
|
||||
// 弹窗打开态:用户主观状态,旋转期间不应突然关闭 — 用 Saveable。
|
||||
var unbindOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var clearAllOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var clearDemoOpen by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val obs = LifecycleEventObserver { _, e ->
|
||||
if (e == Lifecycle.Event.ON_RESUME) {
|
||||
a11yOn = PermissionUtil.accessibilityGranted(ctx)
|
||||
overlayOn = PermissionUtil.overlayGranted(ctx)
|
||||
FloatingServiceLauncher.startIfReady(ctx)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(obs)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
|
||||
}
|
||||
LaunchedEffect(bindState) {
|
||||
if (bindState is BindPhoneState.Success) boundPhone = AuthPrefs.phone(ctx)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.background(DbbColors.Bg)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
HeroBanner(
|
||||
title = "我的",
|
||||
subtitle = "账号 · 权限 · 算价工具 · 自检",
|
||||
icon = Icons.Filled.Person,
|
||||
gradient = DbbGradients.Mine,
|
||||
)
|
||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
// 顶部 hero 个人卡(teal-dark → teal 渐变,统计取本机 Room 实时计算)
|
||||
PersonHeroCard(
|
||||
phone = boundPhone,
|
||||
stats = mineStats,
|
||||
onBind = { ctx.findActivity()?.let { bindVm.bind(it) } },
|
||||
onUnbind = { unbindOpen = true },
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
// 权限
|
||||
SectionLabel("权限")
|
||||
SectionCard {
|
||||
StatusRow("无障碍服务", if (a11yOn) "已开启 ✓" else "去开启", a11yOn) {
|
||||
runCatching { ctx.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) }
|
||||
}
|
||||
ThinDivider()
|
||||
StatusRow("悬浮窗权限", if (overlayOn) "已开启 ✓" else "去开启", overlayOn) {
|
||||
runCatching {
|
||||
ctx.startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${ctx.packageName}")))
|
||||
}
|
||||
}
|
||||
ThinDivider()
|
||||
ClickRow("自启动管理", "保证浮窗与提醒不被系统杀") { VendorAutoStart.openAutoStartSettings(ctx) }
|
||||
ThinDivider()
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("比价浮窗", fontWeight = FontWeight.Medium, color = DbbColors.Ink)
|
||||
Text("在购物 App 内显示比价按钮", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
Switch(
|
||||
checked = floatingOn,
|
||||
onCheckedChange = {
|
||||
floatingOn = it
|
||||
SettingsPrefs.setFloatingEnabled(ctx, it)
|
||||
if (it) FloatingServiceLauncher.startIfReady(ctx)
|
||||
},
|
||||
colors = SwitchDefaults.colors(checkedTrackColor = DbbColors.Teal),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
// 算价工具
|
||||
SectionLabel("算价工具")
|
||||
SectionCard {
|
||||
EmojiRow("📏", "单价对比", "不同规格,谁更划算", onOpenUnitPrice)
|
||||
ThinDivider()
|
||||
EmojiRow("🛒", "凑单计算器", "看怎么凑满减最省", onOpenBundle)
|
||||
ThinDivider()
|
||||
EmojiRow("📉", "分期真实利率", "揭穿「免息分期」真实年化", onOpenInstallment)
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
// 数据与通用
|
||||
SectionLabel("数据与通用")
|
||||
SectionCard {
|
||||
EmojiRow("📋", "我的记录", "全部价格记录与商品组", onOpenRecords)
|
||||
ThinDivider()
|
||||
ClickRow("清除示例数据", "仅删内置示例") { clearDemoOpen = true }
|
||||
ThinDivider()
|
||||
ClickRow("运行自检", "全部正常") { onOpenDiagnostics() }
|
||||
ThinDivider()
|
||||
ClickRow("隐私政策", null) { onOpenPrivacy() }
|
||||
ThinDivider()
|
||||
ClickRow("关于多比比", null) { onOpenAbout() }
|
||||
ThinDivider()
|
||||
ClickRow("清空全部数据", "删除全部记录 / 商品组 / 监控", danger = true) { clearAllOpen = true }
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定状态弹窗
|
||||
when (val s = bindState) {
|
||||
is BindPhoneState.Working -> AlertDialog(
|
||||
onDismissRequest = {}, confirmButton = {},
|
||||
title = { Text("绑定中") },
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.padding(end = 12.dp))
|
||||
Text(s.step)
|
||||
}
|
||||
},
|
||||
)
|
||||
is BindPhoneState.Failed -> AlertDialog(
|
||||
onDismissRequest = bindVm::reset,
|
||||
confirmButton = { TextButton(onClick = bindVm::reset) { Text("知道了") } },
|
||||
title = { Text("绑定失败") }, text = { Text(s.reason) },
|
||||
)
|
||||
is BindPhoneState.Success -> AlertDialog(
|
||||
onDismissRequest = bindVm::reset,
|
||||
confirmButton = { TextButton(onClick = bindVm::reset) { Text("好的") } },
|
||||
title = { Text("绑定成功") }, text = { Text("已绑定 ${AuthPrefs.maskedPhone(s.phone)}") },
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
if (unbindOpen) AlertDialog(
|
||||
onDismissRequest = { unbindOpen = false },
|
||||
title = { Text("解绑手机号?") }, text = { Text("解绑后本机数据保留,可随时重新绑定。") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
AuthPrefs.clear(ctx); boundPhone = null; unbindOpen = false
|
||||
}) { Text("解绑", color = DbbColors.Up) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { unbindOpen = false }) { Text("取消") } },
|
||||
)
|
||||
if (clearDemoOpen) AlertDialog(
|
||||
onDismissRequest = { clearDemoOpen = false },
|
||||
title = { Text("清除示例数据?") }, text = { Text("仅删除内置示例,你自己记的真实数据不受影响。") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
clearDemoOpen = false; mineVm.clearDemo {}
|
||||
}) { Text("清除", color = DbbColors.Up) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { clearDemoOpen = false }) { Text("取消") } },
|
||||
)
|
||||
if (clearAllOpen) AlertDialog(
|
||||
onDismissRequest = { clearAllOpen = false },
|
||||
title = { Text("清空全部数据?") }, text = { Text("将删除全部价格记录、商品组与降价监控,不可恢复。") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
clearAllOpen = false; mineVm.clearAll {}
|
||||
}) { Text("清空", color = DbbColors.Up) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { clearAllOpen = false }) { Text("取消") } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero 渐变个人卡 + 4 栏 stat。统计全部来自 [MineStats] 的本机 Room 计算,空库时为 0。
|
||||
* 渐变改成 teal-dark→teal,去掉 sky 色,与新设计语言"3 色为主"对齐。
|
||||
*/
|
||||
@Composable
|
||||
private fun PersonHeroCard(
|
||||
phone: String?,
|
||||
stats: MineStats,
|
||||
onBind: () -> Unit,
|
||||
onUnbind: () -> Unit,
|
||||
) {
|
||||
val gradient = Brush.linearGradient(listOf(DbbColors.TealDark, DbbColors.Teal))
|
||||
val masked = phone?.let { AuthPrefs.maskedPhone(it) } ?: "未绑定手机号"
|
||||
val sub = if (phone != null) "已绑定 · 多端同步已开启" else "点击绑定开启多端同步"
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(gradient)
|
||||
.padding(15.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(50.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Person, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
Spacer(Modifier.width(13.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable { if (phone == null) onBind() else onUnbind() },
|
||||
) {
|
||||
Text(masked, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 17.sp)
|
||||
Text(sub, color = Color.White.copy(alpha = 0.9f), fontSize = 12.sp)
|
||||
}
|
||||
IconButton(onClick = { /* TODO 设置 */ }) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "设置", tint = Color.White)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row {
|
||||
StatColumn(stats.totalProducts.toString(), "已比价", Modifier.weight(1f))
|
||||
VerticalLine()
|
||||
StatColumn(stats.totalRecords.toString(), "价格记录", Modifier.weight(1f))
|
||||
VerticalLine()
|
||||
StatColumn(stats.activeAlerts.toString(), "监控中", Modifier.weight(1f))
|
||||
VerticalLine()
|
||||
StatColumn("¥${fmtYuan(stats.totalSaved)}", "累计省", Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatColumn(value: String, label: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(value, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 17.sp)
|
||||
Text(label, color = Color.White.copy(alpha = 0.85f), fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerticalLine() {
|
||||
Box(
|
||||
Modifier
|
||||
.width(1.dp)
|
||||
.height(28.dp)
|
||||
.background(Color.White.copy(alpha = 0.25f)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(title: String) {
|
||||
Text(
|
||||
title,
|
||||
color = DbbColors.InkSec,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(content: @Composable () -> Unit) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) { Column { content() } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinDivider() {
|
||||
Divider(color = DbbColors.Border, thickness = 1.dp, modifier = Modifier.padding(horizontal = 14.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClickRow(title: String, subtitle: String?, danger: Boolean = false, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontWeight = FontWeight.Medium, color = if (danger) DbbColors.Up else DbbColors.Ink)
|
||||
subtitle?.let { Text(it, color = DbbColors.InkTer, fontSize = 11.sp) }
|
||||
}
|
||||
Text("›", color = DbbColors.InkTer, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(title: String, status: String, ok: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title, fontWeight = FontWeight.Medium, color = DbbColors.Ink, modifier = Modifier.weight(1f))
|
||||
Text(status, color = if (ok) DbbColors.Save else DbbColors.Teal, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiRow(emoji: String, title: String, subtitle: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(emoji, fontSize = 18.sp, modifier = Modifier.width(28.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontWeight = FontWeight.Medium, color = DbbColors.Ink)
|
||||
Text(subtitle, color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
Text("›", color = DbbColors.InkTer, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.jishisongfu.duobibi.ui.mine
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.data.db.AlertStatus
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
import com.jishisongfu.duobibi.util.DemoDataSeeder
|
||||
import com.jishisongfu.duobibi.util.StatsCalculator
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* "我的"屏顶部 hero 卡里展示的 4 项本机统计。全部由本机 Room 数据实时计算 ——
|
||||
* 不放任何虚构数字,审核员对得上(空库时 4 项都是 0)。
|
||||
*/
|
||||
data class MineStats(
|
||||
val totalProducts: Int,
|
||||
val totalRecords: Int,
|
||||
val activeAlerts: Int,
|
||||
val totalSaved: Double,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class MineViewModel @Inject constructor(
|
||||
app: Application,
|
||||
private val repo: PriceRepository,
|
||||
) : AndroidViewModel(app) {
|
||||
|
||||
/** 三流合并:商品簇 + 价格记录 + 降价雷达 → MineStats。初始值给全零,避免首帧 null 闪烁。 */
|
||||
val stats: StateFlow<MineStats> = combine(
|
||||
repo.observeAllClusters(),
|
||||
repo.observeAllRecords(),
|
||||
repo.observeAllAlerts(),
|
||||
) { clusters, records, alerts ->
|
||||
val overall = StatsCalculator.computeOverall(clusters, records)
|
||||
MineStats(
|
||||
totalProducts = overall.totalProducts,
|
||||
totalRecords = overall.totalRecords,
|
||||
activeAlerts = alerts.count { it.status == AlertStatus.ACTIVE },
|
||||
totalSaved = overall.totalSaved,
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
MineStats(0, 0, 0, 0.0),
|
||||
)
|
||||
|
||||
fun clearDemo(onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
DemoDataSeeder.clearAndUnseed(getApplication(), repo)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll(onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
repo.deleteAll()
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package com.jishisongfu.duobibi.ui.nav
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Balance
|
||||
import androidx.compose.material.icons.filled.Explore
|
||||
import androidx.compose.material.icons.filled.Lightbulb
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.outlined.Balance
|
||||
import androidx.compose.material.icons.outlined.Explore
|
||||
import androidx.compose.material.icons.outlined.Lightbulb
|
||||
import androidx.compose.material.icons.outlined.Notifications
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.jishisongfu.duobibi.ui.alert.AlertScreen
|
||||
import com.jishisongfu.duobibi.ui.arena.ArenaScreen
|
||||
import com.jishisongfu.duobibi.ui.arena.ArenaViewModel
|
||||
import com.jishisongfu.duobibi.ui.detail.ProductDetailScreen
|
||||
import com.jishisongfu.duobibi.ui.discover.DiscoverScreen
|
||||
import com.jishisongfu.duobibi.ui.mine.AboutScreen
|
||||
import com.jishisongfu.duobibi.ui.mine.DiagnosticsScreen
|
||||
import com.jishisongfu.duobibi.ui.mine.MineScreen
|
||||
import com.jishisongfu.duobibi.ui.privacy.PrivacyPolicyScreen
|
||||
import com.jishisongfu.duobibi.ui.records.ClusterDetailScreen
|
||||
import com.jishisongfu.duobibi.ui.records.NewRecordScreen
|
||||
import com.jishisongfu.duobibi.ui.records.RecordsScreen
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import com.jishisongfu.duobibi.ui.tools.BundleCalculatorScreen
|
||||
import com.jishisongfu.duobibi.ui.tools.InstallmentCalculatorScreen
|
||||
import com.jishisongfu.duobibi.ui.tools.UnitPriceCalculatorScreen
|
||||
import com.jishisongfu.duobibi.ui.watched.WatchedProductsScreen
|
||||
import com.jishisongfu.duobibi.ui.worth.WorthBuyScreen
|
||||
|
||||
private object Routes {
|
||||
// 5 个底部 Tab(v1.1:把 RECORDS 挤出 Tab,新增 DISCOVER 作为新首屏)
|
||||
const val DISCOVER = "discover"
|
||||
const val ARENA = "arena"
|
||||
const val ALERT = "alert"
|
||||
const val WORTH = "worth"
|
||||
const val MINE = "mine"
|
||||
|
||||
// 非 Tab 路由(从其它入口可达)
|
||||
const val RECORDS = "records"
|
||||
const val CLUSTER = "cluster/{id}"
|
||||
const val NEW_RECORD = "new_record"
|
||||
const val DIAGNOSTICS = "diagnostics"
|
||||
const val ABOUT = "about"
|
||||
const val PRIVACY = "privacy"
|
||||
const val TOOL_UNIT = "tool_unit"
|
||||
const val TOOL_BUNDLE = "tool_bundle"
|
||||
const val TOOL_INSTALLMENT = "tool_installment"
|
||||
|
||||
// v1.2:Discover 卡片点进的商品详情页(纵深层级,区别于 Arena 的横向搜索)
|
||||
const val PRODUCT_DETAIL = "product/{itemId}"
|
||||
|
||||
// v1.3:发现页 hero 右上 ❤️ 入口跳的关注列表
|
||||
const val WATCHED = "watched"
|
||||
|
||||
fun cluster(id: Long) = "cluster/$id"
|
||||
fun productDetail(itemId: String) = "product/$itemId"
|
||||
}
|
||||
|
||||
private val TABS = setOf(Routes.DISCOVER, Routes.ARENA, Routes.ALERT, Routes.WORTH, Routes.MINE)
|
||||
|
||||
@Composable
|
||||
fun AppNavHost() {
|
||||
val nav = rememberNavController()
|
||||
val backStackEntry by nav.currentBackStackEntryAsState()
|
||||
val current = backStackEntry?.destination?.route
|
||||
val showBar = current in TABS
|
||||
|
||||
// ArenaViewModel 上提到整个 NavHost 作用域 —— Discover 卡片要把 title/category
|
||||
// 喂给同一个 ArenaViewModel 实例再跳过去,避免两个 Tab 各自一份导致状态不同步。
|
||||
val arenaVm: ArenaViewModel = hiltViewModel()
|
||||
|
||||
Scaffold(
|
||||
bottomBar = { if (showBar) BottomBar(current) { route -> nav.navigateTab(route) } },
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = nav,
|
||||
startDestination = Routes.DISCOVER,
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
composable(Routes.DISCOVER) {
|
||||
DiscoverScreen(
|
||||
onOpenDetail = { id -> nav.navigate(Routes.productDetail(id)) },
|
||||
onOpenWatched = { nav.navigate(Routes.WATCHED) },
|
||||
)
|
||||
}
|
||||
composable(Routes.ARENA) {
|
||||
ArenaScreen(
|
||||
onOpenCluster = { nav.navigate(Routes.cluster(it)) },
|
||||
vm = arenaVm,
|
||||
)
|
||||
}
|
||||
composable(Routes.ALERT) { AlertScreen() }
|
||||
composable(Routes.WORTH) {
|
||||
WorthBuyScreen(
|
||||
onOpenCluster = { nav.navigate(Routes.cluster(it)) },
|
||||
onOpenArena = { title ->
|
||||
arenaVm.submitWithTitle(title, null)
|
||||
nav.navigateTab(Routes.ARENA)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.MINE) {
|
||||
MineScreen(
|
||||
onOpenUnitPrice = { nav.navigate(Routes.TOOL_UNIT) },
|
||||
onOpenBundle = { nav.navigate(Routes.TOOL_BUNDLE) },
|
||||
onOpenInstallment = { nav.navigate(Routes.TOOL_INSTALLMENT) },
|
||||
onOpenDiagnostics = { nav.navigate(Routes.DIAGNOSTICS) },
|
||||
onOpenAbout = { nav.navigate(Routes.ABOUT) },
|
||||
onOpenPrivacy = { nav.navigate(Routes.PRIVACY) },
|
||||
onOpenRecords = { nav.navigate(Routes.RECORDS) },
|
||||
)
|
||||
}
|
||||
|
||||
// 非 Tab,从 Discover hero 右上 ❤️ 跳进的关注列表
|
||||
composable(Routes.WATCHED) {
|
||||
WatchedProductsScreen(
|
||||
onBack = { nav.popBackStack() },
|
||||
onOpenDetail = { id -> nav.navigate(Routes.productDetail(id)) },
|
||||
)
|
||||
}
|
||||
|
||||
// 非 Tab,从 Discover 卡片跳进的商品详情(纵深一层)
|
||||
composable(
|
||||
Routes.PRODUCT_DETAIL,
|
||||
arguments = listOf(navArgument("itemId") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val id = entry.arguments?.getString("itemId") ?: return@composable
|
||||
ProductDetailScreen(
|
||||
itemId = id,
|
||||
onBack = { nav.popBackStack() },
|
||||
onOpenArena = { title ->
|
||||
arenaVm.submitWithTitle(title, null)
|
||||
nav.navigateTab(Routes.ARENA)
|
||||
},
|
||||
onAddAlert = { title, target -> arenaVm.addToAlert(title, target) {} },
|
||||
onOpenDetail = { otherId -> nav.navigate(Routes.productDetail(otherId)) },
|
||||
)
|
||||
}
|
||||
|
||||
// 非 Tab,从 Mine 跳进
|
||||
composable(Routes.RECORDS) {
|
||||
RecordsScreen(
|
||||
onOpenCluster = { nav.navigate(Routes.cluster(it)) },
|
||||
onAdd = { nav.navigate(Routes.NEW_RECORD) },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Routes.CLUSTER,
|
||||
arguments = listOf(navArgument("id") { type = NavType.LongType }),
|
||||
) { entry ->
|
||||
val id = entry.arguments?.getLong("id") ?: return@composable
|
||||
ClusterDetailScreen(clusterId = id, onBack = { nav.popBackStack() })
|
||||
}
|
||||
composable(Routes.NEW_RECORD) { NewRecordScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.DIAGNOSTICS) { DiagnosticsScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.ABOUT) {
|
||||
AboutScreen(onBack = { nav.popBackStack() }, onOpenPrivacy = { nav.navigate(Routes.PRIVACY) })
|
||||
}
|
||||
composable(Routes.PRIVACY) { PrivacyPolicyScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.TOOL_UNIT) { UnitPriceCalculatorScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.TOOL_BUNDLE) { BundleCalculatorScreen(onBack = { nav.popBackStack() }) }
|
||||
composable(Routes.TOOL_INSTALLMENT) { InstallmentCalculatorScreen(onBack = { nav.popBackStack() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavController.navigateTab(route: String) {
|
||||
navigate(route) {
|
||||
popUpTo(graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomBar(current: String?, onSelect: (String) -> Unit) {
|
||||
NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
|
||||
Item(current == Routes.DISCOVER, "发现", Icons.Filled.Explore, Icons.Outlined.Explore) { onSelect(Routes.DISCOVER) }
|
||||
Item(current == Routes.ARENA, "比价", Icons.Filled.Balance, Icons.Outlined.Balance) { onSelect(Routes.ARENA) }
|
||||
Item(current == Routes.ALERT, "雷达", Icons.Filled.NotificationsActive, Icons.Outlined.Notifications) { onSelect(Routes.ALERT) }
|
||||
Item(current == Routes.WORTH, "值不值", Icons.Filled.Lightbulb, Icons.Outlined.Lightbulb) { onSelect(Routes.WORTH) }
|
||||
Item(current == Routes.MINE, "我的", Icons.Filled.Person, Icons.Outlined.Person) { onSelect(Routes.MINE) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.Item(selected: Boolean, label: String, on: ImageVector, off: ImageVector, onClick: () -> Unit) {
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
icon = { Icon(if (selected) on else off, contentDescription = label) },
|
||||
label = { Text(label, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal) },
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = Color.White,
|
||||
selectedTextColor = DbbColors.Teal,
|
||||
indicatorColor = DbbColors.Teal,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package com.jishisongfu.duobibi.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Balance
|
||||
import androidx.compose.material.icons.filled.Lightbulb
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.jishisongfu.duobibi.ui.PlatformAvatar
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
private val ONBOARDING_PLATFORMS = listOf("淘宝", "京东", "拼多多", "抖音", "美团", "饿了么")
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(onFinish: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(DbbColors.Bg)
|
||||
.padding(horizontal = 22.dp, vertical = 18.dp),
|
||||
) {
|
||||
// 顶部 brand: logo + 名称 + 副标题
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(DbbColors.Teal),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Balance,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column {
|
||||
Text("多比比", fontWeight = FontWeight.ExtraBold, fontSize = 24.sp, color = DbbColors.Ink)
|
||||
Text("一个商品,全网到手价同台比一比", color = DbbColors.InkSec, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
// 平台头像行
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ONBOARDING_PLATFORMS.forEach {
|
||||
PlatformAvatar(it, small = true)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Text("已覆盖 7 大购物 / 外卖平台", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// 核心功能 section
|
||||
Text(
|
||||
"核心功能",
|
||||
color = DbbColors.InkSec,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(start = 2.dp, bottom = 6.dp),
|
||||
)
|
||||
|
||||
// 4 功能卡:底色统一 Teal 浅,图标统一 Teal 深 —— 屏幕只剩一个主色相,
|
||||
// 与新设计语言"3 色为主"对齐(原来 4 张卡 4 种背景色噪点太多)。
|
||||
FeatureCard(
|
||||
Icons.Filled.Balance, DbbColors.TealBg, DbbColors.TealDark,
|
||||
"比价擂台", "淘宝 / 京东 / 拼多多 / 抖音 到手价同台 PK,自动标出全网最低",
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
FeatureCard(
|
||||
Icons.Filled.NotificationsActive, DbbColors.TealBg, DbbColors.TealDark,
|
||||
"降价雷达", "想买的设心理价、已买的盯价保期,降价第一时间提醒",
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
FeatureCard(
|
||||
Icons.Filled.Lightbulb, DbbColors.TealBg, DbbColors.TealDark,
|
||||
"值不值得买", "报上价格,AI 给 0-100 评分,告诉你该买还是再等",
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
FeatureCard(
|
||||
Icons.Filled.Lock, DbbColors.TealBg, DbbColors.TealDark,
|
||||
"数据在本机", "价格记录默认只存手机本地,比价时才临时联网识别",
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// 开始使用 + 隐私小字
|
||||
Button(
|
||||
onClick = onFinish,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) {
|
||||
Text(
|
||||
"开始使用",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"登录后可多端同步 · 隐私政策已更新",
|
||||
color = DbbColors.InkTer,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeatureCard(
|
||||
icon: ImageVector,
|
||||
iconBg: Color,
|
||||
iconTint: Color,
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(46.dp)
|
||||
.clip(RoundedCornerShape(13.dp))
|
||||
.background(iconBg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
Spacer(Modifier.width(13.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, color = DbbColors.Ink, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(body, color = DbbColors.InkSec, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.jishisongfu.duobibi.ui.privacy
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
@Composable
|
||||
fun PrivacyConsentScreen(onAgree: () -> Unit, onReject: () -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(20.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("欢迎使用多比比", fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, color = DbbColors.Ink)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("多平台比比看,买啥都不吃亏", color = DbbColors.Teal, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||
) { PolicyBody() }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = onAgree,
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp), shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) { Text("同意并开始使用", fontWeight = FontWeight.SemiBold, fontSize = 16.sp) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = onReject,
|
||||
modifier = Modifier.fillMaxWidth().height(46.dp), shape = RoundedCornerShape(14.dp),
|
||||
) { Text("不同意并退出", color = DbbColors.InkSec) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.jishisongfu.duobibi.ui.privacy
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PrivacyPolicyScreen(onBack: () -> Unit) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("隐私政策", fontWeight = FontWeight.Bold, color = DbbColors.Ink) },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回", tint = DbbColors.Ink) } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = DbbColors.Card),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.padding(padding).fillMaxSize().background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(rememberScrollState()).padding(20.dp),
|
||||
) { PolicyBody() }
|
||||
}
|
||||
}
|
||||
|
||||
/** 隐私政策正文,首启同意页与设置内政策页共用。 */
|
||||
@Composable
|
||||
internal fun PolicyBody() {
|
||||
Section("我们是谁", "多比比是一款帮你跨平台比价的工具类 App。本政策说明我们如何处理你的信息。")
|
||||
Section(
|
||||
"权限说明",
|
||||
"· 无障碍服务:仅用于在你主动点击比价浮窗时,读取当前购物页面的商品标题与价格文本。不在后台持续读取、不记录你的浏览行为。\n" +
|
||||
"· 悬浮窗:在淘宝 / 京东 / 拼多多 / 抖音 等购物页显示一个可拖动的「比价」按钮。\n" +
|
||||
"· 通知:用于降价提醒。",
|
||||
)
|
||||
Section(
|
||||
"信息的收集与使用",
|
||||
"· 你的价格记录、商品组、降价监控仅保存在你的本机数据库,默认不上传服务器。\n" +
|
||||
"· 当你主动点击浮窗记价、使用截图记账、比价擂台或「值不值得买」时,相关的页面价格文本 / 标题 / 截图会发送到我们的服务器(api.duobibi.com)做一次性识别与估价,处理完即释放,不做持久化存储。\n" +
|
||||
"· 若你选择用手机号一键登录绑定账号,我们会收集你的手机号用于账号标识。",
|
||||
)
|
||||
Section("信息共享", "我们不会把你的个人信息出售或共享给第三方。比价时调起的购物 App 搜索为系统跳转,不携带任何你的身份信息。")
|
||||
Section("信息存储与安全", "本机数据随 App 卸载一并清除。网络传输使用 HTTPS 加密。")
|
||||
Section("联系我们", "如有疑问可通过应用市场页面或反馈入口联系我们。")
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(title: String, body: String) {
|
||||
Text(title, fontWeight = FontWeight.Bold, color = DbbColors.Ink, fontSize = 15.sp)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(body, color = DbbColors.InkSec, fontSize = 13.sp, lineHeight = 20.sp)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
package com.jishisongfu.duobibi.ui.records
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.data.db.PriceRecord
|
||||
import com.jishisongfu.duobibi.ui.DbbProgressBar
|
||||
import com.jishisongfu.duobibi.ui.PlatformAvatar
|
||||
import com.jishisongfu.duobibi.ui.ProductThumb
|
||||
import com.jishisongfu.duobibi.ui.TagChip
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ClusterDetailScreen(
|
||||
clusterId: Long,
|
||||
onBack: () -> Unit,
|
||||
vm: ClusterDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(clusterId) { vm.load(clusterId) }
|
||||
val cluster by vm.cluster.collectAsStateWithLifecycle()
|
||||
val records by vm.records.collectAsStateWithLifecycle()
|
||||
|
||||
var menuOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var renameOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var deleteClusterOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var deleteRecordId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
cluster?.title ?: "商品详情",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
color = DbbColors.Ink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回", tint = DbbColors.Ink)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(Icons.Filled.MoreVert, "更多", tint = DbbColors.Ink)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(text = { Text("重命名") }, onClick = { menuOpen = false; renameOpen = true })
|
||||
DropdownMenuItem(
|
||||
text = { Text("删除商品组", color = DbbColors.Up) },
|
||||
onClick = { menuOpen = false; deleteClusterOpen = true },
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = DbbColors.Card),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (records.isEmpty()) {
|
||||
Box(
|
||||
Modifier.padding(padding).fillMaxSize().background(DbbColors.Bg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("该商品组暂无记录", color = DbbColors.InkTer, fontSize = 13.sp)
|
||||
}
|
||||
} else {
|
||||
val minP = records.minOf { it.price }
|
||||
val maxP = records.maxOf { it.price }
|
||||
val typical = cluster?.typicalPrice
|
||||
val sortedAsc = records.sortedBy { it.createdAt }
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.background(DbbColors.Bg)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
SummaryCard(records, minP, maxP, typical, sortedAsc)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
PlatformMinCard(records, minP, maxP)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
if (records.size >= 2) {
|
||||
TrendCard(records, sortedAsc)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
Text("全部记录", fontWeight = FontWeight.Bold, color = DbbColors.Ink, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
RecordsList(records, minP) { deleteRecordId = it }
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
ActionRow()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (renameOpen) {
|
||||
var text by remember { mutableStateOf(cluster?.title ?: "") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renameOpen = false },
|
||||
title = { Text("重命名商品组") },
|
||||
text = { OutlinedTextField(value = text, onValueChange = { text = it }, singleLine = true) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
if (text.isNotBlank()) vm.rename(text); renameOpen = false
|
||||
}) { Text("保存", color = DbbColors.Teal) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { renameOpen = false }) { Text("取消") } },
|
||||
)
|
||||
}
|
||||
if (deleteClusterOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteClusterOpen = false },
|
||||
title = { Text("删除整个商品组?") },
|
||||
text = { Text("该商品组及其下全部价格记录都会被删除,不可恢复。") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
deleteClusterOpen = false; vm.deleteCluster(onBack)
|
||||
}) { Text("删除", color = DbbColors.Up) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { deleteClusterOpen = false }) { Text("取消") } },
|
||||
)
|
||||
}
|
||||
deleteRecordId?.let { rid ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { deleteRecordId = null },
|
||||
title = { Text("删除这条记录?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
deleteRecordId = null; vm.deleteRecord(rid, onBack)
|
||||
}) { Text("删除", color = DbbColors.Up) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { deleteRecordId = null }) { Text("取消") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryCard(
|
||||
records: List<PriceRecord>,
|
||||
minP: Double,
|
||||
maxP: Double,
|
||||
typical: Double?,
|
||||
sortedAsc: List<PriceRecord>,
|
||||
) {
|
||||
val firstAt = sortedAsc.first().createdAt
|
||||
val latestAt = sortedAsc.last().createdAt
|
||||
val trackedDays = TimeUnit.MILLISECONDS.toDays(latestAt - firstAt).toInt().coerceAtLeast(0)
|
||||
val minRec = records.minBy { it.price }
|
||||
val savingsVsTypical = typical?.let { (it - minP).coerceAtLeast(0.0) }
|
||||
|
||||
val titleForThumb = records.firstOrNull()?.title ?: ""
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.TealBg),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProductThumb(title = titleForThumb, size = 54.dp, containerColor = Color.White)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text("本机最低 ", color = DbbColors.InkSec, fontSize = 13.sp)
|
||||
Text(
|
||||
"¥${fmtYuan(minP)}",
|
||||
color = DbbColors.TealDark,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 24.sp,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
PlatformAvatar(minRec.sourceApp, small = true)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
minRec.sourceApp, color = DbbColors.TealDark, fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (savingsVsTypical != null && savingsVsTypical > 0) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.White)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
"省 ¥${fmtYuan(savingsVsTypical)}",
|
||||
color = DbbColors.Save,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val typicalPart = if (typical != null) " · 常见 ¥${fmtYuan(typical)}" else ""
|
||||
Text(
|
||||
"最高 ¥${fmtYuan(maxP)} · 价差 ¥${fmtYuan(maxP - minP)} · 共 ${records.size} 条$typicalPart",
|
||||
color = DbbColors.InkSec, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val firstStr = dateFmtShort.format(Date(firstAt))
|
||||
val latestStr = dateFmtShort.format(Date(latestAt))
|
||||
Text(
|
||||
"首次记录 $firstStr · 最近更新 $latestStr · 已追踪 $trackedDays 天",
|
||||
color = DbbColors.InkTer, fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlatformMinCard(records: List<PriceRecord>, minP: Double, maxP: Double) {
|
||||
// 取每个平台的最低价
|
||||
val perPlatform = records.groupBy { it.sourceApp }
|
||||
.map { (app, recs) -> app to recs.minOf { it.price } }
|
||||
.sortedBy { it.second }
|
||||
val range = (maxP - minP).coerceAtLeast(0.01)
|
||||
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
Text("各平台最低价", fontWeight = FontWeight.Bold, color = DbbColors.Ink, fontSize = 13.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
perPlatform.forEachIndexed { i, (app, price) ->
|
||||
if (i > 0) Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
Modifier.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlatformAvatar(app, small = true)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
app, color = DbbColors.Ink, fontSize = 13.sp,
|
||||
modifier = Modifier.width(56.dp),
|
||||
)
|
||||
val isMin = price == minP
|
||||
val isMax = price == maxP && perPlatform.size > 1
|
||||
// 反向映射:价格越低,bar 越长(满刻度)
|
||||
val frac = (1f - ((price - minP) / range).toFloat()).coerceIn(0.04f, 1f)
|
||||
DbbProgressBar(
|
||||
fraction = frac,
|
||||
color = if (isMax) DbbColors.InkTer else DbbColors.Teal,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"¥${fmtYuan(price)}",
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 14.sp,
|
||||
color = if (isMin) DbbColors.Teal else DbbColors.Ink,
|
||||
modifier = Modifier.width(58.dp),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
when {
|
||||
isMin -> TagChip("最低", bg = DbbColors.Teal, fg = Color.White)
|
||||
isMax -> TagChip("最高", bg = DbbColors.UpBg, fg = DbbColors.Up)
|
||||
else -> Spacer(Modifier.width(28.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrendCard(records: List<PriceRecord>, sortedAsc: List<PriceRecord>) {
|
||||
val firstStr = dateFmtShort.format(Date(sortedAsc.first().createdAt))
|
||||
val latestStr = dateFmtShort.format(Date(sortedAsc.last().createdAt))
|
||||
val days = TimeUnit.MILLISECONDS.toDays(sortedAsc.last().createdAt - sortedAsc.first().createdAt)
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("价格趋势", fontWeight = FontWeight.Bold, color = DbbColors.Ink, fontSize = 13.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
val rangeLabel = if (days > 0) "$firstStr → $latestStr" else "近期"
|
||||
Text(rangeLabel, color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
PriceTrendChart(records)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordsList(records: List<PriceRecord>, minP: Double, onDelete: (Long) -> Unit) {
|
||||
val byTime = records.sortedByDescending { it.createdAt }
|
||||
// 把每条与"它之前那次"的价差算出来,用于 ↑/↓ 显示
|
||||
val firstAt = byTime.minOf { it.createdAt }
|
||||
Column {
|
||||
byTime.forEachIndexed { i, r ->
|
||||
val prev = byTime.getOrNull(i + 1)
|
||||
val diff = prev?.let { r.price - it.price }
|
||||
RecordRow(
|
||||
r = r,
|
||||
isMin = r.price == minP,
|
||||
diffFromPrev = diff,
|
||||
isFirst = r.createdAt == firstAt && prev == null,
|
||||
onDelete = { onDelete(r.id) },
|
||||
)
|
||||
if (i < byTime.lastIndex) {
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(DbbColors.Border))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordRow(
|
||||
r: PriceRecord,
|
||||
isMin: Boolean,
|
||||
diffFromPrev: Double?,
|
||||
isFirst: Boolean,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PlatformAvatar(r.sourceApp, small = true)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"¥${fmtYuan(r.price)}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = if (isMin) DbbColors.Teal else DbbColors.Ink,
|
||||
)
|
||||
if (isMin) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
"最低",
|
||||
color = DbbColors.Teal, fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
if (diffFromPrev != null) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
val arrow = if (diffFromPrev < 0) "↓比上次 ¥${fmtYuan(-diffFromPrev)}" else "↑比上次 ¥${fmtYuan(diffFromPrev)}"
|
||||
val color = if (diffFromPrev < 0) DbbColors.Save else DbbColors.Up
|
||||
Text(arrow, color = color, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
val datePart = dateFmt.format(Date(r.createdAt))
|
||||
val sub = if (isFirst) "${r.sourceApp} · $datePart · 首次记录" else "${r.sourceApp} · $datePart"
|
||||
Text(sub, color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Filled.DeleteOutline, "删除", tint = DbbColors.InkTer, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionRow() {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { /* TODO 加入降价雷达 */ },
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
border = BorderStroke(1.5.dp, DbbColors.Teal),
|
||||
) {
|
||||
Text("加入降价雷达", color = DbbColors.TealDark, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
Button(
|
||||
onClick = { /* TODO 去擂台比全网 */ },
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) {
|
||||
Text("去擂台比全网", fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val dateFmt = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
||||
private val dateFmtShort = SimpleDateFormat("MM-dd", Locale.getDefault())
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.jishisongfu.duobibi.ui.records
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.data.db.Cluster
|
||||
import com.jishisongfu.duobibi.data.db.PriceRecord
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class ClusterDetailViewModel @Inject constructor(
|
||||
private val repo: PriceRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _cluster = MutableStateFlow<Cluster?>(null)
|
||||
val cluster: StateFlow<Cluster?> = _cluster.asStateFlow()
|
||||
|
||||
private val _clusterId = MutableStateFlow<Long?>(null)
|
||||
|
||||
val records: StateFlow<List<PriceRecord>> = _clusterId
|
||||
.flatMapLatest { id -> if (id == null) flowOf(emptyList()) else repo.observeRecordsByCluster(id) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun load(id: Long) {
|
||||
_clusterId.value = id
|
||||
viewModelScope.launch { _cluster.value = repo.clusterById(id) }
|
||||
}
|
||||
|
||||
fun rename(newTitle: String) {
|
||||
val id = _clusterId.value ?: return
|
||||
viewModelScope.launch {
|
||||
repo.renameCluster(id, newTitle.trim())
|
||||
_cluster.value = repo.clusterById(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRecord(recordId: Long, onClusterEmpty: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
repo.deleteRecord(recordId)
|
||||
if (records.value.size - 1 <= 0) onClusterEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCluster(onDone: () -> Unit) {
|
||||
val id = _clusterId.value ?: return
|
||||
viewModelScope.launch { repo.deleteCluster(id); onDone() }
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package com.jishisongfu.duobibi.ui.records
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.jishisongfu.duobibi.ui.ALL_PLATFORMS
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewRecordScreen(onBack: () -> Unit, vm: NewRecordViewModel = hiltViewModel()) {
|
||||
// 表单字段:旋转/切后台不丢内容,避免重输。
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var priceText by rememberSaveable { mutableStateOf("") }
|
||||
var source by rememberSaveable { mutableStateOf(ALL_PLATFORMS.first()) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("手动记一笔", fontWeight = FontWeight.Bold, color = DbbColors.Ink) },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回", tint = DbbColors.Ink) } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = DbbColors.Card),
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.padding(padding).fillMaxSize().background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
) {
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("商品名") },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = priceText,
|
||||
onValueChange = { priceText = it.filter { c -> c.isDigit() || c == '.' } },
|
||||
label = { Text("到手价(元)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text("来源平台", color = DbbColors.InkSec, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ALL_PLATFORMS.forEach { p ->
|
||||
FilterChip(selected = source == p, onClick = { source = p }, label = { Text(p) },
|
||||
colors = FilterChipDefaults.filterChipColors(selectedContainerColor = DbbColors.Teal, selectedLabelColor = Color.White))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val price = priceText.toDoubleOrNull()
|
||||
Button(
|
||||
onClick = { if (title.isNotBlank() && price != null && price > 0) vm.save(title, price, source, null, onBack) },
|
||||
enabled = title.isNotBlank() && price != null && price > 0,
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp), shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) { Text("保存到擂台", fontWeight = FontWeight.SemiBold, fontSize = 16.sp) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("保存后会自动归入同名商品组,可在「比价擂台」里跨平台对比。", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.jishisongfu.duobibi.ui.records
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.data.db.Cluster
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NewRecordViewModel @Inject constructor(
|
||||
private val repo: PriceRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val clusters: StateFlow<List<Cluster>> = repo.observeAllClusters()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun save(title: String, price: Double, sourceApp: String, clusterId: Long?, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
repo.insertManualRecord(title.trim(), price, sourceApp, clusterId)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.jishisongfu.duobibi.ui.records
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.jishisongfu.duobibi.data.db.PriceRecord
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
/** 价格趋势折线图(蓝青风)。横轴时间,纵轴价格,最低点红色高亮。 */
|
||||
@Composable
|
||||
fun PriceTrendChart(records: List<PriceRecord>, modifier: Modifier = Modifier) {
|
||||
if (records.size < 2) return
|
||||
val sorted = records.sortedBy { it.createdAt }
|
||||
val minTs = sorted.first().createdAt
|
||||
val tsSpan = (sorted.last().createdAt - minTs).coerceAtLeast(1L).toFloat()
|
||||
val minPrice = sorted.minOf { it.price }
|
||||
val priceSpan = (sorted.maxOf { it.price } - minPrice).coerceAtLeast(0.01).toFloat()
|
||||
|
||||
Box(modifier.fillMaxWidth().height(150.dp)) {
|
||||
Canvas(Modifier.fillMaxWidth().height(150.dp)) {
|
||||
val padL = 8.dp.toPx(); val padR = 8.dp.toPx(); val padT = 16.dp.toPx(); val padB = 16.dp.toPx()
|
||||
val w = size.width - padL - padR
|
||||
val h = size.height - padT - padB
|
||||
for (i in 0..2) {
|
||||
val y = padT + h * (i / 2f)
|
||||
drawLine(DbbColors.Border, Offset(padL, y), Offset(padL + w, y), 1f,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f)))
|
||||
}
|
||||
val points = sorted.map { r ->
|
||||
val x = padL + ((r.createdAt - minTs).toFloat() / tsSpan) * w
|
||||
val y = padT + h - ((r.price - minPrice).toFloat() / priceSpan) * h
|
||||
Offset(x, y) to r
|
||||
}
|
||||
val path = Path()
|
||||
points.forEachIndexed { idx, (p, _) -> if (idx == 0) path.moveTo(p.x, p.y) else path.lineTo(p.x, p.y) }
|
||||
drawPath(path, DbbColors.Teal, style = Stroke(width = 4f))
|
||||
points.forEach { (p, r) ->
|
||||
val isMin = r.price == minPrice
|
||||
drawCircle(if (isMin) DbbColors.Up else DbbColors.Teal, if (isMin) 7f else 5f, p)
|
||||
if (isMin) drawCircle(Color.White, 3f, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
package com.jishisongfu.duobibi.ui.records
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.ui.DbbProgressBar
|
||||
import com.jishisongfu.duobibi.ui.PlatformAvatar
|
||||
import com.jishisongfu.duobibi.ui.ProductThumb
|
||||
import com.jishisongfu.duobibi.ui.TagChip
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import com.jishisongfu.duobibi.util.ClusterStats
|
||||
import com.jishisongfu.duobibi.util.OverallStats
|
||||
|
||||
private enum class RecordsSort(val label: String) {
|
||||
RECENT("最近"), SPREAD("价差大"), MIN_PRICE("价最低")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecordsScreen(
|
||||
onOpenCluster: (Long) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
vm: RecordsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val groups by vm.groups.collectAsStateWithLifecycle()
|
||||
val query by vm.query.collectAsStateWithLifecycle()
|
||||
val overall by vm.overall.collectAsStateWithLifecycle()
|
||||
// RecordsSort 是 enum,Java Enum 默认 Serializable,rememberSaveable autoSaver 直接吃。
|
||||
var sort by rememberSaveable { mutableStateOf(RecordsSort.RECENT) }
|
||||
|
||||
val sorted = remember(groups, sort) {
|
||||
when (sort) {
|
||||
RecordsSort.RECENT -> groups
|
||||
RecordsSort.SPREAD -> groups.sortedByDescending { it.maxPrice - it.minPrice }
|
||||
RecordsSort.MIN_PRICE -> groups.sortedBy { it.minPrice }
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"我的记录",
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp,
|
||||
color = DbbColors.Ink,
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = DbbColors.Card),
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = onAdd,
|
||||
containerColor = DbbColors.Teal,
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, "手动记一笔")
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.background(DbbColors.Bg),
|
||||
) {
|
||||
if (sorted.isEmpty() && query.isBlank() && (overall?.totalRecords ?: 0) == 0) {
|
||||
EmptyHint()
|
||||
} else {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(14.dp, 10.dp, 14.dp, 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
item {
|
||||
overall?.let { StatsBlock(it) }
|
||||
}
|
||||
if (overall?.topSpread?.isNotEmpty() == true) {
|
||||
item { SpreadRankCard(overall!!.topSpread) }
|
||||
}
|
||||
item {
|
||||
SearchAndFilter(
|
||||
query = query,
|
||||
onQuery = vm::setQuery,
|
||||
sort = sort,
|
||||
onSort = { sort = it },
|
||||
groupCount = sorted.size,
|
||||
)
|
||||
}
|
||||
if (sorted.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
Modifier.fillMaxWidth().padding(top = 32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"没找到匹配的商品",
|
||||
color = DbbColors.InkTer, fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(sorted, key = { it.clusterId }) { g ->
|
||||
ProductCard(g) { onOpenCluster(g.clusterId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyHint() {
|
||||
Box(
|
||||
Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"还没有记录。\n在购物 App 点蓝色浮窗、用截图记账,或点右下 + 手动记一笔。",
|
||||
color = DbbColors.InkTer, fontSize = 13.sp, textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsBlock(overall: OverallStats) {
|
||||
Column {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatBox(overall.totalProducts.toString(), "商品", Modifier.weight(1f), alt = false)
|
||||
StatBox(overall.totalRecords.toString(), "记录", Modifier.weight(1f), alt = false)
|
||||
StatBox(fmtYuan(overall.totalSaved), "已省¥", Modifier.weight(1f), alt = false)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatBox(overall.totalPlatforms.toString(), "覆盖平台", Modifier.weight(1f), alt = true)
|
||||
StatBox(overall.recordsToday.toString(), "今日记录", Modifier.weight(1f), alt = true)
|
||||
StatBox(fmtYuan(overall.avgSavePerProduct), "件均省¥", Modifier.weight(1f), alt = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatBox(value: String, label: String, modifier: Modifier, alt: Boolean) {
|
||||
// 两行统计盒都走 teal 家族,深浅区分,不再用 sky 增添第二个色相。
|
||||
val bg = if (alt) Color(0xFFECFAF9) else DbbColors.TealBg
|
||||
Box(
|
||||
modifier.clip(RoundedCornerShape(14.dp)).background(bg).padding(vertical = 11.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(value, fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, color = DbbColors.TealDark)
|
||||
Text(label, fontSize = 11.sp, color = DbbColors.InkSec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpreadRankCard(top: List<ClusterStats>) {
|
||||
val maxSpread = top.maxOfOrNull { it.priceSpread } ?: 1.0
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"💰 价差榜",
|
||||
color = DbbColors.Ink, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("同款跨平台最大差价", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
top.take(3).forEachIndexed { i, c ->
|
||||
if (i > 0) Spacer(Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
c.title,
|
||||
color = DbbColors.Ink, fontSize = 12.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.width(128.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
DbbProgressBar(
|
||||
fraction = (c.priceSpread / maxSpread).toFloat(),
|
||||
color = DbbColors.Teal,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"¥${fmtYuan(c.priceSpread)}",
|
||||
color = DbbColors.Save,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.width(48.dp),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchAndFilter(
|
||||
query: String,
|
||||
onQuery: (String) -> Unit,
|
||||
sort: RecordsSort,
|
||||
onSort: (RecordsSort) -> Unit,
|
||||
groupCount: Int,
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = query, onValueChange = onQuery,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("搜商品名", color = DbbColors.InkTer) },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, null, tint = DbbColors.InkTer) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RecordsSort.entries.forEach { s ->
|
||||
SortChip(s.label, selected = s == sort) { onSort(s) }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("共 $groupCount 个商品组", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SortChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = DbbColors.Teal,
|
||||
selectedLabelColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProductCard(g: ProductGroup, onClick: () -> Unit) {
|
||||
val spread = g.maxPrice - g.minPrice
|
||||
Card(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 11.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProductThumb(title = g.title, size = 38.dp)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
g.title,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = DbbColors.Ink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("近期", color = DbbColors.InkTer, fontSize = 10.sp)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
PlatformAvatar(g.minSourceApp, small = true)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("最低 ", color = DbbColors.InkSec, fontSize = 12.sp)
|
||||
Text(
|
||||
"¥${fmtYuan(g.minPrice)}",
|
||||
color = DbbColors.Teal,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
Text(" ${g.minSourceApp}", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (g.typicalPrice != null) {
|
||||
Text(
|
||||
"常见 ¥${fmtYuan(g.typicalPrice)}",
|
||||
color = DbbColors.InkTer,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
if (spread > 0) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
TagChip("差 ¥${fmtYuan(spread)}", bg = DbbColors.SaveBg, fg = DbbColors.Save)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
g.sources.take(6).forEach { p ->
|
||||
PlatformAvatar(p, small = true)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
"${g.recordCount} 条记录 · ${g.sources.size} 平台",
|
||||
color = DbbColors.InkTer, fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.jishisongfu.duobibi.ui.records
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
import com.jishisongfu.duobibi.util.OverallStats
|
||||
import com.jishisongfu.duobibi.util.StatsCalculator
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ProductGroup(
|
||||
val clusterId: Long,
|
||||
val title: String,
|
||||
val recordCount: Int,
|
||||
val minPrice: Double,
|
||||
val maxPrice: Double,
|
||||
val minSourceApp: String,
|
||||
val sources: List<String>,
|
||||
val typicalPrice: Double?,
|
||||
val latestAt: Long,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class RecordsViewModel @Inject constructor(
|
||||
private val repo: PriceRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _query = MutableStateFlow("")
|
||||
val query: StateFlow<String> = _query
|
||||
|
||||
val groups: StateFlow<List<ProductGroup>> = combine(
|
||||
repo.observeAllClusters(),
|
||||
repo.observeAllRecords(),
|
||||
_query,
|
||||
) { clusters, records, q ->
|
||||
val byCluster = records.groupBy { it.clusterId }
|
||||
clusters.mapNotNull { c ->
|
||||
val items = byCluster[c.id].orEmpty()
|
||||
if (items.isEmpty()) return@mapNotNull null
|
||||
if (q.isNotBlank() && !c.title.contains(q.trim(), ignoreCase = true)) return@mapNotNull null
|
||||
val minRec = items.minBy { it.price }
|
||||
ProductGroup(
|
||||
clusterId = c.id,
|
||||
title = c.title,
|
||||
recordCount = items.size,
|
||||
minPrice = minRec.price,
|
||||
maxPrice = items.maxOf { it.price },
|
||||
minSourceApp = minRec.sourceApp,
|
||||
sources = items.map { it.sourceApp }.distinct(),
|
||||
typicalPrice = c.typicalPrice,
|
||||
latestAt = items.maxOf { it.createdAt },
|
||||
)
|
||||
}.sortedByDescending { it.latestAt }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val overall: StateFlow<OverallStats?> = combine(
|
||||
repo.observeAllClusters(),
|
||||
repo.observeAllRecords(),
|
||||
) { clusters, records ->
|
||||
StatsCalculator.computeOverall(clusters, records)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
fun setQuery(q: String) { _query.value = q }
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.jishisongfu.duobibi.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* 多比比 蓝青科技风配色 token(明显区别于傻瓜比价的暖红 Coral)。
|
||||
* 主色青绿 Teal,辅以天蓝 Sky;价格语义:便宜=绿,贵/涨=红,参考=靛蓝;评分=金。
|
||||
*/
|
||||
object DbbColors {
|
||||
// 主色
|
||||
val Teal = Color(0xFF0EA5A4)
|
||||
val TealDark = Color(0xFF0F766E)
|
||||
val TealBg = Color(0xFFD7F4F2)
|
||||
val Sky = Color(0xFF0284C7)
|
||||
val SkyBg = Color(0xFFE0F2FE)
|
||||
|
||||
// 价格语义
|
||||
val Save = Color(0xFF059669) // 便宜 / 最低
|
||||
val SaveBg = Color(0xFFD1FAE5)
|
||||
val Up = Color(0xFFDC2626) // 贵 / 涨
|
||||
val UpBg = Color(0xFFFEE2E2)
|
||||
val Ref = Color(0xFF6366F1) // 市场常见价参考
|
||||
val RefBg = Color(0xFFEEF2FF)
|
||||
val Gold = Color(0xFFF59E0B) // 评分 / 榜单
|
||||
val GoldBg = Color(0xFFFEF3C7)
|
||||
|
||||
// 中性
|
||||
val Ink = Color(0xFF0F172A)
|
||||
val InkSec = Color(0xFF475569)
|
||||
val InkTer = Color(0xFF94A3B8)
|
||||
val Bg = Color(0xFFF8FAFC) // 冷调温白整页背景
|
||||
val BgWarm = Color(0xFFEEF6F6) // 顶部 hero 下方过渡用的微 teal 暖白
|
||||
val Card = Color(0xFFFFFFFF)
|
||||
val Fill = Color(0xFFF1F5F9) // 浅填充
|
||||
val Border = Color(0xFFE2E8F0)
|
||||
|
||||
// === v1.1 hero 视觉补色:每个 Tab 一套品牌渐变,给视觉层次 ===
|
||||
val Emerald = Color(0xFF10B981) // 决策绿(Worth Hero,搭 Save)
|
||||
val Amber = Color(0xFFF59E0B) // 警示橘(Alert Hero,搭 Up)
|
||||
val Orange = Color(0xFFEA580C)
|
||||
val Indigo = Color(0xFF6366F1) // 复用 Ref,显式命名给 hero 渐变用
|
||||
val Slate = Color(0xFF475569)
|
||||
val SlateDeep = Color(0xFF1E293B)
|
||||
val Rose = Color(0xFFE11D48) // 关注/收藏(❤️)
|
||||
val RoseDeep = Color(0xFFBE123C)
|
||||
val RoseBg = Color(0xFFFFE4E6)
|
||||
}
|
||||
|
||||
/**
|
||||
* 5 个 Tab 各一套品牌 hero 渐变 + 关键 CTA 的渐变。
|
||||
* 切 Tab 时视觉上会有"换了个世界"的辨识感,而不是"换张白卡"。
|
||||
* 端点为 (top, bottom) 用于 [Brush.linearGradient]。
|
||||
*/
|
||||
object DbbGradients {
|
||||
val Discover = listOf(DbbColors.Teal, DbbColors.TealDark) // 主品牌 → 深品牌
|
||||
val Arena = listOf(DbbColors.Teal, DbbColors.Sky) // 跨平台冷调
|
||||
val Alert = listOf(DbbColors.Amber, DbbColors.Orange) // 警示提醒
|
||||
val Worth = listOf(DbbColors.Emerald, DbbColors.Save) // 决策绿
|
||||
val Mine = listOf(DbbColors.Slate, DbbColors.SlateDeep) // 沉稳墨色
|
||||
val Watched = listOf(DbbColors.Rose, DbbColors.RoseDeep) // 关注/收藏
|
||||
/** 强调 CTA 用 — Teal→Indigo 偏冷,跟 Alert/Worth 的暖/绿 hero 区隔 */
|
||||
val CtaAccent = listOf(DbbColors.Teal, DbbColors.Indigo)
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.jishisongfu.duobibi.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* 关闭 Material You 动态取色,手写蓝青配色,所有设备观感统一。
|
||||
*/
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = DbbColors.Teal,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = DbbColors.TealBg,
|
||||
onPrimaryContainer = DbbColors.TealDark,
|
||||
|
||||
secondary = DbbColors.Sky,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = DbbColors.SkyBg,
|
||||
onSecondaryContainer = Color(0xFF075985),
|
||||
|
||||
tertiary = DbbColors.Save,
|
||||
onTertiary = Color.White,
|
||||
tertiaryContainer = DbbColors.SaveBg,
|
||||
onTertiaryContainer = Color(0xFF065F46),
|
||||
|
||||
error = DbbColors.Up,
|
||||
onError = Color.White,
|
||||
errorContainer = DbbColors.UpBg,
|
||||
onErrorContainer = Color(0xFF991B1B),
|
||||
|
||||
background = DbbColors.Bg,
|
||||
onBackground = DbbColors.Ink,
|
||||
surface = DbbColors.Card,
|
||||
onSurface = DbbColors.Ink,
|
||||
surfaceVariant = DbbColors.Fill,
|
||||
onSurfaceVariant = DbbColors.InkSec,
|
||||
outline = DbbColors.Border,
|
||||
outlineVariant = Color(0xFFEEF2F6),
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFF2DD4BF),
|
||||
onPrimary = Color(0xFF00332F),
|
||||
primaryContainer = Color(0xFF0F766E),
|
||||
onPrimaryContainer = Color(0xFFCCFBF1),
|
||||
|
||||
secondary = Color(0xFF38BDF8),
|
||||
onSecondary = Color(0xFF002235),
|
||||
secondaryContainer = Color(0xFF075985),
|
||||
onSecondaryContainer = Color(0xFFE0F2FE),
|
||||
|
||||
tertiary = Color(0xFF34D399),
|
||||
onTertiary = Color(0xFF064E3B),
|
||||
tertiaryContainer = Color(0xFF065F46),
|
||||
onTertiaryContainer = Color(0xFFD1FAE5),
|
||||
|
||||
error = Color(0xFFF87171),
|
||||
onError = Color(0xFF7F1D1D),
|
||||
errorContainer = Color(0xFF991B1B),
|
||||
onErrorContainer = Color(0xFFFEE2E2),
|
||||
|
||||
background = Color(0xFF0B1220),
|
||||
onBackground = Color(0xFFE2E8F0),
|
||||
surface = Color(0xFF111A2B),
|
||||
onSurface = Color(0xFFE2E8F0),
|
||||
surfaceVariant = Color(0xFF1E293B),
|
||||
onSurfaceVariant = Color(0xFF94A3B8),
|
||||
outline = Color(0xFF334155),
|
||||
outlineVariant = Color(0xFF1E293B),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DuobibiTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(colorScheme = if (darkTheme) DarkColors else LightColors, content = content)
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
package com.jishisongfu.duobibi.ui.tools
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.jishisongfu.duobibi.ui.DbbProgressBar
|
||||
import com.jishisongfu.duobibi.ui.TagChip
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
|
||||
private data class Tier(val threshold: Double, val reduction: Double)
|
||||
|
||||
private val TIERS = listOf(
|
||||
Tier(300.0, 50.0), Tier(600.0, 100.0), Tier(1000.0, 200.0), Tier(1500.0, 300.0),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BundleCalculatorScreen(onBack: () -> Unit) {
|
||||
var cartText by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
topBar = { CalcBar("凑单计算器", onBack) },
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.background(DbbColors.Bg)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(14.dp),
|
||||
) {
|
||||
// 顶部说明(teal 浅底,去 sky)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(DbbColors.TealBg)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"阶梯满减:满 300−50 / 满 600−100 / 满 1000−200 / 满 1500−300。看看再凑一点是不是反而更省。",
|
||||
color = DbbColors.TealDark, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 输入框
|
||||
OutlinedTextField(
|
||||
value = cartText,
|
||||
onValueChange = { cartText = it.numeric() },
|
||||
label = { Text("当前购物车金额(元)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
val cart = cartText.toDoubleOrNull()
|
||||
if (cart != null && cart > 0) {
|
||||
val hit = TIERS.filter { cart >= it.threshold }.maxByOrNull { it.threshold }
|
||||
val next = TIERS.firstOrNull { it.threshold > cart }
|
||||
val currentFinal = if (hit != null) cart - hit.reduction else cart
|
||||
|
||||
// 当前实付卡(绿色)
|
||||
CurrentPayCard(currentFinal, hit)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 进度 + 阶梯
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
if (next != null) {
|
||||
ProgressBlock(cart, next)
|
||||
Spacer(Modifier.height(13.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(DbbColors.Border))
|
||||
Spacer(Modifier.height(13.dp))
|
||||
}
|
||||
Text("满减阶梯", fontWeight = FontWeight.Bold, color = DbbColors.Ink, fontSize = 13.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TIERS.forEach { t ->
|
||||
TierRow(
|
||||
t = t,
|
||||
cart = cart,
|
||||
isCurrent = hit?.threshold == t.threshold,
|
||||
isNext = next?.threshold == t.threshold,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 凑单建议(teal 浅底带深 teal 边,去 gold)
|
||||
if (next != null) {
|
||||
val gap = next.threshold - cart
|
||||
val nextFinal = (cart + gap) - next.reduction
|
||||
val diff = currentFinal - nextFinal
|
||||
val msg = if (diff > 0)
|
||||
"💡 再凑 ¥${fmtYuan(gap)} 到「满 ${fmtYuan(next.threshold)} 减 ${fmtYuan(next.reduction)}」,实付反而少花 ¥${fmtYuan(diff)}!"
|
||||
else
|
||||
"再凑 ¥${fmtYuan(gap)} 到下一档,实付会多花 ¥${fmtYuan(-diff)},不划算。"
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(if (diff > 0) DbbColors.TealBg else DbbColors.Fill)
|
||||
.border(
|
||||
width = if (diff > 0) 1.dp else 0.dp,
|
||||
color = if (diff > 0) DbbColors.Teal else Color.Transparent,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Text(
|
||||
msg,
|
||||
color = if (diff > 0) DbbColors.TealDark else DbbColors.InkSec,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CurrentPayCard(currentFinal: Double, hit: Tier?) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(DbbColors.SaveBg)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("当前实付", color = DbbColors.InkSec, fontSize = 12.sp)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
"¥${fmtYuan(currentFinal)}",
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 24.sp,
|
||||
color = DbbColors.Save,
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("满减优惠", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
val text = if (hit != null)
|
||||
"已减 ¥${fmtYuan(hit.reduction)}"
|
||||
else
|
||||
"未达门槛"
|
||||
val color = if (hit != null) DbbColors.Save else DbbColors.InkSec
|
||||
Text(text, color = color, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressBlock(cart: Double, next: Tier) {
|
||||
val gap = next.threshold - cart
|
||||
val frac = (cart / next.threshold).toFloat().coerceIn(0f, 1f)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"距「满 ${fmtYuan(next.threshold)} 减 ${fmtYuan(next.reduction)}」",
|
||||
color = DbbColors.Ink, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"还差 ¥${fmtYuan(gap)}",
|
||||
color = DbbColors.Up, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
DbbProgressBar(fraction = frac, color = DbbColors.Teal)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("¥0", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
"¥${fmtYuan(cart)} / ¥${fmtYuan(next.threshold)}",
|
||||
color = DbbColors.Teal, fontWeight = FontWeight.SemiBold, fontSize = 11.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("¥${fmtYuan(next.threshold)}", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TierRow(t: Tier, cart: Double, isCurrent: Boolean, isNext: Boolean) {
|
||||
// 本档实付:若 cart 已 ≥ 门槛,实付=cart-reduction;否则按刚好凑齐计算
|
||||
val realPay = if (cart >= t.threshold) cart - t.reduction else t.threshold - t.reduction
|
||||
val bg = if (isCurrent) DbbColors.TealBg else Color.Transparent
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bg)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"满 ${fmtYuan(t.threshold)} 减 ${fmtYuan(t.reduction)}",
|
||||
color = if (isCurrent) DbbColors.TealDark else DbbColors.InkSec,
|
||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"实付 ¥${fmtYuan(realPay)}",
|
||||
color = if (isCurrent) DbbColors.TealDark else DbbColors.InkTer,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
if (isCurrent) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TagChip("当前档", bg = DbbColors.Teal, fg = Color.White)
|
||||
} else if (isNext) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TagChip("下一档", bg = DbbColors.Teal, fg = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package com.jishisongfu.duobibi.ui.tools
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import kotlin.math.pow
|
||||
|
||||
private val TERMS = listOf(3, 6, 12, 18, 24)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun InstallmentCalculatorScreen(onBack: () -> Unit) {
|
||||
var principalText by rememberSaveable { mutableStateOf("6000") }
|
||||
var term by rememberSaveable { mutableStateOf(12) }
|
||||
var rateText by rememberSaveable { mutableStateOf("0.66") }
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
topBar = { CalcBar("分期真实利率", onBack) },
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.background(DbbColors.Bg)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(14.dp),
|
||||
) {
|
||||
// 顶部说明(teal 浅底,去 sky)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(DbbColors.TealBg)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"「免息分期」常带手续费。销售说的「年化」多按 月费率×12 算,远低于真实成本。本工具用 IRR 还原真实年化。",
|
||||
color = DbbColors.TealDark, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 输入行:分期金额 + 手续费率(并排)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(
|
||||
value = principalText,
|
||||
onValueChange = { principalText = it.numeric() },
|
||||
label = { Text("分期金额(元)", fontSize = 11.sp) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.width(150.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = rateText,
|
||||
onValueChange = { rateText = it.numeric() },
|
||||
label = { Text("每期手续费率(%)", fontSize = 11.sp) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text("分期数", color = DbbColors.InkSec, fontSize = 12.sp)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
TERMS.forEach { t ->
|
||||
FilterChip(
|
||||
selected = term == t,
|
||||
onClick = { term = t },
|
||||
label = { Text("${t}期", fontSize = 13.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = DbbColors.Teal,
|
||||
selectedLabelColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
val principal = principalText.toDoubleOrNull()
|
||||
val monthlyRate = rateText.toDoubleOrNull()?.div(100.0)
|
||||
if (principal != null && principal > 0 && monthlyRate != null && monthlyRate > 0) {
|
||||
val perFee = principal * monthlyRate
|
||||
val installment = principal / term + perFee
|
||||
val total = installment * term
|
||||
val extra = total - principal
|
||||
val nominal = monthlyRate * 12
|
||||
val real = solveMonthlyIrr(principal, installment, term) * 12
|
||||
val ratio = if (nominal > 0) real / nominal else 0.0
|
||||
|
||||
// 还款明细卡
|
||||
BreakdownCard(
|
||||
installment = installment,
|
||||
term = term,
|
||||
principalPerTerm = principal / term,
|
||||
feePerTerm = perFee,
|
||||
total = total,
|
||||
principal = principal,
|
||||
extra = extra,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 名义 vs 真实 年化
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
RateBox(
|
||||
title = "名义年化",
|
||||
value = "%.2f%%".format(nominal * 100),
|
||||
sub = "月费率 ×12",
|
||||
bg = DbbColors.Fill,
|
||||
fg = DbbColors.InkSec,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
RateBox(
|
||||
title = "真实年化(IRR)",
|
||||
value = "%.2f%%".format(real * 100),
|
||||
sub = "≈ 名义的 ${"%.2f".format(ratio)} 倍",
|
||||
bg = DbbColors.UpBg,
|
||||
fg = DbbColors.Up,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// 警示卡:语义上是"提醒真实成本",用 Up 红更准确(也回收 Gold 第四色)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = DbbColors.UpBg,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"⚠️ 本金逐月减少,手续费却按全额每期照收,真实资金成本接近一般信用贷。分期前请按真实年化评估是否划算。",
|
||||
color = DbbColors.Up, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BreakdownCard(
|
||||
installment: Double,
|
||||
term: Int,
|
||||
principalPerTerm: Double,
|
||||
feePerTerm: Double,
|
||||
total: Double,
|
||||
principal: Double,
|
||||
extra: Double,
|
||||
) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("每期还款", color = DbbColors.InkSec, fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
"¥${fmtYuan(installment)} ",
|
||||
color = DbbColors.Ink, fontWeight = FontWeight.Bold, fontSize = 15.sp,
|
||||
)
|
||||
Text("× $term 期", color = DbbColors.InkTer, fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row {
|
||||
Text(
|
||||
"本金 ¥${fmtYuan(principalPerTerm)} / 期",
|
||||
color = DbbColors.InkTer, fontSize = 12.sp, modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"手续费 ¥${fmtYuan(feePerTerm)} / 期",
|
||||
color = DbbColors.InkTer, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(11.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(DbbColors.Border))
|
||||
Spacer(Modifier.height(11.dp))
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text("分期总额", color = DbbColors.InkSec, fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||
Text("¥${fmtYuan(total)}", color = DbbColors.Ink, fontWeight = FontWeight.ExtraBold, fontSize = 18.sp)
|
||||
}
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Row {
|
||||
Text(
|
||||
"较全款 ¥${fmtYuan(principal)}",
|
||||
color = DbbColors.InkTer, fontSize = 12.sp, modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"多付 ¥${fmtYuan(extra)}",
|
||||
color = DbbColors.Up, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RateBox(
|
||||
title: String,
|
||||
value: String,
|
||||
sub: String,
|
||||
bg: Color,
|
||||
fg: Color,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(bg)
|
||||
.padding(13.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(title, color = fg, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(value, color = fg, fontWeight = FontWeight.ExtraBold, fontSize = 20.sp)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(sub, color = fg.copy(alpha = 0.8f), fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 二分法求月 IRR:PV(还款)=principal。sum 关于 r 单调递减,60 次收敛。 */
|
||||
private fun solveMonthlyIrr(principal: Double, installment: Double, terms: Int): Double {
|
||||
var lo = 0.0; var hi = 1.0
|
||||
repeat(60) {
|
||||
val mid = (lo + hi) / 2
|
||||
val sum = (1..terms).sumOf { installment / (1 + mid).pow(it.toDouble()) }
|
||||
if (sum > principal) lo = mid else hi = mid
|
||||
}
|
||||
return (lo + hi) / 2
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package com.jishisongfu.duobibi.ui.tools
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import kotlin.math.abs
|
||||
|
||||
private enum class PriceUnit(val label: String, val factor: Double, val piece: Boolean, val base: String) {
|
||||
G("克", 1.0, false, "100g"), KG("千克", 1000.0, false, "100g"),
|
||||
ML("毫升", 1.0, false, "100ml"), L("升", 1000.0, false, "100ml"),
|
||||
PIECE("件", 1.0, true, "件"),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UnitPriceCalculatorScreen(onBack: () -> Unit) {
|
||||
// PriceUnit 是 enum,Java Enum 默认 Serializable。
|
||||
var unit by rememberSaveable { mutableStateOf(PriceUnit.G) }
|
||||
var pa by rememberSaveable { mutableStateOf("") }
|
||||
var qa by rememberSaveable { mutableStateOf("") }
|
||||
var pb by rememberSaveable { mutableStateOf("") }
|
||||
var qb by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Scaffold(topBar = { CalcBar("单价对比", onBack) }) { padding ->
|
||||
Column(
|
||||
Modifier.padding(padding).fillMaxSize().background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("选规格单位", color = DbbColors.InkSec, fontSize = 12.sp)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
PriceUnit.entries.forEach { u ->
|
||||
FilterChip(selected = unit == u, onClick = { unit = u }, label = { Text(u.label) },
|
||||
colors = FilterChipDefaults.filterChipColors(selectedContainerColor = DbbColors.Teal, selectedLabelColor = Color.White))
|
||||
}
|
||||
}
|
||||
ItemInput("商品 A", pa, qa, unit.label, { pa = it.numeric() }, { qa = it.numeric() })
|
||||
ItemInput("商品 B", pb, qb, unit.label, { pb = it.numeric() }, { qb = it.numeric() })
|
||||
|
||||
val upa = unitPrice(pa.toDoubleOrNull(), qa.toDoubleOrNull(), unit)
|
||||
val upb = unitPrice(pb.toDoubleOrNull(), qb.toDoubleOrNull(), unit)
|
||||
if (upa != null && upb != null) {
|
||||
val cheaperA = upa < upb
|
||||
val saved = abs(upa - upb)
|
||||
val pct = (saved / maxOf(upa, upb) * 100).toInt()
|
||||
Card(
|
||||
Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = DbbColors.SaveBg),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("每${unit.base}单价", color = DbbColors.InkSec, fontSize = 12.sp)
|
||||
Spacer(Modifier.padding(2.dp))
|
||||
Text("A ¥${fmtYuan(upa)} B ¥${fmtYuan(upb)}", fontWeight = FontWeight.Bold, color = DbbColors.Ink)
|
||||
Spacer(Modifier.padding(4.dp))
|
||||
Text("👉 ${if (cheaperA) "商品 A" else "商品 B"} 更划算,每${unit.base}便宜 ¥${fmtYuan(saved)}(约 $pct%)",
|
||||
color = DbbColors.Save, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unitPrice(price: Double?, qty: Double?, u: PriceUnit): Double? {
|
||||
if (price == null || qty == null || price <= 0 || qty <= 0) return null
|
||||
val baseQty = qty * u.factor
|
||||
return if (u.piece) price / baseQty else price / baseQty * 100
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemInput(
|
||||
name: String,
|
||||
price: String,
|
||||
qty: String,
|
||||
unitLabel: String,
|
||||
onP: (String) -> Unit,
|
||||
onQ: (String) -> Unit,
|
||||
) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Text(name, fontWeight = FontWeight.Bold, color = DbbColors.Ink)
|
||||
Spacer(Modifier.padding(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = price, onValueChange = onP, label = { Text("价格(元)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(value = qty, onValueChange = onQ, label = { Text("规格($unitLabel)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun CalcBar(title: String, onBack: () -> Unit) {
|
||||
TopAppBar(
|
||||
title = { Text(title, fontWeight = FontWeight.Bold, color = DbbColors.Ink) },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回", tint = DbbColors.Ink) } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = DbbColors.Card),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun String.numeric(): String = filter { it.isDigit() || it == '.' }
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.jishisongfu.duobibi.ui.watched
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.ui.discover.DISCOVER_SEED
|
||||
import com.jishisongfu.duobibi.ui.discover.PriceCompareCard
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbGradients
|
||||
import com.jishisongfu.duobibi.util.WatchedPrefs
|
||||
|
||||
/**
|
||||
* 关注的商品列表 —— 从发现页 hero ❤️ 入口进入。
|
||||
*
|
||||
* 跟"降价雷达"语义区分:
|
||||
* - **关注**:用户主动点 ❤️ 收藏的商品(只看价格走势,不要求设心理价 / 触发提醒)
|
||||
* - **雷达**:主动设了心理价的监控(降到价就触发 push 提醒)
|
||||
* 关注更轻、零设置;雷达更重、有目标价。
|
||||
*
|
||||
* 数据源是 [WatchedPrefs.watchedFlow] 反应式订阅 SharedPreferences 里的 id Set,
|
||||
* 再查 [DISCOVER_SEED] 还原 DiscoverItem。后续上自定义关注(非种子内)再迁 Room。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WatchedProductsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenDetail: (itemId: String) -> Unit,
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val watchedFlow = remember { WatchedPrefs.watchedFlow(ctx) }
|
||||
val watchedIds by watchedFlow.collectAsStateWithLifecycle(initialValue = WatchedPrefs.watchedIds(ctx))
|
||||
val items = remember(watchedIds) {
|
||||
// 保留原 DISCOVER_SEED 顺序,只筛 watchedIds 里的
|
||||
DISCOVER_SEED.filter { it.id in watchedIds }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.padding(padding).fillMaxSize().background(DbbColors.Bg),
|
||||
) {
|
||||
WatchedHero(count = items.size, onBack = onBack)
|
||||
if (items.isEmpty()) {
|
||||
EmptyHint()
|
||||
} else {
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(10.dp, 10.dp, 10.dp, 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalItemSpacing = 8.dp,
|
||||
) {
|
||||
items(items, key = { it.id }) { item ->
|
||||
PriceCompareCard(
|
||||
item = item,
|
||||
isWatched = true,
|
||||
onClick = { onOpenDetail(item.id) },
|
||||
onToggleWatch = { WatchedPrefs.toggle(ctx, item.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchedHero(count: Int, onBack: () -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Brush.verticalGradient(DbbGradients.Watched))
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(40.dp)) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回", tint = Color.White)
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.White.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Favorite, contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"我的关注",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
Text(
|
||||
if (count > 0) "$count 件商品在关注中" else "还没关注任何商品",
|
||||
color = Color.White.copy(alpha = 0.88f),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyHint() {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(76.dp)
|
||||
.clip(CircleShape)
|
||||
.background(DbbColors.RoseBg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Favorite,
|
||||
contentDescription = null,
|
||||
tint = DbbColors.Rose,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text("还没关注任何商品", color = DbbColors.Ink, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"去「发现」逛逛,点商品卡右上角的 ❤️ 把它收藏到这里",
|
||||
color = DbbColors.InkTer,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.jishisongfu.duobibi.ui.worth
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* "值不值得买"页用的前端 mock —— 60 天价格走势 + 历史统计 + 大促倒计时。
|
||||
*
|
||||
* 全部确定性算法,不调任何后端。同 (title, typical, current) 永远同曲线/同结果,
|
||||
* 占审重放稳定;数据是"看起来合理"的拟真,不是真实历史价。
|
||||
*/
|
||||
|
||||
/** 60 天历史价 + 当前价快照,用于历史统计 / 水位分位 / 走势图渲染。 */
|
||||
internal data class HistoryStats(
|
||||
val min: Double,
|
||||
val max: Double,
|
||||
val avg: Double,
|
||||
val current: Double,
|
||||
/** 当前价在 [min, max] 区间的分位 0..100;0 = 在最低,100 = 在最高 */
|
||||
val percentile: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* 生成 60 天价格序列(index 0 = 60 天前,index 59 = 今天)。
|
||||
*
|
||||
* 算法:
|
||||
* - base = typical * (1 + 0.07 * sin(2π i/30)) 30 天波浪
|
||||
* - 周末额外 -2%(模拟"周末活动")
|
||||
* - 每月 17/18/19 号 -10%(模拟 618/双11/月度大促节奏)
|
||||
* - +/- 5% 哈希微抖(让曲线"毛糙",不至于一眼像数学函数)
|
||||
* - 最后一天(today)强制用 [currentPrice],让曲线最右端贴合用户实际看到的价
|
||||
*/
|
||||
internal fun generate60dPrices(title: String, typical: Double, currentPrice: Double): List<Double> {
|
||||
val seed = title.hashCode()
|
||||
val raw = (0 until 60).map { i ->
|
||||
val wave = sin(2 * PI * i / 30.0) * 0.07
|
||||
val weekend = if (i % 7 in setOf(5, 6)) -0.02 else 0.0
|
||||
val promo = if (i % 30 in setOf(17, 18, 19)) -0.10 else 0.0
|
||||
val jitterByte = ((seed xor (i * 131)) and 0x7F).toDouble() / 127.0 // 0..1
|
||||
val jitter = (jitterByte - 0.5) * 0.06 // ±3%
|
||||
(typical * (1.0 + wave + weekend + promo + jitter)).coerceAtLeast(typical * 0.6)
|
||||
}.toMutableList()
|
||||
raw[raw.lastIndex] = currentPrice
|
||||
return raw
|
||||
}
|
||||
|
||||
internal fun computeHistoryStats(prices: List<Double>, currentPrice: Double): HistoryStats {
|
||||
val min = prices.min()
|
||||
val max = prices.max()
|
||||
val avg = prices.average()
|
||||
val percentile = if (max - min < 0.01) 50
|
||||
else (((currentPrice - min) / (max - min)) * 100).toInt().coerceIn(0, 100)
|
||||
return HistoryStats(min, max, avg, currentPrice, percentile)
|
||||
}
|
||||
|
||||
// ============== 大促日历 ==============
|
||||
|
||||
internal data class Promo(val name: String, val month: Int, val day: Int)
|
||||
|
||||
/** 6 个全年标志性大促节点。日期与电商行业惯例对齐。 */
|
||||
internal val PROMO_CALENDAR: List<Promo> = listOf(
|
||||
Promo("年货节", 1, 15),
|
||||
Promo("女王节", 3, 8),
|
||||
Promo("618 大促", 6, 18),
|
||||
Promo("88 会员节", 8, 8),
|
||||
Promo("双 11", 11, 11),
|
||||
Promo("双 12", 12, 12),
|
||||
)
|
||||
|
||||
internal data class NextPromo(val name: String, val daysAway: Int)
|
||||
|
||||
/** 距今天最近的未来大促节点(跨年的话排到明年同日)。 */
|
||||
internal fun nextPromo(today: LocalDate = LocalDate.now()): NextPromo {
|
||||
val candidates = PROMO_CALENDAR.flatMap { p ->
|
||||
listOf(today.year, today.year + 1).map { y -> p.name to LocalDate.of(y, p.month, p.day) }
|
||||
}
|
||||
val pick = candidates
|
||||
.filter { (_, d) -> !d.isBefore(today) }
|
||||
.minBy { (_, d) -> d }
|
||||
val days = ChronoUnit.DAYS.between(today, pick.second).toInt()
|
||||
return NextPromo(pick.first, days)
|
||||
}
|
||||
+669
@@ -0,0 +1,669 @@
|
||||
package com.jishisongfu.duobibi.ui.worth
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Balance
|
||||
import androidx.compose.material.icons.filled.Lightbulb
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.jishisongfu.duobibi.data.api.WorthBuyResponse
|
||||
import com.jishisongfu.duobibi.ui.HeroBanner
|
||||
import com.jishisongfu.duobibi.ui.PriceGauge
|
||||
import com.jishisongfu.duobibi.ui.ProductThumb
|
||||
import com.jishisongfu.duobibi.ui.TagChip
|
||||
import com.jishisongfu.duobibi.ui.fmtYuan
|
||||
import com.jishisongfu.duobibi.ui.rememberHaptics
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbColors
|
||||
import com.jishisongfu.duobibi.ui.theme.DbbGradients
|
||||
|
||||
/**
|
||||
* 值不值得买 Tab。结构(自上而下):
|
||||
* 1) AI 评估区:表单 + AI 评估按钮
|
||||
* 2) Success 状态显示:评分卡 / 判断依据 / 更划算时机
|
||||
* 3) Success 状态额外补:**60 天走势图 / 历史统计 / 大促倒计时 / 行动 CTA**
|
||||
* ↑ 这一段对标"什么值得买"/"慢慢买"决策页的核心数据可视化 + 行动闭环
|
||||
*
|
||||
* 父容器用 LazyColumn 而不是 Column+verticalScroll,既方便条件 item 渲染,
|
||||
* 也为日后再挂长列表(如同款好物推荐)留余地。Success-only 卡片用 `if/item {}`。
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WorthBuyScreen(
|
||||
onOpenCluster: (Long) -> Unit,
|
||||
onOpenArena: (String) -> Unit,
|
||||
vm: WorthBuyViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ctx = LocalContext.current
|
||||
val haptics = rememberHaptics()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val title by vm.title.collectAsStateWithLifecycle()
|
||||
val price by vm.price.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
containerColor = DbbColors.Bg,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.fillMaxSize()
|
||||
.background(DbbColors.Bg),
|
||||
) {
|
||||
HeroBanner(
|
||||
title = "值不值得买",
|
||||
subtitle = "AI 评估 · 60 天走势 · 大促倒计时",
|
||||
icon = Icons.Filled.Lightbulb,
|
||||
gradient = DbbGradients.Worth,
|
||||
)
|
||||
GuessYouAskRow(onPick = { t, p -> vm.setTitle(t); vm.setPrice(p) })
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(DbbColors.Bg),
|
||||
contentPadding = PaddingValues(14.dp, 10.dp, 14.dp, 88.dp),
|
||||
) {
|
||||
// ========== 1) AI 评估区 ==========
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = vm::setTitle,
|
||||
label = { Text("商品名") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = price,
|
||||
onValueChange = vm::setPrice,
|
||||
label = { Text("当前到手价(元)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = { haptics.confirm(); vm.submit() },
|
||||
enabled = state !is WorthUiState.Loading && title.isNotBlank() && price.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) {
|
||||
Icon(Icons.Filled.Lightbulb, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("AI 评估", fontWeight = FontWeight.SemiBold, fontSize = 16.sp)
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
}
|
||||
|
||||
// ========== 2) 评估状态分支 ==========
|
||||
item {
|
||||
when (val s = state) {
|
||||
is WorthUiState.Idle -> IdleHint()
|
||||
is WorthUiState.Loading -> Box(
|
||||
Modifier.fillMaxWidth().padding(top = 20.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(color = DbbColors.Teal, strokeWidth = 3.dp)
|
||||
}
|
||||
is WorthUiState.Error -> Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.UpBg),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) { Text(s.message, Modifier.padding(16.dp), color = DbbColors.Up) }
|
||||
is WorthUiState.Success -> ResultBlock(
|
||||
r = s.result,
|
||||
yourPrice = price.toDoubleOrNull() ?: s.result.typicalPrice,
|
||||
title = title,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 3) Success-only 决策数据可视化 + 行动 CTA ==========
|
||||
val s = state
|
||||
if (s is WorthUiState.Success) {
|
||||
val yourPrice = price.toDoubleOrNull() ?: s.result.typicalPrice
|
||||
val effectiveTitle = title.ifBlank { "选中商品" }
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PriceTrendCard(effectiveTitle, yourPrice, s.result.typicalPrice)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
item {
|
||||
HistoryStatsCard(effectiveTitle, yourPrice, s.result.typicalPrice)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
item {
|
||||
NextPromoCard(verdict = s.result.verdict)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
item {
|
||||
ActionCtaRow(
|
||||
onAddAlert = {
|
||||
haptics.confirm()
|
||||
vm.addToAlert(effectiveTitle, yourPrice) {
|
||||
Toast.makeText(
|
||||
ctx,
|
||||
"已加入降价雷达 · 心理价 ¥${fmtYuan(yourPrice)}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
onOpenArena = { haptics.confirm(); onOpenArena(effectiveTitle) },
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Success-only 决策模块
|
||||
// ====================================================================
|
||||
|
||||
/** 60 天价格走势 — 招牌可视化(参考慢慢买/SMZDM 决策页)。 */
|
||||
@Composable
|
||||
private fun PriceTrendCard(title: String, yourPrice: Double, typical: Double) {
|
||||
val prices = remember(title, typical, yourPrice) { generate60dPrices(title, typical, yourPrice) }
|
||||
val minP = prices.min()
|
||||
val maxP = prices.max()
|
||||
val minIdx = prices.indexOf(minP)
|
||||
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("📉", fontSize = 16.sp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
"60 天价格走势",
|
||||
fontWeight = FontWeight.Bold, color = DbbColors.Ink, fontSize = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
"60d 低 ¥${fmtYuan(minP)}",
|
||||
color = DbbColors.Up, fontSize = 11.sp, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
TrendCanvas(prices = prices, minIdx = minIdx)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text("60 天前", color = DbbColors.InkTer, fontSize = 10.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("今天", color = DbbColors.InkTer, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrendCanvas(prices: List<Double>, minIdx: Int) {
|
||||
val minP = prices.min()
|
||||
val maxP = prices.max()
|
||||
val spanP = (maxP - minP).coerceAtLeast(0.01)
|
||||
|
||||
val lineColor = DbbColors.Teal
|
||||
val areaColor = DbbColors.TealBg
|
||||
val gridColor = DbbColors.Border
|
||||
val minPointColor = DbbColors.Up
|
||||
val nowPointColor = DbbColors.Teal
|
||||
|
||||
Box(Modifier.fillMaxWidth().height(124.dp)) {
|
||||
Canvas(Modifier.fillMaxWidth().height(124.dp)) {
|
||||
val padL = 4.dp.toPx()
|
||||
val padR = 4.dp.toPx()
|
||||
val padT = 8.dp.toPx()
|
||||
val padB = 8.dp.toPx()
|
||||
val w = size.width - padL - padR
|
||||
val h = size.height - padT - padB
|
||||
|
||||
// 三条横向虚线(top / mid / bottom)
|
||||
for (i in 0..2) {
|
||||
val y = padT + h * (i / 2f)
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(padL, y),
|
||||
end = Offset(padL + w, y),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
}
|
||||
|
||||
val points = prices.mapIndexed { idx, p ->
|
||||
val x = padL + (idx.toFloat() / (prices.lastIndex.toFloat())) * w
|
||||
val y = padT + h - (((p - minP) / spanP).toFloat() * h)
|
||||
Offset(x, y)
|
||||
}
|
||||
|
||||
// 填充面积(line 下方淡色)
|
||||
val area = Path().apply {
|
||||
moveTo(points.first().x, padT + h)
|
||||
points.forEach { lineTo(it.x, it.y) }
|
||||
lineTo(points.last().x, padT + h)
|
||||
close()
|
||||
}
|
||||
drawPath(area, areaColor)
|
||||
|
||||
// 折线
|
||||
val path = Path().apply {
|
||||
points.forEachIndexed { idx, p ->
|
||||
if (idx == 0) moveTo(p.x, p.y) else lineTo(p.x, p.y)
|
||||
}
|
||||
}
|
||||
drawPath(path, lineColor, style = Stroke(width = 3.5f))
|
||||
|
||||
// 60 天最低点(红圆 + 白心)
|
||||
val minPt = points[minIdx]
|
||||
drawCircle(minPointColor, 7f, minPt)
|
||||
drawCircle(Color.White, 3f, minPt)
|
||||
|
||||
// 今天点(teal 圆环 + 白心)
|
||||
val nowPt = points.last()
|
||||
drawCircle(nowPointColor, 8f, nowPt)
|
||||
drawCircle(Color.White, 3.5f, nowPt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 历史价格统计三栏:60 天最低 / 60 天均价 / 当前水位分位。 */
|
||||
@Composable
|
||||
private fun HistoryStatsCard(title: String, yourPrice: Double, typical: Double) {
|
||||
val prices = remember(title, typical, yourPrice) { generate60dPrices(title, typical, yourPrice) }
|
||||
val stats = remember(prices, yourPrice) { computeHistoryStats(prices, yourPrice) }
|
||||
|
||||
val percentileColor = when {
|
||||
stats.percentile < 25 -> DbbColors.Save
|
||||
stats.percentile > 75 -> DbbColors.Up
|
||||
else -> DbbColors.Teal
|
||||
}
|
||||
val percentileBg = when {
|
||||
stats.percentile < 25 -> DbbColors.SaveBg
|
||||
stats.percentile > 75 -> DbbColors.UpBg
|
||||
else -> DbbColors.TealBg
|
||||
}
|
||||
val percentileLabel = when {
|
||||
stats.percentile < 25 -> "低位可入"
|
||||
stats.percentile > 75 -> "高位可等"
|
||||
else -> "中段水位"
|
||||
}
|
||||
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
StatBox("60 天最低", "¥${fmtYuan(stats.min)}",
|
||||
bg = DbbColors.SaveBg, fg = DbbColors.Save, modifier = Modifier.weight(1f))
|
||||
StatBox("60 天均价", "¥${fmtYuan(stats.avg)}",
|
||||
bg = DbbColors.Fill, fg = DbbColors.InkSec, modifier = Modifier.weight(1f))
|
||||
StatBox("当前水位", "${stats.percentile}%",
|
||||
bg = percentileBg, fg = percentileColor, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"当前价 ¥${fmtYuan(yourPrice)} 处于 60 天区间的 ${stats.percentile}% 分位 — $percentileLabel",
|
||||
color = percentileColor, fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatBox(label: String, value: String, bg: Color, fg: Color, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bg)
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
value,
|
||||
fontWeight = FontWeight.ExtraBold, fontSize = 17.sp, color = fg,
|
||||
letterSpacing = (-0.4).sp,
|
||||
)
|
||||
Text(label, fontSize = 10.sp, color = fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 距下次大促节点的倒计时,辅助"再等 vs 现在买"决策。 */
|
||||
@Composable
|
||||
private fun NextPromoCard(verdict: String) {
|
||||
val promo = remember { nextPromo() }
|
||||
// 临近大促 + AI 建议再等 → 强化"再等" 文案;否则中性
|
||||
val accent = if (promo.daysAway <= 14 && verdict == "wait") DbbColors.Save else DbbColors.Teal
|
||||
val bg = if (promo.daysAway <= 14 && verdict == "wait") DbbColors.SaveBg else DbbColors.TealBg
|
||||
val tail = when {
|
||||
promo.daysAway == 0 -> "今天就是大促日 · 这价位再等没意义"
|
||||
promo.daysAway <= 14 -> "建议再蹲 ${promo.daysAway} 天看大促价"
|
||||
promo.daysAway <= 30 -> "中短期内有节点,可关注"
|
||||
else -> "到下次大促还有阵子,合适就入"
|
||||
}
|
||||
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = bg),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("⏰", fontSize = 18.sp)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"距下次大促:${promo.name}",
|
||||
color = accent, fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
)
|
||||
Text(tail, color = DbbColors.InkSec, fontSize = 11.sp)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(accent)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
"${promo.daysAway} 天",
|
||||
color = Color.White, fontWeight = FontWeight.ExtraBold, fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 行动 CTA 行 — 决策完串到雷达/擂台,形成闭环。 */
|
||||
@Composable
|
||||
private fun ActionCtaRow(onAddAlert: () -> Unit, onOpenArena: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = onAddAlert,
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Teal),
|
||||
) {
|
||||
Icon(Icons.Filled.NotificationsActive, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("加入降价雷达", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
}
|
||||
Button(
|
||||
onClick = onOpenArena,
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = DbbColors.Sky),
|
||||
) {
|
||||
Icon(Icons.Filled.Balance, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("跨平台对比", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 原有内容(保留)
|
||||
// ====================================================================
|
||||
|
||||
@Composable
|
||||
private fun IdleHint() {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.TealBg),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text("💡 别只看价格,看值不值", fontWeight = FontWeight.Bold, color = DbbColors.TealDark)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"输入商品和当前到手价,AI 会给一个 0-100 的「值得买指数」并说明理由;" +
|
||||
"下方会同时显示 60 天价格走势、当前价水位分位、下次大促倒计时,帮你判断「现在买还是再等」。",
|
||||
color = DbbColors.InkSec, fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultBlock(r: WorthBuyResponse, yourPrice: Double, title: String) {
|
||||
val verdictColor = when (r.verdict) {
|
||||
"buy" -> DbbColors.Save
|
||||
"wait" -> DbbColors.Up
|
||||
else -> DbbColors.Teal
|
||||
}
|
||||
val verdictBg = when (r.verdict) {
|
||||
"buy" -> DbbColors.SaveBg
|
||||
"wait" -> DbbColors.UpBg
|
||||
else -> DbbColors.TealBg
|
||||
}
|
||||
val verdictLabel = when (r.verdict) {
|
||||
"buy" -> "建议买"
|
||||
"wait" -> "建议再等"
|
||||
else -> "看个人需求"
|
||||
}
|
||||
val highPrice = r.typicalPrice * 1.08
|
||||
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
if (title.isNotBlank()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProductThumb(title = title, size = 40.dp)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
title,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = DbbColors.Ink,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(DbbColors.Border))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier.size(92.dp).clip(CircleShape).background(verdictBg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"${r.score}",
|
||||
fontSize = 38.sp, fontWeight = FontWeight.ExtraBold,
|
||||
color = verdictColor, letterSpacing = (-1.5).sp,
|
||||
)
|
||||
Text("值得买指数", fontSize = 10.sp, color = verdictColor)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
TagChip(verdictLabel, bg = verdictColor, fg = Color.White)
|
||||
Spacer(Modifier.height(7.dp))
|
||||
Text(r.headline, fontSize = 20.sp, fontWeight = FontWeight.ExtraBold, color = verdictColor)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
"市场常见价 ¥${fmtYuan(r.typicalPrice)}",
|
||||
color = DbbColors.InkSec, fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PriceGauge(min = yourPrice, max = highPrice, marker = r.typicalPrice)
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"你的价 ¥${fmtYuan(yourPrice)}",
|
||||
color = DbbColors.Save, fontSize = 11.sp, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("常见 ¥${fmtYuan(r.typicalPrice)}", color = DbbColors.InkTer, fontSize = 11.sp)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text("高位 ¥${fmtYuan(highPrice)}", color = DbbColors.Up, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
if (r.reasons.isNotEmpty()) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = DbbColors.Card),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Text("判断依据", fontWeight = FontWeight.Bold, color = DbbColors.Ink)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
r.reasons.forEach { reason ->
|
||||
Row(
|
||||
Modifier.padding(vertical = 3.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
"· ", color = DbbColors.Teal,
|
||||
fontWeight = FontWeight.Bold, fontSize = 13.sp,
|
||||
)
|
||||
Text(reason, color = DbbColors.InkSec, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
|
||||
if (r.bestTime.isNotBlank()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(DbbColors.TealBg)
|
||||
.padding(horizontal = 13.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
"⏰ 更划算的时机:${r.bestTime}",
|
||||
color = DbbColors.TealDark, fontSize = 13.sp, fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
Text(
|
||||
"评估由 AI 依据公开常识与价格规律给出,仅供参考。",
|
||||
color = DbbColors.InkTer, fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero 下方"猜你要问"横滑条 — 解决"值不值得买"hero 与输入框之间的空荡感。
|
||||
*
|
||||
* 写死 6 个大众都会想评估的商品(配价格),点一下直接把商品名 + 当前价 prefill 进表单,
|
||||
* 用户再点"AI 评估"按钮即可。
|
||||
* 价格点按主流到手价区间设的;用户当然可以再改。
|
||||
*/
|
||||
@Composable
|
||||
private fun GuessYouAskRow(onPick: (title: String, price: String) -> Unit) {
|
||||
val candidates = remember {
|
||||
listOf(
|
||||
"iPhone 15 Pro 256G" to "7999",
|
||||
"MacBook Air 13 M3" to "8499",
|
||||
"戴森 V12 吸尘器" to "3599",
|
||||
"AirPods 4" to "1099",
|
||||
"Switch OLED 主机" to "2299",
|
||||
"雅诗兰黛小棕瓶 50ml" to "988",
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("💡 猜你要问", color = DbbColors.Emerald, fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
candidates.forEach { (t, p) ->
|
||||
Row(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(11.dp))
|
||||
.background(DbbColors.SaveBg)
|
||||
.clickable { onPick(t, p) }
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(t, color = DbbColors.Save, fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
Text("¥$p", color = DbbColors.Save, fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.jishisongfu.duobibi.ui.worth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.jishisongfu.duobibi.data.api.WorthBuyResponse
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
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
|
||||
|
||||
sealed interface WorthUiState {
|
||||
data object Idle : WorthUiState
|
||||
data object Loading : WorthUiState
|
||||
data class Success(val result: WorthBuyResponse) : WorthUiState
|
||||
data class Error(val message: String) : WorthUiState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class WorthBuyViewModel @Inject constructor(
|
||||
private val repo: PriceRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<WorthUiState>(WorthUiState.Idle)
|
||||
val state: StateFlow<WorthUiState> = _state.asStateFlow()
|
||||
|
||||
private val _title = MutableStateFlow("")
|
||||
val title: StateFlow<String> = _title.asStateFlow()
|
||||
|
||||
private val _price = MutableStateFlow("")
|
||||
val price: StateFlow<String> = _price.asStateFlow()
|
||||
|
||||
fun setTitle(s: String) { _title.value = s }
|
||||
fun setPrice(s: String) { _price.value = s.filter { it.isDigit() || it == '.' } }
|
||||
|
||||
fun submit() {
|
||||
val t = _title.value.trim()
|
||||
val p = _price.value.toDoubleOrNull()
|
||||
if (t.isEmpty()) { _state.value = WorthUiState.Error("请输入商品名"); return }
|
||||
if (p == null || p <= 0) { _state.value = WorthUiState.Error("请输入有效的到手价"); return }
|
||||
_state.value = WorthUiState.Loading
|
||||
viewModelScope.launch {
|
||||
runCatching { repo.worthBuy(t, p) }
|
||||
.onSuccess { _state.value = WorthUiState.Success(it) }
|
||||
.onFailure {
|
||||
// DEBUG: 把异常类型 + message 暴露到 UI 和 logcat,方便定位调用失败
|
||||
android.util.Log.e("WorthBuyVM", "worthBuy failed", it)
|
||||
_state.value = WorthUiState.Error(
|
||||
"评估失败: ${it::class.simpleName}: ${it.message ?: "(no message)"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() { _state.value = WorthUiState.Idle }
|
||||
|
||||
/**
|
||||
* 值不值评估完后,行动 CTA "加入降价雷达" 用 —— 把当前 title 设为想买的雷达,
|
||||
* 心理价默认 = 评估时的到手价(意思:再降破我现在能买到的价就提醒)。
|
||||
*/
|
||||
fun addToAlert(title: String, targetPrice: Double, onDone: () -> Unit) {
|
||||
if (title.isBlank() || targetPrice <= 0) return
|
||||
viewModelScope.launch {
|
||||
repo.addWantAlert(title.trim(), null, targetPrice)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.jishisongfu.duobibi.MainActivity
|
||||
import com.jishisongfu.duobibi.R
|
||||
import com.jishisongfu.duobibi.data.db.AlertMode
|
||||
import com.jishisongfu.duobibi.data.repo.AlertTriggered
|
||||
|
||||
/**
|
||||
* 降价提醒雷达本地通知。batchCheckAlerts 返回的 triggered 列表每条一条通知。
|
||||
*
|
||||
* 通知 ID = [Constants.ALERT_NOTIFICATION_ID_BASE] + alertId,同一条多次触发覆盖不堆积。
|
||||
* 点击跳回 MainActivity(SINGLE_TOP 复用现有实例,不进新栈)。
|
||||
*/
|
||||
object AlertNotifier {
|
||||
|
||||
fun notifyTriggered(ctx: Context, triggered: List<AlertTriggered>) {
|
||||
if (triggered.isEmpty()) return
|
||||
val nm = ctx.getSystemService(NotificationManager::class.java) ?: return
|
||||
ensureChannel(nm, ctx)
|
||||
|
||||
val openAppIntent = Intent(ctx, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
val openAppPi = PendingIntent.getActivity(
|
||||
ctx, 0, openAppIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
triggered.forEach { t ->
|
||||
val plat = t.platform?.takeIf { it.isNotBlank() } ?: "全网"
|
||||
val title: String
|
||||
val body: String
|
||||
if (t.mode == AlertMode.BOUGHT) {
|
||||
title = "💰 可申请价保!可省 ¥${fmt(t.savings)}"
|
||||
body = "${t.title} 在$plat 降到 ¥${fmt(t.currentPrice)}" +
|
||||
"(买价 ¥${fmt(t.anchorPrice)}),点击查看"
|
||||
} else {
|
||||
title = "🎯 降到你的心理价啦!"
|
||||
body = "${t.title} 现已 ¥${fmt(t.currentPrice)}($plat)" +
|
||||
",心理价 ¥${fmt(t.anchorPrice)},点击查看"
|
||||
}
|
||||
val notif = NotificationCompat.Builder(ctx, Constants.ALERT_NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(openAppPi)
|
||||
.build()
|
||||
nm.notify(Constants.ALERT_NOTIFICATION_ID_BASE + t.id.toInt(), notif)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(nm: NotificationManager, ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
Constants.ALERT_NOTIFICATION_CHANNEL_ID,
|
||||
ctx.getString(R.string.alert_notification_channel),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fmt(p: Double): String =
|
||||
if (p == p.toLong().toDouble()) p.toLong().toString() else "%.2f".format(p)
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* 一键登录后的账号持久化(本地侧)。phone 当账号主键(占坑期方案,后续可升级 user_id)。
|
||||
*
|
||||
* 写入点:[com.jishisongfu.duobibi.auth.PhoneBindViewModel] 收到后端解密手机号后
|
||||
* 清除点:Mine 页"解绑"按钮(本地清,本地 wish 数据保留以便后续换号合并)
|
||||
*
|
||||
* 设计上跟 [PrivacyPrefs] 一致 —— 简单 KV,不引入 Datastore。
|
||||
*/
|
||||
object AuthPrefs {
|
||||
|
||||
private const val PREFS = "auth_prefs"
|
||||
private const val KEY_PHONE = "phone"
|
||||
private const val KEY_BOUND_AT = "bound_at"
|
||||
private const val KEY_LAST_SYNCED_AT = "wish_last_synced_at"
|
||||
|
||||
/** 当前绑定的手机号,null = 未绑定 */
|
||||
fun phone(ctx: Context): String? =
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_PHONE, null)
|
||||
|
||||
fun isBound(ctx: Context): Boolean = phone(ctx) != null
|
||||
|
||||
/** 绑定时间戳(ms),用于 UI 展示;phone == null 时此字段无意义 */
|
||||
fun boundAt(ctx: Context): Long =
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getLong(KEY_BOUND_AT, 0L)
|
||||
|
||||
fun setBound(ctx: Context, phone: String, boundAt: Long = System.currentTimeMillis()) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_PHONE, phone)
|
||||
.putLong(KEY_BOUND_AT, boundAt)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clear(ctx: Context) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(KEY_PHONE)
|
||||
.remove(KEY_BOUND_AT)
|
||||
.remove(KEY_LAST_SYNCED_AT)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 心愿单上次同步成功的时间戳(ms)。0 = 从未同步过。换号要清。 */
|
||||
fun wishLastSyncedAt(ctx: Context): Long =
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getLong(KEY_LAST_SYNCED_AT, 0L)
|
||||
|
||||
fun setWishLastSyncedAt(ctx: Context, ts: Long) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_LAST_SYNCED_AT, ts)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 掩码手机号,UI 展示用:138****1511 */
|
||||
fun maskedPhone(phone: String): String {
|
||||
if (phone.length < 7) return phone
|
||||
return phone.take(3) + "****" + phone.takeLast(4)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
object Constants {
|
||||
|
||||
/**
|
||||
* 比价浮窗只在这 7 个购物 App 下显示。无障碍服务收到任意包名的窗口切换事件后,
|
||||
* 用这个集合判断是否让浮窗显示。
|
||||
*/
|
||||
val WHITELIST_PACKAGES: Set<String> = setOf(
|
||||
"com.taobao.taobao", // 淘宝
|
||||
"com.jingdong.app.mall", // 京东
|
||||
"com.xunmeng.pinduoduo", // 拼多多
|
||||
"com.ss.android.ugc.aweme", // 抖音
|
||||
"com.sankuai.meituan", // 美团
|
||||
"com.sankuai.meituan.takeoutnew", // 美团外卖
|
||||
"me.ele", // 饿了么
|
||||
)
|
||||
|
||||
const val FLOATING_NOTIFICATION_CHANNEL_ID = "duobibi_floating"
|
||||
const val FLOATING_NOTIFICATION_ID = 1001
|
||||
|
||||
// 降价提醒雷达本地通知(WANT 到价 / BOUGHT 可申请价保)
|
||||
const val ALERT_NOTIFICATION_CHANNEL_ID = "duobibi_alert"
|
||||
/** 通知 ID 基址 — 实际 ID = 该值 + alertId,保证同一条多次触发只覆盖不堆积 */
|
||||
const val ALERT_NOTIFICATION_ID_BASE = 2000
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import com.jishisongfu.duobibi.data.db.Cluster
|
||||
import com.jishisongfu.duobibi.data.db.PriceRecord
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object CsvExporter {
|
||||
|
||||
/**
|
||||
* 导出全部价格记录为 CSV,写入 Downloads。
|
||||
* 返回写入的文件名。失败抛异常。
|
||||
*/
|
||||
fun export(
|
||||
ctx: Context,
|
||||
records: List<PriceRecord>,
|
||||
clusters: List<Cluster>,
|
||||
): String {
|
||||
val ts = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
|
||||
val filename = "duobibi_export_$ts.csv"
|
||||
val clusterById = clusters.associateBy { it.id }
|
||||
val csv = buildCsv(records, clusterById)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
writeViaMediaStore(ctx, filename, csv)
|
||||
} else {
|
||||
writeToLegacyDownload(ctx, filename, csv)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
private fun buildCsv(
|
||||
records: List<PriceRecord>,
|
||||
clusterById: Map<Long, Cluster>,
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
// BOM,确保 Excel 中文不乱码
|
||||
sb.append('\uFEFF')
|
||||
sb.append("id,商品名,价格,来源 App,商品组,商品组ID,记录时间\n")
|
||||
val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
records.sortedByDescending { it.createdAt }.forEach { r ->
|
||||
val clusterTitle = clusterById[r.clusterId]?.title.orEmpty()
|
||||
sb.append(r.id).append(',')
|
||||
sb.append(escape(r.title)).append(',')
|
||||
sb.append(formatPrice(r.price)).append(',')
|
||||
sb.append(escape(r.sourceApp)).append(',')
|
||||
sb.append(escape(clusterTitle)).append(',')
|
||||
sb.append(r.clusterId).append(',')
|
||||
sb.append(escape(df.format(Date(r.createdAt)))).append('\n')
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun escape(s: String): String {
|
||||
if (s.contains(',') || s.contains('"') || s.contains('\n')) {
|
||||
return "\"" + s.replace("\"", "\"\"") + "\""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private fun formatPrice(p: Double): String =
|
||||
if (p == p.toLong().toDouble()) p.toLong().toString() else "%.2f".format(p)
|
||||
|
||||
private fun writeViaMediaStore(ctx: Context, filename: String, content: String) {
|
||||
val resolver = ctx.contentResolver
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, filename)
|
||||
put(MediaStore.Downloads.MIME_TYPE, "text/csv")
|
||||
put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
?: error("无法创建文件")
|
||||
resolver.openOutputStream(uri).use { out: OutputStream? ->
|
||||
out?.write(content.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 9- 走 app 私有的 Download 目录,无需 WRITE_EXTERNAL_STORAGE 权限。
|
||||
* 路径在 /Android/data/<package>/files/Download/,系统卸载 App 时一并清除。
|
||||
*/
|
||||
private fun writeToLegacyDownload(ctx: Context, filename: String, content: String) {
|
||||
val dir = ctx.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
?: error("无可用的应用私有外部存储目录")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
File(dir, filename).writeText(content, Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* 示例数据(占审用)是否已注入的本地标记。
|
||||
* 命名风格与 OnboardingPrefs / PrivacyPrefs 一致。
|
||||
*
|
||||
* 生命周期:
|
||||
* - 首启隐私同意之后,若 demoSeeded=false → DemoDataSeeder 注入并置 true
|
||||
* - 用户点 设置页"清除示例数据" → 清除 + 置 false(用户可手动重新注入)
|
||||
*/
|
||||
object DemoDataPrefs {
|
||||
|
||||
private const val FILE = "demo_data_prefs"
|
||||
private const val KEY_SEEDED = "demo_seeded"
|
||||
|
||||
fun seeded(ctx: Context): Boolean =
|
||||
prefs(ctx).getBoolean(KEY_SEEDED, false)
|
||||
|
||||
fun setSeeded(ctx: Context, seeded: Boolean) {
|
||||
prefs(ctx).edit().putBoolean(KEY_SEEDED, seeded).apply()
|
||||
}
|
||||
|
||||
private fun prefs(ctx: Context) =
|
||||
ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.jishisongfu.duobibi.data.repo.PriceRepository
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* 示例数据注入器(占审用)。
|
||||
*
|
||||
* 调用方:MainActivity 在隐私同意之后调一次 [seedIfNeeded],幂等。
|
||||
*
|
||||
* 三道闸:
|
||||
* 1. DemoDataPrefs.seeded(ctx) = true → 已处理过(不论 seed 成功还是跳过),直接返回
|
||||
* 2. 用户已有真实数据(cluster 不为空)→ 老用户升级,**不污染** 他的列表;但 mark seeded=true 以后不再判断
|
||||
* 3. 全新空库 → 先清残留 demo(防部分 seed 重试重复)→ 调 repo.seedDemoData() 注入 → mark seeded=true
|
||||
*
|
||||
* 并发:[mutex] 保证同时只一个 seedIfNeeded 在跑。Activity 重建 / 快速 backstack 会触发多次,
|
||||
* 但 Mutex 排队后第二份进来会发现 seeded=true 直接返回,不会重复注入。
|
||||
*/
|
||||
object DemoDataSeeder {
|
||||
|
||||
private const val TAG = "DUOBIBI-DEMO"
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun seedIfNeeded(ctx: Context, repo: PriceRepository) {
|
||||
if (DemoDataPrefs.seeded(ctx)) return
|
||||
mutex.withLock {
|
||||
// 双重检查:第一份跑完后第二份才拿到锁,此时 seeded 已 true,直接返回
|
||||
if (DemoDataPrefs.seeded(ctx)) return@withLock
|
||||
try {
|
||||
// 老用户升级 / 用户在我们 seed 之前已经手动记过价 → 不污染真实数据
|
||||
if (repo.hasAnyRealData()) {
|
||||
Log.i(TAG, "skip seed: user has real data already")
|
||||
DemoDataPrefs.setSeeded(ctx, true)
|
||||
return@withLock
|
||||
}
|
||||
// 防"上次 seed 中途进程被杀,残留半套 demo"的重试场景:
|
||||
// 先 clearDemoData 把残留清掉再 seed,保证最终是干净的 17 条而不是叠加。
|
||||
repo.clearDemoData()
|
||||
repo.seedDemoData()
|
||||
DemoDataPrefs.setSeeded(ctx, true)
|
||||
Log.i(TAG, "demo data seeded")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "seed failed: ${e.message}", e)
|
||||
// 失败不 mark seeded,下次冷启动还会重试(顶部 clearDemoData 保证幂等)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户在设置页主动清除。语义:**"我不要 demo 数据"** —— 所以这里 setSeeded=true
|
||||
* 阻止下次冷启动自动重注入。用户已表态,我们尊重。
|
||||
*
|
||||
* 副作用:这也意味着用户清完之后想"找回 demo"得清 App 数据走首启流程。占审期可接受。
|
||||
*/
|
||||
suspend fun clearAndUnseed(ctx: Context, repo: PriceRepository) {
|
||||
mutex.withLock {
|
||||
repo.clearDemoData()
|
||||
DemoDataPrefs.setSeeded(ctx, true)
|
||||
Log.i(TAG, "demo data cleared (user request)")
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.jishisongfu.duobibi.service.FloatingWindowService
|
||||
|
||||
/**
|
||||
* 在隐私已同意 + 浮窗开关已开 + 必要权限齐备时,把 [FloatingWindowService] 拉起来。
|
||||
* MainActivity.onResume 和 HomeScreen 的 ON_RESUME 都会调一遍,startForegroundService 是幂等的,
|
||||
* 多次调用安全。
|
||||
*/
|
||||
object FloatingServiceLauncher {
|
||||
|
||||
fun startIfReady(ctx: Context) {
|
||||
if (!PrivacyPrefs.agreed(ctx)) return
|
||||
if (!SettingsPrefs.floatingEnabled(ctx)) return
|
||||
if (!PermissionUtil.allGranted(ctx)) return
|
||||
ContextCompat.startForegroundService(
|
||||
ctx, Intent(ctx, FloatingWindowService::class.java),
|
||||
)
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.media.ExifInterface
|
||||
import android.net.Uri
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* 上传 OCR 前的图片压缩工具。
|
||||
*
|
||||
* 目标:JPEG quality 85,短边 ≤ 1024px,最终 < 5MB(后端 MAX_IMAGE_BYTES 上限)。
|
||||
* 在内存里完成不落盘,避免缓存目录管理。
|
||||
*
|
||||
* EXIF 旋转:相机出来的图有 orientation EXIF,直接 decode 不旋转会得到躺平的图。
|
||||
* 这里检 EXIF 旋转一次,让传给 LLM 的图是正方向(影响识别准确率,小红书拍照常见竖屏)。
|
||||
*/
|
||||
object ImageCompressor {
|
||||
|
||||
private const val TARGET_SHORT_EDGE = 1024
|
||||
private const val JPEG_QUALITY = 85
|
||||
private const val MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* 从 Uri 加载图片 → 旋转修正 → 缩到短边 ≤ 1024 → JPEG quality 85 编码 → 返回字节。
|
||||
*
|
||||
* 失败抛 IOException(Uri 无效 / 解码失败 / OOM 等),调用方需 try/catch。
|
||||
*/
|
||||
fun compress(ctx: Context, uri: Uri): ByteArray {
|
||||
// 先读 bounds 算出 inSampleSize 避免大图 OOM
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
ctx.contentResolver.openInputStream(uri).use { stream: InputStream? ->
|
||||
BitmapFactory.decodeStream(stream, null, bounds)
|
||||
}
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
|
||||
throw IllegalArgumentException("decode bounds failed for $uri")
|
||||
}
|
||||
val shortEdge = minOf(bounds.outWidth, bounds.outHeight)
|
||||
// inSampleSize 必须是 2 的幂(BitmapFactory 文档),逐次倍增直到短边 ≤ 1024
|
||||
var sample = 1
|
||||
while (shortEdge / (sample * 2) >= TARGET_SHORT_EDGE) sample *= 2
|
||||
|
||||
val decoded = ctx.contentResolver.openInputStream(uri).use { stream ->
|
||||
BitmapFactory.decodeStream(
|
||||
stream, null,
|
||||
BitmapFactory.Options().apply { inSampleSize = sample },
|
||||
)
|
||||
} ?: throw IllegalStateException("decode bitmap failed for $uri")
|
||||
|
||||
// 再精确缩到短边 1024(inSampleSize 只能 2 倍粗调,这里 createScaledBitmap 精调)
|
||||
val scaled = scaleToShortEdge(decoded, TARGET_SHORT_EDGE)
|
||||
if (scaled !== decoded) decoded.recycle()
|
||||
|
||||
// EXIF 旋转
|
||||
val rotated = applyExifRotation(ctx, uri, scaled)
|
||||
if (rotated !== scaled) scaled.recycle()
|
||||
|
||||
return rotated.use { bmp ->
|
||||
val baos = ByteArrayOutputStream(256 * 1024)
|
||||
bmp.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos)
|
||||
val bytes = baos.toByteArray()
|
||||
if (bytes.size > MAX_BYTES) {
|
||||
// 极端情况 quality 85 + 1024 短边还超 5MB(超大像素 + 高频纹理)→ 降 quality 重压
|
||||
val baos2 = ByteArrayOutputStream(256 * 1024)
|
||||
bmp.compress(Bitmap.CompressFormat.JPEG, 60, baos2)
|
||||
baos2.toByteArray()
|
||||
} else {
|
||||
bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scaleToShortEdge(src: Bitmap, target: Int): Bitmap {
|
||||
val w = src.width
|
||||
val h = src.height
|
||||
val short = minOf(w, h)
|
||||
if (short <= target) return src
|
||||
val scale = target.toFloat() / short
|
||||
return Bitmap.createScaledBitmap(src, (w * scale).toInt(), (h * scale).toInt(), true)
|
||||
}
|
||||
|
||||
private fun applyExifRotation(ctx: Context, uri: Uri, bmp: Bitmap): Bitmap {
|
||||
val orientation = try {
|
||||
ctx.contentResolver.openInputStream(uri).use { stream ->
|
||||
stream ?: return bmp
|
||||
ExifInterface(stream).getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return bmp
|
||||
}
|
||||
val degrees = when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
||||
else -> return bmp
|
||||
}
|
||||
val matrix = Matrix().apply { postRotate(degrees) }
|
||||
return Bitmap.createBitmap(bmp, 0, 0, bmp.width, bmp.height, matrix, true)
|
||||
}
|
||||
|
||||
/** Kotlin's stdlib doesn't have Bitmap.use, but we need recycle-on-scope-exit semantics. */
|
||||
private inline fun <R> Bitmap.use(block: (Bitmap) -> R): R {
|
||||
try {
|
||||
return block(this)
|
||||
} finally {
|
||||
if (!isRecycled) recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/** 首启 onboarding 是否已完成。隐私同意之后、进入主界面之前播一次。 */
|
||||
object OnboardingPrefs {
|
||||
|
||||
private const val PREFS = "onboarding_prefs"
|
||||
private const val KEY_COMPLETED = "completed"
|
||||
|
||||
fun completed(ctx: Context): Boolean =
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_COMPLETED, false)
|
||||
|
||||
fun setCompleted(ctx: Context, value: Boolean) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_COMPLETED, value)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.text.TextUtils
|
||||
import com.jishisongfu.duobibi.service.PriceBotAccessibilityService
|
||||
|
||||
object PermissionUtil {
|
||||
|
||||
fun overlayGranted(ctx: Context): Boolean = Settings.canDrawOverlays(ctx)
|
||||
|
||||
/**
|
||||
* 通过 [Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES] 字符串是否包含本服务的 ComponentName
|
||||
* 来判定无障碍是否开启。这是 Android 上检测无障碍授权的标准做法。
|
||||
*/
|
||||
fun accessibilityGranted(ctx: Context): Boolean {
|
||||
val expected = ComponentName(ctx, PriceBotAccessibilityService::class.java).flattenToString()
|
||||
val enabled = Settings.Secure.getString(
|
||||
ctx.contentResolver,
|
||||
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
|
||||
) ?: return false
|
||||
val splitter = TextUtils.SimpleStringSplitter(':')
|
||||
splitter.setString(enabled)
|
||||
while (splitter.hasNext()) {
|
||||
if (splitter.next().equals(expected, ignoreCase = true)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 自启动权限是厂商私有逻辑,无法通过统一 API 查询,这里只能保守地认为"未确认"。
|
||||
* 占坑期不阻塞用户使用,只在设置页给出"去开启"按钮。
|
||||
*/
|
||||
fun autoStartGranted(@Suppress("UNUSED_PARAMETER") ctx: Context): Boolean = false
|
||||
|
||||
fun allGranted(ctx: Context): Boolean =
|
||||
accessibilityGranted(ctx) && overlayGranted(ctx)
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* 比价擂台"去最低价平台搜"。先把商品名复制到剪贴板兜底,再逐级尝试调起目标平台搜索页:
|
||||
* 1) app 私有 deeplink → 2) 公开 https 网页 → 3) 都失败则 Toast 提示手动搜。
|
||||
*
|
||||
* **裸调** ACTION_VIEW,不带任何推广 / 联盟参数。失败链路兜底,不崩,只降级。
|
||||
*/
|
||||
object PlatformSearch {
|
||||
|
||||
private const val TAG = "DUOBIBI-SEARCH"
|
||||
|
||||
fun copyAndSearch(ctx: Context, platform: String, productName: String) {
|
||||
copyToClipboard(ctx, productName)
|
||||
val q = runCatching { URLEncoder.encode(productName, "UTF-8") }.getOrDefault(productName)
|
||||
for (intent in buildIntents(platform, q)) {
|
||||
if (tryStart(ctx, intent)) {
|
||||
Toast.makeText(ctx, "已复制商品名,正在打开$platform", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
}
|
||||
Toast.makeText(ctx, "商品名已复制,请打开 $platform App 搜索", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun copyToClipboard(ctx: Context, text: String) {
|
||||
val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
cm.setPrimaryClip(ClipData.newPlainText("多比比-商品名", text))
|
||||
}
|
||||
|
||||
private fun buildIntents(platform: String, q: String): List<Intent> = when (platform) {
|
||||
"淘宝" -> listOf(
|
||||
intent("taobao://s.taobao.com/search?q=$q"),
|
||||
intent("https://s.taobao.com/search?q=$q"),
|
||||
)
|
||||
"京东" -> listOf(
|
||||
intent("openapp.jdmobile://virtual?params=%7B%22category%22%3A%22jump%22%2C%22des%22%3A%22productSearchListNJ%22%2C%22keyword%22%3A%22$q%22%7D"),
|
||||
intent("https://search.jd.com/Search?keyword=$q"),
|
||||
)
|
||||
"拼多多" -> listOf(
|
||||
intent("pinduoduo://com.xunmeng.pinduoduo/search_result.html?search_key=$q"),
|
||||
intent("https://mobile.yangkeduo.com/search_result.html?search_key=$q"),
|
||||
)
|
||||
"抖音" -> listOf(
|
||||
intent("snssdk1128://search?keyword=$q"),
|
||||
)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
private fun intent(uri: String): Intent =
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(uri)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
private fun tryStart(ctx: Context, intent: Intent): Boolean = try {
|
||||
ctx.startActivity(intent); true
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "tryStart failed ${intent.data}: ${e.message}"); false
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object PrivacyPrefs {
|
||||
|
||||
private const val PREFS = "privacy_prefs"
|
||||
private const val KEY_AGREED = "agreed"
|
||||
|
||||
fun agreed(ctx: Context): Boolean =
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_AGREED, false)
|
||||
|
||||
fun setAgreed(ctx: Context, value: Boolean) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_AGREED, value)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object SettingsPrefs {
|
||||
|
||||
private const val PREFS = "app_settings"
|
||||
private const val KEY_FLOATING = "floating_enabled"
|
||||
private const val KEY_FLOAT_X = "floating_x"
|
||||
private const val KEY_FLOAT_Y = "floating_y"
|
||||
|
||||
fun floatingEnabled(ctx: Context): Boolean =
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(KEY_FLOATING, true)
|
||||
|
||||
fun setFloatingEnabled(ctx: Context, value: Boolean) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_FLOATING, value)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun savedPosition(ctx: Context): Pair<Int, Int>? {
|
||||
val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (!prefs.contains(KEY_FLOAT_X) || !prefs.contains(KEY_FLOAT_Y)) return null
|
||||
return prefs.getInt(KEY_FLOAT_X, 0) to prefs.getInt(KEY_FLOAT_Y, 0)
|
||||
}
|
||||
|
||||
fun savePosition(ctx: Context, x: Int, y: Int) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putInt(KEY_FLOAT_X, x)
|
||||
.putInt(KEY_FLOAT_Y, y)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import com.jishisongfu.duobibi.data.db.Cluster
|
||||
import com.jishisongfu.duobibi.data.db.PriceRecord
|
||||
|
||||
/**
|
||||
* 一个商品簇的统计数据。
|
||||
*/
|
||||
data class ClusterStats(
|
||||
val clusterId: Long,
|
||||
val title: String,
|
||||
val recordCount: Int,
|
||||
val minPrice: Double,
|
||||
val maxPrice: Double,
|
||||
val priceSpread: Double,
|
||||
val sources: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 全局统计快照。
|
||||
*/
|
||||
data class OverallStats(
|
||||
val totalRecords: Int,
|
||||
val totalProducts: Int,
|
||||
val totalPlatforms: Int,
|
||||
val totalSaved: Double, // 各簇 max-min 之和
|
||||
val avgSavePerProduct: Double, // totalSaved / totalProducts (有商品时)
|
||||
val recordsByPlatform: Map<String, Int>,
|
||||
val topSpread: List<ClusterStats>,
|
||||
val recordsToday: Int,
|
||||
)
|
||||
|
||||
object StatsCalculator {
|
||||
|
||||
fun computeOverall(
|
||||
clusters: List<Cluster>,
|
||||
records: List<PriceRecord>,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): OverallStats {
|
||||
val recordsByCluster = records.groupBy { it.clusterId }
|
||||
val clusterStats = clusters.mapNotNull { c ->
|
||||
val items = recordsByCluster[c.id].orEmpty()
|
||||
if (items.isEmpty()) return@mapNotNull null
|
||||
val minP = items.minOf { it.price }
|
||||
val maxP = items.maxOf { it.price }
|
||||
ClusterStats(
|
||||
clusterId = c.id,
|
||||
title = c.title,
|
||||
recordCount = items.size,
|
||||
minPrice = minP,
|
||||
maxPrice = maxP,
|
||||
priceSpread = maxP - minP,
|
||||
sources = items.map { it.sourceApp }.distinct(),
|
||||
)
|
||||
}
|
||||
|
||||
val totalSaved = clusterStats.sumOf { it.priceSpread }
|
||||
val totalProducts = clusterStats.size
|
||||
val recordsByPlatform = records.groupingBy { it.sourceApp }.eachCount()
|
||||
val topSpread = clusterStats
|
||||
.filter { it.priceSpread > 0 }
|
||||
.sortedByDescending { it.priceSpread }
|
||||
.take(5)
|
||||
|
||||
val startOfToday = startOfDay(nowMillis)
|
||||
val recordsToday = records.count { it.createdAt >= startOfToday }
|
||||
|
||||
return OverallStats(
|
||||
totalRecords = records.size,
|
||||
totalProducts = totalProducts,
|
||||
totalPlatforms = recordsByPlatform.size,
|
||||
totalSaved = totalSaved,
|
||||
avgSavePerProduct = if (totalProducts > 0) totalSaved / totalProducts else 0.0,
|
||||
recordsByPlatform = recordsByPlatform,
|
||||
topSpread = topSpread,
|
||||
recordsToday = recordsToday,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 createdAt 时间窗口过滤记录。
|
||||
* @param windowDays null 表示全部,>0 表示只取最近 N 天
|
||||
*/
|
||||
fun filterByWindow(records: List<PriceRecord>, windowDays: Int?, nowMillis: Long = System.currentTimeMillis()): List<PriceRecord> {
|
||||
if (windowDays == null) return records
|
||||
val cutoff = nowMillis - windowDays * 24L * 60L * 60L * 1000L
|
||||
return records.filter { it.createdAt >= cutoff }
|
||||
}
|
||||
|
||||
private fun startOfDay(ms: Long): Long {
|
||||
val cal = java.util.Calendar.getInstance()
|
||||
cal.timeInMillis = ms
|
||||
cal.set(java.util.Calendar.HOUR_OF_DAY, 0)
|
||||
cal.set(java.util.Calendar.MINUTE, 0)
|
||||
cal.set(java.util.Calendar.SECOND, 0)
|
||||
cal.set(java.util.Calendar.MILLISECOND, 0)
|
||||
return cal.timeInMillis
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import com.jishisongfu.duobibi.R
|
||||
|
||||
object VendorAutoStart {
|
||||
|
||||
private val candidates: List<ComponentName> = listOf(
|
||||
// 小米
|
||||
ComponentName("com.miui.securitycenter", "com.miui.permcenter.autostart.AutoStartManagementActivity"),
|
||||
// 华为
|
||||
ComponentName("com.huawei.systemmanager", "com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"),
|
||||
ComponentName("com.huawei.systemmanager", "com.huawei.systemmanager.optimize.process.ProtectActivity"),
|
||||
// OPPO / OnePlus / realme
|
||||
ComponentName("com.coloros.safecenter", "com.coloros.safecenter.permission.startup.StartupAppListActivity"),
|
||||
ComponentName("com.coloros.safecenter", "com.coloros.safecenter.startupapp.StartupAppListActivity"),
|
||||
ComponentName("com.oppo.safe", "com.oppo.safe.permission.startup.StartupAppListActivity"),
|
||||
// vivo / iQOO
|
||||
ComponentName("com.iqoo.secure", "com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity"),
|
||||
ComponentName("com.vivo.permissionmanager", "com.vivo.permissionmanager.activity.BgStartUpManagerActivity"),
|
||||
// 荣耀
|
||||
ComponentName("com.hihonor.systemmanager", "com.hihonor.systemmanager.startupmgr.ui.StartupNormalAppListActivity"),
|
||||
// 魅族
|
||||
ComponentName("com.meizu.safe", "com.meizu.safe.security.SHOW_APPSEC"),
|
||||
)
|
||||
|
||||
/**
|
||||
* 按厂商品牌猜对应的自启动设置页。命中即跳转;全部不命中则 toast 引导用户手动到系统设置开启。
|
||||
*/
|
||||
fun openAutoStartSettings(ctx: Context) {
|
||||
for (cn in candidates) {
|
||||
val intent = Intent().apply {
|
||||
component = cn
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
ctx.startActivity(intent)
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
// 继续尝试下一个
|
||||
}
|
||||
}
|
||||
Toast.makeText(ctx, R.string.autostart_unknown_vendor, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
fun isLikelyChineseVendor(): Boolean {
|
||||
val brand = Build.BRAND.lowercase()
|
||||
return brand in setOf(
|
||||
"xiaomi", "redmi", "huawei", "honor", "oppo", "oneplus",
|
||||
"realme", "vivo", "iqoo", "meizu",
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.jishisongfu.duobibi.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
/**
|
||||
* 关注商品(收藏)持久化。
|
||||
*
|
||||
* 占坑期不上 Room 表 —— 关注的本质是"id Set",SharedPreferences 一行能搞定。
|
||||
* 收藏对象是 [com.jishisongfu.duobibi.ui.discover.DiscoverItem.id](DiscoverItem 的 id
|
||||
* 字段),消费方读出 id 集合后再去 `DISCOVER_SEED` 里查。后续要持久化"用户自加"的关注
|
||||
* 项(种子之外)时,再升级到 Room。
|
||||
*
|
||||
* UI 反应式订阅用 [watchedFlow],基于 [SharedPreferences.OnSharedPreferenceChangeListener]
|
||||
* 自动触发列表/徽章重算。
|
||||
*/
|
||||
object WatchedPrefs {
|
||||
private const val PREFS = "duobibi_watched"
|
||||
private const val KEY_IDS = "watched_ids"
|
||||
|
||||
private fun prefs(ctx: Context): SharedPreferences =
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun watchedIds(ctx: Context): Set<String> =
|
||||
prefs(ctx).getStringSet(KEY_IDS, emptySet())?.toSet() ?: emptySet()
|
||||
|
||||
fun isWatched(ctx: Context, id: String): Boolean =
|
||||
watchedIds(ctx).contains(id)
|
||||
|
||||
fun count(ctx: Context): Int = watchedIds(ctx).size
|
||||
|
||||
/** Toggle 返回操作后是否被关注(true=新加,false=取消)。 */
|
||||
fun toggle(ctx: Context, id: String): Boolean {
|
||||
val cur = watchedIds(ctx).toMutableSet()
|
||||
val nowWatched = if (cur.contains(id)) {
|
||||
cur.remove(id); false
|
||||
} else {
|
||||
cur.add(id); true
|
||||
}
|
||||
// putStringSet 必须传新 Set 实例,直接复用 cur 会被某些 ROM 缓存导致不触发 listener。
|
||||
prefs(ctx).edit().putStringSet(KEY_IDS, cur.toSet()).apply()
|
||||
return nowWatched
|
||||
}
|
||||
|
||||
fun clearAll(ctx: Context) {
|
||||
prefs(ctx).edit().remove(KEY_IDS).apply()
|
||||
}
|
||||
|
||||
/** 反应式订阅 — 监听 [KEY_IDS] 的变化,自动 emit 新集合;首项立即 emit 当前值。 */
|
||||
fun watchedFlow(ctx: Context): Flow<Set<String>> = callbackFlow {
|
||||
trySend(watchedIds(ctx))
|
||||
val p = prefs(ctx)
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == KEY_IDS) trySend(watchedIds(ctx))
|
||||
}
|
||||
p.registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose { p.unregisterOnSharedPreferenceChangeListener(listener) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:radius="5dp" />
|
||||
</shape>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user