diff --git a/README.md b/README.md index 4a862070..60604346 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

-[🌐 官网](https://count.beejz.com) · [📖 文档](https://count.beejz.com/docs/intro) · [💝 捐赠](#-捐赠支持) · [💬 Telegram](https://t.me/beecount) · [📦 APK](https://github.com/TNT-Likely/BeeCount/releases/latest) · [🚀 TestFlight](https://testflight.apple.com/join/Eaw2rWxa) +[🌐 官网](https://count.beejz.com) · [📖 文档](https://count.beejz.com/docs/intro) · [💝 捐赠](#-捐赠支持) · [👥 微信群](docs/community/README_ZH.md) · [💬 Telegram](https://t.me/beecount) · [📦 APK](https://github.com/TNT-Likely/BeeCount/releases/latest) · [🚀 TestFlight](https://testflight.apple.com/join/Eaw2rWxa) diff --git a/README_EN.md b/README_EN.md index 58c8dd8c..315a9f3d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -28,7 +28,7 @@ Sync via BeeCount Cloud (self-hosted) / iCloud / Supabase / WebDAV / S3

-[🌐 Website](https://count.beejz.com/en/) · [📖 Docs](https://count.beejz.com/en/docs/intro) · [💝 Donate](#-donate) · [💬 Telegram](https://t.me/beecount) · [📦 APK](https://github.com/TNT-Likely/BeeCount/releases/latest) · [🚀 TestFlight](https://testflight.apple.com/join/Eaw2rWxa) +[🌐 Website](https://count.beejz.com/en/) · [📖 Docs](https://count.beejz.com/en/docs/intro) · [💝 Donate](#-donate) · [👥 WeChat Group](docs/community/README_EN.md) · [💬 Telegram](https://t.me/beecount) · [📦 APK](https://github.com/TNT-Likely/BeeCount/releases/latest) · [🚀 TestFlight](https://testflight.apple.com/join/Eaw2rWxa) diff --git a/docs/community/README_EN.md b/docs/community/README_EN.md new file mode 100644 index 00000000..95365cc9 --- /dev/null +++ b/docs/community/README_EN.md @@ -0,0 +1,22 @@ +# WeChat Group + +The BeeCount user community lives on **WeChat** (most users are based in mainland China). Scan the QR code below to join — the developer hangs out in the group daily. + +## QR code + +BeeCount WeChat user group QR + +> ⚠️ The WeChat QR code is **valid for 7 days**. This page is refreshed before each expiry. If the code says expired or the group is full, leave a comment on [GitHub Issues](https://github.com/TNT-Likely/BeeCount/issues) and we'll add you manually. + +## Prefer not using WeChat? + +The same questions can be asked through: + +- [GitHub Issues](https://github.com/TNT-Likely/BeeCount/issues) — bug reports & feature requests with structured templates +- [Telegram](https://t.me/beecount) — community channel for users outside China + +The group is Chinese-speaking; for English-language support, GitHub Issues or Telegram are usually a smoother fit. + +--- + +> **Privacy note**: when discussing issues, please avoid posting **full sync credentials / API keys / server URLs**. For sensitive debugging, DM the admin directly. diff --git a/docs/community/README_ZH.md b/docs/community/README_ZH.md new file mode 100644 index 00000000..3f7fc2e8 --- /dev/null +++ b/docs/community/README_ZH.md @@ -0,0 +1,28 @@ +# 微信交流群 + +欢迎扫码加入 **BeeCount 蜜蜂记账交流群** —— 反馈使用问题、提建议、跟其他用户交流记账方法,作者也常在群里。 + +## 入群二维码 + +蜜蜂记账微信交流群 + +> ⚠️ 微信群二维码 **7 天有效**,过期后本页会重新更新。如果扫码提示已过期 / 满员,请到 [GitHub Issues](https://github.com/TNT-Likely/BeeCount/issues) 留言或加管理员个人微信,我们会拉你进群。 + +## 群里聊什么 + +- 🐛 **使用问题反馈**:同步异常、AI 识别不准、界面 bug 等 +- 💡 **功能建议**:有想要的新功能直接说,采纳率挺高 +- 💬 **使用心得**:多账本怎么用、分类怎么搭、AI 提示词调参等 +- 📢 **版本更新**:新版本发布会在群里通知,可以第一时间体验 + +## 其他联系方式 + +如果你不用微信,也可以走这些渠道: + +- [GitHub Issues](https://github.com/TNT-Likely/BeeCount/issues) —— 报 bug / 提需求的首选,有结构化模板 +- [Telegram](https://t.me/beecount) —— 海外用户社群 +- 小红书 / 抖音 —— 视频教程 + 评论区互动(在 App「关于」页有入口) + +--- + +> **隐私提示**:群里讨论问题时,请避免贴**完整的同步配置 / API Key / 服务器地址**等敏感信息。需要私下排查时单独私聊管理员。 diff --git a/docs/community/wechat-group.png b/docs/community/wechat-group.png new file mode 100644 index 00000000..5e9fd5e4 Binary files /dev/null and b/docs/community/wechat-group.png differ diff --git a/l10n.yaml b/l10n.yaml index b8bf72e1..fa1a9703 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,6 +1,6 @@ arb-dir: lib/l10n template-arb-file: app_en.arb output-localization-file: app_localizations.dart +output-dir: lib/l10n nullable-getter: false -synthetic-package: false preferred-supported-locales: ["en"] \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0239b4d3..a13cfcf6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -150,24 +150,104 @@ "searchNoResults": "No matching results found", "searchBatchMode": "Batch Operations", "searchBatchModeWithCount": "Batch Operations ({selected}/{total})", + "@searchBatchModeWithCount": { + "placeholders": { + "selected": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "searchExitBatchMode": "Exit Batch Mode", "searchSelectAll": "Select All", "searchDeselectAll": "Deselect All", "searchSelectedCount": "{count} selected", + "@searchSelectedCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "searchBatchSetNote": "Set Note", "searchBatchChangeCategory": "Change Category", "searchBatchDeleteConfirmTitle": "Confirm Delete", "searchBatchDeleteConfirmMessage": "Are you sure you want to delete the selected {count} transactions?\nThis action cannot be undone.", + "@searchBatchDeleteConfirmMessage": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "searchBatchSetNoteTitle": "Batch Set Note", "searchBatchSetNoteMessage": "Set the same note for the selected {count} transactions", + "@searchBatchSetNoteMessage": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "searchBatchSetNoteHint": "Enter note content (leave empty to clear notes)", "searchBatchDeleteSuccess": "Successfully deleted {count} transactions", + "@searchBatchDeleteSuccess": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "searchBatchDeleteFailed": "Delete failed: {error}", + "@searchBatchDeleteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "searchBatchSetNoteSuccess": "Successfully set note for {count} transactions", + "@searchBatchSetNoteSuccess": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "searchBatchSetNoteFailed": "Set note failed: {error}", + "@searchBatchSetNoteFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "searchBatchChangeCategorySuccess": "Successfully changed category for {count} transactions", + "@searchBatchChangeCategorySuccess": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "searchBatchChangeCategoryFailed": "Change category failed: {error}", + "@searchBatchChangeCategoryFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "searchResultsCount": "{count} results", + "@searchResultsCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "searchFilterTitle": "Filter", "searchAmountFilter": "Amount Filter", "searchDateFilter": "Date Filter", @@ -209,6 +289,13 @@ "ledgersNew": "New Ledger", "ledgersClear": "Clear Ledger", "ledgersClearMessage": "Are you sure to clear all transactions in ledger \"{name}\"? This action cannot be undone.\\nThe ledger will be kept, only transaction data will be deleted.", + "@ledgersClearMessage": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "ledgerDefaultName": "Default Ledger", "ledgersEdit": "Edit Ledger", "ledgersDelete": "Delete Ledger", @@ -221,6 +308,13 @@ "ledgersDeleteLocal": "Delete Local Ledger Only", "ledgersDeleteLocalTitle": "Delete Local Ledger", "ledgersDeleteLocalMessage": "Are you sure to delete local ledger \"{name}\"?\\nCloud backup will be kept and you can restore it anytime.", + "@ledgersDeleteLocalMessage": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "ledgersDeleteLocalSuccess": "Local ledger deleted", "ledgersName": "Name", "ledgersDefaultLedgerName": "Default Ledger", @@ -379,6 +473,13 @@ "importCompleted": "Import Completed{cancelled}, success {ok}, failed {fail}", "importSkippedNonTransactionTypes": "Skipped {count} non-transaction records (debts, etc.)", "importTransactionFailed": "Import failed, all changes have been rolled back: {error}", + "@importTransactionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "importFileOpenError": "Unable to open file picker: {error}", "@importFileOpenError": { "placeholders": { @@ -686,15 +787,36 @@ "categoryClearUnused": "Clear Unused Categories", "categoryClearUnusedTitle": "Clear Unused Categories", "categoryClearUnusedMessage": "Are you sure you want to delete {count} unused categories? This action cannot be undone.", + "@categoryClearUnusedMessage": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "categoryClearUnusedListTitle": "Categories to be deleted:", "categoryClearUnusedEmpty": "No unused categories", "categoryClearUnusedSuccess": "Deleted {count} categories", + "@categoryClearUnusedSuccess": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "categoryClearUnusedFailed": "Clear failed", "categoryShareScopeTitle": "Select Scope", "categoryShareScopeExpense": "Expense categories only", "categoryShareScopeIncome": "Income categories only", "categoryShareScopeAll": "All categories", "categoryShareSuccess": "Saved to {path}", + "@categoryShareSuccess": { + "placeholders": { + "path": { + "type": "String" + } + } + }, "categoryShareSubject": "BeeCount Category Configuration", "categoryShareFailed": "Share failed", "categoryImportInvalidFile": "Please select a category package file (.zip)", @@ -895,6 +1017,13 @@ "categoryMigrationCompleteMessage": "Successfully migrated {count} transactions from \"{fromName}\" to \"{toName}\".", "categoryMigrationFailedTitle": "Migration Failed", "categoryMigrationFailedMessage": "Migration error: {error}", + "@categoryMigrationFailedMessage": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "categoryMigrationTransactionLabel": "{count} records", "@categoryMigrationTransactionLabel": { "placeholders": { @@ -1785,6 +1914,8 @@ "aboutGitHubRepo": "GitHub Repository", "aboutXiaohongshu": "Xiaohongshu", "aboutDouyin": "Douyin", + "aboutWechatGroup": "WeChat Group", + "aboutWechatGroupSubtitle": "Scan to join, developer hangs out here", "aboutSupportDevelopment": "Support Development", "aboutSupportDevelopmentSubtitle": "Buy me a coffee", "aboutDeveloperStoryTitle": "From the Developer", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 93ef97c6..66653b93 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -8022,6 +8022,18 @@ abstract class AppLocalizations { /// **'Douyin'** String get aboutDouyin; + /// No description provided for @aboutWechatGroup. + /// + /// In en, this message translates to: + /// **'WeChat Group'** + String get aboutWechatGroup; + + /// No description provided for @aboutWechatGroupSubtitle. + /// + /// In en, this message translates to: + /// **'Scan to join, developer hangs out here'** + String get aboutWechatGroupSubtitle; + /// No description provided for @aboutSupportDevelopment. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 16d4c5ed..4b9c1ef3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4201,6 +4201,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutDouyin => 'Douyin'; + @override + String get aboutWechatGroup => 'WeChat Group'; + + @override + String get aboutWechatGroupSubtitle => 'Scan to join, developer hangs out here'; + @override String get aboutSupportDevelopment => 'Support Development'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9de78daf..e85aeace 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4201,6 +4201,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutDouyin => '抖音'; + @override + String get aboutWechatGroup => '微信交流群'; + + @override + String get aboutWechatGroupSubtitle => '扫码加群,作者常在'; + @override String get aboutSupportDevelopment => '支持开发'; @@ -10698,6 +10704,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get aboutDouyin => '抖音'; + @override + String get aboutWechatGroup => '微信交流群'; + + @override + String get aboutWechatGroupSubtitle => '掃碼加群,作者常在'; + @override String get aboutSupportDevelopment => '支持開發'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 812d7ce3..28e1896a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1679,6 +1679,8 @@ "aboutGitHubRepo": "GitHub 仓库", "aboutXiaohongshu": "小红书", "aboutDouyin": "抖音", + "aboutWechatGroup": "微信交流群", + "aboutWechatGroupSubtitle": "扫码加群,作者常在", "aboutSupportDevelopment": "支持开发", "aboutSupportDevelopmentSubtitle": "请开发者喝杯咖啡", "aboutDeveloperStoryTitle": "开发者的话", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index b616941c..880dab98 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -40,6 +40,8 @@ "aboutSupportDevelopment": "支持開發", "aboutSupportDevelopmentSubtitle": "請開發者喝杯咖啡", "aboutWebsite": "官方網站", + "aboutWechatGroup": "微信交流群", + "aboutWechatGroupSubtitle": "掃碼加群,作者常在", "aboutWidget": "關于小組件", "aboutXiaohongshu": "小紅書", "accountAddButton": "添加帳戶", diff --git a/lib/pages/calendar/calendar_page.dart b/lib/pages/calendar/calendar_page.dart index d603104d..a52d31f6 100644 --- a/lib/pages/calendar/calendar_page.dart +++ b/lib/pages/calendar/calendar_page.dart @@ -11,6 +11,7 @@ import '../../widgets/category_icon.dart'; import '../../styles/tokens.dart'; import '../../utils/ui_scale_extensions.dart'; import '../../utils/transaction_edit_utils.dart'; +import '../../utils/category_utils.dart'; import '../../utils/currencies.dart'; import '../../providers.dart'; import '../../providers/calendar_providers.dart'; @@ -433,6 +434,12 @@ class _CalendarPageState extends ConsumerState { transactionsByDateProvider((ledgerId: ledgerId, date: date)), ); + final allCategories = ref.watch(categoriesProvider).valueOrNull ?? []; + final Map categoryNameById = { + for (var c in allCategories) + c.id: CategoryUtils.getDisplayName(c.name, context, kind: c.kind), + }; + final header = Padding( padding: const EdgeInsets.fromLTRB(4, 0, 4, 10), child: Row( @@ -536,104 +543,15 @@ class _CalendarPageState extends ConsumerState { final isTransfer = item.t.type == 'transfer'; // 分类名称 - final categoryName = category?.name ?? l10n.commonUncategorized; - - // 备注作为副标题 - final subtitle = item.t.note ?? ''; - - // 标签列表 - final tagsList = item.tags - .map((tag) => (id: tag.id, name: tag.name, color: tag.color)) - .toList(); - - return TransactionListItem( - icon: getCategoryIconData(category: category, categoryName: categoryName), - category: category, - title: isTransfer - ? (subtitle.isNotEmpty ? subtitle : l10n.transferTitle) - : (subtitle.isNotEmpty ? subtitle : categoryName), - categoryName: isTransfer - ? null - : (subtitle.isNotEmpty ? categoryName : null), - amount: item.t.amount, - isExpense: isExpense, - isTransfer: isTransfer, - happenedAt: item.t.happenedAt, - accountName: item.account?.name, - tags: tagsList.isNotEmpty ? tagsList : null, - attachmentCount: item.attachments.length, - onTap: () async { - await TransactionEditUtils.editTransaction( - context, - ref, - item.t, - item.category, - ); - }, - ); - }, - ); - }, - loading: () => _buildTransactionsSkeleton(context), - error: (err, stack) => Padding( - padding: const EdgeInsets.all(24), - child: Center(child: Text('Error: $err')), - ), - ), - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [header, card], - ); - } - - // 构建当月交易列表(不显示日期和统计) - Widget _buildMonthTransactionsList( - BuildContext context, int ledgerId, DateTime month) { - final l10n = AppLocalizations.of(context); + final displayName = category != null + ? CategoryUtils.getDisplayName(category.name, context) + : l10n.commonUncategorized; - // 使用 Provider 查询当月交易 - final startDate = DateTime(month.year, month.month, 1); - final endDate = DateTime(month.year, month.month + 1, 0, 23, 59, 59); - - final transactionsAsync = ref.watch( - monthTransactionsProvider( - (ledgerId: ledgerId, startDate: startDate, endDate: endDate)), - ); - - return SectionCard( - margin: EdgeInsets.zero, - child: transactionsAsync.when( - data: (transactions) { - if (transactions.isEmpty) { - return Padding( - padding: EdgeInsets.all(24.0.scaled(context, ref)), - child: Center( - child: Text( - l10n.calendarNoTransactions, - style: TextStyle( - color: BeeTokens.textTertiary(context), - ), - ), - ), - ); - } - - // 直接显示交易列表 - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - itemCount: transactions.length, - itemBuilder: (context, index) { - final item = transactions[index]; - final category = item.category; - final isExpense = item.t.type == 'expense'; - final isTransfer = item.t.type == 'transfer'; - - // 分类名称 - final categoryName = category?.name ?? l10n.commonUncategorized; + // 父级分类名称(二级分类时显示) + final parentCategoryName = (!isTransfer && category != null) + ? (CategoryUtils.getParentDisplayName(category.name, category.kind, context) + ?? categoryNameById[category.parentId]) + : null; // 备注作为副标题 final subtitle = item.t.note ?? ''; @@ -644,14 +562,13 @@ class _CalendarPageState extends ConsumerState { .toList(); return TransactionListItem( - icon: getCategoryIconData(category: category, categoryName: categoryName), + icon: getCategoryIconData(category: category, categoryName: displayName), category: category, title: isTransfer ? (subtitle.isNotEmpty ? subtitle : l10n.transferTitle) - : (subtitle.isNotEmpty ? subtitle : categoryName), - categoryName: isTransfer - ? null - : (subtitle.isNotEmpty ? categoryName : null), + : (subtitle.isNotEmpty ? subtitle : displayName), + categoryName: isTransfer ? null : displayName, + parentCategoryName: isTransfer ? null : parentCategoryName, amount: item.t.amount, isExpense: isExpense, isTransfer: isTransfer, @@ -681,6 +598,11 @@ class _CalendarPageState extends ConsumerState { ), ), ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [header, card], + ); } String _formatDate(DateTime date) { diff --git a/lib/pages/settings/about_page.dart b/lib/pages/settings/about_page.dart index 08434d60..34d54f52 100644 --- a/lib/pages/settings/about_page.dart +++ b/lib/pages/settings/about_page.dart @@ -159,7 +159,25 @@ class _AboutPageState extends ConsumerState { await _tryOpenUrl(url); }, ), - // 小红书号(仅简体中文显示) + // 微信群入口:所有语言都显示(群本身中文社区,但海外用户 + // 也允许加,落地页按 locale 选 ZH / EN 版)。 + const Divider(height: 1, thickness: 0.5), + AppListTile( + leading: Icons.groups_outlined, + title: AppLocalizations.of(context).aboutWechatGroup, + subtitle: AppLocalizations.of(context).aboutWechatGroupSubtitle, + onTap: () async { + // 群二维码 7 天有效,挂在 docs/community/ 下,每次过期前 + // 仓库里替换 wechat-group.png 就行,app 不用发版。 + final isZh = Localizations.localeOf(context).languageCode == 'zh'; + final docUrl = isZh + ? 'https://github.com/TNT-Likely/BeeCount/blob/main/docs/community/README_ZH.md' + : 'https://github.com/TNT-Likely/BeeCount/blob/main/docs/community/README_EN.md'; + final url = Uri.parse(docUrl); + await _tryOpenUrl(url); + }, + ), + // 小红书号 / 抖音(仅简体中文显示 —— 国内向短视频渠道) if (Localizations.localeOf(context).languageCode == 'zh') ...[ const Divider(height: 1, thickness: 0.5), AppListTile( diff --git a/lib/pages/tag/tag_detail_page.dart b/lib/pages/tag/tag_detail_page.dart index 1ced2116..dbf23c06 100644 --- a/lib/pages/tag/tag_detail_page.dart +++ b/lib/pages/tag/tag_detail_page.dart @@ -303,13 +303,19 @@ class _TagDetailPageState extends ConsumerState { final categoryName = CategoryUtils.getDisplayName(category?.name, context); final isTransfer = transaction.type == 'transfer'; + // 获取父级分类名称(二级分类时显示) + final parentCategoryName = (!isTransfer && category != null) + ? CategoryUtils.getParentDisplayName(category.name, category.kind, context) + : null; + // 和首页保持一致:有备注显示备注,无备注显示分类名称 final hasNote = transaction.note?.isNotEmpty == true; return TransactionListItem( icon: getCategoryIconData(category: category, categoryName: categoryName), category: category, title: hasNote ? transaction.note! : categoryName, - categoryName: hasNote ? null : categoryName, + categoryName: isTransfer ? null : categoryName, + parentCategoryName: isTransfer ? null : parentCategoryName, amount: transaction.amount, isExpense: transaction.type == 'expense', happenedAt: transaction.happenedAt, diff --git a/lib/pages/transaction/search_page.dart b/lib/pages/transaction/search_page.dart index be44baa1..fddedde9 100644 --- a/lib/pages/transaction/search_page.dart +++ b/lib/pages/transaction/search_page.dart @@ -9,6 +9,7 @@ import '../../widgets/ui/ui.dart'; import '../../styles/tokens.dart'; import '../../utils/category_utils.dart'; import '../../l10n/app_localizations.dart'; +import '../../providers/database_providers.dart'; import '../../utils/transaction_edit_utils.dart'; import '../../widgets/category_icon.dart'; import 'category_detail_page.dart'; @@ -593,6 +594,11 @@ class _SearchPageState extends ConsumerState { final ledgerId = ref.watch(currentLedgerIdProvider); final hide = ref.watch(hideAmountsProvider); final l10n = AppLocalizations.of(context); + final allCategories = ref.watch(categoriesProvider).valueOrNull ?? []; + final Map categoryNameById = { + for (var c in allCategories) + c.id: CategoryUtils.getDisplayName(c.name, context, kind: c.kind), + }; return Scaffold( backgroundColor: BeeTokens.scaffoldBackground(context), @@ -954,6 +960,12 @@ class _SearchPageState extends ConsumerState { // 获取分类显示名称 final categoryName = CategoryUtils.getDisplayName(item.category?.name, context); + // 获取父级分类名称(二级分类时显示) + final parentCategoryName = (!isTransfer && item.category != null) + ? (CategoryUtils.getParentDisplayName(item.category!.name, item.category!.kind, context) + ?? categoryNameById[item.category!.parentId]) + : null; + final subtitle = item.t.note ?? ''; final isSelected = _selectedIds.contains(item.t.id); @@ -969,8 +981,8 @@ class _SearchPageState extends ConsumerState { title: subtitle.isNotEmpty ? subtitle : categoryName, - categoryName: - subtitle.isNotEmpty ? null : categoryName, + categoryName: isTransfer ? null : categoryName, + parentCategoryName: isTransfer ? null : parentCategoryName, amount: item.t.amount, isExpense: isExpense, hide: hide, diff --git a/lib/utils/category_utils.dart b/lib/utils/category_utils.dart index ad20b2c6..730d22c5 100644 --- a/lib/utils/category_utils.dart +++ b/lib/utils/category_utils.dart @@ -196,4 +196,15 @@ class CategoryUtils { return translationString.split(separator).map((e) => e.trim()).toList(); } + + /// 获取父级分类的显示名称 + /// + /// 对于二级分类的key格式(如 "dining_breakfast"),提取父级key("dining") + /// 并返回其翻译后的显示名称("餐饮")。 + /// 如果是一级分类,返回null。 + static String? getParentDisplayName(String? categoryName, String kind, BuildContext context) { + if (categoryName == null || !categoryName.contains('_')) return null; + final parentKey = categoryName.split('_').first; + return getDisplayName(parentKey, context, kind: kind); + } } diff --git a/lib/widgets/biz/transaction_list.dart b/lib/widgets/biz/transaction_list.dart index 4bd8e9e7..b69c79f2 100644 --- a/lib/widgets/biz/transaction_list.dart +++ b/lib/widgets/biz/transaction_list.dart @@ -339,6 +339,13 @@ class TransactionListState extends ConsumerState { // —— account / toAccount 由 Drift JOIN + SharedLedger* table-watch 自动 // 推送,UI 直接读 it.account?.name。 + // 加载全部分类,构建分类名称查找表,用于通过 parentId 查找父级分类名称 + final allCategories = ref.watch(categoriesProvider).valueOrNull ?? []; + final Map categoryNameById = { + for (final c in allCategories) + c.id: CategoryUtils.getDisplayName(c.name, context, kind: c.kind), + }; + _buildFlatItems(); // 无数据时展示空状态 @@ -423,6 +430,16 @@ class TransactionListState extends ConsumerState { ? AppLocalizations.of(context).adjustmentTransaction : CategoryUtils.getDisplayName(it.category?.name, context); + // 获取父级分类名称(二级分类时显示) + // 优先通过名称中的下划线格式解析(内置分类如 "dining_breakfast" → "dining") + // 如果解析失败且有 parentId,从全部分类表中查询 + final parentCategoryName = (!isTransfer && !isAdjustment && it.category != null) + ? (CategoryUtils.getParentDisplayName(it.category!.name, it.category!.kind, context) + ?? (it.category!.parentId != null + ? categoryNameById[it.category!.parentId] + : null)) + : null; + final subtitle = it.t.note ?? ''; // 检查是否是当天最后一项 @@ -502,9 +519,8 @@ class TransactionListState extends ConsumerState { : isAdjustment ? categoryName : (subtitle.isNotEmpty ? subtitle : categoryName), - categoryName: (isTransfer || isAdjustment) - ? null - : (subtitle.isNotEmpty ? null : categoryName), + categoryName: (isTransfer || isAdjustment) ? null : categoryName, + parentCategoryName: isTransfer || isAdjustment ? null : parentCategoryName, amount: it.t.amount, isExpense: isExpense, isTransfer: isTransfer, diff --git a/lib/widgets/biz/transaction_list_item.dart b/lib/widgets/biz/transaction_list_item.dart index a5971dc8..60d1f00f 100644 --- a/lib/widgets/biz/transaction_list_item.dart +++ b/lib/widgets/biz/transaction_list_item.dart @@ -20,6 +20,7 @@ class TransactionListItem extends ConsumerWidget { final VoidCallback? onTap; final VoidCallback? onCategoryTap; // 点击分类图标/名称的回调 final String? categoryName; // 分类名称,用于显示 + final String? parentCategoryName; // 父级分类名称(二级分类时显示) final VoidCallback? onDelete; // 删除回调 final String? accountName; // 账户名称,用于显示 final DateTime? happenedAt; // 交易时间,用于显示时分 @@ -39,46 +40,51 @@ class TransactionListItem extends ConsumerWidget { final VoidCallback? onAttachmentTap; // 点击附件图标回调 const TransactionListItem({ - super.key, - required this.icon, - this.category, - required this.title, - required this.amount, - required this.isExpense, - this.isTransfer = false, - this.isAdjustment = false, - this.hide, - this.onTap, - this.onCategoryTap, - this.categoryName, - this.onDelete, - this.accountName, - this.happenedAt, - this.isSelectionMode = false, - this.isSelected = false, - this.onSelectionChanged, - this.showFullDate = false, - this.tags, - this.onTagTap, - this.attachmentCount = 0, - this.onAttachmentTap, + super.key, + required this.icon, + this.category, + required this.title, + required this.amount, + required this.isExpense, + this.isTransfer = false, + this.isAdjustment = false, + this.hide, + this.onTap, + this.onCategoryTap, + this.categoryName, + this.parentCategoryName, + this.onDelete, + this.accountName, + this.happenedAt, + this.isSelectionMode = false, + this.isSelected = false, + this.onSelectionChanged, + this.showFullDate = false, + this.tags, + this.onTagTap, + this.attachmentCount = 0, + this.onAttachmentTap, }); - - /// 检查是否有次要信息需要显示(时间、账户或附件) + /// 检查是否有次要信息需要显示(分类标签、时间、账户或附件) bool _hasSecondaryInfo(WidgetRef ref) { + // 有分类名时始终显示标签,保持风格一致性 + if (categoryName != null) return true; + // 显示完整日期模式 if (showFullDate && happenedAt != null) return true; // 显示时间(设置开启 + 有数据 + 不是00:00:00) final showTime = ref.watch(showTransactionTimeProvider) && happenedAt != null && - (happenedAt!.hour != 0 || happenedAt!.minute != 0 || happenedAt!.second != 0); + (happenedAt!.hour != 0 || + happenedAt!.minute != 0 || + happenedAt!.second != 0); return showTime || accountName != null || attachmentCount > 0; } - /// 构建次要信息小部件(时间 · 账户 + 附件图标) + /// 构建次要信息小部件(分类标签 · 时间 · 账户 + 附件图标) Widget _buildSecondaryInfo(BuildContext context, WidgetRef ref) { final parts = []; @@ -91,7 +97,9 @@ class TransactionListItem extends ConsumerWidget { '${happenedAt!.hour.toString().padLeft(2, '0')}:${happenedAt!.minute.toString().padLeft(2, '0')}', ); } else if (ref.watch(showTransactionTimeProvider) && - (happenedAt!.hour != 0 || happenedAt!.minute != 0 || happenedAt!.second != 0)) { + (happenedAt!.hour != 0 || + happenedAt!.minute != 0 || + happenedAt!.second != 0)) { // 完整时间模式(HH:mm:ss) parts.add( '${happenedAt!.hour.toString().padLeft(2, '0')}:${happenedAt!.minute.toString().padLeft(2, '0')}:${happenedAt!.second.toString().padLeft(2, '0')}', @@ -105,9 +113,9 @@ class TransactionListItem extends ConsumerWidget { } final textStyle = Theme.of(context).textTheme.bodySmall?.copyWith( - color: BeeTokens.textTertiary(context), - fontSize: 11, - ); + color: BeeTokens.textTertiary(context), + fontSize: 11, + ); // 构建附件图标部件(可点击) Widget buildAttachmentWidget() { @@ -136,21 +144,208 @@ class TransactionListItem extends ConsumerWidget { return widget; } - // 如果只有附件,没有其他信息 - if (parts.isEmpty && attachmentCount > 0) { - return buildAttachmentWidget(); + // 组装行内容 + final rowChildren = []; + + // 前置:分类标签 + if (categoryName != null) { + rowChildren.add(_buildCategoryLabels(context)); } - // 有其他信息时 - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(parts.join(' · '), style: textStyle), - if (attachmentCount > 0) ...[ - Text(' · ', style: textStyle), - buildAttachmentWidget(), - ], - ], + // 中间:时间 · 账户 + if (parts.isNotEmpty) { + if (rowChildren.isNotEmpty) { + rowChildren.add(Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text('·', style: textStyle), + )); + } + rowChildren.add(Text(parts.join(' · '), style: textStyle)); + } + + // 尾部:附件图标 + if (attachmentCount > 0) { + if (rowChildren.isNotEmpty) { + rowChildren.add(Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text('·', style: textStyle), + )); + } + rowChildren.add(buildAttachmentWidget()); + } + + if (rowChildren.isEmpty) return const SizedBox.shrink(); + + final hasCategory = categoryName != null; + final hasTimeOrAccount = parts.isNotEmpty; + final hasAttach = attachmentCount > 0; + + // 用于宽度预估的分类标签样式(与 _buildCategoryLabels 保持一致) + final colorScheme = Theme.of(context).colorScheme; + final primaryColor = colorScheme.primary; + final labelTextStyle = Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 10, + color: primaryColor, + height: 1.2, + fontWeight: FontWeight.w500, + ); + final parentTextStyle = parentCategoryName != null + ? labelTextStyle?.copyWith(color: primaryColor.withValues(alpha: 0.8)) + : null; + + return LayoutBuilder( + builder: (ctx, constraints) { + final availableWidth = constraints.maxWidth; + + // 预估一行总宽度 + double totalWidth = 0; + + // 分类标签宽度(Container padding 6+6=12 + 文字 + 间距) + if (hasCategory) { + totalWidth += 12; + if (parentCategoryName != null) { + totalWidth += _textWidth(parentCategoryName!, parentTextStyle, ctx); + totalWidth += _textWidth('>', labelTextStyle, ctx) + 2; + } + totalWidth += _textWidth(categoryName!, labelTextStyle, ctx); + totalWidth += 12; + } + + // 分隔符 · 和 时间·账户 + if (hasCategory && hasTimeOrAccount) { + totalWidth += _textWidth('·', textStyle, ctx) + 4; + } + if (hasTimeOrAccount) { + totalWidth += _textWidth(parts.join(' · '), textStyle, ctx); + } + + // 分隔符 · 和附件图标 + if (hasAttach) { + if (hasTimeOrAccount || hasCategory) { + totalWidth += _textWidth('·', textStyle, ctx) + 4; + } + totalWidth += 12 + 2 + _textWidth('$attachmentCount', textStyle, ctx); + } + + // 留 8px 余量 + if (totalWidth <= availableWidth + 8) { + // 一行放得下 + return Row( + mainAxisSize: MainAxisSize.min, + children: rowChildren, + ); + } + + // 放不下 → 拆两行:分类标签一行,时间·账户·附件一行 + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasCategory) _buildCategoryLabels(context), + if (hasTimeOrAccount || hasAttach) + Padding( + padding: EdgeInsets.only(top: hasCategory ? 2 : 0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTimeOrAccount) + Text(parts.join(' · '), style: textStyle), + if (hasAttach) ...[ + if (hasTimeOrAccount) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text('·', style: textStyle), + ), + buildAttachmentWidget(), + ], + ], + ), + ), + ], + ); + }, + ); + } + + /// 测量文本宽度(TextPainter) + double _textWidth(String text, TextStyle? style, BuildContext context) { + if (text.isEmpty || style == null) return 0; + final tp = TextPainter( + text: TextSpan(text: text, style: style), + maxLines: 1, + textDirection: Directionality.of(context), + )..layout(); + return tp.width; + } + + /// 构建分类标签小部件(用于内嵌在次要信息行中) + /// 风格:单一标签 + 统一样式,二级分类用「 > 」分隔 + Widget _buildCategoryLabels(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final primaryColor = colorScheme.primary; + + // 统一样式:浅底色 + 主色文字 + final labelTextStyle = Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 10, + color: primaryColor, + height: 1.2, + fontWeight: FontWeight.w500, + ); + + // 有二级分类时,父类文字稍淡 + final parentTextStyle = parentCategoryName != null + ? labelTextStyle?.copyWith(color: primaryColor.withValues(alpha: 0.8)) + : null; + + // 构建标签文字样式 + final TextStyle? labelStyle = labelTextStyle; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: primaryColor.withValues(alpha: 0.22), + width: 0.8, + ), + ), + child: parentCategoryName != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + parentCategoryName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: parentTextStyle, + ), + Padding( + padding: const EdgeInsets.only(left: 1, right: 1), + child: Text( + '>', + style: labelStyle?.copyWith( + color: primaryColor.withValues(alpha: 0.45), + ), + ), + ), + Text( + categoryName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelStyle, + ), + ], + ) + : ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 72), + child: Text( + categoryName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: labelStyle, + ), + ), ); } @@ -191,7 +386,7 @@ class TransactionListItem extends ConsumerWidget { ), ), const SizedBox(width: 12), - // 左侧:分类名称 + 备注 + 时间·账户 + // 左侧:分类名称 + 备注 + 分类标签 + 时间·账户 Expanded( child: Padding( padding: const EdgeInsets.only(right: 12), @@ -200,27 +395,25 @@ class TransactionListItem extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - // 第一行:分类名称(始终显示) - Text( - categoryName ?? title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: BeeTextTokens.title(context), - ), - // 第二行:备注(当title与categoryName不同时显示) - if (categoryName != null && categoryName != title) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: BeeTokens.textSecondary(context), - ), - ), + if (categoryName != null && + categoryName != title && + title.isNotEmpty) + // 有备注时:第一行显示备注,分类名作为标签显示在下面 + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: BeeTextTokens.title(context), + ) + else + // 无备注时:显示分类名称(或transfer等标题) + Text( + categoryName ?? title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: BeeTextTokens.title(context), ), - // 第三行:时间 · 账户 · 附件 + // 时间 · 账户 · 附件 · 分类标签 if (_hasSecondaryInfo(ref)) Padding( padding: const EdgeInsets.only(top: 2), @@ -240,7 +433,9 @@ class TransactionListItem extends ConsumerWidget { AmountText( value: isAdjustment ? amount // adjustment 直接显示原始值(含正负) - : isExpense ? -amount : amount, + : isExpense + ? -amount + : amount, hide: hide, signed: !isTransfer, // 转账不显示正负号 decimals: 2, @@ -292,10 +487,11 @@ class TransactionListItem extends ConsumerWidget { confirmDismiss: (direction) async { // 显示确认对话框 return await AppDialog.confirm( - context, - title: '确认删除', - message: '确定要删除这笔交易吗?此操作无法撤销。', - ) ?? false; + context, + title: '确认删除', + message: '确定要删除这笔交易吗?此操作无法撤销。', + ) ?? + false; }, onDismissed: (direction) { onDelete!(); diff --git a/packages/flutter_cloud_sync_webdav/lib/src/webdav_storage_service.dart b/packages/flutter_cloud_sync_webdav/lib/src/webdav_storage_service.dart index 5125c91c..d69a2ddf 100644 --- a/packages/flutter_cloud_sync_webdav/lib/src/webdav_storage_service.dart +++ b/packages/flutter_cloud_sync_webdav/lib/src/webdav_storage_service.dart @@ -2,6 +2,7 @@ library; import 'dart:convert'; +import 'package:dio/dio.dart'; import 'package:flutter_cloud_sync/flutter_cloud_sync.dart'; import 'package:webdav_client/webdav_client.dart' as webdav; @@ -43,20 +44,57 @@ class WebDAVStorageService implements CloudStorageService { @override Future download({required String path}) async { try { - // Build full path final fullPath = _buildPath(path); - // Download file - final bytes = await _client.read(fullPath); + try { + // Try standard read (OPTIONS + GET). Some WebDAV servers + // (e.g. Synology NAS) return 404 for OPTIONS on individual files, + // so fall back to direct GET if that happens. + final bytes = await _client.read(fullPath); + return utf8.decode(bytes); + } catch (e) { + final msg = e.toString().toLowerCase(); + final isNotFound = msg.contains('404') || msg.contains('not found'); - // Convert bytes to string - return utf8.decode(bytes); + if (!isNotFound) rethrow; + + // OPTIONS pre-check failed — fall back to direct GET. + return _directGet(fullPath); + } } catch (e) { - // Return null if file not found - if (e.toString().contains('404') || e.toString().contains('not found')) { + throw CloudStorageException('Download failed: $e', e); + } + } + + /// Bypass OPTIONS pre-check and download file directly via GET. + Future _directGet(String fullPath) async { + try { + final response = await _client.c.req>( + _client, + 'GET', + fullPath, + optionsHandler: (options) { + options.responseType = ResponseType.bytes; + options.headers?['accept'] = '*/*'; + }, + ); + + if (response.statusCode == 404) return null; + if (response.statusCode != 200) { + throw CloudStorageException( + 'Download failed: HTTP ${response.statusCode}'); + } + + final data = response.data; + if (data == null || data.isEmpty) return ''; + + return utf8.decode(data); + } catch (e) { + final msg = e.toString().toLowerCase(); + if (msg.contains('404') || msg.contains('not found')) { return null; } - throw CloudStorageException('Download failed: $e', e); + rethrow; } } diff --git a/packages/flutter_cloud_sync_webdav/pubspec.lock b/packages/flutter_cloud_sync_webdav/pubspec.lock index cb95eeb2..c0907d97 100644 --- a/packages/flutter_cloud_sync_webdav/pubspec.lock +++ b/packages/flutter_cloud_sync_webdav/pubspec.lock @@ -89,8 +89,24 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - dio: + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "72d146c6d7098689ff5c5f66bcf593ac11efc530095385356e131070333e64da" + url: "https://pub.dev" + source: hosted + version: "11.3.0" + device_info_plus_platform_interface: dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + dio: + dependency: "direct main" description: name: dio sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 @@ -141,6 +157,20 @@ packages: relative: true source: path version: "0.1.0" + flutter_cloud_sync_icloud: + dependency: transitive + description: + path: "../flutter_cloud_sync_icloud" + relative: true + source: path + version: "0.1.0" + flutter_cloud_sync_s3: + dependency: transitive + description: + path: "../flutter_cloud_sync_s3" + relative: true + source: path + version: "0.1.0" flutter_cloud_sync_supabase: dependency: transitive description: @@ -286,6 +316,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -651,6 +697,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" xdg_directories: dependency: transitive description: diff --git a/packages/flutter_cloud_sync_webdav/pubspec.yaml b/packages/flutter_cloud_sync_webdav/pubspec.yaml index 51920f28..b1596c38 100644 --- a/packages/flutter_cloud_sync_webdav/pubspec.yaml +++ b/packages/flutter_cloud_sync_webdav/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: path: ../flutter_cloud_sync webdav_client: ^1.2.2 http: ^1.1.0 + dio: ^5.0.0 dev_dependencies: flutter_test: