UI更新
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# 项目长期记忆:强宝爱记账
|
# 项目长期记忆:强宝小助手
|
||||||
|
|
||||||
## 项目概况
|
## 项目概况
|
||||||
- 名称:`qiangbao_accounting`(强宝爱记账),一个 Flutter 记账类 App。
|
- 名称:`qiangbao_accounting`(强宝小助手),一个 Flutter 记账类 App。
|
||||||
- 定位:由 uniApp 项目 `pages/login/login.vue` 复刻迁移而来(见 `login_page.dart` 顶部注释)。
|
- 定位:由 uniApp 项目 `pages/login/login.vue` 复刻迁移而来(见 `login_page.dart` 顶部注释)。
|
||||||
- 环境:Flutter **3.47.4** stable / Dart **3.13.3**(Windows 开发)。
|
- 环境:Flutter **3.47.4** stable / Dart **3.13.3**(Windows 开发)。
|
||||||
- 目标平台:仅 `android/`、`ios/`、`web/`,未配置桌面端。
|
- 目标平台:仅 `android/`、`ios/`、`web/`,未配置桌面端。
|
||||||
@@ -16,9 +16,18 @@
|
|||||||
## 架构与关键文件
|
## 架构与关键文件
|
||||||
- `lib/app.dart`:根组件 `QiangbaoApp extends ConsumerWidget`。配置 M3 主题,用 `auth.isLoggedIn` 在 `MainShell` / `LoginPage` 之间分发。全局 `navigatorKey` 已抽到 `lib/core/navigation/navigator_key.dart`(避免 app.dart ↔ 网络层循环依赖),供网络层 401 跳转与全局 toast 使用。
|
- `lib/app.dart`:根组件 `QiangbaoApp extends ConsumerWidget`。配置 M3 主题,用 `auth.isLoggedIn` 在 `MainShell` / `LoginPage` 之间分发。全局 `navigatorKey` 已抽到 `lib/core/navigation/navigator_key.dart`(避免 app.dart ↔ 网络层循环依赖),供网络层 401 跳转与全局 toast 使用。
|
||||||
- `lib/core/network/auth_redirect.dart`:网络层与 Riverpod 的桥接(`configureAuthRedirect(container)` 在 `main.dart` 调用),提供 `forceLogout()` / `logoutAndGoLogin()`。
|
- `lib/core/network/auth_redirect.dart`:网络层与 Riverpod 的桥接(`configureAuthRedirect(container)` 在 `main.dart` 调用),提供 `forceLogout()` / `logoutAndGoLogin()`。
|
||||||
- `lib/pages/shell/main_shell.dart`:主框架,4 个 Tab(首页/记账/统计/我的),用 `IndexedStack` 保留各页状态,`NavigationBar`(M3)承载导航,默认 `_index = 1`(记账)。`initState` 里有登录态兜底跳转。
|
- `lib/pages/shell/main_shell.dart`:主框架,4 个 Tab 是**角色**不是功能 —— 首页(仪表盘)/记录(全局录入)/助手(AI)/我的,用 `IndexedStack` 保留各页状态,`NavigationBar`(M3)承载导航,默认 `_index = 0`(首页)。`initState` 里有登录态兜底跳转。
|
||||||
- `lib/pages/login/login_page.dart`:登录/注册页,含毛玻璃卡片(`BackdropFilter`)、动画背景圆球(`AnimationController` + `Stack` + `Positioned` + `Transform.translate`)、记住密码(`utils/crypto.dart` 加解密)。
|
- `lib/pages/login/login_page.dart`:登录/注册页,含毛玻璃卡片(`BackdropFilter`)、动画背景圆球(`AnimationController` + `Stack` + `Positioned` + `Transform.translate`)、记住密码(`utils/crypto.dart` 加解密)。
|
||||||
- 目前只有「记账」页(`AddPage`,含 `manual_entry_tab.dart`、`ocr_tab.dart`、`ocr_result_card.dart`)是真实实现,首页/统计/我的仍是 `_PlaceholderPage`。
|
- 首页、统计、我的**都已是真实实现**(不是占位页)。记账录入页在 `pages/add/`(`AddPage`,手动记账 / OCR 双 Tab)。
|
||||||
|
|
||||||
|
### 模块框架(2026-09-23 做的「阶段 A」)
|
||||||
|
- **`lib/core/module/app_module.dart` 是模块注册表**,`appModules` 列表是唯一的真相来源:首页仪表盘的卡片、`MinePage` 的「功能模块」列表都从它生成。**新增一个模块 = 往列表加一条 + 写页面**,导航/首页/设置页都不用改。
|
||||||
|
- 每个 `AppModule` 有:`id`(稳定标识,将来做排序/开关/AI 可读范围时持久化用)、`name`、`blurb`、`icon`(emoji,和项目卡片风格一致)、`open`(打开入口页的 `WidgetBuilder`)、**可选的 `summary`**(首页卡片上的摘要 widget,由模块自己提供,`null` 就不显示)。
|
||||||
|
- **具体模块不进底部 Tab**。代价是打开 App 多一次点击才能看到账单,换来的是「以后加模块不用改布局」。这是用户明确选的。
|
||||||
|
- 目录按模块划分:`pages/bill/`(记账:首页/统计/编辑 + `widgets/`)、`pages/note/`、`pages/add/`(记录 Tab 的录入)、`pages/home/`(全局仪表盘)、`pages/assistant/`(AI 占位)。
|
||||||
|
- 「记录」Tab 目前直接就是记账录入 —— 只有一种记录类型。**等第二种记录类型(运动/投资)出现时才需要在它上面加一层「选类型」**。
|
||||||
|
- 统计**不再是顶层 Tab**,从记账首页右上角图标进(`BillStatisticsPage`)。
|
||||||
|
- `pages/bill/bill_home_page.dart` 和 `bill_statistics_page.dart` 都是被 push 进来的,所以顶部自绘了返回箭头,底部留白也按 `Navigator.canPop()` 判断(有导航栏时留 90,没有时留 24)。
|
||||||
|
|
||||||
## 设计规范(配色)
|
## 设计规范(配色)
|
||||||
- 品牌主色:`#C8956E`(驼色/焦糖色),在 `app.dart` 中作为 `ColorScheme.fromSeed` 的 seedColor。
|
- 品牌主色:`#C8956E`(驼色/焦糖色),在 `app.dart` 中作为 `ColorScheme.fromSeed` 的 seedColor。
|
||||||
@@ -35,6 +44,8 @@
|
|||||||
- 已知风险:① `--dart-define` 在 release 下同样生效,可击穿"正式包走生产"的保证;② `kDebugMode` 在 profile 模式为 false → profile 包会连生产,宜改用 `kReleaseMode`。
|
- 已知风险:① `--dart-define` 在 release 下同样生效,可击穿"正式包走生产"的保证;② `kDebugMode` 在 profile 模式为 false → profile 包会连生产,宜改用 `kReleaseMode`。
|
||||||
|
|
||||||
## 待办 / 可优化点
|
## 待办 / 可优化点
|
||||||
|
- **模块的排序与开关还没做**(注册表里 `id` 已为此预留);AI 的「可读范围」也需要按 `id` 配置。
|
||||||
|
- **`test/page_layout_test.dart` 在守布局**:用 320 / 393 / 480 三种宽度断言仪表盘和记账首页不溢出。改这两个页面的横向布局后跑一下 —— 它已经抓到过一次真 bug(记账首页顶部四个元素挤一行,在 393 宽的常规机上溢出 32px)。
|
||||||
- 硬编码颜色可迁移为 M3 角色色(`colorScheme.primary` 等),并补暗色模式。
|
- 硬编码颜色可迁移为 M3 角色色(`colorScheme.primary` 等),并补暗色模式。
|
||||||
- `api_env.dart` 优先用 `kReleaseMode` 显式判断,并统一生产地址(域名 + HTTPS);多环境改用 `--dart-define-from-file`;加运行时自检/启动日志显示当前 baseUrl。
|
- `api_env.dart` 优先用 `kReleaseMode` 显式判断,并统一生产地址(域名 + HTTPS);多环境改用 `--dart-define-from-file`;加运行时自检/启动日志显示当前 baseUrl。
|
||||||
- iOS 若需访问明文 HTTP,需在 `Info.plist` 增加 ATS 例外(`NSAllowsLocalNetworking` 或域例外)。
|
- iOS 若需访问明文 HTTP,需在 `Info.plist` 增加 ATS 例外(`NSAllowsLocalNetworking` 或域例外)。
|
||||||
@@ -43,7 +54,7 @@
|
|||||||
- Flutter 3.47 起 Material/Cupertino 已解耦为 `material_ui` / `cupertino_ui` 包,`package:flutter/material.dart` 仍可用,暂无需迁移。
|
- Flutter 3.47 起 Material/Cupertino 已解耦为 `material_ui` / `cupertino_ui` 包,`package:flutter/material.dart` 仍可用,暂无需迁移。
|
||||||
|
|
||||||
## 应用名称与图标(已落地)
|
## 应用名称与图标(已落地)
|
||||||
- 显示名统一为 **强宝爱记账**:`AndroidManifest.xml` 的 `android:label`、`Info.plist` 的 `CFBundleDisplayName`、`web/index.html` 的 `<title>` 与 `apple-mobile-web-app-title`;`web/manifest.json` 用 `short_name = 强宝记账`(4 字以内避免桌面截断)。
|
- 显示名统一为 **强宝小助手**:`AndroidManifest.xml` 的 `android:label`、`Info.plist` 的 `CFBundleDisplayName`、`web/index.html` 的 `<title>` 与 `apple-mobile-web-app-title`;`web/manifest.json` 用 `short_name = 强宝助手`(4 字以内避免桌面截断)。
|
||||||
- 图标链路:`tools/make_app_icon.py`(去水印 + 抠图 + 缩放)→ `assets/icon/app_icon.png` + `app_icon_fg.png` → `dart run flutter_launcher_icons`。
|
- 图标链路:`tools/make_app_icon.py`(去水印 + 抠图 + 缩放)→ `assets/icon/app_icon.png` + `app_icon_fg.png` → `dart run flutter_launcher_icons`。
|
||||||
- 图标配置在 `pubspec.yaml` 末尾的 `flutter_launcher_icons` 段;dev 依赖已加 `flutter_launcher_icons: ^0.14.0`。
|
- 图标配置在 `pubspec.yaml` 末尾的 `flutter_launcher_icons` 段;dev 依赖已加 `flutter_launcher_icons: ^0.14.0`。
|
||||||
- 素材归档在 `assets/icon/_source/`(`raw_bear.png` 采用、`raw_bear_dog.png` 备选)。`assets/icon/` 未声明为 Flutter assets,不会进包。
|
- 素材归档在 `assets/icon/_source/`(`raw_bear.png` 采用、`raw_bear_dog.png` 备选)。`assets/icon/` 未声明为 Flutter assets,不会进包。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<application
|
<application
|
||||||
android:label="强宝爱记账"
|
android:label="强宝小助手"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Flutter 打包 APK 与真机安装指南
|
# Flutter 打包 APK 与真机安装指南
|
||||||
|
|
||||||
> 项目:强宝爱记账(qiangbao_accounting)
|
> 项目:强宝小助手(qiangbao_accounting)
|
||||||
> 环境:Windows 11 + Flutter 3.47.4 / Dart 3.13.3
|
> 环境:Windows 11 + Flutter 3.47.4 / Dart 3.13.3
|
||||||
> 测试机:小米 M2007J22C(Android 12 / MIUI 14,设备号 `9dr4gyqgivl7tw5l`)
|
> 测试机:小米 M2007J22C(Android 12 / MIUI 14,设备号 `9dr4gyqgivl7tw5l`)
|
||||||
|
|
||||||
@@ -288,11 +288,11 @@ version: 1.0.0+1
|
|||||||
|
|
||||||
| 平台 | 位置 | 当前值 |
|
| 平台 | 位置 | 当前值 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Android 桌面名 | `android/app/src/main/AndroidManifest.xml` 的 `android:label` | `强宝爱记账` |
|
| Android 桌面名 | `android/app/src/main/AndroidManifest.xml` 的 `android:label` | `强宝小助手` |
|
||||||
| iOS 桌面名 | `ios/Runner/Info.plist` 的 `CFBundleDisplayName` | `强宝爱记账` |
|
| iOS 桌面名 | `ios/Runner/Info.plist` 的 `CFBundleDisplayName` | `强宝小助手` |
|
||||||
| Web 标签页 | `web/index.html` 的 `<title>` | `强宝爱记账` |
|
| Web 标签页 | `web/index.html` 的 `<title>` | `强宝小助手` |
|
||||||
| Web 主屏名 | `web/index.html` 的 `apple-mobile-web-app-title` | `强宝爱记账` |
|
| Web 主屏名 | `web/index.html` 的 `apple-mobile-web-app-title` | `强宝小助手` |
|
||||||
| Web PWA | `web/manifest.json` 的 `name` / `short_name` | `强宝爱记账` / `强宝记账` |
|
| Web PWA | `web/manifest.json` 的 `name` / `short_name` | `强宝小助手` / `强宝助手` |
|
||||||
| 包名 | `android/app/build.gradle.kts` 的 `applicationId` | `com.qiangbao.qiangbao_accounting`(**上架后不可改**) |
|
| 包名 | `android/app/build.gradle.kts` 的 `applicationId` | `com.qiangbao.qiangbao_accounting`(**上架后不可改**) |
|
||||||
|
|
||||||
> `short_name` 保持 4 个汉字以内,否则 Android 桌面图标下方会被截断成省略号。
|
> `short_name` 保持 4 个汉字以内,否则 Android 桌面图标下方会被截断成省略号。
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>强宝爱记账</string>
|
<string>强宝小助手</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ class QiangbaoApp extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: '强宝爱记账',
|
title: '强宝小助手',
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../pages/bill/bill_home_page.dart';
|
||||||
|
import '../../pages/bill/widgets/bill_module_summary.dart';
|
||||||
|
import '../../pages/note/note_list_page.dart';
|
||||||
|
import '../../pages/note/widgets/note_module_summary.dart';
|
||||||
|
|
||||||
|
/// 一个功能模块要对外暴露的东西:它是谁、怎么打开自己。
|
||||||
|
///
|
||||||
|
/// 首页仪表盘的卡片网格、「我的」里的模块列表都从 [appModules] 生成 ——
|
||||||
|
/// 新增一个模块 = 往列表里加一条 + 写这个模块的页面,
|
||||||
|
/// 布局层(导航栏、首页、设置页)一行都不用改。
|
||||||
|
@immutable
|
||||||
|
class AppModule {
|
||||||
|
const AppModule({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.blurb,
|
||||||
|
required this.icon,
|
||||||
|
required this.open,
|
||||||
|
this.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 稳定标识。将来做「模块排序 / 开关 / AI 可读范围」时要靠它持久化。
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// 模块名,显示在卡片和列表上
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// 一句话说明,让用户在首页就能看懂这个模块是干什么的
|
||||||
|
final String blurb;
|
||||||
|
|
||||||
|
/// 用 emoji 当图标 —— 和项目现有的卡片风格一致(笔记入口、预算提示都是 emoji)
|
||||||
|
final String icon;
|
||||||
|
|
||||||
|
/// 打开模块入口页
|
||||||
|
final WidgetBuilder open;
|
||||||
|
|
||||||
|
/// 首页卡片上的摘要。
|
||||||
|
///
|
||||||
|
/// 由模块自己提供,因为只有模块知道该摘要什么;返回 null 表示这个模块
|
||||||
|
/// 暂时没有可显示的数据(卡片就只显示名字和说明)。
|
||||||
|
final Widget Function(BuildContext context)? summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全部模块。**顺序 = 首页卡片顺序 = 「我的」里的列表顺序。**
|
||||||
|
///
|
||||||
|
/// 现在只有两个真模块。加「投资」「运动」「服务器」时照抄一条即可 ——
|
||||||
|
/// 注意「记录」Tab 目前直接就是记账录入,等第二种记录类型出现时,
|
||||||
|
/// 才需要在它上面加一层「选类型」。
|
||||||
|
final List<AppModule> appModules = [
|
||||||
|
AppModule(
|
||||||
|
id: 'bill',
|
||||||
|
name: '记账',
|
||||||
|
blurb: '收支记录、统计与预算',
|
||||||
|
icon: '💰',
|
||||||
|
open: (_) => const BillHomePage(),
|
||||||
|
summary: (_) => const BillModuleSummary(),
|
||||||
|
),
|
||||||
|
AppModule(
|
||||||
|
id: 'note',
|
||||||
|
name: '笔记',
|
||||||
|
blurb: 'Markdown 知识库,随时查看和记录',
|
||||||
|
icon: '📝',
|
||||||
|
open: (_) => const NoteListPage(),
|
||||||
|
summary: (_) => const NoteModuleSummary(),
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 助手页(占位)。
|
||||||
|
///
|
||||||
|
/// 现在是骨架里预留的位置 —— 先让布局一次到位,阶段 B 接 AI 时只填内容、
|
||||||
|
/// 不再动导航结构。同时它也是首页那个输入框的落地页。
|
||||||
|
class AssistantPage extends StatelessWidget {
|
||||||
|
const AssistantPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_header(context),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 40, 24, 0),
|
||||||
|
children: [
|
||||||
|
const Center(child: Text('✨', style: TextStyle(fontSize: 46))),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'AI 助手正在建设中',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'位置已经预留好,接入后不用再改布局',
|
||||||
|
style: TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_planCard(),
|
||||||
|
const SizedBox(height: 90),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用 Tab 打开时没有上一页,就不显示返回箭头
|
||||||
|
Widget _header(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 6, 16, 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (Navigator.of(context).canPop())
|
||||||
|
InkWell(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(Icons.arrow_back, size: 22, color: Color(0xFF5D4037)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 6),
|
||||||
|
child: Text(
|
||||||
|
'助手',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _planCard() {
|
||||||
|
const items = [
|
||||||
|
('一句话记账', '「昨天买咖啡 32」→ 自动填好分类和金额'),
|
||||||
|
('笔记问答', '从你的知识库里找答案,而不是重读一遍'),
|
||||||
|
('服务器状态', '查状态、看日志 —— 只走预定义动作,不给任意 shell'),
|
||||||
|
('跨模块查询', '「上周服务器相关的花费」这类要跨模块的问题'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'规划中的能力',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
for (final (title, desc) in items) ...[
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 5),
|
||||||
|
child: Icon(Icons.circle, size: 5, color: Color(0xFFC8956E)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
desc,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
],
|
||||||
|
const Divider(height: 1, color: Color(0xFFF0E6D8)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'隐私:涉及你笔记里的服务器信息时,会在请求发出前先脱敏。',
|
||||||
|
style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4), height: 1.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,19 @@ import '../../utils/amount_util.dart';
|
|||||||
import '../../utils/bill_grouping.dart';
|
import '../../utils/bill_grouping.dart';
|
||||||
import '../../utils/date_util.dart';
|
import '../../utils/date_util.dart';
|
||||||
import '../../widgets/state_views.dart';
|
import '../../widgets/state_views.dart';
|
||||||
import '../bill/bill_edit_page.dart';
|
import 'bill_edit_page.dart';
|
||||||
|
import 'bill_statistics_page.dart';
|
||||||
import 'widgets/bill_group_card.dart';
|
import 'widgets/bill_group_card.dart';
|
||||||
import 'widgets/budget_card.dart';
|
import 'widgets/budget_card.dart';
|
||||||
import 'widgets/month_switcher.dart';
|
import 'widgets/month_switcher.dart';
|
||||||
|
|
||||||
/// 首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表
|
/// 记账模块的首页:月份切换 + 本月汇总 + 预算环 + 按日期分组的账单列表。
|
||||||
class HomePage extends ConsumerWidget {
|
///
|
||||||
const HomePage({super.key});
|
/// 这个页面同时承担两个入口角色:
|
||||||
|
/// · 从首页仪表盘的「记账」卡片进来(会被 push,这时有返回箭头)
|
||||||
|
/// · 直接 push 进来时底部没有导航栏,所以底部留白要按情况给
|
||||||
|
class BillHomePage extends ConsumerWidget {
|
||||||
|
const BillHomePage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -48,7 +53,7 @@ class HomePage extends ConsumerWidget {
|
|||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
children: [
|
children: [
|
||||||
_header(anchor),
|
_header(context),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_summaryCard(anchor, statsAsync),
|
_summaryCard(anchor, statsAsync),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -59,7 +64,8 @@ class HomePage extends ConsumerWidget {
|
|||||||
_budgetUnavailableTip(),
|
_budgetUnavailableTip(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
..._billSection(context, ref, billsAsync),
|
..._billSection(context, ref, billsAsync),
|
||||||
const SizedBox(height: 90), // 底部导航避让
|
// 被 push 进来时底部没有导航栏,就不用留出避让高度
|
||||||
|
SizedBox(height: Navigator.of(context).canPop() ? 24 : 90),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -68,24 +74,67 @@ class HomePage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 顶部标题 + 月份切换 ----
|
// ---- 顶部:返回 + 标题 + 统计入口 / 月份切换 ----
|
||||||
Widget _header(DateTime anchor) {
|
//
|
||||||
return Row(
|
// 为什么要分两行:月份切换条本身就有 ~206px 宽,再并上返回箭头(34)、
|
||||||
|
// 标题(80)、统计图标(34),一行要约 364px —— 而 393 宽的常规手机只有
|
||||||
|
// 361px 可用,实测溢出 32px。分两行之后每行都很宽松。
|
||||||
|
Widget _header(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Row(
|
||||||
'我的账单',
|
children: [
|
||||||
style: TextStyle(
|
if (Navigator.of(context).canPop()) ...[
|
||||||
fontSize: 20,
|
_iconButton(
|
||||||
fontWeight: FontWeight.bold,
|
icon: Icons.arrow_back,
|
||||||
color: Color(0xFF5D4037),
|
tooltip: '返回',
|
||||||
),
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
],
|
||||||
|
const Text(
|
||||||
|
'我的账单',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_iconButton(
|
||||||
|
icon: Icons.bar_chart,
|
||||||
|
tooltip: '统计分析',
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const BillStatisticsPage()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(height: 10),
|
||||||
const MonthSwitcher(),
|
const MonthSwitcher(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _iconButton({
|
||||||
|
required IconData icon,
|
||||||
|
required String tooltip,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Icon(icon, size: 22, color: const Color(0xFF5D4037)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 本月汇总 ----
|
// ---- 本月汇总 ----
|
||||||
Widget _summaryCard(DateTime anchor, AsyncValue<StatisticsResponse> statsAsync) {
|
Widget _summaryCard(DateTime anchor, AsyncValue<StatisticsResponse> statsAsync) {
|
||||||
final stats = statsAsync.value;
|
final stats = statsAsync.value;
|
||||||
@@ -162,7 +211,7 @@ class HomePage extends ConsumerWidget {
|
|||||||
if (bills.isEmpty) {
|
if (bills.isEmpty) {
|
||||||
return const EmptyView(
|
return const EmptyView(
|
||||||
title: '本月暂无账单记录',
|
title: '本月暂无账单记录',
|
||||||
tip: '点击底部「记账」添加,或用 OCR 识别小票',
|
tip: '点击底部「记录」添加,或用 OCR 识别小票',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
+32
-11
@@ -10,8 +10,10 @@ import 'widgets/category_pie_card.dart';
|
|||||||
import 'widgets/category_rank_list.dart';
|
import 'widgets/category_rank_list.dart';
|
||||||
|
|
||||||
/// 统计页:按日/周/月切换 + 汇总 + 分类占比饼图 + 分类明细
|
/// 统计页:按日/周/月切换 + 汇总 + 分类占比饼图 + 分类明细
|
||||||
class StatisticsPage extends ConsumerWidget {
|
///
|
||||||
const StatisticsPage({super.key});
|
/// 属于记账模块,从账单首页右上角进(不是顶层 Tab)。
|
||||||
|
class BillStatisticsPage extends ConsumerWidget {
|
||||||
|
const BillStatisticsPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -34,19 +36,13 @@ class StatisticsPage extends ConsumerWidget {
|
|||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
_titleRow(context),
|
||||||
'统计分析',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Color(0xFF5D4037),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_tabBar(tab, ref),
|
_tabBar(tab, ref),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_content(ref, statsAsync),
|
_content(ref, statsAsync),
|
||||||
const SizedBox(height: 90), // 底部导航避让
|
// 被 push 进来时底部没有导航栏,就不用留出避让高度
|
||||||
|
SizedBox(height: Navigator.of(context).canPop() ? 24 : 90),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -54,6 +50,31 @@ class StatisticsPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 顶部标题(带返回,因为这个页面是 push 进来的) ----
|
||||||
|
Widget _titleRow(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (Navigator.of(context).canPop())
|
||||||
|
InkWell(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(Icons.arrow_back, size: 22, color: Color(0xFF5D4037)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'统计分析',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 时间粒度切换 ----
|
// ---- 时间粒度切换 ----
|
||||||
Widget _tabBar(StatisticsTab current, WidgetRef ref) {
|
Widget _tabBar(StatisticsTab current, WidgetRef ref) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../providers/bill_provider.dart';
|
||||||
|
import '../../../utils/amount_util.dart';
|
||||||
|
|
||||||
|
/// 首页仪表盘上「记账」卡片的摘要:当前查看月份的收入 / 支出 / 结余。
|
||||||
|
///
|
||||||
|
/// 故意和账单首页共用 `monthAnchorProvider`,而不是自己取 `DateTime.now()`:
|
||||||
|
/// · 两边看到的永远是同一个月,不会出现「卡片写 9 月、点进去是 8 月」
|
||||||
|
/// · provider 已经是现成的,不会多打一次接口
|
||||||
|
/// 所以标签用「N月」而不是「本月」。
|
||||||
|
class BillModuleSummary extends ConsumerWidget {
|
||||||
|
const BillModuleSummary({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final anchor = ref.watch(monthAnchorProvider);
|
||||||
|
final stats = ref.watch(monthlyStatisticsProvider(anchor)).value;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _item('${anchor.month}月支出', stats?.totalExpense, const Color(0xFFFA3534)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _item('结余', stats?.balance, const Color(0xFF5D4037)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _item(String label, double? amount, Color color) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF8D6E63))),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
// 统计还没回来时显示占位,避免卡片高度跳动
|
||||||
|
amount == null ? '--' : '¥${formatAmount(amount)}',
|
||||||
|
style: TextStyle(fontSize: 14.5, fontWeight: FontWeight.bold, color: color),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/module/app_module.dart';
|
||||||
|
import '../assistant/assistant_page.dart';
|
||||||
|
|
||||||
|
/// 首页仪表盘:AI 输入入口 + 模块卡片网格。
|
||||||
|
///
|
||||||
|
/// 这个页面是「模块目录」,不是某个模块的主页 —— 卡片全部从 [appModules]
|
||||||
|
/// 生成,所以将来加模块时**这个文件不需要改**。
|
||||||
|
///
|
||||||
|
/// 副作用是:打开 App 第一眼看不到账单了,要点一下「记账」卡片才进账单首页。
|
||||||
|
/// 这是刻意的 —— 记账不再是主角。
|
||||||
|
class DashboardPage extends StatelessWidget {
|
||||||
|
const DashboardPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
|
children: [
|
||||||
|
_header(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_aiEntry(context),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
const Text(
|
||||||
|
'功能模块',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_moduleGrid(context),
|
||||||
|
const SizedBox(height: 90), // 底部导航避让
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 标题 + 问候 ----
|
||||||
|
Widget _header() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'强宝小助手',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
letterSpacing: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_greeting(),
|
||||||
|
style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _greeting() {
|
||||||
|
final hour = DateTime.now().hour;
|
||||||
|
if (hour < 6) return '夜深了,注意休息';
|
||||||
|
if (hour < 12) return '早上好,今天想做点什么?';
|
||||||
|
if (hour < 18) return '下午好,今天想做点什么?';
|
||||||
|
return '晚上好,今天想做点什么?';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AI 入口(阶段 B 接上,现在点进去是占位页)----
|
||||||
|
Widget _aiEntry(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const AssistantPage()),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFC8956E), width: 1.2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF5D4037).withValues(alpha: 0.08),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('✨', style: TextStyle(fontSize: 18)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'问点什么,或者说一句…',
|
||||||
|
style: TextStyle(fontSize: 14, color: Color(0xFFBCAAA4)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF5EFE6),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'建设中',
|
||||||
|
style: TextStyle(fontSize: 11, color: Color(0xFF8D6E63)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 模块卡片网格 ----
|
||||||
|
//
|
||||||
|
// 两列。不用 GridView + childAspectRatio:卡片高度由内容(摘要可有可无)
|
||||||
|
// 决定,估错比例就会在开发时刷一屏溢出警告 —— GridView 卡片的经典坑。
|
||||||
|
// 这里让每张卡片自己撑高,居上对齐。
|
||||||
|
Widget _moduleGrid(BuildContext context) {
|
||||||
|
const gap = 12.0;
|
||||||
|
final rows = <Widget>[];
|
||||||
|
|
||||||
|
for (var i = 0; i < appModules.length; i += 2) {
|
||||||
|
final end = i + 2 <= appModules.length ? i + 2 : appModules.length;
|
||||||
|
final pair = appModules.sublist(i, end);
|
||||||
|
|
||||||
|
if (rows.isNotEmpty) rows.add(const SizedBox(height: gap));
|
||||||
|
rows.add(
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (var j = 0; j < pair.length; j++) ...[
|
||||||
|
if (j > 0) const SizedBox(width: gap),
|
||||||
|
Expanded(child: _moduleCard(context, pair[j])),
|
||||||
|
],
|
||||||
|
// 模块数是奇数时补一个空位,否则最后一张卡片会被拉成通栏
|
||||||
|
if (pair.length == 1) ...[
|
||||||
|
const SizedBox(width: gap),
|
||||||
|
const Expanded(child: SizedBox()),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(children: rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _moduleCard(BuildContext context, AppModule module) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: module.open)),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF5D4037).withValues(alpha: 0.08),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(module.icon, style: const TextStyle(fontSize: 24)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
module.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
module.blurb,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63)),
|
||||||
|
),
|
||||||
|
if (module.summary != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
module.summary!(context),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -251,7 +251,7 @@ class _LoginPageState extends ConsumerState<LoginPage>
|
|||||||
_decoLine(),
|
_decoLine(),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Text(
|
const Text(
|
||||||
'强宝爱记账',
|
'强宝小助手',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/module/app_module.dart';
|
||||||
import '../../core/network/api_exception.dart';
|
import '../../core/network/api_exception.dart';
|
||||||
import '../../core/network/auth_redirect.dart';
|
import '../../core/network/auth_redirect.dart';
|
||||||
import '../../models/account.dart';
|
import '../../models/account.dart';
|
||||||
@@ -12,7 +13,6 @@ import '../../utils/amount_input_formatter.dart';
|
|||||||
import '../../utils/amount_util.dart';
|
import '../../utils/amount_util.dart';
|
||||||
import '../../utils/toast.dart';
|
import '../../utils/toast.dart';
|
||||||
import '../../widgets/state_views.dart';
|
import '../../widgets/state_views.dart';
|
||||||
import '../note/note_list_page.dart';
|
|
||||||
|
|
||||||
/// 「我的」页面
|
/// 「我的」页面
|
||||||
///
|
///
|
||||||
@@ -52,7 +52,7 @@ class MinePage extends ConsumerWidget {
|
|||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_budgetCard(context, ref, budgetAsync),
|
_budgetCard(context, ref, budgetAsync),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_notesEntry(context),
|
_moduleSection(context),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_logoutButton(context),
|
_logoutButton(context),
|
||||||
const SizedBox(height: 90),
|
const SizedBox(height: 90),
|
||||||
@@ -369,43 +369,73 @@ class MinePage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 笔记入口 ----------------
|
// ---------------- 功能模块 ----------------
|
||||||
Widget _notesEntry(BuildContext context) {
|
//
|
||||||
return GestureDetector(
|
// 和首页仪表盘的卡片共用同一份注册表(core/module/app_module.dart),
|
||||||
onTap: () => Navigator.of(context).push(
|
// 所以新增模块时这个页面不用改。
|
||||||
MaterialPageRoute(builder: (_) => const NoteListPage()),
|
Widget _moduleSection(BuildContext context) {
|
||||||
),
|
return Material(
|
||||||
child: Container(
|
color: Colors.white,
|
||||||
padding: const EdgeInsets.fromLTRB(18, 16, 14, 16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
decoration: BoxDecoration(
|
clipBehavior: Clip.antiAlias, // 让水波纹被圆角裁掉
|
||||||
color: Colors.white,
|
child: Padding(
|
||||||
borderRadius: BorderRadius.circular(16),
|
padding: const EdgeInsets.fromLTRB(18, 16, 8, 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'功能模块',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
for (final module in appModules) _moduleRow(context, module),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _moduleRow(BuildContext context, AppModule module) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: module.open),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text('📝', style: TextStyle(fontSize: 24)),
|
Text(module.icon, style: const TextStyle(fontSize: 20)),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 12),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'我的笔记',
|
module.name,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF5D4037),
|
color: Color(0xFF5D4037),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 3),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'管理 Markdown 文档,随时查看和记录',
|
module.blurb,
|
||||||
style: TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63)),
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.chevron_right, color: Color(0xFFBCAAA4)),
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(right: 3),
|
||||||
|
child: Icon(Icons.chevron_right, color: Color(0xFFBCAAA4)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../providers/note_provider.dart';
|
||||||
|
|
||||||
|
/// 首页仪表盘上「笔记」卡片的摘要:笔记总数 + 最近更新时间。
|
||||||
|
///
|
||||||
|
/// 数据来自 [noteOverviewProvider](只取 1 条),不碰列表页那套状态。
|
||||||
|
class NoteModuleSummary extends ConsumerWidget {
|
||||||
|
const NoteModuleSummary({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final page = ref.watch(noteOverviewProvider).value;
|
||||||
|
final latest = (page == null || page.items.isEmpty)
|
||||||
|
? null
|
||||||
|
: page.items.first.updateTime;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _item('共', page == null ? null : '${page.total} 篇')),
|
||||||
|
Expanded(child: _item('最近更新', latest == null ? null : _shortDate(latest))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后端给的是 `yyyy-MM-dd HH:mm`,卡片上只要 `MM-dd`
|
||||||
|
String _shortDate(String value) =>
|
||||||
|
value.length >= 10 ? value.substring(5, 10) : value;
|
||||||
|
|
||||||
|
Widget _item(String label, String? value) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF8D6E63))),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
value ?? '--',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14.5,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../add/add_page.dart';
|
import '../add/add_page.dart';
|
||||||
import '../home/home_page.dart';
|
import '../assistant/assistant_page.dart';
|
||||||
|
import '../home/dashboard_page.dart';
|
||||||
import '../login/login_page.dart';
|
import '../login/login_page.dart';
|
||||||
import '../mine/mine_page.dart';
|
import '../mine/mine_page.dart';
|
||||||
import '../statistics/statistics_page.dart';
|
|
||||||
|
|
||||||
|
/// 底部导航的四个 Tab 是四个「角色」,不是四个功能:
|
||||||
|
///
|
||||||
|
/// 首页 = 模块目录(卡片由 core/module/app_module.dart 生成)
|
||||||
|
/// 记录 = 全局录入入口
|
||||||
|
/// 助手 = AI(阶段 B 接入,现在是占位页)
|
||||||
|
/// 我的 = 账号、模块管理、设置
|
||||||
|
///
|
||||||
|
/// **具体模块不进 Tab** —— 记账、笔记都从首页卡片进。
|
||||||
|
/// 代价是打开 App 多一次点击才能看到账单,换来的是「以后加模块不用改这里」。
|
||||||
class MainShell extends ConsumerStatefulWidget {
|
class MainShell extends ConsumerStatefulWidget {
|
||||||
const MainShell({super.key});
|
const MainShell({super.key});
|
||||||
|
|
||||||
@@ -38,12 +47,12 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// IndexedStack 保证四个页面常驻:写操作后首页/统计的数据失效能立即重取,
|
// IndexedStack 保证四个页面常驻:写操作后各页的数据失效能立即重取,
|
||||||
// 用户切回时看到的就是最新数据。
|
// 用户切回时看到的就是最新数据。
|
||||||
const pages = [
|
const pages = [
|
||||||
HomePage(),
|
DashboardPage(),
|
||||||
AddPage(),
|
AddPage(),
|
||||||
StatisticsPage(),
|
AssistantPage(),
|
||||||
MinePage(),
|
MinePage(),
|
||||||
];
|
];
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -54,10 +63,26 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
indicatorColor: const Color(0x33C8956E),
|
indicatorColor: const Color(0x33C8956E),
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: '首页'),
|
NavigationDestination(
|
||||||
NavigationDestination(icon: Icon(Icons.add_circle_outline), selectedIcon: Icon(Icons.add_circle), label: '记账'),
|
icon: Icon(Icons.home_outlined),
|
||||||
NavigationDestination(icon: Icon(Icons.bar_chart_outlined), selectedIcon: Icon(Icons.bar_chart), label: '统计'),
|
selectedIcon: Icon(Icons.home),
|
||||||
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: '我的'),
|
label: '首页',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.add_circle_outline),
|
||||||
|
selectedIcon: Icon(Icons.add_circle),
|
||||||
|
label: '记录',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.auto_awesome_outlined),
|
||||||
|
selectedIcon: Icon(Icons.auto_awesome),
|
||||||
|
label: '助手',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.person_outline),
|
||||||
|
selectedIcon: Icon(Icons.person),
|
||||||
|
label: '我的',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -229,3 +229,12 @@ final noteTagsProvider = FutureProvider.autoDispose<List<String>>((ref) {
|
|||||||
final noteFolderTreeProvider = FutureProvider.autoDispose<FolderTree>((ref) {
|
final noteFolderTreeProvider = FutureProvider.autoDispose<FolderTree>((ref) {
|
||||||
return ref.watch(noteApiProvider).folderTree();
|
return ref.watch(noteApiProvider).folderTree();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 首页仪表盘用:只想知道「一共几篇、最近什么时候更新」。
|
||||||
|
///
|
||||||
|
/// 故意不复用 [noteListProvider] —— 那个 Notifier 带着搜索、标签、分页和防抖,
|
||||||
|
/// 为了一个数字把它挂到首页上,既会多打请求也会引入一堆无关状态。
|
||||||
|
/// 这里只取第 1 页第 1 条,用返回的 total 拿总数。
|
||||||
|
final noteOverviewProvider = FutureProvider.autoDispose<NotePage>((ref) {
|
||||||
|
return ref.watch(noteApiProvider).list(page: 1, size: 1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/models/bill.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/note.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/statistics_response.dart';
|
||||||
|
import 'package:qiangbao_accounting/pages/bill/bill_home_page.dart';
|
||||||
|
import 'package:qiangbao_accounting/pages/home/dashboard_page.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/bill_provider.dart';
|
||||||
|
import 'package:qiangbao_accounting/providers/note_provider.dart';
|
||||||
|
|
||||||
|
/// 临时校验:改造后的两个页面在窄屏 / 常见机 / 大屏下都不溢出。
|
||||||
|
void main() {
|
||||||
|
// 故意用会撑宽的数字,探一探摘要位窄不窄
|
||||||
|
final stats = StatisticsResponse(
|
||||||
|
totalIncome: 123456.78,
|
||||||
|
totalExpense: 98765.43,
|
||||||
|
balance: 24691.35,
|
||||||
|
categoryStatistics: const [],
|
||||||
|
dailyStatistics: const [],
|
||||||
|
);
|
||||||
|
final page = NotePage(
|
||||||
|
items: const [
|
||||||
|
NoteListItem(
|
||||||
|
id: 1,
|
||||||
|
folderId: null,
|
||||||
|
title: 'MySQL 主从复制搭建教程',
|
||||||
|
summary: '',
|
||||||
|
tags: [],
|
||||||
|
pinned: false,
|
||||||
|
updateTime: '2026-09-23 03:36',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total: 128,
|
||||||
|
hasMore: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sizes = [
|
||||||
|
('iPhone SE 窄屏', Size(320, 568)),
|
||||||
|
('常见安卓机', Size(393, 873)),
|
||||||
|
('大屏', Size(480, 1000)),
|
||||||
|
];
|
||||||
|
|
||||||
|
void useSize(WidgetTester tester, Size size) {
|
||||||
|
tester.view.physicalSize = size * 3;
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final (label, size) in sizes) {
|
||||||
|
testWidgets('仪表盘 $label 不溢出', (tester) async {
|
||||||
|
useSize(tester, size);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
monthlyStatisticsProvider.overrideWith((ref, month) async => stats),
|
||||||
|
noteOverviewProvider.overrideWith((ref) async => page),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: DashboardPage()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 先确认假数据真渲染出来了 —— 否则这个测试会「因为卡片是空的」而假通过
|
||||||
|
expect(find.text('128 篇'), findsOneWidget);
|
||||||
|
expect(find.textContaining('98765.43'), findsWidgets);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('记账首页(push 进来,带返回箭头)$label 不溢出', (tester) async {
|
||||||
|
useSize(tester, size);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
monthlyStatisticsProvider.overrideWith((ref, month) async => stats),
|
||||||
|
billsProvider.overrideWith((ref, month) async => <Bill>[]),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (ctx) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const BillHomePage()),
|
||||||
|
),
|
||||||
|
child: const Text('进入'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 真的 push 一次,canPop() 才是 true,才能测到「返回箭头 + 标题 +
|
||||||
|
// 统计图标 + 月份切换」同时挤在一行的最坏情况
|
||||||
|
await tester.tap(find.text('进入'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('我的账单'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -18,18 +18,18 @@
|
|||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
<meta name="description" content="强宝爱记账 —— 智能记账,轻松管理">
|
<meta name="description" content="强宝小助手 —— 智能记账,轻松管理">
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
<meta name="apple-mobile-web-app-title" content="强宝爱记账">
|
<meta name="apple-mobile-web-app-title" content="强宝小助手">
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
<title>强宝爱记账</title>
|
<title>强宝小助手</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "强宝爱记账",
|
"name": "强宝小助手",
|
||||||
"short_name": "强宝记账",
|
"short_name": "强宝助手",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#FFF6EC",
|
"background_color": "#FFF6EC",
|
||||||
"theme_color": "#C8956E",
|
"theme_color": "#C8956E",
|
||||||
"description": "强宝爱记账 —— 智能记账,轻松管理",
|
"description": "强宝小助手 —— 智能记账,轻松管理",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|||||||
Reference in New Issue
Block a user