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 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>[
'(๑•̀ㅂ•́)و✧',
'( ͡• ͜ʖ ͡•)',

View File

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

View File

@ -16,6 +16,7 @@ class PostCubit extends Cubit<PostState> {
Future<void> post({required String text, required int to}) async {
emit(state.copyWith(status: Status.inProgress));
final bool successful = await _postRepository.comment(
parentId: to,
text: text,
@ -42,4 +43,13 @@ class PostCubit extends Cubit<PostState> {
void reset() {
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) {
context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else {
context.push(
'/${ItemScreen.routeName}',
extra: args,
);
context.push('/${ItemScreen.routeName}', extra: args);
}
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_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.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:rxdart/rxdart.dart' show BehaviorSubject;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:visibility_detector/visibility_detector.dart';
import 'package:workmanager/workmanager.dart';
// For receiving payload event from local notifications.
@ -143,6 +145,8 @@ Future<void> main({bool testing = false}) async {
HydratedBloc.storage = storage;
VisibilityDetectorController.instance.updateInterval = Durations.ms200;
runApp(
HackiApp(
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 'discoverable_feature.dart';
export 'export_destination.dart';
export 'fetch_mode.dart';
export 'font.dart';

View File

@ -61,14 +61,6 @@ class _HomeScreenState extends State<HomeScreen>
void initState() {
super.initState();
// This is for testing only.
// FeatureDiscovery.clearPreferences(context, <String>[
// Constants.featureLogIn,
// Constants.featureAddStoryToFavList,
// Constants.featureOpenStoryInWebView,
// Constants.featurePinToTop,
// ]);
ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped);
intentDataStreamSubscription =
@ -89,7 +81,7 @@ class _HomeScreenState extends State<HomeScreen>
FeatureDiscovery.discoverFeatures(
context,
<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(
color: Theme.of(context).canvasColor,
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:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
@ -30,7 +32,7 @@ class ItemScreenArgs extends Equatable {
final bool onlyShowTargetComment;
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
/// all, comments cached in [CommentCache].
final bool useCommentCache;
@ -138,9 +140,12 @@ class ItemScreen extends StatefulWidget {
class _ItemScreenState extends State<ItemScreen> with RouteAware {
final TextEditingController commentEditingController =
TextEditingController();
final FocusNode focusNode = FocusNode();
final ItemScrollController itemScrollController = ItemScrollController();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
final ScrollOffsetListener scrollOffsetListener =
ScrollOffsetListener.create();
final Throttle storyLinkTapThrottle = Throttle(
delay: _storyLinkTapThrottleDelay,
);
@ -148,6 +153,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
delay: _featureDiscoveryDismissThrottleDelay,
);
final GlobalKey fontSizeIconButtonKey = GlobalKey();
StreamSubscription<double>? scrollOffsetSubscription;
static const Duration _storyLinkTapThrottleDelay = Durations.twoSeconds;
static const Duration _featureDiscoveryDismissThrottleDelay =
@ -157,10 +163,16 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
void didPop() {
super.didPop();
if (context.read<EditCubit>().state.text.isNullOrEmpty) {
context.read<EditCubit>().onReplyBoxClosed();
context.read<EditCubit>().reset();
}
}
@override
void didPushNext() {
super.didPushNext();
focusNode.unfocus();
}
@override
void initState() {
super.initState();
@ -169,11 +181,11 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
Constants.featureJumpUpButton,
Constants.featureJumpDownButton,
DiscoverableFeature.pinToTop.featureId,
DiscoverableFeature.addStoryToFavList.featureId,
DiscoverableFeature.openStoryInWebView.featureId,
DiscoverableFeature.jumpUpButton.featureId,
DiscoverableFeature.jumpDownButton.featureId,
},
);
})
@ -187,6 +199,9 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
.subscribe(this, route);
});
scrollOffsetSubscription =
scrollOffsetListener.changes.listen(removeReplyBoxFocusOnScroll);
commentEditingController.text = context.read<EditCubit>().state.text ?? '';
}
@ -195,6 +210,8 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
commentEditingController.dispose();
storyLinkTapThrottle.dispose();
featureDiscoveryDismissThrottle.dispose();
focusNode.dispose();
scrollOffsetSubscription?.cancel();
super.dispose();
}
@ -209,7 +226,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
BlocListener<PostCubit, PostState>(
listener: (BuildContext context, PostState postState) {
if (postState.status == Status.success) {
context.pop();
final String verb =
context.read<EditCubit>().state.replyingTo == null
? 'updated'
@ -220,7 +236,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<EditCubit>().onReplySubmittedSuccessfully();
context.read<PostCubit>().reset();
} else if (postState.status == Status.failure) {
context.pop();
showErrorSnackBar();
context.read<PostCubit>().reset();
}
@ -258,13 +273,13 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
child: MainView(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener,
commentEditingController: commentEditingController,
authState: authState,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
onReplyTapped: showReplyBox,
),
),
BlocBuilder<SplitViewCubit, SplitViewState>(
@ -303,6 +318,18 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
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(
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
scrollOffsetListener: scrollOffsetListener,
commentEditingController: commentEditingController,
authState: authState,
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
onReplyTapped: showReplyBox,
),
floatingActionButton: CustomFloatingActionButton(
itemScrollController: itemScrollController,
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() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
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 removeReplyBoxFocusOnScroll(double _) {
focusNode.unfocus();
if (commentEditingController.text.isEmpty) {
context.read<EditCubit>().reset();
}
}
void onFontSizeTapped() {

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
@ -33,14 +32,7 @@ class PinIconButton extends StatelessWidget {
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Palette.white,
),
featureId: Constants.featurePinToTop,
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),
),
feature: DiscoverableFeature.pinToTop,
child: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: pinned

View File

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

View File

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

View File

@ -83,16 +83,7 @@ class _CustomTabBarState extends State<CustomTabBar> {
size: TextDimens.pt16,
color: Palette.white,
),
featureId: Constants.featureLogIn,
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),
),
feature: DiscoverableFeature.login,
child: BlocBuilder<NotificationCubit, NotificationState>(
buildWhen: (
NotificationState previous,

View File

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

View File

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

View File

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