From c0c29818c36fa6a073323c76f799a24990962f90 Mon Sep 17 00:00:00 2001 From: Jakes <71591438+JakesMD@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:35:36 +0200 Subject: [PATCH 1/2] Add like gems feature with favourites collection (#103) --- .../app/bootstrap/dependencies_provider.dart | 2 + app/lib/app/routes.dart | 4 + app/lib/app/routes.gr.dart | 16 ++ app/lib/localization/arb/intl_de.arb | 1 + app/lib/localization/arb/intl_en.arb | 1 + .../generated/localizations.g.dart | 6 + .../generated/localizations_de.g.dart | 3 + .../generated/localizations_en.g.dart | 3 + app/lib/pages/_pages.dart | 1 + app/lib/pages/chest/page.dart | 30 ++- app/lib/pages/collections/page.dart | 1 + .../pages/collections/widgets/_widgets.dart | 1 + .../widgets/favourites_collection_tile.dart | 24 ++ app/lib/pages/demo/page.dart | 1 + .../favourites_collection/logic/_logic.dart | 1 + .../logic/favourite_gem_ids_fetch_cubit.dart | 31 +++ app/lib/pages/favourites_collection/page.dart | 81 +++++++ app/lib/pages/shared_gem/page.dart | 1 + app/lib/shared/logic/_logic.dart | 1 + app/lib/shared/logic/gem_likes_cubit.dart | 136 ++++++++++++ app/lib/shared/views/collection/view.dart | 16 +- .../views/collection/widgets/_widgets.dart | 1 + .../collection/widgets/bottom_app_bar.dart | 73 ++++--- .../views/collection/widgets/like_button.dart | 47 ++++ app/test/helpers/test_clients.dart | 4 + .../favourites_collection/like_gem_test.dart | 205 ++++++++++++++++++ .../lib/src/exceptions/_exceptions.dart | 2 + .../lib/src/exceptions/gem_like_delete.dart | 12 + .../lib/src/exceptions/gem_like_insert.dart | 12 + .../database_client/lib/src/gem_client.dart | 43 ++++ .../lib/src/tables/_tables.dart | 1 + .../lib/src/tables/gem_likes.dart | 32 +++ .../lib/src/tables/gem_likes.g.dart | 37 ++++ .../lib/src/exceptions/_exceptions.dart | 2 + .../lib/src/exceptions/gem_like_delete.dart | 14 ++ .../lib/src/exceptions/gem_like_insert.dart | 14 ++ .../lib/src/gem_repository.dart | 22 ++ .../test/src/gem_repository_test.dart | 123 +++++++++++ .../20260703191319_add_gem_likes_public.sql | 5 + .../20260703191320_add_gem_likes_table.sql | 85 ++++++++ supabase/schemas/public/pre.sql | 5 +- .../public/tables/gem_likes/constraints.sql | 2 + .../public/tables/gem_likes/permissions.sql | 7 + .../public/tables/gem_likes/policies.sql | 13 ++ .../schemas/public/tables/gem_likes/table.sql | 9 + supabase/seed.sql | 11 +- wiki/Database.md | 4 + 47 files changed, 1096 insertions(+), 50 deletions(-) create mode 100644 app/lib/pages/collections/widgets/favourites_collection_tile.dart create mode 100644 app/lib/pages/favourites_collection/logic/_logic.dart create mode 100644 app/lib/pages/favourites_collection/logic/favourite_gem_ids_fetch_cubit.dart create mode 100644 app/lib/pages/favourites_collection/page.dart create mode 100644 app/lib/shared/logic/gem_likes_cubit.dart create mode 100644 app/lib/shared/views/collection/widgets/like_button.dart create mode 100644 app/test/pages/favourites_collection/like_gem_test.dart create mode 100644 packages/data/database_client/lib/src/exceptions/gem_like_delete.dart create mode 100644 packages/data/database_client/lib/src/exceptions/gem_like_insert.dart create mode 100644 packages/data/database_client/lib/src/tables/gem_likes.dart create mode 100644 packages/data/database_client/lib/src/tables/gem_likes.g.dart create mode 100644 packages/domain/gem_repository/lib/src/exceptions/gem_like_delete.dart create mode 100644 packages/domain/gem_repository/lib/src/exceptions/gem_like_insert.dart create mode 100644 supabase/migrations/20260703191319_add_gem_likes_public.sql create mode 100644 supabase/migrations/20260703191320_add_gem_likes_table.sql create mode 100644 supabase/schemas/public/tables/gem_likes/constraints.sql create mode 100644 supabase/schemas/public/tables/gem_likes/permissions.sql create mode 100644 supabase/schemas/public/tables/gem_likes/policies.sql create mode 100644 supabase/schemas/public/tables/gem_likes/table.sql diff --git a/app/lib/app/bootstrap/dependencies_provider.dart b/app/lib/app/bootstrap/dependencies_provider.dart index 71cbb22b..93ff3ec4 100644 --- a/app/lib/app/bootstrap/dependencies_provider.dart +++ b/app/lib/app/bootstrap/dependencies_provider.dart @@ -92,6 +92,7 @@ class _CAppDependenciesProviderState extends State { final invitationsTable = CInvitationsTable(supabaseClient); final userRolesTable = CUserRolesTable(supabaseClient); final gemShareTokensTable = CGemShareTokensTable(supabaseClient); + final gemLikesTable = CGemLikesTable(supabaseClient); platformClient = CPlatformClient(); authClient = CAuthClient(authClient: supabaseClient.auth); @@ -105,6 +106,7 @@ class _CAppDependenciesProviderState extends State { gemsTable: gemsTable, linesTable: linesTable, gemShareTokensTable: gemShareTokensTable, + gemLikesTable: gemLikesTable, supabaseClient: supabaseClient, ); personClient = CPersonClient( diff --git a/app/lib/app/routes.dart b/app/lib/app/routes.dart index 5e092c68..7825b0e1 100644 --- a/app/lib/app/routes.dart +++ b/app/lib/app/routes.dart @@ -134,6 +134,10 @@ class CAppRouter extends RootStackRouter implements AutoRouteGuard { path: 'collections/randomly-selected', page: CRandomCollectionRoute.page, ), + AutoRoute( + path: 'collections/favourites', + page: CFavouritesCollectionRoute.page, + ), AutoRoute( path: 'people/edit-person', page: CEditPersonRoute.page, diff --git a/app/lib/app/routes.gr.dart b/app/lib/app/routes.gr.dart index 02cd2027..6ad67cef 100644 --- a/app/lib/app/routes.gr.dart +++ b/app/lib/app/routes.gr.dart @@ -347,6 +347,22 @@ class CEditPersonRouteArgs { int get hashCode => person.hashCode ^ isPersonNew.hashCode ^ key.hashCode; } +/// generated route for +/// [CFavouritesCollectionPage] +class CFavouritesCollectionRoute extends PageRouteInfo { + const CFavouritesCollectionRoute({List? children}) + : super(CFavouritesCollectionRoute.name, initialChildren: children); + + static const String name = 'CFavouritesCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return WrappedRoute(child: const CFavouritesCollectionPage()); + }, + ); +} + /// generated route for /// [CGemPage] class CGemRoute extends PageRouteInfo { diff --git a/app/lib/localization/arb/intl_de.arb b/app/lib/localization/arb/intl_de.arb index c02adec4..9c25740c 100644 --- a/app/lib/localization/arb/intl_de.arb +++ b/app/lib/localization/arb/intl_de.arb @@ -22,6 +22,7 @@ "collectionView_shareSheet_createLinkButton": "Link erstellen", "collectionView_shareSheet_deleteLinkButton": "Link löschen", "collectionView_shareSheet_shareLinkButton": "Link teilen", + "collectionsPage_collection_favourites": "Deine Favoriten", "collectionsPage_collection_random": "Zufällig ausgewählt", "collectionsPage_collection_recents": "Zuletzt hinzugefügt", "collectionsPage_noGemsMessage": "Erstelle eine neue Gemme, um loszulegen.", diff --git a/app/lib/localization/arb/intl_en.arb b/app/lib/localization/arb/intl_en.arb index 6acac229..ad6c9fe5 100644 --- a/app/lib/localization/arb/intl_en.arb +++ b/app/lib/localization/arb/intl_en.arb @@ -22,6 +22,7 @@ "collectionView_shareSheet_createLinkButton": "Create a share link", "collectionView_shareSheet_deleteLinkButton": "Delete the link", "collectionView_shareSheet_shareLinkButton": "Share the link", + "collectionsPage_collection_favourites": "Your favourites", "collectionsPage_collection_random": "Randomly selected", "collectionsPage_collection_recents": "Recently added", "collectionsPage_noGemsMessage": "Create a new gem to get started.", diff --git a/app/lib/localization/generated/localizations.g.dart b/app/lib/localization/generated/localizations.g.dart index 7e0a304f..3c7885e5 100644 --- a/app/lib/localization/generated/localizations.g.dart +++ b/app/lib/localization/generated/localizations.g.dart @@ -187,6 +187,12 @@ abstract class CAppL10n { /// **'Share the link'** String get collectionView_shareSheet_shareLinkButton; + /// No description provided for @collectionsPage_collection_favourites. + /// + /// In en, this message translates to: + /// **'Your favourites'** + String get collectionsPage_collection_favourites; + /// No description provided for @collectionsPage_collection_random. /// /// In en, this message translates to: diff --git a/app/lib/localization/generated/localizations_de.g.dart b/app/lib/localization/generated/localizations_de.g.dart index 18e425e5..3648e73e 100644 --- a/app/lib/localization/generated/localizations_de.g.dart +++ b/app/lib/localization/generated/localizations_de.g.dart @@ -58,6 +58,9 @@ class CAppL10nDe extends CAppL10n { @override String get collectionView_shareSheet_shareLinkButton => 'Link teilen'; + @override + String get collectionsPage_collection_favourites => 'Deine Favoriten'; + @override String get collectionsPage_collection_random => 'Zufällig ausgewählt'; diff --git a/app/lib/localization/generated/localizations_en.g.dart b/app/lib/localization/generated/localizations_en.g.dart index a88bf54c..5d2ec409 100644 --- a/app/lib/localization/generated/localizations_en.g.dart +++ b/app/lib/localization/generated/localizations_en.g.dart @@ -59,6 +59,9 @@ class CAppL10nEn extends CAppL10n { @override String get collectionView_shareSheet_shareLinkButton => 'Share the link'; + @override + String get collectionsPage_collection_favourites => 'Your favourites'; + @override String get collectionsPage_collection_random => 'Randomly selected'; diff --git a/app/lib/pages/_pages.dart b/app/lib/pages/_pages.dart index 1287ec5e..8c206476 100644 --- a/app/lib/pages/_pages.dart +++ b/app/lib/pages/_pages.dart @@ -7,6 +7,7 @@ export 'create_gem/page.dart'; export 'demo/page.dart'; export 'edit_gem/page.dart'; export 'edit_person/page.dart'; +export 'favourites_collection/page.dart'; export 'gem/page.dart'; export 'get_started/page.dart'; export 'home/page.dart'; diff --git a/app/lib/pages/chest/page.dart b/app/lib/pages/chest/page.dart index 0f75741c..42a6caa3 100644 --- a/app/lib/pages/chest/page.dart +++ b/app/lib/pages/chest/page.dart @@ -38,18 +38,24 @@ class CChestPage extends StatelessWidget implements AutoRouteWrapper { authRepository: context.read(), ), child: BlocProvider( - create: (context) => - CChestPeopleFetchCubit(personRepository: context.read()) - ..fetchChestPeople( - chestID: context.read().state.id, - ), - child: BlocListener( - listener: (context, _) => - context.read().updateLastViewedChest( - context.read().state.id, - ), - listenWhen: (_, state) => state.succeeded, - child: this, + create: (context) => CGemLikesCubit( + gemRepository: context.read(), + chestID: context.read().state.id, + )..fetchLikedGemIDs(), + child: BlocProvider( + create: (context) => + CChestPeopleFetchCubit(personRepository: context.read()) + ..fetchChestPeople( + chestID: context.read().state.id, + ), + child: BlocListener( + listener: (context, _) => + context.read().updateLastViewedChest( + context.read().state.id, + ), + listenWhen: (_, state) => state.succeeded, + child: this, + ), ), ), ); diff --git a/app/lib/pages/collections/page.dart b/app/lib/pages/collections/page.dart index 3beb0c80..db4e738a 100644 --- a/app/lib/pages/collections/page.dart +++ b/app/lib/pages/collections/page.dart @@ -57,6 +57,7 @@ class CCollectionsPage extends StatelessWidget implements AutoRouteWrapper { const SizedBox(height: 8), const CRecentsCollectionTile(), const CRandomCollectionTile(), + const CFavouritesCollectionTile(), ], ), ), diff --git a/app/lib/pages/collections/widgets/_widgets.dart b/app/lib/pages/collections/widgets/_widgets.dart index 30a71fae..0d4c1e92 100644 --- a/app/lib/pages/collections/widgets/_widgets.dart +++ b/app/lib/pages/collections/widgets/_widgets.dart @@ -1,3 +1,4 @@ +export 'favourites_collection_tile.dart'; export 'random_collection_tile.dart'; export 'recents_collection_tile.dart'; export 'year_collections_section.dart'; diff --git a/app/lib/pages/collections/widgets/favourites_collection_tile.dart b/app/lib/pages/collections/widgets/favourites_collection_tile.dart new file mode 100644 index 00000000..ea6a12df --- /dev/null +++ b/app/lib/pages/collections/widgets/favourites_collection_tile.dart @@ -0,0 +1,24 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:chuckle_chest/app/routes.dart'; +import 'package:chuckle_chest/localization/l10n.dart'; +import 'package:flutter/material.dart'; + +/// {@template CFavouritesCollectionTile} +/// +/// The tile for the favourites collection on the collections page. +/// +/// {@endtemplate} +class CFavouritesCollectionTile extends StatelessWidget { + /// {@macro CFavouritesCollectionTile} + const CFavouritesCollectionTile({super.key}); + + @override + Widget build(BuildContext context) { + return ListTile( + minVerticalPadding: 16, + leading: const Icon(Icons.favorite_rounded), + title: Text(context.cAppL10n.collectionsPage_collection_favourites), + onTap: () => context.router.push(const CFavouritesCollectionRoute()), + ); + } +} diff --git a/app/lib/pages/demo/page.dart b/app/lib/pages/demo/page.dart index 42caa896..ad25ef1f 100644 --- a/app/lib/pages/demo/page.dart +++ b/app/lib/pages/demo/page.dart @@ -30,6 +30,7 @@ class CDemoPage extends StatelessWidget implements AutoRouteWrapper { body: CCollectionView( gemTokens: cubit.gemTokens, userRole: CUserRole.viewer, + isShared: true, gemFromState: (state) => state.gem, gemTokenFromState: (state) => state.gemID, triggerFetchGem: (context, token) => cubit.emitGemWithToken(token), diff --git a/app/lib/pages/favourites_collection/logic/_logic.dart b/app/lib/pages/favourites_collection/logic/_logic.dart new file mode 100644 index 00000000..3b323652 --- /dev/null +++ b/app/lib/pages/favourites_collection/logic/_logic.dart @@ -0,0 +1 @@ +export 'favourite_gem_ids_fetch_cubit.dart'; diff --git a/app/lib/pages/favourites_collection/logic/favourite_gem_ids_fetch_cubit.dart b/app/lib/pages/favourites_collection/logic/favourite_gem_ids_fetch_cubit.dart new file mode 100644 index 00000000..48a948c1 --- /dev/null +++ b/app/lib/pages/favourites_collection/logic/favourite_gem_ids_fetch_cubit.dart @@ -0,0 +1,31 @@ +import 'package:bloc/bloc.dart'; +import 'package:cgem_repository/cgem_repository.dart'; +import 'package:mallard_bloc/mallard_bloc.dart'; + +/// The state for the [CFavouriteGemIDsFetchCubit]. +typedef CFavouriteGemIDsFetchState = + TaskBlocState, CGemIDsFetchException>; + +/// {@template CFavouriteGemIDsFetchCubit} +/// +/// The cubit for handling fetching the IDs of the liked gems. +/// +/// {@endtemplate} +class CFavouriteGemIDsFetchCubit extends Cubit + with TaskCubitMixin { + /// {@macro CFavouriteGemIDsFetchCubit} + CFavouriteGemIDsFetchCubit({ + required this.gemRepository, + required this.chestID, + }) : super(TaskBlocState.initial()); + + /// The repository this cubit uses to fetch the gem IDs. + final CGemRepository gemRepository; + + /// The ID of the chest to fetch the liked gems from. + final String chestID; + + /// Fetches the IDs of the liked gems. + Future fetchLikedGemIDs() => + request(gemRepository.fetchLikedGemIDs(chestID: chestID)); +} diff --git a/app/lib/pages/favourites_collection/page.dart b/app/lib/pages/favourites_collection/page.dart new file mode 100644 index 00000000..71f22864 --- /dev/null +++ b/app/lib/pages/favourites_collection/page.dart @@ -0,0 +1,81 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:cgem_repository/cgem_repository.dart'; +import 'package:chuckle_chest/pages/favourites_collection/logic/_logic.dart'; +import 'package:chuckle_chest/shared/_shared.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mallard_bloc/mallard_bloc.dart'; + +/// {@template CFavouritesCollectionPage} +/// +/// The page for displaying a collection of the gems the user has liked. +/// +/// It fetches the IDs of the liked gems and then displays them in a +/// [CCollectionView] which allow the user to view the gems one by one. +/// +/// {@endtemplate} +@RoutePage() +class CFavouritesCollectionPage extends StatelessWidget + implements AutoRouteWrapper { + /// {@macro CFavouritesCollectionPage} + const CFavouritesCollectionPage({super.key}); + + @override + Widget wrappedRoute(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => CFavouriteGemIDsFetchCubit( + gemRepository: context.read(), + chestID: context.read().state.id, + )..fetchLikedGemIDs(), + ), + BlocProvider( + create: (context) => CGemFetchCubit(gemRepository: context.read()), + ), + ], + child: this, + ); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => Scaffold( + body: switch (state.status) { + TaskBlocStatus.initial => const Center( + child: CCradleLoadingIndicator(), + ), + TaskBlocStatus.inProgress => const Center( + child: CCradleLoadingIndicator(), + ), + TaskBlocStatus.failed => const Center( + child: Icon(Icons.error_rounded), + ), + TaskBlocStatus.succeeded => + CCollectionView< + CGemFetchCubit, + CGemFetchState, + CGemFetchException, + CGem + >( + gemTokens: state.success!, + userRole: context.read().state.userRole, + gemFromState: (state) => state.gem, + gemTokenFromState: (state) => state.gemID, + triggerFetchGem: (context, token) => + context.read().fetchGem(gemID: token), + onFetchFailed: (failure) => switch (failure) { + CGemFetchException.notFound => const CErrorSnackBar( + message: "We couldn't find that gem.", + ).show(context), + CGemFetchException.unknown => const CErrorSnackBar().show( + context, + ), + }, + ), + }, + ), + ); + } +} diff --git a/app/lib/pages/shared_gem/page.dart b/app/lib/pages/shared_gem/page.dart index cbf388b0..09867456 100644 --- a/app/lib/pages/shared_gem/page.dart +++ b/app/lib/pages/shared_gem/page.dart @@ -42,6 +42,7 @@ class CSharedGemPage extends StatelessWidget implements AutoRouteWrapper { >( gemTokens: [shareToken ?? ''], userRole: CUserRole.viewer, + isShared: true, gemFromState: (state) => state.gem, gemTokenFromState: (state) => shareToken ?? '', triggerFetchGem: (context, token) => context diff --git a/app/lib/shared/logic/_logic.dart b/app/lib/shared/logic/_logic.dart index f41b3d7c..2b7fef1d 100644 --- a/app/lib/shared/logic/_logic.dart +++ b/app/lib/shared/logic/_logic.dart @@ -4,6 +4,7 @@ export 'current_chest_cubit.dart'; export 'current_user_cubit.dart'; export 'gem_fetch_cubit.dart'; export 'gem_fetch_from_share_token.dart'; +export 'gem_likes_cubit.dart'; export 'invitation_accept_cubit.dart'; export 'request_cubit.dart'; export 'signout_cubit.dart'; diff --git a/app/lib/shared/logic/gem_likes_cubit.dart b/app/lib/shared/logic/gem_likes_cubit.dart new file mode 100644 index 00000000..f9a030b1 --- /dev/null +++ b/app/lib/shared/logic/gem_likes_cubit.dart @@ -0,0 +1,136 @@ +import 'package:bloc/bloc.dart'; +import 'package:cgem_repository/cgem_repository.dart'; +import 'package:equatable/equatable.dart'; + +/// The status of the [CGemLikesState]. +enum CGemLikesStatus { + /// The liked gem IDs have not been fetched yet. + initial, + + /// The liked gem IDs are being fetched. + inProgress, + + /// The liked gem IDs failed to be fetched. + failed, + + /// The liked gem IDs have been fetched. + succeeded, +} + +/// {@template CGemLikesState} +/// +/// The state for the [CGemLikesCubit]. +/// +/// {@endtemplate} +class CGemLikesState extends Equatable { + /// {@macro CGemLikesState} + const CGemLikesState({ + required this.status, + required this.likedGemIDs, + required this.pendingGemIDs, + }); + + /// {@macro CGemLikesState} + /// + /// The initial state. + const CGemLikesState.initial() + : this( + status: CGemLikesStatus.initial, + likedGemIDs: const {}, + pendingGemIDs: const {}, + ); + + /// The status of the liked gem IDs fetch. + final CGemLikesStatus status; + + /// The IDs of the gems the user has liked. + final Set likedGemIDs; + + /// The IDs of the gems that have an in-flight like/unlike toggle. + final Set pendingGemIDs; + + /// Returns a copy of this state with the given fields replaced. + CGemLikesState copyWith({ + CGemLikesStatus? status, + Set? likedGemIDs, + Set? pendingGemIDs, + }) => CGemLikesState( + status: status ?? this.status, + likedGemIDs: likedGemIDs ?? this.likedGemIDs, + pendingGemIDs: pendingGemIDs ?? this.pendingGemIDs, + ); + + @override + List get props => [status, likedGemIDs, pendingGemIDs]; +} + +/// {@template CGemLikesCubit} +/// +/// The cubit that caches which gems in a chest the user has liked and lets +/// the user toggle a gem's liked status. +/// +/// {@endtemplate} +class CGemLikesCubit extends Cubit { + /// {@macro CGemLikesCubit} + CGemLikesCubit({required this.gemRepository, required this.chestID}) + : super(const CGemLikesState.initial()); + + /// The repository this cubit uses to like/unlike gems. + final CGemRepository gemRepository; + + /// The ID of the chest the liked gems belong to. + final String chestID; + + /// Fetches the IDs of the gems the user has liked in this chest. + Future fetchLikedGemIDs() async { + emit(state.copyWith(status: CGemLikesStatus.inProgress)); + + final result = await gemRepository.fetchLikedGemIDs(chestID: chestID).run(); + + result.resolve( + onSuccess: (ids) => emit( + state.copyWith( + status: CGemLikesStatus.succeeded, + likedGemIDs: ids.toSet(), + ), + ), + onFailure: (_) => emit(state.copyWith(status: CGemLikesStatus.failed)), + ); + } + + /// Toggles the liked status of the gem with the given [gemID]. + Future toggle(String gemID) async { + final wasLiked = state.likedGemIDs.contains(gemID); + + emit( + state.copyWith( + likedGemIDs: wasLiked + ? (Set.of(state.likedGemIDs)..remove(gemID)) + : (Set.of(state.likedGemIDs)..add(gemID)), + pendingGemIDs: Set.of(state.pendingGemIDs)..add(gemID), + ), + ); + + final task = wasLiked + ? gemRepository.unlikeGem(gemID: gemID) + : gemRepository.likeGem(chestID: chestID, gemID: gemID); + + final result = await task.run(); + + result.resolve( + onSuccess: (_) => emit( + state.copyWith( + pendingGemIDs: Set.of(state.pendingGemIDs)..remove(gemID), + ), + ), + onFailure: (_) => emit( + state.copyWith( + likedGemIDs: wasLiked + ? (Set.of(state.likedGemIDs)..add(gemID)) + : (Set.of(state.likedGemIDs)..remove(gemID)), + pendingGemIDs: Set.of(state.pendingGemIDs)..remove(gemID), + ), + ), + ); + } +} diff --git a/app/lib/shared/views/collection/view.dart b/app/lib/shared/views/collection/view.dart index 9ab8c4aa..06e318e2 100644 --- a/app/lib/shared/views/collection/view.dart +++ b/app/lib/shared/views/collection/view.dart @@ -32,6 +32,7 @@ class CCollectionView< required this.gemTokenFromState, required this.onFetchFailed, required this.triggerFetchGem, + this.isShared = false, super.key, }); @@ -45,6 +46,12 @@ class CCollectionView< /// If the user is not authenticated, this should be [CUserRole.viewer]. final CUserRole userRole; + /// Whether this view is being shown on the public shared-gem page. + /// + /// When `true`, no bottom app bar is displayed, since sharing and liking + /// gems both require the user to be viewing gems within a chest. + final bool isShared; + /// The function to extract the gem from the state. final CGem Function(S state) gemFromState; @@ -165,11 +172,12 @@ class _CCollectionViewState< ), ), ), - bottomNavigationBar: widget.userRole != CUserRole.viewer - ? CCollectionViewBottomAppBar( + bottomNavigationBar: widget.isShared + ? null + : CCollectionViewBottomAppBar( + showShareButton: widget.userRole != CUserRole.viewer, onShared: (token) => _shareGem(context, token), - ) - : null, + ), ); } diff --git a/app/lib/shared/views/collection/widgets/_widgets.dart b/app/lib/shared/views/collection/widgets/_widgets.dart index cb4a0b55..e3983e65 100644 --- a/app/lib/shared/views/collection/widgets/_widgets.dart +++ b/app/lib/shared/views/collection/widgets/_widgets.dart @@ -1,4 +1,5 @@ export 'animated_gem.dart'; export 'app_bar.dart'; export 'bottom_app_bar.dart'; +export 'like_button.dart'; export 'share_sheet.dart'; diff --git a/app/lib/shared/views/collection/widgets/bottom_app_bar.dart b/app/lib/shared/views/collection/widgets/bottom_app_bar.dart index 4a212036..00870d61 100644 --- a/app/lib/shared/views/collection/widgets/bottom_app_bar.dart +++ b/app/lib/shared/views/collection/widgets/bottom_app_bar.dart @@ -15,7 +15,14 @@ import 'package:signed_spacing_flex/signed_spacing_flex.dart'; /// {@endtemplate} class CCollectionViewBottomAppBar extends StatelessWidget { /// {@macro CCollectionViewBottomAppBar} - const CCollectionViewBottomAppBar({required this.onShared, super.key}); + const CCollectionViewBottomAppBar({ + required this.showShareButton, + required this.onShared, + super.key, + }); + + /// Whether the share button should be displayed. + final bool showShareButton; /// Called when the share button is pressed. final void Function(String token) onShared; @@ -26,38 +33,40 @@ class CCollectionViewBottomAppBar extends StatelessWidget { child: SignedSpacingRow( mainAxisAlignment: MainAxisAlignment.center, children: [ - BlocBuilder( - builder: (context, viewState) => - BlocBuilder( - builder: (context, shareState) => - BlocBuilder< - CGemShareTokenCreationCubit, - CGemShareTokenCreationState - >( - builder: (context, tokenState) => IconButton( - onPressed: - viewState.currentGem != null && - shareState.status != - CRequestCubitStatus.inProgress && - tokenState.status != - CRequestCubitStatus.inProgress - ? () => CShareSheet( - gem: viewState.currentGem!, - onShared: onShared, - shareTokenCreationCubit: context.read(), - ).show(context) - : null, - icon: - shareState.status != - CRequestCubitStatus.inProgress && - tokenState.status != - CRequestCubitStatus.inProgress - ? const Icon(Icons.share_rounded) - : const CBouncyBallLoadingIndicator(), + const CGemLikeButton(), + if (showShareButton) + BlocBuilder( + builder: (context, viewState) => + BlocBuilder( + builder: (context, shareState) => + BlocBuilder< + CGemShareTokenCreationCubit, + CGemShareTokenCreationState + >( + builder: (context, tokenState) => IconButton( + onPressed: + viewState.currentGem != null && + shareState.status != + CRequestCubitStatus.inProgress && + tokenState.status != + CRequestCubitStatus.inProgress + ? () => CShareSheet( + gem: viewState.currentGem!, + onShared: onShared, + shareTokenCreationCubit: context.read(), + ).show(context) + : null, + icon: + shareState.status != + CRequestCubitStatus.inProgress && + tokenState.status != + CRequestCubitStatus.inProgress + ? const Icon(Icons.share_rounded) + : const CBouncyBallLoadingIndicator(), + ), ), - ), - ), - ), + ), + ), ], ), ); diff --git a/app/lib/shared/views/collection/widgets/like_button.dart b/app/lib/shared/views/collection/widgets/like_button.dart new file mode 100644 index 00000000..81a66580 --- /dev/null +++ b/app/lib/shared/views/collection/widgets/like_button.dart @@ -0,0 +1,47 @@ +import 'package:chuckle_chest/shared/_shared.dart'; +import 'package:chuckle_chest/shared/views/collection/logic/_logic.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// {@template CGemLikeButton} +/// +/// The button that toggles whether the current gem in a [CCollectionView] is +/// liked. +/// +/// {@endtemplate} +class CGemLikeButton extends StatelessWidget { + /// {@macro CGemLikeButton} + const CGemLikeButton({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, viewState) { + final gemID = viewState.currentGem?.id; + + return BlocBuilder( + builder: (context, likeState) { + final isLiked = + gemID != null && likeState.likedGemIDs.contains(gemID); + final isPending = + gemID != null && likeState.pendingGemIDs.contains(gemID); + + return IconButton( + key: const Key('collection_view_like_button'), + onPressed: gemID == null || isPending + ? null + : () => context.read().toggle(gemID), + icon: isPending + ? const CBouncyBallLoadingIndicator() + : Icon( + isLiked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + ), + ); + }, + ); + }, + ); + } +} diff --git a/app/test/helpers/test_clients.dart b/app/test/helpers/test_clients.dart index 12010a4d..322d3777 100644 --- a/app/test/helpers/test_clients.dart +++ b/app/test/helpers/test_clients.dart @@ -3,6 +3,7 @@ import 'package:cauth_client/cauth_client.dart'; import 'package:cdatabase_client/cdatabase_client.dart'; import 'package:cplatform_client/cplatform_client.dart'; import 'package:cstorage_client/cstorage_client.dart'; +import 'package:mallard/mallard.dart'; import 'package:mocktail/mocktail.dart'; class MockCAuthClient extends Mock implements CAuthClient {} @@ -25,6 +26,9 @@ class CTestClients { stream: () => Stream.value(bobsSuccess(bobsAbsent())), ), ); + when( + () => gemClient.fetchLikedGemIDs(chestID: any(named: 'chestID')), + ).thenReturn(Task.succeed([])); } final authClient = MockCAuthClient(); diff --git a/app/test/pages/favourites_collection/like_gem_test.dart b/app/test/pages/favourites_collection/like_gem_test.dart new file mode 100644 index 00000000..233cf7f5 --- /dev/null +++ b/app/test/pages/favourites_collection/like_gem_test.dart @@ -0,0 +1,205 @@ +import 'package:bobs_jobs/bobs_jobs.dart'; +import 'package:cauth_client/cauth_client.dart'; +import 'package:ccore/ccore.dart'; +import 'package:cdatabase_client/cdatabase_client.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mallard/mallard.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test_beautifier/test_beautifier.dart'; + +import '../../helpers/helpers.dart'; + +const _chestID = 'chest-1'; +const _gemID = 'gem-1'; + +const Key _likeButtonKey = Key('collection_view_like_button'); + +const _fakeRawOwnerChest = CRawAuthUserChest( + id: _chestID, + name: 'Test Chest', + userRole: CUserRole.owner, +); + +const _fakeRawViewerChest = CRawAuthUserChest( + id: _chestID, + name: 'Test Chest', + userRole: CUserRole.viewer, +); + +CRawAuthUser _fakeRawUser(CRawAuthUserChest chest) => CRawAuthUser( + id: 'user-1', + username: 'testuser', + email: 'test@example.com', + chests: [chest], +); + +CRawGem _makeRawGem(String id) => CRawGem( + { + 'id': id, + 'chest_id': _chestID, + 'number': 1, + 'occurred_at': '2024-01-15T10:00:00.000Z', + 'lines': >[], + 'gem_share_tokens': null, + }, + null, +); + +void _setupSignedInUser(CTestClients clients, CRawAuthUserChest chest) { + final user = _fakeRawUser(chest); + when(clients.authClient.currentUserStream).thenAnswer( + (_) => BobsStream( + stream: () => Stream.value(bobsSuccess(bobsPresent(user))), + ), + ); + when(() => clients.authClient.currentUser).thenReturn(user); + when( + clients.authClient.refreshSession, + ).thenReturn(bobsFakeSuccessJob(bobsNothing)); +} + +void _setupChest( + CTestClients clients, { + required List gemIDs, + List likedGemIDs = const [], +}) { + when( + () => clients.personClient.fetchChestPeople( + chestID: any(named: 'chestID'), + ), + ).thenReturn(bobsFakeSuccessJob([])); + + when( + () => clients.gemClient.fetchGemYears(chestID: any(named: 'chestID')), + ).thenReturn(bobsFakeSuccessJob([])); + + when( + () => clients.gemClient.fetchRecentGemIDs( + chestID: any(named: 'chestID'), + limit: any(named: 'limit'), + ), + ).thenReturn(bobsFakeSuccessJob(gemIDs)); + + for (final id in gemIDs) { + when( + () => clients.gemClient.fetchGem(gemID: id), + ).thenReturn(bobsFakeSuccessJob(_makeRawGem(id))); + } + + when( + () => clients.gemClient.fetchLikedGemIDs(chestID: any(named: 'chestID')), + ).thenReturn(Task.succeed(likedGemIDs)); + + when( + () => clients.gemClient.likeGem( + chestID: any(named: 'chestID'), + gemID: any(named: 'gemID'), + ), + ).thenReturn(Task.succeed(_gemID)); + + when( + () => clients.gemClient.unlikeGem(gemID: any(named: 'gemID')), + ).thenReturn(Task.succeed(_gemID)); +} + +void main() { + group('Like Gem Tests', () { + late CTestClients clients; + + setUp(() { + clients = CTestClients(); + }); + + testWidgets( + requirement( + given: 'owner viewing an unliked gem', + whenever: 'the like button is tapped', + then: 'calls likeGem with the chest and gem ID', + why: 'liking a gem must persist the like exactly once', + ), + (tester) async { + _setupSignedInUser(clients, _fakeRawOwnerChest); + _setupChest(clients, gemIDs: [_gemID]); + + await tester.pumpChuckleChestApp( + clients: clients, + startAt: '/chest/collections/recently-added', + ); + + await tester.tap(find.byKey(_likeButtonKey)); + await tester.pumpAndSettle(); + + verify( + () => clients.gemClient.likeGem(chestID: _chestID, gemID: _gemID), + ).called(1); + }, + ); + + testWidgets( + requirement( + given: 'owner viewing an already-liked gem', + whenever: 'the like button is tapped', + then: 'calls unlikeGem with the gem ID', + why: 'unliking a gem must persist the removal exactly once', + ), + (tester) async { + _setupSignedInUser(clients, _fakeRawOwnerChest); + _setupChest(clients, gemIDs: [_gemID], likedGemIDs: [_gemID]); + + await tester.pumpChuckleChestApp( + clients: clients, + startAt: '/chest/collections/recently-added', + ); + + await tester.tap(find.byKey(_likeButtonKey)); + await tester.pumpAndSettle(); + + verify( + () => clients.gemClient.unlikeGem(gemID: _gemID), + ).called(1); + }, + ); + + testWidgets( + requirement( + given: 'a gem the user has liked', + whenever: 'navigating to the favourites collection', + then: 'shows that gem', + why: 'the favourites collection must display all liked gems', + ), + (tester) async { + _setupSignedInUser(clients, _fakeRawOwnerChest); + _setupChest(clients, gemIDs: [_gemID], likedGemIDs: [_gemID]); + + await tester.pumpChuckleChestApp( + clients: clients, + startAt: '/chest/collections/favourites', + ); + + expect(find.byKey(_likeButtonKey), findsOneWidget); + }, + ); + + testWidgets( + requirement( + given: 'viewer role viewing a gem', + whenever: 'the collection view is shown', + then: 'shows the like button but not the share button', + why: 'viewers can like gems but cannot share them', + ), + (tester) async { + _setupSignedInUser(clients, _fakeRawViewerChest); + _setupChest(clients, gemIDs: [_gemID]); + + await tester.pumpChuckleChestApp( + clients: clients, + startAt: '/chest/collections/recently-added', + ); + + expect(find.byKey(_likeButtonKey), findsOneWidget); + expect(find.byIcon(Icons.share_rounded), findsNothing); + }, + ); + }); +} diff --git a/packages/data/database_client/lib/src/exceptions/_exceptions.dart b/packages/data/database_client/lib/src/exceptions/_exceptions.dart index b2287fbd..7a95b110 100644 --- a/packages/data/database_client/lib/src/exceptions/_exceptions.dart +++ b/packages/data/database_client/lib/src/exceptions/_exceptions.dart @@ -9,6 +9,8 @@ export 'gem_delete.dart'; export 'gem_fetch.dart'; export 'gem_fetch_from_share_token.dart'; export 'gem_ids_fetch.dart'; +export 'gem_like_delete.dart'; +export 'gem_like_insert.dart'; export 'gem_save.dart'; export 'gem_share_link_insert.dart'; export 'gem_years_fetch.dart'; diff --git a/packages/data/database_client/lib/src/exceptions/gem_like_delete.dart b/packages/data/database_client/lib/src/exceptions/gem_like_delete.dart new file mode 100644 index 00000000..6c891fbe --- /dev/null +++ b/packages/data/database_client/lib/src/exceptions/gem_like_delete.dart @@ -0,0 +1,12 @@ +// Parameters required for bobs jobs. +// ignore_for_file: avoid_unused_constructor_parameters + +/// Represents an exception that occurs when deleting a gem like fails. +enum CRawGemLikeDeleteException { + /// The failure was unitentifiable. + unknown; + + factory CRawGemLikeDeleteException.fromError(Object error) { + return CRawGemLikeDeleteException.unknown; + } +} diff --git a/packages/data/database_client/lib/src/exceptions/gem_like_insert.dart b/packages/data/database_client/lib/src/exceptions/gem_like_insert.dart new file mode 100644 index 00000000..2e7ddffd --- /dev/null +++ b/packages/data/database_client/lib/src/exceptions/gem_like_insert.dart @@ -0,0 +1,12 @@ +// Parameters required for bobs jobs. +// ignore_for_file: avoid_unused_constructor_parameters + +/// Represents an exception that occurs when inserting a gem like fails. +enum CRawGemLikeInsertException { + /// The failure was unitentifiable. + unknown; + + factory CRawGemLikeInsertException.fromError(Object error) { + return CRawGemLikeInsertException.unknown; + } +} diff --git a/packages/data/database_client/lib/src/gem_client.dart b/packages/data/database_client/lib/src/gem_client.dart index 66f2de83..38a53627 100644 --- a/packages/data/database_client/lib/src/gem_client.dart +++ b/packages/data/database_client/lib/src/gem_client.dart @@ -15,6 +15,7 @@ class CGemClient { required this.gemsTable, required this.linesTable, required this.gemShareTokensTable, + required this.gemLikesTable, required this.supabaseClient, }); @@ -27,6 +28,9 @@ class CGemClient { /// The table that represents the `gem_share_tokens` table in the database. final CGemShareTokensTable gemShareTokensTable; + /// The table that represents the `gem_likes` table in the database. + final CGemLikesTable gemLikesTable; + /// The supabase client. final SupabaseClient supabaseClient; @@ -178,4 +182,43 @@ class CGemClient { ), onError: CRawGemShareTokenInsertException.fromError, ); + + /// Fetches the liked gem IDs for the given `chestID` from the database. + Task, CRawGemIDsFetchException> fetchLikedGemIDs({ + required String chestID, + }) => Task.attempt( + run: () => gemLikesTable.fetchValues( + column: CGemLikesTable.gemID, + filter: CGemLikesTable.chestID.equals(chestID), + modifier: gemLikesTable.order(CGemLikesTable.likedAt, ascending: false), + ), + handle: CRawGemIDsFetchException.fromError, + ); + + /// Likes the gem with the given `gemID` in the given `chestID`. + Task likeGem({ + required String chestID, + required String gemID, + }) => Task.attempt( + run: () async { + await gemLikesTable.insert( + inserts: [CGemLikesTableInsert(chestID: chestID, gemID: gemID)], + ); + return gemID; + }, + handle: CRawGemLikeInsertException.fromError, + ); + + /// Unlikes the gem with the given `gemID`. + Task unlikeGem({ + required String gemID, + }) => Task.attempt( + run: () async { + await gemLikesTable.delete( + filter: CGemLikesTable.gemID.equals(gemID), + ); + return gemID; + }, + handle: CRawGemLikeDeleteException.fromError, + ); } diff --git a/packages/data/database_client/lib/src/tables/_tables.dart b/packages/data/database_client/lib/src/tables/_tables.dart index b39ee714..44221ed3 100644 --- a/packages/data/database_client/lib/src/tables/_tables.dart +++ b/packages/data/database_client/lib/src/tables/_tables.dart @@ -1,5 +1,6 @@ export 'avatars.dart'; export 'chests.dart'; +export 'gem_likes.dart'; export 'gem_share_tokens.dart'; export 'gems.dart'; export 'invitations.dart'; diff --git a/packages/data/database_client/lib/src/tables/gem_likes.dart b/packages/data/database_client/lib/src/tables/gem_likes.dart new file mode 100644 index 00000000..600c34ff --- /dev/null +++ b/packages/data/database_client/lib/src/tables/gem_likes.dart @@ -0,0 +1,32 @@ +import 'package:typesafe_supabase/typesafe_supabase.dart'; + +part 'gem_likes.g.dart'; + +/// {@template CGemLikesTable} +/// +/// Represents the `gem_likes` table in the Supabase database. +/// +/// {@endtemplate} +@PgTableHere() +class CGemLikesTable extends SupabaseTable { + /// {@macro CGemLikesTable} + CGemLikesTable(super.client) + : super(tableName: tableName, primaryKey: [gemID, userID]); + + /// The name of the table in the Supabase database. + static const tableName = PgTableName('gem_likes'); + + /// The ID of the chest the gem belongs to. + static final chestID = PgStringColumn('chest_id'); + + /// The ID of the gem. + static final gemID = PgStringColumn('gem_id'); + + /// The ID of the user who liked the gem. + @PgColumnHasDefault() + static final userID = PgStringColumn('user_id'); + + /// The time the gem was liked. + @PgColumnHasDefault() + static final likedAt = PgUTCDateTimeColumn('liked_at'); +} diff --git a/packages/data/database_client/lib/src/tables/gem_likes.g.dart b/packages/data/database_client/lib/src/tables/gem_likes.g.dart new file mode 100644 index 00000000..975a0eaa --- /dev/null +++ b/packages/data/database_client/lib/src/tables/gem_likes.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'gem_likes.dart'; + +// ************************************************************************** +// PgUpsertGenerator +// ************************************************************************** + +// Typedefs are self-documenting. +// ignore_for_line: public_member_api_docs +typedef CGemLikesTableInsert = CGemLikesTableUpsert; + +/// {@template CGemLikesTableUpsert} +/// +/// Represents the data required to perform an insert or upsert operation on the +/// [CGemLikesTable] table. +/// +/// {@endtemplate} +class CGemLikesTableUpsert extends PgUpsert { + /// {@macro CGemLikesTableUpsert} + CGemLikesTableUpsert({ + required this.chestID, + required this.gemID, + this.likedAt, + this.userID, + }) : super([ + CGemLikesTable.chestID(chestID), + CGemLikesTable.gemID(gemID), + if (likedAt != null) CGemLikesTable.likedAt(likedAt), + if (userID != null) CGemLikesTable.userID(userID), + ]); + + final String chestID; + final String gemID; + final DateTime? likedAt; + final String? userID; +} diff --git a/packages/domain/gem_repository/lib/src/exceptions/_exceptions.dart b/packages/domain/gem_repository/lib/src/exceptions/_exceptions.dart index 60d8e93d..29c8d6ea 100644 --- a/packages/domain/gem_repository/lib/src/exceptions/_exceptions.dart +++ b/packages/domain/gem_repository/lib/src/exceptions/_exceptions.dart @@ -2,6 +2,8 @@ export 'gem_delete.dart'; export 'gem_fetch.dart'; export 'gem_fetch_from_share_token.dart'; export 'gem_ids_fetch.dart'; +export 'gem_like_delete.dart'; +export 'gem_like_insert.dart'; export 'gem_save.dart'; export 'gem_share.dart'; export 'gem_share_token_creation.dart'; diff --git a/packages/domain/gem_repository/lib/src/exceptions/gem_like_delete.dart b/packages/domain/gem_repository/lib/src/exceptions/gem_like_delete.dart new file mode 100644 index 00000000..9fbc7a8c --- /dev/null +++ b/packages/domain/gem_repository/lib/src/exceptions/gem_like_delete.dart @@ -0,0 +1,14 @@ +import 'package:cdatabase_client/cdatabase_client.dart'; + +/// Represents an exception that occurs when unliking a gem fails. +enum CGemLikeDeleteException { + /// The failure was unitentifiable. + unknown; + + /// Converts the raw exception to a [CGemLikeDeleteException]. + static CGemLikeDeleteException fromRaw(CRawGemLikeDeleteException e) { + return switch (e) { + CRawGemLikeDeleteException.unknown => CGemLikeDeleteException.unknown, + }; + } +} diff --git a/packages/domain/gem_repository/lib/src/exceptions/gem_like_insert.dart b/packages/domain/gem_repository/lib/src/exceptions/gem_like_insert.dart new file mode 100644 index 00000000..801e3b7f --- /dev/null +++ b/packages/domain/gem_repository/lib/src/exceptions/gem_like_insert.dart @@ -0,0 +1,14 @@ +import 'package:cdatabase_client/cdatabase_client.dart'; + +/// Represents an exception that occurs when liking a gem fails. +enum CGemLikeInsertException { + /// The failure was unitentifiable. + unknown; + + /// Converts the raw exception to a [CGemLikeInsertException]. + static CGemLikeInsertException fromRaw(CRawGemLikeInsertException e) { + return switch (e) { + CRawGemLikeInsertException.unknown => CGemLikeInsertException.unknown, + }; + } +} diff --git a/packages/domain/gem_repository/lib/src/gem_repository.dart b/packages/domain/gem_repository/lib/src/gem_repository.dart index 6573087b..c591a166 100644 --- a/packages/domain/gem_repository/lib/src/gem_repository.dart +++ b/packages/domain/gem_repository/lib/src/gem_repository.dart @@ -135,4 +135,26 @@ class CGemRepository { onFailure: CGemShareTokenCreationException.fromRaw, onSuccess: (success) => success.token, ); + + /// Fetches the IDs of the liked gems in the chest with the given [chestID]. + Task, CGemIDsFetchException> fetchLikedGemIDs({ + required String chestID, + }) => gemClient + .fetchLikedGemIDs(chestID: chestID) + .convertFailure(CGemIDsFetchException.fromRaw); + + /// Likes the gem with the given [gemID] in the chest with the given + /// [chestID]. + Task likeGem({ + required String chestID, + required String gemID, + }) => gemClient + .likeGem(chestID: chestID, gemID: gemID) + .convertFailure(CGemLikeInsertException.fromRaw); + + /// Unlikes the gem with the given [gemID]. + Task unlikeGem({required String gemID}) => + gemClient.unlikeGem(gemID: gemID).convertFailure( + CGemLikeDeleteException.fromRaw, + ); } diff --git a/packages/domain/gem_repository/test/src/gem_repository_test.dart b/packages/domain/gem_repository/test/src/gem_repository_test.dart index cda9370e..71b1bdae 100644 --- a/packages/domain/gem_repository/test/src/gem_repository_test.dart +++ b/packages/domain/gem_repository/test/src/gem_repository_test.dart @@ -548,5 +548,128 @@ void main() { }, ); }); + + group('fetchLikedGemIDs', () { + Task, CRawGemIDsFetchException> mockFetch() => + gemClient.fetchLikedGemIDs(chestID: any(named: 'chestID')); + + Task, CGemIDsFetchException> fetchTask() => + repo.fetchLikedGemIDs(chestID: _chestID); + + test( + requirement( + given: 'chest ID', + whenever: 'fetchLikedGemIDs succeeds', + then: 'returns liked gem IDs list', + ), + () async { + when(mockFetch).thenReturn(Task.succeed([_gemID])); + + final result = await fetchTask().run(); + + expect(result.asSuccess, equals([_gemID])); + }, + ); + + test( + requirement( + given: 'chest ID', + whenever: 'fetchLikedGemIDs fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when( + mockFetch, + ).thenReturn(Task.fail(CRawGemIDsFetchException.unknown)); + + final result = await fetchTask().run(); + + expect(result.asFailure, CGemIDsFetchException.unknown); + }, + ); + }); + + group('likeGem', () { + Task mockLikeGem() => + gemClient.likeGem( + chestID: any(named: 'chestID'), + gemID: any(named: 'gemID'), + ); + + Task likeGemTask() => + repo.likeGem(chestID: _chestID, gemID: _gemID); + + test( + requirement( + given: 'chest ID and gem ID', + whenever: 'likeGem succeeds', + then: 'returns liked gem ID', + ), + () async { + when(mockLikeGem).thenReturn(Task.succeed(_gemID)); + + final result = await likeGemTask().run(); + + expect(result.asSuccess, _gemID); + }, + ); + + test( + requirement( + given: 'chest ID and gem ID', + whenever: 'likeGem fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when( + mockLikeGem, + ).thenReturn(Task.fail(CRawGemLikeInsertException.unknown)); + + final result = await likeGemTask().run(); + + expect(result.asFailure, CGemLikeInsertException.unknown); + }, + ); + }); + + group('unlikeGem', () { + Task mockUnlikeGem() => + gemClient.unlikeGem(gemID: any(named: 'gemID')); + + Task unlikeGemTask() => + repo.unlikeGem(gemID: _gemID); + + test( + requirement( + given: 'gem ID', + whenever: 'unlikeGem succeeds', + then: 'returns unliked gem ID', + ), + () async { + when(mockUnlikeGem).thenReturn(Task.succeed(_gemID)); + + final result = await unlikeGemTask().run(); + + expect(result.asSuccess, _gemID); + }, + ); + + test( + requirement( + given: 'gem ID', + whenever: 'unlikeGem fails for unknown reason', + then: 'returns [unknown] exception', + ), + () async { + when( + mockUnlikeGem, + ).thenReturn(Task.fail(CRawGemLikeDeleteException.unknown)); + + final result = await unlikeGemTask().run(); + + expect(result.asFailure, CGemLikeDeleteException.unknown); + }, + ); + }); }); } diff --git a/supabase/migrations/20260703191319_add_gem_likes_public.sql b/supabase/migrations/20260703191319_add_gem_likes_public.sql new file mode 100644 index 00000000..d2debd67 --- /dev/null +++ b/supabase/migrations/20260703191319_add_gem_likes_public.sql @@ -0,0 +1,5 @@ +alter type "public"."app_permission" add value if not exists 'gem_likes.insert'; + +alter type "public"."app_permission" add value if not exists 'gem_likes.select'; + +alter type "public"."app_permission" add value if not exists 'gem_likes.delete'; diff --git a/supabase/migrations/20260703191320_add_gem_likes_table.sql b/supabase/migrations/20260703191320_add_gem_likes_table.sql new file mode 100644 index 00000000..68ceda7a --- /dev/null +++ b/supabase/migrations/20260703191320_add_gem_likes_table.sql @@ -0,0 +1,85 @@ +create table "public"."gem_likes" ( + "chest_id" uuid not null, + "gem_id" uuid not null, + "user_id" uuid not null default auth.uid(), + "liked_at" timestamp with time zone not null default now() +); + + +alter table "public"."gem_likes" enable row level security; + +CREATE UNIQUE INDEX gem_likes_pkey ON public.gem_likes USING btree (gem_id, user_id); + +alter table "public"."gem_likes" add constraint "gem_likes_pkey" PRIMARY KEY using index "gem_likes_pkey"; + +alter table "public"."gem_likes" add constraint "gem_likes_gem_id_fkey" FOREIGN KEY (gem_id) REFERENCES public.gems(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."gem_likes" validate constraint "gem_likes_gem_id_fkey"; + +grant delete on table "public"."gem_likes" to "anon"; + +grant insert on table "public"."gem_likes" to "anon"; + +grant references on table "public"."gem_likes" to "anon"; + +grant select on table "public"."gem_likes" to "anon"; + +grant trigger on table "public"."gem_likes" to "anon"; + +grant truncate on table "public"."gem_likes" to "anon"; + +grant update on table "public"."gem_likes" to "anon"; + +grant delete on table "public"."gem_likes" to "authenticated"; + +grant insert on table "public"."gem_likes" to "authenticated"; + +grant references on table "public"."gem_likes" to "authenticated"; + +grant select on table "public"."gem_likes" to "authenticated"; + +grant trigger on table "public"."gem_likes" to "authenticated"; + +grant truncate on table "public"."gem_likes" to "authenticated"; + +grant update on table "public"."gem_likes" to "authenticated"; + +grant delete on table "public"."gem_likes" to "service_role"; + +grant insert on table "public"."gem_likes" to "service_role"; + +grant references on table "public"."gem_likes" to "service_role"; + +grant select on table "public"."gem_likes" to "service_role"; + +grant trigger on table "public"."gem_likes" to "service_role"; + +grant truncate on table "public"."gem_likes" to "service_role"; + +grant update on table "public"."gem_likes" to "service_role"; + + + create policy "Allow authorized delete access" + on "public"."gem_likes" + as permissive + for delete + to authenticated +using ((public.authorize('gem_likes.delete'::public.app_permission, chest_id) AND (user_id = auth.uid()))); + + + + create policy "Allow authorized insert access" + on "public"."gem_likes" + as permissive + for insert + to authenticated +with check ((public.authorize('gem_likes.insert'::public.app_permission, chest_id) AND (user_id = auth.uid()))); + + + + create policy "Allow authorized select access" + on "public"."gem_likes" + as permissive + for select + to authenticated +using ((public.authorize('gem_likes.select'::public.app_permission, chest_id) AND (user_id = auth.uid()))); diff --git a/supabase/schemas/public/pre.sql b/supabase/schemas/public/pre.sql index 83a4942c..29ddb436 100644 --- a/supabase/schemas/public/pre.sql +++ b/supabase/schemas/public/pre.sql @@ -45,7 +45,10 @@ CREATE TYPE "public"."app_permission" AS enum( 'collection_share_tokens.insert', 'collection_share_tokens.select', 'collection_share_tokens.update', - 'collection_share_tokens.delete' + 'collection_share_tokens.delete', + 'gem_likes.insert', + 'gem_likes.select', + 'gem_likes.delete' ); CREATE TYPE "public"."app_role" AS enum( diff --git a/supabase/schemas/public/tables/gem_likes/constraints.sql b/supabase/schemas/public/tables/gem_likes/constraints.sql new file mode 100644 index 00000000..a2f8d4b7 --- /dev/null +++ b/supabase/schemas/public/tables/gem_likes/constraints.sql @@ -0,0 +1,2 @@ +ALTER TABLE ONLY "public"."gem_likes" + ADD CONSTRAINT "gem_likes_gem_id_fkey" FOREIGN KEY ("gem_id") REFERENCES "public"."gems"("id") ON UPDATE CASCADE ON DELETE CASCADE; diff --git a/supabase/schemas/public/tables/gem_likes/permissions.sql b/supabase/schemas/public/tables/gem_likes/permissions.sql new file mode 100644 index 00000000..bc07061d --- /dev/null +++ b/supabase/schemas/public/tables/gem_likes/permissions.sql @@ -0,0 +1,7 @@ +ALTER TABLE "public"."gem_likes" OWNER TO "postgres"; + +GRANT ALL ON TABLE "public"."gem_likes" TO "anon"; + +GRANT ALL ON TABLE "public"."gem_likes" TO "authenticated"; + +GRANT ALL ON TABLE "public"."gem_likes" TO "service_role"; diff --git a/supabase/schemas/public/tables/gem_likes/policies.sql b/supabase/schemas/public/tables/gem_likes/policies.sql new file mode 100644 index 00000000..e92a78c7 --- /dev/null +++ b/supabase/schemas/public/tables/gem_likes/policies.sql @@ -0,0 +1,13 @@ +CREATE POLICY "Allow authorized delete access" ON "public"."gem_likes" AS permissive + FOR DELETE TO authenticated + USING (authorize('gem_likes.delete'::app_permission, chest_id) AND user_id = auth.uid()); + +CREATE POLICY "Allow authorized insert access" ON "public"."gem_likes" AS permissive + FOR INSERT TO authenticated + WITH CHECK (authorize('gem_likes.insert'::app_permission, chest_id) AND user_id = auth.uid()); + +CREATE POLICY "Allow authorized select access" ON "public"."gem_likes" AS permissive + FOR SELECT TO authenticated + USING (authorize('gem_likes.select'::app_permission, chest_id) AND user_id = auth.uid()); + +ALTER TABLE "public"."gem_likes" ENABLE ROW LEVEL SECURITY; diff --git a/supabase/schemas/public/tables/gem_likes/table.sql b/supabase/schemas/public/tables/gem_likes/table.sql new file mode 100644 index 00000000..00258cb2 --- /dev/null +++ b/supabase/schemas/public/tables/gem_likes/table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS "public"."gem_likes"( + "chest_id" "uuid" NOT NULL, + "gem_id" "uuid" NOT NULL, + "user_id" "uuid" NOT NULL DEFAULT auth.uid(), + "liked_at" timestamp with time zone NOT NULL DEFAULT now() +); + +ALTER TABLE ONLY "public"."gem_likes" + ADD CONSTRAINT "gem_likes_pkey" PRIMARY KEY ("gem_id", "user_id"); diff --git a/supabase/seed.sql b/supabase/seed.sql index 61a9e35e..68d542ad 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -72,5 +72,14 @@ INSERT INTO role_permissions(ROLE, permission) ('owner', 'collection_share_tokens.select'), ('collaborator', 'collection_share_tokens.select'), ('owner', 'collection_share_tokens.delete'), -('collaborator', 'collection_share_tokens.delete'); +('collaborator', 'collection_share_tokens.delete'), +('owner', 'gem_likes.insert'), +('collaborator', 'gem_likes.insert'), +('viewer', 'gem_likes.insert'), +('owner', 'gem_likes.select'), +('collaborator', 'gem_likes.select'), +('viewer', 'gem_likes.select'), +('owner', 'gem_likes.delete'), +('collaborator', 'gem_likes.delete'), +('viewer', 'gem_likes.delete'); diff --git a/wiki/Database.md b/wiki/Database.md index 31a184d4..6d84feb7 100644 --- a/wiki/Database.md +++ b/wiki/Database.md @@ -81,9 +81,13 @@ Three roles exist per chest, defined as `app_role` enum: | Person avatars | CRUD | CRUD | R | | Collections | CRUD | CRUD | R | | Share tokens | CRD | CRD | — | +| Gem likes | CRD* | CRD* | CRD* | | Invitations | CRUD | — | — | | User roles | RU | — | — | +\* Gem likes: users can only create/read/delete their own like — RLS policies +also require `user_id = auth.uid()`. + Note: owners cannot modify their own `user_roles` row (policy enforces `user_id <> auth.uid()`). From 874ae51eb8aa244bfae7414587cc45fd5e3b9f74 Mon Sep 17 00:00:00 2001 From: JakesMD <71591438+JakesMD@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:05:10 +0200 Subject: [PATCH 2/2] Fix minor issues --- app/lib/shared/logic/gem_likes_cubit.dart | 2 ++ .../database_client/lib/src/exceptions/gem_like_delete.dart | 2 +- .../database_client/lib/src/exceptions/gem_like_insert.dart | 2 +- supabase/migrations/20260703191320_add_gem_likes_table.sql | 4 ++++ supabase/schemas/public/tables/gem_likes/constraints.sql | 3 +++ 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/lib/shared/logic/gem_likes_cubit.dart b/app/lib/shared/logic/gem_likes_cubit.dart index f9a030b1..8a9339a0 100644 --- a/app/lib/shared/logic/gem_likes_cubit.dart +++ b/app/lib/shared/logic/gem_likes_cubit.dart @@ -100,6 +100,8 @@ class CGemLikesCubit extends Cubit { /// Toggles the liked status of the gem with the given [gemID]. Future toggle(String gemID) async { + if (state.pendingGemIDs.contains(gemID)) return; + final wasLiked = state.likedGemIDs.contains(gemID); emit( diff --git a/packages/data/database_client/lib/src/exceptions/gem_like_delete.dart b/packages/data/database_client/lib/src/exceptions/gem_like_delete.dart index 6c891fbe..221ae612 100644 --- a/packages/data/database_client/lib/src/exceptions/gem_like_delete.dart +++ b/packages/data/database_client/lib/src/exceptions/gem_like_delete.dart @@ -3,7 +3,7 @@ /// Represents an exception that occurs when deleting a gem like fails. enum CRawGemLikeDeleteException { - /// The failure was unitentifiable. + /// The failure was unidentifiable. unknown; factory CRawGemLikeDeleteException.fromError(Object error) { diff --git a/packages/data/database_client/lib/src/exceptions/gem_like_insert.dart b/packages/data/database_client/lib/src/exceptions/gem_like_insert.dart index 2e7ddffd..3c85876f 100644 --- a/packages/data/database_client/lib/src/exceptions/gem_like_insert.dart +++ b/packages/data/database_client/lib/src/exceptions/gem_like_insert.dart @@ -3,7 +3,7 @@ /// Represents an exception that occurs when inserting a gem like fails. enum CRawGemLikeInsertException { - /// The failure was unitentifiable. + /// The failure was unidentifiable. unknown; factory CRawGemLikeInsertException.fromError(Object error) { diff --git a/supabase/migrations/20260703191320_add_gem_likes_table.sql b/supabase/migrations/20260703191320_add_gem_likes_table.sql index 68ceda7a..077db809 100644 --- a/supabase/migrations/20260703191320_add_gem_likes_table.sql +++ b/supabase/migrations/20260703191320_add_gem_likes_table.sql @@ -83,3 +83,7 @@ with check ((public.authorize('gem_likes.insert'::public.app_permission, chest_i for select to authenticated using ((public.authorize('gem_likes.select'::public.app_permission, chest_id) AND (user_id = auth.uid()))); + +alter table "public"."gem_likes" add constraint "gem_likes_chest_id_fkey" FOREIGN KEY (chest_id) REFERENCES public.chests(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."gem_likes" validate constraint "gem_likes_chest_id_fkey"; \ No newline at end of file diff --git a/supabase/schemas/public/tables/gem_likes/constraints.sql b/supabase/schemas/public/tables/gem_likes/constraints.sql index a2f8d4b7..41747008 100644 --- a/supabase/schemas/public/tables/gem_likes/constraints.sql +++ b/supabase/schemas/public/tables/gem_likes/constraints.sql @@ -1,2 +1,5 @@ ALTER TABLE ONLY "public"."gem_likes" ADD CONSTRAINT "gem_likes_gem_id_fkey" FOREIGN KEY ("gem_id") REFERENCES "public"."gems"("id") ON UPDATE CASCADE ON DELETE CASCADE; + +ALTER TABLE ONLY "public"."gem_likes" + ADD CONSTRAINT "gem_likes_chest_id_fkey" FOREIGN KEY ("chest_id") REFERENCES "public"."chests"("id") ON UPDATE CASCADE ON DELETE CASCADE;