feat: 使用小技巧系统 + 小组件点击提示 + 图表默认tooltip + 添加小组件权限检测

- 新增 AppTips.kt:13条操作技巧,Dashboard随机展示一条hint,设置页TipsScreen查看全部
- 小组件左上角加 › 箭头提示可点击进APP,其他区域点切换服务
- 图表默认选中最后一根柱子,tooltip首次可见
- 添加小组件二次确认弹窗 + Manifest权限 + MIUI AppOps检测
- Dashboard首次进入显示长按拖拽排序提示横幅
- NavHost新增Tips路由
- 同步AGENTS.md和README.md
This commit is contained in:
WaterRain 2026-07-07 01:57:52 +00:00
parent 86aaef9a60
commit 814ae61f01
No known key found for this signature in database
11 changed files with 404 additions and 47 deletions

View File

@ -92,6 +92,7 @@ Compact(手机):
↘ UsageData(原始数据) ↘ UsageData(原始数据)
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计) → CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
↘ CCGO_USAGE_DATA(原始数据) ↘ CCGO_USAGE_DATA(原始数据)
→ Settings → Tips(使用小技巧)
返回用 guardedPop()(200ms 时间戳围栏,PopGuard 非 State 对象)+ Android predictive back。 返回用 guardedPop()(200ms 时间戳围栏,PopGuard 非 State 对象)+ Android predictive back。
Manifest 开启 `android:enableOnBackInvokedCallback="true"`;`navigation-compose` 保持 2.9.x 以上,使用后续 predictive back 修复。 Manifest 开启 `android:enableOnBackInvokedCallback="true"`;`navigation-compose` 保持 2.9.x 以上,使用后续 predictive back 修复。
@ -103,7 +104,7 @@ PopGuard 额外检查 previousBackStackEntry != null,且 popBackStack() 返回
Expanded(平板,≥840dp): Expanded(平板,≥840dp):
┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: when(pane) 原子切换 ─────┐ ┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: when(pane) 原子切换 ─────┐
│ │ │ ServiceDetail / OCGOUsage / CCGOUsage │ │ │ │ ServiceDetail / OCGOUsage / CCGOUsage │
│ │ │ Settings(内嵌 NavHost) │ │ │ │ Settings(内嵌 NavHost → Tips) │
└────────────────────────────────┘ └────────────────────────────────────────┘ └────────────────────────────────┘ └────────────────────────────────────────┘
右侧用量详情内部子路由:图表 → 总览 / 原始数据(OCGO/CCGO/Settings 各自用局部 NavHost) 右侧用量详情内部子路由:图表 → 总览 / 原始数据(OCGO/CCGO/Settings 各自用局部 NavHost)
面板切换用 when(pane) 分支(同一帧原子重组,零穿透),子路由由局部 NavHost 的 popBackStack() 内置防护。 面板切换用 when(pane) 分支(同一帧原子重组,零穿透),子路由由局部 NavHost 的 popBackStack() 内置防护。
@ -118,9 +119,25 @@ Expanded(平板,≥840dp):
- 进度条颜色按百分比动态变化(<50% 草莓粉 / 50-80% 暖橙 / >80% 玫红) - 进度条颜色按百分比动态变化(<50% 草莓粉 / 50-80% 暖橙 / >80% 玫红)
- **MIUI Widget 适配**:`miuiWidget` 标识 → 可拖入负一屏;`miui.appwidget.action.APPWIDGET_UPDATE` 曝光刷新(划到即触发,20s 冷却);`@android:id/background` 根布局 ID(系统统一裁切圆角) - **MIUI Widget 适配**:`miuiWidget` 标识 → 可拖入负一屏;`miui.appwidget.action.APPWIDGET_UPDATE` 曝光刷新(划到即触发,20s 冷却);`@android:id/background` 根布局 ID(系统统一裁切圆角)
- **自动刷新**:`onUpdate()` 内缓存为空或超过 5 分钟冷却时自动发送 `WidgetRefreshReceiver` 广播 - **自动刷新**:`onUpdate()` 内缓存为空或超过 5 分钟冷却时自动发送 `WidgetRefreshReceiver` 广播
- **一键添桌面**:Dashboard 顶部栏 + 按钮 → `requestPinAppWidget`(有 fallback 到 `ACTION_APPWIDGET_PICK`) - **一键添桌面**:Dashboard 顶部栏 + 按钮 → 二次确认弹窗 → 权限检测(Manifest `INSTALL_SHORTCUT` + MIUI AppOps `android:install_shortcut`)→ `requestPinAppWidget`(有 fallback 到 `ACTION_APPWIDGET_PICK`)
- 服务切换状态持久化到 SharedPreferences(`widget_auto_refresh` 中的 `display_service` key),切换后立即调用 `notifyDataChanged` 触发 `onUpdate()` 渲染 - 服务切换状态持久化到 SharedPreferences(`widget_auto_refresh` 中的 `display_service` key),切换后立即调用 `notifyDataChanged` 触发 `onUpdate()` 渲染
**小组件点击绑定**:
> `widget_wordmark` + `widget_open_hint`(`›` 箭头)→ 打开 APP;`widget_content` / `widget_switch` / `widget_service_title` → 切换服务广播 `ACTION_SWITCH_SERVICE`;`widget_refresh` → 刷新广播。
> 切换 requestCode=2,刷新 requestCode=1。左上角 `›` 箭头是可点击进 APP 的视觉提示。
**使用小技巧系统**:
> - `AppTips`(`ui/components/AppTips.kt`)集中管理 13 条技巧,每条含 `title` / `hint`(一句话)/ `detail`(详细说明)
> - Dashboard 卡片上方每次启动随机显示一条 `hint`(`remember { AppTips.randomHint() }`,不自动轮换)
> - 设置页 `💡 使用小技巧` 卡片 → `TipsScreen` 独立页面(Route `tips`),LazyColumn 逐条展示
> - 首次进入 Dashboard 显示一次性"长按卡片可拖拽排序"提示横幅,SharedPreferences `dashboard_ui_hints` 的 `drag_hint_shown` 标记
**图表默认 tooltip**:
> `StackedBarChart` / `LineChart` 的 `tooltipBucket` 初始值为 `buckets.lastOrNull()`,进入图表页即可看到最新时段数值详情,暗示图表可交互。
## RemoteViews 兼容性红线 ## RemoteViews 兼容性红线
以下元素在 Widget 布局中**不可用**,会导致「载入出现问题」: 以下元素在 Widget 布局中**不可用**,会导致「载入出现问题」:

View File

@ -34,10 +34,11 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 | | 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 | | 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 | | 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
| 📱 **桌面小组件** | 不打开 APP 也能看用量;支持四服务切换(OCGO/CCGO/Codex/Ollama)+ DeepSeek 余额;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) | | 📱 **桌面小组件** | 不打开 APP 也能看用量;支持四服务切换(OCGO/CCGO/Codex/Ollama)+ DeepSeek 余额;左上角 › 进 APP、其他区域点切换、↻ 刷新;可拖入负一屏;划到即自动刷新(MIUI 曝光刷新) |
| 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 | | 🔄 **自动同步** | 首页下拉自动同步用量;无缓存时启动自动全量同步;CCGO 详情页支持手动清除并重新同步 |
| 🌙 **深色模式** | 全局自适应 — App 内文字/图标/背景自动切换,小组件独立适配暗色布局 | | 🌙 **深色模式** | 全局自适应 — App 内文字/图标/背景自动切换,小组件独立适配暗色布局 |
| ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻 | | ➕ **一键添桌面** | APP 内点 + 直接添加小组件,不用去系统列表翻;二次确认 + 权限检测 |
| 💡 **使用小技巧** | 首页随机展示一条操作提示(每次启动刷新);设置页可查看全部 13 条隐藏操作技巧 |
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 | | ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC | | 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |

View File

@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<application <application
android:name=".RainyTokenApplication" android:name=".RainyTokenApplication"

View File

@ -35,6 +35,7 @@ import androidx.navigation.navArgument
import com.rainy.token.data.repository.CommandCodeUsageRepository import com.rainy.token.data.repository.CommandCodeUsageRepository
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.rememberWindowSizeClass import com.rainy.token.ui.components.rememberWindowSizeClass
import com.rainy.token.ui.components.TipsScreen
import com.rainy.token.ui.dashboard.DashboardScreen import com.rainy.token.ui.dashboard.DashboardScreen
import com.rainy.token.ui.dashboard.UsageChartViewModel import com.rainy.token.ui.dashboard.UsageChartViewModel
import com.rainy.token.ui.dashboard.UsageDataScreen import com.rainy.token.ui.dashboard.UsageDataScreen
@ -61,6 +62,7 @@ import com.rainy.token.ui.webview.WebViewLoginScreen
object Routes { object Routes {
const val DASHBOARD = "dashboard" const val DASHBOARD = "dashboard"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val TIPS = "tips"
const val CREDENTIAL_EDIT = "credential_edit/{type}" const val CREDENTIAL_EDIT = "credential_edit/{type}"
fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}" fun credentialEdit(type: ServiceType) = "credential_edit/${type.name}"
const val WEBVIEW_LOGIN = "webview_login/{type}" const val WEBVIEW_LOGIN = "webview_login/{type}"
@ -240,9 +242,13 @@ private fun CompactNavHost() {
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
SettingsScreen( SettingsScreen(
onBack = guardedPop, onBack = guardedPop,
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) } onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) },
onOpenTips = { navController.navigate(Routes.TIPS) }
) )
} }
composable(Routes.TIPS) {
TipsScreen(onBack = guardedPop)
}
composable( composable(
route = Routes.CREDENTIAL_EDIT, route = Routes.CREDENTIAL_EDIT,
arguments = listOf(navArgument("type") { type = NavType.StringType }) arguments = listOf(navArgument("type") { type = NavType.StringType })
@ -415,9 +421,13 @@ private fun ExpandedDetailPane(
onBack = onClose, onBack = onClose,
onEditCredential = { type -> onEditCredential = { type ->
settingsNavController.navigate(Routes.credentialEdit(type)) settingsNavController.navigate(Routes.credentialEdit(type))
} },
onOpenTips = { settingsNavController.navigate("tips") }
) )
} }
composable("tips") {
TipsScreen(onBack = { settingsNavController.popBackStack() })
}
composable( composable(
route = Routes.CREDENTIAL_EDIT, route = Routes.CREDENTIAL_EDIT,
arguments = listOf(navArgument("type") { type = NavType.StringType }) arguments = listOf(navArgument("type") { type = NavType.StringType })

View File

@ -0,0 +1,145 @@
package com.rainy.token.ui.components
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink
/**
* APP 使用小技巧列表。
* Dashboard 卡片上方随机展示一条,设置页可查看全部。
*/
object AppTips {
data class Tip(
val title: String,
val hint: String,
val detail: String
)
val tips: List<Tip> = listOf(
Tip("长按卡片拖拽排序", "长按首页卡片可以拖拽排序", "在首页长按任意服务卡片即可拖拽到想要的位置,排序会自动保存。"),
Tip("小组件点左上角进 APP", "小组件左上角标识可打开 APP", "桌面小组件左上角 RainyToken › 标识可以点击打开应用,其它区域点击切换服务。"),
Tip("图表点击查看详情", "点击图表柱子可查看时段详情", "在用量图表上点击或滑动手指,可查看对应时段的详细数值。"),
Tip("图表标题可点击", "点击图表标题弹出按模型明细", "图表卡片标题(如「消耗金额」)点击后会弹出按模型拆分的明细。"),
Tip("切换图表时区", "用量详情页可切换 UTC+0 / UTC+8", "用量详情页顶部可切换 UTC+0 / UTC+8,图表标签会跟随切换。"),
Tip("CCGO 清除并重新同步", "CCGO 详情页可清除缓存重新同步", "CCGO 用量详情页右上角「清除」可删除本地缓存并重新拉取全部数据。"),
Tip("查看用量详情", "服务卡片底部可进入用量图表", "有凭据的服务卡片底部会出现「查看用量详情」入口,点击进入图表统计。"),
Tip("原始数据浏览", "用量详情页右上角可查看原始记录", "用量详情页右上角「详细数据」可查看每条原始调用记录,支持分页跳转。"),
Tip("下拉刷新联动用量", "下拉刷新会同步余额和用量", "首页下拉刷新不仅刷新余额,还会自动同步 OCGO / CCGO 用量记录。"),
Tip("凭据获取教程", "凭据页底部有获取教程", "凭据编辑页底部的「如何获取」按钮有针对每个服务的详细操作步骤。"),
Tip("剪贴板自动导入", "Codex 支持从剪贴板一键导入", "Codex 凭据页支持「从剪贴板自动导入」,粘贴 auth.json 后一键填入。"),
Tip("详细数据页码跳转", "原始数据页可输入页码直跳", "原始数据页底部可输入页码直接跳转,数据量大时不必逐页翻。"),
Tip("小组件自动刷新", "划到小组件会自动刷新余额", "桌面小组件划到负一屏时会自动刷新余额,也可手动点 ↻ 按钮刷新。"),
)
/** 首页轮换用的一句话文案。 */
val shortHints: List<String> = tips.map { it.hint }
/** 随机取一条短文案。 */
fun randomHint(): String = shortHints.random()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TipsScreen(onBack: () -> Unit) {
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = {
Text(
"使用小技巧",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.Filled.ArrowBack,
contentDescription = "返回",
tint = StrawberryPink
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
itemsIndexed(AppTips.tips) { idx, tip ->
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${idx + 1}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = StrawberryPink
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = tip.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = tip.detail,
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
}
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}

View File

@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@ -81,11 +82,16 @@ import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusChip
import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusLevel
import com.rainy.token.ui.components.StatusStyle import com.rainy.token.ui.components.StatusStyle
import com.rainy.token.ui.components.AppTips
import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.theme.StrawberryPink
import android.app.AppOpsManager
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Process
import android.widget.Toast import android.widget.Toast
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -114,6 +120,15 @@ fun DashboardScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
var showAddWidgetConfirm by remember { mutableStateOf(false) }
// 一次性"长按拖拽排序"提示
var showDragHint by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val prefs = context.getSharedPreferences("dashboard_ui_hints", android.content.Context.MODE_PRIVATE)
if (!prefs.getBoolean("drag_hint_shown", false)) {
showDragHint = true
}
}
val cardOrder = remember { mutableStateListOf<String>() } val cardOrder = remember { mutableStateListOf<String>() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val saved = context.getSharedPreferences(DASHBOARD_ORDER_PREFS, android.content.Context.MODE_PRIVATE) val saved = context.getSharedPreferences(DASHBOARD_ORDER_PREFS, android.content.Context.MODE_PRIVATE)
@ -136,6 +151,29 @@ fun DashboardScreen(
lastRefreshing = uiState.refreshing lastRefreshing = uiState.refreshing
} }
if (showAddWidgetConfirm) {
AlertDialog(
onDismissRequest = { showAddWidgetConfirm = false },
title = { Text("添加桌面小组件?") },
text = { Text("确认后会请求桌面启动器添加雨晴Token小组件。部分系统仍会弹出系统确认窗口。") },
confirmButton = {
TextButton(
onClick = {
showAddWidgetConfirm = false
requestRainyTokenWidgetPin(context)
}
) {
Text("确认添加", color = StrawberryPink)
}
},
dismissButton = {
TextButton(onClick = { showAddWidgetConfirm = false }) {
Text("取消")
}
}
)
}
Scaffold( Scaffold(
containerColor = Color.Transparent, containerColor = Color.Transparent,
topBar = { topBar = {
@ -155,24 +193,7 @@ fun DashboardScreen(
} }
}, },
actions = { actions = {
// 添加小组件:优先 requestPinAppWidget,不支持则打开系统小组件选择器 IconButton(onClick = { showAddWidgetConfirm = true }) {
IconButton(onClick = {
val appWidgetManager = AppWidgetManager.getInstance(context)
val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java)
if (appWidgetManager.isRequestPinAppWidgetSupported) {
appWidgetManager.requestPinAppWidget(component, null, null)
} else {
// MIUI 等桌面回退:直接打开小组件选择器
try {
val pickerIntent = Intent(AppWidgetManager.ACTION_APPWIDGET_PICK).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
}
context.startActivity(pickerIntent)
} catch (e: Exception) {
Toast.makeText(context, "请在桌面长按 → 小组件 → 查找雨晴Token", Toast.LENGTH_LONG).show()
}
}
}) {
Icon( Icon(
imageVector = Icons.Filled.Add, imageVector = Icons.Filled.Add,
contentDescription = "添加小组件到桌面", contentDescription = "添加小组件到桌面",
@ -234,6 +255,14 @@ fun DashboardScreen(
.padding(contentPadding), .padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// 随机小技巧提示(每次启动随机一条)
val tipText = remember { AppTips.randomHint() }
Text(
text = "💡 $tipText",
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.padding(horizontal = 4.dp)
)
val items = rememberDashboardItems( val items = rememberDashboardItems(
cards = uiState.cards, cards = uiState.cards,
order = cardOrder, order = cardOrder,
@ -257,6 +286,41 @@ fun DashboardScreen(
.apply() .apply()
} }
) )
// 一次性"长按拖拽排序"提示横幅
if (showDragHint) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
showDragHint = false
context.getSharedPreferences("dashboard_ui_hints", android.content.Context.MODE_PRIVATE)
.edit().putBoolean("drag_hint_shown", true).apply()
},
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = StrawberryPink.copy(alpha = 0.12f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "💡 长按卡片可拖拽排序",
style = MaterialTheme.typography.bodySmall,
color = StrawberryPink,
modifier = Modifier.weight(1f)
)
Text(
text = "知道了",
style = MaterialTheme.typography.bodySmall,
color = StrawberryPink,
fontWeight = FontWeight.SemiBold
)
}
}
}
// 底部 footer:填空白 + 提供版本号 // 底部 footer:填空白 + 提供版本号
DashboardFooter() DashboardFooter()
} }
@ -635,3 +699,45 @@ private fun DashboardFooter() {
) )
} }
} }
private fun canInstallLauncherShortcut(context: Context): Boolean {
val manifestPermissionGranted = context.packageManager.checkPermission(
"com.android.launcher.permission.INSTALL_SHORTCUT",
context.packageName
) == PackageManager.PERMISSION_GRANTED
if (!manifestPermissionGranted) return false
val appOpsManager = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = runCatching {
appOpsManager.checkOpNoThrow("android:install_shortcut", Process.myUid(), context.packageName)
}.getOrNull() ?: return true
return mode == AppOpsManager.MODE_ALLOWED || mode == AppOpsManager.MODE_DEFAULT
}
private fun requestRainyTokenWidgetPin(context: Context) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val component = ComponentName(context, OpenCodeGoWidgetProvider::class.java)
if (!canInstallLauncherShortcut(context)) {
Toast.makeText(context, "桌面快捷方式权限已被拒绝,请在系统设置中允许后重试", Toast.LENGTH_LONG).show()
return
}
if (appWidgetManager.isRequestPinAppWidgetSupported) {
val requested = appWidgetManager.requestPinAppWidget(component, null, null)
if (!requested) {
Toast.makeText(context, "桌面未接受添加请求,请检查启动器权限", Toast.LENGTH_LONG).show()
}
return
}
val pickerIntent = Intent(AppWidgetManager.ACTION_APPWIDGET_PICK).apply {
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
}
val canOpenPicker = pickerIntent.resolveActivity(context.packageManager) != null
if (canOpenPicker) {
context.startActivity(pickerIntent)
} else {
Toast.makeText(context, "当前桌面不支持应用内添加,请在桌面长按 → 小组件 → 查找雨晴Token", Toast.LENGTH_LONG).show()
}
}

View File

@ -547,7 +547,7 @@ internal fun StackedBarChart(
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall) Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
return return
} }
var tooltipBucket by remember { mutableStateOf<ChartBucket?>(null) } var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
val density = LocalDensity.current val density = LocalDensity.current
val d = density.density val d = density.density
val barCount = buckets.size val barCount = buckets.size
@ -677,7 +677,7 @@ internal fun LineChart(
Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall) Text("暂无数据", color = InkMuted, style = MaterialTheme.typography.bodySmall)
return return
} }
var tooltipBucket by remember { mutableStateOf<ChartBucket?>(null) } var tooltipBucket by remember { mutableStateOf(buckets.lastOrNull()) }
val density = LocalDensity.current val density = LocalDensity.current
val d = density.density val d = density.density
val barCount = buckets.size val barCount = buckets.size

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -42,6 +43,7 @@ import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusChip
import com.rainy.token.ui.components.StatusLevel import com.rainy.token.ui.components.StatusLevel
import com.rainy.token.ui.components.StatusStyle import com.rainy.token.ui.components.StatusStyle
import com.rainy.token.ui.components.AppTips
import com.rainy.token.ui.theme.InkMuted import com.rainy.token.ui.theme.InkMuted
import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.theme.StrawberryPink
@ -58,6 +60,7 @@ import com.rainy.token.ui.theme.StrawberryPink
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEditCredential: (ServiceType) -> Unit, onEditCredential: (ServiceType) -> Unit,
onOpenTips: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel() viewModel: SettingsViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -120,6 +123,9 @@ fun SettingsScreen(
onClick = { onEditCredential(status.service) } onClick = { onEditCredential(status.service) }
) )
} }
item {
TipsCard(onClick = { onOpenTips() })
}
item { item {
Spacer(modifier = Modifier.padding(top = 8.dp)) Spacer(modifier = Modifier.padding(top = 8.dp))
AboutCard() AboutCard()
@ -200,3 +206,40 @@ private fun stateToChip(state: CredentialStatus.State): StatusStyle = when (stat
CredentialStatus.State.EXPIRED -> StatusStyle("已过期", StatusLevel.ERROR) CredentialStatus.State.EXPIRED -> StatusStyle("已过期", StatusLevel.ERROR)
CredentialStatus.State.WARNING -> StatusStyle("需重登", StatusLevel.WARNING) CredentialStatus.State.WARNING -> StatusStyle("需重登", StatusLevel.WARNING)
} }
@Composable
private fun TipsCard(onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = StrawberryPink.copy(alpha = 0.08f)),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "💡",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "使用小技巧",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = StrawberryPink
)
Text(
text = "查看全部 ${AppTips.tips.size} 条隐藏操作",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}

View File

@ -60,15 +60,24 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
for (widgetId in appWidgetIds) { for (widgetId in appWidgetIds) {
val views = RemoteViews(context.packageName, R.layout.widget_opencode_go) val views = RemoteViews(context.packageName, R.layout.widget_opencode_go)
// 点击 Widget → 打开 APP // 点击左上角品牌 → 打开 APP;点击其它内容区域 → 切换服务;刷新按钮单独刷新。
val intent = Intent(context, MainActivity::class.java).apply { val openAppIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
} }
val pendingIntent = PendingIntent.getActivity( val openAppPendingIntent = PendingIntent.getActivity(
context, 0, intent, context, 0, openAppIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
views.setOnClickPendingIntent(R.id.widget_content, pendingIntent) views.setOnClickPendingIntent(R.id.widget_wordmark, openAppPendingIntent)
views.setOnClickPendingIntent(R.id.widget_open_hint, openAppPendingIntent)
val switchPendingIntent = PendingIntent.getBroadcast(
context, 2, Intent(context, OpenCodeGoWidgetProvider::class.java).apply { action = ACTION_SWITCH_SERVICE },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_content, switchPendingIntent)
views.setOnClickPendingIntent(R.id.widget_switch, switchPendingIntent)
views.setOnClickPendingIntent(R.id.widget_service_title, switchPendingIntent)
// 刷新按钮 → 后台广播刷新 // 刷新按钮 → 后台广播刷新
val refreshPendingIntent = PendingIntent.getBroadcast( val refreshPendingIntent = PendingIntent.getBroadcast(
@ -77,12 +86,6 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
) )
views.setOnClickPendingIntent(R.id.widget_refresh, refreshPendingIntent) views.setOnClickPendingIntent(R.id.widget_refresh, refreshPendingIntent)
val switchPendingIntent = PendingIntent.getBroadcast(
context, 2, Intent(context, OpenCodeGoWidgetProvider::class.java).apply { action = ACTION_SWITCH_SERVICE },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_switch, switchPendingIntent)
// 读缓存并填充数据 // 读缓存并填充数据
runBlocking { runBlocking {
try { try {

View File

@ -30,6 +30,26 @@
android:contentDescription="RainyToken" android:contentDescription="RainyToken"
android:adjustViewBounds="true" /> android:adjustViewBounds="true" />
<TextView
android:id="@+id/widget_open_hint"
android:layout_width="10dp"
android:layout_height="24dp"
android:text="›"
android:textColor="#8A7A82"
android:textSize="14sp"
android:gravity="center" />
<TextView
android:id="@+id/widget_switch"
android:layout_width="48dp"
android:layout_height="24dp"
android:text="OCGO"
android:textColor="#FF85A2"
android:textSize="10sp"
android:textStyle="bold"
android:layout_marginStart="6dp"
android:gravity="center" />
<ProgressBar <ProgressBar
style="@android:style/Widget.ProgressBar.Horizontal" style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="0dp" android:layout_width="0dp"
@ -50,12 +70,13 @@
<TextView <TextView
android:id="@+id/widget_refresh" android:id="@+id/widget_refresh"
android:layout_width="wrap_content" android:layout_width="40dp"
android:layout_height="wrap_content" android:layout_height="24dp"
android:text="↻" android:text="↻"
android:textColor="#FF85A2" android:textColor="#FF85A2"
android:textSize="20sp" android:textSize="20sp"
android:layout_marginStart="6dp" /> android:layout_marginStart="6dp"
android:gravity="center" />
</LinearLayout> </LinearLayout>
<!-- OpenCode Go 标识 --> <!-- OpenCode Go 标识 -->
@ -72,6 +93,7 @@
android:contentDescription="OpenCode Go" android:contentDescription="OpenCode Go"
android:layout_marginEnd="5dp" /> android:layout_marginEnd="5dp" />
<TextView <TextView
android:id="@+id/widget_service_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="OpenCode Go · 用量" android:text="OpenCode Go · 用量"

View File

@ -31,17 +31,25 @@
android:contentDescription="RainyToken" android:contentDescription="RainyToken"
android:adjustViewBounds="true" /> android:adjustViewBounds="true" />
<TextView
android:id="@+id/widget_open_hint"
android:layout_width="10dp"
android:layout_height="24dp"
android:text="›"
android:textColor="#C4A0AB"
android:textSize="14sp"
android:gravity="center" />
<TextView <TextView
android:id="@+id/widget_switch" android:id="@+id/widget_switch"
android:layout_width="wrap_content" android:layout_width="48dp"
android:layout_height="wrap_content" android:layout_height="24dp"
android:text="OCGO" android:text="OCGO"
android:textColor="#FF85A2" android:textColor="#FF85A2"
android:textSize="10sp" android:textSize="10sp"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="6dp" android:layout_marginStart="6dp"
android:paddingStart="4dp" android:gravity="center" />
android:paddingEnd="4dp" />
<ProgressBar <ProgressBar
style="@android:style/Widget.ProgressBar.Horizontal" style="@android:style/Widget.ProgressBar.Horizontal"
@ -64,12 +72,13 @@
<TextView <TextView
android:id="@+id/widget_refresh" android:id="@+id/widget_refresh"
android:layout_width="wrap_content" android:layout_width="40dp"
android:layout_height="wrap_content" android:layout_height="24dp"
android:text="↻" android:text="↻"
android:textColor="#FF85A2" android:textColor="#FF85A2"
android:textSize="20sp" android:textSize="20sp"
android:layout_marginStart="6dp" /> android:layout_marginStart="6dp"
android:gravity="center" />
</LinearLayout> </LinearLayout>