初始化:多比比Android客户端(占坑版)

This commit is contained in:
zzhyyyyy
2026-05-29 13:27:41 +08:00
commit 31429e12e0
198 changed files with 15533 additions and 0 deletions
@@ -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/
+166
View File
@@ -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")
}
@@ -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>
@@ -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") }
}
}
}
@@ -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 再试
}
}
}
}
}
@@ -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")
}
}
@@ -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)
}
@@ -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
}
}
@@ -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,
)
@@ -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
}
@@ -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,
)
@@ -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
}
@@ -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,
)
@@ -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,
)
@@ -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 = "",
)
@@ -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
}
@@ -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,
)
@@ -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
}
@@ -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" // 已买到 / 已申请价保,归档
}
@@ -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()
}
@@ -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,
)
@@ -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()
}
@@ -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
}
@@ -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()
}
@@ -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)
}
@@ -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
}
}
@@ -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
}
}
@@ -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(),
)
@@ -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) {}
}
}
@@ -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) }
}
}
@@ -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
}
@@ -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
}
}
@@ -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,
)
@@ -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)
}
}
@@ -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,
)
}
}
@@ -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() }
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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",
)
}
}
@@ -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),
)
}
}
@@ -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) }
}
@@ -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),
)
}
@@ -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
}
}
@@ -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),
)
}
}
}
@@ -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,
)
}
}
}
}
@@ -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
}
}
}
}
@@ -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("餐饮外卖", "网上购物")
}
}
@@ -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,
)
}
}
}
@@ -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()
}
}
@@ -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,
)
}
@@ -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,
),
)
@@ -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,
),
)
}
}
}
@@ -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
}
}
@@ -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)
}
}
}
}
}
}
@@ -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)
}
}
}
}
@@ -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 -> "待检测"
}
@@ -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)
}
}
}
@@ -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-darkteal,去掉 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)
}
}
@@ -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()
}
}
}
@@ -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,
),
)
}
@@ -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)
}
}
}
}
@@ -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))
}
}
@@ -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))
}
@@ -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())
@@ -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() }
}
}
@@ -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)
}
}
}
@@ -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()
}
}
}
@@ -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)
}
}
}
}
@@ -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,
)
}
}
}
}
@@ -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 }
}
@@ -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)
}
@@ -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)
}
@@ -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(
"阶梯满减:满 30050 / 满 600100 / 满 1000200 / 满 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)
}
}
}
}
@@ -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
}
@@ -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 == '.' }
@@ -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,
)
}
}
@@ -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)
}
@@ -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)
}
}
}
}
@@ -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()
}
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
@@ -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)")
}
}
}
@@ -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),
)
}
}
@@ -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()
}
}
}
@@ -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()
}
}
@@ -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)
}
@@ -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
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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",
)
}
}
@@ -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