From f8d479dcc8dc42d05a76c2320e6267305536a8dd Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 28 May 2026 15:50:54 +0800 Subject: [PATCH 1/2] fix(category): upsertCategory now matches by name AND kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents expense categories from being silently mapped to income categories with the same name during CSV import. Root cause: upsertCategory() WHERE clause only filtered by name, ignoring the kind parameter. When a category name existed in both expense and income (e.g. 'transfer'/'转账'), the function returned the first match regardless of kind. Fix: add c.kind.equals(kind) to the WHERE clause, ensuring upsert matches both name and kind before reusing an existing category. --- lib/data/repositories/local/local_category_repository.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/data/repositories/local/local_category_repository.dart b/lib/data/repositories/local/local_category_repository.dart index 52ee609b..0f0d6306 100644 --- a/lib/data/repositories/local/local_category_repository.dart +++ b/lib/data/repositories/local/local_category_repository.dart @@ -203,9 +203,10 @@ class LocalCategoryRepository implements CategoryRepository { String? icon, int? sortOrder, }) async { - // name 全局唯一:按 name 找;有则复用,无则用给定 kind/icon/sortOrder 建。 + // Match by name + kind to prevent same-name categories across + // expense/income from being incorrectly reused (e.g. 'transfer'). final existing = await (db.select(db.categories) - ..where((c) => c.name.equals(name))) + ..where((c) => c.name.equals(name) & c.kind.equals(kind))) .get(); if (existing.isNotEmpty) return existing.first.id; return db.into(db.categories).insert(CategoriesCompanion.insert( From 13529ec8640d1d6ebf0d420aec25cdbd45cad7d1 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 28 May 2026 17:49:06 +0800 Subject: [PATCH 2/2] feat(category): subcategory grid supports drag-to-reorder Replace GridView.builder with ReorderableGridView.builder in the subcategory dialog, allowing users to long-press and drag subcategory cards to reorder them. - onReorder applies optimistic local update then persists to DB via existing updateCategorySortOrders() - Add/edit action buttons at end of grid excluded from drag - ValueKey added to DialogSubCategoryCard for stable identity --- lib/pages/category/category_manage_page.dart | 29 ++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/lib/pages/category/category_manage_page.dart b/lib/pages/category/category_manage_page.dart index 04ccfb05..666b9e3a 100644 --- a/lib/pages/category/category_manage_page.dart +++ b/lib/pages/category/category_manage_page.dart @@ -1019,7 +1019,7 @@ class _SubcategoryDialogState extends ConsumerState<_SubcategoryDialog> { child: Center(child: CircularProgressIndicator()), ) else - GridView.builder( + ReorderableGridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( @@ -1028,10 +1028,29 @@ class _SubcategoryDialogState extends ConsumerState<_SubcategoryDialog> { mainAxisSpacing: 10, childAspectRatio: 1, ), - itemCount: (_subCategories?.length ?? 0) + 2, // 子分类 + 添加 + 编辑 + itemCount: (_subCategories?.length ?? 0) + 2, + onReorder: (oldIndex, newIndex) { + final subCategories = _subCategories; + if (subCategories == null) return; + final subCount = subCategories.length; + // Ignore drags involving the add/edit buttons + if (oldIndex >= subCount || newIndex >= subCount) return; + if (oldIndex < newIndex) newIndex -= 1; + // Optimistic local update + final reordered = List<({db.Category category, int transactionCount})>.from(subCategories); + final moved = reordered.removeAt(oldIndex); + reordered.insert(newIndex, moved); + setState(() => _subCategories = reordered); + // Persist to DB + final repo = ref.read(repositoryProvider); + final updates = reordered.asMap().entries.map((e) => ( + id: e.value.category.id, + sortOrder: e.key, + )).toList(); + repo.updateCategorySortOrders(updates); + }, itemBuilder: (context, index) { final subCategories = _subCategories ?? []; - // 添加按钮 if (index == subCategories.length) { return _DialogActionButton( onTap: widget.onAddSubCategory, @@ -1039,7 +1058,6 @@ class _SubcategoryDialogState extends ConsumerState<_SubcategoryDialog> { label: l10n.commonAdd, ); } - // 编辑按钮 if (index == subCategories.length + 1) { return _DialogActionButton( onTap: widget.onEditParentCategory, @@ -1047,9 +1065,9 @@ class _SubcategoryDialogState extends ConsumerState<_SubcategoryDialog> { label: l10n.commonEdit, ); } - // 子分类 final item = subCategories[index]; return _DialogSubCategoryCard( + key: ValueKey(item.category.id), category: item.category, transactionCount: item.transactionCount, onTap: () => widget.onSubCategoryTap(item.category), @@ -1119,6 +1137,7 @@ class _DialogSubCategoryCard extends StatelessWidget { final VoidCallback onTap; const _DialogSubCategoryCard({ + super.key, required this.category, required this.transactionCount, required this.onTap,