From 26fd9b8f214cd504b6482f2539f7c66fbbb70507 Mon Sep 17 00:00:00 2001 From: Livinglist Date: Wed, 2 Sep 2026 01:22:43 -0700 Subject: [PATCH] feat: swipe to upvote --- .../time_machine/time_machine_cubit.dart | 6 +- .../item/widgets/item_screen_background.dart | 19 +- lib/screens/item/widgets/main_view.dart | 9 + .../item/widgets/time_machine_dialog.dart | 188 +++++++++--------- lib/screens/widgets/comment_tile.dart | 24 ++- lib/services/dialog_proxy.dart | 26 +-- lib/utils/color_utils.dart | 11 +- 7 files changed, 153 insertions(+), 130 deletions(-) diff --git a/lib/cubits/time_machine/time_machine_cubit.dart b/lib/cubits/time_machine/time_machine_cubit.dart index 277fd59f..ff70e7db 100644 --- a/lib/cubits/time_machine/time_machine_cubit.dart +++ b/lib/cubits/time_machine/time_machine_cubit.dart @@ -1,7 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:hacki/config/locator.dart'; -import 'package:hacki/models/models.dart' show Comment; +import 'package:hacki/models/models.dart' show Comment, Item; import 'package:hacki/services/services.dart'; part 'time_machine_state.dart'; @@ -13,13 +13,13 @@ class TimeMachineCubit extends Cubit { final CommentCache _commentCache; - Future activateTimeMachine(Comment comment) async { + Future activateTimeMachine(Comment comment, Item rootItem) async { emit(state.copyWith(ancestors: [])); final List ancestors = []; Comment? parent = _commentCache.getComment(comment.parent); - while (parent != null) { + while (parent != null && parent.id != rootItem.id) { ancestors.insert(0, parent); final int parentId = parent.parent; diff --git a/lib/screens/item/widgets/item_screen_background.dart b/lib/screens/item/widgets/item_screen_background.dart index c0c9bb65..866176f6 100644 --- a/lib/screens/item/widgets/item_screen_background.dart +++ b/lib/screens/item/widgets/item_screen_background.dart @@ -70,6 +70,8 @@ class _ItemScreenBackgroundState extends State { .read() .state .isEyeCandyEnabled; + final bool isDarkModeEnabled = + Theme.of(context).brightness == Brightness.dark; return BlocConsumer( listenWhen: (CommentsState previous, CommentsState current) => previous.status != current.status, @@ -162,6 +164,7 @@ class _ItemScreenBackgroundState extends State { color: ColorUtils.getRainbowColor( i, Theme.of(context).canvasColor, + isDarkModeEnabled: isDarkModeEnabled, ).$1, width: widget.indentLineWidth, isShining: _shineIndex == i, @@ -169,17 +172,11 @@ class _ItemScreenBackgroundState extends State { : Container( width: widget.indentLineWidth, height: MediaQuery.of(context).size.height, - color: - ColorUtils.getRainbowColor( - i, - Theme.of(context).canvasColor, - ).$1.withValues( - alpha: - Theme.of(context).brightness == - Brightness.dark - ? 0.6 - : 1, - ), + color: ColorUtils.getRainbowColor( + i, + Theme.of(context).canvasColor, + isDarkModeEnabled: isDarkModeEnabled, + ).$1, ), ), ), diff --git a/lib/screens/item/widgets/main_view.dart b/lib/screens/item/widgets/main_view.dart index cd029459..db0f6ddc 100644 --- a/lib/screens/item/widgets/main_view.dart +++ b/lib/screens/item/widgets/main_view.dart @@ -353,8 +353,17 @@ class _ParentItemSection extends StatelessWidget { ), DeviceGestureWrapper( child: Slidable( + key: ValueKey('root_item_tile_slidable_${item.id}'), startActionPane: ActionPane( motion: const BehindMotion(), + dismissible: DismissiblePane( + closeOnCancel: true, + confirmDismiss: () async { + onUpvoteTapped.call(item); + return false; + }, + onDismissed: () {}, + ), children: [ if (context.read().state.user.id != item.by) CustomSlidableAction( diff --git a/lib/screens/item/widgets/time_machine_dialog.dart b/lib/screens/item/widgets/time_machine_dialog.dart index 42674729..2b8eba15 100644 --- a/lib/screens/item/widgets/time_machine_dialog.dart +++ b/lib/screens/item/widgets/time_machine_dialog.dart @@ -16,6 +16,7 @@ class TimeMachineDialog extends StatelessWidget { required this.comment, required this.commentsCubit, required this.deviceType, + required this.scrollController, super.key, }) : rootItem = commentsCubit.state.item; @@ -23,11 +24,12 @@ class TimeMachineDialog extends StatelessWidget { final CommentsCubit commentsCubit; final Item rootItem; final DeviceScreenType deviceType; + final ScrollController scrollController; @override Widget build(BuildContext context) { return BlocProvider.value( - value: TimeMachineCubit()..activateTimeMachine(comment), + value: TimeMachineCubit()..activateTimeMachine(comment, rootItem), child: BlocBuilder( builder: (BuildContext context, TimeMachineState state) { return Material( @@ -36,107 +38,109 @@ class TimeMachineDialog extends StatelessWidget { decoration: const BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(Dimens.pt4)), ), - child: Padding( - padding: const EdgeInsets.only(right: Dimens.pt4), - child: Column( - children: [ - Row( + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: Dimens.pt12), + Text( + 'Ancestors:', + style: TextTheme.of(context).titleMedium, + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: Dimens.pt24), + onPressed: () => context.pop(), + padding: EdgeInsets.zero, + ), + ], + ), + Expanded( + child: ListView( + controller: scrollController, children: [ - const SizedBox(width: Dimens.pt8), - Text( - 'Ancestors:', - style: TextTheme.of(context).titleMedium, - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.close, size: Dimens.pt24), - onPressed: () => context.pop(), - padding: EdgeInsets.zero, - ), - ], - ), - Expanded( - child: ListView( - children: [ - switch (rootItem) { - Story() => StoryTile( - shouldShowWebPreview: false, - shouldShowPreviewImage: false, - shouldShowMetadata: true, - shouldShowFavicon: true, - shouldShowUrl: true, - isExpandedTileEnabled: false, - isImageLeftAligned: context - .read() - .state - .isPreviewImageLeftAligned, - story: rootItem as Story, - onTap: () { - final String url = rootItem.url.isNotEmpty - ? rootItem.url - : '''${Constants.hackerNewsItemLinkPrefix}${rootItem.id}'''; - LinkUtils.launch( - url, - context, - shouldUseHackiForHnLink: false, - ); - }, - ), - Comment() => CommentTile( + switch (rootItem) { + Story() => StoryTile( + shouldShowWebPreview: false, + shouldShowPreviewImage: false, + shouldShowMetadata: true, + shouldShowFavicon: true, + shouldShowUrl: true, + isExpandedTileEnabled: false, + isImageLeftAligned: context + .read() + .state + .isPreviewImageLeftAligned, + story: rootItem as Story, + onTap: () { + final String url = rootItem.url.isNotEmpty + ? rootItem.url + : '''${Constants.hackerNewsItemLinkPrefix}${rootItem.id}'''; + LinkUtils.launch( + url, + context, + shouldUseHackiForHnLink: false, + ); + }, + ), + Comment() => Padding( + padding: const EdgeInsets.only(right: Dimens.pt4), + child: CommentTile( comment: rootItem as Comment, isActionable: false, isCollapsable: false, fetchMode: FetchMode.eager, ), - Item() => const SizedBox.shrink(), - }, - for (final (int i, Comment cmt) in [ - ...state.ancestors, - comment, - ].indexed) ...[ - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final int _ in 0.to(i, inclusive: false)) - SizedBoxes.pt6, - Padding( - padding: const EdgeInsets.only( - top: Dimens.pt6, - left: Dimens.pt6, - ), - child: Icon( - Icons.subdirectory_arrow_right_rounded, - size: TextDimens.pt18, - color: i == state.ancestors.length - ? Theme.of(context).colorScheme.primary - : null, - ), + ), + Item() => const SizedBox.shrink(), + }, + for (final (int i, Comment cmt) in [ + ...state.ancestors, + comment, + ].indexed) ...[ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final int _ in 0.to(i, inclusive: false)) + SizedBoxes.pt6, + Padding( + padding: const EdgeInsets.only( + top: Dimens.pt6, + left: Dimens.pt6, ), - Expanded( - child: CommentTile( - comment: i == state.ancestors.length - ? comment - : cmt, - isActionable: false, - isCollapsable: false, - isSelectable: false, - fetchMode: FetchMode.eager, - onTap: () { - context.pop(); - commentsCubit.scrollToComment(cmt); - }, - ), + child: Icon( + Icons.subdirectory_arrow_right_rounded, + size: TextDimens.pt18, + color: i == state.ancestors.length + ? Theme.of(context).colorScheme.primary + : null, ), - ], - ), - const Divider(height: Dimens.zero), - ], + ), + Expanded( + child: CommentTile( + comment: i == state.ancestors.length + ? comment + : cmt, + isActionable: false, + isCollapsable: false, + isSelectable: false, + fetchMode: FetchMode.eager, + onTap: () { + context.pop(); + commentsCubit.scrollToComment(cmt); + }, + ), + ), + SizedBoxes.pt4, + ], + ), + const Divider(height: Dimens.zero), ], - ), + ], ), - ], - ), + ), + ], ), ), ); diff --git a/lib/screens/widgets/comment_tile.dart b/lib/screens/widgets/comment_tile.dart index b364feb7..c1bd4b6e 100644 --- a/lib/screens/widgets/comment_tile.dart +++ b/lib/screens/widgets/comment_tile.dart @@ -85,22 +85,19 @@ class CommentTile extends StatelessWidget { PreferenceState prefState, BlocklistState blocklistState, ) { + final bool isDarkModeEnabled = + Theme.of(context).brightness == Brightness.dark; final (Color, Color) slidableColors = level > 0 ? ColorUtils.getRainbowColor( level, Theme.of(context).canvasColor, + isDarkModeEnabled: isDarkModeEnabled, ) : ( Theme.of(context).colorScheme.primaryContainer, Theme.of(context).colorScheme.onPrimaryContainer, ); - final double backgroundColorAlpha = - Theme.of(context).brightness == Brightness.dark && level > 0 - ? 0.6 - : 1; - final Color backgroundColor = slidableColors.$1.withValues( - alpha: backgroundColorAlpha, - ); + final Color backgroundColor = slidableColors.$1; final Color foregroundColor = slidableColors.$2; int newCommentsCount = 0; @@ -130,7 +127,15 @@ class CommentTile extends StatelessWidget { ), startActionPane: isActionable ? ActionPane( - motion: const StretchMotion(), + motion: const BehindMotion(), + dismissible: DismissiblePane( + closeOnCancel: true, + confirmDismiss: () async { + onUpvoteTapped?.call(comment); + return false; + }, + onDismissed: () {}, + ), children: [ if (onUpvoteTapped != null && context.read().state.user.id != @@ -180,9 +185,10 @@ class CommentTile extends StatelessWidget { : null, endActionPane: isActionable ? ActionPane( - motion: const StretchMotion(), + motion: const BehindMotion(), dismissible: DismissiblePane( closeOnCancel: true, + dismissThreshold: 0.01, confirmDismiss: () async { DialogProxy.showTimeMachineDialog( context, diff --git a/lib/services/dialog_proxy.dart b/lib/services/dialog_proxy.dart index 5a0f088f..1930d55c 100644 --- a/lib/services/dialog_proxy.dart +++ b/lib/services/dialog_proxy.dart @@ -68,19 +68,19 @@ abstract final class DialogProxy { isScrollControlled: true, showDragHandle: true, builder: (BuildContext context) { - return SizedBox( - height: MediaQuery.of(context).size.height - Dimens.pt120, - child: Column( - children: [ - Expanded( - child: TimeMachineDialog( - comment: comment, - commentsCubit: commentsCubit, - deviceType: deviceType, - ), - ), - ], - ), + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.9, + maxChildSize: 0.9, + minChildSize: 0.85, + builder: (BuildContext context, ScrollController scrollController) { + return TimeMachineDialog( + comment: comment, + commentsCubit: commentsCubit, + deviceType: deviceType, + scrollController: scrollController, + ); + }, ); }, ); diff --git a/lib/utils/color_utils.dart b/lib/utils/color_utils.dart index 5a5d1cc3..3b3ad1cf 100644 --- a/lib/utils/color_utils.dart +++ b/lib/utils/color_utils.dart @@ -5,7 +5,11 @@ abstract final class ColorUtils { static final Map levelToRainbowBorderColors = {}; - static (Color, Color) getRainbowColor(int level, Color background) { + static (Color, Color) getRainbowColor( + int level, + Color background, { + required bool isDarkModeEnabled, + }) { const int colorCount = 6; // If id is larger than 6, take modulo @@ -28,12 +32,15 @@ abstract final class ColorUtils { final bool isDarkBg = background.computeLuminance() < 0.5; const double saturation = 0.85; final double lightness = isDarkBg ? 0.60 : 0.45; - final Color color = HSLColor.fromAHSL( + Color color = HSLColor.fromAHSL( 1, // Fully opaque hue, saturation, lightness, ).toColor(); + color = isDarkModeEnabled + ? Color.alphaBlend(color.withValues(alpha: 0.6), background) + : color; final bool isDarkColor = color.computeLuminance() < 0.5; final Color foregroundColor = isDarkColor ? Palette.white : Palette.black;