This commit is contained in:
2026-09-23 15:33:59 +08:00
parent e4cdfbc42f
commit a83cda63f5
24 changed files with 874 additions and 81 deletions
+16 -5
View File
@@ -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 -1
View File
@@ -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">
+6 -6
View File
@@ -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 桌面图标下方会被截断成省略号。
+1 -1
View File
@@ -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
View File
@@ -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(
+68
View File
@@ -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(),
),
];
+165
View File
@@ -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(
@@ -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),
),
],
);
}
}
+214
View File
@@ -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),
],
],
),
),
);
}
}
+1 -1
View File
@@ -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,
+52 -22
View File
@@ -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),
),
),
],
);
}
}
+34 -9
View File
@@ -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: '我的',
),
], ],
), ),
); );
+9
View File
@@ -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);
});
+106
View File
@@ -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
View File
@@ -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
View File
@@ -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": [