Compare commits

..

4 Commits

Author SHA1 Message Date
6c8047ebac feature discovery cleanup. (#259) 2023-09-19 00:16:27 -07:00
00a0135867 fix draft saving. (#258) 2023-09-18 22:49:11 -07:00
1db7be7a2c fix draft saving. (#257) 2023-09-18 22:16:47 -07:00
ff400f9c40 fix reply view. (#256) 2023-09-18 20:31:44 -07:00
21 changed files with 384 additions and 332 deletions

View File

@ -34,15 +34,6 @@ abstract class Constants {
static const String logFilename = 'hacki_log.txt'; static const String logFilename = 'hacki_log.txt';
static const String previousLogFileName = 'old_hacki_log.txt'; static const String previousLogFileName = 'old_hacki_log.txt';
/// Feature ids for feature discovery.
static const String featureAddStoryToFavList = 'add_story_to_fav_list';
static const String featureOpenStoryInWebView = 'open_story_in_web_view';
static const String featureLogIn = 'log_in';
static const String featurePinToTop = 'pin_to_top';
static const String featureJumpUpButton = 'jump_up_button_with_long_press';
static const String featureJumpDownButton =
'jump_down_button_with_long_press';
static final String happyFace = <String>[ static final String happyFace = <String>[
'(๑•̀ㅂ•́)و✧', '(๑•̀ㅂ•́)و✧',
'( ͡• ͜ʖ ͡•)', '( ͡• ͜ʖ ͡•)',

View File

@ -18,6 +18,8 @@ class EditCubit extends HydratedCubit<EditState> {
final DraftCache _draftCache; final DraftCache _draftCache;
final Debouncer _debouncer; final Debouncer _debouncer;
void reset() => emit(const EditState.init());
void onReplyTapped(Item item) { void onReplyTapped(Item item) {
emit( emit(
EditState( EditState(
@ -36,14 +38,6 @@ class EditCubit extends HydratedCubit<EditState> {
); );
} }
void onReplyBoxClosed() {
emit(const EditState.init());
}
void onScrolled() {
emit(const EditState.init());
}
void onReplySubmittedSuccessfully() { void onReplySubmittedSuccessfully() {
if (state.replyingTo != null) { if (state.replyingTo != null) {
_draftCache.removeDraft(replyingTo: state.replyingTo!.id); _draftCache.removeDraft(replyingTo: state.replyingTo!.id);
@ -65,9 +59,14 @@ class EditCubit extends HydratedCubit<EditState> {
} }
} }
void deleteDraft() => clear(); void deleteDraft() {
// Remove draft in storage.
bool called = false; clear();
// Reset cached state.
_cachedState = const EditState.init();
// Reset to init state;
reset();
}
@override @override
EditState? fromJson(Map<String, dynamic> json) { EditState? fromJson(Map<String, dynamic> json) {
@ -96,6 +95,7 @@ class EditCubit extends HydratedCubit<EditState> {
Map<String, dynamic>? toJson(EditState state) { Map<String, dynamic>? toJson(EditState state) {
EditState selected = state; EditState selected = state;
// Override previous draft only when current draft is not empty.
if (state.replyingTo == null || if (state.replyingTo == null ||
(state.replyingTo?.id != _cachedState.replyingTo?.id && (state.replyingTo?.id != _cachedState.replyingTo?.id &&
state.text.isNullOrEmpty)) { state.text.isNullOrEmpty)) {

View File

@ -16,6 +16,7 @@ class PostCubit extends Cubit<PostState> {
Future<void> post({required String text, required int to}) async { Future<void> post({required String text, required int to}) async {
emit(state.copyWith(status: Status.inProgress)); emit(state.copyWith(status: Status.inProgress));
final bool successful = await _postRepository.comment( final bool successful = await _postRepository.comment(
parentId: to, parentId: to,
text: text, text: text,
@ -42,4 +43,13 @@ class PostCubit extends Cubit<PostState> {
void reset() { void reset() {
emit(state.copyWith(status: Status.idle)); emit(state.copyWith(status: Status.idle));
} }
@Deprecated('For debugging only')
Future<bool> getFakeResult() async {
final bool result = await Future<bool>.delayed(
const Duration(seconds: 2),
() => true,
);
return result;
}
} }

View File

@ -36,10 +36,7 @@ extension StateExtension on State {
if (splitViewEnabled && !forceNewScreen) { if (splitViewEnabled && !forceNewScreen) {
context.read<SplitViewCubit>().updateItemScreenArgs(args); context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else { } else {
context.push( context.push('/${ItemScreen.routeName}', extra: args);
'/${ItemScreen.routeName}',
extra: args,
);
} }
return Future<void>.value(); return Future<void>.value();

View File

@ -13,6 +13,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart'; import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.dart'; import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
@ -26,6 +27,7 @@ import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject; import 'package:rxdart/rxdart.dart' show BehaviorSubject;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:visibility_detector/visibility_detector.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
// For receiving payload event from local notifications. // For receiving payload event from local notifications.
@ -143,6 +145,8 @@ Future<void> main({bool testing = false}) async {
HydratedBloc.storage = storage; HydratedBloc.storage = storage;
VisibilityDetectorController.instance.updateInterval = Durations.ms200;
runApp( runApp(
HackiApp( HackiApp(
savedThemeMode: savedThemeMode, savedThemeMode: savedThemeMode,

View File

@ -0,0 +1,47 @@
enum DiscoverableFeature {
addStoryToFavList(
featureId: 'add_story_to_fav_list',
title: 'Fav a Story',
description: '''Add it to your favorites''',
),
openStoryInWebView(
featureId: 'open_story_in_web_view',
title: 'Open in Browser',
description: '''You can tap here to open this story in browser.''',
),
login(
featureId: 'log_in',
title: 'Log in for more',
description:
'''Log in using your Hacker News account to check out stories and comments you have posted in the past, and get in-app notification when there is new reply to your comments or stories.''',
),
pinToTop(
featureId: 'pin_to_top',
title: 'Pin a Story',
description:
'''Pin this story to the top of your home screen so that you can come back later.''',
),
jumpUpButton(
featureId: 'jump_up_button_with_long_press',
title: 'Shortcut',
description:
'''Tapping on this button will take you to the previous off-screen root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
),
jumpDownButton(
featureId: 'jump_down_button_with_long_press',
title: 'Shortcut',
description:
'''Tapping on this button will take you to the next off-screen root level comment.\n\nLong press on it to jump to the end of this thread.''',
);
const DiscoverableFeature({
required this.featureId,
required this.title,
required this.description,
});
/// Feature ids for feature discovery.
final String featureId;
final String title;
final String description;
}

View File

@ -1,4 +1,5 @@
export 'comments_order.dart'; export 'comments_order.dart';
export 'discoverable_feature.dart';
export 'export_destination.dart'; export 'export_destination.dart';
export 'fetch_mode.dart'; export 'fetch_mode.dart';
export 'font.dart'; export 'font.dart';

View File

@ -61,14 +61,6 @@ class _HomeScreenState extends State<HomeScreen>
void initState() { void initState() {
super.initState(); super.initState();
// This is for testing only.
// FeatureDiscovery.clearPreferences(context, <String>[
// Constants.featureLogIn,
// Constants.featureAddStoryToFavList,
// Constants.featureOpenStoryInWebView,
// Constants.featurePinToTop,
// ]);
ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped); ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped);
intentDataStreamSubscription = intentDataStreamSubscription =
@ -89,7 +81,7 @@ class _HomeScreenState extends State<HomeScreen>
FeatureDiscovery.discoverFeatures( FeatureDiscovery.discoverFeatures(
context, context,
<String>{ <String>{
Constants.featureLogIn, DiscoverableFeature.login.featureId,
}, },
); );
}) })
@ -316,4 +308,14 @@ class _HomeScreenState extends State<HomeScreen>
}); });
} }
} }
@Deprecated('For debugging only')
void clearFeatureDiscoveryPreferences(BuildContext context) {
FeatureDiscovery.clearPreferences(context, <String>[
DiscoverableFeature.login.featureId,
DiscoverableFeature.addStoryToFavList.featureId,
DiscoverableFeature.openStoryInWebView.featureId,
DiscoverableFeature.pinToTop.featureId,
]);
}
} }

View File

@ -83,7 +83,7 @@ class _TabletStoryView extends StatelessWidget {
child: ColoredBox( child: ColoredBox(
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: const Center( child: const Center(
child: Text('Tap on story tile to view comments.'), child: Text('Tap on story tile to view its comments.'),
), ),
), ),
); );

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -30,7 +32,7 @@ class ItemScreenArgs extends Equatable {
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final List<Comment>? targetComments; final List<Comment>? targetComments;
/// when a user is trying to view a sub-thread from a main thread, we don't /// when the user is trying to view a sub-thread from a main thread, we don't
/// need to fetch comments from [StoriesRepository] since we have some, if not /// need to fetch comments from [StoriesRepository] since we have some, if not
/// all, comments cached in [CommentCache]. /// all, comments cached in [CommentCache].
final bool useCommentCache; final bool useCommentCache;
@ -138,9 +140,12 @@ class ItemScreen extends StatefulWidget {
class _ItemScreenState extends State<ItemScreen> with RouteAware { class _ItemScreenState extends State<ItemScreen> with RouteAware {
final TextEditingController commentEditingController = final TextEditingController commentEditingController =
TextEditingController(); TextEditingController();
final FocusNode focusNode = FocusNode();
final ItemScrollController itemScrollController = ItemScrollController(); final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener = final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create(); ItemPositionsListener.create();
final ScrollOffsetListener scrollOffsetListener =
ScrollOffsetListener.create();
final Throttle storyLinkTapThrottle = Throttle( final Throttle storyLinkTapThrottle = Throttle(
delay: _storyLinkTapThrottleDelay, delay: _storyLinkTapThrottleDelay,
); );
@ -148,6 +153,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
delay: _featureDiscoveryDismissThrottleDelay, delay: _featureDiscoveryDismissThrottleDelay,
); );
final GlobalKey fontSizeIconButtonKey = GlobalKey(); final GlobalKey fontSizeIconButtonKey = GlobalKey();
StreamSubscription<double>? scrollOffsetSubscription;
static const Duration _storyLinkTapThrottleDelay = Durations.twoSeconds; static const Duration _storyLinkTapThrottleDelay = Durations.twoSeconds;
static const Duration _featureDiscoveryDismissThrottleDelay = static const Duration _featureDiscoveryDismissThrottleDelay =
@ -157,10 +163,16 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
void didPop() { void didPop() {
super.didPop(); super.didPop();
if (context.read<EditCubit>().state.text.isNullOrEmpty) { if (context.read<EditCubit>().state.text.isNullOrEmpty) {
context.read<EditCubit>().onReplyBoxClosed(); context.read<EditCubit>().reset();
} }
} }
@override
void didPushNext() {
super.didPushNext();
focusNode.unfocus();
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -169,11 +181,11 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
FeatureDiscovery.discoverFeatures( FeatureDiscovery.discoverFeatures(
context, context,
<String>{ <String>{
Constants.featurePinToTop, DiscoverableFeature.pinToTop.featureId,
Constants.featureAddStoryToFavList, DiscoverableFeature.addStoryToFavList.featureId,
Constants.featureOpenStoryInWebView, DiscoverableFeature.openStoryInWebView.featureId,
Constants.featureJumpUpButton, DiscoverableFeature.jumpUpButton.featureId,
Constants.featureJumpDownButton, DiscoverableFeature.jumpDownButton.featureId,
}, },
); );
}) })
@ -187,6 +199,9 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
.subscribe(this, route); .subscribe(this, route);
}); });
scrollOffsetSubscription =
scrollOffsetListener.changes.listen(removeReplyBoxFocusOnScroll);
commentEditingController.text = context.read<EditCubit>().state.text ?? ''; commentEditingController.text = context.read<EditCubit>().state.text ?? '';
} }
@ -195,6 +210,8 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
commentEditingController.dispose(); commentEditingController.dispose();
storyLinkTapThrottle.dispose(); storyLinkTapThrottle.dispose();
featureDiscoveryDismissThrottle.dispose(); featureDiscoveryDismissThrottle.dispose();
focusNode.dispose();
scrollOffsetSubscription?.cancel();
super.dispose(); super.dispose();
} }
@ -209,7 +226,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
BlocListener<PostCubit, PostState>( BlocListener<PostCubit, PostState>(
listener: (BuildContext context, PostState postState) { listener: (BuildContext context, PostState postState) {
if (postState.status == Status.success) { if (postState.status == Status.success) {
context.pop();
final String verb = final String verb =
context.read<EditCubit>().state.replyingTo == null context.read<EditCubit>().state.replyingTo == null
? 'updated' ? 'updated'
@ -220,7 +236,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<EditCubit>().onReplySubmittedSuccessfully(); context.read<EditCubit>().onReplySubmittedSuccessfully();
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} else if (postState.status == Status.failure) { } else if (postState.status == Status.failure) {
context.pop();
showErrorSnackBar(); showErrorSnackBar();
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} }
@ -258,13 +273,13 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
child: MainView( child: MainView(
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener,
commentEditingController: commentEditingController, commentEditingController: commentEditingController,
authState: authState, authState: authState,
topPadding: topPadding, topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled, splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
onReplyTapped: showReplyBox,
), ),
), ),
BlocBuilder<SplitViewCubit, SplitViewState>( BlocBuilder<SplitViewCubit, SplitViewState>(
@ -303,6 +318,18 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
), ),
), ),
Positioned(
bottom: Dimens.zero,
left: Dimens.zero,
right: Dimens.zero,
child: ReplyBox(
splitViewEnabled: true,
focusNode: focusNode,
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onChanged: context.read<EditCubit>().onTextChanged,
),
),
], ],
), ),
) )
@ -319,18 +346,24 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
body: MainView( body: MainView(
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener,
commentEditingController: commentEditingController, commentEditingController: commentEditingController,
authState: authState, authState: authState,
topPadding: topPadding, topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled, splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
onReplyTapped: showReplyBox,
), ),
floatingActionButton: CustomFloatingActionButton( floatingActionButton: CustomFloatingActionButton(
itemScrollController: itemScrollController, itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener, itemPositionsListener: itemPositionsListener,
), ),
bottomSheet: ReplyBox(
textEditingController: commentEditingController,
focusNode: focusNode,
onSendTapped: onSendTapped,
onChanged: context.read<EditCubit>().onTextChanged,
),
), ),
), ),
); );
@ -338,31 +371,11 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
); );
} }
void showReplyBox() { void removeReplyBoxFocusOnScroll(double _) {
showModalBottomSheet<void>( focusNode.unfocus();
context: context, if (commentEditingController.text.isEmpty) {
isScrollControlled: true, context.read<EditCubit>().reset();
enableDrag: false, }
builder: (BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ReplyBox(
textEditingController: commentEditingController,
onSendTapped: onSendTapped,
onCloseTapped: () {
context.read<EditCubit>().onReplyBoxClosed();
commentEditingController.clear();
},
onChanged: context.read<EditCubit>().onTextChanged,
),
SizedBox(
height: MediaQuery.of(context).viewInsets.bottom,
),
],
);
},
);
} }
void onFontSizeTapped() { void onFontSizeTapped() {

View File

@ -3,8 +3,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/custom_described_feature_overlay.dart'; import 'package:hacki/models/discoverable_feature.dart';
import 'package:hacki/styles/palette.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
@ -22,80 +23,88 @@ class CustomFloatingActionButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<CommentsCubit, CommentsState>( return BlocBuilder<CommentsCubit, CommentsState>(
builder: (BuildContext context, CommentsState state) { builder: (BuildContext context, CommentsState state) {
return Column( return BlocBuilder<EditCubit, EditState>(
mainAxisSize: MainAxisSize.min, buildWhen: (EditState previous, EditState current) =>
children: <Widget>[ previous.showReplyBox != current.showReplyBox,
CustomDescribedFeatureOverlay( builder: (BuildContext context, EditState editState) {
featureId: Constants.featureJumpUpButton, return AnimatedPadding(
contentLocation: ContentLocation.above, padding: editState.showReplyBox
tapTarget: const Icon( ? const EdgeInsets.only(
Icons.keyboard_arrow_up, bottom: Dimens.replyBoxCollapsedHeight,
color: Palette.white, )
), : EdgeInsets.zero,
title: const Text('Shortcut'), duration: Durations.ms200,
description: const Text( child: Column(
'''Tapping on this button will take you to the previous off-screen root level comment.\n\nLong press on it to jump to the very beginning of this thread.''', mainAxisSize: MainAxisSize.min,
), children: <Widget>[
child: InkWell( CustomDescribedFeatureOverlay(
onLongPress: () => itemScrollController.scrollTo( feature: DiscoverableFeature.jumpUpButton,
index: 0, contentLocation: ContentLocation.above,
duration: Durations.ms400, tapTarget: const Icon(
), Icons.keyboard_arrow_up,
child: FloatingActionButton.small( color: Palette.white,
backgroundColor: Theme.of(context).scaffoldBackgroundColor, ),
child: InkWell(
onLongPress: () => itemScrollController.scrollTo(
index: 0,
duration: Durations.ms400,
),
child: FloatingActionButton.small(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
/// Randomly generated string as heroTag to prevent /// Randomly generated string as heroTag to prevent
/// default [FloatingActionButton] animation. /// default [FloatingActionButton] animation.
heroTag: UniqueKey().hashCode, heroTag: UniqueKey().hashCode,
onPressed: () { onPressed: () {
HapticFeedbackUtil.selection(); HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToPreviousRoot( context.read<CommentsCubit>().scrollToPreviousRoot(
itemScrollController, itemScrollController,
itemPositionsListener, itemPositionsListener,
); );
}, },
child: Icon( child: Icon(
Icons.keyboard_arrow_up, Icons.keyboard_arrow_up,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
),
),
),
), ),
), CustomDescribedFeatureOverlay(
), feature: DiscoverableFeature.jumpDownButton,
), tapTarget: const Icon(
CustomDescribedFeatureOverlay( Icons.keyboard_arrow_down,
featureId: Constants.featureJumpDownButton, color: Palette.white,
tapTarget: const Icon( ),
Icons.keyboard_arrow_down, child: InkWell(
color: Palette.white, onLongPress: () => itemScrollController.scrollTo(
), index: state.comments.length,
title: const Text('Shortcut'), duration: Durations.ms400,
description: const Text( ),
'''Tapping on this button will take you to the next off-screen root level comment.\n\nLong press on it to jump to the end of this thread.''', child: FloatingActionButton.small(
), backgroundColor:
child: InkWell( Theme.of(context).scaffoldBackgroundColor,
onLongPress: () => itemScrollController.scrollTo(
index: state.comments.length,
duration: Durations.ms400,
),
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
/// Same as above. /// Same as above.
heroTag: UniqueKey().hashCode, heroTag: UniqueKey().hashCode,
onPressed: () { onPressed: () {
HapticFeedbackUtil.selection(); HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToNextRoot( context.read<CommentsCubit>().scrollToNextRoot(
itemScrollController, itemScrollController,
itemPositionsListener, itemPositionsListener,
); );
}, },
child: Icon( child: Icon(
Icons.keyboard_arrow_down, Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
),
),
),
), ),
), ],
), ),
), );
], },
); );
}, },
); );

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/discoverable_feature.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -26,12 +26,7 @@ class FavIconButton extends StatelessWidget {
isFav ? Icons.favorite : Icons.favorite_border, isFav ? Icons.favorite : Icons.favorite_border,
color: Palette.white, color: Palette.white,
), ),
featureId: Constants.featureAddStoryToFavList, feature: DiscoverableFeature.addStoryToFavList,
title: const Text('Fav a Story'),
description: const Text(
'Add it to your favorites.',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: Icon( child: Icon(
isFav ? Icons.favorite : Icons.favorite_border, isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Palette.orange : Theme.of(context).iconTheme.color, color: isFav ? Palette.orange : Theme.of(context).iconTheme.color,

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/models/discoverable_feature.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -21,12 +21,7 @@ class LinkIconButton extends StatelessWidget {
Icons.stream, Icons.stream,
color: Palette.white, color: Palette.white,
), ),
featureId: Constants.featureOpenStoryInWebView, feature: DiscoverableFeature.openStoryInWebView,
title: Text('Open in Browser'),
description: Text(
'''You can tap here to open this story in browser.''',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: Icon( child: Icon(
Icons.stream, Icons.stream,
), ),

View File

@ -19,25 +19,25 @@ class MainView extends StatelessWidget {
const MainView({ const MainView({
required this.itemScrollController, required this.itemScrollController,
required this.itemPositionsListener, required this.itemPositionsListener,
required this.scrollOffsetListener,
required this.commentEditingController, required this.commentEditingController,
required this.authState, required this.authState,
required this.topPadding, required this.topPadding,
required this.splitViewEnabled, required this.splitViewEnabled,
required this.onMoreTapped, required this.onMoreTapped,
required this.onRightMoreTapped, required this.onRightMoreTapped,
required this.onReplyTapped,
super.key, super.key,
}); });
final ItemScrollController itemScrollController; final ItemScrollController itemScrollController;
final ItemPositionsListener itemPositionsListener; final ItemPositionsListener itemPositionsListener;
final ScrollOffsetListener scrollOffsetListener;
final TextEditingController commentEditingController; final TextEditingController commentEditingController;
final AuthState authState; final AuthState authState;
final double topPadding; final double topPadding;
final bool splitViewEnabled; final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped; final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<Comment> onRightMoreTapped; final ValueChanged<Comment> onRightMoreTapped;
final VoidCallback onReplyTapped;
static const int _loadingIndicatorOpacityAnimationDuration = 300; static const int _loadingIndicatorOpacityAnimationDuration = 300;
static const double _trailingBoxHeight = 240; static const double _trailingBoxHeight = 240;
@ -49,101 +49,90 @@ class MainView extends StatelessWidget {
Positioned.fill( Positioned.fill(
child: BlocBuilder<CommentsCubit, CommentsState>( child: BlocBuilder<CommentsCubit, CommentsState>(
builder: (BuildContext context, CommentsState state) { builder: (BuildContext context, CommentsState state) {
return Scrollbar( return RefreshIndicator(
interactive: true, displacement: 100,
child: RefreshIndicator( onRefresh: () async {
displacement: 100, HapticFeedbackUtil.light();
onRefresh: () async {
HapticFeedbackUtil.light();
if (context.read<StoriesBloc>().state.isOfflineReading == if (context.read<StoriesBloc>().state.isOfflineReading ==
false && false &&
state.onlyShowTargetComment == false) { state.onlyShowTargetComment == false) {
unawaited(context.read<CommentsCubit>().refresh()); unawaited(context.read<CommentsCubit>().refresh());
if (state.item.isPoll) { if (state.item.isPoll) {
context.read<PollCubit>().refresh(); context.read<PollCubit>().refresh();
}
}
},
child: ScrollablePositionedList.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemCount: state.comments.length + 2,
padding: EdgeInsets.only(top: topPadding),
scrollOffsetListener: scrollOffsetListener,
itemBuilder: (BuildContext context, int index) {
if (index == 0) {
return _ParentItemSection(
commentEditingController: commentEditingController,
state: state,
authState: authState,
topPadding: topPadding,
splitViewEnabled: splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
);
} else if (index == state.comments.length + 1) {
if ((state.status == CommentsStatus.allLoaded &&
state.comments.isNotEmpty) ||
state.onlyShowTargetComment) {
return SizedBox(
height: _trailingBoxHeight,
child: Center(
child: Text(Constants.happyFace),
),
);
} else {
return const SizedBox.shrink();
} }
} }
},
child: ScrollablePositionedList.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
itemCount: state.comments.length + 2,
padding: EdgeInsets.only(top: topPadding),
itemBuilder: (BuildContext context, int index) {
if (index == 0) {
return _ParentItemSection(
commentEditingController: commentEditingController,
state: state,
authState: authState,
topPadding: topPadding,
splitViewEnabled: splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
onReplyTapped: onReplyTapped,
);
} else if (index == state.comments.length + 1) {
if ((state.status == CommentsStatus.allLoaded &&
state.comments.isNotEmpty) ||
state.onlyShowTargetComment) {
return SizedBox(
height: _trailingBoxHeight,
child: Center(
child: Text(Constants.happyFace),
),
);
} else {
return const SizedBox.shrink();
}
}
index = index - 1; index = index - 1;
final Comment comment = state.comments.elementAt(index); final Comment comment = state.comments.elementAt(index);
return FadeIn( return FadeIn(
key: ValueKey<String>('${comment.id}-FadeIn'), key: ValueKey<String>('${comment.id}-FadeIn'),
child: CommentTile( child: CommentTile(
comment: comment, comment: comment,
level: comment.level, level: comment.level,
opUsername: state.item.by, opUsername: state.item.by,
fetchMode: state.fetchMode, fetchMode: state.fetchMode,
onReplyTapped: (Comment cmt) { onReplyTapped: (Comment cmt) {
HapticFeedbackUtil.light(); HapticFeedbackUtil.light();
if (cmt.deleted || cmt.dead) { if (cmt.deleted || cmt.dead) {
return; return;
} }
if (cmt.id != if (cmt.id !=
context context.read<EditCubit>().state.replyingTo?.id) {
.read<EditCubit>()
.state
.replyingTo
?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(cmt);
onReplyTapped();
},
onEditTapped: (Comment cmt) {
HapticFeedbackUtil.light();
if (cmt.deleted || cmt.dead) {
return;
}
commentEditingController.clear(); commentEditingController.clear();
context.read<EditCubit>().onEditTapped(cmt); }
onReplyTapped(); context.read<EditCubit>().onReplyTapped(cmt);
}, },
onMoreTapped: onMoreTapped, onEditTapped: (Comment cmt) {
onRightMoreTapped: onRightMoreTapped, HapticFeedbackUtil.light();
itemScrollController: itemScrollController, if (cmt.deleted || cmt.dead) {
), return;
); }
}, commentEditingController.clear();
), context.read<EditCubit>().onEditTapped(cmt);
},
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
itemScrollController: itemScrollController,
),
);
},
), ),
); );
}, },
@ -184,7 +173,6 @@ class _ParentItemSection extends StatelessWidget {
required this.splitViewEnabled, required this.splitViewEnabled,
required this.onMoreTapped, required this.onMoreTapped,
required this.onRightMoreTapped, required this.onRightMoreTapped,
required this.onReplyTapped,
}); });
final TextEditingController commentEditingController; final TextEditingController commentEditingController;
@ -194,7 +182,6 @@ class _ParentItemSection extends StatelessWidget {
final bool splitViewEnabled; final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped; final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<Comment> onRightMoreTapped; final ValueChanged<Comment> onRightMoreTapped;
final VoidCallback onReplyTapped;
static const double _viewParentButtonWidth = 100; static const double _viewParentButtonWidth = 100;
static const double _viewRootButtonWidth = 80; static const double _viewRootButtonWidth = 80;
@ -225,8 +212,6 @@ class _ParentItemSection extends StatelessWidget {
commentEditingController.clear(); commentEditingController.clear();
} }
context.read<EditCubit>().onReplyTapped(state.item); context.read<EditCubit>().onReplyTapped(state.item);
onReplyTapped();
}, },
backgroundColor: Palette.orange, backgroundColor: Palette.orange,
foregroundColor: Palette.white, foregroundColor: Palette.white,
@ -352,8 +337,8 @@ class _ParentItemSection extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.only(
horizontal: Dimens.pt10, left: Dimens.pt8,
), ),
child: ItemText( child: ItemText(
item: state.item, item: state.item,

View File

@ -2,7 +2,6 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
@ -33,14 +32,7 @@ class PinIconButton extends StatelessWidget {
pinned ? Icons.push_pin : Icons.push_pin_outlined, pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Palette.white, color: Palette.white,
), ),
featureId: Constants.featurePinToTop, feature: DiscoverableFeature.pinToTop,
title: const Text('Pin a Story'),
description: const Text(
'Pin this story to the top of your '
'home screen so that you can come'
' back later.',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: Icon( child: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined, pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: pinned color: pinned

View File

@ -14,18 +14,18 @@ import 'package:hacki/utils/utils.dart';
class ReplyBox extends StatefulWidget { class ReplyBox extends StatefulWidget {
const ReplyBox({ const ReplyBox({
required this.focusNode,
required this.textEditingController, required this.textEditingController,
required this.onSendTapped, required this.onSendTapped,
required this.onCloseTapped,
required this.onChanged, required this.onChanged,
super.key, super.key,
this.splitViewEnabled = false, this.splitViewEnabled = false,
}); });
final bool splitViewEnabled; final bool splitViewEnabled;
final FocusNode focusNode;
final TextEditingController textEditingController; final TextEditingController textEditingController;
final VoidCallback onSendTapped; final VoidCallback onSendTapped;
final VoidCallback onCloseTapped;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
@override @override
@ -40,9 +40,17 @@ class _ReplyBoxState extends State<ReplyBox> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
expandedHeight ??= MediaQuery.of(context).size.height - expandedHeight ??= MediaQuery.of(context).size.height;
MediaQuery.of(context).viewInsets.bottom; return BlocConsumer<EditCubit, EditState>(
return BlocBuilder<EditCubit, EditState>( listenWhen: (EditState previous, EditState current) =>
previous.showReplyBox != current.showReplyBox,
listener: (BuildContext context, EditState editState) {
if (editState.showReplyBox) {
widget.focusNode.requestFocus();
} else {
widget.focusNode.unfocus();
}
},
buildWhen: (EditState previous, EditState current) => buildWhen: (EditState previous, EditState current) =>
previous.showReplyBox != current.showReplyBox || previous.showReplyBox != current.showReplyBox ||
previous.itemBeingEdited != current.itemBeingEdited || previous.itemBeingEdited != current.itemBeingEdited ||
@ -62,7 +70,9 @@ class _ReplyBoxState extends State<ReplyBox> {
: Dimens.zero, : Dimens.zero,
), ),
child: AnimatedContainer( child: AnimatedContainer(
height: expanded ? expandedHeight : collapsedHeight, height: editState.showReplyBox
? (expanded ? expandedHeight : collapsedHeight)
: Dimens.zero,
duration: Durations.ms200, duration: Durations.ms200,
decoration: BoxDecoration( decoration: BoxDecoration(
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
@ -82,8 +92,8 @@ class _ReplyBoxState extends State<ReplyBox> {
height: Dimens.zero, height: Dimens.zero,
), ),
AnimatedContainer( AnimatedContainer(
height: expanded ? Dimens.pt36 : Dimens.zero, height: expanded ? Dimens.pt40 : Dimens.zero,
duration: Durations.ms200, duration: Durations.ms300,
), ),
Row( Row(
children: <Widget>[ children: <Widget>[
@ -145,7 +155,9 @@ class _ReplyBoxState extends State<ReplyBox> {
color: Palette.orange, color: Palette.orange,
), ),
onPressed: () { onPressed: () {
context.pop(); setState(() {
expanded = false;
});
final EditState state = final EditState state =
context.read<EditCubit>().state; context.read<EditCubit>().state;
@ -153,17 +165,13 @@ class _ReplyBoxState extends State<ReplyBox> {
state.text.isNotNullOrEmpty) { state.text.isNotNullOrEmpty) {
showDialog<void>( showDialog<void>(
context: context, context: context,
barrierDismissible: false,
builder: (BuildContext context) => builder: (BuildContext context) =>
AlertDialog( AlertDialog(
title: const Text('Save draft?'), title: const Text('Abort editing?'),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () { onPressed: context.pop,
context
.read<EditCubit>()
.deleteDraft();
context.pop();
},
child: const Text( child: const Text(
'No', 'No',
style: TextStyle( style: TextStyle(
@ -172,15 +180,18 @@ class _ReplyBoxState extends State<ReplyBox> {
), ),
), ),
TextButton( TextButton(
onPressed: () => context.pop(), onPressed: () {
context.pop();
onCloseTapped();
},
child: const Text('Yes'), child: const Text('Yes'),
), ),
], ],
), ),
); );
} else {
onCloseTapped();
} }
widget.onCloseTapped();
expanded = false;
}, },
), ),
], ],
@ -214,39 +225,32 @@ class _ReplyBoxState extends State<ReplyBox> {
], ],
), ),
Expanded( Expanded(
child: Padding( child: TextField(
padding: EdgeInsets.only( focusNode: widget.focusNode,
left: Dimens.pt16, controller: widget.textEditingController,
right: Dimens.pt16, expands: true,
bottom: expanded maxLines: null,
// This padding here prevents keyboard decoration: const InputDecoration(
// overlapping with TextField. alignLabelWithHint: true,
? MediaQuery.of(context).viewInsets.bottom + contentPadding: EdgeInsets.only(
Dimens.pt16 left: Dimens.pt10,
: Dimens.zero,
),
child: TextField(
controller: widget.textEditingController,
autofocus: true,
expands: true,
maxLines: null,
decoration: const InputDecoration(
alignLabelWithHint: true,
contentPadding: EdgeInsets.zero,
hintText: '...',
hintStyle: TextStyle(
color: Palette.grey,
),
focusedBorder: InputBorder.none,
border: InputBorder.none,
), ),
keyboardType: TextInputType.multiline, hintText: '...',
textCapitalization: TextCapitalization.sentences, hintStyle: TextStyle(
textInputAction: TextInputAction.newline, color: Palette.grey,
onChanged: widget.onChanged, ),
focusedBorder: InputBorder.none,
border: InputBorder.none,
), ),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
textInputAction: TextInputAction.newline,
onChanged: widget.onChanged,
), ),
), ),
const SizedBox(
height: Dimens.pt8,
),
], ],
), ),
), ),
@ -258,10 +262,22 @@ class _ReplyBoxState extends State<ReplyBox> {
); );
} }
void onCloseTapped() {
context.read<EditCubit>().deleteDraft();
widget.textEditingController.clear();
}
void showTextPopup() { void showTextPopup() {
final Item? replyingTo = context.read<EditCubit>().state.replyingTo; final Item? replyingTo = context.read<EditCubit>().state.replyingTo;
if (replyingTo == null) return; if (replyingTo == null) {
return;
} else if (replyingTo is Story) {
final ItemScreenArgs args = ItemScreenArgs(item: replyingTo);
context.push('/${ItemScreen.routeName}', extra: args);
expanded = false;
return;
}
showDialog<void>( showDialog<void>(
context: context, context: context,

View File

@ -2,24 +2,22 @@ import 'dart:async';
import 'package:feature_discovery/feature_discovery.dart'; import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/models/discoverable_feature.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
class CustomDescribedFeatureOverlay extends StatelessWidget { class CustomDescribedFeatureOverlay extends StatelessWidget {
const CustomDescribedFeatureOverlay({ const CustomDescribedFeatureOverlay({
required this.featureId, required this.feature,
required this.child, required this.child,
required this.tapTarget, required this.tapTarget,
required this.title,
required this.description,
super.key, super.key,
this.contentLocation = ContentLocation.trivial, this.contentLocation = ContentLocation.trivial,
this.onComplete, this.onComplete,
}); });
final String featureId; final DiscoverableFeature feature;
final Widget tapTarget; final Widget tapTarget;
final Widget title;
final Widget description;
final Widget child; final Widget child;
final ContentLocation contentLocation; final ContentLocation contentLocation;
final VoidCallback? onComplete; final VoidCallback? onComplete;
@ -27,12 +25,15 @@ class CustomDescribedFeatureOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DescribedFeatureOverlay( return DescribedFeatureOverlay(
featureId: featureId, featureId: feature.featureId,
overflowMode: OverflowMode.extendBackground, overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor, targetColor: Theme.of(context).primaryColor,
tapTarget: tapTarget, tapTarget: tapTarget,
title: title, title: Text(feature.title),
description: description, description: Text(
feature.description,
style: const TextStyle(fontSize: TextDimens.pt16),
),
barrierDismissible: false, barrierDismissible: false,
contentLocation: contentLocation, contentLocation: contentLocation,
onBackgroundTap: () { onBackgroundTap: () {

View File

@ -83,16 +83,7 @@ class _CustomTabBarState extends State<CustomTabBar> {
size: TextDimens.pt16, size: TextDimens.pt16,
color: Palette.white, color: Palette.white,
), ),
featureId: Constants.featureLogIn, feature: DiscoverableFeature.login,
title: const Text('Log in for more'),
description: const Text(
'Log in using your Hacker News account '
'to check out stories and comments you have '
'posted in the past, and get in-app '
'notification when there is new reply to '
'your comments or stories.',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: BlocBuilder<NotificationCubit, NotificationState>( child: BlocBuilder<NotificationCubit, NotificationState>(
buildWhen: ( buildWhen: (
NotificationState previous, NotificationState previous,

View File

@ -130,10 +130,11 @@ class _StoriesListViewState extends State<StoriesListView> {
), ),
child: OptionalWrapper( child: OptionalWrapper(
enabled: context enabled: context
.read<PreferenceCubit>() .read<PreferenceCubit>()
.state .state
.storyMarkingMode .storyMarkingMode
.shouldDetectScrollingPast, .shouldDetectScrollingPast &&
!context.read<StoriesBloc>().hasRead(story),
wrapper: (Widget child) => VisibilityDetector( wrapper: (Widget child) => VisibilityDetector(
key: ValueKey<int>(story.id), key: ValueKey<int>(story.id),
onVisibilityChanged: (VisibilityInfo info) { onVisibilityChanged: (VisibilityInfo info) {

View File

@ -20,6 +20,8 @@ abstract class Dimens {
static const double pt64 = 64; static const double pt64 = 64;
static const double pt100 = 100; static const double pt100 = 100;
static const double pt120 = 120; static const double pt120 = 120;
static const double replyBoxCollapsedHeight = 140;
} }
abstract class TextDimens { abstract class TextDimens {

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 1.9.1+122 version: 1.9.2+123
publish_to: none publish_to: none
environment: environment: