commit 31429e12e0f57c8cc11347a060c2a502e11445a7 Author: zzhyyyyy <2685922758@qq.com> Date: Fri May 29 13:27:41 2026 +0800 初始化:多比比Android客户端(占坑版) diff --git a/duobibi(mock)Android/duobibi-mock/.gitignore b/duobibi(mock)Android/duobibi-mock/.gitignore new file mode 100644 index 0000000..f465629 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/.gitignore @@ -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/ diff --git a/duobibi(mock)Android/duobibi-mock/README.md b/duobibi(mock)Android/duobibi-mock/README.md new file mode 100644 index 0000000..ff5f83d --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/README.md @@ -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 包 diff --git a/duobibi(mock)Android/duobibi-mock/app/build.gradle.kts b/duobibi(mock)Android/duobibi-mock/app/build.gradle.kts new file mode 100644 index 0000000..2544683 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/build.gradle.kts @@ -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") +} diff --git a/duobibi(mock)Android/duobibi-mock/app/libs/MiPush_SDK_Client_7_9_2-C_3rd.aar b/duobibi(mock)Android/duobibi-mock/app/libs/MiPush_SDK_Client_7_9_2-C_3rd.aar new file mode 100644 index 0000000..00d45a4 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/libs/MiPush_SDK_Client_7_9_2-C_3rd.aar differ diff --git a/duobibi(mock)Android/duobibi-mock/app/proguard-rules.pro b/duobibi(mock)Android/duobibi-mock/app/proguard-rules.pro new file mode 100644 index 0000000..1602904 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/proguard-rules.pro @@ -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.* ; +} +-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.** { *; } diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/AndroidManifest.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6625e1c --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/AndroidManifest.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/DuobibiApplication.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/DuobibiApplication.kt new file mode 100644 index 0000000..8c1afc7 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/DuobibiApplication.kt @@ -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") } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/MainActivity.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/MainActivity.kt new file mode 100644 index 0000000..b1a60e7 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/MainActivity.kt @@ -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 再试 + } + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/auth/JgInit.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/auth/JgInit.kt new file mode 100644 index 0000000..5a12bf1 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/auth/JgInit.kt @@ -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") + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/auth/OneClickLogin.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/auth/OneClickLogin.kt new file mode 100644 index 0000000..35a94ce --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/auth/OneClickLogin.kt @@ -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 = + 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 = + 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) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/auth/PhoneBindViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/auth/PhoneBindViewModel.kt new file mode 100644 index 0000000..44e85db --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/auth/PhoneBindViewModel.kt @@ -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.Idle) + val state: StateFlow = _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 + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ArenaApi.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ArenaApi.kt new file mode 100644 index 0000000..34d3085 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ArenaApi.kt @@ -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? = 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 = emptyList(), + @SerialName("lowest_platform") val lowestPlatform: String? = null, +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/AuthApi.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/AuthApi.kt new file mode 100644 index 0000000..2adaa68 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/AuthApi.kt @@ -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 +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/OcrApi.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/OcrApi.kt new file mode 100644 index 0000000..a1c0baf --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/OcrApi.kt @@ -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":"","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, +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ParseApi.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ParseApi.kt new file mode 100644 index 0000000..0bafe7d --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ParseApi.kt @@ -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 +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ParseDto.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ParseDto.kt new file mode 100644 index 0000000..16fc998 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ParseDto.kt @@ -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 = 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 = emptyList(), +) + +@Serializable +data class QuickQuoteResponse( + val title: String, + @SerialName("typical_price") val typicalPrice: Double, + @SerialName("cluster_id") val clusterId: Long? = null, +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ProtectApi.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ProtectApi.kt new file mode 100644 index 0000000..895619c --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/ProtectApi.kt @@ -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, +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/WorthBuyApi.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/WorthBuyApi.kt new file mode 100644 index 0000000..fb1fc07 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/api/WorthBuyApi.kt @@ -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 = emptyList(), + @SerialName("typical_price") val typicalPrice: Double, + @SerialName("best_time") val bestTime: String = "", +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/AppDatabase.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/AppDatabase.kt new file mode 100644 index 0000000..03774b3 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/AppDatabase.kt @@ -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 +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/Cluster.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/Cluster.kt new file mode 100644 index 0000000..92e89dc --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/Cluster.kt @@ -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, +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/ClusterDao.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/ClusterDao.kt new file mode 100644 index 0000000..7857dd1 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/ClusterDao.kt @@ -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 + + @Query("SELECT * FROM cluster ORDER BY createdAt DESC") + fun observeAll(): Flow> + + @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 +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceAlert.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceAlert.kt new file mode 100644 index 0000000..0ee1a91 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceAlert.kt @@ -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" // 已买到 / 已申请价保,归档 +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceAlertDao.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceAlertDao.kt new file mode 100644 index 0000000..c9612b3 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceAlertDao.kt @@ -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> + + @Query("SELECT * FROM price_alert WHERE mode = :mode ORDER BY createdAt DESC") + fun observeByMode(mode: String): Flow> + + /** 后台批量查价只跑 active 的 */ + @Query("SELECT * FROM price_alert WHERE status = 'active' ORDER BY createdAt DESC") + suspend fun getActive(): List + + @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 + + /** active 且已到价(lastSavings>0)条数 — 雷达 Tab 角标 / 首屏提示 */ + @Query(""" + SELECT COUNT(*) FROM price_alert + WHERE status = 'active' AND lastSavings IS NOT NULL AND lastSavings > 0 + """) + fun observeReadyCount(): Flow + + /** 后台查完批量更新差价字段,不动基础字段 */ + @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() +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceRecord.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceRecord.kt new file mode 100644 index 0000000..072299b --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceRecord.kt @@ -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, +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceRecordDao.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceRecordDao.kt new file mode 100644 index 0000000..902c9a8 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/db/PriceRecordDao.kt @@ -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> + + @Query("SELECT * FROM price_record WHERE title LIKE '%' || :q || '%' ORDER BY createdAt DESC") + fun search(q: String): Flow> + + @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> + + @Query("SELECT * FROM price_record WHERE clusterId = :clusterId ORDER BY createdAt DESC") + suspend fun byCluster(clusterId: Long): List + + @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() +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/repo/PriceRepository.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/repo/PriceRepository.kt new file mode 100644 index 0000000..34e0a81 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/data/repo/PriceRepository.kt @@ -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 = 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, + 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, + 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 { + val wants = alertDao.getActive().filter { it.mode == AlertMode.WANT } + if (wants.isEmpty()) return emptyList() + val hits = mutableListOf() + 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? = 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 = 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> = recordDao.observeAll() + fun searchRecords(q: String): Flow> = 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> = + recordDao.observeByCluster(clusterId) + + fun observeAllClusters(): Flow> = clusterDao.observeAll() + suspend fun currentClusters(): List = 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> = alertDao.observeAll() + fun observeAlertsByMode(mode: String): Flow> = alertDao.observeByMode(mode) + fun observeActiveAlertCount(): Flow = alertDao.observeActiveCount() + fun observeReadyAlertCount(): Flow = 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 { + val triggered = mutableListOf() + 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 +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/di/DatabaseModule.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/di/DatabaseModule.kt new file mode 100644 index 0000000..08e257d --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/di/DatabaseModule.kt @@ -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() +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/di/NetworkModule.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/di/NetworkModule.kt new file mode 100644 index 0000000..0bcc838 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/di/NetworkModule.kt @@ -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 { + 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) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/AccessibilityServiceHolder.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/AccessibilityServiceHolder.kt new file mode 100644 index 0000000..702ccc8 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/AccessibilityServiceHolder.kt @@ -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? = 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 + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/FloatingButtonView.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/FloatingButtonView.kt new file mode 100644 index 0000000..3ef081f --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/FloatingButtonView.kt @@ -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 + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/NodeDto.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/NodeDto.kt new file mode 100644 index 0000000..1ae8d74 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/NodeDto.kt @@ -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 = emptyList(), +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/TreeSerializer.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/TreeSerializer.kt new file mode 100644 index 0000000..ca283b7 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/floating/TreeSerializer.kt @@ -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() + 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) {} + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/A11yStateProbe.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/A11yStateProbe.kt new file mode 100644 index 0000000..b1daa23 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/A11yStateProbe.kt @@ -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) } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/AppForegroundProbe.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/AppForegroundProbe.kt new file mode 100644 index 0000000..3e606cf --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/AppForegroundProbe.kt @@ -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 +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/DeviceIdProvider.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/DeviceIdProvider.kt new file mode 100644 index 0000000..ce3e866 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/DeviceIdProvider.kt @@ -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 + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatPayload.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatPayload.kt new file mode 100644 index 0000000..bfce3c4 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatPayload.kt @@ -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, +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatReceiver.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatReceiver.kt new file mode 100644 index 0000000..45fee52 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatReceiver.kt @@ -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) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatScheduler.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatScheduler.kt new file mode 100644 index 0000000..65847f0 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatScheduler.kt @@ -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, + ) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatSender.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatSender.kt new file mode 100644 index 0000000..5bda3cd --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/heartbeat/HeartbeatSender.kt @@ -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() } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/push/MiPushBootstrap.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/push/MiPushBootstrap.kt new file mode 100644 index 0000000..cf20f8b --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/push/MiPushBootstrap.kt @@ -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") + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/push/MiPushReceiver.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/push/MiPushReceiver.kt new file mode 100644 index 0000000..0f8e511 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/push/MiPushReceiver.kt @@ -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) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/service/FloatingWindowService.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/service/FloatingWindowService.kt new file mode 100644 index 0000000..e55e02f --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/service/FloatingWindowService.kt @@ -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) { + 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" + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/service/PriceBotAccessibilityService.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/service/PriceBotAccessibilityService.kt new file mode 100644 index 0000000..119edde --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/service/PriceBotAccessibilityService.kt @@ -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 = 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", + ) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/DbbWidgets.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/DbbWidgets.kt new file mode 100644 index 0000000..2281ed5 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/DbbWidgets.kt @@ -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), + ) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/Haptic.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/Haptic.kt new file mode 100644 index 0000000..da892b4 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/Haptic.kt @@ -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) } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/HeroBanner.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/HeroBanner.kt new file mode 100644 index 0000000..945408e --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/HeroBanner.kt @@ -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, + 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), + ) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/ProductImageMap.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/ProductImageMap.kt new file mode 100644 index 0000000..f42d19a --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/ProductImageMap.kt @@ -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, 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 + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/UiCommon.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/UiCommon.kt new file mode 100644 index 0000000..5b192e5 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/UiCommon.kt @@ -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 = listOf("淘宝", "京东", "拼多多", "抖音") + +/** 浮窗 / 记录覆盖的全部 7 个平台。 */ +val ALL_PLATFORMS: List = 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), + ) + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/alert/AlertScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/alert/AlertScreen.kt new file mode 100644 index 0000000..fdeffbd --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/alert/AlertScreen.kt @@ -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) { + val active = remember(allAlerts) { + // 排序优先级:已到价 (lastSavings > 0) > 有查过 > 其它,各类内部按 lastCheckedAt 降序 + allAlerts + .filter { it.lastCurrentPrice != null } + .sortedWith( + compareByDescending { (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(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, + ) + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/alert/AlertViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/alert/AlertViewModel.kt new file mode 100644 index 0000000..a0ebdd8 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/alert/AlertViewModel.kt @@ -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> = + repo.observeAlertsByMode(AlertMode.WANT) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val boughts: StateFlow> = + repo.observeAlertsByMode(AlertMode.BOUGHT) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + val readyCount: StateFlow = + repo.observeReadyAlertCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + + private val _refreshing = MutableStateFlow(false) + val refreshing: StateFlow = _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 + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/arena/ArenaCategory.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/arena/ArenaCategory.kt new file mode 100644 index 0000000..795e1f7 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/arena/ArenaCategory.kt @@ -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, + 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 = listOf("餐饮外卖", "网上购物") + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/arena/ArenaScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/arena/ArenaScreen.kt new file mode 100644 index 0000000..7acb96e --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/arena/ArenaScreen.kt @@ -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) { + 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, + ) + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/arena/ArenaViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/arena/ArenaViewModel.kt new file mode 100644 index 0000000..a3259a7 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/arena/ArenaViewModel.kt @@ -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.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _input = MutableStateFlow("") + val input: StateFlow = _input.asStateFlow() + + /** 当前锁定的品类。null = 未选,走 ARENA_PLATFORMS 默认 4 大电商。 */ + private val _selectedCategory = MutableStateFlow(null) + val selectedCategory: StateFlow = _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() + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/detail/ProductDetailScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/detail/ProductDetailScreen.kt new file mode 100644 index 0000000..d942575 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/detail/ProductDetailScreen.kt @@ -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, 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, + ) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/DiscoverData.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/DiscoverData.kt new file mode 100644 index 0000000..b93b146 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/DiscoverData.kt @@ -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, + /** 可选高亮短标签(如"百亿补贴"/"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 = 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, + ), +) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/DiscoverScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/DiscoverScreen.kt new file mode 100644 index 0000000..2acc498 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/DiscoverScreen.kt @@ -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, + ), + ) + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/DiscoverViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/DiscoverViewModel.kt new file mode 100644 index 0000000..69b3eb4 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/DiscoverViewModel.kt @@ -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(null) + val selectedCategory: StateFlow = _selectedCategory.asStateFlow() + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + val items: StateFlow> = 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 + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/PriceCompareCard.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/PriceCompareCard.kt new file mode 100644 index 0000000..2db1c3d --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/discover/PriceCompareCard.kt @@ -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) + } + } + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/AboutScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/AboutScreen.kt new file mode 100644 index 0000000..c6a0a4f --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/AboutScreen.kt @@ -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) + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/DiagnosticsScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/DiagnosticsScreen.kt new file mode 100644 index 0000000..3818411 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/DiagnosticsScreen.kt @@ -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 -> "待检测" +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/DiagnosticsViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/DiagnosticsViewModel.kt new file mode 100644 index 0000000..e6e8b84 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/DiagnosticsViewModel.kt @@ -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> = _items.asStateFlow() + + private val _running = MutableStateFlow(false) + val running: StateFlow = _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().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 = 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 = 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) + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/MineScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/MineScreen.kt new file mode 100644 index 0000000..03c4e1a --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/MineScreen.kt @@ -0,0 +1,420 @@ +package com.jishisongfu.duobibi.ui.mine + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.jishisongfu.duobibi.auth.BindPhoneState +import com.jishisongfu.duobibi.auth.PhoneBindViewModel +import com.jishisongfu.duobibi.ui.HeroBanner +import com.jishisongfu.duobibi.ui.fmtYuan +import com.jishisongfu.duobibi.ui.theme.DbbColors +import com.jishisongfu.duobibi.ui.theme.DbbGradients +import com.jishisongfu.duobibi.util.AuthPrefs +import com.jishisongfu.duobibi.util.FloatingServiceLauncher +import com.jishisongfu.duobibi.util.PermissionUtil +import com.jishisongfu.duobibi.util.SettingsPrefs +import com.jishisongfu.duobibi.util.VendorAutoStart + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MineScreen( + onOpenUnitPrice: () -> Unit, + onOpenBundle: () -> Unit, + onOpenInstallment: () -> Unit, + onOpenDiagnostics: () -> Unit, + onOpenAbout: () -> Unit, + onOpenPrivacy: () -> Unit, + onOpenRecords: () -> Unit, + bindVm: PhoneBindViewModel = hiltViewModel(), + mineVm: MineViewModel = hiltViewModel(), +) { + val ctx = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val bindState by bindVm.state.collectAsStateWithLifecycle() + val mineStats by mineVm.stats.collectAsStateWithLifecycle() + + // 权限/绑定状态:source-of-truth 在系统/Prefs,旋转后必须重读最新值 — 保持 remember。 + var a11yOn by remember { mutableStateOf(PermissionUtil.accessibilityGranted(ctx)) } + var overlayOn by remember { mutableStateOf(PermissionUtil.overlayGranted(ctx)) } + var floatingOn by remember { mutableStateOf(SettingsPrefs.floatingEnabled(ctx)) } + var boundPhone by remember { mutableStateOf(AuthPrefs.phone(ctx)) } + // 弹窗打开态:用户主观状态,旋转期间不应突然关闭 — 用 Saveable。 + var unbindOpen by rememberSaveable { mutableStateOf(false) } + var clearAllOpen by rememberSaveable { mutableStateOf(false) } + var clearDemoOpen by rememberSaveable { mutableStateOf(false) } + + DisposableEffect(lifecycleOwner) { + val obs = LifecycleEventObserver { _, e -> + if (e == Lifecycle.Event.ON_RESUME) { + a11yOn = PermissionUtil.accessibilityGranted(ctx) + overlayOn = PermissionUtil.overlayGranted(ctx) + FloatingServiceLauncher.startIfReady(ctx) + } + } + lifecycleOwner.lifecycle.addObserver(obs) + onDispose { lifecycleOwner.lifecycle.removeObserver(obs) } + } + LaunchedEffect(bindState) { + if (bindState is BindPhoneState.Success) boundPhone = AuthPrefs.phone(ctx) + } + + Scaffold( + containerColor = DbbColors.Bg, + contentWindowInsets = WindowInsets(0), + ) { padding -> + Column( + Modifier + .padding(padding) + .fillMaxSize() + .background(DbbColors.Bg) + .verticalScroll(rememberScrollState()), + ) { + HeroBanner( + title = "我的", + subtitle = "账号 · 权限 · 算价工具 · 自检", + icon = Icons.Filled.Person, + gradient = DbbGradients.Mine, + ) + Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { + // 顶部 hero 个人卡(teal-dark → teal 渐变,统计取本机 Room 实时计算) + PersonHeroCard( + phone = boundPhone, + stats = mineStats, + onBind = { ctx.findActivity()?.let { bindVm.bind(it) } }, + onUnbind = { unbindOpen = true }, + ) + Spacer(Modifier.height(14.dp)) + + // 权限 + SectionLabel("权限") + SectionCard { + StatusRow("无障碍服务", if (a11yOn) "已开启 ✓" else "去开启", a11yOn) { + runCatching { ctx.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) } + } + ThinDivider() + StatusRow("悬浮窗权限", if (overlayOn) "已开启 ✓" else "去开启", overlayOn) { + runCatching { + ctx.startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${ctx.packageName}"))) + } + } + ThinDivider() + ClickRow("自启动管理", "保证浮窗与提醒不被系统杀") { VendorAutoStart.openAutoStartSettings(ctx) } + ThinDivider() + Row( + Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("比价浮窗", fontWeight = FontWeight.Medium, color = DbbColors.Ink) + Text("在购物 App 内显示比价按钮", color = DbbColors.InkTer, fontSize = 11.sp) + } + Switch( + checked = floatingOn, + onCheckedChange = { + floatingOn = it + SettingsPrefs.setFloatingEnabled(ctx, it) + if (it) FloatingServiceLauncher.startIfReady(ctx) + }, + colors = SwitchDefaults.colors(checkedTrackColor = DbbColors.Teal), + ) + } + } + Spacer(Modifier.height(14.dp)) + + // 算价工具 + SectionLabel("算价工具") + SectionCard { + EmojiRow("📏", "单价对比", "不同规格,谁更划算", onOpenUnitPrice) + ThinDivider() + EmojiRow("🛒", "凑单计算器", "看怎么凑满减最省", onOpenBundle) + ThinDivider() + EmojiRow("📉", "分期真实利率", "揭穿「免息分期」真实年化", onOpenInstallment) + } + Spacer(Modifier.height(14.dp)) + + // 数据与通用 + SectionLabel("数据与通用") + SectionCard { + EmojiRow("📋", "我的记录", "全部价格记录与商品组", onOpenRecords) + ThinDivider() + ClickRow("清除示例数据", "仅删内置示例") { clearDemoOpen = true } + ThinDivider() + ClickRow("运行自检", "全部正常") { onOpenDiagnostics() } + ThinDivider() + ClickRow("隐私政策", null) { onOpenPrivacy() } + ThinDivider() + ClickRow("关于多比比", null) { onOpenAbout() } + ThinDivider() + ClickRow("清空全部数据", "删除全部记录 / 商品组 / 监控", danger = true) { clearAllOpen = true } + } + Spacer(Modifier.height(20.dp)) + } + } + } + + // 绑定状态弹窗 + when (val s = bindState) { + is BindPhoneState.Working -> AlertDialog( + onDismissRequest = {}, confirmButton = {}, + title = { Text("绑定中") }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.padding(end = 12.dp)) + Text(s.step) + } + }, + ) + is BindPhoneState.Failed -> AlertDialog( + onDismissRequest = bindVm::reset, + confirmButton = { TextButton(onClick = bindVm::reset) { Text("知道了") } }, + title = { Text("绑定失败") }, text = { Text(s.reason) }, + ) + is BindPhoneState.Success -> AlertDialog( + onDismissRequest = bindVm::reset, + confirmButton = { TextButton(onClick = bindVm::reset) { Text("好的") } }, + title = { Text("绑定成功") }, text = { Text("已绑定 ${AuthPrefs.maskedPhone(s.phone)}") }, + ) + else -> {} + } + if (unbindOpen) AlertDialog( + onDismissRequest = { unbindOpen = false }, + title = { Text("解绑手机号?") }, text = { Text("解绑后本机数据保留,可随时重新绑定。") }, + confirmButton = { + TextButton(onClick = { + AuthPrefs.clear(ctx); boundPhone = null; unbindOpen = false + }) { Text("解绑", color = DbbColors.Up) } + }, + dismissButton = { TextButton(onClick = { unbindOpen = false }) { Text("取消") } }, + ) + if (clearDemoOpen) AlertDialog( + onDismissRequest = { clearDemoOpen = false }, + title = { Text("清除示例数据?") }, text = { Text("仅删除内置示例,你自己记的真实数据不受影响。") }, + confirmButton = { + TextButton(onClick = { + clearDemoOpen = false; mineVm.clearDemo {} + }) { Text("清除", color = DbbColors.Up) } + }, + dismissButton = { TextButton(onClick = { clearDemoOpen = false }) { Text("取消") } }, + ) + if (clearAllOpen) AlertDialog( + onDismissRequest = { clearAllOpen = false }, + title = { Text("清空全部数据?") }, text = { Text("将删除全部价格记录、商品组与降价监控,不可恢复。") }, + confirmButton = { + TextButton(onClick = { + clearAllOpen = false; mineVm.clearAll {} + }) { Text("清空", color = DbbColors.Up) } + }, + dismissButton = { TextButton(onClick = { clearAllOpen = false }) { Text("取消") } }, + ) +} + +/** + * Hero 渐变个人卡 + 4 栏 stat。统计全部来自 [MineStats] 的本机 Room 计算,空库时为 0。 + * 渐变改成 teal-dark→teal,去掉 sky 色,与新设计语言"3 色为主"对齐。 + */ +@Composable +private fun PersonHeroCard( + phone: String?, + stats: MineStats, + onBind: () -> Unit, + onUnbind: () -> Unit, +) { + val gradient = Brush.linearGradient(listOf(DbbColors.TealDark, DbbColors.Teal)) + val masked = phone?.let { AuthPrefs.maskedPhone(it) } ?: "未绑定手机号" + val sub = if (phone != null) "已绑定 · 多端同步已开启" else "点击绑定开启多端同步" + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(gradient) + .padding(15.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(50.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.22f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Filled.Person, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp)) + } + Spacer(Modifier.width(13.dp)) + Column( + modifier = Modifier + .weight(1f) + .clickable { if (phone == null) onBind() else onUnbind() }, + ) { + Text(masked, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 17.sp) + Text(sub, color = Color.White.copy(alpha = 0.9f), fontSize = 12.sp) + } + IconButton(onClick = { /* TODO 设置 */ }) { + Icon(Icons.Filled.Settings, contentDescription = "设置", tint = Color.White) + } + } + Spacer(Modifier.height(12.dp)) + Row { + StatColumn(stats.totalProducts.toString(), "已比价", Modifier.weight(1f)) + VerticalLine() + StatColumn(stats.totalRecords.toString(), "价格记录", Modifier.weight(1f)) + VerticalLine() + StatColumn(stats.activeAlerts.toString(), "监控中", Modifier.weight(1f)) + VerticalLine() + StatColumn("¥${fmtYuan(stats.totalSaved)}", "累计省", Modifier.weight(1f)) + } + } +} + +@Composable +private fun StatColumn(value: String, label: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(value, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 17.sp) + Text(label, color = Color.White.copy(alpha = 0.85f), fontSize = 11.sp) + } +} + +@Composable +private fun VerticalLine() { + Box( + Modifier + .width(1.dp) + .height(28.dp) + .background(Color.White.copy(alpha = 0.25f)), + ) +} + +@Composable +private fun SectionLabel(title: String) { + Text( + title, + color = DbbColors.InkSec, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 4.dp, bottom = 6.dp), + ) +} + +@Composable +private fun SectionCard(content: @Composable () -> Unit) { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = DbbColors.Card), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(16.dp), + ) { Column { content() } } +} + +@Composable +private fun ThinDivider() { + Divider(color = DbbColors.Border, thickness = 1.dp, modifier = Modifier.padding(horizontal = 14.dp)) +} + +@Composable +private fun ClickRow(title: String, subtitle: String?, danger: Boolean = false, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.Medium, color = if (danger) DbbColors.Up else DbbColors.Ink) + subtitle?.let { Text(it, color = DbbColors.InkTer, fontSize = 11.sp) } + } + Text("›", color = DbbColors.InkTer, fontSize = 18.sp) + } +} + +@Composable +private fun StatusRow(title: String, status: String, ok: Boolean, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, fontWeight = FontWeight.Medium, color = DbbColors.Ink, modifier = Modifier.weight(1f)) + Text(status, color = if (ok) DbbColors.Save else DbbColors.Teal, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun EmojiRow(emoji: String, title: String, subtitle: String, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(emoji, fontSize = 18.sp, modifier = Modifier.width(28.dp)) + Column(Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.Medium, color = DbbColors.Ink) + Text(subtitle, color = DbbColors.InkTer, fontSize = 11.sp) + } + Text("›", color = DbbColors.InkTer, fontSize = 18.sp) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/MineViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/MineViewModel.kt new file mode 100644 index 0000000..4d41465 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/mine/MineViewModel.kt @@ -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 = 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() + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/nav/AppNavHost.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/nav/AppNavHost.kt new file mode 100644 index 0000000..8e82475 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/nav/AppNavHost.kt @@ -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, + ), + ) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/onboarding/OnboardingScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..aa4e922 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/onboarding/OnboardingScreen.kt @@ -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) + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/privacy/PrivacyConsentScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/privacy/PrivacyConsentScreen.kt new file mode 100644 index 0000000..b302dcd --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/privacy/PrivacyConsentScreen.kt @@ -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)) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/privacy/PrivacyPolicyScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/privacy/PrivacyPolicyScreen.kt new file mode 100644 index 0000000..3ce8d64 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/privacy/PrivacyPolicyScreen.kt @@ -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)) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/ClusterDetailScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/ClusterDetailScreen.kt new file mode 100644 index 0000000..05baac3 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/ClusterDetailScreen.kt @@ -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(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, + minP: Double, + maxP: Double, + typical: Double?, + sortedAsc: List, +) { + 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, 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, sortedAsc: List) { + 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, 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()) diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/ClusterDetailViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/ClusterDetailViewModel.kt new file mode 100644 index 0000000..0ea6d8b --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/ClusterDetailViewModel.kt @@ -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(null) + val cluster: StateFlow = _cluster.asStateFlow() + + private val _clusterId = MutableStateFlow(null) + + val records: StateFlow> = _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() } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/NewRecordScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/NewRecordScreen.kt new file mode 100644 index 0000000..e99bac1 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/NewRecordScreen.kt @@ -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) + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/NewRecordViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/NewRecordViewModel.kt new file mode 100644 index 0000000..da16590 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/NewRecordViewModel.kt @@ -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> = 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() + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/PriceTrendChart.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/PriceTrendChart.kt new file mode 100644 index 0000000..5685c42 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/PriceTrendChart.kt @@ -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, 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) + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/RecordsScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/RecordsScreen.kt new file mode 100644 index 0000000..b261d57 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/RecordsScreen.kt @@ -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) { + 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, + ) + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/RecordsViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/RecordsViewModel.kt new file mode 100644 index 0000000..9223209 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/records/RecordsViewModel.kt @@ -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, + val typicalPrice: Double?, + val latestAt: Long, +) + +@HiltViewModel +class RecordsViewModel @Inject constructor( + private val repo: PriceRepository, +) : ViewModel() { + + private val _query = MutableStateFlow("") + val query: StateFlow = _query + + val groups: StateFlow> = 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 = 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 } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/theme/Color.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/theme/Color.kt new file mode 100644 index 0000000..aac9ea4 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/theme/Color.kt @@ -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) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/theme/Theme.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/theme/Theme.kt new file mode 100644 index 0000000..cfcc494 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/theme/Theme.kt @@ -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) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/tools/BundleCalculatorScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/tools/BundleCalculatorScreen.kt new file mode 100644 index 0000000..7da0a29 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/tools/BundleCalculatorScreen.kt @@ -0,0 +1,267 @@ +package com.jishisongfu.duobibi.ui.tools + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.jishisongfu.duobibi.ui.DbbProgressBar +import com.jishisongfu.duobibi.ui.TagChip +import com.jishisongfu.duobibi.ui.fmtYuan +import com.jishisongfu.duobibi.ui.theme.DbbColors + +private data class Tier(val threshold: Double, val reduction: Double) + +private val TIERS = listOf( + Tier(300.0, 50.0), Tier(600.0, 100.0), Tier(1000.0, 200.0), Tier(1500.0, 300.0), +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BundleCalculatorScreen(onBack: () -> Unit) { + var cartText by rememberSaveable { mutableStateOf("") } + + Scaffold( + containerColor = DbbColors.Bg, + topBar = { CalcBar("凑单计算器", onBack) }, + ) { padding -> + Column( + Modifier + .padding(padding) + .fillMaxSize() + .background(DbbColors.Bg) + .verticalScroll(rememberScrollState()) + .padding(14.dp), + ) { + // 顶部说明(teal 浅底,去 sky) + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(DbbColors.TealBg) + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + Text( + "阶梯满减:满 300−50 / 满 600−100 / 满 1000−200 / 满 1500−300。看看再凑一点是不是反而更省。", + color = DbbColors.TealDark, fontSize = 12.sp, + ) + } + Spacer(Modifier.height(12.dp)) + + // 输入框 + OutlinedTextField( + value = cartText, + onValueChange = { cartText = it.numeric() }, + label = { Text("当前购物车金额(元)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + ) + Spacer(Modifier.height(12.dp)) + + val cart = cartText.toDoubleOrNull() + if (cart != null && cart > 0) { + val hit = TIERS.filter { cart >= it.threshold }.maxByOrNull { it.threshold } + val next = TIERS.firstOrNull { it.threshold > cart } + val currentFinal = if (hit != null) cart - hit.reduction else cart + + // 当前实付卡(绿色) + CurrentPayCard(currentFinal, hit) + Spacer(Modifier.height(12.dp)) + + // 进度 + 阶梯 + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = DbbColors.Card), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(16.dp), + ) { + Column(Modifier.padding(14.dp)) { + if (next != null) { + ProgressBlock(cart, next) + Spacer(Modifier.height(13.dp)) + Box(Modifier.fillMaxWidth().height(1.dp).background(DbbColors.Border)) + Spacer(Modifier.height(13.dp)) + } + Text("满减阶梯", fontWeight = FontWeight.Bold, color = DbbColors.Ink, fontSize = 13.sp) + Spacer(Modifier.height(8.dp)) + TIERS.forEach { t -> + TierRow( + t = t, + cart = cart, + isCurrent = hit?.threshold == t.threshold, + isNext = next?.threshold == t.threshold, + ) + Spacer(Modifier.height(2.dp)) + } + } + } + Spacer(Modifier.height(12.dp)) + + // 凑单建议(teal 浅底带深 teal 边,去 gold) + if (next != null) { + val gap = next.threshold - cart + val nextFinal = (cart + gap) - next.reduction + val diff = currentFinal - nextFinal + val msg = if (diff > 0) + "💡 再凑 ¥${fmtYuan(gap)} 到「满 ${fmtYuan(next.threshold)} 减 ${fmtYuan(next.reduction)}」,实付反而少花 ¥${fmtYuan(diff)}!" + else + "再凑 ¥${fmtYuan(gap)} 到下一档,实付会多花 ¥${fmtYuan(-diff)},不划算。" + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(if (diff > 0) DbbColors.TealBg else DbbColors.Fill) + .border( + width = if (diff > 0) 1.dp else 0.dp, + color = if (diff > 0) DbbColors.Teal else Color.Transparent, + shape = RoundedCornerShape(14.dp), + ) + .padding(14.dp), + ) { + Text( + msg, + color = if (diff > 0) DbbColors.TealDark else DbbColors.InkSec, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + ) + } + } + Spacer(Modifier.height(20.dp)) + } + } + } +} + +@Composable +private fun CurrentPayCard(currentFinal: Double, hit: Tier?) { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(DbbColors.SaveBg) + .padding(14.dp), + ) { + Row(verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text("当前实付", color = DbbColors.InkSec, fontSize = 12.sp) + Spacer(Modifier.height(2.dp)) + Text( + "¥${fmtYuan(currentFinal)}", + fontWeight = FontWeight.ExtraBold, + fontSize = 24.sp, + color = DbbColors.Save, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text("满减优惠", color = DbbColors.InkTer, fontSize = 11.sp) + Spacer(Modifier.height(2.dp)) + val text = if (hit != null) + "已减 ¥${fmtYuan(hit.reduction)}" + else + "未达门槛" + val color = if (hit != null) DbbColors.Save else DbbColors.InkSec + Text(text, color = color, fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + } + } +} + +@Composable +private fun ProgressBlock(cart: Double, next: Tier) { + val gap = next.threshold - cart + val frac = (cart / next.threshold).toFloat().coerceIn(0f, 1f) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "距「满 ${fmtYuan(next.threshold)} 减 ${fmtYuan(next.reduction)}」", + color = DbbColors.Ink, fontWeight = FontWeight.Bold, fontSize = 13.sp, + modifier = Modifier.weight(1f), + ) + Text( + "还差 ¥${fmtYuan(gap)}", + color = DbbColors.Up, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, + ) + } + Spacer(Modifier.height(8.dp)) + DbbProgressBar(fraction = frac, color = DbbColors.Teal) + Spacer(Modifier.height(5.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("¥0", color = DbbColors.InkTer, fontSize = 11.sp) + Spacer(Modifier.weight(1f)) + Text( + "¥${fmtYuan(cart)} / ¥${fmtYuan(next.threshold)}", + color = DbbColors.Teal, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, + ) + Spacer(Modifier.weight(1f)) + Text("¥${fmtYuan(next.threshold)}", color = DbbColors.InkTer, fontSize = 11.sp) + } +} + +@Composable +private fun TierRow(t: Tier, cart: Double, isCurrent: Boolean, isNext: Boolean) { + // 本档实付:若 cart 已 ≥ 门槛,实付=cart-reduction;否则按刚好凑齐计算 + val realPay = if (cart >= t.threshold) cart - t.reduction else t.threshold - t.reduction + val bg = if (isCurrent) DbbColors.TealBg else Color.Transparent + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(bg) + .padding(horizontal = 10.dp, vertical = 7.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "满 ${fmtYuan(t.threshold)} 减 ${fmtYuan(t.reduction)}", + color = if (isCurrent) DbbColors.TealDark else DbbColors.InkSec, + fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal, + fontSize = 13.sp, + modifier = Modifier.weight(1f), + ) + Text( + "实付 ¥${fmtYuan(realPay)}", + color = if (isCurrent) DbbColors.TealDark else DbbColors.InkTer, + fontSize = 12.sp, + ) + if (isCurrent) { + Spacer(Modifier.width(8.dp)) + TagChip("当前档", bg = DbbColors.Teal, fg = Color.White) + } else if (isNext) { + Spacer(Modifier.width(8.dp)) + TagChip("下一档", bg = DbbColors.Teal, fg = Color.White) + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/tools/InstallmentCalculatorScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/tools/InstallmentCalculatorScreen.kt new file mode 100644 index 0000000..eacbefa --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/tools/InstallmentCalculatorScreen.kt @@ -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 +} + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/tools/UnitPriceCalculatorScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/tools/UnitPriceCalculatorScreen.kt new file mode 100644 index 0000000..71eb8f0 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/tools/UnitPriceCalculatorScreen.kt @@ -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 == '.' } diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/watched/WatchedProductsScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/watched/WatchedProductsScreen.kt new file mode 100644 index 0000000..ef7d9b5 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/watched/WatchedProductsScreen.kt @@ -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, + ) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/worth/PriceHistory.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/worth/PriceHistory.kt new file mode 100644 index 0000000..de47370 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/worth/PriceHistory.kt @@ -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 { + 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, 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 = 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) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/worth/WorthBuyScreen.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/worth/WorthBuyScreen.kt new file mode 100644 index 0000000..527d614 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/worth/WorthBuyScreen.kt @@ -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, 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) + } + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/worth/WorthBuyViewModel.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/worth/WorthBuyViewModel.kt new file mode 100644 index 0000000..317f173 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/ui/worth/WorthBuyViewModel.kt @@ -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.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _title = MutableStateFlow("") + val title: StateFlow = _title.asStateFlow() + + private val _price = MutableStateFlow("") + val price: StateFlow = _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() + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/AlertNotifier.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/AlertNotifier.kt new file mode 100644 index 0000000..ebc4cdd --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/AlertNotifier.kt @@ -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) { + 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) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/AuthPrefs.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/AuthPrefs.kt new file mode 100644 index 0000000..9c927b4 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/AuthPrefs.kt @@ -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) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/Constants.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/Constants.kt new file mode 100644 index 0000000..62f95df --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/Constants.kt @@ -0,0 +1,26 @@ +package com.jishisongfu.duobibi.util + +object Constants { + + /** + * 比价浮窗只在这 7 个购物 App 下显示。无障碍服务收到任意包名的窗口切换事件后, + * 用这个集合判断是否让浮窗显示。 + */ + val WHITELIST_PACKAGES: Set = 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 +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/CsvExporter.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/CsvExporter.kt new file mode 100644 index 0000000..592e0f1 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/CsvExporter.kt @@ -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, + clusters: List, + ): 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, + clusterById: Map, + ): 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//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) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/DemoDataPrefs.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/DemoDataPrefs.kt new file mode 100644 index 0000000..3da9cac --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/DemoDataPrefs.kt @@ -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) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/DemoDataSeeder.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/DemoDataSeeder.kt new file mode 100644 index 0000000..8edd219 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/DemoDataSeeder.kt @@ -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)") + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/FloatingServiceLauncher.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/FloatingServiceLauncher.kt new file mode 100644 index 0000000..8b754d5 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/FloatingServiceLauncher.kt @@ -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), + ) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/ImageCompressor.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/ImageCompressor.kt new file mode 100644 index 0000000..44ee742 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/ImageCompressor.kt @@ -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 Bitmap.use(block: (Bitmap) -> R): R { + try { + return block(this) + } finally { + if (!isRecycled) recycle() + } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/OnboardingPrefs.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/OnboardingPrefs.kt new file mode 100644 index 0000000..6c416fe --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/OnboardingPrefs.kt @@ -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() + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/PermissionUtil.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/PermissionUtil.kt new file mode 100644 index 0000000..ca37bcf --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/PermissionUtil.kt @@ -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) +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/PlatformSearch.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/PlatformSearch.kt new file mode 100644 index 0000000..f5fb1b2 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/PlatformSearch.kt @@ -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 = 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 + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/PrivacyPrefs.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/PrivacyPrefs.kt new file mode 100644 index 0000000..8031420 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/PrivacyPrefs.kt @@ -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() + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/SettingsPrefs.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/SettingsPrefs.kt new file mode 100644 index 0000000..781c824 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/SettingsPrefs.kt @@ -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? { + 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() + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/StatsCalculator.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/StatsCalculator.kt new file mode 100644 index 0000000..f1ea5d0 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/StatsCalculator.kt @@ -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, +) + +/** + * 全局统计快照。 + */ +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, + val topSpread: List, + val recordsToday: Int, +) + +object StatsCalculator { + + fun computeOverall( + clusters: List, + records: List, + 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, windowDays: Int?, nowMillis: Long = System.currentTimeMillis()): List { + 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 + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/VendorAutoStart.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/VendorAutoStart.kt new file mode 100644 index 0000000..d454d74 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/VendorAutoStart.kt @@ -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 = 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", + ) + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/WatchedPrefs.kt b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/WatchedPrefs.kt new file mode 100644 index 0000000..b9d84e0 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/java/com/jishisongfu/duobibi/util/WatchedPrefs.kt @@ -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 = + 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> = 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) } + } +} diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/dialog_bg.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/dialog_bg.xml new file mode 100644 index 0000000..a8cddb1 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/dialog_bg.xml @@ -0,0 +1,4 @@ + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_daily.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_daily.xml new file mode 100644 index 0000000..115074e --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_daily.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_digital.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_digital.xml new file mode 100644 index 0000000..bd09d90 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_digital.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_ecommerce.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_ecommerce.xml new file mode 100644 index 0000000..61ecb25 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_ecommerce.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_fashion.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_fashion.xml new file mode 100644 index 0000000..319d687 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_fashion.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_grocery.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_grocery.xml new file mode 100644 index 0000000..73257d2 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_grocery.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_takeaway.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_takeaway.xml new file mode 100644 index 0000000..7544e11 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_cat_takeaway.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_floating_button.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_floating_button.xml new file mode 100644 index 0000000..8d8be3e --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_floating_button.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_launcher_foreground.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c157f19 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_capsule.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_capsule.xml new file mode 100644 index 0000000..e6588d5 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_capsule.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_console.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_console.xml new file mode 100644 index 0000000..1e63803 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_console.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_default.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_default.xml new file mode 100644 index 0000000..0af8f1a --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_default.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_earbuds.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_earbuds.xml new file mode 100644 index 0000000..a66f4f8 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_earbuds.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_hairdryer.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_hairdryer.xml new file mode 100644 index 0000000..9be7870 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_hairdryer.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_phone.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_phone.xml new file mode 100644 index 0000000..7411112 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/ic_prod_phone.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_airpods.png b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_airpods.png new file mode 100644 index 0000000..1c9de7f Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_airpods.png differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_beef_rice.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_beef_rice.jpg new file mode 100644 index 0000000..766ed7a Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_beef_rice.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_dyson_hd.png b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_dyson_hd.png new file mode 100644 index 0000000..bd9e375 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_dyson_hd.png differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_dyson_vacuum.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_dyson_vacuum.webp new file mode 100644 index 0000000..aadfe8d Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_dyson_vacuum.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_eggs.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_eggs.jpg new file mode 100644 index 0000000..1714c05 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_eggs.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_estee_lauder.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_estee_lauder.jpg new file mode 100644 index 0000000..a6be329 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_estee_lauder.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_greens.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_greens.webp new file mode 100644 index 0000000..8a5b770 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_greens.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_haidilao.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_haidilao.webp new file mode 100644 index 0000000..9f1b797 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_haidilao.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_haier_cup.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_haier_cup.webp new file mode 100644 index 0000000..2b1e56a Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_haier_cup.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_hamburger.png b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_hamburger.png new file mode 100644 index 0000000..53ab8fd Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_hamburger.png differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_heytea.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_heytea.jpg new file mode 100644 index 0000000..e3e6e1a Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_heytea.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_ipad_air.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_ipad_air.jpg new file mode 100644 index 0000000..c838e4e Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_ipad_air.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_iphone_blue.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_iphone_blue.webp new file mode 100644 index 0000000..d052e2b Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_iphone_blue.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_jdb.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_jdb.jpg new file mode 100644 index 0000000..c3f09c0 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_jdb.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_kfc.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_kfc.jpg new file mode 100644 index 0000000..fc364f3 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_kfc.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_lancome_eye.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_lancome_eye.jpg new file mode 100644 index 0000000..6f5adb0 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_lancome_eye.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_lululemon.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_lululemon.webp new file mode 100644 index 0000000..fa8c409 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_lululemon.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_macbook.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_macbook.jpg new file mode 100644 index 0000000..a4264c5 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_macbook.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_merries.png b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_merries.png new file mode 100644 index 0000000..b375950 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_merries.png differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_midea_kettle.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_midea_kettle.jpg new file mode 100644 index 0000000..f6af0fd Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_midea_kettle.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_moutai.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_moutai.jpg new file mode 100644 index 0000000..ccc6520 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_moutai.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_nescafe.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_nescafe.webp new file mode 100644 index 0000000..bca0e3f Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_nescafe.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_nike_af1.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_nike_af1.webp new file mode 100644 index 0000000..3ad5177 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_nike_af1.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_philips_shaver.png b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_philips_shaver.png new file mode 100644 index 0000000..55f3c07 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_philips_shaver.png differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_philips_toothbrush.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_philips_toothbrush.jpg new file mode 100644 index 0000000..247417d Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_philips_toothbrush.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_pork_belly.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_pork_belly.jpg new file mode 100644 index 0000000..0f2a71b Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_pork_belly.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_shiseido_red.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_shiseido_red.webp new file mode 100644 index 0000000..bcace12 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_shiseido_red.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_skii.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_skii.jpg new file mode 100644 index 0000000..e506d2e Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_skii.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_squirrel.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_squirrel.jpg new file mode 100644 index 0000000..1927732 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_squirrel.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_supor_pan.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_supor_pan.webp new file mode 100644 index 0000000..3d9ede4 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_supor_pan.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_telunsu.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_telunsu.jpg new file mode 100644 index 0000000..8bc3f44 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_telunsu.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_vinda_tissue.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_vinda_tissue.webp new file mode 100644 index 0000000..de9c8eb Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_vinda_tissue.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_xiaomi14pro.jpg b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_xiaomi14pro.jpg new file mode 100644 index 0000000..81f7f68 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_xiaomi14pro.jpg differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_zhxg.webp b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_zhxg.webp new file mode 100644 index 0000000..1842763 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/img_zhxg.webp differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_back.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_back.xml new file mode 100644 index 0000000..de16d55 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_back.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_bg.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_bg.xml new file mode 100644 index 0000000..89e31d9 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_bg.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_login_btn.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_login_btn.xml new file mode 100644 index 0000000..88138a8 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_login_btn.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_logo.png b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_logo.png new file mode 100644 index 0000000..783abdc Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/app/src/main/res/drawable/jverify_logo.png differ diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/values/colors.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1990786 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + + #0EA5A4 + #0EA5A4 + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/values/strings.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..cc0aa24 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/values/strings.xml @@ -0,0 +1,30 @@ + + + 多比比 + + + 多比比比价助手 + 开启后,在淘宝 / 京东 / 拼多多 / 抖音 等购物页点击多比比浮窗,即可一键记录当前商品到手价并加入比价擂台。仅在你主动点击时读取当前页面价格信息,不会在后台持续采集。 + + + 比价浮窗 + 多比比 · 比价中 + 在购物页点蓝色浮窗,一键记价加入擂台 + + + 请在购物 App 商品详情页点击记价 + 识别失败,请重试 + 已记 ¥%1$s(%2$s)· 比常见价 ¥%3$s 便宜 ¥%4$s ↓ + 已记 ¥%1$s(%2$s)· 比常见价 ¥%3$s 贵 ¥%4$s ↑ + 已记 ¥%1$s(%2$s)· 与常见价 ¥%3$s 持平 + 已记 ¥%1$s(%2$s)· 比擂台最低还便宜 ¥%3$s ↓ + 已记 ¥%1$s(%2$s)· 比擂台最低贵 ¥%3$s + 已记 ¥%1$s(%2$s)· 与擂台最低持平 + 已记 ¥%1$s(%2$s)· 新商品已加入擂台 + + + 降价提醒 + + + 未能自动跳转,请到 系统设置 → 应用 → 多比比 → 自启动 手动开启 + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/values/themes.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..247b615 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/values/themes.xml @@ -0,0 +1,13 @@ + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/accessibility_service_config.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..801ded1 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,9 @@ + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/backup_rules.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..809eaac --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/data_extraction_rules.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..a0f223f --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/network_security_config.xml b/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..617a3b0 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,15 @@ + + + + + 192.168.0.109 + + 127.0.0.1 + + 10.0.2.2 + + diff --git a/duobibi(mock)Android/duobibi-mock/build.gradle.kts b/duobibi(mock)Android/duobibi-mock/build.gradle.kts new file mode 100644 index 0000000..82e52d3 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/duobibi(mock)Android/duobibi-mock/gradle.properties b/duobibi(mock)Android/duobibi-mock/gradle.properties new file mode 100644 index 0000000..b73c348 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +android.useAndroidX=true +android.nonTransitiveRClass=true + +kotlin.code.style=official diff --git a/duobibi(mock)Android/duobibi-mock/gradle/libs.versions.toml b/duobibi(mock)Android/duobibi-mock/gradle/libs.versions.toml new file mode 100644 index 0000000..d287d6b --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/gradle/libs.versions.toml @@ -0,0 +1,60 @@ +[versions] +agp = "8.9.0" +kotlin = "2.1.0" +ksp = "2.1.0-1.0.29" + +coreKtx = "1.15.0" +lifecycle = "2.8.7" +activityCompose = "1.9.3" +composeBom = "2024.12.01" +navigationCompose = "2.8.5" + +room = "2.6.1" +hilt = "2.53.1" +hiltNavigation = "1.2.0" + +retrofit = "2.11.0" +okhttp = "4.12.0" +kotlinxSerialization = "1.7.3" +retrofitSerialization = "1.0.0" + +coroutines = "1.9.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } + +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" } + +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitSerialization" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/duobibi(mock)Android/duobibi-mock/gradle/wrapper/gradle-wrapper.jar b/duobibi(mock)Android/duobibi-mock/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/duobibi(mock)Android/duobibi-mock/gradle/wrapper/gradle-wrapper.jar differ diff --git a/duobibi(mock)Android/duobibi-mock/gradle/wrapper/gradle-wrapper.properties b/duobibi(mock)Android/duobibi-mock/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/duobibi(mock)Android/duobibi-mock/gradlew b/duobibi(mock)Android/duobibi-mock/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/duobibi(mock)Android/duobibi-mock/gradlew.bat b/duobibi(mock)Android/duobibi-mock/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/duobibi(mock)Android/duobibi-mock/settings.gradle.kts b/duobibi(mock)Android/duobibi-mock/settings.gradle.kts new file mode 100644 index 0000000..1d935e5 --- /dev/null +++ b/duobibi(mock)Android/duobibi-mock/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "duobibi" +include(":app") diff --git a/duobibi(mock)backend/duobibi-server/.gitignore b/duobibi(mock)backend/duobibi-server/.gitignore new file mode 100644 index 0000000..2827330 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ +.env +*.egg-info/ +.coverage +htmlcov/ +.idea/ +.vscode/ +dist/ +build/ diff --git a/duobibi(mock)backend/duobibi-server/README.md b/duobibi(mock)backend/duobibi-server/README.md new file mode 100644 index 0000000..b097f53 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/README.md @@ -0,0 +1,230 @@ +# duobibi-server + +多比比(占坑版)后端 — 比价 / 估价服务。 + +> 与傻瓜比价后端**同一套代码**,部署到独立域名 `api.duobibi.com`(端口 8766,与旧 8765 并存)。 +> 新增两个多比比专属端点:`/arena-quote`(比价擂台)、`/worth-buy`(AI 值不值得买)。 +> 占坑期 LLM key 与极光 key 仍硬编码,上线前迁移到环境变量。 + +## 技术栈 + +- Python 3.11+ +- FastAPI 0.115+ / uvicorn (standard) / pydantic 2.9+ +- 智谱 zai-sdk(`glm-5-turbo`,温度 0.0,确定性输出) +- SQLite(WAL 模式,单文件) +- cryptography(极光 RSA 解密手机号) +- pytest + pytest-asyncio + httpx(测试) + +## 工程结构 + +``` +duobibi-server/ +├── app/ +│ ├── main.py # FastAPI 应用入口,所有 router 注册,启动时建表 +│ ├── schemas.py # 全部请求/响应 Pydantic DTO +│ ├── db.py # SQLite 连接管理 + WAL + 表初始化 +│ ├── llm_client.py # 智谱客户端,DUOBIBI_MOCK_LLM 开关 +│ ├── extractor.py # Android 控件树 → LLM → title/price 提取 +│ ├── mock_extractor.py # 占坑期预置商品池(确定性哈希选) +│ └── api/ +│ ├── parse.py # POST /api/v1/parse Android 无障碍树识别 +│ ├── parse_image.py # POST /api/v1/parse-image iOS 截图 OCR +│ ├── parse_text.py # POST /api/v1/parse-text iOS 文本新建 +│ ├── parse_ocr.py # POST /api/v1/parse-ocr OCR 文本识别(补齐缺失) +│ ├── quick_quote.py # POST /api/v1/quick-quote 用户主动比价查询 +│ ├── arena.py # POST /api/v1/arena-quote 比价擂台(多比比新增) +│ ├── worth_buy.py # POST /api/v1/worth-buy AI 值不值(多比比新增) +│ ├── protect.py # POST /api/v1/track-price 价保哨兵 +│ ├── auth.py # POST /api/v1/auth/jverify-login 极光一键登录 +│ └── wish.py # POST /api/v1/wish/{list,upsert} 心愿单同步 +├── tests/ # test_health / test_arena_worth / test_parse_image +├── deploy/ +│ ├── duobibi-mock.service # systemd unit(生产) +│ └── nginx/api.duobibi.com.conf +├── pyproject.toml +├── run-backend.ps1 # Windows 一键启动脚本 +└── data.db # SQLite(开发本地,生产在 /opt/duobibi-server/data.db) +``` + +## 接口 + +### `GET /health` + +```json +{ "status": "ok" } +``` + +### `POST /api/v1/arena-quote` — 比价擂台(多比比新增) + +请求: +```json +{ "title": "iPhone 15 Pro 256GB", "platforms": ["淘宝","京东","拼多多","抖音"] } +``` +`platforms` 可省略(默认 4 大电商)。响应: +```json +{ + "title": "iPhone 15 Pro 256GB", + "typical_price": 8299.0, + "quotes": [ + {"platform": "淘宝", "price": 8099.0, "note": ""}, + {"platform": "京东", "price": 8299.0, "note": "自营"}, + {"platform": "拼多多", "price": 7888.0, "note": "百亿补贴"}, + {"platform": "抖音", "price": 8190.0, "note": ""} + ], + "lowest_platform": "拼多多" +} +``` +- LLM 缺平台报价时,按 `(title, platform)` 哈希确定性合成,保证 4 条都齐 +- LLM 整体失败返回 422 `llm_no_quote`,不抛 5xx +- 设 `DUOBIBI_MOCK_LLM=1` 可离线测试 + +### `POST /api/v1/worth-buy` — AI 值不值得买(多比比新增) + +请求: +```json +{ "title": "iPhone 15 Pro 256GB", "price": 8099.0, "platform": "京东" } +``` +响应: +```json +{ + "score": 78, "verdict": "buy", "headline": "现在入手划算", + "reasons": ["低于市场常见价约 2%", "距下次大促还有一段时间"], + "typical_price": 8299.0, "best_time": "现在就合适" +} +``` +- `score`: 0–100,80+ → `buy` / 40–79 → `neutral` / <40 → `wait` +- LLM 抽风 / JSON 解析失败也会返回中性结论,不抛 5xx +- Mock 模式按"当前价 / 市场常见价"水位确定性算分 + +### 沿用傻瓜比价的端点 + +| 端点 | 方法 | 用途 | +|---|---|---| +| `/api/v1/parse` | POST | Android 无障碍树 → title / price / cluster_id | +| `/api/v1/parse-image` | POST(multipart)| iOS 截图 OCR(图片 ≤ 5MB) | +| `/api/v1/parse-text` | POST | iOS 文本新建 | +| `/api/v1/parse-ocr` | POST | OCR 文本识别(本工程补齐 mock 实现) | +| `/api/v1/quick-quote` | POST | 用户主动比价查询 | +| `/api/v1/track-price` | POST | 价保哨兵(确定性日抖动模拟降价) | +| `/api/v1/auth/jverify-login` | POST | 极光 loginToken → 真手机号(RSA 解密) | +| `/api/v1/wish/list` | POST | 拉取心愿单(含软删) | +| `/api/v1/wish/upsert` | POST | 批量上传心愿单修改(LWW) | + +> 修复:旧后端 `main.py` 引用 `app.api.parse_ocr` 但文件缺失会导致启动 `ImportError`; +> 本工程补齐了 `app/api/parse_ocr.py`(mock 实现),服务可正常启动。 + +## 包名 → 品牌映射 + +| 包名 | 品牌 | +|---|---| +| com.taobao.taobao | 淘宝 | +| com.jingdong.app.mall | 京东 | +| com.xunmeng.pinduoduo | 拼多多 | +| com.ss.android.ugc.aweme | 抖音 | +| com.sankuai.meituan | 美团 | +| com.sankuai.meituan.takeoutnew | 美团外卖 | +| me.ele | 饿了么 | + +非白名单包名返回 `source_app: "未知"`。 + +## 环境变量 + +| 变量 | 用途 | 默认值 | +|---|---|---| +| `DUOBIBI_DB_PATH` | SQLite 数据库路径 | `/opt/duobibi-server/data.db`(生产)/ `./data.db`(本地) | +| `DUOBIBI_MOCK_LLM` | 设为非空值 → 跳过真实 LLM,返回确定性 mock 数据 | 空(生产用真实 LLM) | +| `JG_PRIVATE_KEY_PATH` | 极光一键登录 RSA 私钥路径 | `/opt/duobibi-server/secrets/jverify_rsa_private.pem` | +| `PYTHONUTF8` | Windows 控制台 UTF-8(`run-backend.ps1` 自动设) | 空 | + +**硬编码待迁移**(`app/llm_client.py` / `app/api/auth.py`):智谱 API key、极光 App key、极光 Master Secret。 + +## 数据存储 + +SQLite 单文件 + WAL,启动时 `IF NOT EXISTS` 建表,无 migration 工具。 + +**`user`** — 占坑期登录用户 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `phone` | TEXT PK | 用户手机号(占坑期主键) | +| `created_at` | INTEGER | 创建时间(ms epoch) | +| `last_seen_at` | INTEGER | 最近一次登录时间(ms epoch) | + +**`wish_item`** — 心愿单(跨设备同步) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | INTEGER PK AUTO | 自增 | +| `phone` | TEXT FK | 用户手机号 | +| `title` / `target_price` / `note` / `notify_enabled` | — | 业务字段 | +| `created_at` / `updated_at` | INTEGER | ms epoch | +| `deleted_at` | INTEGER | NULL = 未删,非空 = 软删,防跨设备"复活" | + +索引:`idx_wish_phone_updated` on `(phone, updated_at DESC)`。 + +## 本地启动 + +**环境要求** + +- Python 3.11+ +- 默认 mock 模式无需任何 API key 即可启动(不调真实 LLM,返回确定性占位数据) + +**方式一:PowerShell 脚本(Windows 推荐)** + +```powershell +.\run-backend.ps1 # mock 模式,监听 127.0.0.1:8766 +.\run-backend.ps1 -Lan # 监听 0.0.0.0,供真机/局域网访问 +.\run-backend.ps1 -Real # 连真实智谱 LLM(需先配好 key) +.\run-backend.ps1 -Reload # 热重载,代码改动自动重启 +.\run-backend.ps1 -Port 8888 # 改端口 +``` + +**方式二:手动 pip + uvicorn** + +```bash +cd duobibi-server +pip install -e ".[dev]" +uvicorn app.main:app --reload --port 8766 +``` + +首次启动会自动在当前目录创建 `data.db`(SQLite,WAL 模式),无需手动建表。 + +**验证启动** + +```bash +curl http://localhost:8766/health +# {"status":"ok"} +``` + +**测试** + +```bash +pytest -q +``` + +**烟测核心端点** + +```bash +curl -X POST http://localhost:8766/api/v1/arena-quote \ + -H 'Content-Type: application/json' \ + -d '{"title":"iPhone 15 Pro 256GB"}' +curl -X POST http://localhost:8766/api/v1/worth-buy \ + -H 'Content-Type: application/json' \ + -d '{"title":"iPhone 15 Pro 256GB","price":8099}' +``` + +## 部署(生产) + +- 域名: `api.duobibi.com` (HTTPS) +- 进程: `uvicorn app.main:app --host 127.0.0.1 --port 8766 --workers 1`,systemd 守护 + (`deploy/duobibi-mock.service`) +- 反代: nginx → 127.0.0.1:8766(`deploy/nginx/api.duobibi.com.conf`,`client_max_body_size 6MB`) +- SQLite 单文件(`DUOBIBI_DB_PATH` env 可覆盖,默认 `/opt/duobibi-server/data.db`) +- 极光 RSA 私钥不入 git,部署时单独 scp 到 `/opt/duobibi-server/secrets/jverify_rsa_private.pem` + +## 待办(占坑期遗留) + +- `app/llm_client.py` 的智谱 API key 仍硬编码,上线前迁移到环境变量并替换为多比比自己的 key +- `app/api/auth.py` 的极光 App key / Master Secret 同样硬编码,需迁移到 env +- 接口无鉴权 / 无限流,后续加 `X-Client-Token` 头校验 + IP 限流 +- 无 Dockerfile / docker-compose,目前只支持 systemd 部署 diff --git a/duobibi(mock)backend/duobibi-server/app/__init__.py b/duobibi(mock)backend/duobibi-server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/duobibi(mock)backend/duobibi-server/app/api/__init__.py b/duobibi(mock)backend/duobibi-server/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/duobibi(mock)backend/duobibi-server/app/api/arena.py b/duobibi(mock)backend/duobibi-server/app/api/arena.py new file mode 100644 index 0000000..21f5b0e --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/arena.py @@ -0,0 +1,191 @@ +"""POST /api/v1/arena-quote —— 多比比"比价擂台"接口。 + +用户输入一个商品名,后端用 LLM 估出该商品在主流电商各平台的「到手价」 +以及「市场常见价」(中位)。客户端把估价铺成"多平台对比表",再用本机 +真实记录覆盖对应平台,最低价高亮。 + +application/json: + {"title": "iPhone 15 Pro 256GB", "platforms": ["淘宝","京东",...](可选)} + +响应: + {"title": "...(归一化)", "typical_price": 8299.0, + "quotes": [{"platform":"淘宝","price":8099.0,"note":"..."}, ...], + "lowest_platform": "拼多多"} + +兜底:LLM 给了 typical 但缺平台报价 → 围绕 typical 确定性合成,保证表格每行有值; +完全估不出价格 → 422 llm_no_quote。 +""" +from __future__ import annotations + +import hashlib +import json +import logging +import re +from typing import Optional + +from fastapi import APIRouter, HTTPException + +from app.llm_client import MOCK_LLM, chat +from app.schemas import ArenaPlatformQuote, ArenaQuoteRequest, ArenaQuoteResponse + +logger = logging.getLogger("app.arena") + +router = APIRouter(prefix="/api/v1") + +# 比价擂台默认对比 4 大主流电商(同一实体商品可跨平台买)。 +# 美团 / 饿了么是本地生活 O2O,不进默认对比池,但客户端可显式传入 platforms 覆盖。 +DEFAULT_PLATFORMS = ["淘宝", "京东", "拼多多", "抖音"] + +SYSTEM_PROMPT = """你是一个电商多平台价格估算助手。 + +# 任务 +用户给你一个商品标题和一组电商平台。请估算该商品在每个平台的「日常到手价」 +(实付价,不含双 11 / 618 等大促),以及该商品的「市场常见价」(各平台中位值)。 + +# 估算原则 +- 同一商品在不同平台价格通常有差异:拼多多 / 百亿补贴常偏低,京东自营偏高且稳, + 淘宝居中,抖音随直播波动。请给出**有合理区分度**的价格,不要每个平台都一样。 +- 不熟悉的商品按品类 / 品牌 / 规格常识估算。完全陌生时给出符合常识的合理估值。 +- 所有价格为正数(元),可带小数。 + +# 输出格式 +严格 JSON,无 markdown,无解释: + +{"title": "归一化后的商品标题", "typical_price": 8299.0, + "quotes": [{"platform": "淘宝", "price": 8099.0, "note": "一句话(可空)"}, + {"platform": "京东", "price": 8299.0, "note": ""}]} + +字段: +- title: 整理后的标题(纠错 / 统一规格写法,保留原意) +- typical_price: 市场常见价(正数,必填) +- quotes: 对**给定的每个平台**各给一条;price 正数必填,note 可空 +""" + + +def _extract_json(s: str) -> Optional[dict]: + s = s.strip() + s = re.sub(r"^```(?:json)?\s*", "", s) + s = re.sub(r"\s*```$", "", s) + try: + data = json.loads(s) + except json.JSONDecodeError: + m = re.search(r"\{.*\}", s, re.DOTALL) + if not m: + return None + try: + data = json.loads(m.group(0)) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def _num(v) -> Optional[float]: + if isinstance(v, bool): + return None + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + try: + return float(v.strip()) + except ValueError: + return None + return None + + +def _synth_price(title: str, platform: str, typical: float) -> float: + """给 LLM 没给报价的平台合成一个确定性估价(围绕 typical 的 0.93~1.06 倍)。""" + seed = int(hashlib.sha256(f"{title}|{platform}".encode("utf-8")).hexdigest()[:6], 16) + factor = 0.93 + (seed % 14) / 100.0 + return round(typical * factor, 2) + + +def _mock_raw(title: str) -> str: + """占坑期 mock(DUOBIBI_MOCK_LLM):基于标题确定性造一个市场常见价, + 各平台报价交给下游 _synth_price 围绕 typical 合成。返回 LLM 同格式 JSON。""" + seed = int(hashlib.sha256(title.encode("utf-8")).hexdigest()[:8], 16) + typical = round(49 + seed % 9950 + (seed % 100) / 100.0, 2) + return json.dumps({"title": title, "typical_price": typical}, ensure_ascii=False) + + +@router.post("/arena-quote", response_model=ArenaQuoteResponse) +def arena_quote(req: ArenaQuoteRequest) -> ArenaQuoteResponse: + title_in = req.title.strip() + if not title_in: + raise HTTPException(status_code=422, detail="empty_title") + platforms = [p.strip() for p in (req.platforms or DEFAULT_PLATFORMS) if p and p.strip()] + if not platforms: + platforms = list(DEFAULT_PLATFORMS) + + user_msg = f"商品标题: {title_in}\n平台: {', '.join(platforms)}" + if MOCK_LLM: + raw = _mock_raw(title_in) + else: + try: + raw = chat( + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ] + ) + logger.debug("arena-quote LLM raw: %s", raw[:400]) + except Exception as e: + # LLM 不可用(限流/网络/超时等):降级为空响应,下方走 422 llm_no_quote 而非 500。 + logger.warning("arena-quote LLM call failed: %s", e) + raw = "" + + data = _extract_json(raw) or {} + title_out = data.get("title") + title_out = title_out.strip() if isinstance(title_out, str) and title_out.strip() else title_in + typical = _num(data.get("typical_price")) + + # 解析 quotes,仅保留请求的平台,price 必须 > 0 + by_platform: dict[str, ArenaPlatformQuote] = {} + raw_quotes = data.get("quotes") + if isinstance(raw_quotes, list): + for q in raw_quotes: + if not isinstance(q, dict): + continue + plat = q.get("platform") + price = _num(q.get("price")) + if not isinstance(plat, str) or plat.strip() not in platforms: + continue + if price is None or price <= 0: + continue + note = q.get("note") + by_platform[plat.strip()] = ArenaPlatformQuote( + platform=plat.strip(), + price=round(price, 2), + note=note.strip() if isinstance(note, str) else "", + ) + + # typical 兜底:用已解析报价的中位值 + if (typical is None or typical <= 0) and by_platform: + prices = sorted(p.price for p in by_platform.values()) + typical = prices[len(prices) // 2] + + if typical is None or typical <= 0: + logger.info("arena-quote no usable price for %r", title_in) + raise HTTPException(status_code=422, detail="llm_no_quote") + + # 给缺失平台合成估价,保证表格每行都有值 + for plat in platforms: + if plat not in by_platform: + by_platform[plat] = ArenaPlatformQuote( + platform=plat, + price=_synth_price(title_out, plat, typical), + note="估算", + ) + + quotes = [by_platform[p] for p in platforms] + lowest = min(quotes, key=lambda q: q.price).platform if quotes else None + + logger.info( + "arena-quote title=%r typical=%.2f platforms=%d lowest=%s", + title_out, typical, len(quotes), lowest, + ) + return ArenaQuoteResponse( + title=title_out, + typical_price=round(typical, 2), + quotes=quotes, + lowest_platform=lowest, + ) diff --git a/duobibi(mock)backend/duobibi-server/app/api/auth.py b/duobibi(mock)backend/duobibi-server/app/api/auth.py new file mode 100644 index 0000000..3356e2c --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/auth.py @@ -0,0 +1,187 @@ +"""极光一键登录验证 endpoint。 + +链路: + Android 客户端 SDK loginAuth → 拿到 loginToken + → POST 本 endpoint + → 调极光 REST /v1/web/loginTokenVerify(Basic Auth = AppKey:MasterSecret) + → 极光返回 RSA 公钥加密的手机号(base64) + → 用部署在服务器上的对应 RSA 私钥解密 + → 返回明文手机号给客户端 + +验通路阶段只返回手机号,不入库、不签 session、不做 CPS 归因。 +跑通后再补持久化和绑定逻辑。 + +RSA 私钥位置:`$JG_PRIVATE_KEY_PATH` 环境变量,默认 /opt/duobibi-server/secrets/jverify_rsa_private.pem。 +私钥不入 git,部署时单独 scp 到服务器。 +""" +from __future__ import annotations + +import base64 +import json +import logging +import os +import time +import urllib.error +import urllib.request + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app import db + +logger = logging.getLogger("shagua.auth") + +router = APIRouter(prefix="/api/v1/auth") + +# 占坑期硬编码,跟 llm_client.py 的 key 风格一致(后续统一挪到 env) +JG_APP_KEY = "966b451a8d9cfe12d173ea9d" +JG_MASTER_SECRET = "dae6d856c7556ac1b8f2deda" +JG_VERIFY_ENDPOINT = "https://api.verification.jpush.cn/v1/web/loginTokenVerify" +JG_REQUEST_TIMEOUT_SEC = 15 + +JG_PRIVATE_KEY_PATH = os.environ.get( + "JG_PRIVATE_KEY_PATH", + "/opt/duobibi-server/secrets/jverify_rsa_private.pem", +) + +_private_key_cache = None + + +def _get_private_key(): + """懒加载 RSA 私钥并缓存。首次调用时如果文件不存在会抛 FileNotFoundError, + 让请求 500 + 日志,而不是 import 时炸进程。""" + global _private_key_cache + if _private_key_cache is None: + with open(JG_PRIVATE_KEY_PATH, "rb") as f: + _private_key_cache = serialization.load_pem_private_key(f.read(), password=None) + return _private_key_cache + + +class JverifyLoginRequest(BaseModel): + login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken") + operator: str = Field(..., description="CM/CU/CT,客户端透传过来用于日志") + + +class JverifyLoginResponse(BaseModel): + phone: str + + +def _call_jg_login_token_verify(login_token: str) -> str: + """调极光 REST,返回 RSA 加密后的 base64 手机号字符串。""" + body = json.dumps({"loginToken": login_token, "exID": ""}).encode("utf-8") + auth = base64.b64encode(f"{JG_APP_KEY}:{JG_MASTER_SECRET}".encode()).decode() + req = urllib.request.Request( + JG_VERIFY_ENDPOINT, + data=body, + method="POST", + headers={ + "Content-Type": "application/json", + "Authorization": f"Basic {auth}", + }, + ) + try: + with urllib.request.urlopen(req, timeout=JG_REQUEST_TIMEOUT_SEC) as resp: + raw = resp.read() + except urllib.error.HTTPError as e: + body_text = "" + try: + body_text = e.read().decode("utf-8", errors="replace") + except Exception: + pass + logger.error("[JG] HTTP %s %s body=%s", e.code, e.reason, body_text[:500]) + raise HTTPException(status_code=502, detail=f"jg http error: {e.code}") + + data = json.loads(raw) + code = data.get("code") + if code != 8000: + logger.error("[JG] verify failed code=%s content=%s", code, data.get("content")) + raise HTTPException(status_code=400, detail=f"jg verify failed code={code}") + + # JG 返回里加密手机号在 "phone" 字段(已验证) + encrypted = data.get("phone") or "" + if not encrypted: + logger.error("[JG] response missing phone field: %s", data) + raise HTTPException(status_code=500, detail="jg response missing phone") + return encrypted + + +def _decrypt_phone(encrypted_b64: str) -> str: + """RSA 解密 base64 密文。 + + 极光文档没明说 padding。实测 PKCS1v15 在 OAEP 密文上会"假成功"返回垃圾 + (UnicodeDecodeError 0x8e),因此按 OAEP-SHA1 → OAEP-SHA256 → PKCS1v15 顺序 + 尝试,谁能解出 11 位纯数字手机号就用谁。 + + base64 padding 兼容:JG 各运营商可能返回不带 `=` 的 base64,先按 4 字节对齐补齐。 + """ + private_key = _get_private_key() + padded_b64 = encrypted_b64 + "=" * (-len(encrypted_b64) % 4) + ciphertext = base64.b64decode(padded_b64) + + paddings = [ + ("OAEP-SHA1", padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA1()), + algorithm=hashes.SHA1(), + label=None, + )), + ("OAEP-SHA256", padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + )), + ("PKCS1v15", padding.PKCS1v15()), + ] + + last_err: Exception | None = None + for name, pad in paddings: + try: + pt = private_key.decrypt(ciphertext, pad) + except Exception as e: + last_err = e + logger.debug("[JG] decrypt padding=%s failed: %s", name, e) + continue + try: + phone = pt.decode("utf-8") + except UnicodeDecodeError: + # decrypt 在 PKCS1v15 上对错误密文会"假成功"返回垃圾,跳到下一种 padding + logger.debug("[JG] decrypt padding=%s yielded non-UTF8 bytes head=%s", name, pt[:4].hex()) + continue + # 验证是不是合法手机号(中国大陆 11 位纯数字 / 或带 + 前缀) + if phone.isdigit() and len(phone) == 11: + logger.info("[JG] decrypted with padding=%s", name) + return phone + # 不是 11 位纯数字但是 ASCII 可打印,也接受(留个口子,后续看实际格式) + if phone.isascii() and phone.isprintable(): + logger.warning("[JG] decrypted with padding=%s, non-standard format: %r", name, phone) + return phone + logger.debug("[JG] decrypt padding=%s yielded non-phone string: %r", name, phone[:20]) + + raise RuntimeError(f"all RSA paddings failed; last error: {last_err}") + + +@router.post("/jverify-login", response_model=JverifyLoginResponse) +def jverify_login(req: JverifyLoginRequest) -> JverifyLoginResponse: + logger.info( + "jverify_login operator=%s token_len=%d", + req.operator, + len(req.login_token), + ) + encrypted = _call_jg_login_token_verify(req.login_token) + try: + phone = _decrypt_phone(encrypted) + except Exception as e: + logger.error("[JG] decrypt failed: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="phone decrypt failed") + + # upsert user(占坑期账号体系:phone 当主键)。失败不阻塞登录响应,只 log + try: + db.upsert_user(phone, int(time.time() * 1000)) + except Exception as e: + logger.error("[JG] upsert_user failed (login still succeeds): %s", e) + + # 日志里只打掩码,明文只返回客户端 + masked = (phone[:3] + "****" + phone[-2:]) if len(phone) >= 5 else "***" + logger.info("jverify_login ok operator=%s phone=%s", req.operator, masked) + return JverifyLoginResponse(phone=phone) diff --git a/duobibi(mock)backend/duobibi-server/app/api/parse.py b/duobibi(mock)backend/duobibi-server/app/api/parse.py new file mode 100644 index 0000000..6a506aa --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/parse.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException + +from app.extractor import extract +from app.schemas import ParseRequest, ParseResponse + +logger = logging.getLogger("shagua.parse") + +router = APIRouter(prefix="/api/v1") + + +@router.post("/parse", response_model=ParseResponse) +def parse(req: ParseRequest) -> ParseResponse: + logger.info( + "parse request package=%s tree=%s clusters=%d", + req.package_name, + bool(req.tree), + len(req.clusters), + ) + result = extract(req.package_name, req.tree, req.clusters) + + if not result.get("success"): + logger.info("parse failed: reason=%s raw=%s", result.get("reason"), result.get("raw")) + raise HTTPException( + status_code=422, + detail=result.get("reason", "extract_failed"), + ) + + return ParseResponse( + title=result["title"], + price=result["price"], + source_app=result["source_app"], + cluster_id=result.get("cluster_id"), + typical_price=result["typical_price"], + ) diff --git a/duobibi(mock)backend/duobibi-server/app/api/parse_image.py b/duobibi(mock)backend/duobibi-server/app/api/parse_image.py new file mode 100644 index 0000000..d1df66b --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/parse_image.py @@ -0,0 +1,73 @@ +"""POST /api/v1/parse-image —— iOS 端的截图识别接口。 + +multipart/form-data: + - image: file (JPEG/PNG/HEIC,客户端已压到 < 5MB) + - clusters: str (JSON 序列化的 [{"id":"","title":"..."}, ...]) + - package_name: str (可选,iOS 通常不传或传 null;Mock 实现不使用) + +占坑期实现接 mock_extractor;后续接真模型时只改这里调 extractor_vision。 +""" +from __future__ import annotations + +import json +import logging +from typing import Optional + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from pydantic import ValidationError + +from app.mock_extractor import mock_extract_image +from app.schemas import ClusterDtoStr, ParseStrResponse + +logger = logging.getLogger("shagua.parse_image") + +router = APIRouter(prefix="/api/v1") + +# 单张图片上限 5MB。客户端正常压完短边 1024 的 JPEG 通常 < 500KB, +# 超过 5MB 是异常情况(原图 / 客户端压缩 bug),直接拒掉省 LLM 钱。 +MAX_IMAGE_BYTES = 5 * 1024 * 1024 + + +@router.post("/parse-image", response_model=ParseStrResponse) +async def parse_image( + image: UploadFile = File(...), + clusters: str = Form("[]"), + package_name: Optional[str] = Form(None), +) -> ParseStrResponse: + image_bytes = await image.read() + if not image_bytes: + logger.info("parse_image rejected: no_image bytes=0") + raise HTTPException(status_code=422, detail="no_image") + if len(image_bytes) > MAX_IMAGE_BYTES: + logger.info("parse_image rejected: image_too_large bytes=%d", len(image_bytes)) + raise HTTPException(status_code=422, detail="image_too_large") + + try: + clusters_raw = json.loads(clusters) if clusters else [] + if not isinstance(clusters_raw, list): + raise ValueError("clusters must be a JSON array") + cluster_dtos = [ClusterDtoStr(**c) for c in clusters_raw] + except (json.JSONDecodeError, ValueError, TypeError, ValidationError) as e: + # ValidationError 不是 ValueError 子类(pydantic v2),必须显式 catch, + # 否则客户端发字段不全的 cluster 会让接口冒 500 + logger.info("parse_image rejected: invalid_clusters err=%s", e) + raise HTTPException(status_code=422, detail="invalid_clusters") + + logger.info( + "parse_image request bytes=%d package=%s clusters=%d", + len(image_bytes), + package_name, + len(cluster_dtos), + ) + + result = await mock_extract_image(image_bytes, cluster_dtos) + if not result.get("success"): + raise HTTPException(status_code=422, detail=result.get("reason", "extract_failed")) + + return ParseStrResponse( + title=result["title"], + price=result["price"], + source_app=result["source_app"], + cluster_id=result.get("cluster_id"), + typical_price=result["typical_price"], + ) diff --git a/duobibi(mock)backend/duobibi-server/app/api/parse_ocr.py b/duobibi(mock)backend/duobibi-server/app/api/parse_ocr.py new file mode 100644 index 0000000..78b0f4f --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/parse_ocr.py @@ -0,0 +1,44 @@ +"""POST /api/v1/parse-ocr —— OCR 文本识别接口(占坑期 mock 实现)。 + +application/json: + {"ocr_text": "...", "clusters": [{"id":"","title":"..."}, ...]} + +客户端在本地 OCR(或截图)后把整段文本发来,后端从文本里抠出 +title/price/source_app + 归簇 + 估常见价。占坑期接 mock_extractor; +接真模型时改这里调真实 extractor_ocr。 + +注:此前 main.py 引用了本 router 但文件缺失,会在启动时 ImportError —— 本文件补齐。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException + +from app.mock_extractor import mock_extract_ocr +from app.schemas import ParseOcrRequest, ParseStrResponse + +logger = logging.getLogger("app.parse_ocr") + +router = APIRouter(prefix="/api/v1") + + +@router.post("/parse-ocr", response_model=ParseStrResponse) +async def parse_ocr(req: ParseOcrRequest) -> ParseStrResponse: + text = req.ocr_text.strip() + if not text: + raise HTTPException(status_code=422, detail="empty_ocr_text") + + logger.info("parse_ocr request chars=%d clusters=%d", len(text), len(req.clusters)) + + result = await mock_extract_ocr(text, req.clusters) + if not result.get("success"): + raise HTTPException(status_code=422, detail=result.get("reason", "extract_failed")) + + return ParseStrResponse( + title=result["title"], + price=result["price"], + source_app=result["source_app"], + cluster_id=result.get("cluster_id"), + typical_price=result["typical_price"], + ) diff --git a/duobibi(mock)backend/duobibi-server/app/api/parse_text.py b/duobibi(mock)backend/duobibi-server/app/api/parse_text.py new file mode 100644 index 0000000..fea48fd --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/parse_text.py @@ -0,0 +1,52 @@ +"""POST /api/v1/parse-text —— iOS 端的"已知商品/价格,只需归簇 + 估常见价"接口。 + +application/json: + {"title": "...", "price": 99.0, "source_app": "淘宝", + "clusters": [{"id":"","title":"..."}, ...]} + +使用场景: + - iOS 主 App 内"手动新建记录":用户手填了 title/price/source_app + - Share Extension 收到的是纯文本(分享链接的标题文案)而非图片 + +占坑期实现接 mock_extractor;后续接真模型时改这里调真实 extractor_text。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException + +from app.mock_extractor import mock_extract_text +from app.schemas import ParseStrResponse, ParseTextRequest + +logger = logging.getLogger("shagua.parse_text") + +router = APIRouter(prefix="/api/v1") + + +@router.post("/parse-text", response_model=ParseStrResponse) +async def parse_text(req: ParseTextRequest) -> ParseStrResponse: + if not req.title.strip(): + raise HTTPException(status_code=422, detail="empty_title") + if req.price <= 0: + raise HTTPException(status_code=422, detail="invalid_price") + + logger.info( + "parse_text request title=%r price=%.2f source=%s clusters=%d", + req.title, + req.price, + req.source_app, + len(req.clusters), + ) + + result = await mock_extract_text(req.title, req.price, req.source_app, req.clusters) + if not result.get("success"): + raise HTTPException(status_code=422, detail=result.get("reason", "extract_failed")) + + return ParseStrResponse( + title=result["title"], + price=result["price"], + source_app=result["source_app"], + cluster_id=result.get("cluster_id"), + typical_price=result["typical_price"], + ) diff --git a/duobibi(mock)backend/duobibi-server/app/api/protect.py b/duobibi(mock)backend/duobibi-server/app/api/protect.py new file mode 100644 index 0000000..327e755 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/protect.py @@ -0,0 +1,220 @@ +"""POST /api/v1/track-price —— 价保哨兵的价格查询接口(LLM mock 实现)。 + +## 场景 + +用户在 App 内告知"我在 X 平台 ¥XXX 买了 Y 商品",客户端每天后台调一次此接口 +查"该商品当前在该平台的合理价"。客户端按 `savings > 0` 弹"该申请价保了"通知。 + +## 为什么是 mock + LLM + +占坑期没有真实平台价 API。爬虫法规风险大。OCR 用户主动截图比较被动 — 不适合 +"每天自动监控"场景。 + +折中方案:LLM 估"该商品在该平台的日常常见价"(锚价),再用哈希抖动模拟"今天降价 +/ 持平 / 涨价"。抖动是**确定性的**(seed = title+platform+date),同一商品同一天 +结果一致,跨天才会变化,不会让用户看到"通知反复抖动"。 + +未来切真 API 时客户端 0 改动(接口签名/响应固定)。 + +## 抖动设计 + +seed = sha256(title + platform + YYYY-MM-DD).hexdigest()[:8] +h = int(seed, 16) % 100 + +- h ∈ [0, 60) → 降价 3-8%, trend="down" (60% 概率,让通知活跃) +- h ∈ [60, 90) → 持平 ±2%, trend="flat" +- h ∈ [90, 100)→ 涨价 3-8%, trend="up" + +60% 降价概率是策略选择:demo 体验里用户经常能看到"该申请价保了"通知, +功能"动"得多。真实降价频率比这低很多,但 mock 阶段优先体验。 +""" +from __future__ import annotations + +import hashlib +import json +import logging +import re +import time +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, HTTPException + +from app.llm_client import MOCK_LLM, chat +from app.schemas import TrackPriceRequest, TrackPriceResponse + +logger = logging.getLogger("shagua.protect") + +router = APIRouter(prefix="/api/v1") + + +SYSTEM_PROMPT = """你是一个商品当前价格估算助手。 + +# 输入 +用户会告诉你: +- 平台: 例如 京东 / 淘宝 / 拼多多 / 抖音 +- 商品标题: 商品完整名称 +- 用户购买价: 参考用,不一定是当前价 +- 购买距今天数: 例如 7天 + +# 任务 + +估算该商品在该平台目前的**日常常见售价**(不含特殊大促),作为"基础价"输出。 + +# 估算思路 + +- 考虑该平台的常态价位区间(例:京东自营常比拼多多百亿补贴高 5-10%) +- 考虑时间维度:新品 3 个月内可能略涨,旧品逐渐降 +- 考虑大促节奏:618 / 双11 前后日常价会下浮 +- 不熟悉的商品 → 根据品类 + 品牌常识估算合理日常价 +- 完全陌生 → 取用户购买价的 ±5% 区间合理值 + +# 输出格式 + +严格 JSON,无 markdown,无解释,无任何其他文字: + +{"base_price": 数字, "reasoning": "一句话"} + +字段: +- base_price: 商品在该平台的日常常见价数字,**必须正数,不允许 null** +- reasoning: 一句话理由(给排查 LLM 行为用,客户端不展示) +""" + + +def _parse_llm_output(s: str) -> tuple[Optional[float], str]: + """从 LLM 输出剥出 base_price + reasoning。返回 (base_price | None, reasoning)。""" + s = s.strip() + s = re.sub(r"^```(?:json)?\s*", "", s) + s = re.sub(r"\s*```$", "", s) + try: + data = json.loads(s) + except json.JSONDecodeError: + m = re.search(r"\{[^{}]*\}", s) + if not m: + return None, "" + try: + data = json.loads(m.group(0)) + except json.JSONDecodeError: + return None, "" + if not isinstance(data, dict): + return None, "" + + bp_raw = data.get("base_price") + if isinstance(bp_raw, bool): + base_price = None + elif isinstance(bp_raw, (int, float)): + base_price = float(bp_raw) + elif isinstance(bp_raw, str): + try: + base_price = float(bp_raw.strip()) + except ValueError: + base_price = None + else: + base_price = None + + reasoning = data.get("reasoning") + if not isinstance(reasoning, str): + reasoning = "" + + return base_price, reasoning + + +def _compute_jitter(title: str, platform: str, today_str: str) -> tuple[float, str]: + """确定性抖动 — 同 title+platform+date 结果一致,跨天才变。 + + 返回 (factor, trend),factor 是乘到 base_price 上的系数。 + """ + seed_str = f"{title}|{platform}|{today_str}" + digest = hashlib.sha256(seed_str.encode("utf-8")).hexdigest() + h = int(digest[:8], 16) % 100 + + if h < 60: + # 降价 3-8%。在 [0, 60) 内线性插值到 [0.92, 0.97] + factor = 0.92 + (h / 60.0) * 0.05 + trend = "down" + elif h < 90: + # 持平 ±2%。在 [60, 90) 内线性插值到 [0.98, 1.02] + factor = 0.98 + ((h - 60) / 30.0) * 0.04 + trend = "flat" + else: + # 涨价 3-8%。在 [90, 100) 内线性插值到 [1.03, 1.08] + factor = 1.03 + ((h - 90) / 10.0) * 0.05 + trend = "up" + + return factor, trend + + +@router.post("/track-price", response_model=TrackPriceResponse) +def track_price(req: TrackPriceRequest) -> TrackPriceResponse: + title = req.product_title.strip() + platform = req.platform.strip() + if not title or not platform: + raise HTTPException(status_code=422, detail="empty_title_or_platform") + if req.purchase_price <= 0: + raise HTTPException(status_code=422, detail="invalid_purchase_price") + + # 计算"购买距今多少天",给 LLM 当上下文 + now_ts = int(time.time()) + days_since = max(0, (now_ts - req.purchase_at) // 86400) + + user_msg = ( + f"平台: {platform}\n" + f"商品标题: {title}\n" + f"用户购买价: ¥{req.purchase_price:.2f}\n" + f"购买距今: {days_since}天" + ) + if MOCK_LLM: + # mock:跳过 LLM,下方 base_price 回退到 purchase_price 锚 + 确定性抖动。 + raw = "" + else: + try: + raw = chat( + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ] + ) + logger.debug("track-price LLM raw: %s", raw[:300]) + except Exception as e: + # LLM 不可用(限流/网络/超时):不 500,用 purchase_price 作锚。 + logger.warning("track-price LLM call failed, using purchase_price anchor: %s", e) + raw = "" + + base_price, reasoning = _parse_llm_output(raw) + + # base_price 兜底:LLM 不听话或给 <=0,用 purchase_price 作为锚 + # (occurence 极少;占坑期 mock 不能让接口因 LLM 抽风就 500) + if base_price is None or base_price <= 0: + logger.warning( + "LLM did not return valid base_price for %r, fallback to purchase_price", + title, + ) + base_price = req.purchase_price + + # 抖动 — seed = title + platform + 服务器当前日期(UTC,日切清晰) + today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + factor, trend = _compute_jitter(title, platform, today_str) + current_price = round(base_price * factor, 2) + + savings = round(req.purchase_price - current_price, 2) + + logger.info( + "track-price: platform=%s title=%r purchase=¥%.2f base=¥%.2f current=¥%.2f" + " trend=%s savings=¥%.2f reasoning=%r", + platform, + title, + req.purchase_price, + base_price, + current_price, + trend, + savings, + reasoning[:80], + ) + + return TrackPriceResponse( + current_price=current_price, + base_price=base_price, + trend=trend, + savings=savings, + checked_at=now_ts, + ) diff --git a/duobibi(mock)backend/duobibi-server/app/api/quick_quote.py b/duobibi(mock)backend/duobibi-server/app/api/quick_quote.py new file mode 100644 index 0000000..841e973 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/quick_quote.py @@ -0,0 +1,189 @@ +"""POST /api/v1/quick-quote —— Android 端主动比价查询接口。 + +application/json: + {"title": "iPhone 15 Pro Max 1TB", "clusters": [{"id":1,"title":"..."}, ...]} + +使用场景:用户在 App 内"快速比价"输入框输入商品名查询 — 不需要先去 +购物 App 浮窗记账,直接得到"市场常见价 + 是否命中已记过的同款"。 + +返回: + {"title": "...", "typical_price": 13999.0, "cluster_id": 1 | null} + +LLM 行为: +- title 标准化 (修正错别字 / 统一规格表达) +- typical_price 必须给数字 (与 /parse 一致的兜底逻辑) +- cluster_id 按现有 簇匹配规则 判断 +""" +from __future__ import annotations + +import hashlib +import json +import logging +import re +from typing import Optional + +from fastapi import APIRouter, HTTPException + +from app.llm_client import MOCK_LLM, chat +from app.schemas import ClusterDto, QuickQuoteRequest, QuickQuoteResponse + +logger = logging.getLogger("shagua.quick_quote") + +router = APIRouter(prefix="/api/v1") + + +SYSTEM_PROMPT = """你是商品归簇与市场参考价估算助手。 + +# 任务 + +用户会发给你两部分输入: +1. 一个商品标题字符串 +2. 用户已有的"商品簇"列表(每个簇用一个代表标题描述) + +请你完成两件事: +A. 估算该商品的「市场常见价」(typical_price):主流电商平台常见售价区间 + 的中位值,不含双 11 / 618 等特殊促销价 +B. 判断该商品是否归属于已有簇中的某一个,若是返回该簇 id,若否返回 null + +# 簇匹配规则 + +两件商品视为「同一簇」,核心商品名一致即可,无视规格、颜色、容量、装数、 +性别、码数等差异。 + +例: +- "iPhone 15 Pro 256GB" ↔ "iPhone 15 Pro 1TB 暮光紫" → 同簇 ✓ +- "iPhone 15 Pro" ↔ "iPhone 14 Pro" → 不同簇 ✗ (型号不同) +- "海尔保温杯 500ml" ↔ "九阳保温杯 500ml" → 不同簇 ✗ (品牌不同) + +# 市场常见价估算 + +- 取主流电商(淘宝/京东/拼多多/抖音电商)常见售价区间的中位值 +- 不含双 11、618、年货节、品牌大促等特殊促销价 +- 单位:元,可以有小数,必须为正数 +- **必须给出一个数字,不允许 null**。即使你不熟悉,也要根据品类、品牌、 + 规格的常识给出合理估算 +- 估算思路: + * 知名品牌 → 该品牌该品类的官方建议零售价或主流电商常见价 + * 冷门品牌 → 同品类的市场常见价区间中位值 + * 完全陌生 → 给出符合常识的合理估值 + +# 输出格式 + +严格 JSON,无任何额外文字、不要 markdown 代码块、不要解释: + +{"title": "标准化后的商品标题", "typical_price": 99.9, "cluster_id": 3} + +字段说明: +- title:整理后的标题(可纠错 / 统一规格写法,但保留原意) +- typical_price:市场常见价数字(正数),**必须返回数字,不允许 null** +- cluster_id:命中已有簇返回其 id (整数);未命中返回 null +""" + + +def _format_clusters(clusters: list[ClusterDto]) -> str: + if not clusters: + return "(无,这是用户记录的第一件商品)" + return "\n".join(f"- id={c.id}: {c.title}" for c in clusters) + + +def _mock_raw(title: str) -> str: + """占坑期 mock(DUOBIBI_MOCK_LLM):基于标题确定性造市场常见价,不归簇(由客户端新建)。""" + seed = int(hashlib.sha256(title.encode("utf-8")).hexdigest()[:8], 16) + typical = round(49 + seed % 9950 + (seed % 100) / 100.0, 2) + return json.dumps({"title": title, "typical_price": typical, "cluster_id": None}, ensure_ascii=False) + + +def _parse_llm_output(s: str) -> tuple[Optional[str], Optional[float], Optional[int]]: + s = s.strip() + s = re.sub(r"^```(?:json)?\s*", "", s) + s = re.sub(r"\s*```$", "", s) + try: + data = json.loads(s) + except json.JSONDecodeError: + m = re.search(r"\{[^{}]*\}", s) + if not m: + return None, None, None + try: + data = json.loads(m.group(0)) + except json.JSONDecodeError: + return None, None, None + if not isinstance(data, dict): + return None, None, None + + title = data.get("title") + title = title.strip() if isinstance(title, str) and title.strip() else None + + tp_raw = data.get("typical_price") + if isinstance(tp_raw, bool): + typical_price = None + elif isinstance(tp_raw, (int, float)): + typical_price = float(tp_raw) + elif isinstance(tp_raw, str): + try: + typical_price = float(tp_raw.strip()) + except ValueError: + typical_price = None + else: + typical_price = None + + cid_raw = data.get("cluster_id") + if isinstance(cid_raw, bool): + cluster_id = None + elif isinstance(cid_raw, int): + cluster_id = cid_raw + elif isinstance(cid_raw, float) and cid_raw.is_integer(): + cluster_id = int(cid_raw) + else: + cluster_id = None + + return title, typical_price, cluster_id + + +@router.post("/quick-quote", response_model=QuickQuoteResponse) +def quick_quote(req: QuickQuoteRequest) -> QuickQuoteResponse: + title_in = req.title.strip() + if not title_in: + raise HTTPException(status_code=422, detail="empty_title") + + user_msg = ( + f"商品标题: {title_in}\n\n" + f"已有商品簇:\n{_format_clusters(req.clusters)}" + ) + if MOCK_LLM: + raw = _mock_raw(title_in) + else: + try: + raw = chat( + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ] + ) + logger.debug("quick-quote LLM raw: %s", raw[:300]) + except Exception as e: + # LLM 不可用:不 500,下方 typical_price 走兜底。 + logger.warning("quick-quote LLM call failed: %s", e) + raw = "" + + title_out, typical_price, cluster_id = _parse_llm_output(raw) + + # title 兜底:LLM 没整理就用原值 + if title_out is None: + title_out = title_in + + # cluster_id 校验:防 LLM 编造 + valid_ids = {c.id for c in req.clusters} + if cluster_id is not None and cluster_id not in valid_ids: + logger.warning("LLM returned unknown cluster_id=%s, treating as null", cluster_id) + cluster_id = None + + # typical_price 兜底:LLM 不听话或给 <=0,用一个保守占位(实际极少触发) + if typical_price is None or typical_price <= 0: + logger.warning("LLM did not return valid typical_price, fallback to 0.0") + typical_price = 0.0 + + return QuickQuoteResponse( + title=title_out, + typical_price=typical_price, + cluster_id=cluster_id, + ) diff --git a/duobibi(mock)backend/duobibi-server/app/api/wish.py b/duobibi(mock)backend/duobibi-server/app/api/wish.py new file mode 100644 index 0000000..16be215 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/wish.py @@ -0,0 +1,195 @@ +"""心愿单同步 endpoint。 + +两个 endpoint: + - POST /api/v1/wish/list 拉取某 phone 的全部心愿单(含 soft-deleted) + - POST /api/v1/wish/upsert 批量推送本地修改(新增 / 更新 / 软删) + +同步模型: + - phone 作为账号主键(占坑期) + - 每条 wish 有 updated_at,冲突解决用 last-write-wins + - 软删通过 deleted_at != NULL,避免 A 设备删了 B 设备再 push 上来 "复活" + - 客户端用 client_temp_id 关联自家本地行,服务端在响应里回带 server_id + +不做的: + - 不做 since 增量,占坑期心愿单条目数估计 < 100,全量同步成本低且简单 + - 不做 user_id 单独主键,phone 当主键(后续升级 user_id 时迁移 1 张表) +""" +from __future__ import annotations + +import logging +import time +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from app import db + +logger = logging.getLogger("shagua.wish") + +router = APIRouter(prefix="/api/v1/wish") + + +class WishItem(BaseModel): + """跟客户端 sync 用的精简 schema。 + matchedClusterId / matchCount / 等本地命中状态不同步(跨设备无意义)。""" + server_id: Optional[int] = None # 新增本地条目时为 null + client_temp_id: Optional[str] = None # 客户端用于关联响应里的 server_id 回填 + title: str + target_price: Optional[float] = None + note: Optional[str] = None + notify_enabled: bool = True + created_at: int # 客户端创建时间(ms) + updated_at: int # 最后修改时间(ms),冲突解决用 + deleted_at: Optional[int] = None # 软删,非 NULL 表示已删 + + +class WishListRequest(BaseModel): + phone: str = Field(..., min_length=11, max_length=15) + + +class WishListResponse(BaseModel): + items: list[WishItem] + + +class WishUpsertRequest(BaseModel): + phone: str = Field(..., min_length=11, max_length=15) + items: list[WishItem] + + +class WishUpsertItemAck(BaseModel): + """每条上传 item 的 server-side 处理结果。 + client_temp_id 透传,用于客户端按它找到对应本地行回填 server_id。""" + client_temp_id: Optional[str] + server_id: int + updated_at: int # 服务端落库后的 updated_at(可能因冲突解决用了 client 提供的也可能用了 server 已有的) + accepted: bool # false=服务端有更新的版本,拒绝了本次更新(客户端应保留服务端版本) + + +class WishUpsertResponse(BaseModel): + items: list[WishUpsertItemAck] + + +@router.post("/list", response_model=WishListResponse) +def wish_list(req: WishListRequest) -> WishListResponse: + """拉取该 phone 的所有 wish(含软删),客户端按 deleted_at 自行过滤显示。""" + with db.get_conn() as conn: + rows = conn.execute( + """ + SELECT id, title, target_price, note, notify_enabled, + created_at, updated_at, deleted_at + FROM wish_item + WHERE phone = ? + ORDER BY updated_at DESC + """, + (req.phone,), + ).fetchall() + + items = [ + WishItem( + server_id=r["id"], + client_temp_id=None, + title=r["title"], + target_price=r["target_price"], + note=r["note"], + notify_enabled=bool(r["notify_enabled"]), + created_at=r["created_at"], + updated_at=r["updated_at"], + deleted_at=r["deleted_at"], + ) + for r in rows + ] + logger.info("wish_list phone=%s count=%d", _mask(req.phone), len(items)) + return WishListResponse(items=items) + + +@router.post("/upsert", response_model=WishUpsertResponse) +def wish_upsert(req: WishUpsertRequest) -> WishUpsertResponse: + """批量 upsert。每条: + - server_id 为 null → INSERT,返回新 id + - server_id 不为 null → 检查 phone 归属 + 比较 updated_at + - 客户端 updated_at > 服务端 → UPDATE,accepted=true + - 否则 → 拒绝,返回服务端最新数据(accepted=false) + """ + acks: list[WishUpsertItemAck] = [] + with db.get_conn() as conn: + for it in req.items: + if it.server_id is None: + cur = conn.execute( + """ + INSERT INTO wish_item + (phone, title, target_price, note, notify_enabled, + created_at, updated_at, deleted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + req.phone, it.title, it.target_price, it.note, + 1 if it.notify_enabled else 0, + it.created_at, it.updated_at, it.deleted_at, + ), + ) + acks.append(WishUpsertItemAck( + client_temp_id=it.client_temp_id, + server_id=int(cur.lastrowid), + updated_at=it.updated_at, + accepted=True, + )) + continue + + # server_id 非空:先查现状,做 last-write-wins + row = conn.execute( + "SELECT phone, updated_at FROM wish_item WHERE id = ?", + (it.server_id,), + ).fetchone() + if row is None or row["phone"] != req.phone: + # 没找到 / 不属于该 phone:拒绝(防越权) + logger.warning( + "wish_upsert reject phone=%s server_id=%s (not found or phone mismatch)", + _mask(req.phone), it.server_id, + ) + acks.append(WishUpsertItemAck( + client_temp_id=it.client_temp_id, + server_id=it.server_id, + updated_at=row["updated_at"] if row else it.updated_at, + accepted=False, + )) + continue + if it.updated_at <= row["updated_at"]: + # 服务端有更新或同样的版本,拒绝;客户端应该按服务端版本同步过去 + acks.append(WishUpsertItemAck( + client_temp_id=it.client_temp_id, + server_id=it.server_id, + updated_at=row["updated_at"], + accepted=False, + )) + continue + conn.execute( + """ + UPDATE wish_item SET + title = ?, target_price = ?, note = ?, notify_enabled = ?, + updated_at = ?, deleted_at = ? + WHERE id = ? AND phone = ? + """, + ( + it.title, it.target_price, it.note, + 1 if it.notify_enabled else 0, + it.updated_at, it.deleted_at, + it.server_id, req.phone, + ), + ) + acks.append(WishUpsertItemAck( + client_temp_id=it.client_temp_id, + server_id=it.server_id, + updated_at=it.updated_at, + accepted=True, + )) + + logger.info( + "wish_upsert phone=%s in=%d accepted=%d", + _mask(req.phone), len(req.items), sum(1 for a in acks if a.accepted), + ) + return WishUpsertResponse(items=acks) + + +def _mask(phone: str) -> str: + return (phone[:3] + "****" + phone[-2:]) if len(phone) >= 5 else "***" diff --git a/duobibi(mock)backend/duobibi-server/app/api/worth_buy.py b/duobibi(mock)backend/duobibi-server/app/api/worth_buy.py new file mode 100644 index 0000000..3c78039 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/api/worth_buy.py @@ -0,0 +1,194 @@ +"""POST /api/v1/worth-buy —— 多比比"AI 值不值得买"接口。 + +用户给一个商品标题 + 当前到手价,LLM 综合市场常见价、价格水位、大促节奏, +给出 0-100 的"现在值不值得买"评分 + 建议 + 理由 + 更划算的时机。 + +application/json: + {"title": "...", "price": 8099.0, "platform": "京东"(可选)} + +响应: + {"score": 78, "verdict": "buy", "headline": "现在入手划算", + "reasons": ["...","..."], "typical_price": 8299.0, "best_time": "..."} + +verdict: buy(建议买) / wait(再等等) / neutral(看个人需求) +兜底:LLM 抽风/解析失败也总能给中性结论,不抛 5xx(决策类接口应该永远有答案)。 +""" +from __future__ import annotations + +import hashlib +import json +import logging +import re +from typing import Optional + +from fastapi import APIRouter, HTTPException + +from app.llm_client import MOCK_LLM, chat +from app.schemas import WorthBuyRequest, WorthBuyResponse + +logger = logging.getLogger("app.worth_buy") + +router = APIRouter(prefix="/api/v1") + +SYSTEM_PROMPT = """你是一个理性购物决策助手。 + +# 任务 +用户给你一个商品标题 + 当前到手价(可选平台)。判断「现在是不是入手的好时机」, +综合考虑:市场常见价水位、该价格在历史区间的位置、临近的大促节奏(618 / 双11 / 年货节)、 +品类贬值速度(数码贬值快、日用品稳定)。 + +# 评分口径(score 0-100) +- 越高 = 越值得现在买(价格够低 / 临近无更优时机)。 +- 80-100 建议买(verdict=buy);40-79 看个人需求(verdict=neutral);0-39 再等等(verdict=wait)。 + +# 输出格式 +严格 JSON,无 markdown,无解释: + +{"score": 78, "verdict": "buy", "headline": "一句话结论", + "reasons": ["理由1", "理由2"], "typical_price": 8299.0, "best_time": "更划算的时机"} + +字段: +- score: 0-100 整数 +- verdict: "buy" / "wait" / "neutral" +- headline: 一句话结论(尽量 ≤ 14 字) +- reasons: 2-4 条短理由 +- typical_price: 市场常见价(正数,必填) +- best_time: 若现在不是最佳给出更划算时机;若现在就值,可写"现在就合适" +""" + + +def _extract_json(s: str) -> Optional[dict]: + s = s.strip() + s = re.sub(r"^```(?:json)?\s*", "", s) + s = re.sub(r"\s*```$", "", s) + try: + data = json.loads(s) + except json.JSONDecodeError: + m = re.search(r"\{.*\}", s, re.DOTALL) + if not m: + return None + try: + data = json.loads(m.group(0)) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def _num(v) -> Optional[float]: + if isinstance(v, bool): + return None + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + try: + return float(v.strip()) + except ValueError: + return None + return None + + +def _mock_raw(title: str, price: float, platform: str) -> str: + """占坑期 mock(DUOBIBI_MOCK_LLM):不调 LLM,按"当前价相对常见价的水位" + 确定性算分(价越低分越高),造 LLM 同格式 JSON,走下游解析/兜底。""" + seed = int(hashlib.sha256(f"{title}|{platform}".encode("utf-8")).hexdigest()[:8], 16) + typical = round(price * (0.90 + (seed % 25) / 100.0), 2) # 常见价:price 的 0.90~1.14 倍 + ratio = price / typical if typical > 0 else 1.0 + score = int(round(max(0.0, min(100.0, (1.15 - ratio) / 0.30 * 100.0)))) + if score >= 80: + verdict, headline, best_time = "buy", "现在入手划算", "现在就合适" + elif score < 40: + verdict, headline, best_time = "wait", "建议再等等", "下次大促(如 618 / 双11)" + else: + verdict, headline, best_time = "neutral", "看个人需求", "可关注大促节点" + pct = round((typical - price) / typical * 100, 1) if typical > 0 else 0.0 + if pct > 0: + reasons = [f"当前价低于市场常见价约 {pct}%", "近期价格处于偏低水位"] + elif pct < 0: + reasons = [f"当前价高于市场常见价约 {abs(pct)}%", "可等促销或比价后再入手"] + else: + reasons = ["当前价与市场常见价基本持平", "建议结合自身需求判断"] + return json.dumps( + {"score": score, "verdict": verdict, "headline": headline, + "reasons": reasons, "typical_price": typical, "best_time": best_time}, + ensure_ascii=False, + ) + + +@router.post("/worth-buy", response_model=WorthBuyResponse) +def worth_buy(req: WorthBuyRequest) -> WorthBuyResponse: + title = req.title.strip() + if not title: + raise HTTPException(status_code=422, detail="empty_title") + if req.price <= 0: + raise HTTPException(status_code=422, detail="invalid_price") + + plat = (req.platform or "").strip() + user_msg = ( + f"商品标题: {title}\n" + f"当前到手价: ¥{req.price:.2f}\n" + + (f"平台: {plat}\n" if plat else "") + ) + if MOCK_LLM: + raw = _mock_raw(title, req.price, plat) + else: + try: + raw = chat( + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ] + ) + logger.debug("worth-buy LLM raw: %s", raw[:400]) + except Exception as e: + # LLM 不可用(限流/网络/超时等):决策类接口不抛 5xx,降级走下方中性兜底。 + logger.warning("worth-buy LLM call failed, falling back to neutral: %s", e) + raw = "" + + data = _extract_json(raw) or {} + + # score → clamp 0..100,解析失败给 50(中性) + score_raw = data.get("score") + if isinstance(score_raw, bool): + score = 50 + else: + n = _num(score_raw) + score = int(round(n)) if n is not None else 50 + score = max(0, min(100, score)) + + verdict = data.get("verdict") + if verdict not in {"buy", "wait", "neutral"}: + verdict = "buy" if score >= 80 else "wait" if score < 40 else "neutral" + + headline = data.get("headline") + if not isinstance(headline, str) or not headline.strip(): + headline = {"buy": "现在入手划算", "wait": "建议再等等", "neutral": "看个人需求"}[verdict] + headline = headline.strip() + + reasons_raw = data.get("reasons") + if isinstance(reasons_raw, list): + reasons = [r.strip() for r in reasons_raw if isinstance(r, str) and r.strip()][:4] + else: + reasons = [] + if not reasons: + reasons = ["暂无足够信息,建议结合自身需求判断"] + + typical = _num(data.get("typical_price")) + if typical is None or typical <= 0: + typical = req.price + + best_time = data.get("best_time") + if not isinstance(best_time, str) or not best_time.strip(): + best_time = "现在就合适" if verdict == "buy" else "下次大促(如 618 / 双11)" + best_time = best_time.strip() + + logger.info( + "worth-buy title=%r price=%.2f score=%d verdict=%s", title, req.price, score, verdict + ) + return WorthBuyResponse( + score=score, + verdict=verdict, + headline=headline, + reasons=reasons, + typical_price=round(typical, 2), + best_time=best_time, + ) diff --git a/duobibi(mock)backend/duobibi-server/app/db.py b/duobibi(mock)backend/duobibi-server/app/db.py new file mode 100644 index 0000000..78d6cb0 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/db.py @@ -0,0 +1,82 @@ +"""SQLite 持久化层。占坑期方案,够用即可。 + +- 单文件 sqlite3,默认 `/opt/duobibi-server/data.db`(env `DUOBIBI_DB_PATH` 可覆盖) +- 用 stdlib sqlite3,不引入 SQLAlchemy(占坑期不需要 ORM) +- 表结构: + - `user`:phone 当主键(占坑期账号体系) + - `wish_item`:用户的心愿单,phone 关联 user,deleted_at 软删,updated_at 用于同步冲突解决 + +并发说明:sqlite3 默认每个连接独占,FastAPI 是 thread pool 跑 sync handler。 +每次请求新开 connection + use as ctx manager,简单可靠。 +""" +from __future__ import annotations + +import os +import sqlite3 +from contextlib import contextmanager +from typing import Iterator + +DB_PATH = os.environ.get("DUOBIBI_DB_PATH", "/opt/duobibi-server/data.db") + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=10.0, isolation_level=None) + conn.row_factory = sqlite3.Row + # WAL 提升并发读写;FastAPI thread pool 下多线程访问同一文件时有用 + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +@contextmanager +def get_conn() -> Iterator[sqlite3.Connection]: + """每次请求开一个新 connection,with 块结束自动 close。""" + conn = _connect() + try: + yield conn + finally: + conn.close() + + +def init_schema() -> None: + """启动时调用,IF NOT EXISTS 建表。改 schema 需手动 migration(写额外 SQL)。""" + with get_conn() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS user ( + phone TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS wish_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT NOT NULL, + title TEXT NOT NULL, + target_price REAL, + note TEXT, + notify_enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + FOREIGN KEY (phone) REFERENCES user(phone) ON DELETE CASCADE + ); + + -- 按用户拉取 / 增量同步主索引 + CREATE INDEX IF NOT EXISTS idx_wish_phone_updated + ON wish_item(phone, updated_at DESC); + """ + ) + + +def upsert_user(phone: str, now_ms: int) -> None: + """登录成功时调:有则更新 last_seen_at,无则插入。""" + with get_conn() as conn: + conn.execute( + """ + INSERT INTO user (phone, created_at, last_seen_at) + VALUES (?, ?, ?) + ON CONFLICT(phone) DO UPDATE SET last_seen_at = excluded.last_seen_at + """, + (phone, now_ms, now_ms), + ) diff --git a/duobibi(mock)backend/duobibi-server/app/extractor.py b/duobibi(mock)backend/duobibi-server/app/extractor.py new file mode 100644 index 0000000..b36eb3c --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/extractor.py @@ -0,0 +1,243 @@ +"""把无障碍树扁平化 + 候选簇喂给 LLM,一次出 商品标题、到手价、归属簇。""" +from __future__ import annotations + +import hashlib +import json +import logging +import re +from typing import Optional + +from app.llm_client import MOCK_LLM, chat +from app.schemas import ClusterDto, NodeDto + +logger = logging.getLogger("shagua.extractor") + +PKG_TO_BRAND: dict[str, str] = { + "com.taobao.taobao": "淘宝", + "com.jingdong.app.mall": "京东", + "com.xunmeng.pinduoduo": "拼多多", + "com.ss.android.ugc.aweme": "抖音", + "com.sankuai.meituan": "美团", + "com.sankuai.meituan.takeoutnew": "美团", + "me.ele": "饿了么", +} + +MAX_FLAT_CHARS = 12000 + +SYSTEM_PROMPT = """你是一个商品页结构化提取、商品归簇与市场参考价估算助手。 + +# 任务 + +用户会发给你两部分输入: +1. 一段从 Android 无障碍树取出的某个购物 / 外卖 / 团购 App 页面的可见文本与控件信息 +2. 用户已有的"商品簇"列表(每个簇用一个代表标题描述) + +请你完成三件事: +A. 从页面信息中识别当前商品的「标题」与「到手价」(单位:元,数字) +B. 判断当前商品是否归属于已有簇中的某一个,若是返回该簇 id,若否返回 null(由客户端新建簇) +C. 估算该商品的「市场常见价」(typical_price):主流电商平台常见售价区间的中位值,不含双 11 / 618 等特殊促销价 + +# 簇匹配规则 + +两件商品视为「同一簇」,核心商品名一致即可,无视规格、颜色、容量、装数、性别、码数等差异。 + +例: +- "iPhone 15 Pro 256GB" ↔ "iPhone 15 Pro 1TB 暮光紫" → 同簇 ✓ +- "iPhone 15 Pro" ↔ "iPhone 14 Pro" → 不同簇 ✗ (型号不同) +- "海尔保温杯 500ml" ↔ "九阳保温杯 500ml" → 不同簇 ✗ (品牌不同) +- "PaulFrank 卫衣 男款 L" ↔ "PaulFrank 卫衣 女款 M" → 同簇 ✓ (性别码数算规格) + +# 市场常见价估算 + +- 取主流电商(淘宝/京东/拼多多/抖音电商)常见售价区间的中位值 +- 不含双 11、618、年货节、品牌大促等特殊促销价 +- 单位:元,可以有小数,必须为正数 +- **必须给出一个数字,不允许 null**。即使你对该商品不熟悉,也要根据**品类**(如矿泉水、卫衣、手机、咖啡等)、**品牌**(如有)、**规格**(数量/重量/容量/型号)的常识给出合理估算 +- 估算思路: + * 知名品牌 → 该品牌该品类的官方建议零售价或主流电商常见价 + * 冷门品牌 → 同品类(品类无关品牌)的市场常见价区间中位值 + * 完全陌生 → 参考给定到手价 ±20% 的合理区间 + +# 输出格式 + +严格 JSON,无任何额外文字、不要 markdown 代码块、不要解释: + +{"title": "完整商品标题", "price": 99.9, "cluster_id": 3, "typical_price": 120.0} + +字段说明: +- title:商品标题字符串(最显眼最完整那段,不截断、不拼规格) +- price:到手价数字(优先级:实付到手价 > 优惠后价 > 标价) +- cluster_id:命中已有簇返回其 id (整数);未命中返回 null +- typical_price:市场常见价数字(正数),**必须返回数字,不允许 null** + +# 失败处理 + +页面不是商品/团购/外卖详情页,或无法提取标题/价格时: + +{"title": null, "price": null, "cluster_id": null, "typical_price": null} + +# 价格细则 + +到手价选择优先级:实付 > 优惠后 > 单品标价。 +""" + + +def flatten_tree(node: Optional[NodeDto]) -> str: + if node is None: + return "" + lines: list[str] = [] + _walk(node, depth=0, out=lines) + text = "\n".join(lines) + if len(text) > MAX_FLAT_CHARS: + text = text[:MAX_FLAT_CHARS] + "\n...(truncated)" + return text + + +def _walk(node: NodeDto, depth: int, out: list[str]) -> None: + parts: list[str] = [] + if node.view_id: + parts.append(f"id={node.view_id}") + if node.text: + parts.append(f'text="{node.text}"') + if node.desc: + parts.append(f'desc="{node.desc}"') + if parts: + out.append(" " * depth + " | ".join(parts)) + for child in node.children or []: + _walk(child, depth + 1, out) + + +def _format_clusters(clusters: list[ClusterDto]) -> str: + if not clusters: + return "(无,这是用户记录的第一件商品)" + return "\n".join(f"- id={c.id}: {c.title}" for c in clusters) + + +def _mock_raw(flat: str) -> str: + """占坑期 mock(DUOBIBI_MOCK_LLM):按页面文本哈希从预置商品池挑一件, + 造 LLM 同格式 JSON(不归簇,由客户端按 cluster 规则新建)。""" + from app.mock_extractor import MOCK_PRODUCTS + + h = hashlib.sha256(flat.encode("utf-8")).digest() + p = MOCK_PRODUCTS[h[0] % len(MOCK_PRODUCTS)] + return json.dumps( + {"title": p["title"], "price": p["price"], "cluster_id": None, + "typical_price": p["typical_price"]}, + ensure_ascii=False, + ) + + +def extract( + package_name: str, + tree: Optional[NodeDto], + clusters: list[ClusterDto], +) -> dict: + """返回 {success, title?, price?, source_app, cluster_id?, reason?}。""" + brand = PKG_TO_BRAND.get(package_name, "未知") + if tree is None: + return {"success": False, "reason": "no_tree", "source_app": brand} + + flat = flatten_tree(tree) + if not flat.strip(): + return {"success": False, "reason": "empty_tree", "source_app": brand} + + user_msg = ( + f"App: {brand}\n\n" + f"页面信息:\n{flat}\n\n" + f"已有商品簇:\n{_format_clusters(clusters)}" + ) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ] + if MOCK_LLM: + raw = _mock_raw(flat) + else: + try: + raw = chat(messages) + logger.debug("LLM raw output: %s", raw[:500]) + except Exception as e: + # LLM 不可用(限流/网络/超时):不 500,返回提取失败让客户端提示重试。 + logger.warning("extract LLM call failed: %s", e) + raw = "" + + title, price, cluster_id, typical_price = _parse_llm_output(raw) + if title is None or price is None: + return { + "success": False, + "reason": "llm_no_extract", + "source_app": brand, + "raw": raw[:200], + } + + # 校验 cluster_id 是不是用户提供过的(防 LLM 编造) + valid_ids = {c.id for c in clusters} + if cluster_id is not None and cluster_id not in valid_ids: + logger.warning("LLM returned unknown cluster_id=%s, treating as new cluster", cluster_id) + cluster_id = None + + # typical_price 兜底: prompt 已要求 LLM 必须给数字,但仍可能不听话或给负数。 + # 这种极少数情况下用当前价兜底(客户端会显示"持平"),保证字段总有值。 + if typical_price is None or typical_price <= 0: + typical_price = price + + return { + "success": True, + "title": title, + "price": price, + "source_app": brand, + "cluster_id": cluster_id, + "typical_price": typical_price, + } + + +def _parse_number(raw) -> Optional[float]: + if isinstance(raw, bool): + return None + if isinstance(raw, (int, float)): + return float(raw) + if isinstance(raw, str): + try: + return float(raw.strip()) + except ValueError: + return None + return None + + +def _parse_llm_output(s: str) -> tuple[Optional[str], Optional[float], Optional[int], Optional[float]]: + s = s.strip() + s = re.sub(r"^```(?:json)?\s*", "", s) + s = re.sub(r"\s*```$", "", s) + + data: Optional[dict] = None + try: + data = json.loads(s) + except json.JSONDecodeError: + m = re.search(r'\{[^{}]*"title"\s*:\s*[^{}]+\}', s) + if m: + try: + data = json.loads(m.group(0)) + except json.JSONDecodeError: + pass + + if not isinstance(data, dict): + return None, None, None, None + + title = data.get("title") + title = title.strip() if isinstance(title, str) and title.strip() else None + + price = _parse_number(data.get("price")) + typical_price = _parse_number(data.get("typical_price")) + + cid_raw = data.get("cluster_id") + if isinstance(cid_raw, bool): + cluster_id = None + elif isinstance(cid_raw, int): + cluster_id = cid_raw + elif isinstance(cid_raw, float) and cid_raw.is_integer(): + cluster_id = int(cid_raw) + else: + cluster_id = None + + return title, price, cluster_id, typical_price diff --git a/duobibi(mock)backend/duobibi-server/app/llm_client.py b/duobibi(mock)backend/duobibi-server/app/llm_client.py new file mode 100644 index 0000000..b439cb2 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/llm_client.py @@ -0,0 +1,56 @@ +"""精简版 LLM 客户端 — 只保留智谱 GLM-5-turbo-nothinking,占坑期够用。""" +from __future__ import annotations + +import logging +import os +import time +from typing import Any + +from zai import ZhipuAiClient + +logger = logging.getLogger("shagua.llm") + +# 占坑期硬编码,与 pricebot-backend 同 key +ZHIPU_API_KEY = "c298ebdfda044e0387a3dc571f98ceed.QDY8lVCcGt04C6BD" +MODEL = "glm-5-turbo" + +# 占坑期联调开关:DUOBIBI_MOCK_LLM=1 时各端点跳过真实 LLM 调用,改用本地确定性 +# 算法造数据(不烧额度 / 不依赖外网,见 app/api/arena.py、app/api/worth_buy.py)。 +MOCK_LLM = os.environ.get("DUOBIBI_MOCK_LLM", "").strip().lower() in ("1", "true", "yes", "on") +EXTRA_PARAMS: dict[str, Any] = {"thinking": {"type": "disabled"}} + +DEFAULT_TEMPERATURE = 0.0 +DEFAULT_MAX_TOKENS = 1024 + +_client = ZhipuAiClient(api_key=ZHIPU_API_KEY) + + +def chat( + messages: list[dict[str, Any]], + temperature: float = DEFAULT_TEMPERATURE, + max_tokens: int = DEFAULT_MAX_TOKENS, +) -> str: + start = time.time() + response = _client.chat.completions.create( + model=MODEL, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + **EXTRA_PARAMS, + ) + latency_ms = int((time.time() - start) * 1000) + + content = (response.choices[0].message.content or "").strip() + usage = response.usage + if usage is not None: + logger.info( + "[LLM] model=%s tokens in=%s out=%s total=%s latency=%sms", + MODEL, + usage.prompt_tokens, + usage.completion_tokens, + usage.total_tokens, + latency_ms, + ) + else: + logger.info("[LLM] model=%s latency=%sms (no usage)", MODEL, latency_ms) + return content diff --git a/duobibi(mock)backend/duobibi-server/app/main.py b/duobibi(mock)backend/duobibi-server/app/main.py new file mode 100644 index 0000000..a052bb2 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/main.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import logging +import sys + +from fastapi import FastAPI + +# 让应用自己的 logger(app.*)在 INFO 级别输出到 stdout/journal, +# uvicorn 自带 logging config 只管它自己的 access log +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stdout, +) + +from app import db +from app.api.arena import router as arena_router +from app.api.auth import router as auth_router +from app.api.parse import router as parse_router +from app.api.parse_image import router as parse_image_router +from app.api.parse_ocr import router as parse_ocr_router +from app.api.parse_text import router as parse_text_router +from app.api.protect import router as protect_router +from app.api.quick_quote import router as quick_quote_router +from app.api.wish import router as wish_router +from app.api.worth_buy import router as worth_buy_router + +app = FastAPI(title="DuoBiBi Mock", version="1.0.0") + + +@app.on_event("startup") +def _startup() -> None: + # IF NOT EXISTS 建表,首次启动建库 + 后续启动无害 + db.init_schema() + + +app.include_router(parse_router) +app.include_router(parse_image_router) +app.include_router(parse_ocr_router) +app.include_router(parse_text_router) +app.include_router(protect_router) +app.include_router(quick_quote_router) +app.include_router(auth_router) +app.include_router(wish_router) +# 多比比新增端点 +app.include_router(arena_router) +app.include_router(worth_buy_router) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/duobibi(mock)backend/duobibi-server/app/mock_extractor.py b/duobibi(mock)backend/duobibi-server/app/mock_extractor.py new file mode 100644 index 0000000..6f293e7 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/mock_extractor.py @@ -0,0 +1,180 @@ +"""Mock 模式的"提取/归簇/估常见价"实现。 + +用于占坑期 iOS 端开发与审核演示:不调真实 LLM,直接按图片字节哈希在 +预置商品池中轮询返回,稳定可重放,且能自然制造"归簇命中"和"新建簇"两种路径。 + +接真模型时: +- 把 routes 里调用 mock_extract_image / mock_extract_text 的位置切换到调用真实 extractor +- 或在外层加 USE_MOCK 环境变量分流(本占坑期方案不引入开关,接模型时直接换实现) +""" +from __future__ import annotations + +import asyncio +import hashlib +import logging +import random +from typing import Optional + +from app.schemas import ClusterDtoStr + +logger = logging.getLogger("shagua.mock") + +# 10 件预置商品。挑选原则:覆盖 7 个白名单平台 + 3C/食品/服饰/小家电 多品类, +# 价格分布从 28 元(快餐)到 9999 元(手机)跨度大,便于测试统计页/趋势图视觉效果。 +MOCK_PRODUCTS: list[dict] = [ + {"title": "iPhone 15 Pro Max 256GB 暮光紫", "price": 9999.0, "source_app": "淘宝", "typical_price": 13999.0}, + {"title": "海底捞外卖经典套餐 2-3 人餐", "price": 158.0, "source_app": "美团", "typical_price": 199.0}, + {"title": "元气森林白桃味气泡水 480ml*15", "price": 49.9, "source_app": "京东", "typical_price": 65.0}, + {"title": "Nike Air Force 1 三色刺绣男款 41", "price": 599.0, "source_app": "拼多多", "typical_price": 799.0}, + {"title": "海尔不锈钢真空保温杯 500ml", "price": 89.0, "source_app": "京东", "typical_price": 129.0}, + {"title": "麦当劳麦辣鸡腿堡套餐(大份)", "price": 28.0, "source_app": "美团", "typical_price": 35.0}, + {"title": "飞利浦多功能旋转剃须刀 S5586", "price": 469.0, "source_app": "淘宝", "typical_price": 599.0}, + {"title": "Lululemon Align 高腰瑜伽裤 25", "price": 750.0, "source_app": "抖音", "typical_price": 980.0}, + {"title": "蒙牛特仑苏纯牛奶 250ml*16 礼盒", "price": 65.0, "source_app": "京东", "typical_price": 88.0}, + {"title": "戴森 V12 Detect Slim 无线吸尘器", "price": 3699.0, "source_app": "淘宝", "typical_price": 4490.0}, +] + + +# 让"同一张图触发同一个商品"可复现,但同一个 mock 商品在不同 record 里, +# 价格上下浮动 ±5%(模拟用户在不同时间记到的不同到手价),便于看趋势图效果。 +def _picked_with_jitter(base: dict, jitter_seed: int) -> dict: + rnd = random.Random(jitter_seed) + delta = rnd.uniform(-0.05, 0.05) + price = round(base["price"] * (1 + delta), 2) + return { + "title": base["title"], + "price": price, + "source_app": base["source_app"], + "typical_price": base["typical_price"], + } + + +def _pick_by_bytes(blob: bytes) -> dict: + """字节稳定哈希到预置商品。同样字节 → 同样商品,便于真机重放。""" + h = hashlib.sha256(blob).digest() + idx = h[0] % len(MOCK_PRODUCTS) + # 用第二个字节作为价格抖动种子,让"同一商品"在多次记账时价格略有差异 + jitter_seed = h[1] + return _picked_with_jitter(MOCK_PRODUCTS[idx], jitter_seed) + + +def _pick_by_text(title: str, price: float) -> dict: + """文本场景: 客户端已知 title/price,后端只决定 typical_price。 + 根据 title 哈希挑一个 mock 商品的 typical_price 作为参考价。""" + h = hashlib.sha256(title.encode("utf-8")).digest() + idx = h[0] % len(MOCK_PRODUCTS) + return MOCK_PRODUCTS[idx] + + +def _match_cluster(picked_title: str, clusters: list[ClusterDtoStr]) -> Optional[str]: + """简易归簇: 取 picked title 前 4 个字符,在 clusters 里找包含该子串的; + 找到就返回该 cluster.id (字符串)。 + + 这不是真实 LLM 的语义归簇能力,只是为了让客户端在测试中能稳定触发"归簇命中"路径: + 用户第一次记某商品 → 新建簇;再次记同 hash 的图片 → 命中已有簇。 + """ + if not clusters: + return None + needle = picked_title[:4] if len(picked_title) >= 4 else picked_title + for c in clusters: + if needle and needle in c.title: + return c.id + return None + + +async def _simulate_latency() -> None: + """模拟真实 LLM 延迟 1.5-2.5 秒,让 iOS 端 loading UI 测起来真实。 + + **必须用 asyncio.sleep 而非 time.sleep**:本模块被 async 路由 (parse_image) 调用, + 同步 sleep 会阻塞整个 event loop —— 任何一个请求 sleep 期间,server 无法处理 + 任何其他请求(健康检查、其他识别请求都会卡)。占坑期 QPS 低也会因为并发审核 + 被串行化,严重影响体验。 + """ + await asyncio.sleep(random.uniform(1.5, 2.5)) + + +async def mock_extract_image(image_bytes: bytes, clusters: list[ClusterDtoStr]) -> dict: + """图片版 mock 提取。返回与真实 extractor 同形状的 dict。 + + 入参: + - image_bytes: 客户端上传的截图原始字节(已被客户端降采样) + - clusters: 客户端发来的已有商品簇列表 + + 返回(success): + {success, title, price, source_app, cluster_id, typical_price} + """ + await _simulate_latency() + picked = _pick_by_bytes(image_bytes) + cluster_id = _match_cluster(picked["title"], clusters) + + logger.info( + "mock image: bytes=%d picked=%r cluster_id=%s", + len(image_bytes), + picked["title"], + cluster_id, + ) + return { + "success": True, + "title": picked["title"], + "price": picked["price"], + "source_app": picked["source_app"], + "cluster_id": cluster_id, + "typical_price": picked["typical_price"], + } + + +async def mock_extract_text( + title: str, + price: float, + source_app: str, + clusters: list[ClusterDtoStr], +) -> dict: + """文本版 mock: 客户端已知 title/price/source_app,后端只决定归簇 + 估常见价。""" + await _simulate_latency() + picked = _pick_by_text(title, price) + typical_price = picked["typical_price"] + cluster_id = _match_cluster(title, clusters) + + logger.info( + "mock text: title=%r price=%.2f source=%s cluster_id=%s typical=%.2f", + title, + price, + source_app, + cluster_id, + typical_price, + ) + return { + "success": True, + "title": title, + "price": price, + "source_app": source_app, + "cluster_id": cluster_id, + "typical_price": typical_price, + } + + +async def mock_extract_ocr(ocr_text: str, clusters: list[ClusterDtoStr]) -> dict: + """OCR 文本版 mock:按整段 OCR 文本的哈希挑预置商品,稳定可重放。 + + 与 image / text 版同形状返回,客户端复用同一套结果处理逻辑。 + (此前 main.py 引用了 parse_ocr router 但实现缺失会导致启动 ImportError,这里补齐对应 mock。) + """ + await _simulate_latency() + h = hashlib.sha256(ocr_text.encode("utf-8")).digest() + picked = _picked_with_jitter(MOCK_PRODUCTS[h[0] % len(MOCK_PRODUCTS)], h[1]) + cluster_id = _match_cluster(picked["title"], clusters) + + logger.info( + "mock ocr: chars=%d picked=%r cluster_id=%s", + len(ocr_text), + picked["title"], + cluster_id, + ) + return { + "success": True, + "title": picked["title"], + "price": picked["price"], + "source_app": picked["source_app"], + "cluster_id": cluster_id, + "typical_price": picked["typical_price"], + } diff --git a/duobibi(mock)backend/duobibi-server/app/schemas.py b/duobibi(mock)backend/duobibi-server/app/schemas.py new file mode 100644 index 0000000..256836c --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/app/schemas.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class NodeDto(BaseModel): + """客户端无障碍序列化的节点。占坑期后端不解析,字段保留为兼容未来真实解析。""" + + view_id: Optional[str] = None + text: Optional[str] = None + desc: Optional[str] = None + children: List["NodeDto"] = Field(default_factory=list) + + +class ClusterDto(BaseModel): + """Android `/parse` 接口用,id 是 Room 自增整数。""" + + id: int + title: str + + +class ParseRequest(BaseModel): + package_name: str + tree: Optional[NodeDto] = None + clusters: List[ClusterDto] = Field(default_factory=list) + + +class ParseResponse(BaseModel): + title: str + price: float + source_app: str + cluster_id: Optional[int] = None + typical_price: float + + +NodeDto.model_rebuild() + + +class QuickQuoteRequest(BaseModel): + """Android `/api/v1/quick-quote` 接口用:用户主动输入商品名查询比价。 + 无需 price(用户来查的就是不知道价),只需 title + 已有簇用于归簇判断。 + """ + + title: str + clusters: List[ClusterDto] = Field(default_factory=list) + + +class QuickQuoteResponse(BaseModel): + """quick-quote 响应:LLM 估的市场常见价 + 是否命中已有簇。""" + + title: str # 规范化后的标题(LLM 可能整理一下用户输入) + typical_price: float # 市场常见价,LLM 必须给数字 + cluster_id: Optional[int] = None # 命中已有簇的 id;null 表示用户没记过同款 + + +# ---------- iOS 新接口用的 DTO (cluster id 用字符串以兼容 UUID) ---------- # + + +class ClusterDtoStr(BaseModel): + """iOS `/parse-image` / `/parse-text` 接口用,id 是 SwiftData 的 UUID 字符串。""" + + id: str + title: str + + +class ParseTextRequest(BaseModel): + """iOS 手动新建记录时调用:客户端已知 title/price/source_app, + 后端只做归簇 + 估市场常见价。""" + + title: str + price: float + source_app: str + clusters: List[ClusterDtoStr] = Field(default_factory=list) + + +class ParseStrResponse(BaseModel): + """iOS 三个接口的统一响应。结构同 ParseResponse,但 cluster_id 是字符串。""" + + title: str + price: float + source_app: str + cluster_id: Optional[str] = None + typical_price: float + + +class ParseOcrRequest(BaseModel): + """iOS 截图 OCR 后调用:客户端只发 OCR 文本 + 已有簇, + 由后端 LLM 抠出 title/price/source_app/cluster_id/typical_price 全部 5 项。""" + + ocr_text: str + clusters: List[ClusterDtoStr] = Field(default_factory=list) + + +# ---------- 价保哨兵 ---------- # + + +class TrackPriceRequest(BaseModel): + """Android `/api/v1/track-price`:用户告知"我在 X 平台 ¥XXX 买了 Y" → 服务端 + 用 LLM 估当前合理价 + 哈希抖动模拟降价,返给客户端做"该申请价保了"通知判断。 + + 占坑期实现 LLM mock(无真实平台价 API)。未来接真 API 时客户端 0 改动。 + """ + + platform: str # "京东" / "淘宝" / "拼多多" / "抖音" 等中文平台名 + product_title: str # 商品完整标题 + purchase_price: float # 用户购买时支付的价格(参考用,实际抖动以 LLM 给的基础价为锚) + purchase_at: int # 购买时间(秒级 epoch),用于"距今 N 天"提示 + 抖动种子 + + +class TrackPriceResponse(BaseModel): + """track-price 响应:当前价 + 跌涨趋势 + 可省金额。 + + 客户端按 `savings > 0` 决定是否发"该申请价保了"通知。 + """ + + current_price: float # 当前估算价(抖动后的最终输出) + base_price: float # LLM 估的"日常常见价",抖动前的锚 + trend: str # "down" / "flat" / "up" + savings: float # purchase_price - current_price;> 0 = 可申请价保 + checked_at: int # 服务端响应生成时间(秒级 epoch) + + +# ---------- 多比比新增:比价擂台 ---------- # + + +class ArenaQuoteRequest(BaseModel): + """多比比 `/api/v1/arena-quote`:用户输入商品名,后端 LLM 估各平台到手价 + 市场常见价。""" + + title: str + platforms: Optional[List[str]] = None # 不传则用后端默认 4 大电商 + + +class ArenaPlatformQuote(BaseModel): + platform: str + price: float + note: str = "" + + +class ArenaQuoteResponse(BaseModel): + title: str # 归一化后的标题 + typical_price: float # 市场常见价(各平台中位) + quotes: List[ArenaPlatformQuote] + lowest_platform: Optional[str] = None + + +# ---------- 多比比新增:AI 值不值得买 ---------- # + + +class WorthBuyRequest(BaseModel): + """多比比 `/api/v1/worth-buy`:给商品 + 当前到手价,LLM 评估值不值得现在买。""" + + title: str + price: float + platform: Optional[str] = None + + +class WorthBuyResponse(BaseModel): + score: int # 0-100,越高越值得现在买 + verdict: str # "buy" / "wait" / "neutral" + headline: str # 一句话结论 + reasons: List[str] + typical_price: float + best_time: str diff --git a/duobibi(mock)backend/duobibi-server/data.db b/duobibi(mock)backend/duobibi-server/data.db new file mode 100644 index 0000000..a1aa3a7 Binary files /dev/null and b/duobibi(mock)backend/duobibi-server/data.db differ diff --git a/duobibi(mock)backend/duobibi-server/deploy/duobibi-mock.service b/duobibi(mock)backend/duobibi-server/deploy/duobibi-mock.service new file mode 100644 index 0000000..088a740 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/deploy/duobibi-mock.service @@ -0,0 +1,22 @@ +[Unit] +Description=DuoBiBi Mock Backend (FastAPI / uvicorn) +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/duobibi-server +Environment="PATH=/opt/duobibi-server/.venv/bin:/usr/bin:/bin" +ExecStart=/opt/duobibi-server/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8766 --workers 1 --log-level info +Restart=on-failure +RestartSec=3 + +# 安全收紧 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/opt/duobibi-server +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/duobibi(mock)backend/duobibi-server/deploy/nginx/api.duobibi.com.conf b/duobibi(mock)backend/duobibi-server/deploy/nginx/api.duobibi.com.conf new file mode 100644 index 0000000..e099d34 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/deploy/nginx/api.duobibi.com.conf @@ -0,0 +1,36 @@ +# 80 端口: 强制跳转到 443 +server { + listen 80; + listen [::]:80; + server_name api.duobibi.com; + return 301 https://$host$request_uri; +} + +# 443 端口: 反代到本地 uvicorn (多比比独立端口 8766,与傻瓜比价 8765 并存) +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name api.duobibi.com; + + ssl_certificate /etc/nginx/ssl/api.duobibi.com.pem; + ssl_certificate_key /etc/nginx/ssl/api.duobibi.com.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Android 控件树最大上限 ~3MB;截图客户端兜底 5MB + multipart overhead → 6MB + client_max_body_size 6m; + + location / { + proxy_pass http://127.0.0.1:8766; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } +} diff --git a/duobibi(mock)backend/duobibi-server/pyproject.toml b/duobibi(mock)backend/duobibi-server/pyproject.toml new file mode 100644 index 0000000..86cfe5a --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "duobibi-server" +version = "1.0.0" +description = "Mock + LLM backend for 多比比 (placeholder release)" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "pydantic>=2.9.0", + "zai-sdk>=0.0.4", + "python-multipart>=0.0.9", + # 极光一键登录:RSA 解密极光返回的加密手机号(PKCS#1 v1.5 padding) + "cryptography>=42.0.0", +] + +[project.optional-dependencies] +dev = [ + "httpx>=0.27.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", +] + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/duobibi(mock)backend/duobibi-server/run-backend.ps1 b/duobibi(mock)backend/duobibi-server/run-backend.ps1 new file mode 100644 index 0000000..56e0b12 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/run-backend.ps1 @@ -0,0 +1,70 @@ +<# +.SYNOPSIS + 本机启动「多比比」后端 (FastAPI / uvicorn)。 + +.DESCRIPTION + 默认离线 mock 模式:不调智谱 LLM、不烧额度、不依赖外网。 + 自动用 conda 环境 pricebot 的 Python,并设好必需的环境变量 + (DUOBIBI_DB_PATH / PYTHONUTF8 / DUOBIBI_MOCK_LLM),无需先 conda activate。 + +.EXAMPLE + .\run-backend.ps1 # 默认:mock 模式, 监听 127.0.0.1:8766 + .\run-backend.ps1 -Reload # 改代码自动热重载 (开发用) + .\run-backend.ps1 -Lan # 监听 0.0.0.0,供真机/同局域网设备连 + .\run-backend.ps1 -Real # 连真实智谱 LLM (需可用 key,否则 429) + .\run-backend.ps1 -Port 8888 # 换端口 (App 的 BASE_URL 也要同步改) +#> +param( + [int]$Port = 8766, + [switch]$Lan, # 监听 0.0.0.0 (真机联调); 默认仅本机 127.0.0.1 + [switch]$Real, # 连真实 LLM; 默认离线 mock + [switch]$Reload # uvicorn 热重载 (改代码自动重启,开发用) +) + +$ErrorActionPreference = "Stop" + +# 脚本所在目录就是 duobibi-server/,用它定位项目与 DB,不写死项目路径 +$ServerDir = $PSScriptRoot + +# pricebot 环境的 Python。若你的 conda 装在别处,改这一行即可。 +$Python = "C:\Users\muzhiyuan\anaconda3\envs\pricebot\python.exe" +if (-not (Test-Path $Python)) { + Write-Error "找不到 pricebot 环境的 Python:`n $Python`n请确认 conda 环境存在 (conda env list),或修改本脚本里的 `$Python 路径。" + exit 1 +} + +# --- 必需的环境变量 --- +# DB 路径:不设的话代码默认 /opt/duobibi-server/data.db,Windows 上建库会失败 +$env:DUOBIBI_DB_PATH = Join-Path $ServerDir "data.db" +# 让日志里的 ¥ / 中文不触发 Windows GBK 控制台的 UnicodeEncodeError +$env:PYTHONUTF8 = "1" + +if ($Real) { + Remove-Item Env:\DUOBIBI_MOCK_LLM -ErrorAction SilentlyContinue + Write-Host "[mode ] 真实 LLM (需可用智谱 key,当前硬编码 key 可能 429)" -ForegroundColor Yellow +} else { + $env:DUOBIBI_MOCK_LLM = "1" + Write-Host "[mode ] 离线 mock (默认,不烧额度/不连外网)" -ForegroundColor Green +} + +$BindHost = if ($Lan) { "0.0.0.0" } else { "127.0.0.1" } + +Write-Host "[serve] http://${BindHost}:$Port" -ForegroundColor Cyan +Write-Host "[db ] $($env:DUOBIBI_DB_PATH)" -ForegroundColor DarkGray +if ($Lan) { + Write-Host "[lan ] 真机请连这台电脑的局域网 IP:$Port (需同一 WiFi + 防火墙放行该端口)" -ForegroundColor Yellow +} +Write-Host "[check] 另开一个窗口验证: Invoke-RestMethod http://127.0.0.1:$Port/health" -ForegroundColor DarkGray +Write-Host "[stop ] Ctrl+C 停止" -ForegroundColor DarkGray +Write-Host "" + +# --- 启动 uvicorn (用 --app-dir 让 app.main 可解析,避免受当前工作目录影响) --- +$uvArgs = @( + "-m", "uvicorn", "app.main:app", + "--app-dir", $ServerDir, + "--host", $BindHost, + "--port", "$Port" +) +if ($Reload) { $uvArgs += "--reload" } + +& $Python @uvArgs diff --git a/duobibi(mock)backend/duobibi-server/tests/__init__.py b/duobibi(mock)backend/duobibi-server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/duobibi(mock)backend/duobibi-server/tests/test_arena_worth.py b/duobibi(mock)backend/duobibi-server/tests/test_arena_worth.py new file mode 100644 index 0000000..7e1c236 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/tests/test_arena_worth.py @@ -0,0 +1,95 @@ +"""多比比新增端点的冒烟测试。LLM 调用被 monkeypatch 成固定 JSON,避免真打智谱。""" +from __future__ import annotations + +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + +ARENA_JSON = ( + '{"title":"iPhone 15 Pro 256GB","typical_price":8299.0,' + '"quotes":[{"platform":"淘宝","price":8099.0,"note":""},' + '{"platform":"京东","price":8299.0,"note":"自营"},' + '{"platform":"拼多多","price":7888.0,"note":"百亿补贴"},' + '{"platform":"抖音","price":8190.0,"note":""}]}' +) + +WORTH_JSON = ( + '{"score":78,"verdict":"buy","headline":"现在入手划算",' + '"reasons":["低于市场常见价约 2%","距下次大促还有一段时间"],' + '"typical_price":8299.0,"best_time":"现在就合适"}' +) + + +def test_arena_quote_basic() -> None: + with patch("app.api.arena.chat", return_value=ARENA_JSON): + r = client.post("/api/v1/arena-quote", json={"title": "iPhone 15 Pro 256GB"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["typical_price"] > 0 + assert len(body["quotes"]) == 4 + assert body["lowest_platform"] == "拼多多" # 7888 最低 + + +def test_arena_quote_synthesizes_missing_platforms() -> None: + """LLM 只给 1 个平台,其余平台应被确定性合成,保证每行有值。""" + only_one = '{"title":"x","typical_price":100.0,"quotes":[{"platform":"淘宝","price":95.0}]}' + with patch("app.api.arena.chat", return_value=only_one): + r = client.post( + "/api/v1/arena-quote", + json={"title": "x", "platforms": ["淘宝", "京东", "拼多多"]}, + ) + assert r.status_code == 200, r.text + quotes = r.json()["quotes"] + assert len(quotes) == 3 + assert {q["platform"] for q in quotes} == {"淘宝", "京东", "拼多多"} + + +def test_arena_quote_empty_title() -> None: + r = client.post("/api/v1/arena-quote", json={"title": " "}) + assert r.status_code == 422 + + +def test_worth_buy_basic() -> None: + with patch("app.api.worth_buy.chat", return_value=WORTH_JSON): + r = client.post( + "/api/v1/worth-buy", + json={"title": "iPhone 15 Pro 256GB", "price": 8099.0}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert 0 <= body["score"] <= 100 + assert body["verdict"] in {"buy", "wait", "neutral"} + assert isinstance(body["reasons"], list) and body["reasons"] + + +def test_worth_buy_fallback_on_garbage() -> None: + """LLM 返回非 JSON 垃圾时,接口仍给中性结论而非 5xx。""" + with patch("app.api.worth_buy.chat", return_value="不是 JSON 的废话"): + r = client.post("/api/v1/worth-buy", json={"title": "x", "price": 50.0}) + assert r.status_code == 200, r.text + body = r.json() + assert body["typical_price"] > 0 + assert body["verdict"] in {"buy", "wait", "neutral"} + + +def test_worth_buy_invalid_price() -> None: + r = client.post("/api/v1/worth-buy", json={"title": "x", "price": 0}) + assert r.status_code == 422 + + +def test_parse_ocr_boots_and_works() -> None: + """parse-ocr 端点存在(修复了缺失 import),mock 延迟被关掉后能正常返回。""" + async def _instant(_seconds): + return None + + with patch("app.mock_extractor.asyncio.sleep", _instant): + r = client.post( + "/api/v1/parse-ocr", + json={"ocr_text": "iPhone 15 Pro Max 256GB ¥8099 京东自营", "clusters": []}, + ) + assert r.status_code == 200, r.text + assert r.json()["price"] > 0 diff --git a/duobibi(mock)backend/duobibi-server/tests/test_health.py b/duobibi(mock)backend/duobibi-server/tests/test_health.py new file mode 100644 index 0000000..6fd10a9 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/tests/test_health.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health() -> None: + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} diff --git a/duobibi(mock)backend/duobibi-server/tests/test_parse_image.py b/duobibi(mock)backend/duobibi-server/tests/test_parse_image.py new file mode 100644 index 0000000..bfd86c6 --- /dev/null +++ b/duobibi(mock)backend/duobibi-server/tests/test_parse_image.py @@ -0,0 +1,198 @@ +"""新接口的端到端测试。会真实跑到 mock_extractor,因此每个 case 会 sleep 1.5-2.5s。 +我们 monkey-patch 把 sleep 拿掉,加快测试。""" +from __future__ import annotations + +import io +import json +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def _no_sleep(): + """禁用 mock_extractor 的真实异步延迟,测试运行时间从 ~10s 降到 < 1s。""" + async def _instant(_seconds): + return None + with patch("app.mock_extractor.asyncio.sleep", _instant): + yield + + +def _fake_image(payload: bytes = b"fake jpeg bytes 0123456789") -> tuple: + return ("image", ("test.jpg", io.BytesIO(payload), "image/jpeg")) + + +def test_parse_image_basic() -> None: + r = client.post( + "/api/v1/parse-image", + files=[_fake_image()], + data={"clusters": "[]"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["title"] + assert body["price"] > 0 + assert body["typical_price"] > 0 + assert body["source_app"] in {"淘宝", "京东", "拼多多", "抖音", "美团", "饿了么"} + assert body["cluster_id"] is None # 空 clusters 必然新建 + + +def test_parse_image_same_bytes_same_product() -> None: + """同样字节 → 同样商品(便于真机重放)。允许价格因抖动小幅不同。""" + r1 = client.post("/api/v1/parse-image", files=[_fake_image(b"AAAA")], data={"clusters": "[]"}) + r2 = client.post("/api/v1/parse-image", files=[_fake_image(b"AAAA")], data={"clusters": "[]"}) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert r1.json()["title"] == r2.json()["title"] + assert r1.json()["source_app"] == r2.json()["source_app"] + + +def test_parse_image_cluster_match() -> None: + """如果 clusters 里有 title 与 mock 选中商品共享前 4 字符,应命中该 cluster。""" + # 先发一次拿到 picked title + r1 = client.post("/api/v1/parse-image", files=[_fake_image(b"BBBB")], data={"clusters": "[]"}) + picked_title = r1.json()["title"] + cluster_id = "uuid-fake-1" + clusters = [{"id": cluster_id, "title": picked_title}] + r2 = client.post( + "/api/v1/parse-image", + files=[_fake_image(b"BBBB")], + data={"clusters": json.dumps(clusters)}, + ) + assert r2.status_code == 200 + assert r2.json()["cluster_id"] == cluster_id + + +def test_parse_image_no_image() -> None: + r = client.post( + "/api/v1/parse-image", + files=[("image", ("empty.jpg", io.BytesIO(b""), "image/jpeg"))], + data={"clusters": "[]"}, + ) + assert r.status_code == 422 + assert r.json()["detail"] == "no_image" + + +def test_parse_image_missing_image_field() -> None: + r = client.post("/api/v1/parse-image", data={"clusters": "[]"}) + assert r.status_code == 422 # FastAPI 字段校验失败也返回 422 + + +def test_parse_image_invalid_clusters_json() -> None: + r = client.post( + "/api/v1/parse-image", + files=[_fake_image()], + data={"clusters": "not a json"}, + ) + assert r.status_code == 422 + assert r.json()["detail"] == "invalid_clusters" + + +def test_parse_image_clusters_not_array() -> None: + r = client.post( + "/api/v1/parse-image", + files=[_fake_image()], + data={"clusters": '{"id":"x","title":"y"}'}, + ) + assert r.status_code == 422 + assert r.json()["detail"] == "invalid_clusters" + + +def test_parse_image_too_large() -> None: + big = b"x" * (5 * 1024 * 1024 + 1) + r = client.post( + "/api/v1/parse-image", + files=[("image", ("big.jpg", io.BytesIO(big), "image/jpeg"))], + data={"clusters": "[]"}, + ) + assert r.status_code == 422 + assert r.json()["detail"] == "image_too_large" + + +def test_parse_text_basic() -> None: + r = client.post( + "/api/v1/parse-text", + json={ + "title": "iPhone 15 Pro Max 256GB", + "price": 9999.0, + "source_app": "淘宝", + "clusters": [], + }, + ) + assert r.status_code == 200 + body = r.json() + assert body["title"] == "iPhone 15 Pro Max 256GB" + assert body["price"] == 9999.0 + assert body["source_app"] == "淘宝" + assert body["typical_price"] > 0 + assert body["cluster_id"] is None + + +def test_parse_text_cluster_match() -> None: + """同样 title 前 4 字符的 cluster 应命中。""" + cluster_id = "uuid-text-1" + r = client.post( + "/api/v1/parse-text", + json={ + "title": "iPhone 15 Pro Max 1TB 暮光紫", + "price": 11999.0, + "source_app": "京东", + "clusters": [{"id": cluster_id, "title": "iPhone 15 Pro 256GB"}], + }, + ) + assert r.status_code == 200 + assert r.json()["cluster_id"] == cluster_id + + +def test_parse_text_empty_title() -> None: + r = client.post( + "/api/v1/parse-text", + json={"title": " ", "price": 9.9, "source_app": "淘宝", "clusters": []}, + ) + assert r.status_code == 422 + assert r.json()["detail"] == "empty_title" + + +def test_parse_text_invalid_price() -> None: + r = client.post( + "/api/v1/parse-text", + json={"title": "x", "price": 0, "source_app": "淘宝", "clusters": []}, + ) + assert r.status_code == 422 + assert r.json()["detail"] == "invalid_price" + + +def test_parse_text_negative_price() -> None: + r = client.post( + "/api/v1/parse-text", + json={"title": "x", "price": -1, "source_app": "淘宝", "clusters": []}, + ) + assert r.status_code == 422 + + +def test_parse_image_invalid_cluster_schema() -> None: + """clusters 数组里某个对象缺 id/title 字段 → Pydantic ValidationError 必须返回 422 而非 500""" + r = client.post( + "/api/v1/parse-image", + files=[_fake_image()], + data={"clusters": '[{"id":"only-id-no-title"}]'}, + ) + assert r.status_code == 422 + assert r.json()["detail"] == "invalid_clusters" + + +def test_concurrent_parse_image_does_not_block_event_loop(_no_sleep) -> None: + """禁用 sleep 后并发应该极快;若 event loop 被阻塞会显著退化。 + 主要保护 mock_extract_image 永远是 async + await。""" + import time as _t + start = _t.time() + for _ in range(10): + r = client.post("/api/v1/parse-image", files=[_fake_image()], data={"clusters": "[]"}) + assert r.status_code == 200 + # 10 次串行(TestClient 是同步客户端)且 sleep 被 mock 掉,应远 < 1s + assert _t.time() - start < 2.0