From f092f2d3710e8e2889be4b89ef7145c2ae9422bf Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 28 Jan 2026 23:38:00 +0630 Subject: [PATCH 01/11] ci: refactor release workflow for create release using different actions --- .github/workflows/release.yaml | 74 +++++++++++++--------------------- 1 file changed, 28 insertions(+), 46 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cacda9d..2aafd32 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,13 +7,11 @@ on: jobs: # ======================================== - # Job 1: Create GitHub Release + # Build and Release Android # ======================================== - create-release: - name: Create Release + release-android: + name: Build and Release Android runs-on: ubuntu-latest - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} steps: - name: πŸ“š Checkout repository @@ -23,29 +21,6 @@ jobs: id: get_version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - - name: πŸŽ‰ Create GitHub Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ steps.get_version.outputs.VERSION }} - draft: false - prerelease: false - - # ======================================== - # Job 2: Build and Release Android - # ======================================== - release-android: - name: Release Android - runs-on: ubuntu-latest - needs: create-release - - steps: - - name: πŸ“š Checkout repository - uses: actions/checkout@v6 - - name: β˜• Setup Java uses: actions/setup-java@v4 with: @@ -118,29 +93,34 @@ jobs: # track: production # status: completed - - name: πŸ“€ Upload APK to Release - uses: actions/upload-release-asset@v1 + - name: πŸŽ‰ Create Release and Upload APK + uses: softprops/action-gh-release@v2 + with: + name: Release ${{ steps.get_version.outputs.VERSION }} + draft: false + prerelease: false + files: | + apps/mobile/build/app/outputs/flutter-apk/app-release.apk + fail_on_unmatched_files: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: apps/mobile/build/app/outputs/flutter-apk/app-release.apk - asset_name: collection-tracker-android.apk - asset_content_type: application/vnd.android.package-archive # ======================================== # Job 3: Build and Release iOS # ======================================== # release-ios: - # name: Release iOS + # name: Build and Release iOS # runs-on: macos-latest - # needs: create-release # steps: # - name: πŸ“š Checkout repository # uses: actions/checkout@v6 - # - name: 🐦 Setup Flutter + # - name: οΏ½ Extract version from tag + # id: get_version + # run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT + + # - name: �🐦 Setup Flutter # uses: subosito/flutter-action@v2 # with: # flutter-version: '3.38.x' @@ -187,12 +167,14 @@ jobs: # cd apps/mobile # flutter build ipa --release --export-options-plist=ios/ExportOptions.plist - # - name: πŸ“€ Upload to App Store + # - name: πŸŽ‰ Create Release and Upload IPA + # uses: softprops/action-gh-release@v2 + # with: + # name: Release ${{ steps.get_version.outputs.VERSION }} + # draft: false + # prerelease: false + # files: | + # apps/mobile/build/ios/ipa/*.ipa + # fail_on_unmatched_files: true # env: - # APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} - # run: | - # xcrun altool --upload-app \ - # --type ios \ - # --file apps/mobile/build/ios/ipa/*.ipa \ - # --apiKey ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} \ - # --apiIssuer ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From c281eb8757ba9e14aa102346d2be6b33b5a519d6 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 10:06:54 +0630 Subject: [PATCH 02/11] fix: metadata search is not shown --- .../features/items/presentation/views/add_item_screen.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart b/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart index 3201d59..3da2cb7 100644 --- a/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -42,6 +44,9 @@ class _AddItemScreenState extends ConsumerState { @override Widget build(BuildContext context) { + // Watch collection details so they are available for search/scan actions + ref.watch(collectionDetailProvider(widget.collectionId)); + return Scaffold( appBar: AppBar(title: const Text('Add Item')), body: Form( @@ -187,6 +192,7 @@ class _AddItemScreenState extends ConsumerState { } Future _showMetadataSearch(BuildContext context) async { + log('show metadata search'); final collectionAsync = ref.read( collectionDetailProvider(widget.collectionId), ); From 3c7f77aa04aab8c4b25688894dd8fdd7a2032302 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 18:43:53 +0630 Subject: [PATCH 03/11] feat: tags system with database, repository --- README.md | 6 +- .../repositories/item_repository_impl.dart | 64 +++-- .../database/lib/src/app_database.dart | 12 +- .../database/lib/src/daos/item_dao.dart | 237 +++++++++++++++++- .../lib/src/tables/item_tags_table.dart | 15 ++ .../database/lib/src/tables/tables.dart | 2 + .../database/lib/src/tables/tags_table.dart | 13 + .../database/test/database_test.dart | 64 +++++ 8 files changed, 379 insertions(+), 34 deletions(-) create mode 100644 packages/integrations/database/lib/src/tables/item_tags_table.dart create mode 100644 packages/integrations/database/lib/src/tables/tags_table.dart diff --git a/README.md b/README.md index 281cc88..4dff37a 100644 --- a/README.md +++ b/README.md @@ -252,9 +252,9 @@ If you have any questions or issues: ## πŸ—ΊοΈ Roadmap -- [ ] Barcode scanning with camera -- [ ] Image upload and gallery -- [ ] Advanced search and filters +- [x] Barcode scanning with camera +- [x] Image upload and gallery +- [x] Advanced search and filters - [ ] Cloud synchronization - [ ] Import/Export data (CSV, JSON) - [ ] Price tracking and statistics diff --git a/packages/core/data/lib/src/repositories/item_repository_impl.dart b/packages/core/data/lib/src/repositories/item_repository_impl.dart index 0c57186..f90503e 100644 --- a/packages/core/data/lib/src/repositories/item_repository_impl.dart +++ b/packages/core/data/lib/src/repositories/item_repository_impl.dart @@ -16,19 +16,21 @@ class ItemRepositoryImpl implements ItemRepository { int? offset, }) async { try { - final List data; + final List<(ItemData, List)> data; if (limit != null && offset != null) { - data = await _dao.getItemsPaginated( + data = await _dao.getItemsWithTagsPaginated( collectionId: collectionId, limit: limit, offset: offset, ); } else { - data = await _dao.getItemsByCollection(collectionId); + data = await _dao.getItemsWithTags(collectionId); } - final items = data.map(_mapToEntity).toList(); + final items = data + .map((entry) => _mapToEntity(entry.$1, entry.$2)) + .toList(); return Right(items); } catch (e, stack) { return Left( @@ -43,7 +45,7 @@ class ItemRepositoryImpl implements ItemRepository { @override Future> getItemById(String id) async { try { - final data = await _dao.getItemById(id); + final data = await _dao.getItemWithTags(id); if (data == null) { return const Left( AppException.notFound( @@ -52,7 +54,7 @@ class ItemRepositoryImpl implements ItemRepository { ), ); } - return Right(_mapToEntity(data)); + return Right(_mapToEntity(data.$1, data.$2)); } catch (e, stack) { return Left( AppException.database( @@ -67,7 +69,7 @@ class ItemRepositoryImpl implements ItemRepository { Future> createItem(Item item) async { try { final companion = _mapToCompanion(item); - await _dao.insertItem(companion); + await _dao.insertItem(companion, tags: item.tags); return Right(item); } catch (e, stack) { return Left( @@ -83,7 +85,7 @@ class ItemRepositoryImpl implements ItemRepository { Future> updateItem(Item item) async { try { final companion = _mapToCompanion(item); - final success = await _dao.updateItem(companion); + final success = await _dao.updateItem(companion, tags: item.tags); if (success < 1) { return const Left( AppException.notFound( @@ -121,29 +123,44 @@ class ItemRepositoryImpl implements ItemRepository { @override Stream> watchItems(String collectionId) { return _dao - .watchItemsByCollection(collectionId) - .map((data) => data.map(_mapToEntity).toList()); + .watchItemsWithTags(collectionId) + .map( + (data) => + data.map((entry) => _mapToEntity(entry.$1, entry.$2)).toList(), + ); } @override Stream watchItemById(String id) { return _dao - .watchItemById(id) - .map((data) => data != null ? _mapToEntity(data) : null); + .watchItemWithTags(id) + .map((data) => data != null ? _mapToEntity(data.$1, data.$2) : null); } @override Stream> watchAllFavoriteItems() { - return _dao.watchAllFavoriteItems().map( - (data) => data.map(_mapToEntity).toList(), - ); + return _dao.watchAllFavoriteItems().asyncMap((data) async { + final mapped = await Future.wait( + data.map((item) async { + final tags = await _dao.getTagsForItem(item.id); + return _mapToEntity(item, tags); + }), + ); + return mapped; + }); } @override Stream> watchAllWishlistItems() { - return _dao.watchAllWishlistItems().map( - (data) => data.map(_mapToEntity).toList(), - ); + return _dao.watchAllWishlistItems().asyncMap((data) async { + final mapped = await Future.wait( + data.map((item) async { + final tags = await _dao.getTagsForItem(item.id); + return _mapToEntity(item, tags); + }), + ); + return mapped; + }); } @override @@ -156,7 +173,12 @@ class ItemRepositoryImpl implements ItemRepository { collectionId: collectionId, query: query, ); - final items = data.map(_mapToEntity).toList(); + final items = await Future.wait( + data.map((item) async { + final tags = await _dao.getTagsForItem(item.id); + return _mapToEntity(item, tags); + }), + ); return Right(items); } catch (e, stack) { return Left( @@ -183,7 +205,7 @@ class ItemRepositoryImpl implements ItemRepository { } } - Item _mapToEntity(ItemData data) { + Item _mapToEntity(ItemData data, [List tags = const []]) { return Item( id: data.id, collectionId: data.collectionId, @@ -210,7 +232,7 @@ class ItemRepositoryImpl implements ItemRepository { isWishlist: data.isWishlist, quantity: data.quantity, sortOrder: data.sortOrder, - tags: [], // Tags will be implemented later + tags: tags, createdAt: data.createdAt, updatedAt: data.updatedAt, ); diff --git a/packages/integrations/database/lib/src/app_database.dart b/packages/integrations/database/lib/src/app_database.dart index 0371d38..7dce502 100644 --- a/packages/integrations/database/lib/src/app_database.dart +++ b/packages/integrations/database/lib/src/app_database.dart @@ -6,12 +6,15 @@ import 'package:path_provider/path_provider.dart'; part 'app_database.g.dart'; -@DriftDatabase(tables: [Collections, Items], daos: [CollectionDao, ItemDao]) +@DriftDatabase( + tables: [Collections, Items, Tags, ItemTags], + daos: [CollectionDao, ItemDao], +) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration { @@ -33,6 +36,11 @@ class AppDatabase extends _$AppDatabase { if (from < 3) { await m.addColumn(items, items.isWishlist); } + + if (from < 4) { + await m.createTable(tags); + await m.createTable(itemTags); + } }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); diff --git a/packages/integrations/database/lib/src/daos/item_dao.dart b/packages/integrations/database/lib/src/daos/item_dao.dart index 95e5fc9..7713fbb 100644 --- a/packages/integrations/database/lib/src/daos/item_dao.dart +++ b/packages/integrations/database/lib/src/daos/item_dao.dart @@ -4,7 +4,7 @@ import 'package:drift/drift.dart'; part 'item_dao.g.dart'; -@DriftAccessor(tables: [Items, Collections]) +@DriftAccessor(tables: [Items, Collections, ItemTags, Tags]) class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { ItemDao(super.db); @@ -114,12 +114,187 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { .watch(); } - // Insert item - Future insertItem(ItemsCompanion item) { + // Get tags for an item + Future> getTagsForItem(String itemId) async { + final query = select(itemTags).join([ + innerJoin(tags, tags.id.equalsExp(itemTags.tagId)), + ])..where(itemTags.itemId.equals(itemId)); + + final result = await query.map((row) => row.readTable(tags).name).get(); + return result; + } + + // Watch tags for an item + Stream> watchTagsForItem(String itemId) { + final query = select(itemTags).join([ + innerJoin(tags, tags.id.equalsExp(itemTags.tagId)), + ])..where(itemTags.itemId.equals(itemId)); + + return query.map((row) => row.readTable(tags).name).watch(); + } + + // Watch items with tags + Stream)>> watchItemsWithTags( + String collectionId, + ) { + final query = + select(items).join([ + leftOuterJoin(itemTags, itemTags.itemId.equalsExp(items.id)), + leftOuterJoin(tags, tags.id.equalsExp(itemTags.tagId)), + ]) + ..where(items.collectionId.equals(collectionId)) + ..orderBy([OrderingTerm.desc(items.createdAt)]); + + return query.watch().map((rows) { + final grouped = )>{}; + + for (final row in rows) { + final item = row.readTable(items); + final tag = row.readTableOrNull(tags); + + if (!grouped.containsKey(item.id)) { + grouped[item.id] = (item, []); + } + + if (tag != null) { + grouped[item.id]!.$2.add(tag.name); + } + } + + return grouped.values.toList(); + }); + } + + // Watch item with tags + Stream<(ItemData, List)?> watchItemWithTags(String id) { + final query = select(items).join([ + leftOuterJoin(itemTags, itemTags.itemId.equalsExp(items.id)), + leftOuterJoin(tags, tags.id.equalsExp(itemTags.tagId)), + ])..where(items.id.equals(id)); + + return query.watch().map((rows) { + if (rows.isEmpty) return null; + + final item = rows.first.readTable(items); + final tagNames = []; + + for (final row in rows) { + final tag = row.readTableOrNull(tags); + if (tag != null) { + tagNames.add(tag.name); + } + } + + return (item, tagNames); + }); + } + + // Get item with tags + Future<(ItemData, List)?> getItemWithTags(String id) async { + final query = select(items).join([ + leftOuterJoin(itemTags, itemTags.itemId.equalsExp(items.id)), + leftOuterJoin(tags, tags.id.equalsExp(itemTags.tagId)), + ])..where(items.id.equals(id)); + + final rows = await query.get(); + if (rows.isEmpty) return null; + + final item = rows.first.readTable(items); + final tagNames = []; + + for (final row in rows) { + final tag = row.readTableOrNull(tags); + if (tag != null) { + tagNames.add(tag.name); + } + } + + return (item, tagNames); + } + + // Get items with tags + Future)>> getItemsWithTags( + String collectionId, + ) async { + final query = + select(items).join([ + leftOuterJoin(itemTags, itemTags.itemId.equalsExp(items.id)), + leftOuterJoin(tags, tags.id.equalsExp(itemTags.tagId)), + ]) + ..where(items.collectionId.equals(collectionId)) + ..orderBy([OrderingTerm.desc(items.createdAt)]); + + final rows = await query.get(); + final grouped = )>{}; + + for (final row in rows) { + final item = row.readTable(items); + final tag = row.readTableOrNull(tags); + + if (!grouped.containsKey(item.id)) { + grouped[item.id] = (item, []); + } + + if (tag != null) { + grouped[item.id]!.$2.add(tag.name); + } + } + + return grouped.values.toList(); + } + + // Get items with tags paginated + Future)>> getItemsWithTagsPaginated({ + required String collectionId, + required int limit, + required int offset, + }) async { + // We need to query items first to apply pagination, then join tags + final itemsQuery = select(items) + ..where((tbl) => tbl.collectionId.equals(collectionId)) + ..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)]) + ..limit(limit, offset: offset); + + final itemRows = await itemsQuery.get(); + + if (itemRows.isEmpty) { + return []; + } + + final itemIds = itemRows.map((e) => e.id).toList(); + + final tagsQuery = select(itemTags).join([ + innerJoin(tags, tags.id.equalsExp(itemTags.tagId)), + ])..where(itemTags.itemId.isIn(itemIds)); + + final tagRows = await tagsQuery.get(); + + final tagMap = >{}; + for (final row in tagRows) { + final itemId = row.readTable(itemTags).itemId; + final tagName = row.readTable(tags).name; + + if (!tagMap.containsKey(itemId)) { + tagMap[itemId] = []; + } + tagMap[itemId]!.add(tagName); + } + + return itemRows.map((item) { + return (item, tagMap[item.id] ?? []); + }).toList(); + } + + // Insert item with tags + Future insertItem(ItemsCompanion item, {List? tags}) { return transaction(() async { final id = await into(items).insert(item); final collectionId = item.collectionId.value; + if (tags != null && tags.isNotEmpty) { + await _updateItemTags(item.id.value, tags); + } + final collection = await (select( collections, )..where((tbl) => tbl.id.equals(collectionId))).getSingleOrNull(); @@ -139,11 +314,55 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { }); } - // Update item - Future updateItem(ItemsCompanion item) { - return (update( - items, - )..where((tbl) => tbl.id.equals(item.id.value))).write(item); + // Update item with tags + Future updateItem(ItemsCompanion item, {List? tags}) { + return transaction(() async { + final rowsAffected = await (update( + items, + )..where((tbl) => tbl.id.equals(item.id.value))).write(item); + + if (rowsAffected > 0 && tags != null) { + await _updateItemTags(item.id.value, tags); + } + + return rowsAffected; + }); + } + + Future _updateItemTags(String itemId, List tagNames) async { + // 1. Get or create tags + final tagIds = []; + for (final name in tagNames) { + final existingTag = await (select( + tags, + )..where((tbl) => tbl.name.equals(name))).getSingleOrNull(); + + if (existingTag != null) { + tagIds.add(existingTag.id); + } else { + final newTagId = DateTime.now().microsecondsSinceEpoch + .toString(); // Simple ID generation + await into(tags).insert( + TagsCompanion.insert( + id: newTagId, + name: name, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + tagIds.add(newTagId); + } + } + + // 2. Remove existing item tags + await (delete(itemTags)..where((tbl) => tbl.itemId.equals(itemId))).go(); + + // 3. Insert new item tags + for (final tagId in tagIds) { + await into( + itemTags, + ).insert(ItemTagsCompanion.insert(itemId: itemId, tagId: tagId)); + } } // Delete item @@ -152,6 +371,8 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { final item = await getItemById(id); if (item == null) return 0; + // Tags and ItemTags are deleted by cascade + final deletedCount = await (delete( items, )..where((tbl) => tbl.id.equals(id))).go(); diff --git a/packages/integrations/database/lib/src/tables/item_tags_table.dart b/packages/integrations/database/lib/src/tables/item_tags_table.dart new file mode 100644 index 0000000..a3d74f7 --- /dev/null +++ b/packages/integrations/database/lib/src/tables/item_tags_table.dart @@ -0,0 +1,15 @@ +import 'package:drift/drift.dart'; + +import 'items_table.dart'; +import 'tags_table.dart'; + +@DataClassName('ItemTagData') +class ItemTags extends Table { + TextColumn get itemId => + text().references(Items, #id, onDelete: KeyAction.cascade)(); + TextColumn get tagId => + text().references(Tags, #id, onDelete: KeyAction.cascade)(); + + @override + Set get primaryKey => {itemId, tagId}; +} diff --git a/packages/integrations/database/lib/src/tables/tables.dart b/packages/integrations/database/lib/src/tables/tables.dart index bec7f98..54bd61d 100644 --- a/packages/integrations/database/lib/src/tables/tables.dart +++ b/packages/integrations/database/lib/src/tables/tables.dart @@ -1,2 +1,4 @@ export 'collections_table.dart'; export 'items_table.dart'; +export 'tags_table.dart'; +export 'item_tags_table.dart'; diff --git a/packages/integrations/database/lib/src/tables/tags_table.dart b/packages/integrations/database/lib/src/tables/tags_table.dart new file mode 100644 index 0000000..164a0ae --- /dev/null +++ b/packages/integrations/database/lib/src/tables/tags_table.dart @@ -0,0 +1,13 @@ +import 'package:drift/drift.dart'; + +@DataClassName('TagData') +class Tags extends Table { + TextColumn get id => text()(); + TextColumn get name => text().unique().withLength(min: 1, max: 50)(); + TextColumn get color => text().nullable()(); // Hex color string + DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get updatedAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} diff --git a/packages/integrations/database/test/database_test.dart b/packages/integrations/database/test/database_test.dart index 8c21916..761a3be 100644 --- a/packages/integrations/database/test/database_test.dart +++ b/packages/integrations/database/test/database_test.dart @@ -331,5 +331,69 @@ void main() { expect(wishes.length, 1); expect(wishes.first.title, 'Wish'); }); + + test('insert item with tags', () async { + final now = DateTime.now(); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-tags-1', + collectionId: collectionId, + title: 'Tagged Item', + createdAt: now, + updatedAt: now, + ), + tags: const ['Manga', 'Rare'], + ); + + final itemWithTags = await db.itemDao.getItemWithTags('item-tags-1'); + expect(itemWithTags, isNotNull); + expect(itemWithTags!.$2, containsAll(['Manga', 'Rare'])); + }); + + test('update item tags replaces existing tags', () async { + final now = DateTime.now(); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-tags-2', + collectionId: collectionId, + title: 'Replace Tags', + createdAt: now, + updatedAt: now, + ), + tags: const ['OldTag'], + ); + + await db.itemDao.updateItem( + ItemsCompanion( + id: const Value('item-tags-2'), + title: const Value('Replace Tags'), + updatedAt: Value(now.add(const Duration(minutes: 1))), + ), + tags: const ['NewTagA', 'NewTagB'], + ); + + final tags = await db.itemDao.getTagsForItem('item-tags-2'); + expect(tags, containsAll(['NewTagA', 'NewTagB'])); + expect(tags, isNot(contains('OldTag'))); + }); + + test('deleting item removes item-tag relations', () async { + final now = DateTime.now(); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-tags-3', + collectionId: collectionId, + title: 'Delete Tagged', + createdAt: now, + updatedAt: now, + ), + tags: const ['ToDelete'], + ); + + await db.itemDao.deleteItem('item-tags-3'); + final tags = await db.itemDao.getTagsForItem('item-tags-3'); + + expect(tags, isEmpty); + }); }); } From a3b89bfc33fb66a8f520d225927eea676823e483 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 18:59:59 +0630 Subject: [PATCH 04/11] feat: Implement item tag management including editor, filtering, and display across item screens. --- .../providers/items_filter_provider.dart | 27 ++- .../view_models/items_view_model.dart | 2 + .../presentation/views/add_item_screen.dart | 13 ++ .../presentation/views/edit_item_screen.dart | 14 ++ .../views/item_detail_screen.dart | 28 +++ .../presentation/views/items_screen.dart | 7 +- .../items/presentation/widgets/item_card.dart | 35 ++++ .../widgets/item_filter_sheet.dart | 40 ++++- .../presentation/widgets/item_grid_card.dart | 13 ++ .../widgets/item_tags_editor.dart | 161 ++++++++++++++++++ 10 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/lib/features/items/presentation/widgets/item_tags_editor.dart diff --git a/apps/mobile/lib/features/items/presentation/providers/items_filter_provider.dart b/apps/mobile/lib/features/items/presentation/providers/items_filter_provider.dart index 233db77..fb079d9 100644 --- a/apps/mobile/lib/features/items/presentation/providers/items_filter_provider.dart +++ b/apps/mobile/lib/features/items/presentation/providers/items_filter_provider.dart @@ -21,6 +21,7 @@ class ItemFilterState { final ItemSortBy sortBy; final bool sortAscending; final Set conditions; + final Set tags; final bool showOnlyFavorites; final bool showOnlyWishlist; @@ -29,6 +30,7 @@ class ItemFilterState { this.sortBy = ItemSortBy.custom, this.sortAscending = true, this.conditions = const {}, + this.tags = const {}, this.showOnlyFavorites = false, this.showOnlyWishlist = false, }); @@ -38,6 +40,7 @@ class ItemFilterState { ItemSortBy? sortBy, bool? sortAscending, Set? conditions, + Set? tags, bool? showOnlyFavorites, bool? showOnlyWishlist, }) { @@ -46,6 +49,7 @@ class ItemFilterState { sortBy: sortBy ?? this.sortBy, sortAscending: sortAscending ?? this.sortAscending, conditions: conditions ?? this.conditions, + tags: tags ?? this.tags, showOnlyFavorites: showOnlyFavorites ?? this.showOnlyFavorites, showOnlyWishlist: showOnlyWishlist ?? this.showOnlyWishlist, ); @@ -81,6 +85,16 @@ class ItemFilter extends _$ItemFilter { state = state.copyWith(conditions: nextConditions); } + void toggleTag(String tag) { + final nextTags = Set.from(state.tags); + if (nextTags.contains(tag)) { + nextTags.remove(tag); + } else { + nextTags.add(tag); + } + state = state.copyWith(tags: nextTags); + } + void toggleFavorites() { state = state.copyWith(showOnlyFavorites: !state.showOnlyFavorites); } @@ -107,7 +121,8 @@ Stream> filteredItemsList(Ref ref, String collectionId) async* { final query = filter.searchQuery.toLowerCase(); filtered = filtered.where((item) { return item.title.toLowerCase().contains(query) || - (item.description?.toLowerCase().contains(query) ?? false); + (item.description?.toLowerCase().contains(query) ?? false) || + item.tags.any((tag) => tag.toLowerCase().contains(query)); }).toList(); } @@ -129,6 +144,16 @@ Stream> filteredItemsList(Ref ref, String collectionId) async* { }).toList(); } + // Tags (match any selected tag) + if (filter.tags.isNotEmpty) { + filtered = filtered.where((item) { + final itemTags = item.tags.map((tag) => tag.toLowerCase()).toSet(); + return filter.tags.any( + (selected) => itemTags.contains(selected.toLowerCase()), + ); + }).toList(); + } + // Sorting filtered.sort((a, b) { final comparison = switch (filter.sortBy) { diff --git a/apps/mobile/lib/features/items/presentation/view_models/items_view_model.dart b/apps/mobile/lib/features/items/presentation/view_models/items_view_model.dart index d2a9251..a1ba1c8 100644 --- a/apps/mobile/lib/features/items/presentation/view_models/items_view_model.dart +++ b/apps/mobile/lib/features/items/presentation/view_models/items_view_model.dart @@ -21,6 +21,7 @@ Future createItem( String? description, String? coverImageUrl, String? coverImagePath, + List tags = const [], }) async { final repository = ref.read(itemRepositoryProvider); @@ -32,6 +33,7 @@ Future createItem( description: description, coverImageUrl: coverImageUrl, coverImagePath: coverImagePath, + tags: tags, createdAt: DateTime.now(), updatedAt: DateTime.now(), ); diff --git a/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart b/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart index 3da2cb7..e68fa60 100644 --- a/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/add_item_screen.dart @@ -11,6 +11,7 @@ import 'package:metadata_api/metadata_api.dart'; import 'metadata_search_delegate.dart'; import '../view_models/items_view_model.dart'; +import '../widgets/item_tags_editor.dart'; class AddItemScreen extends ConsumerStatefulWidget { final String collectionId; @@ -33,6 +34,7 @@ class _AddItemScreenState extends ConsumerState { bool _isFetchingMetadata = false; String? _imagePath; String? _coverImageUrl; + List _tags = const []; @override void dispose() { @@ -172,6 +174,16 @@ class _AddItemScreenState extends ConsumerState { maxLines: 4, textCapitalization: TextCapitalization.sentences, ), + const SizedBox(height: 16), + + ItemTagsEditor( + initialTags: _tags, + onChanged: (tags) { + _tags = tags; + }, + label: 'Tags (optional)', + hintText: 'e.g., Rare, Completed Set', + ), const SizedBox(height: 24), // Add button @@ -295,6 +307,7 @@ class _AddItemScreenState extends ConsumerState { : _descriptionController.text.trim(), coverImageUrl: _coverImageUrl, coverImagePath: _imagePath, + tags: _tags, ).future, ); diff --git a/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart b/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart index 8e049d9..4295bc7 100644 --- a/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/edit_item_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../view_models/items_view_model.dart'; +import '../widgets/item_tags_editor.dart'; class EditItemScreen extends ConsumerStatefulWidget { final String itemId; @@ -27,6 +28,7 @@ class _EditItemScreenState extends ConsumerState { bool _isInitialized = false; Item? _item; ItemCondition? _selectedCondition; + List _tags = const []; @override void initState() { @@ -66,6 +68,7 @@ class _EditItemScreenState extends ConsumerState { _locationController.text = item.location ?? ''; _quantityController.text = item.quantity.toString(); _selectedCondition = item.condition; + _tags = List.from(item.tags); _isInitialized = true; } @@ -128,6 +131,16 @@ class _EditItemScreenState extends ConsumerState { ), const SizedBox(height: 16), + ItemTagsEditor( + initialTags: _tags, + onChanged: (tags) { + _tags = tags; + }, + label: 'Tags (optional)', + hintText: 'e.g., Signed, First Edition', + ), + const SizedBox(height: 16), + // Condition selector DropdownButtonFormField( initialValue: _selectedCondition, @@ -247,6 +260,7 @@ class _EditItemScreenState extends ConsumerState { : _locationController.text.trim(), quantity: int.parse(_quantityController.text), condition: _selectedCondition, + tags: _tags, ); await ref.read(updateItemProvider(updated).future); diff --git a/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart b/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart index ccc69db..bb6dfc9 100644 --- a/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart @@ -149,6 +149,34 @@ class ItemDetailScreen extends ConsumerWidget { const SizedBox(height: 16), ], + if (item.tags.isNotEmpty) ...[ + Text( + 'Tags', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + AnimatedSize( + duration: const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: item.tags + .map( + (tag) => Chip( + label: Text(tag), + backgroundColor: + theme.colorScheme.secondaryContainer, + ), + ) + .toList(), + ), + ), + const SizedBox(height: 16), + ], + // Details Card Card( child: Padding( diff --git a/apps/mobile/lib/features/items/presentation/views/items_screen.dart b/apps/mobile/lib/features/items/presentation/views/items_screen.dart index 05b4960..f94dd8a 100644 --- a/apps/mobile/lib/features/items/presentation/views/items_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/items_screen.dart @@ -108,6 +108,7 @@ class _ItemsScreenState extends ConsumerState { icon: Badge( isLabelVisible: filter.conditions.isNotEmpty || + filter.tags.isNotEmpty || filter.showOnlyFavorites || filter.sortBy != ItemSortBy.createdAt, child: const Icon(Icons.filter_list), @@ -144,6 +145,7 @@ class _ItemsScreenState extends ConsumerState { Icon( _isSearching || filter.conditions.isNotEmpty || + filter.tags.isNotEmpty || filter.showOnlyFavorites ? Icons.search_off : Icons.inventory_2_outlined, @@ -157,6 +159,7 @@ class _ItemsScreenState extends ConsumerState { items.isEmpty && (_isSearching || filter.conditions.isNotEmpty || + filter.tags.isNotEmpty || filter.showOnlyFavorites) ? 'No matches found' : 'No items yet', @@ -166,12 +169,14 @@ class _ItemsScreenState extends ConsumerState { Text( _isSearching || filter.conditions.isNotEmpty || + filter.tags.isNotEmpty || filter.showOnlyFavorites ? 'Try adjusting your filters' : 'Add your first item to get started', ), if (!_isSearching && filter.conditions.isEmpty && + filter.tags.isEmpty && !filter.showOnlyFavorites) ...[ const SizedBox(height: 24), FilledButton.icon( @@ -336,7 +341,7 @@ class _ItemsScreenState extends ConsumerState { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => const ItemFilterSheet(), + builder: (context) => ItemFilterSheet(collectionId: widget.collectionId), ); } diff --git a/apps/mobile/lib/features/items/presentation/widgets/item_card.dart b/apps/mobile/lib/features/items/presentation/widgets/item_card.dart index 708e2e8..acf3774 100644 --- a/apps/mobile/lib/features/items/presentation/widgets/item_card.dart +++ b/apps/mobile/lib/features/items/presentation/widgets/item_card.dart @@ -103,6 +103,41 @@ class ItemCard extends StatelessWidget { ), ], ), + if (item.tags.isNotEmpty) ...[ + const SizedBox(height: 8), + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: Wrap( + spacing: 6, + runSpacing: 6, + children: item.tags + .take(3) + .map( + (tag) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + tag, + style: theme.textTheme.labelSmall?.copyWith( + color: theme + .colorScheme + .onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ) + .toList(), + ), + ), + ], ], ), ), diff --git a/apps/mobile/lib/features/items/presentation/widgets/item_filter_sheet.dart b/apps/mobile/lib/features/items/presentation/widgets/item_filter_sheet.dart index 5df7be5..7155abc 100644 --- a/apps/mobile/lib/features/items/presentation/widgets/item_filter_sheet.dart +++ b/apps/mobile/lib/features/items/presentation/widgets/item_filter_sheet.dart @@ -1,16 +1,28 @@ import 'package:domain/domain.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../providers/items_filter_provider.dart'; +import '../view_models/items_view_model.dart'; class ItemFilterSheet extends ConsumerWidget { - const ItemFilterSheet({super.key}); + final String collectionId; + + const ItemFilterSheet({required this.collectionId, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final filter = ref.watch(itemFilterProvider); final notifier = ref.read(itemFilterProvider.notifier); + final itemsAsync = ref.watch(itemsListProvider(collectionId)); final theme = Theme.of(context); + final availableTags = itemsAsync.maybeWhen( + data: (items) { + final tags = items.expand((item) => item.tags).toSet().toList()..sort(); + return tags; + }, + orElse: () => const [], + ); return DraggableScrollableSheet( initialChildSize: 0.6, @@ -110,6 +122,32 @@ class ItemFilterSheet extends ConsumerWidget { ); }).toList(), ), + if (availableTags.isNotEmpty) ...[ + const SizedBox(height: 24), + Text( + 'Tags', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: availableTags.map((tag) { + final isSelected = filter.tags.contains(tag); + return FilterChip( + label: Text(tag), + selected: isSelected, + onSelected: (_) => notifier.toggleTag(tag), + ); + }).toList(), + ), + ), + ], const SizedBox(height: 48), SizedBox( diff --git a/apps/mobile/lib/features/items/presentation/widgets/item_grid_card.dart b/apps/mobile/lib/features/items/presentation/widgets/item_grid_card.dart index 402058a..7771e25 100644 --- a/apps/mobile/lib/features/items/presentation/widgets/item_grid_card.dart +++ b/apps/mobile/lib/features/items/presentation/widgets/item_grid_card.dart @@ -58,6 +58,19 @@ class ItemGridCard extends StatelessWidget { color: theme.colorScheme.primary, ), ), + if (item.tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '#${item.tags.first}', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ], ), ), diff --git a/apps/mobile/lib/features/items/presentation/widgets/item_tags_editor.dart b/apps/mobile/lib/features/items/presentation/widgets/item_tags_editor.dart new file mode 100644 index 0000000..5dc7931 --- /dev/null +++ b/apps/mobile/lib/features/items/presentation/widgets/item_tags_editor.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; + +class ItemTagsEditor extends StatefulWidget { + final List initialTags; + final ValueChanged> onChanged; + final String label; + final String hintText; + + const ItemTagsEditor({ + required this.initialTags, + required this.onChanged, + this.label = 'Tags', + this.hintText = 'Add a tag', + super.key, + }); + + @override + State createState() => _ItemTagsEditorState(); +} + +class _ItemTagsEditorState extends State { + final _controller = TextEditingController(); + late List _tags; + + @override + void initState() { + super.initState(); + _tags = List.from(widget.initialTags); + } + + @override + void didUpdateWidget(covariant ItemTagsEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.initialTags != widget.initialTags) { + _tags = List.from(widget.initialTags); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _controller, + decoration: InputDecoration( + hintText: widget.hintText, + prefixIcon: const Icon(Icons.sell_outlined), + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _addTag(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _addTag, + icon: const Icon(Icons.add), + tooltip: 'Add tag', + ), + ], + ), + const SizedBox(height: 10), + AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: _tags.isEmpty + ? Container( + key: const ValueKey('empty-tags'), + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'No tags yet. Add tags to organize items faster.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + : AnimatedSize( + key: const ValueKey('tags'), + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _tags + .map( + (tag) => InputChip( + label: Text(tag), + selected: true, + selectedColor: theme.colorScheme.secondaryContainer, + onDeleted: () => _removeTag(tag), + ), + ) + .toList(), + ), + ), + ), + ], + ); + } + + void _addTag() { + final raw = _controller.text.trim(); + if (raw.isEmpty) return; + + final normalized = raw.replaceAll(RegExp(r'\s+'), ' '); + if (normalized.length > 50) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tags must be 50 characters or less')), + ); + return; + } + + final alreadyExists = _tags.any( + (tag) => tag.toLowerCase() == normalized.toLowerCase(), + ); + if (alreadyExists) { + _controller.clear(); + return; + } + + setState(() { + _tags = [..._tags, normalized]; + _controller.clear(); + }); + widget.onChanged(_tags); + } + + void _removeTag(String tag) { + setState(() { + _tags = _tags.where((value) => value != tag).toList(); + }); + widget.onChanged(_tags); + } +} From b3c8cb1f719a3aa4e359c1d7d7401fb44c4d2b5d Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 19:16:03 +0630 Subject: [PATCH 05/11] feat: Implement tag management functionality, including renaming, merging, and deleting tags. --- apps/mobile/lib/core/router/app_router.dart | 9 + apps/mobile/lib/core/router/routes.dart | 1 + .../tag_management_view_model.dart | 42 +++ .../presentation/views/items_screen.dart | 5 + .../views/tag_management_screen.dart | 312 ++++++++++++++++++ .../presentation/views/settings_screen.dart | 7 + .../repositories/item_repository_impl.dart | 56 ++++ .../lib/src/repositories/item_repository.dart | 10 + .../database/lib/src/daos/item_dao.dart | 126 +++++++ .../database/test/database_test.dart | 122 +++++++ 10 files changed, 690 insertions(+) create mode 100644 apps/mobile/lib/features/items/presentation/view_models/tag_management_view_model.dart create mode 100644 apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart diff --git a/apps/mobile/lib/core/router/app_router.dart b/apps/mobile/lib/core/router/app_router.dart index dbd656e..259e825 100644 --- a/apps/mobile/lib/core/router/app_router.dart +++ b/apps/mobile/lib/core/router/app_router.dart @@ -15,6 +15,7 @@ import '../../features/scanner/presentation/views/scanner_screen.dart'; import '../../features/search/presentation/views/search_screen.dart'; import '../../features/settings/presentation/views/settings_screen.dart'; import '../../features/statistics/presentation/views/statistics_screen.dart'; +import '../../features/items/presentation/views/tag_management_screen.dart'; import 'app_shell.dart'; import 'package:collection_tracker/core/observers/analytics_observer.dart'; import 'routes.dart'; @@ -125,6 +126,14 @@ GoRouter appRouter(Ref ref) { path: Routes.settings, name: 'settings', builder: (_, _) => const SettingsScreen(), + routes: [ + GoRoute( + path: 'tags', + name: 'manage-tags', + parentNavigatorKey: _rootNavigatorKey, + builder: (_, _) => const TagManagementScreen(), + ), + ], ), ], ), diff --git a/apps/mobile/lib/core/router/routes.dart b/apps/mobile/lib/core/router/routes.dart index 024eba5..1111fed 100644 --- a/apps/mobile/lib/core/router/routes.dart +++ b/apps/mobile/lib/core/router/routes.dart @@ -7,6 +7,7 @@ abstract final class Routes { static const scanner = '/scanner'; static const statistics = '/statistics'; static const settings = '/settings'; + static const settingsTags = '/settings/tags'; // static String bookingWithId(int id) => '$booking/$id'; } diff --git a/apps/mobile/lib/features/items/presentation/view_models/tag_management_view_model.dart b/apps/mobile/lib/features/items/presentation/view_models/tag_management_view_model.dart new file mode 100644 index 0000000..08c07ef --- /dev/null +++ b/apps/mobile/lib/features/items/presentation/view_models/tag_management_view_model.dart @@ -0,0 +1,42 @@ +import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final tagsWithUsageProvider = StreamProvider>((ref) { + final repository = ref.watch(itemRepositoryProvider); + return repository.watchTagsWithUsage(); +}); + +final renameTagProvider = + FutureProvider.family(( + ref, + args, + ) async { + final repository = ref.read(itemRepositoryProvider); + final result = await repository.renameTag( + oldName: args.oldName, + newName: args.newName, + ); + result.fold((exception) => throw exception, (_) => null); + }); + +final mergeTagsProvider = + FutureProvider.family(( + ref, + args, + ) async { + final repository = ref.read(itemRepositoryProvider); + final result = await repository.mergeTags( + sourceName: args.sourceName, + targetName: args.targetName, + ); + result.fold((exception) => throw exception, (_) => null); + }); + +final deleteTagProvider = FutureProvider.family(( + ref, + tagName, +) async { + final repository = ref.read(itemRepositoryProvider); + final result = await repository.deleteTag(tagName); + result.fold((exception) => throw exception, (_) => null); +}); diff --git a/apps/mobile/lib/features/items/presentation/views/items_screen.dart b/apps/mobile/lib/features/items/presentation/views/items_screen.dart index f94dd8a..fca7a79 100644 --- a/apps/mobile/lib/features/items/presentation/views/items_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/items_screen.dart @@ -115,6 +115,11 @@ class _ItemsScreenState extends ConsumerState { ), onPressed: () => _showFilterSheet(context), ), + IconButton( + icon: const Icon(Icons.sell_outlined), + tooltip: 'Manage tags', + onPressed: () => context.push('/settings/tags'), + ), ], IconButton( icon: Icon(_isSearching ? Icons.close : Icons.search), diff --git a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart new file mode 100644 index 0000000..03b6f5c --- /dev/null +++ b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../view_models/tag_management_view_model.dart'; + +class TagManagementScreen extends ConsumerStatefulWidget { + const TagManagementScreen({super.key}); + + @override + ConsumerState createState() => + _TagManagementScreenState(); +} + +class _TagManagementScreenState extends ConsumerState { + final _searchController = TextEditingController(); + String _query = ''; + bool _isBusy = false; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tagsAsync = ref.watch(tagsWithUsageProvider); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Manage Tags')), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: TextField( + controller: _searchController, + onChanged: (value) => setState(() => _query = value.trim()), + decoration: const InputDecoration( + hintText: 'Search tags...', + prefixIcon: Icon(Icons.search), + ), + ), + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: tagsAsync.when( + data: (tags) { + final filtered = tags.where((entry) { + if (_query.isEmpty) return true; + return entry.$1.toLowerCase().contains( + _query.toLowerCase(), + ); + }).toList(); + + if (filtered.isEmpty) { + return Center( + key: const ValueKey('empty-tags'), + child: Text( + _query.isEmpty + ? 'No tags created yet' + : 'No tags match "$_query"', + style: theme.textTheme.bodyLarge, + ), + ); + } + + return ListView.separated( + key: const ValueKey('tags-list'), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + itemCount: filtered.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final tag = filtered[index].$1; + final usage = filtered[index].$2; + + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: + theme.colorScheme.secondaryContainer, + child: const Icon(Icons.sell_outlined), + ), + title: Text(tag), + subtitle: Text( + usage == 1 + ? 'Used in 1 item' + : 'Used in $usage items', + ), + trailing: PopupMenuButton<_TagAction>( + onSelected: (action) => + _handleAction(action: action, tag: tag), + itemBuilder: (context) => const [ + PopupMenuItem( + value: _TagAction.rename, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.drive_file_rename_outline, + ), + title: Text('Rename'), + ), + ), + PopupMenuItem( + value: _TagAction.merge, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.merge_type), + title: Text('Merge Into...'), + ), + ), + PopupMenuItem( + value: _TagAction.delete, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.delete, + color: Colors.red, + ), + title: Text( + 'Delete', + style: TextStyle(color: Colors.red), + ), + ), + ), + ], + ), + ), + ) + .animate(delay: (index * 35).ms) + .fadeIn(duration: 260.ms, curve: Curves.easeOut) + .slideY(begin: 0.08, end: 0, duration: 260.ms); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text('Failed to load tags: $error'), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Future _handleAction({ + required _TagAction action, + required String tag, + }) async { + if (_isBusy) return; + + switch (action) { + case _TagAction.rename: + await _renameTag(tag); + case _TagAction.merge: + await _mergeTag(tag); + case _TagAction.delete: + await _deleteTag(tag); + } + } + + Future _renameTag(String oldName) async { + final controller = TextEditingController(text: oldName); + final newName = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Rename Tag'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'New name', + prefixIcon: Icon(Icons.sell_outlined), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('Rename'), + ), + ], + ), + ); + controller.dispose(); + + if (newName == null || newName.isEmpty || newName == oldName || !mounted) { + return; + } + + await _runTagMutation( + action: () => ref.read( + renameTagProvider((oldName: oldName, newName: newName)).future, + ), + successMessage: '"$oldName" renamed to "$newName"', + ); + } + + Future _mergeTag(String sourceName) async { + final tags = ref.read(tagsWithUsageProvider).asData?.value ?? const []; + final candidates = tags + .map((entry) => entry.$1) + .where((name) => name != sourceName) + .toList(); + if (candidates.isEmpty || !mounted) return; + + final targetName = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + const ListTile( + title: Text('Merge Into'), + subtitle: Text('Choose destination tag'), + ), + ...candidates.map( + (name) => ListTile( + leading: const Icon(Icons.sell_outlined), + title: Text(name), + onTap: () => Navigator.pop(context, name), + ), + ), + ], + ), + ), + ); + + if (targetName == null || targetName.isEmpty || !mounted) return; + + await _runTagMutation( + action: () => ref.read( + mergeTagsProvider(( + sourceName: sourceName, + targetName: targetName, + )).future, + ), + successMessage: '"$sourceName" merged into "$targetName"', + ); + } + + Future _deleteTag(String tagName) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Tag'), + content: Text( + 'Delete "$tagName" from all items?\n\nThis cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + await _runTagMutation( + action: () => ref.read(deleteTagProvider(tagName).future), + successMessage: '"$tagName" deleted', + ); + } + + Future _runTagMutation({ + required Future Function() action, + required String successMessage, + }) async { + setState(() => _isBusy = true); + try { + await action(); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(successMessage))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Tag update failed: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isBusy = false); + } + } + } +} + +enum _TagAction { rename, merge, delete } diff --git a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart index 3e3a24d..d0957cc 100644 --- a/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart +++ b/apps/mobile/lib/features/settings/presentation/views/settings_screen.dart @@ -1,6 +1,7 @@ import 'package:collection_tracker/core/providers/providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:storage/storage.dart'; import 'package:ui/ui.dart'; @@ -61,6 +62,12 @@ class SettingsScreen extends ConsumerWidget { // todo(mixin27): Implement cloud sync }, ), + ListTile( + leading: const Icon(Icons.sell_outlined), + title: const Text('Manage Tags'), + subtitle: const Text('Rename, merge, and delete tags'), + onTap: () => context.push('/settings/tags'), + ), const Divider(), _SectionHeader(title: 'About'), ListTile( diff --git a/packages/core/data/lib/src/repositories/item_repository_impl.dart b/packages/core/data/lib/src/repositories/item_repository_impl.dart index f90503e..2241872 100644 --- a/packages/core/data/lib/src/repositories/item_repository_impl.dart +++ b/packages/core/data/lib/src/repositories/item_repository_impl.dart @@ -163,6 +163,11 @@ class ItemRepositoryImpl implements ItemRepository { }); } + @override + Stream> watchTagsWithUsage() { + return _dao.watchTagsWithUsage(); + } + @override Future>> searchItems({ required String collectionId, @@ -205,6 +210,57 @@ class ItemRepositoryImpl implements ItemRepository { } } + @override + Future> renameTag({ + required String oldName, + required String newName, + }) async { + try { + await _dao.renameTag(oldName: oldName, newName: newName); + return const Right(null); + } catch (e, stack) { + return Left( + AppException.database( + message: 'Failed to rename tag', + stackTrace: stack, + ), + ); + } + } + + @override + Future> mergeTags({ + required String sourceName, + required String targetName, + }) async { + try { + await _dao.mergeTags(sourceName: sourceName, targetName: targetName); + return const Right(null); + } catch (e, stack) { + return Left( + AppException.database( + message: 'Failed to merge tags', + stackTrace: stack, + ), + ); + } + } + + @override + Future> deleteTag(String tagName) async { + try { + await _dao.deleteTagByName(tagName); + return const Right(null); + } catch (e, stack) { + return Left( + AppException.database( + message: 'Failed to delete tag', + stackTrace: stack, + ), + ); + } + } + Item _mapToEntity(ItemData data, [List tags = const []]) { return Item( id: data.id, diff --git a/packages/core/domain/lib/src/repositories/item_repository.dart b/packages/core/domain/lib/src/repositories/item_repository.dart index a7730c7..8b4891e 100644 --- a/packages/core/domain/lib/src/repositories/item_repository.dart +++ b/packages/core/domain/lib/src/repositories/item_repository.dart @@ -18,6 +18,7 @@ abstract class ItemRepository { Stream watchItemById(String id); Stream> watchAllFavoriteItems(); Stream> watchAllWishlistItems(); + Stream> watchTagsWithUsage(); Future>> searchItems({ required String collectionId, @@ -25,4 +26,13 @@ abstract class ItemRepository { }); Future> reorderItems(List itemIds); + Future> renameTag({ + required String oldName, + required String newName, + }); + Future> mergeTags({ + required String sourceName, + required String targetName, + }); + Future> deleteTag(String tagName); } diff --git a/packages/integrations/database/lib/src/daos/item_dao.dart b/packages/integrations/database/lib/src/daos/item_dao.dart index 7713fbb..b9eaab0 100644 --- a/packages/integrations/database/lib/src/daos/item_dao.dart +++ b/packages/integrations/database/lib/src/daos/item_dao.dart @@ -8,6 +8,42 @@ part 'item_dao.g.dart'; class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { ItemDao(super.db); + // Get all tags with usage count + Future> getTagsWithUsage() async { + final rows = await customSelect( + ''' + SELECT t.name AS name, COUNT(it.item_id) AS usage + FROM tags t + LEFT JOIN item_tags it ON it.tag_id = t.id + GROUP BY t.id, t.name + ORDER BY t.name COLLATE NOCASE ASC + ''', + readsFrom: {tags, itemTags}, + ).get(); + + return rows + .map((row) => (row.read('name'), row.read('usage'))) + .toList(); + } + + // Watch all tags with usage count + Stream> watchTagsWithUsage() { + return customSelect( + ''' + SELECT t.name AS name, COUNT(it.item_id) AS usage + FROM tags t + LEFT JOIN item_tags it ON it.tag_id = t.id + GROUP BY t.id, t.name + ORDER BY t.name COLLATE NOCASE ASC + ''', + readsFrom: {tags, itemTags}, + ).watch().map( + (rows) => rows + .map((row) => (row.read('name'), row.read('usage'))) + .toList(), + ); + } + // Get all items in a collection Future> getItemsByCollection(String collectionId) { return (select(items) @@ -365,6 +401,96 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { } } + // Rename tag. If new tag already exists, this becomes a merge. + Future renameTag({ + required String oldName, + required String newName, + }) async { + final sourceName = oldName.trim(); + final targetName = newName.trim(); + if (sourceName.isEmpty || targetName.isEmpty || sourceName == targetName) { + return; + } + + await transaction(() async { + final sourceTag = await (select( + tags, + )..where((tbl) => tbl.name.equals(sourceName))).getSingleOrNull(); + if (sourceTag == null) return; + + final targetTag = await (select( + tags, + )..where((tbl) => tbl.name.equals(targetName))).getSingleOrNull(); + + if (targetTag == null) { + await (update(tags)..where((tbl) => tbl.id.equals(sourceTag.id))).write( + TagsCompanion( + name: Value(targetName), + updatedAt: Value(DateTime.now()), + ), + ); + return; + } + + await _mergeTagIds(sourceTagId: sourceTag.id, targetTagId: targetTag.id); + await (delete(tags)..where((tbl) => tbl.id.equals(sourceTag.id))).go(); + }); + } + + // Merge source tag into target tag and remove source tag. + Future mergeTags({ + required String sourceName, + required String targetName, + }) async { + final source = sourceName.trim(); + final target = targetName.trim(); + if (source.isEmpty || target.isEmpty || source == target) return; + + await transaction(() async { + final sourceTag = await (select( + tags, + )..where((tbl) => tbl.name.equals(source))).getSingleOrNull(); + final targetTag = await (select( + tags, + )..where((tbl) => tbl.name.equals(target))).getSingleOrNull(); + + if (sourceTag == null || targetTag == null) return; + + await _mergeTagIds(sourceTagId: sourceTag.id, targetTagId: targetTag.id); + await (delete(tags)..where((tbl) => tbl.id.equals(sourceTag.id))).go(); + }); + } + + // Delete tag and all item-tag relationships by cascade. + Future deleteTagByName(String tagName) async { + final normalized = tagName.trim(); + if (normalized.isEmpty) return; + + await (delete(tags)..where((tbl) => tbl.name.equals(normalized))).go(); + } + + Future _mergeTagIds({ + required String sourceTagId, + required String targetTagId, + }) async { + if (sourceTagId == targetTagId) return; + + // Remove rows that would conflict with (item_id, tag_id) unique key. + await customStatement( + ''' + DELETE FROM item_tags + WHERE tag_id = ? + AND item_id IN ( + SELECT item_id FROM item_tags WHERE tag_id = ? + ) + ''', + [sourceTagId, targetTagId], + ); + + await (update(itemTags)..where((tbl) => tbl.tagId.equals(sourceTagId))) + .write(ItemTagsCompanion(tagId: Value(targetTagId))); + } + // Delete item Future deleteItem(String id) { return transaction(() async { diff --git a/packages/integrations/database/test/database_test.dart b/packages/integrations/database/test/database_test.dart index 761a3be..0ef2d76 100644 --- a/packages/integrations/database/test/database_test.dart +++ b/packages/integrations/database/test/database_test.dart @@ -395,5 +395,127 @@ void main() { expect(tags, isEmpty); }); + + test('watch tags with usage returns counts', () async { + final now = DateTime.now(); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-usage-1', + collectionId: collectionId, + title: 'Usage 1', + createdAt: now, + updatedAt: now, + ), + tags: const ['Shared', 'UniqueA'], + ); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-usage-2', + collectionId: collectionId, + title: 'Usage 2', + createdAt: now, + updatedAt: now, + ), + tags: const ['Shared'], + ); + + final tagsWithUsage = await db.itemDao.getTagsWithUsage(); + expect(tagsWithUsage, contains(('Shared', 2))); + expect(tagsWithUsage, contains(('UniqueA', 1))); + }); + + test('rename tag updates tag name for linked items', () async { + final now = DateTime.now(); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-rename-1', + collectionId: collectionId, + title: 'Rename Tag Item', + createdAt: now, + updatedAt: now, + ), + tags: const ['OldName'], + ); + + await db.itemDao.renameTag(oldName: 'OldName', newName: 'NewName'); + final tags = await db.itemDao.getTagsForItem('item-rename-1'); + + expect(tags, contains('NewName')); + expect(tags, isNot(contains('OldName'))); + }); + + test('merge tags consolidates duplicates across items', () async { + final now = DateTime.now(); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-merge-1', + collectionId: collectionId, + title: 'Merge 1', + createdAt: now, + updatedAt: now, + ), + tags: const ['SourceTag', 'TargetTag'], + ); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-merge-2', + collectionId: collectionId, + title: 'Merge 2', + createdAt: now, + updatedAt: now, + ), + tags: const ['SourceTag'], + ); + + await db.itemDao.mergeTags( + sourceName: 'SourceTag', + targetName: 'TargetTag', + ); + + final tagsItem1 = await db.itemDao.getTagsForItem('item-merge-1'); + final tagsItem2 = await db.itemDao.getTagsForItem('item-merge-2'); + final allTags = await db.itemDao.getTagsWithUsage(); + + expect(tagsItem1.where((t) => t == 'TargetTag').length, 1); + expect(tagsItem1, isNot(contains('SourceTag'))); + expect(tagsItem2, contains('TargetTag')); + expect(tagsItem2, isNot(contains('SourceTag'))); + expect(allTags.any((entry) => entry.$1 == 'SourceTag'), isFalse); + }); + + test('delete tag removes it from all items', () async { + final now = DateTime.now(); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-delete-tag-1', + collectionId: collectionId, + title: 'Delete Tag 1', + createdAt: now, + updatedAt: now, + ), + tags: const ['DeleteMe', 'KeepMe'], + ); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-delete-tag-2', + collectionId: collectionId, + title: 'Delete Tag 2', + createdAt: now, + updatedAt: now, + ), + tags: const ['DeleteMe'], + ); + + await db.itemDao.deleteTagByName('DeleteMe'); + + final tags1 = await db.itemDao.getTagsForItem('item-delete-tag-1'); + final tags2 = await db.itemDao.getTagsForItem('item-delete-tag-2'); + final allTags = await db.itemDao.getTagsWithUsage(); + + expect(tags1, contains('KeepMe')); + expect(tags1, isNot(contains('DeleteMe'))); + expect(tags2, isNot(contains('DeleteMe'))); + expect(allTags.any((entry) => entry.$1 == 'DeleteMe'), isFalse); + }); }); } From 93b0d32703bb9b30ba77ccdcd32e64b77f5fe148 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 19:23:52 +0630 Subject: [PATCH 06/11] feat: Introduce multi-selection mode for tags, enabling merge and delete actions, and refine the tag renaming input field. --- .../views/tag_management_screen.dart | 281 +++++++++++++++--- 1 file changed, 235 insertions(+), 46 deletions(-) diff --git a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart index 03b6f5c..66ff30b 100644 --- a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart @@ -16,6 +16,8 @@ class _TagManagementScreenState extends ConsumerState { final _searchController = TextEditingController(); String _query = ''; bool _isBusy = false; + bool _selectionMode = false; + final Set _selectedTags = {}; @override void dispose() { @@ -29,7 +31,42 @@ class _TagManagementScreenState extends ConsumerState { final theme = Theme.of(context); return Scaffold( - appBar: AppBar(title: const Text('Manage Tags')), + appBar: AppBar( + title: Text( + _selectionMode ? '${_selectedTags.length} selected' : 'Manage Tags', + ), + actions: _selectionMode + ? [ + IconButton( + tooltip: 'Merge selected', + onPressed: _selectedTags.length >= 2 && !_isBusy + ? _mergeSelectedTags + : null, + icon: const Icon(Icons.merge_type), + ), + IconButton( + tooltip: 'Delete selected', + onPressed: _selectedTags.isNotEmpty && !_isBusy + ? _deleteSelectedTags + : null, + icon: const Icon(Icons.delete_outline), + ), + IconButton( + tooltip: 'Cancel selection', + onPressed: _isBusy ? null : _clearSelectionMode, + icon: const Icon(Icons.close), + ), + ] + : [ + IconButton( + tooltip: 'Select tags', + onPressed: _isBusy + ? null + : () => setState(() => _selectionMode = true), + icon: const Icon(Icons.checklist), + ), + ], + ), body: Column( children: [ Padding( @@ -75,13 +112,30 @@ class _TagManagementScreenState extends ConsumerState { itemBuilder: (context, index) { final tag = filtered[index].$1; final usage = filtered[index].$2; + final isSelected = _selectedTags.contains(tag); return Card( child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.secondaryContainer, - child: const Icon(Icons.sell_outlined), + onTap: _selectionMode + ? () => _toggleSelection(tag) + : null, + leading: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: _selectionMode + ? Checkbox( + key: ValueKey('checkbox-$tag'), + value: isSelected, + onChanged: _isBusy + ? null + : (_) => _toggleSelection(tag), + ) + : CircleAvatar( + key: ValueKey('avatar-$tag'), + backgroundColor: theme + .colorScheme + .secondaryContainer, + child: const Icon(Icons.sell_outlined), + ), ), title: Text(tag), subtitle: Text( @@ -89,44 +143,50 @@ class _TagManagementScreenState extends ConsumerState { ? 'Used in 1 item' : 'Used in $usage items', ), - trailing: PopupMenuButton<_TagAction>( - onSelected: (action) => - _handleAction(action: action, tag: tag), - itemBuilder: (context) => const [ - PopupMenuItem( - value: _TagAction.rename, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - Icons.drive_file_rename_outline, + trailing: _selectionMode + ? null + : PopupMenuButton<_TagAction>( + onSelected: (action) => _handleAction( + action: action, + tag: tag, ), - title: Text('Rename'), + itemBuilder: (context) => const [ + PopupMenuItem( + value: _TagAction.rename, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.drive_file_rename_outline, + ), + title: Text('Rename'), + ), + ), + PopupMenuItem( + value: _TagAction.merge, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.merge_type), + title: Text('Merge Into...'), + ), + ), + PopupMenuItem( + value: _TagAction.delete, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.delete, + color: Colors.red, + ), + title: Text( + 'Delete', + style: TextStyle( + color: Colors.red, + ), + ), + ), + ), + ], ), - ), - PopupMenuItem( - value: _TagAction.merge, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.merge_type), - title: Text('Merge Into...'), - ), - ), - PopupMenuItem( - value: _TagAction.delete, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - Icons.delete, - color: Colors.red, - ), - title: Text( - 'Delete', - style: TextStyle(color: Colors.red), - ), - ), - ), - ], - ), ), ) .animate(delay: (index * 35).ms) @@ -167,18 +227,21 @@ class _TagManagementScreenState extends ConsumerState { } Future _renameTag(String oldName) async { - final controller = TextEditingController(text: oldName); + var draftName = oldName; final newName = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Rename Tag'), - content: TextField( - controller: controller, + content: TextFormField( + initialValue: oldName, autofocus: true, decoration: const InputDecoration( labelText: 'New name', prefixIcon: Icon(Icons.sell_outlined), ), + onChanged: (value) { + draftName = value.trim(); + }, ), actions: [ TextButton( @@ -186,13 +249,12 @@ class _TagManagementScreenState extends ConsumerState { child: const Text('Cancel'), ), FilledButton( - onPressed: () => Navigator.pop(context, controller.text.trim()), + onPressed: () => Navigator.pop(context, draftName), child: const Text('Rename'), ), ], ), ); - controller.dispose(); if (newName == null || newName.isEmpty || newName == oldName || !mounted) { return; @@ -206,6 +268,133 @@ class _TagManagementScreenState extends ConsumerState { ); } + void _toggleSelection(String tag) { + if (_isBusy) return; + setState(() { + if (_selectedTags.contains(tag)) { + _selectedTags.remove(tag); + } else { + _selectedTags.add(tag); + } + if (_selectedTags.isEmpty) { + _selectionMode = false; + } + }); + } + + void _clearSelectionMode() { + setState(() { + _selectionMode = false; + _selectedTags.clear(); + }); + } + + Future _mergeSelectedTags() async { + final selected = _selectedTags.toList()..sort(); + if (selected.length < 2 || !mounted) return; + + String destination = selected.first; + final confirmed = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('Merge Selected Tags'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Choose destination tag:'), + const SizedBox(height: 12), + ...selected.map( + (tag) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon( + destination == tag + ? Icons.radio_button_checked + : Icons.radio_button_off, + ), + title: Text(tag), + onTap: () => setDialogState(() => destination = tag), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Merge'), + ), + ], + ), + ), + ); + + if (confirmed != true || !mounted) return; + + final sources = selected.where((tag) => tag != destination).toList(); + await _runTagMutation( + action: () async { + for (final source in sources) { + await ref.read( + mergeTagsProvider(( + sourceName: source, + targetName: destination, + )).future, + ); + } + }, + successMessage: 'Merged ${sources.length} tags into "$destination"', + ); + if (mounted) { + _clearSelectionMode(); + } + } + + Future _deleteSelectedTags() async { + final selected = _selectedTags.toList()..sort(); + if (selected.isEmpty || !mounted) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Selected Tags'), + content: Text( + 'Delete ${selected.length} selected tags from all items?\n\nThis cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + await _runTagMutation( + action: () async { + for (final tag in selected) { + await ref.read(deleteTagProvider(tag).future); + } + }, + successMessage: 'Deleted ${selected.length} tags', + ); + if (mounted) { + _clearSelectionMode(); + } + } + Future _mergeTag(String sourceName) async { final tags = ref.read(tagsWithUsageProvider).asData?.value ?? const []; final candidates = tags From 4d656dc4b8ddbe2b72e3f2049d729bc75bd683bf Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 19:34:14 +0630 Subject: [PATCH 07/11] feat: add selection controls to tag management screen and update home navigation icons. --- apps/mobile/lib/core/router/app_shell.dart | 8 +- .../views/tag_management_screen.dart | 216 +++++++++++------- 2 files changed, 138 insertions(+), 86 deletions(-) diff --git a/apps/mobile/lib/core/router/app_shell.dart b/apps/mobile/lib/core/router/app_shell.dart index d3f750e..c230207 100644 --- a/apps/mobile/lib/core/router/app_shell.dart +++ b/apps/mobile/lib/core/router/app_shell.dart @@ -57,8 +57,8 @@ class ScaffoldWithNavigationBar extends StatelessWidget { selectedIndex: currentIndex, destinations: const [ NavigationDestination( - icon: Icon(Icons.collections_outlined), - selectedIcon: Icon(Icons.collections), + icon: Icon(Icons.inventory_2_outlined), + selectedIcon: Icon(Icons.inventory_2), label: 'Home', ), NavigationDestination( @@ -148,8 +148,8 @@ class ScaffoldWithNavigationRail extends StatelessWidget { ), destinations: const [ NavigationRailDestination( - icon: Icon(Icons.collections_outlined), - selectedIcon: Icon(Icons.collections), + icon: Icon(Icons.inventory_2_outlined), + selectedIcon: Icon(Icons.inventory_2), label: Text('Home'), ), NavigationRailDestination( diff --git a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart index 66ff30b..59f874a 100644 --- a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart @@ -104,95 +104,139 @@ class _TagManagementScreenState extends ConsumerState { ); } - return ListView.separated( - key: const ValueKey('tags-list'), - padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), - itemCount: filtered.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final tag = filtered[index].$1; - final usage = filtered[index].$2; - final isSelected = _selectedTags.contains(tag); - - return Card( - child: ListTile( - onTap: _selectionMode - ? () => _toggleSelection(tag) - : null, - leading: AnimatedSwitcher( - duration: const Duration(milliseconds: 180), - child: _selectionMode - ? Checkbox( - key: ValueKey('checkbox-$tag'), - value: isSelected, - onChanged: _isBusy - ? null - : (_) => _toggleSelection(tag), - ) - : CircleAvatar( - key: ValueKey('avatar-$tag'), - backgroundColor: theme - .colorScheme - .secondaryContainer, - child: const Icon(Icons.sell_outlined), + return Column( + children: [ + if (_selectionMode) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: _isBusy + ? null + : () => _selectAllFiltered( + filtered.map((entry) => entry.$1), ), + icon: const Icon(Icons.select_all), + label: const Text('Select all filtered'), ), - title: Text(tag), - subtitle: Text( - usage == 1 - ? 'Used in 1 item' - : 'Used in $usage items', + OutlinedButton.icon( + onPressed: _isBusy || _selectedTags.isEmpty + ? null + : _clearSelectionMode, + icon: const Icon(Icons.deselect), + label: const Text('Clear selection'), ), - trailing: _selectionMode - ? null - : PopupMenuButton<_TagAction>( - onSelected: (action) => _handleAction( - action: action, - tag: tag, + ], + ), + ), + Expanded( + child: ListView.separated( + key: const ValueKey('tags-list'), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), + itemCount: filtered.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final tag = filtered[index].$1; + final usage = filtered[index].$2; + final isSelected = _selectedTags.contains(tag); + + return Card( + child: ListTile( + onTap: _selectionMode + ? () => _toggleSelection(tag) + : null, + leading: AnimatedSwitcher( + duration: const Duration( + milliseconds: 180, ), - itemBuilder: (context) => const [ - PopupMenuItem( - value: _TagAction.rename, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - Icons.drive_file_rename_outline, - ), - title: Text('Rename'), - ), - ), - PopupMenuItem( - value: _TagAction.merge, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(Icons.merge_type), - title: Text('Merge Into...'), - ), - ), - PopupMenuItem( - value: _TagAction.delete, - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - Icons.delete, - color: Colors.red, - ), - title: Text( - 'Delete', - style: TextStyle( - color: Colors.red, + child: _selectionMode + ? Checkbox( + key: ValueKey('checkbox-$tag'), + value: isSelected, + onChanged: _isBusy + ? null + : (_) => + _toggleSelection(tag), + ) + : CircleAvatar( + key: ValueKey('avatar-$tag'), + backgroundColor: theme + .colorScheme + .secondaryContainer, + child: const Icon( + Icons.sell_outlined, ), ), - ), - ), - ], ), - ), - ) - .animate(delay: (index * 35).ms) - .fadeIn(duration: 260.ms, curve: Curves.easeOut) - .slideY(begin: 0.08, end: 0, duration: 260.ms); - }, + title: Text(tag), + subtitle: Text( + usage == 1 + ? 'Used in 1 item' + : 'Used in $usage items', + ), + trailing: _selectionMode + ? null + : PopupMenuButton<_TagAction>( + onSelected: (action) => + _handleAction( + action: action, + tag: tag, + ), + itemBuilder: (context) => const [ + PopupMenuItem( + value: _TagAction.rename, + child: ListTile( + contentPadding: + EdgeInsets.zero, + leading: Icon( + Icons + .drive_file_rename_outline, + ), + title: Text('Rename'), + ), + ), + PopupMenuItem( + value: _TagAction.merge, + child: ListTile( + contentPadding: + EdgeInsets.zero, + leading: Icon( + Icons.merge_type, + ), + title: Text('Merge Into...'), + ), + ), + PopupMenuItem( + value: _TagAction.delete, + child: ListTile( + contentPadding: + EdgeInsets.zero, + leading: Icon( + Icons.delete, + color: Colors.red, + ), + title: Text( + 'Delete', + style: TextStyle( + color: Colors.red, + ), + ), + ), + ), + ], + ), + ), + ) + .animate(delay: (index * 35).ms) + .fadeIn(duration: 260.ms, curve: Curves.easeOut) + .slideY(begin: 0.08, end: 0, duration: 260.ms); + }, + ), + ), + ], ); }, loading: () => const Center(child: CircularProgressIndicator()), @@ -289,6 +333,14 @@ class _TagManagementScreenState extends ConsumerState { }); } + void _selectAllFiltered(Iterable filteredTags) { + if (_isBusy) return; + setState(() { + _selectedTags.addAll(filteredTags); + _selectionMode = _selectedTags.isNotEmpty; + }); + } + Future _mergeSelectedTags() async { final selected = _selectedTags.toList()..sort(); if (selected.length < 2 || !mounted) return; From 4dd56c13aaa53dca46e786175ced81514ee15f42 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 19:46:38 +0630 Subject: [PATCH 08/11] feat: Implement infinite scrolling for tag lists in tag management, adding pagination, a load more indicator, and refined selection controls. --- .../views/tag_management_screen.dart | 85 +++++++++++++++++-- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart index 59f874a..8c815f1 100644 --- a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart @@ -14,14 +14,26 @@ class TagManagementScreen extends ConsumerStatefulWidget { class _TagManagementScreenState extends ConsumerState { final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + static const int _pageSize = 50; String _query = ''; bool _isBusy = false; bool _selectionMode = false; final Set _selectedTags = {}; + int _visibleCount = _pageSize; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } @override void dispose() { _searchController.dispose(); + _scrollController + ..removeListener(_onScroll) + ..dispose(); super.dispose(); } @@ -73,7 +85,10 @@ class _TagManagementScreenState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: TextField( controller: _searchController, - onChanged: (value) => setState(() => _query = value.trim()), + onChanged: (value) => setState(() { + _query = value.trim(); + _visibleCount = _pageSize; + }), decoration: const InputDecoration( hintText: 'Search tags...', prefixIcon: Icon(Icons.search), @@ -91,6 +106,8 @@ class _TagManagementScreenState extends ConsumerState { _query.toLowerCase(), ); }).toList(); + final visibleCount = _visibleCount.clamp(0, filtered.length); + final visible = filtered.take(visibleCount).toList(); if (filtered.isEmpty) { return Center( @@ -117,10 +134,19 @@ class _TagManagementScreenState extends ConsumerState { onPressed: _isBusy ? null : () => _selectAllFiltered( - filtered.map((entry) => entry.$1), + visible.map((entry) => entry.$1), ), icon: const Icon(Icons.select_all), - label: const Text('Select all filtered'), + label: const Text('Select visible'), + ), + OutlinedButton.icon( + onPressed: _isBusy + ? null + : () => _selectAllFiltered( + filtered.map((entry) => entry.$1), + ), + icon: const Icon(Icons.done_all), + label: const Text('Select all matches'), ), OutlinedButton.icon( onPressed: _isBusy || _selectedTags.isEmpty @@ -135,16 +161,45 @@ class _TagManagementScreenState extends ConsumerState { Expanded( child: ListView.separated( key: const ValueKey('tags-list'), + controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 8, 12, 24), - itemCount: filtered.length, + itemCount: + visible.length + + (visible.length < filtered.length ? 1 : 0), separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { - final tag = filtered[index].$1; - final usage = filtered[index].$2; + if (index == visible.length) { + final remaining = + filtered.length - visible.length; + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 10, + ), + child: Center( + child: Text( + 'Scroll to load $remaining more tags', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + final tag = visible[index].$1; + final usage = visible[index].$2; final isSelected = _selectedTags.contains(tag); return Card( child: ListTile( + onLongPress: _isBusy + ? null + : () { + _toggleSelection(tag); + setState( + () => _selectionMode = true, + ); + }, onTap: _selectionMode ? () => _toggleSelection(tag) : null, @@ -341,6 +396,24 @@ class _TagManagementScreenState extends ConsumerState { }); } + void _onScroll() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + if (position.pixels < position.maxScrollExtent - 120) return; + if (!mounted || _isBusy) return; + + final tags = ref.read(tagsWithUsageProvider).asData?.value ?? const []; + final filteredLength = tags.where((entry) { + if (_query.isEmpty) return true; + return entry.$1.toLowerCase().contains(_query.toLowerCase()); + }).length; + + if (_visibleCount >= filteredLength) return; + setState(() { + _visibleCount = (_visibleCount + _pageSize).clamp(0, filteredLength); + }); + } + Future _mergeSelectedTags() async { final selected = _selectedTags.toList()..sort(); if (selected.length < 2 || !mounted) return; From c087550ab1d0d099db930ca6139d30e3462931a3 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 19:53:11 +0630 Subject: [PATCH 09/11] refactor: Move tag management action buttons from the AppBar to a new bottom navigation bar. --- .../views/tag_management_screen.dart | 66 +++++++++++++++---- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart index 8c815f1..b03a30f 100644 --- a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart @@ -49,20 +49,6 @@ class _TagManagementScreenState extends ConsumerState { ), actions: _selectionMode ? [ - IconButton( - tooltip: 'Merge selected', - onPressed: _selectedTags.length >= 2 && !_isBusy - ? _mergeSelectedTags - : null, - icon: const Icon(Icons.merge_type), - ), - IconButton( - tooltip: 'Delete selected', - onPressed: _selectedTags.isNotEmpty && !_isBusy - ? _deleteSelectedTags - : null, - icon: const Icon(Icons.delete_outline), - ), IconButton( tooltip: 'Cancel selection', onPressed: _isBusy ? null : _clearSelectionMode, @@ -79,6 +65,58 @@ class _TagManagementScreenState extends ConsumerState { ), ], ), + bottomNavigationBar: _selectionMode + ? SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _selectedTags.isEmpty || _isBusy + ? null + : _clearSelectionMode, + icon: const Icon(Icons.deselect), + label: const Text('Clear'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: _selectedTags.length < 2 || _isBusy + ? null + : _mergeSelectedTags, + icon: const Icon(Icons.merge_type), + label: const Text('Merge'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.red, + ), + onPressed: _selectedTags.isEmpty || _isBusy + ? null + : _deleteSelectedTags, + icon: const Icon(Icons.delete_outline), + label: const Text('Delete'), + ), + ), + ], + ), + ), + ), + ) + : null, body: Column( children: [ Padding( From 9caa1dfc1523913e9a6f19d96aaedaea51810c27 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 22:04:19 +0630 Subject: [PATCH 10/11] feat: Implement tag-specific item viewing by adding a dedicated screen, routing, data access, and making tags clickable in item details. --- apps/mobile/lib/core/router/app_router.dart | 9 + apps/mobile/lib/core/router/routes.dart | 1 + .../view_models/tag_items_view_model.dart | 11 ++ .../views/item_detail_screen.dart | 6 +- .../presentation/views/tag_items_screen.dart | 168 ++++++++++++++++++ .../views/tag_management_screen.dart | 6 +- .../repositories/item_repository_impl.dart | 13 ++ .../lib/src/repositories/item_repository.dart | 1 + .../database/lib/src/daos/item_dao.dart | 29 +++ .../database/test/database_test.dart | 43 +++++ 10 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/lib/features/items/presentation/view_models/tag_items_view_model.dart create mode 100644 apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart diff --git a/apps/mobile/lib/core/router/app_router.dart b/apps/mobile/lib/core/router/app_router.dart index 259e825..dd53faf 100644 --- a/apps/mobile/lib/core/router/app_router.dart +++ b/apps/mobile/lib/core/router/app_router.dart @@ -10,6 +10,7 @@ import '../../features/items/presentation/views/add_item_screen.dart'; import '../../features/items/presentation/views/edit_item_screen.dart'; import '../../features/items/presentation/views/item_detail_screen.dart'; import '../../features/items/presentation/views/items_screen.dart'; +import '../../features/items/presentation/views/tag_items_screen.dart'; import '../../features/onboarding/presentation/views/onboarding_screen.dart'; import '../../features/scanner/presentation/views/scanner_screen.dart'; import '../../features/search/presentation/views/search_screen.dart'; @@ -159,6 +160,14 @@ GoRouter appRouter(Ref ref) { ), ], ), + GoRoute( + path: Routes.tagItems, + name: 'tag-items', + builder: (context, state) { + final tag = state.uri.queryParameters['tag'] ?? ''; + return TagItemsScreen(tagName: tag); + }, + ), GoRoute( path: Routes.scanner, name: 'scanner', diff --git a/apps/mobile/lib/core/router/routes.dart b/apps/mobile/lib/core/router/routes.dart index 1111fed..278a252 100644 --- a/apps/mobile/lib/core/router/routes.dart +++ b/apps/mobile/lib/core/router/routes.dart @@ -8,6 +8,7 @@ abstract final class Routes { static const statistics = '/statistics'; static const settings = '/settings'; static const settingsTags = '/settings/tags'; + static const tagItems = '/tags/items'; // static String bookingWithId(int id) => '$booking/$id'; } diff --git a/apps/mobile/lib/features/items/presentation/view_models/tag_items_view_model.dart b/apps/mobile/lib/features/items/presentation/view_models/tag_items_view_model.dart new file mode 100644 index 0000000..2d83220 --- /dev/null +++ b/apps/mobile/lib/features/items/presentation/view_models/tag_items_view_model.dart @@ -0,0 +1,11 @@ +import 'package:collection_tracker/core/providers/providers.dart'; +import 'package:domain/domain.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'tag_items_view_model.g.dart'; + +@riverpod +Stream> tagItems(Ref ref, String tagName) { + final repository = ref.watch(itemRepositoryProvider); + return repository.watchItemsByTag(tagName); +} diff --git a/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart b/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart index bb6dfc9..7732ffe 100644 --- a/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/item_detail_screen.dart @@ -165,10 +165,14 @@ class ItemDetailScreen extends ConsumerWidget { runSpacing: 8, children: item.tags .map( - (tag) => Chip( + (tag) => ActionChip( label: Text(tag), backgroundColor: theme.colorScheme.secondaryContainer, + onPressed: () => context.pushNamed( + 'tag-items', + queryParameters: {'tag': tag}, + ), ), ) .toList(), diff --git a/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart new file mode 100644 index 0000000..fc142ba --- /dev/null +++ b/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart @@ -0,0 +1,168 @@ +import 'package:collection_tracker/features/collections/presentation/view_models/collections_view_model.dart'; +import 'package:domain/domain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../view_models/items_view_model.dart'; +import '../view_models/tag_items_view_model.dart'; +import '../widgets/item_card.dart'; + +class TagItemsScreen extends ConsumerWidget { + final String tagName; + + const TagItemsScreen({required this.tagName, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final itemsAsync = ref.watch(tagItemsProvider(tagName)); + final collectionsAsync = ref.watch(collectionsViewModelProvider); + + return Scaffold( + appBar: AppBar(title: Text('Tag: $tagName')), + body: itemsAsync.when( + data: (items) => collectionsAsync.when( + data: (collections) => + _buildContent(context, ref, items, collections), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Error: $error')), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('Error: $error')), + ), + ); + } + + Widget _buildContent( + BuildContext context, + WidgetRef ref, + List items, + List collections, + ) { + if (items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 72, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(height: 12), + Text( + 'No items found for this tag', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ); + } + + final collectionNames = { + for (final collection in collections) collection.id: collection.name, + }; + final grouped = >{}; + for (final item in items) { + grouped.putIfAbsent(item.collectionId, () => []).add(item); + } + final sortedCollectionIds = grouped.keys.toList() + ..sort((a, b) { + final nameA = collectionNames[a] ?? a; + final nameB = collectionNames[b] ?? b; + return nameA.compareTo(nameB); + }); + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), + itemCount: sortedCollectionIds.length, + itemBuilder: (context, sectionIndex) { + final collectionId = sortedCollectionIds[sectionIndex]; + final sectionItems = grouped[collectionId]!; + final collectionName = collectionNames[collectionId] ?? 'Unknown'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8, top: 8), + child: Row( + children: [ + Text( + collectionName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + Text( + '(${sectionItems.length})', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ...sectionItems.map((item) { + final heroTag = 'tag_${tagName}_${item.id}'; + return ItemCard( + item: item, + heroTag: heroTag, + onTap: () => context.pushNamed( + 'item-detail', + pathParameters: {'id': item.id}, + queryParameters: {'heroTag': heroTag}, + ), + onDelete: () => _showDeleteDialog(context, ref, item), + ); + }), + ], + ); + }, + ); + } + + Future _showDeleteDialog( + BuildContext context, + WidgetRef ref, + Item item, + ) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Item'), + content: Text('Delete "${item.title}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + await ref.read(deleteItemProvider(item.id).future); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Item deleted'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Delete failed: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } +} diff --git a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart index b03a30f..a80a84f 100644 --- a/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/tag_management_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../view_models/tag_management_view_model.dart'; @@ -240,7 +241,10 @@ class _TagManagementScreenState extends ConsumerState { }, onTap: _selectionMode ? () => _toggleSelection(tag) - : null, + : () => context.pushNamed( + 'tag-items', + queryParameters: {'tag': tag}, + ), leading: AnimatedSwitcher( duration: const Duration( milliseconds: 180, diff --git a/packages/core/data/lib/src/repositories/item_repository_impl.dart b/packages/core/data/lib/src/repositories/item_repository_impl.dart index 2241872..8219628 100644 --- a/packages/core/data/lib/src/repositories/item_repository_impl.dart +++ b/packages/core/data/lib/src/repositories/item_repository_impl.dart @@ -163,6 +163,19 @@ class ItemRepositoryImpl implements ItemRepository { }); } + @override + Stream> watchItemsByTag(String tagName) { + return _dao.watchItemsByTag(tagName).asyncMap((data) async { + final mapped = await Future.wait( + data.map((item) async { + final tags = await _dao.getTagsForItem(item.id); + return _mapToEntity(item, tags); + }), + ); + return mapped; + }); + } + @override Stream> watchTagsWithUsage() { return _dao.watchTagsWithUsage(); diff --git a/packages/core/domain/lib/src/repositories/item_repository.dart b/packages/core/domain/lib/src/repositories/item_repository.dart index 8b4891e..9034086 100644 --- a/packages/core/domain/lib/src/repositories/item_repository.dart +++ b/packages/core/domain/lib/src/repositories/item_repository.dart @@ -18,6 +18,7 @@ abstract class ItemRepository { Stream watchItemById(String id); Stream> watchAllFavoriteItems(); Stream> watchAllWishlistItems(); + Stream> watchItemsByTag(String tagName); Stream> watchTagsWithUsage(); Future>> searchItems({ diff --git a/packages/integrations/database/lib/src/daos/item_dao.dart b/packages/integrations/database/lib/src/daos/item_dao.dart index b9eaab0..dc16112 100644 --- a/packages/integrations/database/lib/src/daos/item_dao.dart +++ b/packages/integrations/database/lib/src/daos/item_dao.dart @@ -150,6 +150,35 @@ class ItemDao extends DatabaseAccessor with _$ItemDaoMixin { .watch(); } + // Get items that contain a specific tag + Future> getItemsByTag(String tagName) async { + final query = + select(items).join([ + innerJoin(itemTags, itemTags.itemId.equalsExp(items.id)), + innerJoin(tags, tags.id.equalsExp(itemTags.tagId)), + ]) + ..where(tags.name.equals(tagName)) + ..orderBy([OrderingTerm.desc(items.createdAt)]); + + final rows = await query.get(); + return rows.map((row) => row.readTable(items)).toList(); + } + + // Watch items that contain a specific tag + Stream> watchItemsByTag(String tagName) { + final query = + select(items).join([ + innerJoin(itemTags, itemTags.itemId.equalsExp(items.id)), + innerJoin(tags, tags.id.equalsExp(itemTags.tagId)), + ]) + ..where(tags.name.equals(tagName)) + ..orderBy([OrderingTerm.desc(items.createdAt)]); + + return query.watch().map((rows) { + return rows.map((row) => row.readTable(items)).toList(); + }); + } + // Get tags for an item Future> getTagsForItem(String itemId) async { final query = select(itemTags).join([ diff --git a/packages/integrations/database/test/database_test.dart b/packages/integrations/database/test/database_test.dart index 0ef2d76..552d36f 100644 --- a/packages/integrations/database/test/database_test.dart +++ b/packages/integrations/database/test/database_test.dart @@ -517,5 +517,48 @@ void main() { expect(tags2, isNot(contains('DeleteMe'))); expect(allTags.any((entry) => entry.$1 == 'DeleteMe'), isFalse); }); + + test('get items by tag returns matching collection items', () async { + final now = DateTime.now(); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-tag-query-1', + collectionId: collectionId, + title: 'Match A', + createdAt: now, + updatedAt: now, + ), + tags: const ['FilterTag'], + ); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-tag-query-2', + collectionId: collectionId, + title: 'No Match', + createdAt: now, + updatedAt: now, + ), + tags: const ['OtherTag'], + ); + await db.itemDao.insertItem( + ItemsCompanion.insert( + id: 'item-tag-query-3', + collectionId: collectionId, + title: 'Match B', + createdAt: now, + updatedAt: now, + ), + tags: const ['FilterTag'], + ); + + final filtered = await db.itemDao.getItemsByTag('FilterTag'); + + expect(filtered.length, 2); + expect( + filtered.map((item) => item.title), + containsAll(['Match A', 'Match B']), + ); + expect(filtered.map((item) => item.title), isNot(contains('No Match'))); + }); }); } From cb90478c7e70d5a9a21e888c9f7ae21878f74c84 Mon Sep 17 00:00:00 2001 From: Kyaw Zayar Tun Date: Wed, 11 Feb 2026 22:39:50 +0630 Subject: [PATCH 11/11] feat: Introduce item sorting and collapsible collection sections on the tag items screen, and enable tag navigation from item cards. --- .../presentation/views/tag_items_screen.dart | 143 ++++++++++++++---- .../items/presentation/widgets/item_card.dart | 21 +-- .../presentation/widgets/item_grid_card.dart | 28 +++- 3 files changed, 143 insertions(+), 49 deletions(-) diff --git a/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart b/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart index fc142ba..39040c8 100644 --- a/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart +++ b/apps/mobile/lib/features/items/presentation/views/tag_items_screen.dart @@ -8,18 +8,53 @@ import '../view_models/items_view_model.dart'; import '../view_models/tag_items_view_model.dart'; import '../widgets/item_card.dart'; -class TagItemsScreen extends ConsumerWidget { +class TagItemsScreen extends ConsumerStatefulWidget { final String tagName; const TagItemsScreen({required this.tagName, super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _TagItemsScreenState(); +} + +enum _TagItemsSort { newest, oldest, title } + +class _TagItemsScreenState extends ConsumerState { + final Set _collapsedCollections = {}; + _TagItemsSort _sort = _TagItemsSort.newest; + + @override + Widget build(BuildContext context) { + final tagName = widget.tagName; final itemsAsync = ref.watch(tagItemsProvider(tagName)); final collectionsAsync = ref.watch(collectionsViewModelProvider); return Scaffold( - appBar: AppBar(title: Text('Tag: $tagName')), + appBar: AppBar( + title: Text('Tag: $tagName'), + actions: [ + PopupMenuButton<_TagItemsSort>( + tooltip: 'Sort', + initialValue: _sort, + onSelected: (value) => setState(() => _sort = value), + itemBuilder: (context) => const [ + PopupMenuItem( + value: _TagItemsSort.newest, + child: Text('Sort: Newest'), + ), + PopupMenuItem( + value: _TagItemsSort.oldest, + child: Text('Sort: Oldest'), + ), + PopupMenuItem( + value: _TagItemsSort.title, + child: Text('Sort: Title'), + ), + ], + icon: const Icon(Icons.sort), + ), + ], + ), body: itemsAsync.when( data: (items) => collectionsAsync.when( data: (collections) => @@ -78,49 +113,93 @@ class TagItemsScreen extends ConsumerWidget { itemCount: sortedCollectionIds.length, itemBuilder: (context, sectionIndex) { final collectionId = sortedCollectionIds[sectionIndex]; - final sectionItems = grouped[collectionId]!; + final sectionItems = _sortedItems(grouped[collectionId]!); final collectionName = collectionNames[collectionId] ?? 'Unknown'; + final isCollapsed = _collapsedCollections.contains(collectionId); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8, top: 8), - child: Row( - children: [ - Text( - collectionName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _toggleCollectionCollapse(collectionId), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 4, 8), + child: Row( + children: [ + Icon(isCollapsed ? Icons.expand_more : Icons.expand_less), + const SizedBox(width: 4), + Expanded( + child: Text( + collectionName, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), ), - ), - const SizedBox(width: 8), - Text( - '(${sectionItems.length})', - style: Theme.of(context).textTheme.bodySmall, - ), - ], + Text( + '(${sectionItems.length})', + style: Theme.of(context).textTheme.bodySmall, + ), + IconButton( + tooltip: 'Open collection', + icon: const Icon(Icons.open_in_new, size: 20), + onPressed: () => context.go('/collections/$collectionId'), + ), + ], + ), ), ), - ...sectionItems.map((item) { - final heroTag = 'tag_${tagName}_${item.id}'; - return ItemCard( - item: item, - heroTag: heroTag, - onTap: () => context.pushNamed( - 'item-detail', - pathParameters: {'id': item.id}, - queryParameters: {'heroTag': heroTag}, - ), - onDelete: () => _showDeleteDialog(context, ref, item), - ); - }), + AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: isCollapsed + ? const SizedBox.shrink() + : Column( + key: ValueKey(collectionId), + children: sectionItems.map((item) { + final heroTag = 'tag_${widget.tagName}_${item.id}'; + return ItemCard( + item: item, + heroTag: heroTag, + onTap: () => context.pushNamed( + 'item-detail', + pathParameters: {'id': item.id}, + queryParameters: {'heroTag': heroTag}, + ), + onDelete: () => _showDeleteDialog(context, ref, item), + ); + }).toList(), + ), + ), ], ); }, ); } + List _sortedItems(List items) { + final sorted = List.from(items); + sorted.sort((a, b) { + return switch (_sort) { + _TagItemsSort.newest => b.createdAt.compareTo(a.createdAt), + _TagItemsSort.oldest => a.createdAt.compareTo(b.createdAt), + _TagItemsSort.title => a.title.toLowerCase().compareTo( + b.title.toLowerCase(), + ), + }; + }); + return sorted; + } + + void _toggleCollectionCollapse(String collectionId) { + setState(() { + if (_collapsedCollections.contains(collectionId)) { + _collapsedCollections.remove(collectionId); + } else { + _collapsedCollections.add(collectionId); + } + }); + } + Future _showDeleteDialog( BuildContext context, WidgetRef ref, diff --git a/apps/mobile/lib/features/items/presentation/widgets/item_card.dart b/apps/mobile/lib/features/items/presentation/widgets/item_card.dart index acf3774..3ef335b 100644 --- a/apps/mobile/lib/features/items/presentation/widgets/item_card.dart +++ b/apps/mobile/lib/features/items/presentation/widgets/item_card.dart @@ -114,16 +114,11 @@ class ItemCard extends StatelessWidget { children: item.tags .take(3) .map( - (tag) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: theme.colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(999), - ), - child: Text( + (tag) => ActionChip( + visualDensity: VisualDensity.compact, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + label: Text( tag, style: theme.textTheme.labelSmall?.copyWith( color: theme @@ -132,6 +127,12 @@ class ItemCard extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + backgroundColor: + theme.colorScheme.secondaryContainer, + onPressed: () => context.pushNamed( + 'tag-items', + queryParameters: {'tag': tag}, + ), ), ) .toList(), diff --git a/apps/mobile/lib/features/items/presentation/widgets/item_grid_card.dart b/apps/mobile/lib/features/items/presentation/widgets/item_grid_card.dart index 7771e25..65e72b6 100644 --- a/apps/mobile/lib/features/items/presentation/widgets/item_grid_card.dart +++ b/apps/mobile/lib/features/items/presentation/widgets/item_grid_card.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:domain/domain.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; class ItemGridCard extends StatelessWidget { final Item item; @@ -61,14 +62,27 @@ class ItemGridCard extends StatelessWidget { if (item.tags.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4), - child: Text( - '#${item.tags.first}', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, + child: InkWell( + onTap: () => context.pushNamed( + 'tag-items', + queryParameters: {'tag': item.tags.first}, + ), + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: Text( + '#${item.tags.first}', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), ), ],